From 3619f827969cba5e2bce1f95770f480c697551c6 Mon Sep 17 00:00:00 2001 From: lky-spec Date: Tue, 28 Apr 2026 15:53:53 +0800 Subject: [PATCH] Revert "Trim agent planning tools and validate strategy patches" This reverts commit fe0dbce367cb715769bf6d45a972cfe436cfc521. --- agent/config_visibility_test.go | 49 ----- agent/llm_flow_extractor.go | 6 +- agent/llm_skill_router.go | 65 +------ agent/planner_runtime.go | 4 +- agent/planner_tools_test.go | 84 --------- agent/skill_execution_handlers.go | 40 ----- agent/skill_outcome.go | 2 - agent/tools.go | 287 ++---------------------------- agent/unified_turn_router_test.go | 49 ----- 9 files changed, 20 insertions(+), 566 deletions(-) delete mode 100644 agent/planner_tools_test.go diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go index c0adc875..a9b41485 100644 --- a/agent/config_visibility_test.go +++ b/agent/config_visibility_test.go @@ -246,55 +246,6 @@ 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 79a66987..15ce752d 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", strategyConfigPatchFieldDescription(lang), false) + 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) } if session.Action == "update_prompt" { add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false) @@ -270,10 +270,6 @@ 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 5dfc27f5..829ae300 100644 --- a/agent/llm_skill_router.go +++ b/agent/llm_skill_router.go @@ -20,7 +20,6 @@ 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"` @@ -118,7 +117,6 @@ 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{} } @@ -136,7 +134,7 @@ func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecis decision.TopicIntent = "" } switch decision.BusinessAction { - case "direct_answer", "new_skill", "skill_tasks", "continue_skill", "planned_agent", "none": + case "direct_answer", "new_skill", "continue_skill", "planned_agent", "none": default: decision.BusinessAction = "" } @@ -159,13 +157,8 @@ 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": @@ -241,20 +234,12 @@ topic_intent values: business_action values: - "direct_answer": reply_to_user is the final answer; do not change state -- "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 +- "new_skill": start a management/diagnosis skill; target_skill is required - "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 -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: +target_skill format for new_skill: skill_name:action, for example "trader_management:create". Available skills: trader_management, exchange_management, model_management, strategy_management, @@ -277,9 +262,7 @@ 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 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 new_skill for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy. - 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. @@ -288,7 +271,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|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}`) +{"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}`) 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, @@ -351,9 +334,6 @@ 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 @@ -371,8 +351,6 @@ 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 { @@ -395,39 +373,6 @@ 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 3a8401da..198d6ec4 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(plannerToolsForText(state.Goal)) + toolDefs, _ := json.Marshal(agentTools()) 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(plannerToolsForText(userText)) + toolDefs, _ := json.Marshal(agentTools()) 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 deleted file mode 100644 index 955efee2..00000000 --- a/agent/planner_tools_test.go +++ /dev/null @@ -1,84 +0,0 @@ -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 33779fae..3659dfba 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -2836,52 +2836,12 @@ 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 383ef010..8922ad2d 100644 --- a/agent/skill_outcome.go +++ b/agent/skill_outcome.go @@ -152,8 +152,6 @@ 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 e381451d..c1705385 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -43,243 +43,6 @@ 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)) } @@ -2012,15 +1775,14 @@ 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(validation.Config) > 0 { - merged, err := store.MergeStrategyConfig(defaultConfig, validation.Config) + if len(args.Config) > 0 { + merged, err := store.MergeStrategyConfig(defaultConfig, args.Config) if err != nil { return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) } @@ -2051,14 +1813,10 @@ 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", - "created_strategy_id": record.ID, - "strategy": safeStrategyForTool(record), - "changed_fields": validation.ChangedFields, - "unchanged_defaults": validation.UnchangedDefaults, - "rejected_fields": validation.RejectedFields, - "warnings": warnings, + "status": "ok", + "action": "create", + "strategy": safeStrategyForTool(record), + "warnings": warnings, }) return string(payload) case "update": @@ -2069,7 +1827,6 @@ 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) @@ -2098,29 +1855,16 @@ 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(validation.Config) > 0 { + if len(args.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, validation.Config) + merged, err := store.MergeStrategyConfig(existingConfig, args.Config) if err != nil { return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) } @@ -2152,18 +1896,11 @@ 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_id": updated.ID, - "strategy": safeStrategyForTool(updated), - "changed_fields": changedFields, - "unchanged_defaults": validation.UnchangedDefaults, - "rejected_fields": validation.RejectedFields, - "warnings": warnings, + "status": "ok", + "action": "update", + "strategy": safeStrategyForTool(updated), + "warnings": warnings, }) return string(payload) case "delete": diff --git a/agent/unified_turn_router_test.go b/agent/unified_turn_router_test.go index 214e22e0..79edc1d3 100644 --- a/agent/unified_turn_router_test.go +++ b/agent/unified_turn_router_test.go @@ -34,53 +34,6 @@ 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", @@ -146,8 +99,6 @@ 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)