package agent import ( "context" "encoding/json" "fmt" "strings" "nofx/mcp" ) // 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. - 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(buildSkillDomainPrimer(lang, activeSession.SkillName), "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 } 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) 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) 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{} } mergeExtractedData(&session, stepDecision.ExtractedData) 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 } a.clearActiveSkillSession(userID) if reply == "" { return "", false, nil } emitBrainReply(onEvent, reply) a.recordSkillInteraction(userID, text, reply) return reply, true, nil case "ask_user": reply := strings.TrimSpace(stepDecision.Reply) if reply == "" { reply = a.askForMissingFields(lang, session) } 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 { 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 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 } 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 (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 concrete strategy settings can be determined, write them into extracted_data.config_patch as a StrategyConfig-shaped JSON patch. - If the previous assistant already asked the user to confirm a concrete creation proposal and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too. - If the user is asking you to design settings but has not confirmed creation yet, use route ask_user, provide a concise final confirmation reply, and include the designed config in extracted_data.config_patch plus extracted_data.awaiting_final_confirmation=true. - Do not claim the strategy was created. This step only repairs state or asks for more information. - If there is not enough information to determine a config, ask one natural follow-up question. 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 guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkillSession) (string, bool) { if session.SkillName != "strategy_management" || session.ActionName != "create" { return "", false } if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) { 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 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 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 := buildSkillDomainPrimer(lang, session.SkillName) 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. - For strategy_management:create/update_config: every turn, reason about whether any config fields can now be determined from the user's message and conversation history. If yes, write them into extracted_data.config_patch. - For strategy_management:create: when the user asks you to design/recommend settings, think as the strategy designer, produce a concrete recommended config in your reply, and also put the same structured config into extracted_data.config_patch. Do not ask the user to fill fields you can reasonably choose for them. - For strategy_management:create: once the structured config is sufficient to create, ask for one final confirmation and set extracted_data.awaiting_final_confirmation=true. Do not execute create in that same turn. - For strategy_management:create: choose execute_skill only when awaiting_final_confirmation is already true and the current user message confirms the final summary. If the user changes a number, update config_patch and ask for final confirmation again. - For strategy_management:create: if the previous assistant reply said the strategy was not actually created yet and that the next step is to call the structured create tool, then a user request to continue/proceed means execute the current skill when the structured config is ready. Do not answer with another promise such as "I will create it now"; choose execute_skill. - 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. - 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. - For strategy_management:create, do not ask for exchange accounts or model bindings. Strategy templates are independent drafts/configs; exchange/model are only needed when creating, deploying, or starting a trader. - Strategy templates should be visible in the strategy list/page after creation. Do not bring up trader/model/exchange binding unless the user asks to run or deploy. - For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. The product schema is type-isolated: grid strategies use only top-level strategy_type + grid_config + publish_config; AI strategies use only top-level strategy_type + ai_config + publish_config. For example, "BTC趋势做空" as an AI strategy should set ai_config.coin_source to static BTCUSDT and add ai_config prompt/risk/entry rules. - 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)"), )) 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 (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 } 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 == "" { 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 } } 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 != "" { resources["current_strategy_type"] = strategyType resources["current_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType) } else if strategyType, ok := a.strategyTypeForTarget(storeUserID, session.TargetRef); ok { resources["target_strategy_type"] = strategyType resources["target_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType) } return resources default: return nil } } 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{} } 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 len(out) == 0 { return nil } return out } func emitBrainReply(onEvent func(event, data string), reply string) { if onEvent == nil || reply == "" { return } onEvent(StreamEventTool, "central_brain") emitStreamText(onEvent, reply) }