Require explicit agent mutation targets

This commit is contained in:
lky-spec
2026-04-26 22:38:16 +08:00
parent ce3a8582af
commit e8eafce1e0
6 changed files with 157 additions and 62 deletions

View File

@@ -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. - 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"}. - 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". - 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. - 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". - 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. - 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 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. - 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 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. - 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. - If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.

View File

@@ -1123,24 +1123,6 @@ func traderCreateFieldsFromExecutionExtraction(result executionFlowExtractionRes
return fields 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) { func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID int64, text string, state ExecutionState, extraction executionFlowExtractionResult) (skillSession, bool) {
skillName, action := inferExecutionStateSkillBridge(state, text) skillName, action := inferExecutionStateSkillBridge(state, text)
if a == nil || skillName == "" || action == "" || !hasSkillBridgeSignal(a, storeUserID, skillName, action, text, extraction) { 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 { switch skillName {
case "trader_management": case "trader_management":
if action != "create" && session.TargetRef == nil {
session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "trader_management"))
}
a.hydrateCreateTraderSlotReferences(storeUserID, &session) 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.saveSkillSession(userID, session)
a.clearExecutionState(userID) a.clearExecutionState(userID)

View File

@@ -1029,9 +1029,6 @@ func resolveTargetSelection(text string, options []traderSkillOption, existing *
if opt := findUniqueContainingOption(options, text); opt != nil { if opt := findUniqueContainingOption(options, text); opt != nil {
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}} 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 { if len(options) > 1 {
return targetResolution{Ambiguous: options} return targetResolution{Ambiguous: options}
} }

View File

@@ -2591,7 +2591,25 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN) return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN)
} }
setSkillDAGStep(&session, "execute_update") 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") == "" { if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {

View File

@@ -264,25 +264,6 @@ func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, sessi
return resources 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) { func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
if session.Name != "trader_management" || session.Action == "" { if session.Name != "trader_management" || session.Action == "" {
return "", false 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 { if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
session.TargetRef = resolved.Ref session.TargetRef = resolved.Ref
} }
if session.TargetRef == nil {
session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName)
}
if session.TargetRef == nil { if session.TargetRef == nil {
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") { if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
setSkillDAGStep(&session, "resolve_target") 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 { if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
session.TargetRef = resolved.Ref 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" { if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
a.saveSkillSession(userID, session) a.saveSkillSession(userID, session)
label := formatOptionList("可选对象:", options) label := formatOptionList("可选对象:", options)

View File

@@ -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) { func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db") dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db")
st, err := store.New(dbPath) st, err := store.New(dbPath)