Files
nofx/agent/central_brain.go
tinkle-community 1851508353 feat(agent): make the assistant agentic - visible tools, LLM voice, full toolset
The agent felt like an artificial idiot because the LLM almost never spoke
for itself: 14+ Go paths injected fmt.Sprintf canned replies, the frontend
filtered out tool-progress events so users saw three dots for 10-20s, the
main prompt told the LLM "be a trading partner" AND "answer only what's
asked", and the planner sliced the toolset by inferred domain so a "BTC
dropped, how much am I losing?" question couldn't see positions and market
at the same time.

- agent/central_brain.go: shouldTrustDeterministicSkillReply now always
  returns false. Successful mutations (trader/strategy/model/exchange
  create/update/start/stop/delete) flow through reviewTaskCompletion so the
  LLM sees the real outcome JSON and writes the user-facing prose. The
  trade-confirmation regex path (handleTradeConfirmation) was already
  outside this code path and is unaffected.

- agent/agent.go: rewrite the Behavior section of the main system prompt.
  Replace the contradictory "answer only what's asked / don't upsell" with
  "lead with the direct answer, then optionally one relevant follow-up
  only when (a) open risk, (b) missing config, or (c) the next step is
  obvious — e.g. created, want me to start it?". Explicitly authorize
  chaining ("if the user says create and start, do both this turn") and
  ban "please wait / I'll get back to you" language because there is no
  background job to come back from.

- agent/tools.go: plannerToolsForText always returns the full 22-tool set
  (new __all__ domain). The old per-domain trimming hid manage_trader from
  market questions and execute_trade from anything that didn't look like
  an explicit trade — cross-domain reasoning was structurally blocked. The
  compact-vs-full strategy schema switch is preserved so mutation intents
  still see the full config schema.

- web/src/components/agent/{AgentStepPanel,ChatMessages}.tsx: stop
  filtering tool: steps. Map raw tool names to friendly labels with emoji
  ("get_positions" → "📊 检查持仓") in zh/en/id. Users now see what the
  agent is doing in real time instead of silence. central_brain routing
  chatter still gets dropped.

- agent/planner_tools_test.go: tests updated to assert the new
  full-toolset behavior and the compact-vs-full strategy schema switch.
2026-05-29 22:13:05 +08:00

