diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go index a9b41485..c0adc875 100644 --- a/agent/config_visibility_test.go +++ b/agent/config_visibility_test.go @@ -246,6 +246,55 @@ func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) { } } +func TestToolManageStrategyReportsChangedAndRejectedFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "strategy-change-summary.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + resp := a.toolManageStrategy("default", `{"action":"create","name":"高频-短线ETH","config":{"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},"indicators":{"klines":{"primary_timeframe":"1m","selected_timeframes":["1m","3m"]}},"order_execution_speed":"fast"}}`) + if strings.Contains(resp, `"error"`) { + t.Fatalf("expected create to succeed with rejected unknown fields, got: %s", resp) + } + for _, want := range []string{ + `"created_strategy_id"`, + `"changed_fields"`, + `coin_source.source_type`, + `indicators.klines.primary_timeframe`, + `"rejected_fields"`, + `order_execution_speed (not in current strategy config)`, + `"unchanged_defaults"`, + } { + if !strings.Contains(resp, want) { + t.Fatalf("expected response to contain %q, got: %s", want, resp) + } + } + + strategies, err := st.Strategy().List("default") + if err != nil { + t.Fatalf("list strategies: %v", err) + } + var created *store.Strategy + for _, strategy := range strategies { + if strategy.Name == "高频-短线ETH" { + created = strategy + break + } + } + if created == nil { + t.Fatalf("expected strategy to be created") + } + var cfg map[string]any + if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil { + t.Fatalf("parse config: %v", err) + } + if _, ok := cfg["order_execution_speed"]; ok { + t.Fatalf("unknown field should not be persisted: %s", created.Config) + } +} + func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "exchange-options.db") st, err := store.New(dbPath) diff --git a/agent/llm_flow_extractor.go b/agent/llm_flow_extractor.go index 15ce752d..79a66987 100644 --- a/agent/llm_flow_extractor.go +++ b/agent/llm_flow_extractor.go @@ -252,7 +252,7 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false) case "strategy_management": if session.Action == "create" || session.Action == "update_config" { - add(&out, "config_patch", "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use this for strategy requirements such as target coins, trend style, short/long bias, indicators, risk, timeframes, and prompt sections.", false) + add(&out, "config_patch", strategyConfigPatchFieldDescription(lang), false) } if session.Action == "update_prompt" { add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false) @@ -270,6 +270,10 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl return out } +func strategyConfigPatchFieldDescription(lang string) string { + return "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use this for strategy requirements such as target coins, trend style, short/long bias, indicators, risk, timeframes, and prompt sections." +} + func currentFieldValuesForSkillSession(session skillSession) map[string]string { values := map[string]string{} for key, value := range session.Fields { diff --git a/agent/llm_skill_router.go b/agent/llm_skill_router.go index 829ae300..5dfc27f5 100644 --- a/agent/llm_skill_router.go +++ b/agent/llm_skill_router.go @@ -20,6 +20,7 @@ type unifiedTurnDecision struct { TopicIntent string `json:"topic_intent,omitempty"` BusinessAction string `json:"business_action,omitempty"` TargetSkill string `json:"target_skill,omitempty"` + Tasks []WorkflowTask `json:"tasks,omitempty"` TargetSnapshotID string `json:"target_snapshot_id,omitempty"` ContextMode string `json:"context_mode,omitempty"` ExtractedData map[string]any `json:"extracted_data,omitempty"` @@ -117,6 +118,7 @@ func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecis decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID) decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode)) decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser) + decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks if decision.ExtractedData == nil { decision.ExtractedData = map[string]any{} } @@ -134,7 +136,7 @@ func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecis decision.TopicIntent = "" } switch decision.BusinessAction { - case "direct_answer", "new_skill", "continue_skill", "planned_agent", "none": + case "direct_answer", "new_skill", "skill_tasks", "continue_skill", "planned_agent", "none": default: decision.BusinessAction = "" } @@ -157,8 +159,13 @@ func (d unifiedTurnDecision) reliable() bool { case "direct_answer": return strings.TrimSpace(d.ReplyToUser) != "" case "new_skill": + if len(d.Tasks) > 0 { + return true + } skill, _ := parseTargetSkill(d.TargetSkill) return skill != "" + case "skill_tasks": + return len(d.Tasks) > 0 case "continue_skill": return d.TopicIntent == "continue_active" case "planned_agent", "none": @@ -234,12 +241,20 @@ topic_intent values: business_action values: - "direct_answer": reply_to_user is the final answer; do not change state -- "new_skill": start a management/diagnosis skill; target_skill is required +- "skill_tasks": start one or more management/diagnosis skill tasks; tasks is required +- "new_skill": legacy single-skill route; target_skill is required if tasks is empty - "continue_skill": continue the active skill session - "planned_agent": hand off to the execution planner/tools - "none": only valid with cancel when no more action is needed -target_skill format for new_skill: +tasks format for skill_tasks: +- id: "task_1", "task_2", ... +- skill: one available skill name +- action: one available action +- request: the self-contained user-readable subtask +- depends_on: array of task ids, empty when independent + +target_skill format for legacy new_skill: skill_name:action, for example "trader_management:create". Available skills: trader_management, exchange_management, model_management, strategy_management, @@ -262,7 +277,9 @@ Rules: - If the user answers the previous assistant question, choose continue_active. - If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task. - If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context. -- Use new_skill for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy. +- Use skill_tasks for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy. +- If the user request contains multiple management operations, include multiple tasks and depends_on where a later task needs an earlier result. +- If the request contains exactly one management operation, include exactly one task. - Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks. - For model_management, "provider" means AI vendor, never an exchange. - Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one. @@ -271,7 +288,7 @@ Rules: - confidence should reflect how safe it is to execute this decision without the old router fallback. Return JSON with this exact shape: -{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|new_skill|continue_skill|planned_agent|none","target_skill":"","target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`) +{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|skill_tasks|new_skill|continue_skill|planned_agent|none","target_skill":"","tasks":[{"id":"task_1","skill":"","action":"","request":"","depends_on":[]}],"target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`) userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n\nManagement domain primer:\n%s\n\nActive task details:\n%s\n", lang, @@ -334,6 +351,9 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri a.runPostResponseMaintenanceAsync(userID) return decision.ReplyToUser, true, nil case "new_skill": + if len(decision.Tasks) > 0 { + return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent) + } skill, action := parseTargetSkill(decision.TargetSkill) if skill == "" { return "", false, nil @@ -351,6 +371,8 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) mergeExtractedData(&session, decision.ExtractedData) return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) + case "skill_tasks": + return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent) case "continue_skill": activeSession, hasActive := a.getActiveSkillSession(userID) if !hasActive { @@ -373,6 +395,39 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri } } +func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) { + tasks := normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks + if len(tasks) == 0 { + return "", false, nil + } + if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" { + if !a.suspendActiveContexts(userID, lang) { + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + } + a.clearActiveSkillSession(userID) + } + if len(tasks) == 1 { + task := tasks[0] + session := newActiveSkillSession(userID, task.Skill, task.Action) + session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text)) + decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) + mergeExtractedData(&session, decision.ExtractedData) + return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent) + } + session := normalizeWorkflowSession(WorkflowSession{ + UserID: userID, + OriginalRequest: strings.TrimSpace(text), + Tasks: tasks, + }) + if len(session.Tasks) == 0 { + return "", false, nil + } + a.saveWorkflowSession(userID, session) + return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent) +} + func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) { raw = strings.TrimSpace(raw) raw = strings.TrimPrefix(raw, "```json") diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 198d6ec4..3a8401da 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -2823,7 +2823,7 @@ type nextStepDecision struct { } func (a *Agent) decideNextStep(ctx context.Context, userID int64, lang string, state ExecutionState) (nextStepDecision, error) { - toolDefs, _ := json.Marshal(agentTools()) + toolDefs, _ := json.Marshal(plannerToolsForText(state.Goal)) obsJSON, _ := json.Marshal(buildObservationContext(state)) recentlyFetchedJSON, _ := json.Marshal(buildRecentlyFetchedData(state, time.Now().UTC())) currentTurnCtx := a.buildCurrentTurnContext(userID, lang, state.Goal) @@ -3010,7 +3010,7 @@ func (a *Agent) buildRecentConversationContext(userID int64, currentUserText str } func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) { - toolDefs, _ := json.Marshal(agentTools()) + toolDefs, _ := json.Marshal(plannerToolsForText(userText)) currentTurnCtx := a.buildCurrentTurnContext(userID, lang, userText) activeTaskCtx := a.buildActiveTaskStateContext(userID, lang) currentReferenceSummary := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) diff --git a/agent/planner_tools_test.go b/agent/planner_tools_test.go new file mode 100644 index 00000000..955efee2 --- /dev/null +++ b/agent/planner_tools_test.go @@ -0,0 +1,84 @@ +package agent + +import ( + "encoding/json" + "testing" + + "nofx/mcp" +) + +func TestPlannerToolsForMarketIntentAreTrimmed(t *testing.T) { + tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线") + names := toolNamesForTest(tools) + + for _, expected := range []string{"get_market_snapshot", "get_market_price", "get_kline"} { + if !containsString(names, expected) { + t.Fatalf("expected market tool %q in %v", expected, names) + } + } + for _, unexpected := range []string{"manage_strategy", "manage_trader", "manage_exchange_config", "manage_model_config"} { + if containsString(names, unexpected) { + t.Fatalf("did not expect management tool %q in market tools %v", unexpected, names) + } + } +} + +func TestPlannerToolsForExchangeIntentAreTrimmed(t *testing.T) { + tools := plannerToolsForText("帮我添加 okx 交易所 API key") + names := toolNamesForTest(tools) + + if len(names) != 2 { + t.Fatalf("expected two exchange tools, got %v", names) + } + for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} { + if !containsString(names, expected) { + t.Fatalf("expected exchange tool %q in %v", expected, names) + } + } +} + +func TestPlannerToolsUseCompactManageStrategyForReadIntent(t *testing.T) { + tools := plannerToolsForText("列出我的策略") + tool := findToolForTest(tools, "manage_strategy") + if tool == nil { + t.Fatalf("expected manage_strategy in strategy tools") + } + + raw, _ := json.Marshal(tool.Function.Parameters) + if len(raw) > 900 { + t.Fatalf("expected compact strategy schema, got %d bytes", len(raw)) + } + if string(raw) == "" || !json.Valid(raw) { + t.Fatalf("expected valid strategy schema JSON") + } +} + +func TestPlannerToolsKeepFullManageStrategyForMutationIntent(t *testing.T) { + tools := plannerToolsForText("创建一个 BTC 网格策略") + tool := findToolForTest(tools, "manage_strategy") + if tool == nil { + t.Fatalf("expected manage_strategy in strategy tools") + } + + raw, _ := json.Marshal(tool.Function.Parameters) + if len(raw) < 1500 { + t.Fatalf("expected full strategy schema for mutation intent, got %d bytes", len(raw)) + } +} + +func toolNamesForTest(tools []mcp.Tool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + names = append(names, tool.Function.Name) + } + return names +} + +func findToolForTest(tools []mcp.Tool, name string) *mcp.Tool { + for i := range tools { + if tools[i].Function.Name == name { + return &tools[i] + } + } + return nil +} diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go index 3659dfba..33779fae 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -2836,12 +2836,52 @@ func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, la enMsg += "\n\nAdjusted to stay within safe limits:\n- " + strings.Join(warnings, "\n- ") } } + if summary := parseStrategyToolChangeSummary(resp); len(summary.ChangedFields) > 0 || len(summary.RejectedFields) > 0 || len(summary.UnchangedDefaults) > 0 { + if lang == "zh" { + if len(summary.ChangedFields) > 0 { + zhMsg += "\n- 实际写入配置:" + strings.Join(summary.ChangedFields, "、") + } + if len(summary.RejectedFields) > 0 { + zhMsg += "\n- 未写入字段:" + strings.Join(summary.RejectedFields, "、") + } + if len(summary.UnchangedDefaults) > 0 { + zhMsg += "\n- 仍使用默认值:" + strings.Join(summary.UnchangedDefaults, "、") + } + } else { + if len(summary.ChangedFields) > 0 { + enMsg += "\n- Config fields written: " + strings.Join(summary.ChangedFields, ", ") + } + if len(summary.RejectedFields) > 0 { + enMsg += "\n- Rejected fields: " + strings.Join(summary.RejectedFields, ", ") + } + if len(summary.UnchangedDefaults) > 0 { + enMsg += "\n- Defaults still in use: " + strings.Join(summary.UnchangedDefaults, ", ") + } + } + } if lang == "zh" { return zhMsg } return enMsg } +type strategyToolChangeSummary struct { + CreatedStrategyID string `json:"created_strategy_id"` + StrategyID string `json:"strategy_id"` + ChangedFields []string `json:"changed_fields"` + UnchangedDefaults []string `json:"unchanged_defaults"` + RejectedFields []string `json:"rejected_fields"` +} + +func parseStrategyToolChangeSummary(raw string) strategyToolChangeSummary { + var payload strategyToolChangeSummary + _ = json.Unmarshal([]byte(raw), &payload) + payload.ChangedFields = cleanStringList(payload.ChangedFields) + payload.UnchangedDefaults = cleanStringList(payload.UnchangedDefaults) + payload.RejectedFields = cleanStringList(payload.RejectedFields) + return payload +} + func parseToolWarnings(raw string) []string { var payload struct { Warnings []string `json:"warnings"` diff --git a/agent/skill_outcome.go b/agent/skill_outcome.go index 8922ad2d..383ef010 100644 --- a/agent/skill_outcome.go +++ b/agent/skill_outcome.go @@ -152,6 +152,8 @@ Rules: - Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome. - Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help. - If you choose "complete", produce the final user-facing answer in the user's language. +- For strategy_management create/update outcomes, only mention config fields present in changed_fields, unchanged_defaults, rejected_fields, warnings, or user_message. Do not add strategy settings that are not in the structured skill outcome. +- If a strategy field is not in the current StrategyConfig or appears in rejected_fields, say it was not written / is not in the current strategy config. Do not use trading common sense to invent fields. - ` + cleanUserFacingReplyInstruction + ` Return JSON with this exact shape: diff --git a/agent/tools.go b/agent/tools.go index c1705385..e381451d 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -43,6 +43,243 @@ var ( // agentTools returns the tools available to the LLM for autonomous action. func agentTools() []mcp.Tool { return cachedTools } +func plannerToolsForText(text string) []mcp.Tool { + domain := plannerToolDomainForText(text) + compactStrategy := !looksLikeStrategyMutationIntent(text) + names := plannerToolNamesForDomain(domain) + return toolsByName(names, compactStrategy) +} + +func plannerToolDomainForText(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return "general" + } + if containsAny(lower, []string{"诊断", "排查", "为什么", "为啥", "失败", "报错", "异常", "停止", "没下单", "failed", "error", "diagnose", "debug", "logs", "stopped", "not trading"}) { + return "diagnosis" + } + if hasExplicitManagementDomainCue(text, "exchange") || containsAny(lower, []string{"交易所", "exchange", "apikey", "secret", "passphrase", "wallet address", "api凭证"}) { + return "exchange" + } + if hasExplicitManagementDomainCue(text, "model") || containsAny(lower, []string{"ai model", "模型", "provider", "api key", "custom_model", "custom api"}) { + return "model" + } + if hasExplicitManagementDomainCue(text, "strategy") || containsAny(lower, []string{"策略", "strategy", "选币", "止盈", "止损", "杠杆", "风控", "risk control"}) { + return "strategy" + } + if hasExplicitManagementDomainCue(text, "trader") || containsAny(lower, []string{"交易员", "trader", "启动", "停止交易员", "扫描间隔", "竞技场"}) { + return "trader" + } + if containsAny(lower, []string{"余额", "资产", "仓位", "持仓", "订单", "成交", "交易历史", "balance", "position", "positions", "trade history", "account"}) { + return "account" + } + if containsAny(lower, []string{"行情", "价格", "k线", "kline", "market", "price", "btc", "eth", "sol", "usdt", "股票", "stock"}) { + return "market" + } + return "general" +} + +func plannerToolNamesForDomain(domain string) []string { + switch domain { + case "market": + return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock"} + case "account": + return []string{"get_balance", "get_positions", "get_trade_history"} + case "trader": + return []string{"get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"} + case "model": + return []string{"get_model_configs", "manage_model_config"} + case "exchange": + return []string{"get_exchange_configs", "manage_exchange_config"} + case "strategy": + return []string{"get_strategies", "manage_strategy"} + case "diagnosis": + return []string{"get_backend_logs", "get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"} + default: + return []string{ + "get_preferences", "manage_preferences", + "get_backend_logs", + "get_exchange_configs", "manage_exchange_config", + "get_model_configs", "manage_model_config", + "get_strategies", "manage_strategy", + "manage_trader", + "get_balance", "get_positions", "get_trade_history", + "get_market_snapshot", "get_market_price", "get_kline", "search_stock", + } + } +} + +func toolsByName(names []string, compactStrategy bool) []mcp.Tool { + if len(names) == 0 { + return nil + } + byName := make(map[string]mcp.Tool, len(cachedTools)) + for _, tool := range cachedTools { + byName[tool.Function.Name] = tool + } + out := make([]mcp.Tool, 0, len(names)) + seen := make(map[string]bool, len(names)) + for _, name := range names { + if seen[name] { + continue + } + seen[name] = true + tool, ok := byName[name] + if !ok { + continue + } + if compactStrategy && name == "manage_strategy" { + tool = compactManageStrategyTool(tool) + } + out = append(out, tool) + } + return out +} + +func compactManageStrategyTool(tool mcp.Tool) mcp.Tool { + tool.Function.Description = "List, query, delete, activate, duplicate, create, or update strategy templates. Planning schema is compact; use action plus strategy_id/name/description/lang/is_public/config_visible, and include config only when the user explicitly provides strategy config fields." + tool.Function.Parameters = map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"list", "create", "update", "delete", "activate", "duplicate", "get_default_config"}}, + "strategy_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "lang": map[string]any{"type": "string", "enum": []string{"zh", "en"}}, + "is_public": map[string]any{"type": "boolean"}, + "config_visible": map[string]any{"type": "boolean"}, + "config": map[string]any{"type": "object", "description": "Strategy config patch. Use precise StrategyConfig field paths/objects from the user request; grid risk fields such as max_drawdown_pct, stop_loss_pct, and daily_loss_limit_pct belong under grid_config. Omit when listing/querying/deleting/activating/duplicating."}, + }, + "required": []string{"action"}, + } + return tool +} + +func looksLikeStrategyMutationIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + return hasExplicitManagementDomainCue(text, "strategy") && + containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "修改", "更新", "编辑", "调整", "配置", "create", "new", "update", "edit", "configure"}) +} + +type strategyConfigPatchValidation struct { + Config map[string]any + ChangedFields []string + UnchangedDefaults []string + RejectedFields []string +} + +func validateStrategyConfigPatch(config map[string]any) strategyConfigPatchValidation { + out := strategyConfigPatchValidation{ + Config: map[string]any{}, + } + if len(config) == 0 { + out.UnchangedDefaults = defaultStrategyConfigSections() + return out + } + schema := strategyConfigSchema() + props, _ := schema["properties"].(map[string]any) + for key, value := range config { + key = strings.TrimSpace(key) + if key == "" { + continue + } + prop, ok := props[key] + if !ok { + out.RejectedFields = append(out.RejectedFields, key+" (not in current strategy config)") + continue + } + cleaned, changed, rejected := sanitizeStrategyConfigValue(key, value, prop) + out.RejectedFields = append(out.RejectedFields, rejected...) + if len(changed) == 0 { + continue + } + out.Config[key] = cleaned + out.ChangedFields = append(out.ChangedFields, changed...) + } + out.UnchangedDefaults = unchangedStrategyDefaults(out.ChangedFields) + sort.Strings(out.ChangedFields) + sort.Strings(out.UnchangedDefaults) + sort.Strings(out.RejectedFields) + return out +} + +func sanitizeStrategyConfigValue(path string, value any, schema any) (any, []string, []string) { + schemaMap, _ := schema.(map[string]any) + if schemaMap == nil { + return value, []string{path}, nil + } + if strings.EqualFold(strings.TrimSpace(fmt.Sprint(schemaMap["type"])), "object") { + props, _ := schemaMap["properties"].(map[string]any) + if len(props) == 0 { + return value, []string{path}, nil + } + valueMap, ok := value.(map[string]any) + if !ok { + if typed, ok := value.(map[string]string); ok { + valueMap = make(map[string]any, len(typed)) + for k, v := range typed { + valueMap[k] = v + } + ok = true + } + } + if !ok { + return nil, nil, []string{path + " (expected object)"} + } + out := make(map[string]any, len(valueMap)) + var changed []string + var rejected []string + for key, nestedValue := range valueMap { + key = strings.TrimSpace(key) + if key == "" { + continue + } + nestedPath := path + "." + key + prop, ok := props[key] + if !ok { + rejected = append(rejected, nestedPath+" (not in current strategy config)") + continue + } + cleaned, nestedChanged, nestedRejected := sanitizeStrategyConfigValue(nestedPath, nestedValue, prop) + rejected = append(rejected, nestedRejected...) + if len(nestedChanged) == 0 { + continue + } + out[key] = cleaned + changed = append(changed, nestedChanged...) + } + if len(out) == 0 { + return nil, nil, rejected + } + return out, changed, rejected + } + return value, []string{path}, nil +} + +func defaultStrategyConfigSections() []string { + return []string{"strategy_type", "language", "coin_source", "indicators", "custom_prompt", "risk_control", "prompt_sections", "grid_config"} +} + +func unchangedStrategyDefaults(changedFields []string) []string { + changedTop := make(map[string]bool, len(changedFields)) + for _, field := range changedFields { + top := strings.TrimSpace(field) + if idx := strings.Index(top, "."); idx >= 0 { + top = top[:idx] + } + if top != "" { + changedTop[top] = true + } + } + out := make([]string, 0, len(defaultStrategyConfigSections())) + for _, section := range defaultStrategyConfigSections() { + if !changedTop[section] { + out = append(out, section) + } + } + return out +} + func normalizedEntityName(value string) string { return strings.ToLower(strings.TrimSpace(value)) } @@ -1775,14 +2012,15 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) } + validation := validateStrategyConfigPatch(args.Config) if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang)) var cfg any = defaultConfig var warnings []string - if len(args.Config) > 0 { - merged, err := store.MergeStrategyConfig(defaultConfig, args.Config) + if len(validation.Config) > 0 { + merged, err := store.MergeStrategyConfig(defaultConfig, validation.Config) if err != nil { return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) } @@ -1813,10 +2051,14 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err) } payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "create", - "strategy": safeStrategyForTool(record), - "warnings": warnings, + "status": "ok", + "action": "create", + "created_strategy_id": record.ID, + "strategy": safeStrategyForTool(record), + "changed_fields": validation.ChangedFields, + "unchanged_defaults": validation.UnchangedDefaults, + "rejected_fields": validation.RejectedFields, + "warnings": warnings, }) return string(payload) case "update": @@ -1827,6 +2069,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) } + validation := validateStrategyConfigPatch(args.Config) existing, err := a.store.Strategy().Get(storeUserID, strategyID) if err != nil { return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err) @@ -1855,16 +2098,29 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if args.ConfigVisible != nil { configVisible = *args.ConfigVisible } + metadataChanged := make([]string, 0, 4) + if !sameEntityName(name, existing.Name) { + metadataChanged = append(metadataChanged, "name") + } + if description != existing.Description { + metadataChanged = append(metadataChanged, "description") + } + if isPublic != existing.IsPublic { + metadataChanged = append(metadataChanged, "is_public") + } + if configVisible != existing.ConfigVisible { + metadataChanged = append(metadataChanged, "config_visible") + } configJSON := existing.Config var warnings []string - if len(args.Config) > 0 { + if len(validation.Config) > 0 { var existingConfig store.StrategyConfig if strings.TrimSpace(existing.Config) != "" { if err := json.Unmarshal([]byte(existing.Config), &existingConfig); err != nil { return fmt.Sprintf(`{"error":"failed to load existing strategy config: %s"}`, err) } } - merged, err := store.MergeStrategyConfig(existingConfig, args.Config) + merged, err := store.MergeStrategyConfig(existingConfig, validation.Config) if err != nil { return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) } @@ -1896,11 +2152,18 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if err != nil { return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err) } + changedFields := append([]string{}, metadataChanged...) + changedFields = append(changedFields, validation.ChangedFields...) + sort.Strings(changedFields) payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "update", - "strategy": safeStrategyForTool(updated), - "warnings": warnings, + "status": "ok", + "action": "update", + "strategy_id": updated.ID, + "strategy": safeStrategyForTool(updated), + "changed_fields": changedFields, + "unchanged_defaults": validation.UnchangedDefaults, + "rejected_fields": validation.RejectedFields, + "warnings": warnings, }) return string(payload) case "delete": diff --git a/agent/unified_turn_router_test.go b/agent/unified_turn_router_test.go index 79edc1d3..214e22e0 100644 --- a/agent/unified_turn_router_test.go +++ b/agent/unified_turn_router_test.go @@ -34,6 +34,53 @@ func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) { } } +func TestParseUnifiedTurnDecisionAcceptsSkillTaskList(t *testing.T) { + raw := `{ + "topic_intent": "start_new", + "business_action": "skill_tasks", + "context_mode": "fresh_context", + "tasks": [ + {"id":"task_1","skill":"strategy_management","action":"create","request":"创建高频交易策略","depends_on":[]}, + {"id":"task_2","skill":"trader_management","action":"configure_strategy","request":"绑定到交易员","depends_on":["task_1"]} + ], + "confidence": 0.86 + }` + + decision, err := parseUnifiedTurnDecision(raw) + if err != nil { + t.Fatalf("parse unified decision: %v", err) + } + if decision.BusinessAction != "skill_tasks" { + t.Fatalf("expected skill_tasks, got %q", decision.BusinessAction) + } + if len(decision.Tasks) != 2 { + t.Fatalf("expected 2 tasks, got %+v", decision.Tasks) + } + if decision.Tasks[0].Skill != "strategy_management" || decision.Tasks[0].Action != "create" { + t.Fatalf("unexpected first task: %+v", decision.Tasks[0]) + } + if !decision.reliable() { + t.Fatalf("expected task-list decision to be reliable: %+v", decision) + } +} + +func TestUnifiedTurnDecisionNewSkillCanUseSingleTask(t *testing.T) { + decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{ + TopicIntent: "start_new", + BusinessAction: "new_skill", + ContextMode: "fresh_context", + Tasks: []WorkflowTask{{ + Skill: "strategy_management", + Action: "create", + Request: "创建高频交易策略", + }}, + Confidence: 0.9, + }) + if !decision.reliable() { + t.Fatalf("expected new_skill with task list to be reliable: %+v", decision) + } +} + func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) { lowConfidence := unifiedTurnDecision{ TopicIntent: "start_new", @@ -99,6 +146,8 @@ func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) { "context_mode values", "fresh_context", "downstream modules", + "tasks format", + "skill_tasks", } { if !strings.Contains(systemPrompt, want) { t.Fatalf("expected system prompt to contain %q", want)