diff --git a/agent/central_brain.go b/agent/central_brain.go index f45706b3..052b383f 100644 --- a/agent/central_brain.go +++ b/agent/central_brain.go @@ -93,6 +93,7 @@ Rules: - 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". @@ -418,6 +419,7 @@ Rules: - 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 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. Example: "BTC趋势做空" should set coin_source to static BTCUSDT and add prompt/risk/entry rules for BTC trend-following short bias. - If there are multiple targets and the user did not disambiguate, ask a natural question with the available names. diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 01fbf992..9a68b9b5 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -1123,24 +1123,6 @@ func traderCreateFieldsFromExecutionExtraction(result executionFlowExtractionRes return fields } -func executionStateCurrentReference(state ExecutionState, skillName string) *EntityReference { - if state.CurrentReferences == nil { - return nil - } - switch skillName { - case "trader_management": - return state.CurrentReferences.Trader - case "model_management": - return state.CurrentReferences.Model - case "exchange_management": - return state.CurrentReferences.Exchange - case "strategy_management": - return state.CurrentReferences.Strategy - default: - return nil - } -} - func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID int64, text string, state ExecutionState, extraction executionFlowExtractionResult) (skillSession, bool) { skillName, action := inferExecutionStateSkillBridge(state, text) if a == nil || skillName == "" || action == "" || !hasSkillBridgeSignal(a, storeUserID, skillName, action, text, extraction) { @@ -1176,22 +1158,7 @@ func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID in switch skillName { case "trader_management": - if action != "create" && session.TargetRef == nil { - session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "trader_management")) - } a.hydrateCreateTraderSlotReferences(storeUserID, &session) - case "model_management": - if session.TargetRef == nil && action != "create" { - session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "model_management")) - } - case "exchange_management": - if session.TargetRef == nil && action != "create" { - session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "exchange_management")) - } - case "strategy_management": - if session.TargetRef == nil && action != "create" { - session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "strategy_management")) - } } a.saveSkillSession(userID, session) a.clearExecutionState(userID) diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go index cc5dd8d6..4534fa18 100644 --- a/agent/skill_dispatcher.go +++ b/agent/skill_dispatcher.go @@ -1029,9 +1029,6 @@ func resolveTargetSelection(text string, options []traderSkillOption, existing * if opt := findUniqueContainingOption(options, text); opt != nil { return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}} } - if len(options) == 1 { - return targetResolution{Ref: &EntityReference{ID: options[0].ID, Name: options[0].Name}} - } if len(options) > 1 { return targetResolution{Ambiguous: options} } diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go index 94e78e8c..3659dfba 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -2591,7 +2591,25 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN) } setSkillDAGStep(&session, "execute_update") - return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, merged, msgZH, msgEN) + raw, _ := json.Marshal(map[string]any{ + "action": "update", + "strategy_id": strategy.ID, + "config": patch, + "allow_clamped_update": true, + }) + resp := a.toolManageStrategy(storeUserID, string(raw)) + a.clearSkillSession(userID) + if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { + if lang == "zh" { + return "这次没改成功:" + errMsg + } + return "That change did not go through: " + errMsg + } + a.rememberReferencesFromToolResult(userID, "manage_strategy", resp) + if lang == "zh" { + return msgZH + } + return msgEN } if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" { diff --git a/agent/skill_management_handlers.go b/agent/skill_management_handlers.go index fb616c8b..4f32cb8d 100644 --- a/agent/skill_management_handlers.go +++ b/agent/skill_management_handlers.go @@ -264,25 +264,6 @@ func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, sessi return resources } -func (a *Agent) inferredCurrentReferenceForSkill(userID int64, skillName string) *EntityReference { - refs := a.semanticCurrentReferences(userID) - if refs == nil { - return nil - } - switch skillName { - case "trader_management": - return normalizeEntityReference(refs.Trader) - case "exchange_management": - return normalizeEntityReference(refs.Exchange) - case "model_management": - return normalizeEntityReference(refs.Model) - case "strategy_management": - return normalizeEntityReference(refs.Strategy) - default: - return nil - } -} - func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { if session.Name != "trader_management" || session.Action == "" { return "", false @@ -1693,9 +1674,6 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { session.TargetRef = resolved.Ref } - if session.TargetRef == nil { - session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) - } if session.TargetRef == nil { if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") { setSkillDAGStep(&session, "resolve_target") @@ -1727,9 +1705,6 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { session.TargetRef = resolved.Ref } - if session.TargetRef == nil { - session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) - } if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" { a.saveSkillSession(userID, session) label := formatOptionList("可选对象:", options) diff --git a/agent/trader_scope_test.go b/agent/trader_scope_test.go index a52633da..eaa21a67 100644 --- a/agent/trader_scope_test.go +++ b/agent/trader_scope_test.go @@ -251,6 +251,142 @@ func TestResolveTargetSelectionMatchesUniqueNameInUserText(t *testing.T) { } } +func TestStrategyUpdateUsesExplicitTargetOverCurrentReference(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "strategy-explicit-target-over-current.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + userID := int64(99) + + cfg := store.GetDefaultStrategyConfig("zh") + rawCfg, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal strategy config: %v", err) + } + for _, strategy := range []*store.Strategy{ + {ID: "strategy-short", UserID: "default", Name: "BTC趋势做空", ConfigVisible: true, Config: string(rawCfg)}, + {ID: "strategy-long", UserID: "default", Name: "AI500 做多策略", ConfigVisible: true, Config: string(rawCfg)}, + } { + if err := st.Strategy().Create(strategy); err != nil { + t.Fatalf("seed strategy %s: %v", strategy.ID, err) + } + } + a.saveReferenceMemory(userID, &CurrentReferences{ + Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"}, + }, nil) + + patch := map[string]any{ + "coin_source": map[string]any{ + "source_type": "ai500", + "use_ai500": true, + "ai500_limit": 5, + }, + "custom_prompt": "AI500 强做多策略:只寻找强趋势多头机会。", + } + rawPatch, _ := json.Marshal(patch) + session := skillSession{ + Name: "strategy_management", + Action: "update_config", + Phase: "collecting", + Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)}, + } + + reply, handled := a.handleSimpleEntitySkill( + "default", + userID, + "zh", + "我想基于AI500 做多策略来调整成更强的做多逻辑", + session, + "strategy_management", + "update_config", + a.loadStrategyOptions("default"), + ) + if !handled { + t.Fatalf("expected handler to handle request") + } + if !strings.Contains(reply, "已更新策略配置") { + t.Fatalf("expected strategy update reply, got: %s", reply) + } + + shortStrategy, err := st.Strategy().Get("default", "strategy-short") + if err != nil { + t.Fatalf("load short strategy: %v", err) + } + longStrategy, err := st.Strategy().Get("default", "strategy-long") + if err != nil { + t.Fatalf("load long strategy: %v", err) + } + if strings.Contains(shortStrategy.Config, "强做多") { + t.Fatalf("current reference strategy was incorrectly updated: %s", shortStrategy.Config) + } + if !strings.Contains(longStrategy.Config, "强做多") { + t.Fatalf("explicitly named strategy was not updated: %s", longStrategy.Config) + } +} + +func TestStrategyUpdateDoesNotInferTargetFromCurrentReference(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "strategy-no-current-reference-fallback.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + userID := int64(100) + + cfg := store.GetDefaultStrategyConfig("zh") + rawCfg, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal strategy config: %v", err) + } + if err := st.Strategy().Create(&store.Strategy{ + ID: "strategy-short", + UserID: "default", + Name: "BTC趋势做空", + ConfigVisible: true, + Config: string(rawCfg), + }); err != nil { + t.Fatalf("seed strategy: %v", err) + } + a.saveReferenceMemory(userID, &CurrentReferences{ + Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"}, + }, nil) + + patch := map[string]any{"custom_prompt": "不应被写入"} + rawPatch, _ := json.Marshal(patch) + session := skillSession{ + Name: "strategy_management", + Action: "update_config", + Phase: "collecting", + Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)}, + } + + reply, handled := a.handleSimpleEntitySkill( + "default", + userID, + "zh", + "帮我把策略改强一点", + session, + "strategy_management", + "update_config", + a.loadStrategyOptions("default"), + ) + if !handled { + t.Fatalf("expected handler to ask for target") + } + if !strings.Contains(reply, "确定目标对象") && !strings.Contains(reply, "明确要操作的是哪一个对象") { + t.Fatalf("expected target clarification, got: %s", reply) + } + strategy, err := st.Strategy().Get("default", "strategy-short") + if err != nil { + t.Fatalf("load strategy: %v", err) + } + if strings.Contains(strategy.Config, "不应被写入") { + t.Fatalf("strategy was incorrectly updated through current reference fallback: %s", strategy.Config) + } +} + func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db") st, err := store.New(dbPath)