1495 lines
60 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
"nofx/store"
)
// brainDecision is the routing contract between the first-pass LLM and the executor.
type brainDecision struct {
ThoughtProcess string `json:"thought_process"`
ActionType string `json:"action_type"` // CONTINUE_TASK | NEW_TASK | EXPLAIN_KNOWLEDGE | CANCEL_TASK
TargetSkill string `json:"target_skill,omitempty"` // "skill_name:action" for NEW_TASK
ExtractedData map[string]any `json:"extracted_data,omitempty"`
ReplyToUser string `json:"reply_to_user"`
}
// activeSessionStepDecision is the per-turn control loop inside one active skill task.
type activeSessionStepDecision struct {
Route string `json:"route"` // ask_user | execute_skill | finish_task | cancel_task
Reply string `json:"reply,omitempty"`
ExtractedData map[string]any `json:"extracted_data,omitempty"`
}
// tryMinimalBrain is the single entry point replacing tryUnifiedSemanticGateway.
// Intelligence layer: one routing LLM call → active-session loop → legacy skill execution.
func (a *Agent) tryMinimalBrain(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
activeSession, hasActive := a.getActiveSkillSession(userID)
recentHistory := a.buildRecentConversationContext(userID, text)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
previousAssistantReply := a.currentPendingHintText(userID)
systemPrompt := buildBrainSystemPrompt(lang)
userPrompt := buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs, activeSession, hasActive)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return "", false, nil
}
decision, ok := parseBrainDecision(raw)
if !ok {
return "", false, nil
}
return a.executeBrainDecision(ctx, storeUserID, userID, lang, text, decision, activeSession, hasActive, onEvent)
}
func buildBrainSystemPrompt(lang string) string {
return prependNOFXiAdvisorPreamble(`You are the central brain of NOFXi. Read the intelligence report and output ONE JSON decision. No markdown, no extra text.
Available action_type values:
- "CONTINUE_TASK": user is continuing the current active task
- "NEW_TASK": user is starting a new task
- "EXPLAIN_KNOWLEDGE": user is asking a knowledge question only
- "CANCEL_TASK": user wants to stop the current task
Available skills (for NEW_TASK target_skill):
trader_management, exchange_management, model_management, strategy_management,
trader_diagnosis, exchange_diagnosis, model_diagnosis, strategy_diagnosis
Available actions:
create, update, update_name, update_bindings, configure_strategy, configure_exchange, configure_model,
update_status, update_endpoint, update_config, update_prompt, delete, start, stop, activate, duplicate,
query_list, query_detail, query_running
Rules:
- Prefer CONTINUE_TASK when there is an active task and the user is still talking about it.
- If the current user message is only a greeting, thanks, acknowledgement, or lightweight social chat like "你好", "hi", "hello", "thanks", "谢谢", "收到", do NOT continue the task.
- For those lightweight social messages, choose EXPLAIN_KNOWLEDGE and reply naturally, or let the task stay suspended.
- Use NEW_TASK only when there is no active task, or the user clearly switches goals/domains.
- Use EXPLAIN_KNOWLEDGE for concept/range/help questions; do not change state. When answering, use ONLY the options/values listed in the active session's missing_required_fields. Never invent field values or provider names.
- For diagnosis, create, update, delete, start, stop, activation, duplication, or historical-performance analysis tasks, never reply only with a future promise such as "I'll do it now", "please wait", "diagnosis is running", or "I'll tell you later". If the next step is execution, choose the corresponding skill/planned execution. If execution is impossible, say exactly what information or data is missing.
- Use CANCEL_TASK for "cancel", "stop", "forget it", "never mind", "算了", "取消".
- Domain guard: if the user says "模型", "AI 模型", or "model" and asks to create or configure one, you must route to model_management, not exchange_management.
- Domain guard: for model_management, the field "provider" means the AI model vendor such as OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, Minimax, claw402, blockrun-base, or blockrun-sol. It never means an exchange like Binance, OKX, Bybit, CFD, forex, or metals.
- extracted_data should include any concrete facts from the user's message.
- When an active session exposes allowed_field_spec_json, extracted_data must use only those canonical keys. Never output aliases, translated labels, or raw user wording as keys.
- If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}.
- For strategy changes, do not use the generic "strategy_management:update" action. Use "strategy_management:update_name" for renaming, "strategy_management:update_prompt" for prompt changes, or "strategy_management:update_config" for parameter/config changes. For strategy_management:update_config, extracted_data may include a StrategyConfig-shaped "config_patch".
- Current references are context only. Do not turn a current reference into target_ref_id/target_ref_name unless the user explicitly names that object or clearly refers to "this/current/that previous one". If a mutating task has no clear target, ask instead of executing.
- reply_to_user should be concise and in the user's language.
- For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create".
Output shape (JSON only):
{"thought_process":"...","action_type":"...","target_skill":"...","extracted_data":{},"reply_to_user":"..."}`)
}
func buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs string, activeSession ActiveSkillSession, hasActive bool) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Language: %s\nUser message: %s\n\n", lang, text))
sb.WriteString("=== PREVIOUS ASSISTANT REPLY ===\n")
sb.WriteString(defaultIfEmpty(strings.TrimSpace(previousAssistantReply), "none"))
sb.WriteString("\n\n")
sb.WriteString("=== MANAGEMENT DOMAIN PRIMER ===\n")
if hasActive {
sb.WriteString(defaultIfEmpty(buildSkillDomainPrimerForSession(lang, activeToLegacySkillSession(activeSession)), "none"))
} else {
sb.WriteString(defaultIfEmpty(buildManagementDomainPrimer(lang), "none"))
}
sb.WriteString("\n\n")
sb.WriteString("=== ACTIVE SESSION ===\n")
if hasActive {
sb.WriteString(fmt.Sprintf("skill: %s\naction: %s\n", activeSession.SkillName, activeSession.ActionName))
if strings.TrimSpace(activeSession.Goal) != "" {
sb.WriteString(fmt.Sprintf("goal: %s\n", activeSession.Goal))
}
if activeSession.PendingHint != nil && strings.TrimSpace(activeSession.PendingHint.Prompt) != "" {
sb.WriteString(fmt.Sprintf("pending_hint: %s\n", strings.TrimSpace(activeSession.PendingHint.Prompt)))
}
if len(activeSession.CollectedFields) > 0 {
fieldsJSON, _ := json.Marshal(activeSession.CollectedFields)
sb.WriteString(fmt.Sprintf("collected_fields: %s\n", fieldsJSON))
}
if missing := fieldConstraintSummary(activeSession); missing != "" {
sb.WriteString("missing_required_fields:\n")
sb.WriteString(missing)
sb.WriteString("\n")
}
fieldSpecs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(activeSession), lang)
if len(fieldSpecs) > 0 {
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
sb.WriteString(fmt.Sprintf("allowed_field_spec_json: %s\n", fieldSpecsJSON))
}
} else {
sb.WriteString("none\n")
}
sb.WriteString("\n=== CURRENT REFERENCES ===\n")
sb.WriteString(currentRefs)
sb.WriteString("\n\n=== RECENT CONVERSATION ===\n")
sb.WriteString(recentHistory)
return sb.String()
}
func parseBrainDecision(raw string) (brainDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var d brainDecision
if err := json.Unmarshal([]byte(raw), &d); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return brainDecision{}, false
}
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return brainDecision{}, false
}
}
d.ActionType = strings.ToUpper(strings.TrimSpace(d.ActionType))
d.TargetSkill = strings.TrimSpace(d.TargetSkill)
d.ReplyToUser = strings.TrimSpace(d.ReplyToUser)
switch d.ActionType {
case "CONTINUE_TASK", "NEW_TASK", "EXPLAIN_KNOWLEDGE", "CANCEL_TASK":
return d, true
default:
return brainDecision{}, false
}
}
func parseActiveSessionStepDecision(raw string) (activeSessionStepDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var d activeSessionStepDecision
if err := json.Unmarshal([]byte(raw), &d); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return activeSessionStepDecision{}, false
}
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return activeSessionStepDecision{}, false
}
}
d.Route = strings.TrimSpace(strings.ToLower(d.Route))
d.Reply = strings.TrimSpace(d.Reply)
switch d.Route {
case "ask_user", "execute_skill", "finish_task", "cancel_task":
return d, true
default:
return activeSessionStepDecision{}, false
}
}
func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, d brainDecision, activeSession ActiveSkillSession, hasActive bool, onEvent func(event, data string)) (string, bool, error) {
switch d.ActionType {
case "CANCEL_TASK":
a.clearActiveSkillSession(userID)
a.clearAnyActiveContext(userID)
reply := d.ReplyToUser
if reply == "" {
if lang == "zh" {
reply = "已取消当前流程。"
} else {
reply = "Cancelled the current flow."
}
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "EXPLAIN_KNOWLEDGE":
reply := d.ReplyToUser
if reply == "" {
return "", false, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
reply = guarded
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "NEW_TASK":
skill, action := parseTargetSkill(d.TargetSkill)
if skill == "" {
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
return answer, true, err
}
session := newActiveSkillSession(userID, skill, action)
session.Goal = strings.TrimSpace(text)
d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang)
markStrategyCreateConfigProgressThisTurn(&session, d.ExtractedData)
mergeExtractedData(&session, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
case "CONTINUE_TASK":
if !hasActive {
return "", false, nil
}
d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang)
markStrategyCreateConfigProgressThisTurn(&activeSession, d.ExtractedData)
mergeExtractedData(&activeSession, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
default:
return "", false, nil
}
}
func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession, onEvent func(event, data string)) (string, bool, error) {
session = appendActiveSessionLocalHistory(session, "user", text)
clearActiveSessionPendingHint(&session)
stepDecision, ok := a.planActiveSessionStep(ctx, storeUserID, userID, lang, text, session)
if !ok {
stepDecision = activeSessionStepDecision{}
}
configProgressThisTurn := consumeStrategyCreateConfigProgressThisTurn(&session)
if strategyCreateDecisionHasConfigProgress(session, stepDecision.ExtractedData) {
configProgressThisTurn = true
}
mergeExtractedData(&session, stepDecision.ExtractedData)
maybeForceStrategyCreateExecutionOnConfirmation(lang, text, &session, &stepDecision)
if stepDecision.Route == "" {
if len(missingRequiredFields(session)) > 0 {
stepDecision.Route = "ask_user"
} else {
stepDecision.Route = "execute_skill"
}
}
switch stepDecision.Route {
case "cancel_task":
a.clearActiveSkillSession(userID)
reply := defaultIfEmpty(stepDecision.Reply, "已取消当前流程。")
if lang != "zh" && strings.TrimSpace(stepDecision.Reply) == "" {
reply = "Cancelled the current flow."
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "finish_task":
reply := strings.TrimSpace(stepDecision.Reply)
if guarded, blocked := guardUnexecutedActiveTaskCompletion(lang, session, reply); blocked {
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
setActiveSessionPendingHint(&session, guarded)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, guarded)
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
setActiveSessionPendingHint(&session, guarded)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, guarded)
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
a.clearActiveSkillSession(userID)
if reply == "" {
return "", false, nil
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "ask_user":
reply := ""
if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked {
session.CollectedFields["awaiting_final_confirmation"] = true
reply = guarded
}
if reply == "" && configProgressThisTurn {
if deterministic, ok := strategyCreateTemplateMissingReply(lang, text, session); ok {
reply = deterministic
}
}
if reply == "" {
reply = strings.TrimSpace(stepDecision.Reply)
if reply == "" {
reply = a.askForMissingFields(lang, session)
}
}
if guarded, blocked := guardStrategyCreateAINonTemplateQuestion(lang, session, reply); blocked {
reply = guarded
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
reply = guarded
}
if len(missingRequiredFields(session)) == 0 && actionNeedsConfirmation(session.SkillName, session.ActionName) {
session.LegacyPhase = "await_confirmation"
session.CollectedFields["phase"] = "await_confirmation"
}
session = appendActiveSessionLocalHistory(session, "assistant", reply)
setActiveSessionPendingHint(&session, reply)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "execute_skill":
var repairReply string
var canExecute bool
session, repairReply, canExecute = a.ensureStrategyCreateExecutableState(ctx, lang, text, session)
if !canExecute {
if strategyCreateLooseConfirmationReply(text) {
repairReply = a.askForMissingFields(lang, session)
} else {
repairReply = defaultIfEmpty(repairReply, a.askForMissingFields(lang, session))
}
session = appendActiveSessionLocalHistory(session, "assistant", repairReply)
setActiveSessionPendingHint(&session, repairReply)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, repairReply)
a.recordSkillInteraction(userID, text, repairReply)
return repairReply, true, nil
}
if !strategyCreateLooseConfirmationReply(text) {
if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked {
session.CollectedFields["awaiting_final_confirmation"] = true
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
setActiveSessionPendingHint(&session, guarded)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, guarded)
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
}
outcome, nextSession, pending, ok := a.executeActiveSkillSession(storeUserID, userID, lang, text, session)
if !ok {
return "", false, nil
}
if pending {
reply := strings.TrimSpace(outcome.UserMessage)
if reply == "" {
reply = a.askForMissingFields(lang, nextSession)
}
nextSession = appendActiveSessionLocalHistory(nextSession, "assistant", reply)
setActiveSessionPendingHint(&nextSession, reply)
a.saveActiveSkillSession(nextSession)
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
}
if shouldTrustDeterministicSkillReply(outcome) {
answer := strings.TrimSpace(outcome.UserMessage)
if answer == "" {
return "", false, nil
}
a.clearActiveSkillSession(userID)
emitBrainReply(onEvent, answer)
a.recordSkillInteraction(userID, text, answer)
return answer, true, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if review.Route == "replan" && answer == "" {
answer = outcome.UserMessage
}
if answer == "" {
return "", false, nil
}
a.clearActiveSkillSession(userID)
emitBrainReply(onEvent, answer)
a.recordSkillInteraction(userID, text, answer)
return answer, true, nil
default:
return "", false, nil
}
}
func strategyCreateLooseConfirmationReply(text string) bool {
if strategyCreateConfirmationReply(text) {
return true
}
lower := strings.ToLower(strings.TrimSpace(text))
return strings.Contains(lower, "确认创建") ||
strings.Contains(lower, "按这个创建") ||
strings.Contains(lower, "confirm create")
}
func (a *Agent) ensureStrategyCreateExecutableState(ctx context.Context, lang, text string, session ActiveSkillSession) (ActiveSkillSession, string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return session, "", true
}
if strategyCreateSessionReady(lang, session) {
return session, "", true
}
if a.aiClient == nil {
return session, "", true
}
legacy := activeToLegacySkillSession(session)
collectedJSON, _ := json.Marshal(session.CollectedFields)
fieldSpecsJSON, _ := json.Marshal(allowedFieldSpecsForSkillSession(legacy, lang))
history := formatActiveSessionLocalHistory(session.LocalHistory)
if history == "" {
history = "(empty)"
}
systemPrompt := prependNOFXiAdvisorPreamble(`You repair structured state for one active NOFXi strategy creation task.
Return JSON only.
Rules:
- Think from the current user message, previous assistant proposal, and active history.
- If the previous assistant already asked the user to confirm a concrete creation proposal in chat and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too.
- For each user message, decide how it relates to the currently selected strategy product template.
- If the message provides explicit values, corrections, preferences, constraints, or asks you to recommend/design, translate only the determinable template fields into extracted_data.config_patch as a StrategyConfig-shaped JSON patch.
- If the message is only a question, explanation request, greeting, or unrelated text, answer it without inventing config_patch.
- Do not silently fill missing fields when the user has not authorized it. But if the user explicitly says things like "你帮我定 / 你推荐 / 按稳健高频设计 / 其他你定", that is authorization for the Agent to design the remaining fields. In that case you must produce a recommended config_patch based on the current strategy template and field limits, and explain which values came from the user versus which values are Agent recommendations.
- The product editor template is the source of truth. Use only fields from the selected product template.
- If the user switches strategy type, set extracted_data.strategy_type to the new type and discard fields from the previous type. Keep only shared fields such as name/description/publish settings.
- In NOFXi product schema, AI500/OI Top/OI Low/static coin-source requests are ai_trading, not grid_trading.
- Strategy creation is chat-executable. Do not tell the user to click a web/app button, open a page, or manually create it elsewhere.
- Do not claim the strategy was created and do not promise future execution ("马上创建", "正在创建", "稍后通知"). This step only repairs state or asks for missing information.
- When the current user message is a confirmation, prefer route="ready" whenever the structured template can be repaired. If it cannot be repaired, route="ask_user" with only the missing fields; never reply that you are about to create it.
- If the template is still incomplete after applying determinable config_patch, ask one natural follow-up question or explain the missing fields.
Return shape:
{"route":"ready|ask_user","reply":"","extracted_data":{}}`)
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nCurrent collected fields JSON:\n%s\n\nAllowed field spec JSON:\n%s\n\nActive task history:\n%s", lang, text, string(collectedJSON), string(fieldSpecsJSON), history)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return session, "", false
}
decision, ok := parseStrategyCreateStateRepairDecision(raw)
if !ok {
return session, "", false
}
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
if decision.Route == "ask_user" {
return session, strings.TrimSpace(decision.Reply), false
}
if strategyCreateSessionReady(lang, session) {
return session, strings.TrimSpace(decision.Reply), true
}
return session, strings.TrimSpace(decision.Reply), false
}
type strategyCreateStateRepairDecision struct {
Route string `json:"route"`
Reply string `json:"reply,omitempty"`
ExtractedData map[string]any `json:"extracted_data,omitempty"`
}
func parseStrategyCreateStateRepairDecision(raw string) (strategyCreateStateRepairDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var d strategyCreateStateRepairDecision
if err := json.Unmarshal([]byte(raw), &d); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return strategyCreateStateRepairDecision{}, false
}
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return strategyCreateStateRepairDecision{}, false
}
}
d.Route = strings.ToLower(strings.TrimSpace(d.Route))
d.Reply = strings.TrimSpace(d.Reply)
switch d.Route {
case "ready", "ask_user":
return d, true
default:
return strategyCreateStateRepairDecision{}, false
}
}
func strategyCreateSessionReady(lang string, session ActiveSkillSession) bool {
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return false
}
ready, _ := strategyCreateConfigReady(legacy, cfg, "")
return ready
}
func strategyCreateDecisionHasConfigProgress(session ActiveSkillSession, data map[string]any) bool {
if session.SkillName != "strategy_management" || session.ActionName != "create" || len(data) == 0 {
return false
}
patch, ok := data[strategyCreateConfigPatchField]
if !ok {
return false
}
sanitized := sanitizeStrategyCreateConfigPatchForType(patch, defaultIfEmpty(strategyTypeFromExtractedData(data), strategyTypeFromCollectedFields(session.CollectedFields)))
return len(sanitized) > 0
}
const strategyCreateConfigProgressThisTurnField = "__strategy_create_config_progress_this_turn"
func markStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession, data map[string]any) {
if session == nil || !strategyCreateDecisionHasConfigProgress(*session, data) {
return
}
if session.CollectedFields == nil {
session.CollectedFields = map[string]any{}
}
session.CollectedFields[strategyCreateConfigProgressThisTurnField] = true
}
func consumeStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession) bool {
if session == nil || session.CollectedFields == nil {
return false
}
progress := activeFieldBool(session.CollectedFields[strategyCreateConfigProgressThisTurnField])
delete(session.CollectedFields, strategyCreateConfigProgressThisTurnField)
return progress
}
func maybeForceStrategyCreateExecutionOnConfirmation(lang, text string, session *ActiveSkillSession, decision *activeSessionStepDecision) bool {
if session == nil || decision == nil {
return false
}
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return false
}
if !strategyCreateLooseConfirmationReply(text) {
return false
}
if !strategyCreateSessionReady(lang, *session) {
return false
}
if session.CollectedFields == nil {
session.CollectedFields = map[string]any{}
}
session.CollectedFields["awaiting_final_confirmation"] = true
decision.Route = "execute_skill"
decision.Reply = ""
return true
}
func (a *Agent) activeStrategyCreateSession(userID int64) (ActiveSkillSession, bool) {
if session, ok := a.getActiveSkillSession(userID); ok && session.SkillName == "strategy_management" && session.ActionName == "create" {
return session, true
}
if legacy := a.getSkillSession(userID); legacy.Name == "strategy_management" && legacy.Action == "create" {
return activeSessionFromLegacy(ActiveSkillSession{
UserID: userID,
SkillName: "strategy_management",
ActionName: "create",
}, legacy), true
}
return ActiveSkillSession{}, false
}
func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkillSession) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
}
if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) && strategyCreateHasPriorConfirmationPrompt(session) {
return "", false
}
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return "", false
}
if ready, _ := strategyCreateConfigReady(legacy, cfg, ""); !ready {
return "", false
}
return formatStrategyCreateFinalConfirmation(lang, legacy, cfg), true
}
func strategyCreateTemplateMissingReply(lang, text string, session ActiveSkillSession) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
}
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return "", false
}
ready, missingKind := strategyCreateConfigReady(legacy, cfg, "")
if ready || strings.TrimSpace(missingKind) == "" {
return "", false
}
if reply := formatStrategyCreateFieldOptionsReply(lang, text, missingKind); reply != "" {
return reply, true
}
return formatStrategyCreateConfigNeeded(lang, missingKind), true
}
func strategyCreateHasPriorConfirmationPrompt(session ActiveSkillSession) bool {
for i := len(session.LocalHistory) - 1; i >= 0; i-- {
msg := session.LocalHistory[i]
if msg.Role != "assistant" {
continue
}
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
lower := strings.ToLower(content)
return strings.Contains(content, "确认创建") ||
strings.Contains(content, "确认后我再创建") ||
strings.Contains(content, "配置整理好了") ||
strings.Contains(content, "请确认是否按以上设置创建") ||
strings.Contains(content, "如果没问题,我就执行创建") ||
strings.Contains(content, "是否按以上设置创建") ||
strings.Contains(lower, "confirm") ||
strings.Contains(lower, "create it")
}
return false
}
func activeFieldBool(v any) bool {
switch typed := v.(type) {
case bool:
return typed
case string:
return strings.EqualFold(strings.TrimSpace(typed), "true")
default:
return false
}
}
func guardUnexecutedActiveTaskCompletion(lang string, session ActiveSkillSession, reply string) (string, bool) {
if !isMutatingActiveTask(session) || !looksLikeCompletionClaim(reply) {
return "", false
}
if lang == "zh" {
if session.SkillName == "strategy_management" {
return "还没有真正创建到策略列表里。刚才只是整理/确认配置方案;需要继续的话,我会先用结构化配置调用策略创建工具,再基于真实结果回复。", true
}
return "还没有真正执行完成。刚才只是继续当前配置流程;需要实际执行时,我会调用对应工具后再基于真实结果回复。", true
}
return "It has not actually been executed yet. The previous step only prepared or confirmed the draft; I need to run the structured tool before claiming completion.", true
}
func guardStrategyCreateAINonTemplateQuestion(lang string, session ActiveSkillSession, reply string) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
}
if strategyTypeFromCollectedFields(session.CollectedFields) != "ai_trading" {
return "", false
}
lower := strings.ToLower(strings.TrimSpace(reply))
if lower == "" {
return "", false
}
if !containsAny(lower, []string{
"投入多少", "投入资金", "总投入", "固定投入", "每笔交易", "每笔固定", "100u", "500u", "1000u",
"止损", "日亏损", "最大回撤",
"investment amount", "capital", "fixed amount", "per-trade", "stop loss", "daily loss", "max drawdown",
}) {
return "", false
}
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return "", false
}
_, missingKind := strategyCreateConfigReady(legacy, cfg, "")
if strings.TrimSpace(missingKind) == "" {
return "", false
}
if lang == "zh" {
return "这些不是 AI 策略创建模板里的字段。我会继续按 AI 策略模板填写;当前还需要围绕选币来源、周期、杠杆、置信度、盈亏比、交易频率和开仓标准来确定配置。你也可以直接说“全部你定,按稳健/高频/激进”。", true
}
return "Those are not fields in the AI strategy creation template. I will continue using the AI strategy template: coin source, timeframes, leverage, confidence, risk/reward, trading frequency, and entry standards.", true
}
func guardUnsupportedAsyncPromise(lang, reply string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(reply))
if lower == "" {
return "", false
}
promiseSignals := []string{
"请稍等", "稍等片刻", "再稍等", "马上", "稍后", "立刻告诉", "数据一出来", "一两分钟",
"还在进行", "正在进行", "正在为", "正在帮", "一直在帮", "诊断中", "分析中",
"please wait", "give me a moment", "still running", "i'll let you know", "i will let you know",
}
taskSignals := []string{
"诊断", "分析", "历史交易", "历史表现", "亏损原因", "创建", "修改", "删除", "启动", "停止",
"diagnos", "analyz", "history", "performance", "loss", "create", "update", "delete", "start", "stop",
}
if !containsAny(lower, promiseSignals) || !containsAny(lower, taskSignals) {
return "", false
}
if lang == "zh" {
return "我需要纠正一下:我没有后台异步任务在运行,也不会自动推送后续结果。诊断/创建/修改/启动这类任务必须在当前回复里实际执行并给出真实结果;如果还不能执行,我应该直接说明缺少哪个对象、时间范围或数据。", true
}
return "I need to correct that: there is no background task running, and I will not automatically push a later result. Diagnosis/create/update/start tasks must actually execute and return a real result in the current response; if execution is not possible, I should state which target, range, or data is missing.", true
}
func isMutatingActiveTask(session ActiveSkillSession) bool {
if strings.TrimSpace(session.SkillName) == "" {
return false
}
switch strings.TrimSpace(session.ActionName) {
case "create", "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model", "update_status", "update_endpoint", "update_config", "update_prompt", "delete", "start", "stop", "activate", "duplicate":
return true
default:
return false
}
}
func looksLikeCompletionClaim(reply string) bool {
lower := strings.ToLower(strings.TrimSpace(reply))
if lower == "" {
return false
}
return containsAny(lower, []string{
"已创建", "创建好了", "创建好", "已经创建", "已更新", "更新好了", "已修改", "已删除", "已启动", "已停止", "已激活", "已复制", "已经完成", "已完成",
"created", "has been created", "updated", "deleted", "started", "stopped", "activated", "duplicated", "completed",
})
}
func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (activeSessionStepDecision, bool) {
if a.aiClient == nil {
return activeSessionStepDecision{}, false
}
legacy := activeToLegacySkillSession(session)
resources := a.buildActiveSessionResources(storeUserID, legacy)
resourcesJSON, _ := json.Marshal(resources)
collectedJSON, _ := json.Marshal(session.CollectedFields)
missingSummary := formatConversationMissingFields(lang, missingRequiredFieldsForBrain(session))
fieldSpecs := allowedFieldSpecsForSkillSession(legacy, lang)
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
localHistory := formatActiveSessionLocalHistory(session.LocalHistory)
if localHistory == "" {
localHistory = "(empty)"
}
previousAssistantReply := a.currentPendingHintText(userID)
domainPrimer := buildSkillDomainPrimerForSession(lang, legacy)
specificRules := activeSessionSpecificRules(legacy)
systemPrompt := prependNOFXiAdvisorPreamble(fmt.Sprintf(`You are the active-task orchestration loop for NOFXi.
You decide the NEXT step for exactly one active task. Return JSON only.
Active task:
- skill: %s
- action: %s
- goal: %s
Current collected fields:
%s
Current missing field summary:
%s
Relevant disclosed resources:
%s
Allowed field spec JSON:
%s
Domain knowledge:
%s
Rules:
- Your job is to decide the next move, not to explain internal schema names.
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal, confirmation request, or question.
- Use contextual memory from the active task history and current references.
- Prefer "execute_skill" when the user has already given enough information to act.
- Prefer "ask_user" only when something truly necessary is still missing.
%s
- For any mutating task, a reply that only promises future execution ("now I will create/update/start it", "result soon") is not a valid finish_task or ask_user outcome. If execution is the next step, choose execute_skill.
- For diagnosis, create, update, delete, start, stop, query/history, and performance-analysis tasks, never answer with only "马上处理 / 请稍等 / 诊断中 / I'll tell you later". NOFXi has no background chat job that will later push an answer. Choose execute_skill/planned_agent when enough information exists; otherwise ask for the missing target/range/data.
- Never choose finish_task for an unfinished mutating active task by claiming it was created/updated/deleted/started/stopped. Only a real skill/tool execution outcome can support that claim.
- If the user says they do not understand the current form, choices, or required information, choose "ask_user" and explain the current pending question in plain language before asking the next easiest question. Cover the relevant concepts from the previous assistant reply; do not collapse the answer to only the first missing field.
- For beginner/confusion replies, give a safe recommended path when the domain supports one, but do not execute or create anything unless the user confirms after the explanation.
- If the current message is only a greeting, thanks, acknowledgement, or small talk and does not add task information, do NOT continue task execution. Choose "ask_user" only if you need to gently restate what is pending; otherwise choose "finish_task" with a short social reply.
- Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details.
- If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref.
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
- Current references are context for reasoning only. Do not copy a current reference into target_ref_id/target_ref_name unless the user explicitly refers to that object by name/id or clearly says "this/current/that previous one". If the target is not clear, ask instead of executing.
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.
- If the current user message answers a missing field directly, extract it and continue.
- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.
- If a user-provided value does not fit one of those canonical keys, omit it; never create another key.
- If this task is already done and the best next step is just to tell the user the result, choose "finish_task".
- If the user aborts the task, choose "cancel_task".
Return JSON with this exact shape:
{"route":"ask_user|execute_skill|finish_task|cancel_task","reply":"","extracted_data":{}}`,
session.SkillName,
session.ActionName,
defaultIfEmpty(session.Goal, "(not set)"),
defaultIfEmpty(string(collectedJSON), "{}"),
missingSummary,
defaultIfEmpty(string(resourcesJSON), "{}"),
defaultIfEmpty(string(fieldSpecsJSON), "[]"),
defaultIfEmpty(domainPrimer, "(none)"),
specificRules,
))
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nPrevious assistant reply:\n%s\n\nActive task local history:\n%s\n", lang, text, defaultIfEmpty(previousAssistantReply, "(empty)"), localHistory)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return activeSessionStepDecision{}, false
}
decision, ok := parseActiveSessionStepDecision(raw)
if !ok {
return activeSessionStepDecision{}, false
}
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
return decision, true
}
func activeSessionSpecificRules(session skillSession) string {
if session.Name != "strategy_management" {
return ""
}
switch session.Action {
case "create", "update_config":
return strings.Join([]string{
"- For strategy_management:create/update_config, the selected product editor template is the only schema. Write values only through extracted_data.config_patch, using the current type branch only: ai_trading => strategy_type + ai_config + publish_config; grid_trading => strategy_type + grid_config + publish_config.",
"- For strategy_management:create/update_config, config_patch values must be product schema raw values, not user-facing labels. Examples: source_type=\"ai500\" not \"AI500\"; strategy_type=\"ai_trading\" not \"AI 策略\"; selected_timeframes=[\"1m\",\"5m\",\"15m\"] not a JSON string.",
"- For strategy_management:create, AI500/OI Top/OI Low/static coin-source requests imply strategy_type=\"ai_trading\". Do not leave strategy type ambiguous in that case.",
"- For strategy_management:create/update_config, judge the user's natural-language intent. Explicit values, corrections, constraints, preferences, or requests to recommend/design must become config_patch for every determinable current-template field; pure questions/greetings/acknowledgements must not invent config_patch.",
"- For strategy_management:create, the Relevant disclosed resources include product_default_template and current_missing_template_fields. Treat product_default_template as the product editor's default template and field shape.",
"- For strategy_management:create, do not ask for or present fields listed in product_default_template.non_fields. They are not part of the selected product editor template.",
"- For strategy_management:create, when the user states a strategy style/preference or authorizes the Agent to recommend/design remaining settings, use product_default_template as the base, adjust it to the user's stated preference, and output config_patch that fills every determinable missing template field. Do not ask the user to restate fields that can be responsibly selected from the default template.",
"- For grid_trading create, if the user authorizes the Agent to choose/recommend remaining settings, set grid_config.use_atr_bounds=true for the price range unless the user explicitly gives manual upper_price/lower_price. Never invent current market prices or say a symbol is currently near a price without a fresh market-data tool result.",
"- For strategy_management:create, any user-facing strategy plan must be generated from the post-merge structured config built from config_patch and the current strategy type. Do not display fields that would be filtered out or belong to the other strategy type.",
"- For strategy_management:create, once complete, ask for one chat confirmation with awaiting_final_confirmation=true; after confirmation execute synchronously with empty reply and only report success after the tool returns.",
}, "\n")
default:
return ""
}
}
func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) {
legacy := activeToLegacySkillSession(session)
a.saveSkillSession(userID, legacy)
answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, legacy)
if !handled {
a.clearSkillSession(userID)
return skillOutcome{}, ActiveSkillSession{}, false, false
}
updatedLegacy := a.getSkillSession(userID)
a.clearSkillSession(userID)
outcome := inferSkillOutcome(session.SkillName, session.ActionName, answer, updatedLegacy, skillDataForAction(storeUserID, session.SkillName, session.ActionName, a))
if updatedLegacy.Name != "" {
nextSession := activeSessionFromLegacy(session, updatedLegacy)
return outcome, nextSession, true, true
}
return outcome, ActiveSkillSession{}, false, true
}
// shouldTrustDeterministicSkillReply controls whether a Go-generated UserMessage
// is shown verbatim to the user (true) or whether the LLM gets to review the
// tool outcome and write a natural reply (false).
//
// Historically this returned true for every successful mutation on trader /
// strategy / model / exchange — which meant the user always saw the same
// canned `fmt.Sprintf` lines (e.g. "已创建 Trader: X. 我没有自动启动..."), and
// the agent felt mechanical / "non-agentic". It now always returns false so the
// LLM owns the voice. The cost is one extra LLM call per mutation; the upside
// is that the agent can chain ("trader created — want me to start it now?"),
// apologize on errors in plain language, respect the user's language and
// tone, and behave like an actual agent instead of a settings panel.
//
// The trade-confirmation flow (execute_trade -> "确认 trade_xxx") is unaffected:
// it runs through handleTradeConfirmation in trade.go before this code path.
func shouldTrustDeterministicSkillReply(_ skillOutcome) bool {
return false
}
func (a *Agent) askForMissingFields(lang string, session ActiveSkillSession) string {
missing := missingRequiredFieldsForBrain(session)
if len(missing) == 0 {
if lang == "zh" {
return "还需要一点信息,我再继续。"
}
return "I need a bit more information before I continue."
}
if session.SkillName == "model_management" && session.ActionName == "create" {
for _, field := range missing {
if field == "provider" {
return modelProviderChoicePrompt(lang)
}
}
}
def, ok := getSkillDefinition(session.SkillName)
if !ok {
if lang == "zh" {
return "还需要更多信息,请继续。"
}
return "I need a bit more information to continue."
}
labels := make([]string, 0, len(missing))
for _, field := range missing {
label := slotDisplayName(field, lang)
if constraint, ok := def.FieldConstraints[field]; ok {
desc := strings.TrimSpace(constraint.Description)
if len(constraint.Values) > 0 {
desc = strings.Join(constraint.Values, " / ")
}
if desc != "" {
label = fmt.Sprintf("%s%s", label, desc)
}
}
labels = append(labels, label)
}
if lang == "zh" {
return "还差一点信息,我才能继续:" + strings.Join(labels, "、") + "。"
}
return "I still need a bit more information before I can continue: " + strings.Join(labels, ", ") + "."
}
func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
legacy := skillSession{
Name: s.SkillName,
Action: s.ActionName,
Phase: defaultIfEmpty(strings.TrimSpace(s.LegacyPhase), "executing"),
Fields: make(map[string]string),
}
for k, v := range s.CollectedFields {
str := activeFieldString(v)
if str == "" || str == "<nil>" {
continue
}
switch k {
case "phase":
legacy.Phase = str
case "target_ref_id":
ensureTargetRef(&legacy)
legacy.TargetRef.ID = str
case "target_ref_name":
ensureTargetRef(&legacy)
legacy.TargetRef.Name = str
case "target_ref":
ensureTargetRef(&legacy)
if legacy.TargetRef.ID == "" {
legacy.TargetRef.ID = str
}
if legacy.TargetRef.Name == "" {
legacy.TargetRef.Name = str
}
default:
legacy.Fields[k] = str
}
}
if s.SkillName == "strategy_management" && s.ActionName == "create" && legacy.Fields["name"] == "" {
for i := len(s.LocalHistory) - 1; i > 0; i-- {
msg := s.LocalHistory[i]
if msg.Role != "user" || !activeHistoryMessageAsksStrategyName(s.LocalHistory[i-1].Content) {
continue
}
if inferred := inferStandaloneStrategyName(msg.Content); inferred != "" {
legacy.Fields["name"] = inferred
break
}
}
}
return legacy
}
func activeFieldString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case map[string]any, []any, map[string]string, []string:
raw, err := json.Marshal(v)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func activeSessionFromLegacy(base ActiveSkillSession, legacy skillSession) ActiveSkillSession {
next := base
next.LegacyPhase = strings.TrimSpace(legacy.Phase)
if next.CollectedFields == nil {
next.CollectedFields = map[string]any{}
}
for key, value := range legacy.Fields {
value = strings.TrimSpace(value)
if value == "" {
continue
}
next.CollectedFields[key] = value
}
if legacy.TargetRef != nil {
if value := strings.TrimSpace(legacy.TargetRef.ID); value != "" {
next.CollectedFields["target_ref_id"] = value
}
if value := strings.TrimSpace(legacy.TargetRef.Name); value != "" {
next.CollectedFields["target_ref_name"] = value
}
}
return next
}
func ensureTargetRef(s *skillSession) {
if s.TargetRef == nil {
s.TargetRef = &EntityReference{}
}
}
func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSession) map[string]any {
switch session.Name {
case "trader_management":
if session.Action == "create" {
return a.buildTraderCreateConversationResources(storeUserID, session)
}
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadTraderOptions(storeUserID))
case "exchange_management":
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadExchangeOptions(storeUserID))
case "model_management":
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadEnabledModelOptions(storeUserID))
case "strategy_management":
resources := a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID))
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
lang := defaultIfEmpty(a.config.Language, "zh")
resources["current_strategy_type"] = strategyType
resources["current_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
if session.Action == "create" || session.Action == "update_config" {
resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType)
if cfg, _, _, err := strategyCreateConfigFromSession(session, lang); err == nil {
resources["current_missing_template_fields"] = strategyCreateMissingTemplateFields(session, cfg)
}
}
} else if strategyType, ok := a.strategyTypeForTarget(storeUserID, session.TargetRef); ok {
lang := defaultIfEmpty(a.config.Language, "zh")
resources["target_strategy_type"] = strategyType
resources["target_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType)
}
return resources
default:
return nil
}
}
func strategyProductDefaultTemplateResource(lang, strategyType string) map[string]any {
cfg := store.GetDefaultStrategyConfig(defaultIfEmpty(lang, "zh"))
cfg.StrategyType = strings.TrimSpace(strategyType)
cfg.ClampLimits()
publish := map[string]any{
"is_public": false,
"config_visible": true,
}
switch cfg.StrategyType {
case "grid_trading":
grid := cfg.GridConfig
if grid == nil {
defaultGrid := store.DefaultGridStrategyConfig()
grid = &defaultGrid
}
return map[string]any{
"strategy_type": "grid_trading",
"grid_config": grid,
"publish_config": publish,
"required_fields": strategyCreateMissingGridFields(skillSession{}),
}
default:
return map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": cfg.CoinSource.SourceType,
"static_coins": cfg.CoinSource.StaticCoins,
"excluded_coins": cfg.CoinSource.ExcludedCoins,
"ai500_limit": cfg.CoinSource.AI500Limit,
"oi_top_limit": cfg.CoinSource.OITopLimit,
"oi_low_limit": cfg.CoinSource.OILowLimit,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": cfg.Indicators.Klines.PrimaryTimeframe,
"primary_count": cfg.Indicators.Klines.PrimaryCount,
"selected_timeframes": cfg.Indicators.Klines.SelectedTimeframes,
},
"enable_ema": cfg.Indicators.EnableEMA,
"enable_macd": cfg.Indicators.EnableMACD,
"enable_rsi": cfg.Indicators.EnableRSI,
"enable_atr": cfg.Indicators.EnableATR,
"enable_boll": cfg.Indicators.EnableBOLL,
"enable_volume": cfg.Indicators.EnableVolume,
"enable_oi": cfg.Indicators.EnableOI,
"enable_funding_rate": cfg.Indicators.EnableFundingRate,
},
"risk_control": map[string]any{
"btc_eth_max_leverage": cfg.RiskControl.BTCETHMaxLeverage,
"altcoin_max_leverage": cfg.RiskControl.AltcoinMaxLeverage,
"min_confidence": cfg.RiskControl.MinConfidence,
"min_risk_reward_ratio": cfg.RiskControl.MinRiskRewardRatio,
},
"prompt_sections": map[string]any{
"trading_frequency": cfg.PromptSections.TradingFrequency,
"entry_standards": cfg.PromptSections.EntryStandards,
},
"custom_prompt": cfg.CustomPrompt,
},
"publish_config": publish,
"non_fields": []string{
"investment_amount",
"fixed_position_size",
"stop_loss_pct",
"daily_loss_limit_pct",
"max_drawdown_pct",
},
"required_fields": []string{
"source_type",
"primary_timeframe",
"selected_timeframes",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_confidence",
"min_risk_reward_ratio",
"trading_frequency",
"entry_standards",
},
}
}
}
func missingRequiredFieldsForBrain(session ActiveSkillSession) []string {
missing := missingRequiredFields(session)
if len(missing) == 0 {
return nil
}
out := make([]string, 0, len(missing))
for _, field := range missing {
if field == "target_ref" {
if activeSessionHasField(session, "target_ref") {
continue
}
}
out = append(out, field)
}
return out
}
func formatActiveSessionLocalHistory(history []chatMessage) string {
if len(history) == 0 {
return ""
}
start := 0
if len(history) > 8 {
start = len(history) - 8
}
lines := make([]string, 0, len(history)-start)
for _, msg := range history[start:] {
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "unknown"
}
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("%s: %s", role, content))
}
return strings.Join(lines, "\n")
}
func appendActiveSessionLocalHistory(session ActiveSkillSession, role, content string) ActiveSkillSession {
content = strings.TrimSpace(content)
if content == "" {
return session
}
session.LocalHistory = append(session.LocalHistory, chatMessage{
Role: strings.TrimSpace(role),
Content: content,
})
if len(session.LocalHistory) > 12 {
session.LocalHistory = append([]chatMessage(nil), session.LocalHistory[len(session.LocalHistory)-12:]...)
}
return session
}
func parseTargetSkill(target string) (skill, action string) {
parts := strings.SplitN(target, ":", 2)
if len(parts) != 2 {
return "", ""
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
func mergeExtractedData(s *ActiveSkillSession, data map[string]any) {
if s.CollectedFields == nil {
s.CollectedFields = map[string]any{}
}
if s.SkillName == "strategy_management" && s.ActionName == "create" {
if incomingType := strategyTypeFromExtractedData(data); incomingType != "" {
currentType := strategyTypeFromCollectedFields(s.CollectedFields)
if currentType != "" && currentType != incomingType {
resetActiveStrategyCreateFieldsForType(s, incomingType)
}
}
}
for k, v := range data {
k = strings.TrimSpace(k)
if k == "" {
continue
}
s.CollectedFields[k] = v
}
}
func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[string]any, lang string) map[string]any {
if len(data) == 0 {
return data
}
specs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(session), lang)
if len(specs) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
out := make(map[string]any, len(data))
for key, value := range data {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := allowed[key]; !ok {
continue
}
out[key] = value
}
if session.SkillName == "strategy_management" && session.ActionName == "create" {
out = filterStrategyCreateExtractedDataByTemplate(session, out)
}
if len(out) == 0 {
return nil
}
return out
}
func strategyTypeFromExtractedData(data map[string]any) string {
if len(data) == 0 {
return ""
}
if value, ok := data["strategy_type"]; ok {
if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" {
return strategyType
}
}
if patch, ok := data[strategyCreateConfigPatchField]; ok {
if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" {
return strategyType
}
}
return ""
}
func strategyTypeFromCollectedFields(fields map[string]any) string {
if len(fields) == 0 {
return ""
}
if value, ok := fields["strategy_type"]; ok {
if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" {
return strategyType
}
}
if patch, ok := fields[strategyCreateConfigPatchField]; ok {
if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" {
return strategyType
}
}
return ""
}
func strategyTypeFromConfigPatchAny(value any) string {
patch := mapFromAny(value)
if len(patch) == 0 {
return ""
}
if strategyType := parseStrategyTypeValue(fmt.Sprint(patch["strategy_type"])); strategyType != "" {
return strategyType
}
if _, ok := patch["grid_config"]; ok {
return "grid_trading"
}
if _, ok := patch["ai_config"]; ok {
return "ai_trading"
}
return ""
}
func resetActiveStrategyCreateFieldsForType(s *ActiveSkillSession, strategyType string) {
if s.CollectedFields == nil {
s.CollectedFields = map[string]any{}
}
keep := map[string]any{}
for _, key := range []string{"name", "description", "is_public", "config_visible", "lang"} {
if value, ok := s.CollectedFields[key]; ok {
keep[key] = value
}
}
keep["strategy_type"] = strategyType
s.CollectedFields = keep
}
func filterStrategyCreateExtractedDataByTemplate(session ActiveSkillSession, data map[string]any) map[string]any {
if len(data) == 0 {
return data
}
strategyType := strategyTypeFromExtractedData(data)
if strategyType == "" {
strategyType = strategyTypeFromCollectedFields(session.CollectedFields)
}
if strategyType == "" {
return data
}
allowed := map[string]struct{}{}
for _, key := range manualStrategyEditableFieldKeysForType(strategyType) {
allowed[key] = struct{}{}
}
out := make(map[string]any, len(data))
for key, value := range data {
if key == strategyCreateConfigPatchField {
if patch := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(patch) > 0 {
out[key] = patch
}
continue
}
if key == "awaiting_final_confirmation" {
out[key] = value
continue
}
if _, ok := allowed[key]; ok {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func sanitizeStrategyCreateConfigPatchForType(value any, strategyType string) map[string]any {
patch := mapFromAny(value)
if len(patch) == 0 {
return nil
}
out := map[string]any{
"strategy_type": strategyType,
}
if publish := mapFromAny(patch["publish_config"]); len(publish) > 0 {
out["publish_config"] = publish
}
switch strategyType {
case "grid_trading":
if grid := mapFromAny(patch["grid_config"]); len(grid) > 0 {
out["grid_config"] = grid
}
case "ai_trading":
ai := mapFromAny(patch["ai_config"])
if ai == nil {
ai = map[string]any{}
}
for _, key := range []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} {
if value, ok := patch[key]; ok {
ai[key] = value
}
}
if len(ai) > 0 {
out["ai_config"] = ai
}
}
if len(out) == 1 {
return nil
}
return out
}
func mapFromAny(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
case string:
var out map[string]any
if err := json.Unmarshal([]byte(typed), &out); err == nil {
return out
}
}
return nil
}
func emitBrainReply(onEvent func(event, data string), reply string) {
if onEvent == nil || reply == "" {
return
}
onEvent(StreamEventTool, "central_brain")
emitStreamText(onEvent, reply)
}