diff --git a/agent/agent.go b/agent/agent.go index 80a0f8d5..123ae547 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -868,16 +868,33 @@ func aiServiceFailureGuidance(lang, reason string) string { strings.Contains(lower, "unexpected character '<'") || strings.Contains(lower, " 0 { + fieldSpecsJSON, _ := json.Marshal(fieldSpecs) + sb.WriteString(fmt.Sprintf("allowed_field_spec_json: %s\n", fieldSpecsJSON)) + } } else { sb.WriteString("none\n") } @@ -234,6 +240,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us } session := newActiveSkillSession(userID, skill, action) session.Goal = strings.TrimSpace(text) + d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang) mergeExtractedData(&session, d.ExtractedData) return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) @@ -241,6 +248,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us if !hasActive { return "", false, nil } + d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang) mergeExtractedData(&activeSession, d.ExtractedData) return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent) @@ -366,6 +374,8 @@ func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, u resourcesJSON, _ := json.Marshal(resources) collectedJSON, _ := json.Marshal(session.CollectedFields) missingSummary := formatConversationMissingFields(lang, missingRequiredFieldsForBrain(session)) + fieldSpecs := allowedFieldSpecsForSkillSession(legacy, lang) + fieldSpecsJSON, _ := json.Marshal(fieldSpecs) localHistory := formatActiveSessionLocalHistory(session.LocalHistory) if localHistory == "" { localHistory = "(empty)" @@ -391,6 +401,9 @@ Current missing field summary: Relevant disclosed resources: %s +Allowed field spec JSON: +%s + Domain knowledge: %s @@ -406,6 +419,8 @@ Rules: - 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 there are multiple targets and the user did not disambiguate, ask a natural question with the available names. - If the current user message answers a missing field directly, extract it and continue. +- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys. +- If a user-provided value does not fit one of those canonical keys, omit it; never create another key. - If this task is already done and the best next step is just to tell the user the result, choose "finish_task". - If the user aborts the task, choose "cancel_task". @@ -417,6 +432,7 @@ Return JSON with this exact shape: defaultIfEmpty(string(collectedJSON), "{}"), missingSummary, defaultIfEmpty(string(resourcesJSON), "{}"), + defaultIfEmpty(string(fieldSpecsJSON), "[]"), defaultIfEmpty(domainPrimer, "(none)"), )) userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nPrevious assistant reply:\n%s\n\nActive task local history:\n%s\n", lang, text, defaultIfEmpty(previousAssistantReply, "(empty)"), localHistory) @@ -434,7 +450,12 @@ Return JSON with this exact shape: if err != nil { return activeSessionStepDecision{}, false } - return parseActiveSessionStepDecision(raw) + decision, ok := parseActiveSessionStepDecision(raw) + if !ok { + return activeSessionStepDecision{}, false + } + decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) + return decision, true } func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) { @@ -662,6 +683,38 @@ func mergeExtractedData(s *ActiveSkillSession, data map[string]any) { } } +func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[string]any, lang string) map[string]any { + if len(data) == 0 { + return data + } + specs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(session), lang) + if len(specs) == 0 { + return nil + } + allowed := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + key := strings.TrimSpace(spec.Key) + if key != "" { + allowed[key] = struct{}{} + } + } + out := make(map[string]any, len(data)) + for key, value := range data { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := allowed[key]; !ok { + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + func emitBrainReply(onEvent func(event, data string), reply string) { if onEvent == nil || reply == "" { return diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go index a39f2628..638dd9bc 100644 --- a/agent/config_visibility_test.go +++ b/agent/config_visibility_test.go @@ -87,6 +87,58 @@ func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) { } } +func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-create-enabled.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"binance","account_name":"Binance Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`) + if strings.Contains(resp, `"error"`) { + t.Fatalf("expected create to succeed, got: %s", resp) + } + + exchanges, err := st.Exchange().List("default") + if err != nil { + t.Fatalf("list exchanges: %v", err) + } + if len(exchanges) != 1 || exchanges[0] == nil { + t.Fatalf("expected one created exchange, got %#v", exchanges) + } + if !exchanges[0].Enabled { + t.Fatalf("expected agent-created exchange to default to enabled so it matches manual creation") + } +} + +func TestToolManageExchangeConfigUpdateAutoEnablesWhenConfigBecomesComplete(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-update-auto-enable.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + exchangeID, err := st.Exchange().Create("default", "okx", "OKX Main", false, "api-test-123456", "secret-test-123456", "", false, "", false, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed incomplete exchange: %v", err) + } + + resp := a.toolManageExchangeConfig("default", `{"action":"update","exchange_id":"`+exchangeID+`","passphrase":"passphrase-123456"}`) + if strings.Contains(resp, `"error"`) { + t.Fatalf("expected update to succeed, got: %s", resp) + } + + updated, err := st.Exchange().GetByID("default", exchangeID) + if err != nil { + t.Fatalf("reload exchange: %v", err) + } + if !updated.Enabled { + t.Fatalf("expected completed exchange config to auto-enable after update") + } +} + func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "visibility-list.db") st, err := store.New(dbPath) @@ -155,6 +207,50 @@ func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing. } } +func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "strategy-fixed-min-position.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + cfg := store.GetDefaultStrategyConfig("zh") + rawCfg, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal strategy config: %v", err) + } + strategy := &store.Strategy{ + ID: "strategy-fixed-min-position", + UserID: "default", + Name: "固定最小开仓策略", + Description: "test", + IsPublic: false, + ConfigVisible: true, + Config: string(rawCfg), + } + if err := st.Strategy().Create(strategy); err != nil { + t.Fatalf("create strategy: %v", err) + } + + resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-fixed-min-position","config":{"risk_control":{"min_position_size":20}}}`) + if !strings.Contains(resp, "固定值 12 USDT") { + t.Fatalf("expected fixed min position size rejection, got: %s", resp) + } + + updated, err := st.Strategy().Get("default", strategy.ID) + if err != nil { + t.Fatalf("reload strategy: %v", err) + } + parsed, err := updated.ParseConfig() + if err != nil { + t.Fatalf("parse updated strategy config: %v", err) + } + if parsed.RiskControl.MinPositionSize != 12 { + t.Fatalf("expected stored min position size to remain fixed at 12, got %v", parsed.RiskControl.MinPositionSize) + } +} + func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "exchange-options.db") st, err := store.New(dbPath) @@ -246,9 +342,12 @@ func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) t.Fatalf("expected field label %q in summary, got: %s", expected, summary) } } + if strings.Contains(summary, "最小开仓金额") { + t.Fatalf("strategy field summary should not expose fixed min position size editing: %s", summary) + } } -func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testing.T) { +func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db") st, err := store.New(dbPath) if err != nil { @@ -257,13 +356,118 @@ func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testin a := New(nil, st, DefaultConfig(), slog.Default()) summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update") - for _, expected := range []string{"名称", "交易所", "模型", "策略", "扫描间隔"} { + for _, expected := range []string{"交易所", "模型", "策略", "扫描间隔", "全仓模式", "竞技场显示"} { if !strings.Contains(summary, expected) { t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary) } } - if strings.Contains(summary, "初始资金") || strings.Contains(summary, "初始余额") { - t.Fatalf("trader field summary should not expose manual balance editing: %s", summary) + for _, unexpected := range []string{"名称", "初始资金", "初始余额", "杠杆", "交易对", "Prompt", "AI500", "OI Top"} { + if strings.Contains(summary, unexpected) { + t.Fatalf("trader field summary should stay within manual panel fields, got: %s", summary) + } + } +} + +func TestToolUpdateTraderRejectsRenameOutsideManualPanel(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-update-reject-rename.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed model: %v", err) + } + exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed exchange: %v", err) + } + 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-trader-rename", + UserID: "default", + Name: "Rename Strategy", + Description: "test", + IsPublic: false, + ConfigVisible: true, + Config: string(rawCfg), + }); err != nil { + t.Fatalf("seed strategy: %v", err) + } + if err := st.Trader().Create(&store.Trader{ + ID: "trader-rename", + UserID: "default", + Name: "原交易员", + AIModelID: "default_deepseek", + ExchangeID: exchangeID, + StrategyID: "strategy-trader-rename", + InitialBalance: 1000, + ScanIntervalMinutes: 5, + IsCrossMargin: true, + ShowInCompetition: true, + }); err != nil { + t.Fatalf("seed trader: %v", err) + } + + resp := a.toolManageTrader("default", `{"action":"update","trader_id":"trader-rename","name":"新名字"}`) + if !strings.Contains(resp, "trader rename is not supported here") { + t.Fatalf("expected rename rejection, got: %s", resp) + } +} + +func TestToolCreateTraderResponseHidesLegacyTraderTuningFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-create-response-shape.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed model: %v", err) + } + exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed exchange: %v", err) + } + 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-trader-shape", + UserID: "default", + Name: "Shape Strategy", + Description: "test", + IsPublic: false, + ConfigVisible: true, + Config: string(rawCfg), + }); err != nil { + t.Fatalf("seed strategy: %v", err) + } + + originalFetcher := traderInitialBalanceFetcher + traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) { + return 88.5, true, nil + } + defer func() { + traderInitialBalanceFetcher = originalFetcher + }() + + resp := a.toolManageTrader("default", `{"action":"create","name":"形状测试","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-trader-shape"}`) + if strings.Contains(resp, `"error"`) { + t.Fatalf("expected trader create to succeed, got: %s", resp) + } + for _, blocked := range []string{"btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "system_prompt_template"} { + if strings.Contains(resp, blocked) { + t.Fatalf("expected trader create response to hide legacy tuning field %q, got: %s", blocked, resp) + } } } diff --git a/agent/entity_field_catalog.go b/agent/entity_field_catalog.go index 4c95d0e0..7cc4712f 100644 --- a/agent/entity_field_catalog.go +++ b/agent/entity_field_catalog.go @@ -9,21 +9,12 @@ type entityFieldMeta struct { } var traderFieldCatalog = []entityFieldMeta{ - {Key: "name", Keywords: []string{"改名", "重命名", "rename", "名字"}, ValueType: "name", ManualEditable: true, AgentUpdatable: true}, {Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, {Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, {Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, {Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true}, {Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, {Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, - {Key: "btc_eth_leverage", Keywords: []string{"btc/eth杠杆", "主流币杠杆", "btc eth leverage"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true}, - {Key: "altcoin_leverage", Keywords: []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true}, - {Key: "trading_symbols", Keywords: []string{"交易对", "symbols", "币种"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true}, - {Key: "custom_prompt", Keywords: []string{"自定义prompt", "custom prompt", "提示词"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true}, - {Key: "override_base_prompt", Keywords: []string{"覆盖基础提示词", "override base prompt"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, - {Key: "system_prompt_template", Keywords: []string{"提示词模板", "system prompt template", "prompt template"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true}, - {Key: "use_ai500", Keywords: []string{"ai500"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, - {Key: "use_oi_top", Keywords: []string{"oi top", "持仓量增长"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, } var modelFieldCatalog = []entityFieldMeta{ diff --git a/agent/llm_flow_extractor.go b/agent/llm_flow_extractor.go index e58c2aa1..ccf5eb9e 100644 --- a/agent/llm_flow_extractor.go +++ b/agent/llm_flow_extractor.go @@ -45,7 +45,13 @@ Rules: - Prefer "continue" only when the message clearly contributes to the current flow. - Set target_snapshot_id only when the user is clearly referring to one suspended snapshot from Suspended snapshots JSON. - For greetings, thanks, and casual chat, use "instant_reply". -- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个".` +- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个". +- Treat this as semantic slot filling, not keyword copying. +- Users will often speak in natural language, shorthand, colloquial labels, translated labels, or mild misspellings instead of exact schema keys. +- Your job is to decide which allowed canonical field each value belongs to based on the active flow, field descriptions, current missing fields, and conversation context. +- Never require the user to say the exact internal field key. +- In task.fields, always emit the canonical field keys from Allowed field spec JSON, never aliases, paraphrases, or user wording. +- If the user clearly supplied a value for one allowed field, normalize it to that canonical key before returning JSON.` sections := []string{ fmt.Sprintf("Language: %s", lang), @@ -99,6 +105,11 @@ func (a *Agent) extractSkillSessionFieldsWithLLM(ctx context.Context, userID int - This is the structured continuation input for an active NOFXi task flow. - For "continue", return exactly one task for the active skill/action and place extracted field values in task.fields. - Only extract fields from the allowed field spec list. +- Treat Allowed field spec JSON as the canonical output schema. +- If a user-provided value does not fit one of those canonical keys, omit it; never create another key. +- Use field descriptions plus current missing fields to infer the best canonical destination field for each user-provided value. +- When the user supplies a credential, endpoint, name, toggle, or config value in natural language, map it to the most plausible allowed canonical field instead of echoing the user's label. +- Do not return near-match keys, guessed aliases, or raw user labels as JSON keys. - Do not invent values that were not supported by the user message or strong context. - If the user explicitly says "you choose one for me", you may leave that field empty and explain it in reason. - If the active skill dependency summary says the current flow depends on other resource configs, treat dependency repair as continuation of the active flow instead of a new peer task. @@ -122,7 +133,7 @@ Return JSON with this shape: if err != nil { return llmFlowExtractionResult{} } - return parseLLMFlowExtractionResult(raw) + return filterLLMFlowExtractionFields(parseLLMFlowExtractionResult(raw), fieldSpecs) } func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult { @@ -191,6 +202,44 @@ func parseRawFlowExtractionEnvelope(raw string) (llmFlowExtractionResult, bool) return out, out.Intent != "" } +func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFlowFieldSpec) llmFlowExtractionResult { + if len(specs) == 0 { + result.Fields = nil + for i := range result.Tasks { + result.Tasks[i].Fields = nil + } + return result + } + allowed := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + key := strings.TrimSpace(spec.Key) + if key != "" { + allowed[key] = struct{}{} + } + } + filter := func(fields map[string]string) map[string]string { + if len(fields) == 0 { + return fields + } + clean := make(map[string]string, len(fields)) + for key, value := range fields { + if _, ok := allowed[key]; !ok { + continue + } + clean[key] = value + } + if len(clean) == 0 { + return nil + } + return clean + } + result.Fields = filter(result.Fields) + for i := range result.Tasks { + result.Tasks[i].Fields = filter(result.Tasks[i].Fields) + } + return result +} + func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) { currentStep, _ := currentSkillDAGStep(session) fieldSpecs := allowedFieldSpecsForSkillSession(session, lang) @@ -205,6 +254,13 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl *out = append(*out, llmFlowFieldSpec{Key: key, Description: description, Required: required}) } out := make([]llmFlowFieldSpec, 0, 24) + if actionRequiresSlot(session.Name, session.Action, "target_ref") { + add(&out, "target_ref_id", slotDisplayName("target_ref", lang)+" ID", true) + add(&out, "target_ref_name", slotDisplayName("target_ref", lang), true) + } + if supportsBulkTargetSelection(session.Name, session.Action) { + add(&out, "bulk_scope", "bulk deletion scope, use all only when the user clearly requested all targets", false) + } switch session.Name { case "model_management": required := map[string]bool{"provider": true} @@ -472,6 +528,26 @@ func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *sk if value == "" { continue } + switch key { + case "target_ref_id": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.ID = value + if session.TargetRef.Source == "" { + session.TargetRef.Source = "llm_extraction" + } + continue + case "target_ref_name": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.Name = value + if session.TargetRef.Source == "" { + session.TargetRef.Source = "llm_extraction" + } + continue + } switch session.Name { case "model_management": if key == "provider" || key == "name" || key == "custom_model_name" || key == "api_key" || key == "custom_api_url" || key == "enabled" || key == "update_field" { diff --git a/agent/llm_flow_extractor_test.go b/agent/llm_flow_extractor_test.go index d0d1d9e4..e9df9c20 100644 --- a/agent/llm_flow_extractor_test.go +++ b/agent/llm_flow_extractor_test.go @@ -1,42 +1,28 @@ package agent -import "testing" +import ( + "strings" + "testing" +) -func TestSanitizeLLMExtractionForSkillSessionDoesNotInventModelProvider(t *testing.T) { - session := skillSession{Name: "model_management", Action: "create"} - result := llmFlowExtractionResult{ - Intent: "continue", - Tasks: []llmFlowExtractionTask{{ - Skill: "model_management", - Action: "create", - Fields: map[string]string{ - "provider": "claw402", - }, - }}, - } +func TestBuildActiveFlowExtractionPromptRequiresCanonicalFieldOutput(t *testing.T) { + systemPrompt, _ := buildActiveFlowExtractionPrompt( + "zh", + "skill_session", + "Active flow type: skill_session\nSkill: exchange_management\nAction: create", + "secret是abc123456", + "", + nil, + nil, + nil, + ) - sanitized := sanitizeLLMExtractionForSkillSession("新建一个模型", session, result) - if got := sanitized.Tasks[0].Fields["provider"]; got != "" { - t.Fatalf("expected provider guess to be stripped, got %q", got) + for _, want := range []string{ + "Treat this as semantic slot filling, not keyword copying.", + "always emit the canonical field keys from Allowed field spec JSON", + } { + if !strings.Contains(systemPrompt, want) { + t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt) + } } } - -func TestSanitizeLLMExtractionForSkillSessionKeepsExplicitModelProvider(t *testing.T) { - session := skillSession{Name: "model_management", Action: "create"} - result := llmFlowExtractionResult{ - Intent: "continue", - Tasks: []llmFlowExtractionTask{{ - Skill: "model_management", - Action: "create", - Fields: map[string]string{ - "provider": "claw402", - }, - }}, - } - - sanitized := sanitizeLLMExtractionForSkillSession("新建一个 claw402 模型", session, result) - if got := sanitized.Tasks[0].Fields["provider"]; got != "claw402" { - t.Fatalf("expected explicit provider to remain, got %q", got) - } -} - diff --git a/agent/llm_skill_router.go b/agent/llm_skill_router.go index be4bef83..83a168fc 100644 --- a/agent/llm_skill_router.go +++ b/agent/llm_skill_router.go @@ -82,27 +82,44 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI return answer, true, err } + interruptedActiveContext := false if a.hasAnyActiveContext(userID) { a.clearPendingProposalSession(userID) - return a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) { + return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) + } + interruptedActiveContext = true } switch decision.Route { case "workflow": a.clearPendingProposalSession(userID) answer, handled, execErr := a.executeWorkflowDecomposition(ctx, storeUserID, userID, lang, text, workflowDecomposition{Tasks: decision.Tasks}, onEvent) + if interruptedActiveContext { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + } return answer, handled, execErr case "skill": a.clearPendingProposalSession(userID) - return a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent) + answer, handled, execErr := a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent) + if interruptedActiveContext { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + } + return answer, handled, execErr case "planner": a.clearPendingProposalSession(userID) - answer, execErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent) + if interruptedActiveContext { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + } return answer, true, execErr default: if decision.NeedPlannerHelp || decision.Track == "planning_track" { a.clearPendingProposalSession(userID) - answer, execErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent) + if interruptedActiveContext { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + } return answer, true, execErr } } @@ -110,6 +127,13 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI return "", false, nil } +func plannerContextModeFromRouteDecision(decision llmSkillRouteDecision) string { + if decision.ContextSwitch { + return "fresh_context" + } + return "" +} + func (a *Agent) executeRoutedAtomicSkill(ctx context.Context, storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision, onEvent func(event, data string)) (string, bool, error) { outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision) if !ok { @@ -346,6 +370,7 @@ Rules: - If the request is broad, ambiguous, or creative, you may choose route "planner". - If a single management or diagnosis skill can handle it directly, prefer route "skill". - If multiple dependent steps are needed, prefer route "workflow". +- Set context_switch=true when the user is opening a new topic/task and prior current references or suspended snapshots should not be used to fill business fields. Set context_switch=false when the user intentionally relies on previous context. - Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON. Return JSON with this exact shape: @@ -361,16 +386,20 @@ Rules: - Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question. - If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks. - Prefer "continue_active" when the user is plausibly answering the current active flow. +- If the user asks a read-only management query while an active flow is open, output intent "start_new", route "skill", and the matching query action. For example, "现有策略有哪些" means strategy_management/query_list and must use the strategy query tool, not a freeform answer. +- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new", route "workflow", and fill tasks exactly like the normal top-level router. Do not squeeze a complex new request into only the first skill/action. - If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active". - Examples of forced switch: "不是交易员,是策略", "不是这个", "换个任务", "I mean the strategy, not the trader". - If the user refers to a suspended task and one snapshot clearly matches, use "resume_snapshot". - If the user cancels the current task, use "cancel". - If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply". - Short greetings or acknowledgements like "你好", "hi", "hello", "谢谢", "收到", "好的" should default to "instant_reply" unless they clearly contain task data. +- If intent=start_new, keep the same business routing semantics as the normal router: use route "skill" for one atomic management action, route "workflow" for multiple dependent or independent management actions, and route "planner" for broad or ambiguous work. - You may set target_skill when intent=start_new and the next task is clear. +- Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON. Return JSON with this exact shape: -{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","target_skill":"","extracted_fields":{},"need_planner_help":false,"reason":"","confidence":0.0}`) +{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","route":"skill|workflow|planner","track":"fast_track|planning_track","skill":"","action":"","target_skill":"","filter":"","tasks":[],"context_switch":false,"extracted_fields":{},"need_planner_help":false,"reason":"","confidence":0.0}`) } userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nManagement skill summary:\n%s\n\nManagement domain primer:\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", diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index d117cb16..b5ffe9ca 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -2582,8 +2582,7 @@ Return JSON with this exact shape: } a.history.Add(userID, "user", text) a.history.Add(userID, "assistant", answer) - a.maybeUpdateTaskStateIncrementally(ctx, userID) - a.maybeCompressHistory(ctx, userID) + a.runPostResponseMaintenanceAsync(userID) if onEvent != nil { emitStreamText(onEvent, answer) } @@ -2735,13 +2734,17 @@ func (a *Agent) tryRecoverFromInternalAgentJSON(ctx context.Context, storeUserID } func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { + return a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "", onEvent) +} + +func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID string, userID int64, lang, text string, contextMode string, onEvent func(event, data string)) (string, error) { a.history.Add(userID, "user", text) if onEvent != nil { onEvent(StreamEventPlanning, a.planningStatusText(lang)) } requestStartedAt := time.Now() - state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text) + state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text, contextMode) if err != nil { a.logPlannerTiming("", userID, "prepare_execution_state", requestStartedAt, err) if isPlannerTimeoutError(err) { @@ -2777,13 +2780,29 @@ func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID } a.history.Add(userID, "assistant", answer) - a.maybeUpdateTaskStateIncrementally(ctx, userID) - a.maybeCompressHistory(ctx, userID) + a.runPostResponseMaintenanceAsync(userID) a.logPlannerTiming(state.SessionID, userID, "run_planned_agent_total", requestStartedAt, nil) return answer, nil } -func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text string) (ExecutionState, error) { +func (a *Agent) runPostResponseMaintenanceAsync(userID int64) { + if a == nil || a.aiClient == nil || a.history == nil { + return + } + go func() { + defer func() { + if r := recover(); r != nil { + a.log().Warn("post-response maintenance panicked", "user_id", userID, "panic", r) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + a.maybeUpdateTaskStateIncrementally(ctx, userID) + a.maybeCompressHistory(ctx, userID) + }() +} + +func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text, contextMode string) (ExecutionState, error) { existing := a.getExecutionState(userID) if shouldResetExecutionStateForNewAttempt(text, existing) { a.clearExecutionState(userID) @@ -2817,9 +2836,15 @@ func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, u } state := newExecutionState(userID, text) - if mem := a.getReferenceMemory(userID); mem.CurrentReferences != nil { - state.CurrentReferences = mem.CurrentReferences - state.ReferenceHistory = mem.ReferenceHistory + mem := a.getReferenceMemory(userID) + switch strings.TrimSpace(contextMode) { + case "fresh_context": + a.SnapshotManager(userID).Clear() + default: + if mem.CurrentReferences != nil { + state.CurrentReferences = mem.CurrentReferences + state.ReferenceHistory = mem.ReferenceHistory + } } a.refreshCurrentReferencesForUserText(storeUserID, text, &state) state = a.refreshStateForDynamicRequests(storeUserID, text, state) diff --git a/agent/skill_dag.go b/agent/skill_dag.go index a87592e5..2a6d758a 100644 --- a/agent/skill_dag.go +++ b/agent/skill_dag.go @@ -35,15 +35,6 @@ func buildSkillDAGRegistry() map[string]SkillDAG { {ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true}, }, }, - { - SkillName: "trader_management", - Action: "update_name", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}}, - {ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true}, - }, - }, { SkillName: "trader_management", Action: "update_bindings", diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go index 3f1c3e0d..b974c9b5 100644 --- a/agent/skill_dispatcher.go +++ b/agent/skill_dispatcher.go @@ -275,13 +275,15 @@ func (a *Agent) loadEnabledModelOptions(storeUserID string) []traderSkillOption } out := make([]traderSkillOption, 0, len(models)) for _, model := range models { - parts := cleanStringList([]string{ - strings.TrimSpace(model.Name), + name := strings.TrimSpace(model.Name) + if name == "" { + name = strings.TrimSpace(model.ID) + } + hint := strings.Join(cleanStringList([]string{ strings.TrimSpace(model.CustomModelName), strings.TrimSpace(model.Provider), - }) - name := strings.Join(parts, " ") - out = append(out, traderSkillOption{ID: model.ID, Name: name, Enabled: model.Enabled}) + }), " / ") + out = append(out, traderSkillOption{ID: model.ID, Name: name, Hint: hint, Enabled: model.Enabled}) } return out } @@ -554,34 +556,25 @@ func (a *Agent) handleCreateTraderSkill(storeUserID string, userID int64, lang, return formatValidationFeedback(lang, "trader", err), true } } + if missing := missingFieldKeysForSkillSession(session); len(missing) > 0 { + session.Phase = "collecting" + a.saveSkillSession(userID, session) + return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true + } if !result.Ready && result.Question == "" { - if missing := missingFieldKeysForSkillSession(session); len(missing) > 0 { - session.Phase = "collecting" - a.saveSkillSession(userID, session) - return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true - } result.Ready = true } if !result.Ready { session.Phase = "collecting" a.saveSkillSession(userID, session) - if result.Question != "" { - return result.Question, true - } return "", false } if stillMissing := missingFieldKeysForSkillSession(session); len(stillMissing) > 0 { session.Phase = "collecting" a.saveSkillSession(userID, session) - if result.Question != "" { - return result.Question, true - } - if lang == "zh" { - return "我理解了你的意思,但创建交易员还缺这些信息:" + strings.Join(renderSkillMissingLabels(lang, stillMissing), "、") + "。", true - } - return "I understand the intent, but creating the trader still needs: " + strings.Join(renderSkillMissingLabels(lang, stillMissing), ", ") + ".", true + return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true } if fieldValue(session, "auto_start") == "true" { @@ -679,12 +672,19 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session missing := missingFieldKeysForSkillSession(session) missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、") prereqs := make([]string, 0, 3) + optionLines := make([]string, 0, 3) if exchanges, _ := availableResources["exchanges"].([]traderSkillOption); len(exchanges) == 0 && containsString(missing, "exchange_name") { if lang == "zh" { prereqs = append(prereqs, "当前还没有可用交易所配置") } else { prereqs = append(prereqs, "there is no exchange config yet") } + } else if containsString(missing, "exchange_name") { + if list := formatOptionList("现有交易所:", exchanges); lang == "zh" && list != "" { + optionLines = append(optionLines, list) + } else if list := formatOptionList("Available exchanges:", exchanges); lang != "zh" && list != "" { + optionLines = append(optionLines, list) + } } if models, _ := availableResources["models"].([]traderSkillOption); len(models) == 0 && containsString(missing, "model_name") { if lang == "zh" { @@ -692,6 +692,12 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session } else { prereqs = append(prereqs, "there is no model config yet") } + } else if containsString(missing, "model_name") { + if list := formatOptionList("现有模型:", models); lang == "zh" && list != "" { + optionLines = append(optionLines, list) + } else if list := formatOptionList("Available models:", models); lang != "zh" && list != "" { + optionLines = append(optionLines, list) + } } if strategies, _ := availableResources["strategies"].([]traderSkillOption); len(strategies) == 0 && containsString(missing, "strategy_name") { if lang == "zh" { @@ -699,18 +705,30 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session } else { prereqs = append(prereqs, "there is no strategy yet") } + } else if containsString(missing, "strategy_name") { + if list := formatOptionList("现有策略:", strategies); lang == "zh" && list != "" { + optionLines = append(optionLines, list) + } else if list := formatOptionList("Available strategies:", strategies); lang != "zh" && list != "" { + optionLines = append(optionLines, list) + } } if lang == "zh" { - reply := "还缺这些信息:" + missingLabels + "。" + reply := "新建交易员还缺这些槽位:" + missingLabels + "。" if len(prereqs) > 0 { reply += "\n" + strings.Join(prereqs, ";") + "。" } + if len(optionLines) > 0 { + reply += "\n" + strings.Join(optionLines, "\n") + } return reply } - reply := "Still missing: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "." + reply := "Creating the trader still needs these slots: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "." if len(prereqs) > 0 { reply += "\n" + strings.Join(prereqs, "; ") + "." } + if len(optionLines) > 0 { + reply += "\n" + strings.Join(optionLines, "\n") + } return reply } @@ -738,23 +756,14 @@ func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang a.hydrateCreateTraderSlotReferences(storeUserID, &session) normalizedArgs, _ := normalizeTraderArgsToManualLimits(lang, buildTraderUpdateArgsFromSession(session)) args := manageTraderArgs{ - Action: "create", - Name: fieldValue(session, "name"), - AIModelID: fieldValue(session, "model_id"), - ExchangeID: fieldValue(session, "exchange_id"), - StrategyID: fieldValue(session, "strategy_id"), - InitialBalance: normalizedArgs.InitialBalance, - ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, - IsCrossMargin: normalizedArgs.IsCrossMargin, - ShowInCompetition: normalizedArgs.ShowInCompetition, - BTCETHLeverage: normalizedArgs.BTCETHLeverage, - AltcoinLeverage: normalizedArgs.AltcoinLeverage, - TradingSymbols: normalizedArgs.TradingSymbols, - CustomPrompt: normalizedArgs.CustomPrompt, - OverrideBasePrompt: normalizedArgs.OverrideBasePrompt, - SystemPromptTemplate: normalizedArgs.SystemPromptTemplate, - UseAI500: normalizedArgs.UseAI500, - UseOITop: normalizedArgs.UseOITop, + Action: "create", + Name: fieldValue(session, "name"), + AIModelID: fieldValue(session, "model_id"), + ExchangeID: fieldValue(session, "exchange_id"), + StrategyID: fieldValue(session, "strategy_id"), + ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, + IsCrossMargin: normalizedArgs.IsCrossMargin, + ShowInCompetition: normalizedArgs.ShowInCompetition, } createRaw := a.toolCreateTrader(storeUserID, args) if errMsg := parseSkillError(createRaw); errMsg != "" && strings.Contains(createRaw, `"error"`) { @@ -1009,6 +1018,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s setField(session, "exchange_id", opt.ID) } } + if fieldValue(*session, "exchange_id") != "" { + options := a.loadExchangeOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "exchange_id")); opt != nil { + setField(session, "exchange_id", opt.ID) + if fieldValue(*session, "exchange_name") == "" { + setField(session, "exchange_name", opt.Name) + } + } + } if fieldValue(*session, "model_id") == "" && fieldValue(*session, "model_name") != "" { options := a.loadEnabledModelOptions(storeUserID) if opt := findOptionByIDOrName(options, fieldValue(*session, "model_name")); opt != nil { @@ -1017,6 +1035,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s setField(session, "model_id", opt.ID) } } + if fieldValue(*session, "model_id") != "" { + options := a.loadEnabledModelOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "model_id")); opt != nil { + setField(session, "model_id", opt.ID) + if fieldValue(*session, "model_name") == "" { + setField(session, "model_name", opt.Name) + } + } + } if fieldValue(*session, "strategy_id") == "" && fieldValue(*session, "strategy_name") != "" { options := a.loadStrategyOptions(storeUserID) if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_name")); opt != nil { @@ -1025,6 +1052,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s setField(session, "strategy_id", opt.ID) } } + if fieldValue(*session, "strategy_id") != "" { + options := a.loadStrategyOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_id")); opt != nil { + setField(session, "strategy_id", opt.ID) + if fieldValue(*session, "strategy_name") == "" { + setField(session, "strategy_name", opt.Name) + } + } + } } func (a *Agent) maybeResumeParentTaskAfterSuccessfulSkill(storeUserID string, userID int64, lang, skill, action, answer string) string { @@ -1085,10 +1121,24 @@ func resolveTargetSelection(text string, options []traderSkillOption, existing * if existing != nil && strings.TrimSpace(existing.ID) != "" { for _, opt := range options { if opt.ID == existing.ID { - return targetResolution{Ref: existing} + return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: defaultIfEmpty(opt.Name, existing.Name), Source: existing.Source}} } } } + if existing != nil && strings.TrimSpace(existing.Name) != "" { + if opt := findOptionByIDOrName(options, existing.Name); opt != nil { + return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}} + } + if opt := findUniqueContainingOption(options, existing.Name); opt != nil { + return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}} + } + } + if opt := findOptionByIDOrName(options, text); opt != nil { + return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}} + } + 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}} } diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go index 066833a1..7f9aff96 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -57,10 +57,6 @@ func parseFlagValue(text string, keywords []string) (bool, bool) { return false, false } -func extractCredentialValue(text string, keywords []string) string { - return "" -} - func parseScanIntervalMinutes(text string) (int, bool) { return 0, false } @@ -305,22 +301,6 @@ func parseLooseTextValue(text string) string { return "" } -func parseModelFieldValue(text, field string) (string, bool) { - return "", false -} - -func parseExchangeFieldValue(text, field string) (string, bool) { - return "", false -} - -func (a *Agent) parseTraderFieldValue(storeUserID, text, field string) (string, bool) { - return "", false -} - -func detectCatalogFieldPatches(text string, catalog []entityFieldMeta, overrides map[string]string) []entityFieldPatch { - return nil -} - func entityFieldExplicitlyMentioned(text string, keywords []string) bool { if len(keywords) == 0 { return false @@ -328,54 +308,18 @@ func entityFieldExplicitlyMentioned(text string, keywords []string) bool { return containsAny(strings.ToLower(text), keywords) } -func hasTraderUpdatePatch(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return len(detectTraderUpdatePatches(nil, "", text)) > 0 -} - -func hasModelUpdatePatch(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return len(detectModelUpdatePatches(text)) > 0 -} - -func hasExchangeUpdatePatch(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return len(detectExchangeUpdatePatches(text)) > 0 -} - type traderUpdateArgs struct { - AIModelID string - ExchangeID string - StrategyID string - InitialBalance *float64 - ScanIntervalMinutes *int - IsCrossMargin *bool - ShowInCompetition *bool - BTCETHLeverage *int - AltcoinLeverage *int - TradingSymbols string - CustomPrompt string - OverrideBasePrompt *bool - SystemPromptTemplate string - UseAI500 *bool - UseOITop *bool + AIModelID string + ExchangeID string + StrategyID string + ScanIntervalMinutes *int + IsCrossMargin *bool + ShowInCompetition *bool } func (a traderUpdateArgs) hasAny() bool { - return a.AIModelID != "" || a.ExchangeID != "" || a.StrategyID != "" || a.InitialBalance != nil || - a.ScanIntervalMinutes != nil || a.IsCrossMargin != nil || a.ShowInCompetition != nil || - a.BTCETHLeverage != nil || a.AltcoinLeverage != nil || a.TradingSymbols != "" || - a.CustomPrompt != "" || a.OverrideBasePrompt != nil || a.SystemPromptTemplate != "" || - a.UseAI500 != nil || a.UseOITop != nil + return a.AIModelID != "" || a.ExchangeID != "" || a.StrategyID != "" || + a.ScanIntervalMinutes != nil || a.IsCrossMargin != nil || a.ShowInCompetition != nil } func parseStandaloneTraderUpdateArgs(text string) traderUpdateArgs { @@ -392,9 +336,6 @@ func mergeTraderUpdateArgs(base, patch traderUpdateArgs) traderUpdateArgs { if patch.StrategyID != "" { base.StrategyID = patch.StrategyID } - if patch.InitialBalance != nil { - base.InitialBalance = patch.InitialBalance - } if patch.ScanIntervalMinutes != nil { base.ScanIntervalMinutes = patch.ScanIntervalMinutes } @@ -404,41 +345,9 @@ func mergeTraderUpdateArgs(base, patch traderUpdateArgs) traderUpdateArgs { if patch.ShowInCompetition != nil { base.ShowInCompetition = patch.ShowInCompetition } - if patch.BTCETHLeverage != nil { - base.BTCETHLeverage = patch.BTCETHLeverage - } - if patch.AltcoinLeverage != nil { - base.AltcoinLeverage = patch.AltcoinLeverage - } - if patch.TradingSymbols != "" { - base.TradingSymbols = patch.TradingSymbols - } - if patch.CustomPrompt != "" { - base.CustomPrompt = patch.CustomPrompt - } - if patch.OverrideBasePrompt != nil { - base.OverrideBasePrompt = patch.OverrideBasePrompt - } - if patch.SystemPromptTemplate != "" { - base.SystemPromptTemplate = patch.SystemPromptTemplate - } - if patch.UseAI500 != nil { - base.UseAI500 = patch.UseAI500 - } - if patch.UseOITop != nil { - base.UseOITop = patch.UseOITop - } return base } -func buildTraderUpdateArgs(a *Agent, storeUserID string, text string) traderUpdateArgs { - return traderUpdateArgs{} -} - -func detectTraderUpdatePatches(a *Agent, storeUserID, text string) []entityFieldPatch { - return nil -} - func applyTraderUpdateArgsToSession(session *skillSession, args traderUpdateArgs) { if args.AIModelID != "" { setField(session, "ai_model_id", args.AIModelID) @@ -458,30 +367,6 @@ func applyTraderUpdateArgsToSession(session *skillSession, args traderUpdateArgs if args.ShowInCompetition != nil { setField(session, "show_in_competition", strconv.FormatBool(*args.ShowInCompetition)) } - if args.BTCETHLeverage != nil { - setField(session, "btc_eth_leverage", strconv.Itoa(*args.BTCETHLeverage)) - } - if args.AltcoinLeverage != nil { - setField(session, "altcoin_leverage", strconv.Itoa(*args.AltcoinLeverage)) - } - if args.TradingSymbols != "" { - setField(session, "trading_symbols", args.TradingSymbols) - } - if args.CustomPrompt != "" { - setField(session, "custom_prompt", args.CustomPrompt) - } - if args.OverrideBasePrompt != nil { - setField(session, "override_base_prompt", strconv.FormatBool(*args.OverrideBasePrompt)) - } - if args.SystemPromptTemplate != "" { - setField(session, "system_prompt_template", args.SystemPromptTemplate) - } - if args.UseAI500 != nil { - setField(session, "use_ai500", strconv.FormatBool(*args.UseAI500)) - } - if args.UseOITop != nil { - setField(session, "use_oi_top", strconv.FormatBool(*args.UseOITop)) - } } func buildTraderUpdateArgsFromSession(session skillSession) traderUpdateArgs { @@ -502,31 +387,6 @@ func buildTraderUpdateArgsFromSession(session skillSession) traderUpdateArgs { parsed := value == "true" args.ShowInCompetition = &parsed } - if value := fieldValue(session, "btc_eth_leverage"); value != "" { - if parsed, err := strconv.Atoi(value); err == nil { - args.BTCETHLeverage = &parsed - } - } - if value := fieldValue(session, "altcoin_leverage"); value != "" { - if parsed, err := strconv.Atoi(value); err == nil { - args.AltcoinLeverage = &parsed - } - } - args.TradingSymbols = fieldValue(session, "trading_symbols") - args.CustomPrompt = fieldValue(session, "custom_prompt") - if value := fieldValue(session, "override_base_prompt"); value != "" { - parsed := value == "true" - args.OverrideBasePrompt = &parsed - } - args.SystemPromptTemplate = fieldValue(session, "system_prompt_template") - if value := fieldValue(session, "use_ai500"); value != "" { - parsed := value == "true" - args.UseAI500 = &parsed - } - if value := fieldValue(session, "use_oi_top"); value != "" { - parsed := value == "true" - args.UseOITop = &parsed - } return args } @@ -541,14 +401,6 @@ func (p modelUpdatePatch) hasAny() bool { return p.Enabled != nil || p.APIKey != "" || p.CustomAPIURL != "" || p.CustomModelName != "" } -func buildModelUpdatePatch(text string) modelUpdatePatch { - return modelUpdatePatch{} -} - -func detectModelUpdatePatches(text string) []entityFieldPatch { - return nil -} - func applyModelUpdatePatchToSession(session *skillSession, patch modelUpdatePatch) { if patch.CustomAPIURL != "" { setField(session, "custom_api_url", patch.CustomAPIURL) @@ -615,14 +467,6 @@ func (p exchangeUpdatePatch) hasAny() bool { p.LighterAPIKeyPrivateKey != "" || p.LighterAPIKeyIndex != nil } -func buildExchangeUpdatePatch(text string) exchangeUpdatePatch { - return exchangeUpdatePatch{} -} - -func detectExchangeUpdatePatches(text string) []entityFieldPatch { - return nil -} - func applyExchangeUpdatePatchToSession(session *skillSession, patch exchangeUpdatePatch) { if patch.AccountName != "" { setField(session, "account_name", patch.AccountName) @@ -1624,8 +1468,6 @@ func strategyFieldKeywords(field string) []string { return []string{"山寨币仓位价值倍数", "altcoin position value"} case "max_margin_usage": return []string{"最大保证金使用率", "max margin usage"} - case "min_position_size": - return []string{"最小开仓金额", "min position size"} default: return nil } @@ -1689,8 +1531,6 @@ func strategyFieldExplicitlyMentioned(text, field string) bool { keywords = []string{"山寨币仓位价值倍数", "altcoin position value"} case "max_margin_usage": keywords = []string{"最大保证金使用率", "max margin usage"} - case "min_position_size": - keywords = []string{"最小开仓金额", "min position size"} case "primary_timeframe": keywords = []string{"主周期", "主时间周期", "primary timeframe"} case "primary_count": @@ -1760,6 +1600,9 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "await_confirmation") } + if session.Action == "delete" && fieldValue(session, "bulk_scope") == "all" { + return a.executeBulkTraderDelete(storeUserID, userID, lang, text, session) + } if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { a.saveSkillSession(userID, session) return msg @@ -1791,7 +1634,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, return fmt.Sprintf("已完成交易员操作:%s。", session.Action) } return fmt.Sprintf("Completed trader action: %s.", session.Action) - case "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": + case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" { if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "collect_bindings") @@ -1994,68 +1837,13 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, setSkillDAGStep(&session, "collect_name") } parsedArgs := buildTraderUpdateArgsFromSession(session) - if !parsedArgs.hasAny() { - parsedArgs = buildTraderUpdateArgs(a, storeUserID, text) - } selectedField := fieldValue(session, "update_field") - selectedFieldJustChosen := false if selectedField == "" { - if session.Action == "update_name" { - selectedField = "name" - } else if !parsedArgs.hasAny() { + if !parsedArgs.hasAny() { selectedField = detectCatalogField(text, traderFieldCatalog) } if selectedField != "" { setField(&session, "update_field", selectedField) - selectedFieldJustChosen = true - } - } - if !parsedArgs.hasAny() && selectedField != "" && !(selectedFieldJustChosen && looksLikeBareFieldSelection(text, traderFieldKeywords(selectedField))) { - if value, ok := a.parseTraderFieldValue(storeUserID, text, selectedField); ok { - switch selectedField { - case "ai_model_id": - parsedArgs.AIModelID = value - case "exchange_id": - parsedArgs.ExchangeID = value - case "strategy_id": - parsedArgs.StrategyID = value - case "scan_interval_minutes": - if parsed, err := strconv.Atoi(value); err == nil { - parsedArgs.ScanIntervalMinutes = &parsed - } - case "is_cross_margin": - parsed := value == "true" - parsedArgs.IsCrossMargin = &parsed - case "show_in_competition": - parsed := value == "true" - parsedArgs.ShowInCompetition = &parsed - case "btc_eth_leverage": - if parsed, err := strconv.Atoi(value); err == nil { - parsedArgs.BTCETHLeverage = &parsed - } - case "altcoin_leverage": - if parsed, err := strconv.Atoi(value); err == nil { - parsedArgs.AltcoinLeverage = &parsed - } - case "trading_symbols": - parsedArgs.TradingSymbols = value - case "custom_prompt": - parsedArgs.CustomPrompt = value - case "override_base_prompt": - parsed := value == "true" - parsedArgs.OverrideBasePrompt = &parsed - case "system_prompt_template": - parsedArgs.SystemPromptTemplate = value - case "use_ai500": - parsed := value == "true" - parsedArgs.UseAI500 = &parsed - case "use_oi_top": - parsed := value == "true" - parsedArgs.UseOITop = &parsed - } - if selectedField == "name" { - setField(&session, "name", value) - } } } applyTraderUpdateArgsToSession(&session, parsedArgs) @@ -2064,23 +1852,14 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, normalizedArgs, warnings := normalizeTraderArgsToManualLimits(lang, parsedArgs) applyTraderUpdateArgsToSession(&session, normalizedArgs) args := manageTraderArgs{ - Action: "update", - TraderID: session.TargetRef.ID, - AIModelID: normalizedArgs.AIModelID, - ExchangeID: normalizedArgs.ExchangeID, - StrategyID: normalizedArgs.StrategyID, - InitialBalance: normalizedArgs.InitialBalance, - ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, - IsCrossMargin: normalizedArgs.IsCrossMargin, - ShowInCompetition: normalizedArgs.ShowInCompetition, - BTCETHLeverage: normalizedArgs.BTCETHLeverage, - AltcoinLeverage: normalizedArgs.AltcoinLeverage, - TradingSymbols: normalizedArgs.TradingSymbols, - CustomPrompt: normalizedArgs.CustomPrompt, - OverrideBasePrompt: normalizedArgs.OverrideBasePrompt, - SystemPromptTemplate: normalizedArgs.SystemPromptTemplate, - UseAI500: normalizedArgs.UseAI500, - UseOITop: normalizedArgs.UseOITop, + Action: "update", + TraderID: session.TargetRef.ID, + AIModelID: normalizedArgs.AIModelID, + ExchangeID: normalizedArgs.ExchangeID, + StrategyID: normalizedArgs.StrategyID, + ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, + IsCrossMargin: normalizedArgs.IsCrossMargin, + ShowInCompetition: normalizedArgs.ShowInCompetition, } setSkillDAGStep(&session, "execute_update") resp := a.toolUpdateTrader(storeUserID, args) @@ -2104,54 +1883,134 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, } return reply } - newName := "" - if newName != "" { - setField(&session, "name", newName) + if selectedField != "" { + setSkillDAGStep(&session, "collect_field_value") + } else { + setSkillDAGStep(&session, "collect_name") } - newName = fieldValue(session, "name") - if newName == "" { - if selectedField != "" { - setSkillDAGStep(&session, "collect_field_value") - } else { - setSkillDAGStep(&session, "collect_name") - } - a.saveSkillSession(userID, session) - if lang == "zh" { - if selectedField != "" { - if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" { - return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang)) - } - return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang)) - } - return "你可以直接告诉我想改哪一项,比如名称,或者绑定的模型、交易所、策略。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。" - } + a.saveSkillSession(userID, session) + if lang == "zh" { if selectedField != "" { if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" { - return fmt.Sprintf("One more thing: tell me which %s you want to use.", displayCatalogFieldName(selectedField, lang)) + return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang)) } - return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang)) + return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang)) } - return "Tell me what you want to change first, for example the name or the linked model, exchange, or strategy. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow." + return "你可以直接告诉我想改哪一项,比如绑定的模型、交易所、策略,或者扫描间隔、保证金模式、是否展示到竞技场。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。" } - args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID, Name: newName} - setSkillDAGStep(&session, "execute_update") - resp := a.toolUpdateTrader(storeUserID, args) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没改成功:" + errMsg + if selectedField != "" { + if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" { + return fmt.Sprintf("One more thing: tell me which %s you want to use.", displayCatalogFieldName(selectedField, lang)) } - return "That change did not go through: " + errMsg + return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang)) } - if lang == "zh" { - return fmt.Sprintf("已将交易员改名为“%s”。", newName) - } - return fmt.Sprintf("Renamed trader to %q.", newName) + return "Tell me what you want to change first, for example the linked model, exchange, strategy, scan interval, margin mode, or competition visibility. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow." default: return "" } } +func (a *Agent) executeBulkTraderDelete(storeUserID string, userID int64, lang, text string, session skillSession) string { + if a == nil || a.store == nil { + if lang == "zh" { + return "我这边暂时无法读取交易员列表。" + } + return "I cannot load the trader list right now." + } + traders, err := a.store.Trader().List(storeUserID) + if err != nil { + if lang == "zh" { + return "我这边暂时没读到交易员列表:" + err.Error() + } + return "I could not load the trader list just now: " + err.Error() + } + if len(traders) == 0 { + a.clearSkillSession(userID) + if lang == "zh" { + return "当前没有可删除的交易员。" + } + return "There are no traders to delete." + } + + deletable := make([]*store.Trader, 0, len(traders)) + runningNames := make([]string, 0) + for _, trader := range traders { + if trader == nil { + continue + } + isRunning := trader.IsRunning + if a.traderManager != nil { + if memTrader, err := a.traderManager.GetTrader(trader.ID); err == nil { + if running, ok := memTrader.GetStatus()["is_running"].(bool); ok { + isRunning = running + } + } + } + if isRunning { + runningNames = append(runningNames, defaultIfEmpty(trader.Name, trader.ID)) + continue + } + deletable = append(deletable, trader) + } + + if len(deletable) == 0 { + a.clearSkillSession(userID) + if lang == "zh" { + return "当前所有交易员都还在运行中,删除前需要先停止:" + strings.Join(runningNames, "、") + } + return "All traders are still running. Stop them before deleting: " + strings.Join(runningNames, ", ") + } + + targetLabel := fmt.Sprintf("全部已停止交易员(共 %d 个)", len(deletable)) + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting { + a.saveSkillSession(userID, session) + return msg + } + if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { + a.saveSkillSession(userID, session) + return msg + } + + setSkillDAGStep(&session, "execute_delete") + deletedNames := make([]string, 0, len(deletable)) + failedNames := make([]string, 0) + for _, trader := range deletable { + resp := a.toolDeleteTrader(storeUserID, trader.ID) + if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { + failedNames = append(failedNames, fmt.Sprintf("%s(%s)", defaultIfEmpty(trader.Name, trader.ID), errMsg)) + continue + } + deletedNames = append(deletedNames, defaultIfEmpty(trader.Name, trader.ID)) + } + a.clearSkillSession(userID) + + if lang == "zh" { + parts := []string{fmt.Sprintf("批量删除交易员已完成:成功删除 %d 个。", len(deletedNames))} + if len(runningNames) > 0 { + parts = append(parts, "这些交易员仍在运行,已跳过,删除前需要先停止:"+strings.Join(runningNames, "、")) + } + if len(failedNames) > 0 { + parts = append(parts, "这些没删成功:"+strings.Join(failedNames, ";")) + } + if len(deletedNames) > 0 { + parts = append(parts, "已删除:"+strings.Join(deletedNames, "、")) + } + return strings.Join(parts, "\n") + } + + parts := []string{fmt.Sprintf("Bulk trader deletion finished: deleted %d trader(s).", len(deletedNames))} + if len(runningNames) > 0 { + parts = append(parts, "Skipped running traders; stop them before deleting: "+strings.Join(runningNames, ", ")) + } + if len(failedNames) > 0 { + parts = append(parts, "These did not delete successfully: "+strings.Join(failedNames, "; ")) + } + if len(deletedNames) > 0 { + parts = append(parts, "Deleted: "+strings.Join(deletedNames, ", ")) + } + return strings.Join(parts, "\n") +} + func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { switch session.Action { case "query_detail": @@ -2811,10 +2670,18 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la } if fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" { + if strings.Contains(strings.ToLower(text), "min position size") || strings.Contains(strings.ToLower(text), "最小开仓金额") { + a.clearSkillSession(userID) + return strategyLockedFieldError(lang, "min_position_size") + } patches := detectStrategyConfigPatches(text) if len(patches) > 1 { changed := make([]string, 0, len(patches)) for _, patch := range patches { + if patch.Field == "min_position_size" { + a.clearSkillSession(userID) + return strategyLockedFieldError(lang, "min_position_size") + } if err := applyStrategyConfigPatch(&cfg, patch.Field, patch.Value); err != nil { a.saveSkillSession(userID, session) if lang == "zh" { @@ -2849,6 +2716,10 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la if field == "" { field = detectStrategyConfigField(text) if field != "" { + if field == "min_position_size" { + a.clearSkillSession(userID) + return strategyLockedFieldError(lang, field) + } setField(&session, "config_field", field) if currentStep.ID == "resolve_config_field" { advanceSkillDAGStep(&session, currentStep.ID) diff --git a/agent/skill_management_handlers.go b/agent/skill_management_handlers.go index d1aa4786..5bce7c6d 100644 --- a/agent/skill_management_handlers.go +++ b/agent/skill_management_handlers.go @@ -180,7 +180,12 @@ func textMeansAllTargets(text string) bool { } func supportsBulkTargetSelection(skillName, action string) bool { - return skillName == "strategy_management" && action == "delete" + switch skillName { + case "strategy_management", "trader_management": + return action == "delete" + default: + return false + } } func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference { @@ -253,7 +258,18 @@ func ensureLiveTargetReference(session *skillSession, options []traderSkillOptio if session == nil || session.TargetRef == nil { return true } - match := findOptionByIDOrName(options, defaultIfEmpty(session.TargetRef.ID, session.TargetRef.Name)) + var match *traderSkillOption + if id := strings.TrimSpace(session.TargetRef.ID); id != "" { + match = findOptionByIDOrName(options, id) + } + if match == nil { + if name := strings.TrimSpace(session.TargetRef.Name); name != "" { + match = findOptionByIDOrName(options, name) + if match == nil { + match = findUniqueContainingOption(options, name) + } + } + } if match == nil { session.TargetRef = nil return false @@ -601,7 +617,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string { if lang != "zh" { apiURL = defaultIfEmpty(fieldValue(session, "custom_api_url"), "provider default endpoint") } - enabled := fieldValue(session, "enabled") == "true" + enabled := fieldValue(session, "enabled") != "false" if lang == "zh" { lines := []string{ fmt.Sprintf("我先整理了一份模型配置草稿“%s”。", name), @@ -609,7 +625,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string { fmt.Sprintf("- 配置名称:%s", name), fmt.Sprintf("- 模型名称:%s", modelName), fmt.Sprintf("- 接口地址:%s", apiURL), - fmt.Sprintf("- 启用状态:%t(未指定时默认 false)", enabled), + fmt.Sprintf("- 启用状态:%t(未指定时默认 true)", enabled), modelProviderDetailedGuidance(lang, providerID), "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。", } @@ -621,7 +637,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string { fmt.Sprintf("- Config name: %s", name), fmt.Sprintf("- Model name: %s", modelName), fmt.Sprintf("- API URL: %s", apiURL), - fmt.Sprintf("- Enabled: %t (defaults to false if omitted)", enabled), + fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled), modelProviderDetailedGuidance(lang, providerID), "Reply 'confirm' to create it, or keep refining any field.", } @@ -631,14 +647,14 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string { func formatExchangeCreateDraftSummary(lang string, session skillSession) string { exType := defaultIfEmpty(fieldValue(session, "exchange_type"), "未选择") accountName := defaultIfEmpty(fieldValue(session, "account_name"), "未命名账户") - enabled := fieldValue(session, "enabled") == "true" + enabled := fieldValue(session, "enabled") != "false" testnet := fieldValue(session, "testnet") == "true" if lang == "zh" { lines := []string{ fmt.Sprintf("我先整理了一份交易所配置草稿“%s”。", accountName), fmt.Sprintf("- 交易所:%s", exType), fmt.Sprintf("- 账户名:%s", accountName), - fmt.Sprintf("- 启用状态:%t(未指定时默认 false)", enabled), + fmt.Sprintf("- 启用状态:%t(未指定时默认 true)", enabled), fmt.Sprintf("- 测试网:%t(未指定时默认 false)", testnet), } switch exType { @@ -685,7 +701,7 @@ func formatExchangeCreateDraftSummary(lang string, session skillSession) string fmt.Sprintf("I prepared a draft exchange config %q.", accountName), fmt.Sprintf("- Exchange: %s", exType), fmt.Sprintf("- Account name: %s", accountName), - fmt.Sprintf("- Enabled: %t (defaults to false if omitted)", enabled), + fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled), fmt.Sprintf("- Testnet: %t (defaults to false if omitted)", testnet), } switch exType { @@ -1449,9 +1465,6 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang if v := inferCreateDisplayName(text); fieldValue(session, "account_name") == "" && v != "" { setField(&session, "account_name", v) } - patch := buildExchangeUpdatePatch(text) - patch, warnings := normalizeExchangePatchToManualLimits(lang, patch) - applyExchangeUpdatePatchToSession(&session, patch) exType := fieldValue(session, "exchange_type") accountName := fieldValue(session, "account_name") missing := make([]string, 0, 6) @@ -1510,15 +1523,7 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang session.Phase = "await_create_confirmation" setSkillDAGStep(&session, "await_create_confirmation") a.saveSkillSession(userID, session) - reply := formatExchangeCreateDraftSummary(lang, session) - if len(warnings) > 0 { - if lang == "zh" { - reply += "\n这些字段里有超出手动面板范围的值,我已经先按风控范围收敛:\n- " + strings.Join(warnings, "\n- ") - } else { - reply += "\nSome values exceeded the manual editor limits, so I normalized them first:\n- " + strings.Join(warnings, "\n- ") - } - } - return reply + return formatExchangeCreateDraftSummary(lang, session) } setSkillDAGStep(&session, "execute_create") args := map[string]any{ @@ -1554,17 +1559,9 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang a.clearSkillSession(userID) a.rememberReferencesFromToolResult(userID, "manage_exchange_config", resp) if lang == "zh" { - reply := fmt.Sprintf("已创建交易所配置:%s(%s)。", accountName, exType) - if len(warnings) > 0 { - reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") - } - return reply + return fmt.Sprintf("已创建交易所配置:%s(%s)。", accountName, exType) } - reply := fmt.Sprintf("Created exchange config %s (%s).", accountName, exType) - if len(warnings) > 0 { - reply += "\n\nAdjusted to stay within the manual editor limits:\n- " + strings.Join(warnings, "\n- ") - } - return reply + return fmt.Sprintf("Created exchange config %s (%s).", accountName, exType) } func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { @@ -1587,8 +1584,6 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t if v := inferCreateDisplayName(text); fieldValue(session, "name") == "" && v != "" { setField(&session, "name", v) } - patch := buildModelUpdatePatch(text) - applyModelUpdatePatchToSession(&session, patch) provider := fieldValue(session, "provider") if provider != "" && fieldValue(session, "api_key") == "" { if credential := inferModelCredentialFromText(provider, text); credential != "" { @@ -1791,10 +1786,17 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, return result.Question, true } } + if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) { + setField(&session, "bulk_scope", "all") + session.TargetRef = nil + } if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 { currentStep, _ := currentSkillDAGStep(session) if currentStep.ID == "resolve_target" { + if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { + session.TargetRef = resolved.Ref + } if session.TargetRef == nil { session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) } @@ -1826,6 +1828,9 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, } } } else { + if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { + session.TargetRef = resolved.Ref + } if session.TargetRef == nil { session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) } diff --git a/agent/skill_outcome.go b/agent/skill_outcome.go index 9c40e7e9..a1515cad 100644 --- a/agent/skill_outcome.go +++ b/agent/skill_outcome.go @@ -47,8 +47,8 @@ func normalizeAtomicSkillAction(skill, action string) string { case "query_binding": return "query_detail" case "update": - return "update_name" - case "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": + return "update_bindings" + case "update_bindings", "configure_strategy", "configure_exchange", "configure_model": return action } case "exchange_management": diff --git a/agent/skill_semantic_gate.go b/agent/skill_semantic_gate.go index c2325f8a..e7c2d4fc 100644 --- a/agent/skill_semantic_gate.go +++ b/agent/skill_semantic_gate.go @@ -237,10 +237,9 @@ func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action st add(displayCatalogFieldName(field, lang)) } case "trader_management": - add(slotDisplayName("name", lang)) - add(slotDisplayName("exchange", lang)) - add(slotDisplayName("model", lang)) - add(slotDisplayName("strategy", lang)) + if strings.TrimSpace(action) == "create" { + add(slotDisplayName("name", lang)) + } for _, field := range manualTraderEditableFieldKeys() { add(displayCatalogFieldName(field, lang)) } diff --git a/agent/skills/exchange_management.json b/agent/skills/exchange_management.json index e4a04616..bcb29a06 100644 --- a/agent/skills/exchange_management.json +++ b/agent/skills/exchange_management.json @@ -38,8 +38,8 @@ }, "enabled": { "type": "bool", - "default": false, - "description": "是否启用该交易所配置。启用前必须通过凭证完整性校验。" + "default": true, + "description": "是否启用该交易所配置。只要必要字段齐全并配置成功,就默认启用。" }, "hyperliquid_wallet_addr": { "type": "credential", diff --git a/agent/skills/strategy_management.json b/agent/skills/strategy_management.json index 04d39b1f..2ccffb42 100644 --- a/agent/skills/strategy_management.json +++ b/agent/skills/strategy_management.json @@ -348,11 +348,6 @@ "type": "float", "min": 0, "description": "最大保证金占用比例。" - }, - "min_position_size": { - "type": "float", - "min": 0, - "description": "最小下单金额。" } }, "validation_rules": [ @@ -365,7 +360,8 @@ "删除操作不可逆,必须先向用户确认再执行。", "激活(activate)操作将该策略设为默认模板,不是启动运行。", "scan_interval_minutes、initial_balance、lighter_api_key_index 这类交易员/交易所边界值不属于策略本身,若用户在改策略时提到,应引导去对应 trader 或 exchange 配置。", - "btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。", + "btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。", + "最小开仓金额是系统固定值 12 USDT,Agent 不能修改;若用户要求改这个值,应直接说明这是手动面板中的 System enforced 固定项。", "启用量化数据相关开关时,若需要 nofxos_api_key,应主动提醒用户补齐。", "启用排行榜相关能力时,只修改用户明确提到的 enable_*、duration、limit 字段,不要偷偷打开其他排行榜。" ], @@ -373,7 +369,7 @@ "create": { "description": "创建策略模板。至少需要名称,其他配置可按需追问或按默认值补齐。", "required_slots": ["name"], - "optional_slots": ["description", "is_public", "config_visible", "lang", "strategy_type", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], + "optional_slots": ["description", "is_public", "config_visible", "lang", "strategy_type", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage"], "goal": "创建一个可供 trader 绑定使用的策略模板。", "dynamic_rules": [ "若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。", @@ -387,7 +383,7 @@ "update": { "description": "更新策略模板的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["name", "description", "is_public", "config_visible", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], + "optional_slots": ["name", "description", "is_public", "config_visible", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage"], "goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。", "dynamic_rules": [ "只更新用户明确提到的字段,不要覆盖未提及的字段。", diff --git a/agent/skills/trade_execution.json b/agent/skills/trade_execution.json index 40cdc3eb..9adfe7f9 100644 --- a/agent/skills/trade_execution.json +++ b/agent/skills/trade_execution.json @@ -25,7 +25,7 @@ "若单笔名义价值超过账户权益的 100%,直接拒绝,不允许创建待确认订单。", "加密货币订单的杠杆上限受策略 btceth_max_leverage / altcoin_max_leverage 约束,默认上限为 5x;超出时直接拒绝。", "BTC/ETH 单笔最大仓位价值默认不超过 5 倍账户权益,山寨币默认不超过 1 倍账户权益;若策略里有自定义比例,以策略为准。", - "最小仓位价值默认 12 USDT;若策略配置了 min_position_size,以策略为准。低于最小值时直接拒绝。", + "最小仓位价值固定为 12 USDT;这是系统强制项,不允许通过 Agent 修改。低于最小值时直接拒绝。", "创建后的待确认订单默认 5 分钟有效,超时自动失效。" ], "success_output": "返回 trade_id、估算仓位价值、是否触发大额确认、确认命令和 5 分钟有效期。", diff --git a/agent/skills/trader_management.json b/agent/skills/trader_management.json index c0b0f2f2..5efc6272 100644 --- a/agent/skills/trader_management.json +++ b/agent/skills/trader_management.json @@ -2,7 +2,7 @@ "name": "trader_management", "kind": "management", "domain": "trader", - "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层,核心是名称以及绑定的交易所、模型、策略;编辑交易员默认只换绑定,不修改这些依赖对象的内部配置。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。", + "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略。编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。", "intents": [ "创建交易员", "修改交易员", @@ -50,45 +50,6 @@ "default": true, "description": "是否在竞技场中显示该交易员的成绩。" }, - "btc_eth_leverage": { - "type": "int", - "min": 1, - "max": 20, - "description": "交易员级别的 BTC/ETH 杠杆覆盖值,手动面板上限 20。" - }, - "altcoin_leverage": { - "type": "int", - "min": 1, - "max": 20, - "description": "交易员级别的山寨币杠杆覆盖值,手动面板上限 20。" - }, - "trading_symbols": { - "type": "string", - "description": "指定交易币对,通常为逗号分隔,例如 BTCUSDT,ETHUSDT。" - }, - "custom_prompt": { - "type": "string", - "description": "交易员级别附加提示词,用于覆盖或补充默认策略提示。" - }, - "override_base_prompt": { - "type": "bool", - "default": false, - "description": "是否完全覆盖系统默认提示词。" - }, - "system_prompt_template": { - "type": "string", - "description": "系统提示词模板名称,例如 default。" - }, - "use_ai500": { - "type": "bool", - "default": false, - "description": "是否启用 AI500 作为交易员级别候选币来源。" - }, - "use_oi_top": { - "type": "bool", - "default": false, - "description": "是否启用 OI Top 作为交易员级别候选币来源。" - }, "auto_start": { "type": "bool", "default": false, @@ -103,8 +64,6 @@ "交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。", "启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。", "若绑定的是 OKX 交易所,启用前必须已有 passphrase;若绑定的是 Hyperliquid,启用前必须已有 wallet_addr;若绑定的是 Aster,启用前必须已有 user、signer、private_key;若绑定的是 Lighter,启用前必须已有 wallet_addr 和 api_key_private_key。", - "btc_eth_leverage 和 altcoin_leverage 若超出系统允许范围,应自动收敛或提示用户修正。", - "trading_symbols 若填写,应保持为可识别的交易对列表格式。", "启动(start)和停止(stop)操作属于高风险操作,必须先向用户确认再执行。", "删除(delete)操作不可逆,必须先向用户确认再执行。" ], @@ -112,14 +71,13 @@ "create": { "description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。", "required_slots": ["name", "exchange", "model", "strategy"], - "optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition", "btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "override_base_prompt", "system_prompt_template", "use_ai500", "use_oi_top"], + "optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], "goal": "创建并初始化一个交易员。", "dynamic_rules": [ "若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。", "若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。", "子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。", "scan_interval_minutes 超出 3~60 时,自动收敛并告知用户。", - "若用户明确想覆盖杠杆、币种范围或提示词,应允许在创建阶段一并收集 btc_eth_leverage、altcoin_leverage、trading_symbols、custom_prompt、override_base_prompt、system_prompt_template、use_ai500、use_oi_top。", "不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。", "创建完成后询问用户是否立即启动(auto_start),启动前再次确认。" ], @@ -127,35 +85,27 @@ "failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。" }, "update": { - "description": "更新已有交易员,但默认只处理改名或换绑策略、交易所、模型。", + "description": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。", "required_slots": ["target_ref"], - "optional_slots": ["name", "exchange_id", "ai_model_id", "strategy_id"], - "goal": "更新一个已有交易员的名称或绑定关系,但不改动策略、模型、交易所内部配置。", + "optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], + "goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。", "dynamic_rules": [ "只更新用户明确提到的字段,不要覆盖未提及的字段。", "换绑交易所/模型/策略时,新的资源必须已存在且已启用,否则提示用户先启用或新建。", + "如果用户要求改名,应明确告知交易员改名不在这里处理。", "如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update;应切到对应 management skill。" ], "success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。", "failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。" }, - "update_name": { - "description": "仅修改交易员名称。", - "required_slots": ["target_ref", "name"], - "goal": "把指定交易员改成新的名称,不影响其他配置。", - "dynamic_rules": [ - "若当前输入里同时包含别的配置字段,应优先转去更通用的 update,而不是只改名。" - ], - "success_output": "返回 trader_id,并明确告知新的交易员名称。", - "failure_output": "明确指出目标交易员不存在,或新的名称仍未收齐。" - }, "update_bindings": { - "description": "修改交易员绑定的交易所、模型或策略(可同时修改多个)。", + "description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。", "required_slots": ["target_ref"], - "optional_slots": ["exchange_id", "ai_model_id", "strategy_id"], - "goal": "调整交易员绑定的依赖资源,而不改动无关配置。", + "optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], + "goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。", "dynamic_rules": [ - "新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。" + "新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。", + "扫描间隔超出 3~60 时,自动收敛并告知用户。" ], "success_output": "返回 trader_id,并明确展示新的模型/交易所/策略绑定结果。", "failure_output": "明确指出缺少哪个绑定目标,或当前依赖资源为什么不可直接绑定。" @@ -214,15 +164,17 @@ "failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。" }, "delete": { - "description": "删除交易员,不可逆操作,必须确认。", - "required_slots": ["target_ref"], + "description": "删除交易员,不可逆操作,必须确认。支持删除单个、多个或全部交易员。", + "required_slots": [], "needs_confirmation": true, - "goal": "删除一个交易员及其运行入口。", + "goal": "删除一个、多个或全部交易员及其运行入口。", "dynamic_rules": [ - "必须在确认后执行,并明确提醒该操作不可逆。" + "必须在确认后执行,并明确提醒该操作不可逆。", + "删除范围可以是单个 target_ref、多个目标,或 bulk_scope=all。", + "删除前必须确认目标交易员都已停止;若存在运行中的交易员,不能删除,应要求用户先停止这些交易员。" ], - "success_output": "返回删除成功结果,并明确告知该交易员已被移除。", - "failure_output": "明确指出缺少确认、目标交易员不存在,或删除失败原因。" + "success_output": "返回删除成功结果,并明确告知哪些交易员已被移除。", + "failure_output": "明确指出缺少确认、目标交易员不存在、目标仍在运行,或删除失败原因。" }, "query_list": { "description": "查询所有交易员列表,包含名称、运行状态、绑定信息。", @@ -256,7 +208,6 @@ "tool_mapping": { "create": "manage_trader:create", "update": "manage_trader:update", - "update_name": "manage_trader:update", "update_bindings": "manage_trader:update", "configure_strategy": "manage_trader:update", "configure_exchange": "manage_trader:update", diff --git a/agent/strategy_field_catalog.go b/agent/strategy_field_catalog.go index b5d227d5..f0e46666 100644 --- a/agent/strategy_field_catalog.go +++ b/agent/strategy_field_catalog.go @@ -64,7 +64,6 @@ func manualStrategyEditableFieldKeys() []string { "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", - "min_position_size", "min_risk_reward_ratio", "min_confidence", "role_definition", @@ -139,7 +138,6 @@ func agentStrategyUpdatableFieldKeys() []string { "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", - "min_position_size", "min_risk_reward_ratio", "min_confidence", "role_definition", diff --git a/agent/tools.go b/agent/tools.go index c0126109..cdc3be09 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -224,7 +224,6 @@ func strategyConfigSchema() map[string]any { "btc_eth_max_position_value_ratio": map[string]any{"type": "number"}, "altcoin_max_position_value_ratio": map[string]any{"type": "number"}, "max_margin_usage": map[string]any{"type": "number"}, - "min_position_size": map[string]any{"type": "number"}, "min_risk_reward_ratio": map[string]any{"type": "number"}, "min_confidence": map[string]any{"type": "number"}, }, @@ -335,21 +334,13 @@ func traderConfigFieldsSchema() map[string]any { "type": "string", "description": "Required for update, delete, start, and stop.", }, - "name": map[string]any{"type": "string", "description": "Trader display name."}, - "ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."}, - "exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."}, - "strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."}, - "scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."}, - "is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."}, - "show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."}, - "btc_eth_leverage": map[string]any{"type": "number", "description": "BTC/ETH leverage override."}, - "altcoin_leverage": map[string]any{"type": "number", "description": "Altcoin leverage override."}, - "trading_symbols": map[string]any{"type": "string", "description": "Comma-separated symbol list such as BTCUSDT,ETHUSDT."}, - "custom_prompt": map[string]any{"type": "string", "description": "Additional trader custom prompt."}, - "override_base_prompt": map[string]any{"type": "boolean", "description": "Whether to override the base system prompt."}, - "system_prompt_template": map[string]any{"type": "string", "description": "Prompt template preset such as default."}, - "use_ai500": map[string]any{"type": "boolean", "description": "Whether to use AI500 candidate sourcing."}, - "use_oi_top": map[string]any{"type": "boolean", "description": "Whether to use OI Top candidate sourcing."}, + "name": map[string]any{"type": "string", "description": "Trader display name. Required for create."}, + "ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."}, + "exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."}, + "strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."}, + "scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."}, + "is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."}, + "show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."}, } } @@ -515,7 +506,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "manage_trader", - Description: "List, create, update, delete, start, or stop traders. Use this when the user asks to create a trader, rename one, switch its exchange/model/strategy bindings, or control its running state. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead.", + Description: "List, create, update, delete, start, or stop traders. Trader edits are limited to exchange/model/strategy bindings, scan interval, margin mode, and competition visibility so they match the manual trader panel. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -523,22 +514,14 @@ func buildAgentTools() []mcp.Tool { "type": "string", "enum": []string{"list", "create", "update", "delete", "start", "stop"}, }, - "trader_id": traderConfigFieldsSchema()["trader_id"], - "name": traderConfigFieldsSchema()["name"], - "ai_model_id": traderConfigFieldsSchema()["ai_model_id"], - "exchange_id": traderConfigFieldsSchema()["exchange_id"], - "strategy_id": traderConfigFieldsSchema()["strategy_id"], - "scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"], - "is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"], - "show_in_competition": traderConfigFieldsSchema()["show_in_competition"], - "btc_eth_leverage": traderConfigFieldsSchema()["btc_eth_leverage"], - "altcoin_leverage": traderConfigFieldsSchema()["altcoin_leverage"], - "trading_symbols": traderConfigFieldsSchema()["trading_symbols"], - "custom_prompt": traderConfigFieldsSchema()["custom_prompt"], - "override_base_prompt": traderConfigFieldsSchema()["override_base_prompt"], - "system_prompt_template": traderConfigFieldsSchema()["system_prompt_template"], - "use_ai500": traderConfigFieldsSchema()["use_ai500"], - "use_oi_top": traderConfigFieldsSchema()["use_oi_top"], + "trader_id": traderConfigFieldsSchema()["trader_id"], + "name": traderConfigFieldsSchema()["name"], + "ai_model_id": traderConfigFieldsSchema()["ai_model_id"], + "exchange_id": traderConfigFieldsSchema()["exchange_id"], + "strategy_id": traderConfigFieldsSchema()["strategy_id"], + "scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"], + "is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"], + "show_in_competition": traderConfigFieldsSchema()["show_in_competition"], }, "required": []string{"action"}, }, @@ -825,21 +808,16 @@ type safeModelToolConfig struct { } type safeTraderToolConfig struct { - ID string `json:"id"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - StrategyID string `json:"strategy_id,omitempty"` - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - IsRunning bool `json:"is_running"` - IsCrossMargin bool `json:"is_cross_margin"` - ShowInCompetition bool `json:"show_in_competition"` - BTCETHLeverage int `json:"btc_eth_leverage,omitempty"` - AltcoinLeverage int `json:"altcoin_leverage,omitempty"` - TradingSymbols string `json:"trading_symbols,omitempty"` - CustomPrompt string `json:"custom_prompt,omitempty"` - SystemPromptTemplate string `json:"system_prompt_template,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + AIModelID string `json:"ai_model_id"` + ExchangeID string `json:"exchange_id"` + StrategyID string `json:"strategy_id,omitempty"` + InitialBalance float64 `json:"initial_balance"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsRunning bool `json:"is_running"` + IsCrossMargin bool `json:"is_cross_margin"` + ShowInCompetition bool `json:"show_in_competition"` } type safeStrategyToolConfig struct { @@ -886,24 +864,15 @@ func stripSensitiveToolFields(value any) any { } type manageTraderArgs struct { - Action string `json:"action"` - TraderID string `json:"trader_id"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - StrategyID string `json:"strategy_id"` - InitialBalance *float64 `json:"initial_balance"` - ScanIntervalMinutes *int `json:"scan_interval_minutes"` - IsCrossMargin *bool `json:"is_cross_margin"` - ShowInCompetition *bool `json:"show_in_competition"` - BTCETHLeverage *int `json:"btc_eth_leverage"` - AltcoinLeverage *int `json:"altcoin_leverage"` - TradingSymbols string `json:"trading_symbols"` - CustomPrompt string `json:"custom_prompt"` - OverrideBasePrompt *bool `json:"override_base_prompt"` - SystemPromptTemplate string `json:"system_prompt_template"` - UseAI500 *bool `json:"use_ai500"` - UseOITop *bool `json:"use_oi_top"` + Action string `json:"action"` + TraderID string `json:"trader_id"` + Name string `json:"name"` + AIModelID string `json:"ai_model_id"` + ExchangeID string `json:"exchange_id"` + StrategyID string `json:"strategy_id"` + ScanIntervalMinutes *int `json:"scan_interval_minutes"` + IsCrossMargin *bool `json:"is_cross_margin"` + ShowInCompetition *bool `json:"show_in_competition"` } func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig { @@ -1034,21 +1003,16 @@ func modelConfigUsable(provider, modelID, apiKey, customAPIURL, customModelName func safeTraderForTool(trader *store.Trader, isRunning bool) safeTraderToolConfig { return safeTraderToolConfig{ - ID: trader.ID, - Name: trader.Name, - AIModelID: trader.AIModelID, - ExchangeID: trader.ExchangeID, - StrategyID: trader.StrategyID, - InitialBalance: trader.InitialBalance, - ScanIntervalMinutes: trader.ScanIntervalMinutes, - IsRunning: isRunning, - IsCrossMargin: trader.IsCrossMargin, - ShowInCompetition: trader.ShowInCompetition, - BTCETHLeverage: trader.BTCETHLeverage, - AltcoinLeverage: trader.AltcoinLeverage, - TradingSymbols: trader.TradingSymbols, - CustomPrompt: trader.CustomPrompt, - SystemPromptTemplate: trader.SystemPromptTemplate, + ID: trader.ID, + Name: trader.Name, + AIModelID: trader.AIModelID, + ExchangeID: trader.ExchangeID, + StrategyID: trader.StrategyID, + InitialBalance: trader.InitialBalance, + ScanIntervalMinutes: trader.ScanIntervalMinutes, + IsRunning: isRunning, + IsCrossMargin: trader.IsCrossMargin, + ShowInCompetition: trader.ShowInCompetition, } } @@ -1455,9 +1419,9 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if trimmed := strings.TrimSpace(args.LighterAPIKeyPrivateKey); trimmed != "" { effectiveLighterAPIKeyPrivateKey = trimmed } - if err := (exchangeConfigValidator{ + validator := exchangeConfigValidator{ exchangeType: existing.ExchangeType, - enabled: enabled, + enabled: true, apiKey: effectiveAPIKey, secretKey: effectiveSecretKey, passphrase: effectivePassphrase, @@ -1468,7 +1432,14 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { lighterWalletAddr: lighterWallet, lighterPrivateKey: effectiveLighterPrivateKey, lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey, - }).Validate(); err != nil { + } + if args.Enabled == nil { + if err := validator.Validate(); err == nil { + enabled = true + } + } + validator.enabled = enabled + if err := validator.Validate(); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } if err := a.store.Exchange().Update( @@ -1817,6 +1788,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if name == "" { return `{"error":"name is required for create"}` } + if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { + return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) + } if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } @@ -1866,6 +1840,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if strategyID == "" { return `{"error":"strategy_id is required for update"}` } + if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { + return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) + } existing, err := a.store.Strategy().Get(storeUserID, strategyID) if err != nil { return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err) @@ -2234,14 +2211,8 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri if existing == nil { return `{"error":"trader not found"}` } - name := existing.Name - if trimmed := strings.TrimSpace(args.Name); trimmed != "" { - name = trimmed - } - if !sameEntityName(name, existing.Name) { - if err := a.ensureUniqueTraderName(storeUserID, name, existing.ID); err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } + if trimmed := strings.TrimSpace(args.Name); trimmed != "" && !sameEntityName(trimmed, existing.Name) { + return `{"error":"trader rename is not supported here; only bindings, scan interval, margin mode, and competition visibility can be edited"}` } aiModelID := existing.AIModelID if trimmed := strings.TrimSpace(args.AIModelID); trimmed != "" { @@ -2261,7 +2232,7 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri record := &store.Trader{ ID: existing.ID, UserID: storeUserID, - Name: name, + Name: existing.Name, AIModelID: aiModelID, ExchangeID: exchangeID, StrategyID: strategyID, @@ -2291,30 +2262,6 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri if args.ShowInCompetition != nil { record.ShowInCompetition = *args.ShowInCompetition } - if args.BTCETHLeverage != nil && *args.BTCETHLeverage > 0 { - record.BTCETHLeverage = *args.BTCETHLeverage - } - if args.AltcoinLeverage != nil && *args.AltcoinLeverage > 0 { - record.AltcoinLeverage = *args.AltcoinLeverage - } - if trimmed := strings.TrimSpace(args.TradingSymbols); trimmed != "" { - record.TradingSymbols = trimmed - } - if trimmed := strings.TrimSpace(args.CustomPrompt); trimmed != "" { - record.CustomPrompt = trimmed - } - if args.OverrideBasePrompt != nil { - record.OverrideBasePrompt = *args.OverrideBasePrompt - } - if trimmed := strings.TrimSpace(args.SystemPromptTemplate); trimmed != "" { - record.SystemPromptTemplate = trimmed - } - if args.UseAI500 != nil { - record.UseAI500 = *args.UseAI500 - } - if args.UseOITop != nil { - record.UseOITop = *args.UseOITop - } if err := a.store.Trader().Update(record); err != nil { return fmt.Sprintf(`{"error":"failed to update trader: %s"}`, err) } @@ -2334,13 +2281,27 @@ func (a *Agent) toolDeleteTrader(storeUserID, traderID string) string { if traderID == "" { return `{"error":"trader_id is required for delete"}` } + if a.traderManager != nil { + if trader, err := a.traderManager.GetTrader(traderID); err == nil { + if running, ok := trader.GetStatus()["is_running"].(bool); ok && running { + return `{"error":"trader is running; stop it before deleting"}` + } + } + } + if record, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err == nil && record != nil && record.Trader != nil && record.Trader.IsRunning { + return `{"error":"trader is running; stop it before deleting"}` + } + if traders, err := a.store.Trader().List(storeUserID); err == nil { + for _, trader := range traders { + if trader != nil && trader.ID == traderID && trader.IsRunning { + return `{"error":"trader is running; stop it before deleting"}` + } + } + } if err := a.store.Trader().Delete(storeUserID, traderID); err != nil { return fmt.Sprintf(`{"error":"failed to delete trader: %s"}`, err) } if a.traderManager != nil { - if trader, err := a.traderManager.GetTrader(traderID); err == nil { - trader.Stop() - } a.traderManager.RemoveTrader(traderID) } result, _ := json.Marshal(map[string]any{ @@ -2983,6 +2944,36 @@ func maxInt(a, b int) int { return b } +func strategyLockedFieldError(lang, field string) string { + switch strings.TrimSpace(field) { + case "min_position_size": + if lang == "zh" { + return "最小开仓金额是系统固定值 12 USDT,手动面板里也是 System enforced,Agent 不能修改。" + } + return "The minimum position size is a fixed system value of 12 USDT. It is System enforced in the manual panel and cannot be changed by the agent." + default: + if lang == "zh" { + return "这个字段是系统固定项,Agent 不能修改。" + } + return "This field is system enforced and cannot be changed by the agent." + } +} + +func strategyConfigContainsLockedField(config map[string]any) (string, bool) { + if len(config) == 0 { + return "", false + } + if _, ok := config["min_position_size"]; ok { + return "min_position_size", true + } + if risk, ok := config["risk_control"].(map[string]any); ok { + if _, ok := risk["min_position_size"]; ok { + return "min_position_size", true + } + } + return "", false +} + func validKlineInterval(interval string) bool { switch strings.TrimSpace(strings.ToLower(interval)) { case "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1mo": diff --git a/agent/trader_scope_test.go b/agent/trader_scope_test.go index 621dcea1..318fc220 100644 --- a/agent/trader_scope_test.go +++ b/agent/trader_scope_test.go @@ -1,11 +1,16 @@ package agent import ( + "encoding/json" + "log/slog" + "path/filepath" "strings" "testing" + + "nofx/store" ) -func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) { +func TestClassifyWorkflowTaskTreatsTraderEditAsManualPanelUpdate(t *testing.T) { task, ok := classifyWorkflowTask("帮我把交易员小爱换策略") if !ok { t.Fatal("expected trader binding edit to classify") @@ -14,12 +19,12 @@ func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) { t.Fatalf("unexpected task: %+v", task) } - task, ok = classifyWorkflowTask("帮我把交易员小爱改名") + task, ok = classifyWorkflowTask("帮我把交易员小爱扫描间隔改成10分钟") if !ok { - t.Fatal("expected trader rename to classify") + t.Fatal("expected trader manual-panel edit to classify") } - if task.Skill != "trader_management" || task.Action != "update_name" { - t.Fatalf("unexpected rename task: %+v", task) + if task.Skill != "trader_management" || task.Action != "update_bindings" { + t.Fatalf("unexpected trader update task: %+v", task) } } @@ -35,3 +40,424 @@ func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) { } } } + +func TestLoadEnabledModelOptionsUseConfigNameAsPrimaryLabel(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-model-options.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed model: %v", err) + } + + options := a.loadEnabledModelOptions("default") + if len(options) != 1 { + t.Fatalf("expected one model option, got %d", len(options)) + } + if options[0].Name != "DeepSeek AI" { + t.Fatalf("expected primary option label to stay on config name, got %q", options[0].Name) + } + if !strings.Contains(options[0].Hint, "deepseek-chat") || !strings.Contains(options[0].Hint, "deepseek") { + t.Fatalf("expected hint to retain runtime model/provider context, got %q", options[0].Hint) + } +} + +func TestHydrateCreateTraderSlotReferencesNormalizesModelIDFromVisibleName(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-model-id-normalize.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed model: %v", err) + } + + session := skillSession{ + Name: "trader_management", + Action: "create", + Fields: map[string]string{ + "model_id": "DeepSeek AI", + }, + } + a.hydrateCreateTraderSlotReferences("default", &session) + if got := fieldValue(session, "model_id"); got != "default_deepseek" { + t.Fatalf("expected visible model name in model_id slot to normalize to actual id, got %q", got) + } + if got := fieldValue(session, "model_name"); got != "DeepSeek AI" { + t.Fatalf("expected normalized model name to be preserved, got %q", got) + } +} + +func TestHydrateCreateTraderSlotReferencesNormalizesExchangeIDFromVisibleName(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-exchange-id-normalize.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + exchangeID, err := st.Exchange().Create("default", "okx", "小偶", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed exchange: %v", err) + } + + session := skillSession{ + Name: "trader_management", + Action: "create", + Fields: map[string]string{ + "exchange_id": "小偶", + }, + } + a.hydrateCreateTraderSlotReferences("default", &session) + if got := fieldValue(session, "exchange_id"); got != exchangeID { + t.Fatalf("expected visible exchange name in exchange_id slot to normalize to actual id, got %q", got) + } + if got := fieldValue(session, "exchange_name"); got != "小偶" { + t.Fatalf("expected normalized exchange name to be preserved, got %q", got) + } +} + +func TestToolDeleteTraderRejectsRunningTrader(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "delete-running-trader.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.Trader().Create(&store.Trader{ + ID: "trader-running", + UserID: "default", + Name: "运行中", + AIModelID: "model-1", + ExchangeID: "exchange-1", + InitialBalance: 100, + ScanIntervalMinutes: 3, + IsRunning: true, + }); err != nil { + t.Fatalf("seed trader: %v", err) + } + + resp := a.toolDeleteTrader("default", "trader-running") + if !strings.Contains(resp, "stop it before deleting") { + t.Fatalf("expected running trader delete to be rejected, got: %s", resp) + } + traders, err := st.Trader().List("default") + if err != nil { + t.Fatalf("list traders: %v", err) + } + if len(traders) != 1 { + t.Fatalf("expected running trader to remain, got %d traders", len(traders)) + } +} + +func TestBulkTraderDeleteDeletesOnlyStoppedTraders(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + for _, trader := range []*store.Trader{ + {ID: "trader-stopped", UserID: "default", Name: "已停止", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: false}, + {ID: "trader-running", UserID: "default", Name: "运行中", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: true}, + } { + if err := st.Trader().Create(trader); err != nil { + t.Fatalf("seed trader %s: %v", trader.ID, err) + } + } + + session := skillSession{ + Name: "trader_management", + Action: "delete", + Phase: "await_confirmation", + Fields: map[string]string{ + "bulk_scope": "all", + skillDAGStepField: "await_confirmation", + }, + } + resp := a.executeBulkTraderDelete("default", 99, "zh", "确认", session) + if !strings.Contains(resp, "成功删除 1 个") || !strings.Contains(resp, "运行中") { + t.Fatalf("expected stopped trader deleted and running trader skipped, got: %s", resp) + } + traders, err := st.Trader().List("default") + if err != nil { + t.Fatalf("list traders: %v", err) + } + if len(traders) != 1 || traders[0].ID != "trader-running" { + t.Fatalf("expected only running trader to remain, got: %+v", traders) + } +} + +func TestBulkTraderDeleteRequiresConfirmationBeforeDeleting(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders-confirmation.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.Trader().Create(&store.Trader{ + ID: "trader-stopped", + UserID: "default", + Name: "已停止", + AIModelID: "model-1", + ExchangeID: "exchange-1", + InitialBalance: 100, + ScanIntervalMinutes: 3, + IsRunning: false, + }); err != nil { + t.Fatalf("seed trader: %v", err) + } + + session := skillSession{ + Name: "trader_management", + Action: "delete", + Fields: map[string]string{ + "bulk_scope": "all", + }, + } + resp := a.executeBulkTraderDelete("default", 99, "zh", "全部删除", session) + if !strings.Contains(resp, "请回复“确认”继续") { + t.Fatalf("expected confirmation prompt, got: %s", resp) + } + traders, err := st.Trader().List("default") + if err != nil { + t.Fatalf("list traders: %v", err) + } + if len(traders) != 1 { + t.Fatalf("expected trader to remain before confirmation, got %d traders", len(traders)) + } +} + +func TestResolveTargetSelectionMatchesUniqueNameInUserText(t *testing.T) { + options := []traderSkillOption{ + {ID: "exchange-a", Name: "okx"}, + {ID: "exchange-b", Name: "为:小易"}, + {ID: "exchange-c", Name: "小偶"}, + } + resolved := resolveTargetSelection("先把 为:小易 删掉,其他 5 个先保留", options, nil) + if resolved.Ref == nil { + t.Fatal("expected target ref to resolve from user text") + } + if resolved.Ref.ID != "exchange-b" || resolved.Ref.Name != "为:小易" { + t.Fatalf("unexpected resolved target: %+v", resolved.Ref) + } +} + +func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + 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-custom", + UserID: "default", + Name: "自定义策略", + ConfigVisible: true, + Config: string(rawCfg), + }); err != nil { + t.Fatalf("seed strategy: %v", err) + } + + session := skillSession{ + Name: "strategy_management", + Action: "delete", + Fields: map[string]string{ + "bulk_scope": "all", + }, + } + resp := a.executeStrategyManagementAction("default", 99, "zh", "全部删除", session) + if !strings.Contains(resp, "请回复“确认”继续") { + t.Fatalf("expected confirmation prompt, got: %s", resp) + } + strategies, err := st.Strategy().List("default") + if err != nil { + t.Fatalf("list strategies: %v", err) + } + found := false + for _, strategy := range strategies { + if strategy.ID == "strategy-custom" { + found = true + } + } + if !found { + t.Fatal("expected strategy to remain before confirmation") + } +} + +func TestEnsureLiveTargetReferenceFallsBackFromStaleIDToName(t *testing.T) { + session := skillSession{ + TargetRef: &EntityReference{ + ID: "stale-id", + Name: "小易", + }, + } + options := []traderSkillOption{ + {ID: "exchange-a", Name: "okx"}, + {ID: "exchange-b", Name: "为:小易"}, + } + if !ensureLiveTargetReference(&session, options) { + t.Fatal("expected stale id with matching name to resolve") + } + if session.TargetRef == nil || session.TargetRef.ID != "exchange-b" || session.TargetRef.Name != "为:小易" { + t.Fatalf("unexpected target ref after live check: %+v", session.TargetRef) + } +} + +func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "trader-create-missing-prompt.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed model: %v", err) + } + exchangeID, err := st.Exchange().Create("default", "okx", "OKX 主账户", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed exchange: %v", err) + } + _ = exchangeID + 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-ai500", + UserID: "default", + Name: "AI500稳重策略", + Description: "test", + IsPublic: false, + ConfigVisible: true, + Config: string(rawCfg), + }); err != nil { + t.Fatalf("seed strategy: %v", err) + } + + session := skillSession{ + Name: "trader_management", + Action: "create", + Phase: "collecting", + Fields: map[string]string{}, + } + prompt := a.buildTraderCreateMissingPrompt("default", "zh", session, a.buildTraderCreateConversationResources("default", session)) + for _, want := range []string{"名称", "交易所", "模型", "策略"} { + if !strings.Contains(prompt, want) { + t.Fatalf("expected missing prompt to include %q, got: %s", want, prompt) + } + } + for _, want := range []string{"现有交易所", "现有模型", "现有策略"} { + if !strings.Contains(prompt, want) { + t.Fatalf("expected missing prompt to include options line %q, got: %s", want, prompt) + } + } +} + +func TestPlannerContextModeFollowsRouterContextSwitch(t *testing.T) { + if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{ContextSwitch: true}); got != "fresh_context" { + t.Fatalf("expected fresh context mode, got %q", got) + } + if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{}); got != "" { + t.Fatalf("expected default context mode, got %q", got) + } +} + +func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) { + result := llmFlowExtractionResult{ + Intent: "continue", + Tasks: []llmFlowExtractionTask{{ + Skill: "exchange_management", + Action: "create", + Fields: map[string]string{ + "secret": "wrong-key", + "secret_key": "canonical-secret", + "api_key": "api", + }, + }}, + } + filtered := filterLLMFlowExtractionFields(result, []llmFlowFieldSpec{ + {Key: "secret_key"}, + {Key: "api_key"}, + }) + fields := filtered.Tasks[0].Fields + if _, ok := fields["secret"]; ok { + t.Fatalf("expected invented field key to be filtered, got: %+v", fields) + } + if fields["secret_key"] != "canonical-secret" || fields["api_key"] != "api" { + t.Fatalf("expected canonical fields to remain, got: %+v", fields) + } +} + +func TestExchangeCreateAllowedFieldSpecsUseCanonicalSecretKey(t *testing.T) { + specs := allowedFieldSpecsForSkillSession(skillSession{Name: "exchange_management", Action: "create"}, "zh") + foundSecretKey := false + for _, spec := range specs { + if spec.Key == "secret" { + t.Fatal("exchange create schema should not expose non-canonical secret key") + } + if spec.Key == "secret_key" { + foundSecretKey = true + } + } + if !foundSecretKey { + t.Fatal("expected exchange create schema to include canonical secret_key") + } +} + +func TestActiveSessionExtractedDataFiltersToAllowedSchema(t *testing.T) { + session := ActiveSkillSession{ + SkillName: "exchange_management", + ActionName: "create", + CollectedFields: map[string]any{ + "exchange_type": "okx", + }, + } + filtered := filterExtractedDataForActiveSession(session, map[string]any{ + "account_name": "呢呢", + "api_key": "api", + "secret": "wrong-key", + "secret_key": "canonical-secret", + "passphrase": "pass", + }, "zh") + if _, ok := filtered["secret"]; ok { + t.Fatalf("expected central brain alias key to be filtered, got: %+v", filtered) + } + for _, key := range []string{"account_name", "api_key", "secret_key", "passphrase"} { + if _, ok := filtered[key]; !ok { + t.Fatalf("expected canonical key %q to remain, got: %+v", key, filtered) + } + } +} + +func TestBrainUserPromptIncludesActiveAllowedFieldSchema(t *testing.T) { + prompt := buildBrainUserPrompt( + "zh", + "密钥是abc123456", + "要创建交易所配置,还缺这些字段:Secret。", + "", + "", + ActiveSkillSession{SkillName: "exchange_management", ActionName: "create"}, + true, + ) + if !strings.Contains(prompt, "allowed_field_spec_json") || !strings.Contains(prompt, `"secret_key"`) { + t.Fatalf("expected brain prompt to expose canonical field schema, got:\n%s", prompt) + } +} diff --git a/agent/workflow.go b/agent/workflow.go index 95d34704..a15a0f68 100644 --- a/agent/workflow.go +++ b/agent/workflow.go @@ -511,7 +511,7 @@ func looksLikeCompoundTraderIntent(text string) bool { return false } hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) - hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}) hasLifecycle := containsAny(lower, []string{"启动", "开始", "start", "停止", "stop"}) return (hasCreate && (hasBindingsOrConfig || hasLifecycle)) || (hasBindingsOrConfig && hasLifecycle) @@ -554,7 +554,7 @@ Each task must include: - request - depends_on (array, may be empty) Rules: -- Prefer atomic actions such as create, update_name, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail. +- Prefer atomic actions such as create, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail. - If one request contains create plus follow-up edits in the same skill, split them into multiple tasks. - If later tasks need an entity created earlier, make the dependency explicit in depends_on. - Keep each request user-readable and self-contained enough for a single skill handler to execute. @@ -717,7 +717,7 @@ func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask { func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask { lower := strings.ToLower(strings.TrimSpace(text)) - hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"}) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}) hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) if !hasUpdate && !hasStart && !hasStop { @@ -725,12 +725,7 @@ func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask { } var tasks []WorkflowTask if hasUpdate { - action := "update_bindings" - if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) && - !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { - action = "update_name" - } - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text}) } if hasStart { tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) @@ -804,7 +799,7 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask { } lower := strings.ToLower(strings.TrimSpace(text)) hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) - hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"}) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}) hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) @@ -813,12 +808,7 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask { tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "create", Request: text}) } if hasUpdate { - action := "update_bindings" - if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) && - !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { - action = "update_name" - } - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text}) } if hasStart { tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) @@ -847,9 +837,6 @@ func classifyCompoundModelWorkflowTasks(text string) []WorkflowTask { } if hasConfig { action := "update_endpoint" - if extractModelNameValue(text) != "" || extractCredentialValue(text, []string{"api key", "apikey", "api_key"}) != "" { - action = "update_endpoint" - } tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: action, Request: text}) } if hasStatus { @@ -925,12 +912,10 @@ func classifyWorkflowTask(text string) (WorkflowTask, bool) { action = "stop" case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}): action = "delete" - case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}): + case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}): action = "update_bindings" - case containsAny(lower, []string{"改名", "重命名", "rename", "名字", "名称", "name"}): - action = "update_name" case containsAny(lower, []string{"修改", "更新", "改"}): - action = "update" + action = "update_bindings" case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}): action = "query_detail" case containsAny(lower, []string{"列表", "全部", "哪些", "list"}): diff --git a/api/handler_exchange.go b/api/handler_exchange.go index 33c9a4c4..deb6102b 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -31,11 +31,39 @@ type SafeExchangeConfig struct { Name string `json:"name"` // Display name Type string `json:"type"` // "cex" or "dex" Enabled bool `json:"enabled"` + HasAPIKey bool `json:"has_api_key"` + HasSecretKey bool `json:"has_secret_key"` + HasPassphrase bool `json:"has_passphrase"` Testnet bool `json:"testnet,omitempty"` HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive) + HasAsterPrivateKey bool `json:"has_aster_private_key"` AsterUser string `json:"asterUser"` // Aster username (not sensitive) AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive) LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive) + HasLighterPrivateKey bool `json:"has_lighter_private_key"` + HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"` +} + +func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig { + return SafeExchangeConfig{ + ID: exchange.ID, + ExchangeType: exchange.ExchangeType, + AccountName: exchange.AccountName, + Name: exchange.Name, + Type: exchange.Type, + Enabled: exchange.Enabled, + HasAPIKey: exchange.APIKey != "", + HasSecretKey: exchange.SecretKey != "", + HasPassphrase: exchange.Passphrase != "", + Testnet: exchange.Testnet, + HyperliquidWalletAddr: exchange.HyperliquidWalletAddr, + HasAsterPrivateKey: exchange.AsterPrivateKey != "", + AsterUser: exchange.AsterUser, + AsterSigner: exchange.AsterSigner, + LighterWalletAddr: exchange.LighterWalletAddr, + HasLighterPrivateKey: exchange.LighterPrivateKey != "", + HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "", + } } type UpdateExchangeConfigRequest struct { @@ -102,19 +130,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { if !store.IsVisibleExchange(exchange) { continue } - safeExchanges = append(safeExchanges, SafeExchangeConfig{ - ID: exchange.ID, - ExchangeType: exchange.ExchangeType, - AccountName: exchange.AccountName, - Name: exchange.Name, - Type: exchange.Type, - Enabled: exchange.Enabled, - Testnet: exchange.Testnet, - HyperliquidWalletAddr: exchange.HyperliquidWalletAddr, - AsterUser: exchange.AsterUser, - AsterSigner: exchange.AsterSigner, - LighterWalletAddr: exchange.LighterWalletAddr, - }) + safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange)) } c.JSON(http.StatusOK, safeExchanges) diff --git a/api/handler_exchange_test.go b/api/handler_exchange_test.go new file mode 100644 index 00000000..4c4da538 --- /dev/null +++ b/api/handler_exchange_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "testing" + + "nofx/crypto" + "nofx/store" +) + +func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) { + cfg := &store.Exchange{ + ID: "ex-1", + ExchangeType: "okx", + AccountName: "OKX Main", + Name: "OKX Main", + Type: "cex", + Enabled: true, + APIKey: crypto.EncryptedString("api-test-123"), + SecretKey: crypto.EncryptedString("secret-test-123"), + Passphrase: crypto.EncryptedString("passphrase-test-123"), + AsterPrivateKey: crypto.EncryptedString("aster-private-key"), + LighterPrivateKey: crypto.EncryptedString("lighter-private-key"), + LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"), + } + + safe := safeExchangeConfigFromStore(cfg) + if !safe.HasAPIKey { + t.Fatalf("expected has_api_key to be true") + } + if !safe.HasSecretKey { + t.Fatalf("expected has_secret_key to be true") + } + if !safe.HasPassphrase { + t.Fatalf("expected has_passphrase to be true") + } + if !safe.HasAsterPrivateKey { + t.Fatalf("expected has_aster_private_key to be true") + } + if !safe.HasLighterPrivateKey { + t.Fatalf("expected has_lighter_private_key to be true") + } + if !safe.HasLighterAPIKey { + t.Fatalf("expected has_lighter_api_key_private_key to be true") + } +}