mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Require explicit agent mutation targets
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user