diff --git a/AGENT_IMPLEMENTATION_SPEC.md b/AGENT_IMPLEMENTATION_SPEC.md new file mode 100644 index 00000000..8f1a3490 --- /dev/null +++ b/AGENT_IMPLEMENTATION_SPEC.md @@ -0,0 +1,167 @@ +# AGENT Implementation Spec + +## Goal +重构 Agent 的主循环逻辑:将意图识别、快照管理、字段提取完全交给 LLM 驱动。建立“任务栈”管理机制,确保跨 Skill、中途换话题、回填参数等复杂场景下的状态一致性,并由 DAG 自动化完成执行逻辑。 + +## 1. Core Principles + +### 1.1 必须遵守 +- 大脑前置:每一轮用户输入必须先经过意图识别,禁止规则层先于模型层截断复合语义。 +- 快照优先:LLM 必须感知当前及历史快照(Snapshot/Reference),优先判断输入是否属于对已有任务的续接。 +- DAG 驱动执行:Skill Handler 仅负责原子操作,复杂的逻辑依赖、顺序、确认由 LLM 生成的 DAG 任务包驱动执行。 + +### 1.2 明确禁止 +- 禁止静默失败:参数越界或逻辑冲突时,必须通过 LLM 反馈给用户原因,禁止直接丢弃输入。 +- 禁止单向路由:禁止在单次意图识别中只认一个 Action,必须支持多 Action 列表化输出。 + +## 2. Intent Handling + +### 2.1 总入口 +所有用户输入都必须先经过统一意图识别: +- [x] 是 + +统一意图识别需要判断以下结果: +- [x] `continue` +说明:续接当前快照。 +- [x] `switch` +说明:开启新快照或切换到历史快照。 +- [x] `cancel` +说明:明确取消当前任务。 +- [x] `instant_reply` +说明:直接回答,无需进入快照流转。 + +### 2.2 由谁判断 +意图识别主要由以下方式负责: +- [x] 大模型 + +### 2.3 当前 Flow 中的输入 +`continue`: +- 用户提供了缺失字段。 +- 用户对配置表示确认,例如“OK”“建吧”。 +- 用户对已有配置进行微调。 + +`switch`: +- 用户输入了与当前快照领域无关的新需求。 +- 例如正在调策略时突然要查余额。 +- 例如正在配置一个模型时突然要求配置另一个模型。 + +`cancel`: +- 用户表达了明确的负面意图。 +- 例如“算了”“不要了”“重来”。 + +## 3. Slot Extraction / Field Extraction + +### 3.1 抽取方式 +字段抽取主要由以下方式负责: +- [x] 大模型 + +如果由大模型负责,抽取时必须输入以下上下文: +- [x] 当前 `skill/action` +- [x] 当前 `draft/session` +- [x] 当前缺失字段(来自 DAG 定义) +- [x] 历史对话(近 3 轮) +- [x] 快照 / 当前引用 + +### 3.2 输出格式 +期望大模型返回如下结构化 JSON: + +```json +{ + "intent": "continue | switch | cancel", + "target_snapshot_id": "uuid-xxxx", + "tasks": [ + { + "skill": "strategy", + "action": "create", + "fields": { + "leverage": 20, + "name": "my_strat" + } + } + ], + "reason": "用户提供了杠杆倍数,继续策略创建流程" +} +``` + +### 3.3 合并策略 +- 补全模式:新抽取的字段 merge 到原快照中。 +- 覆盖模式:若用户明确修改已存在的值,以最新输入为准,但必须经过 Validator 重新校验。 + +## 4. Flow / State Machine + +### 4.1 统一状态机 +所有 flow 必须统一走同一个 orchestrator: +- [x] 是 + +Flow 状态至少包含: +- [x] `collecting` +说明:字段收集中。 +- [x] `waiting_confirmation` +说明:待用户确认。 +- [x] `ready` +说明:校验通过。 +- [x] `executing` +说明:DAG 执行中。 +- [x] `suspended` +说明:被新任务压栈挂起。 + +### 4.2 Switch / Suspend / Resume +用户切换话题时,当前任务应该: +- [x] 压栈 +说明:放入 History Snapshots 栈,支持后续唤回。 + +## 5. Skill Scope + +### 5.1 适用范围 +这套方法模式适用于: +- [x] 全部 +说明:实现全架构的语义编排。 + +### 5.2 不允许单独补丁 +是否禁止只针对单个 skill / 单句子打补丁: +- [x] 是 + +补充说明: +- 必须保证所有 Skill 共享同一套 Router 和快照机制。 + +## 6. Risk Control / Validation + +### 6.1 校验层 +字段抽取后必须统一进入 validator: +- [x] 是 + +validator 需要覆盖: +- [x] `strategy` 数值限制(Clamp) +- [x] `model` 配置合法性 +- [x] `exchange` 凭证合法性 +- [x] `trader` 绑定关系合法性 + +### 6.2 错误提示 +- 提示原则:LLM 解析校验失败结果,用自然语言告知用户安全范围。 +- 示例:`杠杆最高 20 倍,已为您设为 20,是否接受?` + +## 7. Performance + +### 7.1 调用策略 +- 流式响应:意图识别确定后,第一时间返回“正在处理[某意图]...”,减少用户感知延迟。 +- LLM Cache:对高频重复意图进行缓存。 + +### 7.2 快路径 +允许不用大模型直接返回的场景: +- 简单打招呼,例如 `Hi`、`Hello` +- 完全匹配的单词退出指令,例如 `exit`、`quit` + +## 8. Testing / Acceptance + +### 8.1 必测场景 +- 意图切换:正在创建策略时,询问“比特币价格”,查完后回答“继续创建刚才的策略吗”并成功恢复快照。 +- 多动作合并:一句话同时完成“创建策略 A”和“配置交易所 B”。 +- 纠错重填:用户输入了错误的杠杆倍数,系统提示纠正后,用户补填正确数值,系统能正确合并到原快照。 + +### 8.2 验收标准 +- 无静默吞咽:任何有效信息必须体现在快照更新或回复中。 +- 快照一致性:`CurrentReferences` 必须能精准映射到用户口中的“它”或“那个策略”。 + +## 9. Notes +- 快照快照还是快照:代码底层必须实现一个 `SnapshotManager`,支持 `Save/Load/List` 动作,供 LLM 通过特定的内部 Tool 进行调用。 +- DAG 是约束而非死板流程:DAG 告诉 LLM 缺什么,但 LLM 决定如何通过对话向用户要到这些。 diff --git a/agent/active_session.go b/agent/active_session.go new file mode 100644 index 00000000..2ce7b6c6 --- /dev/null +++ b/agent/active_session.go @@ -0,0 +1,259 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// ActiveSkillSession is the minimal session for the central brain architecture. +// It replaces the old skillSession + ExecutionState combo for management skill flows. +type ActiveSkillSession struct { + SessionID string `json:"session_id"` + UserID int64 `json:"user_id"` + SkillName string `json:"skill_name"` + ActionName string `json:"action_name"` + LegacyPhase string `json:"legacy_phase,omitempty"` + Goal string `json:"goal,omitempty"` + PendingHint *PendingHint `json:"pending_hint,omitempty"` + CollectedFields map[string]any `json:"collected_fields,omitempty"` + LocalHistory []chatMessage `json:"local_history,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +type PendingHint struct { + Prompt string `json:"prompt,omitempty"` + HintType string `json:"hint_type,omitempty"` +} + +type PendingProposalSession struct { + UserID int64 `json:"user_id"` + SourceUserText string `json:"source_user_text,omitempty"` + ProposalText string `json:"proposal_text,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func activeSkillSessionKey(userID int64) string { + return fmt.Sprintf("agent_active_skill_session_%d", userID) +} + +func pendingProposalSessionKey(userID int64) string { + return fmt.Sprintf("agent_pending_proposal_session_%d", userID) +} + +func (a *Agent) getActiveSkillSession(userID int64) (ActiveSkillSession, bool) { + if a.store == nil { + return ActiveSkillSession{}, false + } + raw, err := a.store.GetSystemConfig(activeSkillSessionKey(userID)) + if err != nil || strings.TrimSpace(raw) == "" { + return ActiveSkillSession{}, false + } + var s ActiveSkillSession + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return ActiveSkillSession{}, false + } + if s.SessionID == "" || s.SkillName == "" { + return ActiveSkillSession{}, false + } + s.PendingHint = normalizePendingHint(s.PendingHint) + return s, true +} + +func (a *Agent) saveActiveSkillSession(s ActiveSkillSession) { + if a.store == nil { + return + } + s.PendingHint = normalizePendingHint(s.PendingHint) + s.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, _ := json.Marshal(s) + _ = a.store.SetSystemConfig(activeSkillSessionKey(s.UserID), string(data)) +} + +func (a *Agent) clearActiveSkillSession(userID int64) { + if a.store == nil { + return + } + _ = a.store.SetSystemConfig(activeSkillSessionKey(userID), "") +} + +func (a *Agent) getPendingProposalSession(userID int64) (PendingProposalSession, bool) { + if a.store == nil { + return PendingProposalSession{}, false + } + raw, err := a.store.GetSystemConfig(pendingProposalSessionKey(userID)) + if err != nil || strings.TrimSpace(raw) == "" { + return PendingProposalSession{}, false + } + var s PendingProposalSession + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return PendingProposalSession{}, false + } + if s.UserID == 0 || strings.TrimSpace(s.ProposalText) == "" { + return PendingProposalSession{}, false + } + return s, true +} + +func (a *Agent) savePendingProposalSession(s PendingProposalSession) { + if a.store == nil { + return + } + s.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, _ := json.Marshal(s) + _ = a.store.SetSystemConfig(pendingProposalSessionKey(s.UserID), string(data)) +} + +func (a *Agent) clearPendingProposalSession(userID int64) { + if a.store == nil { + return + } + _ = a.store.SetSystemConfig(pendingProposalSessionKey(userID), "") +} + +func newActiveSkillSession(userID int64, skill, action string) ActiveSkillSession { + return ActiveSkillSession{ + SessionID: fmt.Sprintf("as_%d", time.Now().UnixNano()), + UserID: userID, + SkillName: skill, + ActionName: action, + LegacyPhase: "collecting", + Goal: "", + PendingHint: nil, + CollectedFields: map[string]any{}, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +func normalizePendingHint(hint *PendingHint) *PendingHint { + if hint == nil { + return nil + } + prompt := strings.TrimSpace(hint.Prompt) + if prompt == "" { + return nil + } + out := &PendingHint{ + Prompt: prompt, + HintType: strings.TrimSpace(hint.HintType), + } + return out +} + +func pendingHintFromAssistantReply(reply string) *PendingHint { + reply = strings.TrimSpace(reply) + if reply == "" { + return nil + } + hintType := "" + switch { + case strings.Contains(reply, "请选择") || strings.Contains(strings.ToLower(reply), "choose"): + hintType = "choice" + case strings.Contains(reply, "确认") || strings.Contains(strings.ToLower(reply), "confirm"): + hintType = "confirmation" + case strings.HasSuffix(reply, "?") || strings.HasSuffix(reply, "?"): + hintType = "question" + } + if hintType == "" { + return nil + } + return &PendingHint{Prompt: reply, HintType: hintType} +} + +func setActiveSessionPendingHint(session *ActiveSkillSession, reply string) { + if session == nil { + return + } + session.PendingHint = pendingHintFromAssistantReply(reply) +} + +func clearActiveSessionPendingHint(session *ActiveSkillSession) { + if session == nil { + return + } + session.PendingHint = nil +} + +func (a *Agent) currentPendingHintText(userID int64) string { + if active, ok := a.getActiveSkillSession(userID); ok && active.PendingHint != nil && strings.TrimSpace(active.PendingHint.Prompt) != "" { + return strings.TrimSpace(active.PendingHint.Prompt) + } + if state := a.getExecutionState(userID); state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" { + return strings.TrimSpace(state.Waiting.Question) + } + if proposal, ok := a.getPendingProposalSession(userID); ok && strings.TrimSpace(proposal.ProposalText) != "" { + return strings.TrimSpace(proposal.ProposalText) + } + return strings.TrimSpace(a.getLastAssistantReply(userID)) +} + +func activeSessionHasField(s ActiveSkillSession, slot string) bool { + slot = strings.TrimSpace(slot) + if slot == "" { + return false + } + if len(s.CollectedFields) == 0 { + return false + } + switch slot { + case "target_ref": + if value, ok := s.CollectedFields["bulk_scope"]; ok && strings.EqualFold(strings.TrimSpace(fmt.Sprint(value)), "all") { + return true + } + for _, key := range []string{"target_ref", "target_ref_id", "target_ref_name"} { + if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { + return true + } + } + return false + default: + value, ok := s.CollectedFields[slot] + return ok && strings.TrimSpace(fmt.Sprint(value)) != "" + } +} + +// missingRequiredFields returns required slots not yet collected, reading from skill registry. +func missingRequiredFields(s ActiveSkillSession) []string { + def, ok := getSkillDefinition(s.SkillName) + if !ok { + return nil + } + actionDef, ok := def.Actions[s.ActionName] + if !ok { + return nil + } + var missing []string + for _, slot := range actionDef.RequiredSlots { + if !activeSessionHasField(s, slot) { + missing = append(missing, slot) + } + } + return missing +} + +// fieldConstraintSummary returns a compact description of missing fields for the LLM prompt. +func fieldConstraintSummary(s ActiveSkillSession) string { + def, ok := getSkillDefinition(s.SkillName) + if !ok { + return "" + } + missing := missingRequiredFields(s) + if len(missing) == 0 { + return "" + } + lines := make([]string, 0, len(missing)) + for _, key := range missing { + constraint, ok := def.FieldConstraints[key] + if !ok { + lines = append(lines, fmt.Sprintf("- %s (required)", key)) + continue + } + desc := constraint.Description + if len(constraint.Values) > 0 { + desc += fmt.Sprintf(" [options: %s]", strings.Join(constraint.Values, ", ")) + } + lines = append(lines, fmt.Sprintf("- %s: %s", key, desc)) + } + return strings.Join(lines, "\n") +} diff --git a/agent/agent.go b/agent/agent.go index 93f6351c..67d91b8f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -11,9 +11,11 @@ import ( "fmt" "log/slog" "net/http" + "os" "sort" "strconv" "strings" + "sync" "time" "nofx/manager" @@ -34,22 +36,30 @@ type Agent struct { history *chatHistory pending *pendingTrades stopCh chan struct{} // signals background goroutines to stop + setupStates sync.Map + flowLocks sync.Map NotifyFunc func(userID int64, text string) error } type Config struct { - Language string `json:"language"` - WatchSymbols []string `json:"watch_symbols"` - EnableBriefs bool `json:"enable_briefs"` - EnableNews bool `json:"enable_news"` - EnableSentinel bool `json:"enable_sentinel"` - BriefTimes []int `json:"brief_times"` + Language string `json:"language"` + WatchSymbols []string `json:"watch_symbols"` + EnableBriefs bool `json:"enable_briefs"` + EnableNews bool `json:"enable_news"` + EnableSentinel bool `json:"enable_sentinel"` + AllowTradeExecution bool `json:"allow_trade_execution"` + BriefTimes []int `json:"brief_times"` } func DefaultConfig() *Config { return &Config{ - Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"}, - EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20}, + Language: "zh", + WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"}, + EnableBriefs: true, + EnableNews: true, + EnableSentinel: true, + AllowTradeExecution: false, + BriefTimes: []int{8, 20}, } } @@ -57,7 +67,7 @@ func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.L if cfg == nil { cfg = DefaultConfig() } - return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})} + return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(chatHistoryMaxTurns), pending: newPendingTrades(), stopCh: make(chan struct{})} } func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c } @@ -69,6 +79,14 @@ func (a *Agent) log() *slog.Logger { return slog.Default() } +func (a *Agent) flowLock(userID int64) *sync.Mutex { + if a == nil { + return &sync.Mutex{} + } + lock, _ := a.flowLocks.LoadOrStore(userID, &sync.Mutex{}) + return lock.(*sync.Mutex) +} + func (a *Agent) EnsureAIClient() { a.ensureAIClientForStoreUser("default") } @@ -100,45 +118,79 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str if storeUserID == "" { storeUserID = "default" } + candidateUserIDs := []string{storeUserID} + if storeUserID != "default" { + candidateUserIDs = append(candidateUserIDs, "default") + } + for _, candidateUserID := range candidateUserIDs { + models, err := a.store.AIModel().List(candidateUserID) + if err != nil { + a.log().Warn("failed to list AI models for store user", "store_user_id", candidateUserID, "error", err) + continue + } + for _, model := range models { + if model == nil || !model.Enabled || !agentModelHasUsableAPIKey(model) { + continue + } - model, err := a.store.AIModel().GetDefault(storeUserID) - if err != nil || model == nil { - a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err) - return nil, "", false + a.log().Info( + "agent evaluating AI model config", + "store_user_id", candidateUserID, + "model_id", model.ID, + "provider", model.Provider, + "enabled", model.Enabled, + "has_api_key", len(model.APIKey) > 0, + "custom_api_url", strings.TrimSpace(model.CustomAPIURL), + "custom_model_name", strings.TrimSpace(model.CustomModelName), + ) + + apiKey := strings.TrimSpace(string(model.APIKey)) + customAPIURL := strings.TrimSpace(model.CustomAPIURL) + modelName := strings.TrimSpace(model.CustomModelName) + customAPIURL, modelName = resolveModelRuntimeConfig(model.Provider, customAPIURL, modelName, model.ID) + if apiKey == "" || customAPIURL == "" { + a.log().Warn( + "skipping incomplete enabled AI model", + "store_user_id", candidateUserID, + "model_id", model.ID, + "provider", model.Provider, + "has_api_key", apiKey != "", + "has_custom_api_url", customAPIURL != "", + ) + continue + } + + httpClient := &http.Client{Timeout: 60 * time.Second} + client := mcp.NewClient(mcp.WithHTTPClient(httpClient)) + client.SetAPIKey(apiKey, customAPIURL, modelName) + a.log().Info("agent AI client selected", "store_user_id", candidateUserID, "model_id", model.ID, "model", modelName) + return client, modelName, true + } } - a.log().Info( - "agent selected AI model config", - "store_user_id", storeUserID, - "model_id", model.ID, - "provider", model.Provider, - "enabled", model.Enabled, - "has_api_key", len(model.APIKey) > 0, - "custom_api_url", strings.TrimSpace(model.CustomAPIURL), - "custom_model_name", strings.TrimSpace(model.CustomModelName), - ) + a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID) + return nil, "", false +} - apiKey := string(model.APIKey) - customAPIURL := strings.TrimSpace(model.CustomAPIURL) - modelName := strings.TrimSpace(model.CustomModelName) - customAPIURL, modelName = resolveModelRuntimeConfig(model.Provider, customAPIURL, modelName, model.ID) - if apiKey == "" || customAPIURL == "" { - a.log().Warn( - "enabled AI model is incomplete", - "store_user_id", storeUserID, - "model_id", model.ID, - "provider", model.Provider, - "has_api_key", apiKey != "", - "has_custom_api_url", customAPIURL != "", - ) - return nil, "", false +func agentModelHasUsableAPIKey(model *store.AIModel) bool { + if model == nil { + return false } - - httpClient := &http.Client{Timeout: 60 * time.Second} - client := mcp.NewClient(mcp.WithHTTPClient(httpClient)) - name := modelName - client.SetAPIKey(apiKey, customAPIURL, name) - return client, name, true + if strings.TrimSpace(string(model.APIKey)) != "" { + return true + } + envKeyByProvider := map[string]string{ + "deepseek": "DEEPSEEK_API_KEY", + "openai": "OPENAI_API_KEY", + "claude": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", + "grok": "XAI_API_KEY", + "kimi": "MOONSHOT_API_KEY", + "minimax": "MINIMAX_API_KEY", + "qwen": "DASHSCOPE_API_KEY", + } + envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))] + return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != "" } func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) { @@ -160,6 +212,7 @@ func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallback "grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"}, "kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"}, "minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"}, + "claw402": {url: "https://claw402.ai", model: "deepseek"}, } if customAPIURL == "" { @@ -248,9 +301,7 @@ func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID strin return a.handleStatus(lang), nil } if text == "/clear" { - a.history.Clear(userID) - a.clearTaskState(userID) - a.clearExecutionState(userID) + a.clearConversationState(userID) if lang == "zh" { return "🧹 对话记忆已清除。", nil } @@ -294,9 +345,7 @@ func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID return a.handleStatus(lang), nil } if text == "/clear" { - a.history.Clear(userID) - a.clearTaskState(userID) - a.clearExecutionState(userID) + a.clearConversationState(userID) if lang == "zh" { return "🧹 对话记忆已清除。", nil } @@ -304,13 +353,31 @@ func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID } if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled { if onEvent != nil { - onEvent(StreamEventDelta, reply) + emitStreamText(onEvent, reply) } return reply, nil } return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent) } +func (a *Agent) clearConversationState(userID int64) { + if a == nil { + return + } + if a.history != nil { + a.history.Clear(userID) + } + a.clearTaskState(userID) + a.clearSkillSession(userID) + a.clearActiveSkillSession(userID) + a.clearPendingProposalSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + a.clearReferenceMemory(userID) + a.SnapshotManager(userID).Clear() + a.clearSetupState(userID) +} + // StreamEvent types sent via SSE to the frontend. const ( StreamEventPlanning = "planning" @@ -367,18 +434,22 @@ func (a *Agent) buildSystemPrompt(lang string) string { ## 工具使用 你可以调用以下工具来执行操作: - **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。 -- **execute_trade** — 下单交易(加密货币或美股)。美股:open_long=买入,close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。 +- **execute_trade** — 下单交易(加密货币或美股)。常见写法:"做多 BTC 0.01 x10"、"做空 ETH 0.1"、"平多 BTC"、"平空 ETH";英文也支持 "long BTC 0.01 x10"、"short ETH 0.1"、"close long BTC"、"close short ETH"。美股:open_long=买入,close_long=卖出。调用后先创建待确认订单,不会立刻成交。若触发大额风控,用户必须回复"确认大额 trade_xxx";待确认订单 5 分钟后自动失效。 - **get_positions** — 查看当前所有持仓(加密货币 + 股票) - **get_balance** — 查看账户余额 - **get_market_price** — 获取实时价格(加密货币或股票代码) +- **get_kline** — 获取最近 K 线 / 蜡烛图数据(适合“看 15 分钟 K 线”“最近 50 根 1 小时 K 线”) - **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置 - **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置 - **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板 - **manage_trader** — 查看、新增、修改、删除、启动、停止交易员 +- **get_watchlist / manage_watchlist** — 查看、添加、移除运行时监控币对,适合“把 BTC 加入监控”“别再监控 SOL”这类请求 ### 配置、策略与交易员管理规则 - 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy - **策略模板本身是独立资源,不默认依赖交易所或 AI 模型** +- **策略模板不能直接启动或运行;只有交易员有运行态。** +- 如果用户说“启动策略 / 运行策略”,要明确说明:应先把策略绑定到交易员,再启动交易员 - 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader - 当用户要求配置交易所、绑定 API Key、修改交易所账户时,优先使用 manage_exchange_config - 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时,优先使用 manage_model_config @@ -391,9 +462,10 @@ func (a *Agent) buildSystemPrompt(lang string) string { ### 交易安全规则 - 用户明确要求交易时才调用 execute_trade +- 下单前先尊重风控:数量过大、仓位太小、杠杆过高、超过权益上限时,不要假装能下单,要直接用人话解释原因 - 分析和建议不需要调用工具,直接回复即可 - 交易确认信息要清晰展示:品种、方向、数量、杠杆 -- 提醒用户确认命令格式 +- 提醒用户确认命令格式;普通订单用“确认 trade_xxx”,大额订单用“确认大额 trade_xxx” ### 数据真实性规则(极其重要!) - **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓 @@ -404,6 +476,10 @@ func (a *Agent) buildSystemPrompt(lang string) string { - 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓" ## 行为准则 +- 把用户当交易小白,而不是开发者或量化工程师。 +- 先说结论,再说原因和下一步。 +- 语言要简单、清楚、直接,少用术语。 +- 如果必须用术语,立刻用大白话解释。 - 简洁、专业、有观点。不说废话。 - 用户问什么答什么,不要推销配置。 - 有实时数据时给具体价位,没有时给策略框架和思路。 @@ -446,10 +522,11 @@ func (a *Agent) buildSystemPrompt(lang string) string { ## Tools You can call these tools to take action: - **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock. -- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation. +- **execute_trade** — Place a trade order (crypto or US stocks). Common phrasings include "long BTC 0.01 x10", "short ETH 0.1", "close long BTC", and "close short ETH". For stocks: open_long=buy, close_long=sell. This creates a pending trade first; it does not execute immediately. Large orders require "confirm large trade_xxx", and pending trades expire after 5 minutes. - **get_positions** — View all current open positions (crypto + stocks) - **get_balance** — View account balance and equity - **get_market_price** — Get real-time price from the exchange (crypto or stock symbol) +- **get_kline** — Get recent candlestick / kline data for a crypto symbol - **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings - **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings - **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates @@ -458,10 +535,13 @@ You can call these tools to take action: ### Configuration, Strategy, and Trader Rules - When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy - **A strategy template is an independent asset and does not require exchange or model bindings by default** +- **A strategy template cannot be started or run directly; only traders have runtime state** +- If the user says "start the strategy" or "run this strategy", explain that the strategy must be attached to a trader first, then the trader can be started - Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader - When the user wants to bind or edit an exchange account, prefer manage_exchange_config - When the user wants to bind or edit an AI model, prefer manage_model_config - When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader +- When the user wants to add, remove, or inspect monitored coins, prefer get_watchlist / manage_watchlist - If required fields are missing, ask a focused follow-up question first, then call the tool - **Do not claim the system lacks these capabilities when the tools exist** - For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full @@ -470,9 +550,10 @@ You can call these tools to take action: ### Trade Safety Rules - Only call execute_trade when user explicitly requests a trade +- Respect risk guardrails before placing a trade: if the quantity is too large, the notional is too small, leverage is too high, or the order exceeds equity limits, explain the reason plainly instead of pretending it can be placed - Analysis and advice don't need tools — just reply directly - Show trade details clearly: symbol, direction, quantity, leverage -- Remind user of the confirmation command format +- Remind user of the confirmation command format; normal orders use "confirm trade_xxx", large orders use "confirm large trade_xxx" ### Data Truthfulness Rules (CRITICAL!) - **Position data MUST come from get_positions tool only** — NEVER fabricate positions @@ -483,6 +564,10 @@ You can call these tools to take action: - Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding" ## Behavior +- Treat the user like a trading beginner, not a developer. +- Lead with the conclusion first, then explain the reason and next step. +- Use plain language and keep jargon to a minimum. +- If you must use a technical term, explain it in simple words immediately. - Concise, professional, opinionated. No fluff. - Answer what's asked. Don't push setup. - With real-time data: give specific levels. Without: give strategy frameworks. @@ -649,9 +734,9 @@ func (a *Agent) noAIFallback(lang, text string) (string, error) { } if lang == "zh" { - return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用:\n• 加密货币实时行情(试试「BTC」)\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil + return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用:\n• 加密货币实时行情(试试「BTC」)\n• `/status` 查看系统状态\n• `/clear` 清空当前对话记忆\n\n发送 *开始配置* 配置 AI 模型。", nil } - return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil + return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` to check system status\n• `/clear` to clear the current conversation memory\n\nSend *setup* to configure AI.", nil } func (a *Agent) aiServiceFailure(lang string, err error) (string, error) { diff --git a/agent/atomic_skill_executor.go b/agent/atomic_skill_executor.go new file mode 100644 index 00000000..7fa2cfb5 --- /dev/null +++ b/agent/atomic_skill_executor.go @@ -0,0 +1,87 @@ +package agent + +import "strings" + +func (a *Agent) executeAtomicSkillTask(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (string, bool) { + return a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent) +} + +func (a *Agent) executeAtomicSkillTaskWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (string, bool) { + skill := strings.TrimSpace(session.Name) + action := normalizeAtomicSkillAction(skill, session.Action) + session.Name = skill + session.Action = action + if strings.TrimSpace(session.Phase) == "" { + session.Phase = "collecting" + } + skill = strings.TrimSpace(skill) + action = normalizeAtomicSkillAction(skill, action) + + var ( + answer string + handled bool + ) + + switch skill { + case "trader_management": + if action == "create" { + answer, handled = a.handleCreateTraderSkill(storeUserID, userID, lang, text, session) + } else { + answer, handled = a.handleTraderManagementSkill(storeUserID, userID, lang, text, session) + if handled && action == "query_running" { + answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only") + } + } + case "exchange_management": + answer, handled = a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session) + case "model_management": + answer, handled = a.handleModelManagementSkill(storeUserID, userID, lang, text, session) + case "strategy_management": + answer, handled = a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session) + case "model_diagnosis": + answer, handled = a.handleModelDiagnosisSkill(storeUserID, lang, text), true + case "exchange_diagnosis": + answer, handled = a.handleExchangeDiagnosisSkill(storeUserID, lang, text), true + case "trader_diagnosis": + answer, handled = a.handleTraderDiagnosisSkill(storeUserID, lang, text), true + case "strategy_diagnosis": + answer, handled = a.handleStrategyDiagnosisSkill(storeUserID, lang, text), true + default: + return "", false + } + + if handled && onEvent != nil { + label := "atomic_skill:" + skill + if action != "" { + label += ":" + action + } + onEvent(StreamEventTool, label) + emitStreamText(onEvent, answer) + } + return answer, handled +} + +func (a *Agent) executeAtomicSkillTaskOutcome(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (skillOutcome, bool) { + return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent) +} + +func (a *Agent) executeAtomicSkillTaskOutcomeWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (skillOutcome, bool) { + answer, handled := a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, session, onEvent) + if !handled { + return skillOutcome{}, false + } + skill := strings.TrimSpace(session.Name) + action := normalizeAtomicSkillAction(skill, session.Action) + switch skill { + case "model_diagnosis", "exchange_diagnosis", "trader_diagnosis", "strategy_diagnosis": + return skillOutcome{ + Skill: skill, + Action: defaultIfEmpty(action, "diagnose"), + Status: skillOutcomeSuccess, + GoalAchieved: true, + UserMessage: answer, + }, true + default: + return inferSkillOutcome(skill, action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, skill, action, a)), true + } +} diff --git a/agent/backend_logs_test.go b/agent/backend_logs_test.go deleted file mode 100644 index 16f37a64..00000000 --- a/agent/backend_logs_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package agent - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "nofx/store" -) - -func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) { - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmp := t.TempDir() - if err := os.Chdir(tmp); err != nil { - t.Fatalf("Chdir(tmp) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(wd) - }) - - if err := os.MkdirAll("data", 0o755); err != nil { - t.Fatalf("MkdirAll(data) error = %v", err) - } - logPath := filepath.Join("data", "nofx_2099-01-01.log") - content := strings.Join([]string{ - "04-19 13:00:00 [INFO] api/server.go:590 API server starting", - "04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account", - "04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key", - }, "\n") + "\n" - if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - path, entries, err := readBackendLogEntries(10, "model", true) - if err != nil { - t.Fatalf("readBackendLogEntries() error = %v", err) - } - if !strings.Contains(path, "nofx_2099-01-01.log") { - t.Fatalf("unexpected log path: %s", path) - } - if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") { - t.Fatalf("unexpected filtered entries: %#v", entries) - } -} - -func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) { - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmp := t.TempDir() - if err := os.Chdir(tmp); err != nil { - t.Fatalf("Chdir(tmp) error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(wd) - }) - - if err := os.MkdirAll("data", 0o755); err != nil { - t.Fatalf("MkdirAll(data) error = %v", err) - } - logPath := filepath.Join("data", "nofx_2099-01-01.log") - content := strings.Join([]string{ - "04-19 13:00:00 [INFO] api/server.go:590 API server starting", - "04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed", - "04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed", - }, "\n") + "\n" - if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - a := newTestAgentWithStore(t) - if err := a.store.Trader().Create(&store.Trader{ - ID: "trader-owned", - UserID: "user-1", - Name: "Owned Trader", - AIModelID: "model-1", - ExchangeID: "exchange-1", - StrategyID: "strategy-1", - InitialBalance: 1000, - }); err != nil { - t.Fatalf("create owned trader: %v", err) - } - if err := a.store.Trader().Create(&store.Trader{ - ID: "trader-other", - UserID: "user-2", - Name: "Other Trader", - AIModelID: "model-2", - ExchangeID: "exchange-2", - StrategyID: "strategy-2", - InitialBalance: 1000, - }); err != nil { - t.Fatalf("create other trader: %v", err) - } - - resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`) - var okResult struct { - TraderID string `json:"trader_id"` - Entries []string `json:"entries"` - Count int `json:"count"` - } - if err := json.Unmarshal([]byte(resp), &okResult); err != nil { - t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp) - } - if okResult.TraderID != "trader-owned" || okResult.Count != 1 { - t.Fatalf("unexpected owned response: %+v", okResult) - } - if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") { - t.Fatalf("unexpected owned entries: %#v", okResult.Entries) - } - - resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`) - var denied struct { - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(resp), &denied); err != nil { - t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp) - } - if denied.Error != "trader not found for current user" { - t.Fatalf("unexpected denied response: %+v", denied) - } -} diff --git a/agent/brain.go b/agent/brain.go index c3c267c9..dfb146bf 100644 --- a/agent/brain.go +++ b/agent/brain.go @@ -17,6 +17,7 @@ type Brain struct { logger *slog.Logger http *http.Client stopCh chan struct{} + stopOnce sync.Once recentSignals sync.Map // debounce } @@ -29,7 +30,11 @@ func NewBrain(agent *Agent, logger *slog.Logger) *Brain { } } -func (b *Brain) Stop() { close(b.stopCh) } +func (b *Brain) Stop() { + b.stopOnce.Do(func() { + close(b.stopCh) + }) +} // cleanStaleSignals removes debounce entries older than 30 minutes. func (b *Brain) cleanStaleSignals() { @@ -53,22 +58,26 @@ func (b *Brain) HandleSignal(sig Signal) { emoji := map[string]string{"info": "ℹ️", "warning": "⚠️", "critical": "🚨"} e := emoji[sig.Severity] - if e == "" { e = "📊" } + if e == "" { + e = "📊" + } b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail)) } func (b *Brain) StartNewsScan(interval time.Duration) { seen := make(map[string]bool) + seenOrder := make([]string, 0, 1024) safe.GoNamed("brain-news-scan", func() { ticker := time.NewTicker(interval) defer ticker.Stop() cleanTick := 0 for { select { - case <-b.stopCh: return + case <-b.stopCh: + return case <-ticker.C: - b.scanNews(seen) + b.scanNews(seen, &seenOrder) cleanTick++ if cleanTick%6 == 0 { // every ~30 min b.cleanStaleSignals() @@ -78,16 +87,20 @@ func (b *Brain) StartNewsScan(interval time.Duration) { }) } -func (b *Brain) scanNews(seen map[string]bool) { +func (b *Brain) scanNews(seen map[string]bool, seenOrder *[]string) { resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest") - if err != nil { return } + if err != nil { + return + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b.logger.Debug("news API non-200", "status", resp.StatusCode) return } body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit - if err != nil { return } + if err != nil { + return + } var result struct { Data []struct { @@ -99,39 +112,65 @@ func (b *Brain) scanNews(seen map[string]bool) { PublishedOn int64 `json:"published_on"` } `json:"Data"` } - if err := json.Unmarshal(body, &result); err != nil { return } + if err := json.Unmarshal(body, &result); err != nil { + return + } bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"} bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"} for _, d := range result.Data { - if seen[d.URL] { continue } + if seen[d.URL] { + continue + } seen[d.URL] = true - if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue } + *seenOrder = append(*seenOrder, d.URL) + if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { + continue + } lower := strings.ToLower(d.Title + " " + d.Body) bc, brc := 0, 0 - for _, w := range bullish { if strings.Contains(lower, w) { bc++ } } - for _, w := range bearish { if strings.Contains(lower, w) { brc++ } } + for _, w := range bullish { + if strings.Contains(lower, w) { + bc++ + } + } + for _, w := range bearish { + if strings.Contains(lower, w) { + brc++ + } + } - if bc == 0 && brc == 0 { continue } + if bc == 0 && brc == 0 { + continue + } emoji := "📰" sentiment := "NEUTRAL" - if bc > brc { emoji = "🟢"; sentiment = "BULLISH" } - if brc > bc { emoji = "🔴"; sentiment = "BEARISH" } + if bc > brc { + emoji = "🟢" + sentiment = "BULLISH" + } + if brc > bc { + emoji = "🔴" + sentiment = "BEARISH" + } b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s", emoji, d.Title, d.Source, sentiment)) } - // Evict ~half when seen map gets large (keep recent half to avoid re-notifying) + // Evict the oldest half when seen grows large so recent URLs stay deduped deterministically. if len(seen) > 1000 { - i, half := 0, len(seen)/2 - for k := range seen { - if i >= half { break } - delete(seen, k) - i++ + half := len(seen) / 2 + for i := 0; i < half && i < len(*seenOrder); i++ { + delete(seen, (*seenOrder)[i]) + } + if half < len(*seenOrder) { + *seenOrder = append((*seenOrder)[:0], (*seenOrder)[half:]...) + } else { + *seenOrder = (*seenOrder)[:0] } } } @@ -143,7 +182,8 @@ func (b *Brain) StartMarketBriefs(hours []int) { sent := make(map[string]bool) for { select { - case <-b.stopCh: return + case <-b.stopCh: + return case now := <-ticker.C: key := now.Format("2006-01-02-15") for _, h := range hours { @@ -159,21 +199,35 @@ func (b *Brain) StartMarketBriefs(hours []int) { func (b *Brain) sendBrief(hour int) { title := "☀️ *早间市场简报*" - if hour >= 18 { title = "🌙 *晚间市场简报*" } + if hour >= 18 { + title = "🌙 *晚间市场简报*" + } // Fetch BTC/ETH prices for the brief var btcPrice, ethPrice, btcChg, ethChg string for _, sym := range []string{"BTCUSDT", "ETHUSDT"} { resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym)) - if err != nil { continue } + if err != nil { + continue + } body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit statusOK := resp.StatusCode == http.StatusOK resp.Body.Close() - if readErr != nil || !statusOK { continue } + if readErr != nil || !statusOK { + continue + } var t map[string]string - if err := json.Unmarshal(body, &t); err != nil { continue } - if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] } - if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] } + if err := json.Unmarshal(body, &t); err != nil { + continue + } + if sym == "BTCUSDT" { + btcPrice = t["lastPrice"] + btcChg = t["priceChangePercent"] + } + if sym == "ETHUSDT" { + ethPrice = t["lastPrice"] + ethChg = t["priceChangePercent"] + } } brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_", diff --git a/agent/central_brain.go b/agent/central_brain.go new file mode 100644 index 00000000..c6b1a5d2 --- /dev/null +++ b/agent/central_brain.go @@ -0,0 +1,671 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "nofx/mcp" +) + +// brainDecision is the routing contract between the first-pass LLM and the executor. +type brainDecision struct { + ThoughtProcess string `json:"thought_process"` + ActionType string `json:"action_type"` // CONTINUE_TASK | NEW_TASK | EXPLAIN_KNOWLEDGE | CANCEL_TASK + TargetSkill string `json:"target_skill,omitempty"` // "skill_name:action" for NEW_TASK + ExtractedData map[string]any `json:"extracted_data,omitempty"` + ReplyToUser string `json:"reply_to_user"` +} + +// activeSessionStepDecision is the per-turn control loop inside one active skill task. +type activeSessionStepDecision struct { + Route string `json:"route"` // ask_user | execute_skill | finish_task | cancel_task + Reply string `json:"reply,omitempty"` + ExtractedData map[string]any `json:"extracted_data,omitempty"` +} + +// tryMinimalBrain is the single entry point replacing tryUnifiedSemanticGateway. +// Intelligence layer: one routing LLM call → active-session loop → legacy skill execution. +func (a *Agent) tryMinimalBrain(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { + if a.aiClient == nil { + return "", false, nil + } + + activeSession, hasActive := a.getActiveSkillSession(userID) + recentHistory := a.buildRecentConversationContext(userID, text) + currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) + previousAssistantReply := a.currentPendingHintText(userID) + + systemPrompt := buildBrainSystemPrompt(lang) + userPrompt := buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs, activeSession, hasActive) + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return "", false, nil + } + + decision, ok := parseBrainDecision(raw) + if !ok { + return "", false, nil + } + + return a.executeBrainDecision(ctx, storeUserID, userID, lang, text, decision, activeSession, hasActive, onEvent) +} + +func buildBrainSystemPrompt(lang string) string { + return prependNOFXiAdvisorPreamble(`You are the central brain of NOFXi. Read the intelligence report and output ONE JSON decision. No markdown, no extra text. + +Available action_type values: +- "CONTINUE_TASK": user is continuing the current active task +- "NEW_TASK": user is starting a new task +- "EXPLAIN_KNOWLEDGE": user is asking a knowledge question only +- "CANCEL_TASK": user wants to stop the current task + +Available skills (for NEW_TASK target_skill): +trader_management, exchange_management, model_management, strategy_management, +trader_diagnosis, exchange_diagnosis, model_diagnosis, strategy_diagnosis + +Available actions: +create, update, update_name, update_bindings, configure_strategy, configure_exchange, configure_model, +update_status, update_endpoint, update_config, update_prompt, delete, start, stop, activate, duplicate, +query_list, query_detail, query_running + +Rules: +- Prefer CONTINUE_TASK when there is an active task and the user is still talking about it. +- If the current user message is only a greeting, thanks, acknowledgement, or lightweight social chat like "你好", "hi", "hello", "thanks", "谢谢", "收到", do NOT continue the task. +- For those lightweight social messages, choose EXPLAIN_KNOWLEDGE and reply naturally, or let the task stay suspended. +- Use NEW_TASK only when there is no active task, or the user clearly switches goals/domains. +- Use EXPLAIN_KNOWLEDGE for concept/range/help questions; do not change state. When answering, use ONLY the options/values listed in the active session's missing_required_fields. Never invent field values or provider names. +- Use CANCEL_TASK for "cancel", "stop", "forget it", "never mind", "算了", "取消". +- Domain guard: if the user says "模型", "AI 模型", or "model" and asks to create or configure one, you must route to model_management, not exchange_management. +- Domain guard: for model_management, the field "provider" means the AI model vendor such as OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, Minimax, claw402, blockrun-base, or blockrun-sol. It never means an exchange like Binance, OKX, Bybit, CFD, forex, or metals. +- extracted_data should include any concrete facts from the user's message. +- If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}. +- reply_to_user should be concise and in the user's language. +- For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create". + +Output shape (JSON only): +{"thought_process":"...","action_type":"...","target_skill":"...","extracted_data":{},"reply_to_user":"..."}`) +} + +func buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs string, activeSession ActiveSkillSession, hasActive bool) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Language: %s\nUser message: %s\n\n", lang, text)) + sb.WriteString("=== PREVIOUS ASSISTANT REPLY ===\n") + sb.WriteString(defaultIfEmpty(strings.TrimSpace(previousAssistantReply), "none")) + sb.WriteString("\n\n") + sb.WriteString("=== MANAGEMENT DOMAIN PRIMER ===\n") + if hasActive { + sb.WriteString(defaultIfEmpty(buildSkillDomainPrimer(lang, activeSession.SkillName), "none")) + } else { + sb.WriteString(defaultIfEmpty(buildManagementDomainPrimer(lang), "none")) + } + sb.WriteString("\n\n") + + sb.WriteString("=== ACTIVE SESSION ===\n") + if hasActive { + sb.WriteString(fmt.Sprintf("skill: %s\naction: %s\n", activeSession.SkillName, activeSession.ActionName)) + if strings.TrimSpace(activeSession.Goal) != "" { + sb.WriteString(fmt.Sprintf("goal: %s\n", activeSession.Goal)) + } + if activeSession.PendingHint != nil && strings.TrimSpace(activeSession.PendingHint.Prompt) != "" { + sb.WriteString(fmt.Sprintf("pending_hint: %s\n", strings.TrimSpace(activeSession.PendingHint.Prompt))) + } + if len(activeSession.CollectedFields) > 0 { + fieldsJSON, _ := json.Marshal(activeSession.CollectedFields) + sb.WriteString(fmt.Sprintf("collected_fields: %s\n", fieldsJSON)) + } + if missing := fieldConstraintSummary(activeSession); missing != "" { + sb.WriteString("missing_required_fields:\n") + sb.WriteString(missing) + sb.WriteString("\n") + } + } else { + sb.WriteString("none\n") + } + + sb.WriteString("\n=== CURRENT REFERENCES ===\n") + sb.WriteString(currentRefs) + + sb.WriteString("\n\n=== RECENT CONVERSATION ===\n") + sb.WriteString(recentHistory) + + return sb.String() +} + +func parseBrainDecision(raw string) (brainDecision, bool) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var d brainDecision + if err := json.Unmarshal([]byte(raw), &d); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return brainDecision{}, false + } + if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil { + return brainDecision{}, false + } + } + d.ActionType = strings.ToUpper(strings.TrimSpace(d.ActionType)) + d.TargetSkill = strings.TrimSpace(d.TargetSkill) + d.ReplyToUser = strings.TrimSpace(d.ReplyToUser) + switch d.ActionType { + case "CONTINUE_TASK", "NEW_TASK", "EXPLAIN_KNOWLEDGE", "CANCEL_TASK": + return d, true + default: + return brainDecision{}, false + } +} + +func parseActiveSessionStepDecision(raw string) (activeSessionStepDecision, bool) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var d activeSessionStepDecision + if err := json.Unmarshal([]byte(raw), &d); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return activeSessionStepDecision{}, false + } + if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil { + return activeSessionStepDecision{}, false + } + } + d.Route = strings.TrimSpace(strings.ToLower(d.Route)) + d.Reply = strings.TrimSpace(d.Reply) + switch d.Route { + case "ask_user", "execute_skill", "finish_task", "cancel_task": + return d, true + default: + return activeSessionStepDecision{}, false + } +} + +func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, d brainDecision, activeSession ActiveSkillSession, hasActive bool, onEvent func(event, data string)) (string, bool, error) { + switch d.ActionType { + case "CANCEL_TASK": + a.clearActiveSkillSession(userID) + a.clearAnyActiveContext(userID) + reply := d.ReplyToUser + if reply == "" { + if lang == "zh" { + reply = "已取消当前流程。" + } else { + reply = "Cancelled the current flow." + } + } + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + + case "EXPLAIN_KNOWLEDGE": + reply := d.ReplyToUser + if reply == "" { + return "", false, nil + } + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + + case "NEW_TASK": + skill, action := parseTargetSkill(d.TargetSkill) + if skill == "" { + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + return answer, true, err + } + session := newActiveSkillSession(userID, skill, action) + session.Goal = strings.TrimSpace(text) + mergeExtractedData(&session, d.ExtractedData) + return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) + + case "CONTINUE_TASK": + if !hasActive { + return "", false, nil + } + mergeExtractedData(&activeSession, d.ExtractedData) + return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent) + + default: + return "", false, nil + } +} + +func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession, onEvent func(event, data string)) (string, bool, error) { + if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, activeToLegacySkillSession(session), text); ok { + session = appendActiveSessionLocalHistory(session, "user", text) + session = appendActiveSessionLocalHistory(session, "assistant", answer) + setActiveSessionPendingHint(&session, answer) + a.saveActiveSkillSession(session) + emitBrainReply(onEvent, answer) + a.recordSkillInteraction(userID, text, answer) + return answer, true, nil + } + + session = appendActiveSessionLocalHistory(session, "user", text) + clearActiveSessionPendingHint(&session) + + stepDecision, ok := a.planActiveSessionStep(ctx, storeUserID, userID, lang, text, session) + if !ok { + stepDecision = activeSessionStepDecision{} + } + mergeExtractedData(&session, stepDecision.ExtractedData) + + if stepDecision.Route == "" { + if len(missingRequiredFields(session)) > 0 { + stepDecision.Route = "ask_user" + } else { + stepDecision.Route = "execute_skill" + } + } + + switch stepDecision.Route { + case "cancel_task": + a.clearActiveSkillSession(userID) + reply := defaultIfEmpty(stepDecision.Reply, "已取消当前流程。") + if lang != "zh" && strings.TrimSpace(stepDecision.Reply) == "" { + reply = "Cancelled the current flow." + } + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + + case "finish_task": + a.clearActiveSkillSession(userID) + reply := strings.TrimSpace(stepDecision.Reply) + if reply == "" { + return "", false, nil + } + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + + case "ask_user": + reply := strings.TrimSpace(stepDecision.Reply) + if reply == "" { + reply = a.askForMissingFields(lang, session) + } + if len(missingRequiredFields(session)) == 0 && actionNeedsConfirmation(session.SkillName, session.ActionName) { + session.LegacyPhase = "await_confirmation" + session.CollectedFields["phase"] = "await_confirmation" + } + session = appendActiveSessionLocalHistory(session, "assistant", reply) + setActiveSessionPendingHint(&session, reply) + a.saveActiveSkillSession(session) + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + + case "execute_skill": + outcome, nextSession, pending, ok := a.executeActiveSkillSession(storeUserID, userID, lang, text, session) + if !ok { + return "", false, nil + } + if pending { + reply := strings.TrimSpace(outcome.UserMessage) + if reply == "" { + reply = a.askForMissingFields(lang, nextSession) + } + nextSession = appendActiveSessionLocalHistory(nextSession, "assistant", reply) + setActiveSessionPendingHint(&nextSession, reply) + a.saveActiveSkillSession(nextSession) + emitBrainReply(onEvent, reply) + a.recordSkillInteraction(userID, text, reply) + return reply, true, nil + } + + review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome) + if err != nil { + review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage} + } + answer := strings.TrimSpace(review.Answer) + if answer == "" { + answer = strings.TrimSpace(outcome.UserMessage) + } + if review.Route == "replan" && answer == "" { + answer = outcome.UserMessage + } + if answer == "" { + return "", false, nil + } + a.clearActiveSkillSession(userID) + emitBrainReply(onEvent, answer) + a.recordSkillInteraction(userID, text, answer) + return answer, true, nil + + default: + return "", false, nil + } +} + +func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (activeSessionStepDecision, bool) { + if a.aiClient == nil { + return activeSessionStepDecision{}, false + } + + legacy := activeToLegacySkillSession(session) + resources := a.buildActiveSessionResources(storeUserID, legacy) + resourcesJSON, _ := json.Marshal(resources) + collectedJSON, _ := json.Marshal(session.CollectedFields) + missingSummary := formatConversationMissingFields(lang, missingRequiredFieldsForBrain(session)) + localHistory := formatActiveSessionLocalHistory(session.LocalHistory) + if localHistory == "" { + localHistory = "(empty)" + } + previousAssistantReply := a.currentPendingHintText(userID) + + domainPrimer := buildSkillDomainPrimer(lang, session.SkillName) + + systemPrompt := prependNOFXiAdvisorPreamble(fmt.Sprintf(`You are the active-task orchestration loop for NOFXi. +You decide the NEXT step for exactly one active task. Return JSON only. + +Active task: +- skill: %s +- action: %s +- goal: %s + +Current collected fields: +%s + +Current missing field summary: +%s + +Relevant disclosed resources: +%s + +Domain knowledge: +%s + +Rules: +- Your job is to decide the next move, not to explain internal schema names. +- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal, confirmation request, or question. +- Use contextual memory from the active task history and current references. +- Prefer "execute_skill" when the user has already given enough information to act. +- Prefer "ask_user" only when something truly necessary is still missing. +- If the current message is only a greeting, thanks, acknowledgement, or small talk and does not add task information, do NOT continue task execution. Choose "ask_user" only if you need to gently restate what is pending; otherwise choose "finish_task" with a short social reply. +- Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details. +- If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref. +- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it. +- 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. +- 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". + +Return JSON with this exact shape: +{"route":"ask_user|execute_skill|finish_task|cancel_task","reply":"","extracted_data":{}}`, + session.SkillName, + session.ActionName, + defaultIfEmpty(session.Goal, "(not set)"), + defaultIfEmpty(string(collectedJSON), "{}"), + missingSummary, + defaultIfEmpty(string(resourcesJSON), "{}"), + 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) + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return activeSessionStepDecision{}, false + } + return parseActiveSessionStepDecision(raw) +} + +func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) { + legacy := activeToLegacySkillSession(session) + a.saveSkillSession(userID, legacy) + answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, legacy) + if !handled { + a.clearSkillSession(userID) + return skillOutcome{}, ActiveSkillSession{}, false, false + } + + updatedLegacy := a.getSkillSession(userID) + a.clearSkillSession(userID) + outcome := inferSkillOutcome(session.SkillName, session.ActionName, answer, updatedLegacy, skillDataForAction(storeUserID, session.SkillName, session.ActionName, a)) + if updatedLegacy.Name != "" { + nextSession := activeSessionFromLegacy(session, updatedLegacy) + return outcome, nextSession, true, true + } + return outcome, ActiveSkillSession{}, false, true +} + +func (a *Agent) askForMissingFields(lang string, session ActiveSkillSession) string { + missing := missingRequiredFieldsForBrain(session) + if len(missing) == 0 { + if lang == "zh" { + return "还需要一点信息,我再继续。" + } + return "I need a bit more information before I continue." + } + + if session.SkillName == "model_management" && session.ActionName == "create" { + for _, field := range missing { + if field == "provider" { + return modelProviderChoicePrompt(lang) + } + } + } + + def, ok := getSkillDefinition(session.SkillName) + if !ok { + if lang == "zh" { + return "还需要更多信息,请继续。" + } + return "I need a bit more information to continue." + } + + labels := make([]string, 0, len(missing)) + for _, field := range missing { + label := slotDisplayName(field, lang) + if constraint, ok := def.FieldConstraints[field]; ok { + desc := strings.TrimSpace(constraint.Description) + if len(constraint.Values) > 0 { + desc = strings.Join(constraint.Values, " / ") + } + if desc != "" { + label = fmt.Sprintf("%s(%s)", label, desc) + } + } + labels = append(labels, label) + } + + if lang == "zh" { + return "还差一点信息,我才能继续:" + strings.Join(labels, "、") + "。" + } + return "I still need a bit more information before I can continue: " + strings.Join(labels, ", ") + "." +} + +func activeToLegacySkillSession(s ActiveSkillSession) skillSession { + legacy := skillSession{ + Name: s.SkillName, + Action: s.ActionName, + Phase: defaultIfEmpty(strings.TrimSpace(s.LegacyPhase), "executing"), + Fields: make(map[string]string), + } + for k, v := range s.CollectedFields { + str := strings.TrimSpace(fmt.Sprint(v)) + if str == "" || str == "" { + continue + } + switch k { + case "phase": + legacy.Phase = str + case "target_ref_id": + ensureTargetRef(&legacy) + legacy.TargetRef.ID = str + case "target_ref_name": + ensureTargetRef(&legacy) + legacy.TargetRef.Name = str + case "target_ref": + ensureTargetRef(&legacy) + if legacy.TargetRef.ID == "" { + legacy.TargetRef.ID = str + } + if legacy.TargetRef.Name == "" { + legacy.TargetRef.Name = str + } + default: + legacy.Fields[k] = str + } + } + return legacy +} + +func activeSessionFromLegacy(base ActiveSkillSession, legacy skillSession) ActiveSkillSession { + next := base + next.LegacyPhase = strings.TrimSpace(legacy.Phase) + if next.CollectedFields == nil { + next.CollectedFields = map[string]any{} + } + for key, value := range legacy.Fields { + value = strings.TrimSpace(value) + if value == "" { + continue + } + next.CollectedFields[key] = value + } + if legacy.TargetRef != nil { + if value := strings.TrimSpace(legacy.TargetRef.ID); value != "" { + next.CollectedFields["target_ref_id"] = value + } + if value := strings.TrimSpace(legacy.TargetRef.Name); value != "" { + next.CollectedFields["target_ref_name"] = value + } + } + return next +} + +func ensureTargetRef(s *skillSession) { + if s.TargetRef == nil { + s.TargetRef = &EntityReference{} + } +} + +func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSession) map[string]any { + switch session.Name { + case "trader_management": + if session.Action == "create" { + return a.buildTraderCreateConversationResources(storeUserID, session) + } + return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadTraderOptions(storeUserID)) + case "exchange_management": + return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadExchangeOptions(storeUserID)) + case "model_management": + return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadEnabledModelOptions(storeUserID)) + case "strategy_management": + return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID)) + default: + return nil + } +} + +func missingRequiredFieldsForBrain(session ActiveSkillSession) []string { + missing := missingRequiredFields(session) + if len(missing) == 0 { + return nil + } + out := make([]string, 0, len(missing)) + for _, field := range missing { + if field == "target_ref" { + if activeSessionHasField(session, "target_ref") { + continue + } + } + out = append(out, field) + } + return out +} + +func formatActiveSessionLocalHistory(history []chatMessage) string { + if len(history) == 0 { + return "" + } + start := 0 + if len(history) > 8 { + start = len(history) - 8 + } + lines := make([]string, 0, len(history)-start) + for _, msg := range history[start:] { + role := strings.TrimSpace(msg.Role) + if role == "" { + role = "unknown" + } + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + lines = append(lines, fmt.Sprintf("%s: %s", role, content)) + } + return strings.Join(lines, "\n") +} + +func appendActiveSessionLocalHistory(session ActiveSkillSession, role, content string) ActiveSkillSession { + content = strings.TrimSpace(content) + if content == "" { + return session + } + session.LocalHistory = append(session.LocalHistory, chatMessage{ + Role: strings.TrimSpace(role), + Content: content, + }) + if len(session.LocalHistory) > 12 { + session.LocalHistory = append([]chatMessage(nil), session.LocalHistory[len(session.LocalHistory)-12:]...) + } + return session +} + +func parseTargetSkill(target string) (skill, action string) { + parts := strings.SplitN(target, ":", 2) + if len(parts) != 2 { + return "", "" + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +func mergeExtractedData(s *ActiveSkillSession, data map[string]any) { + if s.CollectedFields == nil { + s.CollectedFields = map[string]any{} + } + for k, v := range data { + k = strings.TrimSpace(k) + if k == "" { + continue + } + s.CollectedFields[k] = v + } +} + +func emitBrainReply(onEvent func(event, data string), reply string) { + if onEvent == nil || reply == "" { + return + } + onEvent(StreamEventTool, "central_brain") + emitStreamText(onEvent, reply) +} diff --git a/agent/clear_memory_test.go b/agent/clear_memory_test.go new file mode 100644 index 00000000..05b5c91e --- /dev/null +++ b/agent/clear_memory_test.go @@ -0,0 +1,116 @@ +package agent + +import ( + "context" + "log/slog" + "path/filepath" + "strings" + "testing" + + "nofx/store" +) + +func TestClearRemovesActiveAndPendingConversationState(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "agent-clear.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + + a := New(nil, st, DefaultConfig(), slog.Default()) + userID := int64(42) + + a.history.Add(userID, "assistant", "之前的回复") + _ = a.saveTaskState(userID, TaskState{CurrentGoal: "配置模型"}) + a.saveActiveSkillSession(ActiveSkillSession{ + SessionID: "as_test", + UserID: userID, + SkillName: "model_management", + ActionName: "create", + PendingHint: &PendingHint{ + Prompt: "请选择 provider", + HintType: "question", + }, + }) + a.savePendingProposalSession(PendingProposalSession{ + UserID: userID, + SourceUserText: "帮我配置模型", + ProposalText: "推荐 claw402,你要继续吗?", + }) + a.saveSetupState(userID, &SetupState{ + Step: "await_ai_model", + AIProvider: "claw402", + }) + if err := st.SetSystemConfig(skillSessionConfigKey(userID), `{"name":"model_management","action":"create"}`); err != nil { + t.Fatalf("seed skill session: %v", err) + } + a.saveWorkflowSession(userID, WorkflowSession{ + Tasks: []WorkflowTask{{ + ID: "task_1", + Skill: "model_management", + Action: "create", + Request: "帮我配置模型", + Status: workflowTaskPending, + }}, + }) + if err := st.SetSystemConfig(ExecutionStateConfigKey(userID), `{"user_id":42,"session_id":"exec_1"}`); err != nil { + t.Fatalf("seed execution state: %v", err) + } + a.saveReferenceMemory(userID, &CurrentReferences{ + Model: &EntityReference{ID: "m1", Name: "claw402", Source: "context"}, + }, nil) + a.SnapshotManager(userID).Save(SuspendedTask{ResumeHint: "旧任务"}) + + reply, err := a.HandleMessage(context.Background(), userID, "/clear") + if err != nil { + t.Fatalf("clear returned error: %v", err) + } + if reply == "" { + t.Fatalf("expected clear reply") + } + + if got := a.history.Get(userID); len(got) != 0 { + t.Fatalf("history not cleared: %+v", got) + } + if got := a.buildRecentConversationContext(userID, "你好"); got != "" { + t.Fatalf("recent conversation context not cleared: %q", got) + } + if got := a.currentPendingHintText(userID); got != "" { + t.Fatalf("pending hint not cleared: %q", got) + } + if got := a.buildCurrentTurnContext(userID, "zh", "你好"); got != "" { + if strings.Contains(got, "Previous assistant reply:") || strings.Contains(got, "Recent conversation:") { + t.Fatalf("current turn context still contains prior chat memory: %q", got) + } + } + if got := a.buildActiveTaskStateContext(userID, "zh"); got != "" { + t.Fatalf("active task state context not cleared: %q", got) + } + if state := a.getTaskState(userID); state.CurrentGoal != "" || state.ActiveFlow != "" { + t.Fatalf("task state not cleared: %+v", state) + } + if _, ok := a.getActiveSkillSession(userID); ok { + t.Fatalf("active skill session not cleared") + } + if _, ok := a.getPendingProposalSession(userID); ok { + t.Fatalf("pending proposal session not cleared") + } + if session := a.getSkillSession(userID); session.Name != "" { + t.Fatalf("legacy skill session not cleared: %+v", session) + } + if session := a.getWorkflowSession(userID); len(session.Tasks) != 0 { + t.Fatalf("workflow session not cleared: %+v", session) + } + if state := a.getExecutionState(userID); state.SessionID != "" { + t.Fatalf("execution state not cleared: %+v", state) + } + if memory := a.getReferenceMemory(userID); memory.CurrentReferences != nil || len(memory.ReferenceHistory) != 0 { + t.Fatalf("reference memory not cleared: %+v", memory) + } + if stack := a.SnapshotManager(userID).List(); len(stack) != 0 { + t.Fatalf("snapshots not cleared: %+v", stack) + } + if setup := a.getSetupState(userID); setup.Step != "" || setup.AIProvider != "" { + t.Fatalf("setup state not cleared: %+v", setup) + } +} diff --git a/agent/config_tools_test.go b/agent/config_tools_test.go deleted file mode 100644 index 4cf717d7..00000000 --- a/agent/config_tools_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package agent - -import ( - "encoding/json" - "path/filepath" - "strings" - "testing" - - "nofx/mcp" - "nofx/store" -) - -func newTestAgentWithStore(t *testing.T) *Agent { - t.Helper() - st, err := store.New(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("create test store: %v", err) - } - t.Cleanup(func() { - _ = st.Close() - }) - return &Agent{store: st} -} - -func TestToolManageExchangeConfigLifecycle(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Main", - "enabled":true, - "testnet":true - }`) - - var created struct { - Status string `json:"status"` - Action string `json:"action"` - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp) - } - if created.Status != "ok" || created.Action != "create" { - t.Fatalf("unexpected create response: %+v", created) - } - if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" { - t.Fatalf("unexpected exchange payload: %+v", created.Exchange) - } - - updateResp := a.toolManageExchangeConfig("user-1", `{ - "action":"update", - "exchange_id":"`+created.Exchange.ID+`", - "account_name":"Renamed", - "enabled":false - }`) - var updated struct { - Status string `json:"status"` - Action string `json:"action"` - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(updateResp), &updated); err != nil { - t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp) - } - if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled { - t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange) - } - - deleteResp := a.toolManageExchangeConfig("user-1", `{ - "action":"delete", - "exchange_id":"`+created.Exchange.ID+`" - }`) - var deleted map[string]any - if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil { - t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp) - } - if deleted["status"] != "ok" || deleted["action"] != "delete" { - t.Fatalf("unexpected delete response: %+v", deleted) - } -} - -func TestToolManageModelConfigLifecycle(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5-mini" - }`) - - var created struct { - Status string `json:"status"` - Action string `json:"action"` - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp) - } - if created.Status != "ok" || created.Action != "create" { - t.Fatalf("unexpected create response: %+v", created) - } - if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" { - t.Fatalf("unexpected model payload: %+v", created.Model) - } - - updateResp := a.toolManageModelConfig("user-1", `{ - "action":"update", - "model_id":"`+created.Model.ID+`", - "enabled":false, - "custom_model_name":"gpt-5" - }`) - var updated struct { - Status string `json:"status"` - Action string `json:"action"` - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(updateResp), &updated); err != nil { - t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp) - } - if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" { - t.Fatalf("unexpected updated model payload: %+v", updated.Model) - } - - deleteResp := a.toolManageModelConfig("user-1", `{ - "action":"delete", - "model_id":"`+created.Model.ID+`" - }`) - var deleted map[string]any - if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil { - t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp) - } - if deleted["status"] != "ok" || deleted["action"] != "delete" { - t.Fatalf("unexpected delete response: %+v", deleted) - } -} - -func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":false, - "custom_model_name":"gpt-4o" - }`) - var created struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp) - } - - updateResp := a.toolManageModelConfig("user-1", `{ - "action":"update", - "model_id":"`+created.Model.ID+`", - "enabled":true - }`) - if !strings.Contains(updateResp, "cannot enable model config before API key is configured") { - t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp) - } -} - -func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) { - a := newTestAgentWithStore(t) - - incompleteCreate := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "custom_model_name":"gpt-4o" - }`) - var incomplete struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil { - t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate) - } - - completeCreate := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_model_name":"deepseek-chat" - }`) - var complete struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil { - t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate) - } - - model, err := a.store.AIModel().GetDefault("user-1") - if err != nil { - t.Fatalf("GetDefault() error = %v", err) - } - if model.ID != complete.Model.ID { - t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID) - } -} - -func TestToolManageTraderLifecycle(t *testing.T) { - a := newTestAgentWithStore(t) - - modelResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5-mini" - }`) - var modelCreated struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil { - t.Fatalf("unmarshal model response: %v", err) - } - - exchangeResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Main", - "enabled":true - }`) - var exchangeCreated struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil { - t.Fatalf("unmarshal exchange response: %v", err) - } - - createResp := a.toolManageTrader("user-1", `{ - "action":"create", - "name":"Momentum Trader", - "ai_model_id":"`+modelCreated.Model.ID+`", - "exchange_id":"`+exchangeCreated.Exchange.ID+`", - "scan_interval_minutes":5 - }`) - var created struct { - Status string `json:"status"` - Action string `json:"action"` - Trader safeTraderToolConfig `json:"trader"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp) - } - if created.Status != "ok" || created.Action != "create" { - t.Fatalf("unexpected create trader response: %+v", created) - } - if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 { - t.Fatalf("unexpected created trader: %+v", created.Trader) - } - - listResp := a.toolManageTrader("user-1", `{"action":"list"}`) - var listed struct { - Count int `json:"count"` - Traders []safeTraderToolConfig `json:"traders"` - } - if err := json.Unmarshal([]byte(listResp), &listed); err != nil { - t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp) - } - if listed.Count != 1 || len(listed.Traders) != 1 { - t.Fatalf("unexpected trader list: %+v", listed) - } - - updateResp := a.toolManageTrader("user-1", `{ - "action":"update", - "trader_id":"`+created.Trader.ID+`", - "name":"Renamed Trader", - "scan_interval_minutes":8 - }`) - var updated struct { - Status string `json:"status"` - Action string `json:"action"` - Trader safeTraderToolConfig `json:"trader"` - } - if err := json.Unmarshal([]byte(updateResp), &updated); err != nil { - t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp) - } - if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 { - t.Fatalf("unexpected updated trader: %+v", updated.Trader) - } - - deleteResp := a.toolManageTrader("user-1", `{ - "action":"delete", - "trader_id":"`+created.Trader.ID+`" - }`) - var deleted map[string]any - if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil { - t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp) - } - if deleted["status"] != "ok" || deleted["action"] != "delete" { - t.Fatalf("unexpected delete trader response: %+v", deleted) - } -} - -func TestToolManageStrategyLifecycle(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"激进", - "description":"激进策略模板", - "lang":"zh" - }`) - - var created struct { - Status string `json:"status"` - Action string `json:"action"` - Strategy safeStrategyToolConfig `json:"strategy"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp) - } - if created.Status != "ok" || created.Action != "create" { - t.Fatalf("unexpected create response: %+v", created) - } - if created.Strategy.Name != "激进" { - t.Fatalf("unexpected strategy payload: %+v", created.Strategy) - } - - listResp := a.toolGetStrategies("user-1") - if !strings.Contains(listResp, "激进") { - t.Fatalf("expected created strategy in list, got %s", listResp) - } - - updateResp := a.toolManageStrategy("user-1", `{ - "action":"update", - "strategy_id":"`+created.Strategy.ID+`", - "description":"更新后的描述" - }`) - var updated struct { - Status string `json:"status"` - Action string `json:"action"` - Strategy safeStrategyToolConfig `json:"strategy"` - } - if err := json.Unmarshal([]byte(updateResp), &updated); err != nil { - t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp) - } - if updated.Strategy.Description != "更新后的描述" { - t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy) - } - - activateResp := a.toolManageStrategy("user-1", `{ - "action":"activate", - "strategy_id":"`+created.Strategy.ID+`" - }`) - if !strings.Contains(activateResp, `"action":"activate"`) { - t.Fatalf("unexpected activate response: %s", activateResp) - } - - deleteResp := a.toolManageStrategy("user-1", `{ - "action":"delete", - "strategy_id":"`+created.Strategy.ID+`" - }`) - if !strings.Contains(deleteResp, `"action":"delete"`) { - t.Fatalf("unexpected delete response: %s", deleteResp) - } -} - -func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) { - a := newTestAgentWithStore(t) - - if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil { - t.Fatalf("seed model: %v", err) - } - - client, modelName, ok := a.loadAIClientFromStoreUser("user-42") - if !ok { - t.Fatal("expected AI client to load from user-specific model") - } - if client == nil { - t.Fatal("expected non-nil AI client") - } - if modelName != "gpt-5-mini" { - t.Fatalf("unexpected model name: %s", modelName) - } - - if _, ok := client.(*mcp.Client); !ok { - t.Fatalf("expected *mcp.Client, got %T", client) - } -} diff --git a/agent/config_validation.go b/agent/config_validation.go new file mode 100644 index 00000000..05ae3f63 --- /dev/null +++ b/agent/config_validation.go @@ -0,0 +1,456 @@ +package agent + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "nofx/security" + "nofx/store" +) + +type ConfigValidationResult struct { + Warnings []string +} + +type ConfigValidator interface { + Validate() error +} + +var ( + openAIAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9\-_]{4,}$`) + genericAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_\-]{8,}$`) + hexCredentialPattern = regexp.MustCompile(`^(0x)?[A-Fa-f0-9]{16,}$`) + supportedModelProvider = map[string]struct{}{ + "openai": {}, "deepseek": {}, "claude": {}, "gemini": {}, "qwen": {}, "kimi": {}, "grok": {}, "minimax": {}, "claw402": {}, "blockrun-base": {}, "blockrun-sol": {}, + } +) + +const ( + manualTraderScanIntervalMin = 3 + manualTraderScanIntervalMax = 60 + manualTraderInitialBalance = 100.0 + manualLighterAPIKeyIndexMin = 0 + manualLighterAPIKeyIndexMax = 255 +) + +type modelConfigValidator struct { + provider string + enabled bool + apiKey string + customAPIURL string + customModelName string + modelID string +} + +func (v modelConfigValidator) Validate() error { + provider := strings.ToLower(strings.TrimSpace(v.provider)) + if provider == "" { + return fmt.Errorf("provider is required") + } + if _, ok := supportedModelProvider[provider]; !ok { + return fmt.Errorf("unsupported provider: %s", provider) + } + if trimmed := strings.TrimSpace(v.customAPIURL); trimmed != "" { + if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil { + return fmt.Errorf("invalid custom_api_url: %w", err) + } + } + if v.enabled && !modelConfigUsable(provider, v.modelID, strings.TrimSpace(v.apiKey), strings.TrimSpace(v.customAPIURL), strings.TrimSpace(v.customModelName)) { + return fmt.Errorf("cannot enable model config before a usable API key, URL, and model are configured") + } + if provider == "openai" && strings.TrimSpace(v.apiKey) != "" && !openAIAPIKeyPattern.MatchString(strings.TrimSpace(v.apiKey)) { + return fmt.Errorf("OpenAI API Key format looks invalid") + } + return nil +} + +type exchangeConfigValidator struct { + exchangeType string + enabled bool + apiKey string + secretKey string + passphrase string + hyperliquidWalletAddr string + asterUser string + asterSigner string + asterPrivateKey string + lighterWalletAddr string + lighterPrivateKey string + lighterAPIKeyPrivateKey string +} + +func (v exchangeConfigValidator) Validate() error { + exchangeType := strings.ToLower(strings.TrimSpace(v.exchangeType)) + if exchangeType == "" { + return fmt.Errorf("exchange_type is required") + } + if trimmed := strings.TrimSpace(v.apiKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) { + return fmt.Errorf("API Key format looks invalid") + } + if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) { + return fmt.Errorf("Secret format looks invalid") + } + if exchangeType == "okx" && v.enabled && strings.TrimSpace(v.passphrase) == "" { + return fmt.Errorf("OKX requires passphrase before enabling this exchange config") + } + if exchangeType == "hyperliquid" && v.enabled && strings.TrimSpace(v.hyperliquidWalletAddr) == "" { + return fmt.Errorf("Hyperliquid requires wallet address before enabling this exchange config") + } + if exchangeType == "aster" && v.enabled { + if strings.TrimSpace(v.asterUser) == "" || strings.TrimSpace(v.asterSigner) == "" || strings.TrimSpace(v.asterPrivateKey) == "" { + return fmt.Errorf("Aster requires user, signer, and private key before enabling this exchange config") + } + } + if exchangeType == "lighter" && v.enabled { + if strings.TrimSpace(v.lighterWalletAddr) == "" || strings.TrimSpace(v.lighterAPIKeyPrivateKey) == "" { + return fmt.Errorf("Lighter requires wallet address and API key private key before enabling this exchange config") + } + } + return nil +} + +type traderBindingValidator struct { + store *store.Store + storeUserID string + aiModelID string + exchangeID string + strategyID string +} + +func (v traderBindingValidator) Validate() error { + if v.store == nil { + return fmt.Errorf("store unavailable") + } + if strings.TrimSpace(v.aiModelID) == "" { + return fmt.Errorf("ai_model_id is required") + } + if strings.TrimSpace(v.exchangeID) == "" { + return fmt.Errorf("exchange_id is required") + } + model, err := v.store.AIModel().Get(v.storeUserID, strings.TrimSpace(v.aiModelID)) + if err != nil { + return fmt.Errorf("invalid ai_model_id: %w", err) + } + if !model.Enabled { + return fmt.Errorf("ai model is disabled") + } + if !modelConfigUsable(model.Provider, model.ID, strings.TrimSpace(string(model.APIKey)), strings.TrimSpace(model.CustomAPIURL), strings.TrimSpace(model.CustomModelName)) { + return fmt.Errorf("ai model config is incomplete") + } + exchange, err := v.store.Exchange().GetByID(v.storeUserID, strings.TrimSpace(v.exchangeID)) + if err != nil { + return fmt.Errorf("invalid exchange_id: %w", err) + } + if !exchange.Enabled { + return fmt.Errorf("exchange is disabled") + } + if err := (exchangeConfigValidator{ + exchangeType: exchange.ExchangeType, + enabled: exchange.Enabled, + apiKey: strings.TrimSpace(string(exchange.APIKey)), + secretKey: strings.TrimSpace(string(exchange.SecretKey)), + passphrase: strings.TrimSpace(string(exchange.Passphrase)), + hyperliquidWalletAddr: exchange.HyperliquidWalletAddr, + asterUser: exchange.AsterUser, + asterSigner: exchange.AsterSigner, + asterPrivateKey: strings.TrimSpace(string(exchange.AsterPrivateKey)), + lighterWalletAddr: exchange.LighterWalletAddr, + lighterPrivateKey: strings.TrimSpace(string(exchange.LighterPrivateKey)), + lighterAPIKeyPrivateKey: strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)), + }).Validate(); err != nil { + return fmt.Errorf("exchange config is incomplete: %w", err) + } + if trimmed := strings.TrimSpace(v.strategyID); trimmed != "" { + if _, err := v.store.Strategy().Get(v.storeUserID, trimmed); err != nil { + return fmt.Errorf("invalid strategy_id: %w", err) + } + } + return nil +} + +func (a *Agent) validateModelDraft(storeUserID, modelID, provider string, enabled bool, apiKey, customAPIURL, customModelName string) error { + if a == nil || a.store == nil { + return fmt.Errorf("store unavailable") + } + if strings.TrimSpace(provider) == "" && strings.TrimSpace(modelID) != "" { + model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(modelID)) + if err != nil { + return err + } + provider = model.Provider + if strings.TrimSpace(apiKey) == "" { + apiKey = strings.TrimSpace(string(model.APIKey)) + } + if strings.TrimSpace(customAPIURL) == "" { + customAPIURL = strings.TrimSpace(model.CustomAPIURL) + } + if strings.TrimSpace(customModelName) == "" { + customModelName = strings.TrimSpace(model.CustomModelName) + } + } + return (modelConfigValidator{ + provider: provider, + enabled: enabled, + apiKey: apiKey, + customAPIURL: customAPIURL, + customModelName: customModelName, + modelID: modelID, + }).Validate() +} + +func (a *Agent) validateExchangeDraft(storeUserID, exchangeID, exchangeType string, enabled bool, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) error { + if a == nil || a.store == nil { + return fmt.Errorf("store unavailable") + } + if strings.TrimSpace(exchangeType) == "" && strings.TrimSpace(exchangeID) != "" { + exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID)) + if err != nil { + return err + } + exchangeType = exchange.ExchangeType + if strings.TrimSpace(apiKey) == "" { + apiKey = strings.TrimSpace(string(exchange.APIKey)) + } + if strings.TrimSpace(secretKey) == "" { + secretKey = strings.TrimSpace(string(exchange.SecretKey)) + } + if strings.TrimSpace(passphrase) == "" { + passphrase = strings.TrimSpace(string(exchange.Passphrase)) + } + if strings.TrimSpace(hyperliquidWalletAddr) == "" { + hyperliquidWalletAddr = strings.TrimSpace(exchange.HyperliquidWalletAddr) + } + if strings.TrimSpace(asterUser) == "" { + asterUser = strings.TrimSpace(exchange.AsterUser) + } + if strings.TrimSpace(asterSigner) == "" { + asterSigner = strings.TrimSpace(exchange.AsterSigner) + } + if strings.TrimSpace(asterPrivateKey) == "" { + asterPrivateKey = strings.TrimSpace(string(exchange.AsterPrivateKey)) + } + if strings.TrimSpace(lighterWalletAddr) == "" { + lighterWalletAddr = strings.TrimSpace(exchange.LighterWalletAddr) + } + if strings.TrimSpace(lighterAPIKeyPrivateKey) == "" { + lighterAPIKeyPrivateKey = strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)) + } + } + return (exchangeConfigValidator{ + exchangeType: exchangeType, + enabled: enabled, + apiKey: apiKey, + secretKey: secretKey, + passphrase: passphrase, + hyperliquidWalletAddr: hyperliquidWalletAddr, + asterUser: asterUser, + asterSigner: asterSigner, + asterPrivateKey: asterPrivateKey, + lighterWalletAddr: lighterWalletAddr, + lighterAPIKeyPrivateKey: lighterAPIKeyPrivateKey, + }).Validate() +} + +func (a *Agent) validateTraderDraft(storeUserID, aiModelID, exchangeID, strategyID string) error { + return (traderBindingValidator{ + store: a.store, + storeUserID: storeUserID, + aiModelID: aiModelID, + exchangeID: exchangeID, + strategyID: strategyID, + }).Validate() +} + +func formatValidationFeedback(lang, domain string, err error) string { + if err == nil { + return "" + } + raw := strings.TrimSpace(err.Error()) + lower := strings.ToLower(raw) + if lang == "zh" { + switch { + case strings.Contains(lower, "openai api key format looks invalid"): + return "这份配置还有问题:API Key 格式不对。OpenAI 的 API Key 通常以 `sk-` 开头,请直接发完整 Key,我继续帮你补进当前草稿。" + case strings.Contains(lower, "api key format looks invalid"): + return "这份配置还有问题:API Key 格式不对。请直接发完整的 API Key,不要附带多余说明文字。" + case strings.Contains(lower, "secret format looks invalid"): + return "这份配置还有问题:Secret 格式不对。请直接发完整的 Secret 值,不要和 API Key 填反。" + case strings.Contains(lower, "okx requires passphrase"): + return "这份配置还有问题:OKX 账户缺少 Passphrase,启用前需要补齐。你直接把 Passphrase 发我就行。" + case strings.Contains(lower, "hyperliquid requires wallet address"): + return "这份配置还有问题:Hyperliquid 账户缺少钱包地址,启用前需要补齐。" + case strings.Contains(lower, "aster requires user, signer, and private key"): + return "这份配置还有问题:Aster 账户还缺 user、signer 和 private key,启用前需要补齐。" + case strings.Contains(lower, "lighter requires wallet address and api key private key"): + return "这份配置还有问题:Lighter 账户还缺钱包地址和 API key private key,启用前需要补齐。" + case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"): + return "这份配置还有问题:要先把 API Key、接口地址和模型名称配完整,才能启用。你可以继续把缺的字段发给我。" + case strings.Contains(lower, "unsupported provider"): + return "这份配置还有问题:provider 不在支持范围内。请从 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax 里选一个。" + case strings.Contains(lower, "invalid custom_api_url"): + return "这份配置还有问题:接口地址格式不对。请给我完整的 URL,或直接说使用默认地址。" + case strings.Contains(lower, "ai model is disabled"): + return "这份配置还有问题:绑定的模型当前是禁用状态。请换一个已启用模型,或先启用这个模型。" + case strings.Contains(lower, "exchange is disabled"): + return "这份配置还有问题:绑定的交易所当前已禁用。请换一个已启用交易所,或先启用这个交易所。" + case strings.Contains(lower, "ai model config is incomplete"): + return "这份配置还有问题:绑定的模型配置还没补完整,暂时不能使用。" + case strings.Contains(lower, "invalid ai_model_id"): + return "这份配置还有问题:模型引用无效。请明确告诉我你要绑定哪个模型。" + case strings.Contains(lower, "invalid exchange_id"): + return "这份配置还有问题:交易所引用无效。请明确告诉我你要绑定哪个交易所。" + case strings.Contains(lower, "invalid strategy_id"): + return "这份配置还有问题:策略引用无效。请明确告诉我你要绑定哪个策略。" + case strings.Contains(lower, "provider is required"): + return "这份配置还缺 provider。请先告诉我你要用哪个模型提供商。" + case strings.Contains(lower, "exchange_type is required"): + return "这份配置还缺交易所类型。请先告诉我你要接哪个交易所。" + } + switch domain { + case "model": + return "这份模型草稿还有问题:" + raw + case "exchange": + return "这份交易所草稿还有问题:" + raw + case "trader": + return "这份交易员草稿还有问题:" + raw + case "strategy": + return "这份策略草稿还有问题:" + raw + default: + return "这份配置还有问题:" + raw + } + } + + switch { + case strings.Contains(lower, "openai api key format looks invalid"): + return "This draft still has an issue: the API key format looks wrong. OpenAI keys usually start with `sk-`. Send the full key and I'll keep filling the draft." + case strings.Contains(lower, "api key format looks invalid"): + return "This draft still has an issue: the API key format looks wrong. Send the full API key directly." + case strings.Contains(lower, "secret format looks invalid"): + return "This draft still has an issue: the secret format looks wrong. Send the full secret value directly." + case strings.Contains(lower, "okx requires passphrase"): + return "This draft still has an issue: an OKX config needs a passphrase before it can be enabled. Send the passphrase and I'll keep going." + case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"): + return "This draft still has an issue: the API key, endpoint URL, and model name must be completed before the config can be enabled." + } + switch domain { + case "model": + return "This model draft still has an issue: " + raw + case "exchange": + return "This exchange draft still has an issue: " + raw + case "trader": + return "This trader draft still has an issue: " + raw + case "strategy": + return "This strategy draft still has an issue: " + raw + default: + return "This draft still has an issue: " + raw + } +} + +func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (traderUpdateArgs, []string) { + warnings := make([]string, 0, 2) + if args.ScanIntervalMinutes != nil { + requested := *args.ScanIntervalMinutes + normalized := requested + if normalized < manualTraderScanIntervalMin { + normalized = manualTraderScanIntervalMin + } + if normalized > manualTraderScanIntervalMax { + normalized = manualTraderScanIntervalMax + } + if normalized != requested { + args.ScanIntervalMinutes = &normalized + if lang == "zh" { + warnings = append(warnings, fmt.Sprintf("扫描间隔手动可配置范围是 %d 到 %d 分钟,已从 %d 调整为 %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized)) + } else { + warnings = append(warnings, fmt.Sprintf("scan interval is limited to %d-%d minutes in the manual config, adjusted from %d to %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized)) + } + } + } + if args.InitialBalance != nil { + requested := *args.InitialBalance + normalized := requested + if normalized < manualTraderInitialBalance { + normalized = manualTraderInitialBalance + } + if normalized != requested { + args.InitialBalance = &normalized + if lang == "zh" { + warnings = append(warnings, fmt.Sprintf("初始资金手动面板最低是 %.2f,已从 %.2f 调整为 %.2f", manualTraderInitialBalance, requested, normalized)) + } else { + warnings = append(warnings, fmt.Sprintf("initial balance has a manual minimum of %.2f, adjusted from %.2f to %.2f", manualTraderInitialBalance, requested, normalized)) + } + } + } + return args, warnings +} + +func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLabel string) string { + if len(warnings) == 0 { + return "" + } + if lang == "zh" { + lines := []string{ + "这些配置超出了手动面板允许的范围,我已经先按风控范围收敛:", + } + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + lines = append(lines, fmt.Sprintf("如果接受当前范围,回复“%s”;也可以继续告诉我你想怎么改。", confirmLabel)) + return strings.Join(lines, "\n") + } + lines := []string{ + "Some values were outside the manual editor limits, so I normalized them first:", + } + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel)) + return strings.Join(lines, "\n") +} + +func marshalStringList(values []string) string { + if len(values) == 0 { + return "" + } + raw, err := json.Marshal(values) + if err != nil { + return "" + } + return string(raw) +} + +func unmarshalStringList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil + } + return values +} + +func normalizeExchangePatchToManualLimits(lang string, patch exchangeUpdatePatch) (exchangeUpdatePatch, []string) { + warnings := make([]string, 0, 1) + if patch.LighterAPIKeyIndex != nil { + requested := *patch.LighterAPIKeyIndex + normalized := requested + if normalized < manualLighterAPIKeyIndexMin { + normalized = manualLighterAPIKeyIndexMin + } + if normalized > manualLighterAPIKeyIndexMax { + normalized = manualLighterAPIKeyIndexMax + } + if normalized != requested { + patch.LighterAPIKeyIndex = &normalized + if lang == "zh" { + warnings = append(warnings, fmt.Sprintf("Lighter API Key Index 手动面板范围是 %d 到 %d,已从 %d 调整为 %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized)) + } else { + warnings = append(warnings, fmt.Sprintf("lighter API key index is limited to %d-%d in the manual editor, adjusted from %d to %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized)) + } + } + } + return patch, warnings +} diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go new file mode 100644 index 00000000..b82c0a78 --- /dev/null +++ b/agent/config_visibility_test.go @@ -0,0 +1,125 @@ +package agent + +import ( + "log/slog" + "path/filepath" + "strings" + "testing" + + "nofx/store" +) + +func TestToolManageModelConfigCreateRequiresCredential(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "visibility.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + resp := a.toolManageModelConfig("default", `{"action":"create","provider":"deepseek"}`) + if !strings.Contains(resp, `"error":"api_key is required for create"`) { + t.Fatalf("expected missing api_key error, got: %s", resp) + } +} + +func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "visibility-list.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_openai", "OpenAI", false, "", "", ""); err != nil { + t.Fatalf("seed incomplete model: %v", err) + } + if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", false, "sk-test-12345", "", "deepseek-chat"); err != nil { + t.Fatalf("seed configured model: %v", err) + } + + resp := a.toolGetModelConfigs("default") + if strings.Contains(resp, `"id":"default_openai"`) { + t.Fatalf("incomplete model should be hidden from tool query: %s", resp) + } + if !strings.Contains(resp, `"id":"default_deepseek"`) { + t.Fatalf("configured model should remain visible: %s", resp) + } +} + +func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-options.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + summary := a.exchangeSkillOptionSummary("zh") + for _, expected := range []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"} { + if !strings.Contains(summary, expected) { + t.Fatalf("expected option %q in summary, got: %s", expected, summary) + } + } + for _, hidden := range []string{"Alpaca", "Forex", "Metals"} { + if strings.Contains(summary, hidden) { + t.Fatalf("did not expect hidden manual-page option %q in summary: %s", hidden, summary) + } + } +} + +func TestDescribeExchangeIncludesTypeSpecificVisibleFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-detail.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + hyperID, err := st.Exchange().Create("default", "hyperliquid", "Dex Pro", true, "hyper-api-key", "", "", true, "0xabc", true, "", "", "", "", "", "", 0) + if err != nil { + t.Fatalf("seed hyperliquid exchange: %v", err) + } + detail, ok := a.describeExchange("default", "zh", &EntityReference{ID: hyperID}) + if !ok { + t.Fatal("expected describeExchange to resolve hyperliquid config") + } + for _, expected := range []string{"交易所配置“Dex Pro”详情", "交易所:hyperliquid", "账户名:Dex Pro", "API Key:true", "Hyperliquid 钱包地址:0xabc"} { + if !strings.Contains(detail, expected) { + t.Fatalf("expected hyperliquid detail to contain %q, got: %s", expected, detail) + } + } + + lighterID, err := st.Exchange().Create("default", "lighter", "Lighter Main", false, "", "", "", false, "", true, "", "", "", "wallet-1", "", "lighter-secret", 7) + if err != nil { + t.Fatalf("seed lighter exchange: %v", err) + } + detail, ok = a.describeExchange("default", "zh", &EntityReference{ID: lighterID}) + if !ok { + t.Fatal("expected describeExchange to resolve lighter config") + } + for _, expected := range []string{"交易所:lighter", "Lighter 钱包地址:wallet-1", "Lighter API Key 私钥:true", "Lighter API Key Index:7"} { + if !strings.Contains(detail, expected) { + t.Fatalf("expected lighter detail to contain %q, got: %s", expected, detail) + } + } +} + +func TestSkillVisibleFieldSummaryForExchangeUsesReadableNames(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-field-summary.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + summary := a.skillVisibleFieldSummary("default", "zh", "exchange_management", "update") + for _, expected := range []string{"交易所类型", "账户名", "API Key", "Secret", "Passphrase", "Hyperliquid 钱包地址", "Aster User", "Lighter API Key 私钥", "Lighter API Key Index"} { + if !strings.Contains(summary, expected) { + t.Fatalf("expected field label %q in summary, got: %s", expected, summary) + } + } + if strings.Contains(summary, "hyperliquid_wallet_addr") || strings.Contains(summary, "lighter_api_key_private_key") { + t.Fatalf("field summary should use readable labels instead of raw keys: %s", summary) + } +} diff --git a/agent/entity_field_catalog.go b/agent/entity_field_catalog.go new file mode 100644 index 00000000..aa12d8fa --- /dev/null +++ b/agent/entity_field_catalog.go @@ -0,0 +1,121 @@ +package agent + +type entityFieldMeta struct { + Key string + Keywords []string + ValueType string + ManualEditable bool + AgentUpdatable bool +} + +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: "initial_balance", Keywords: []string{"初始资金", "初始余额", "initial balance"}, ValueType: "float", 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{ + {Key: "provider", Keywords: []string{"provider", "模型提供商", "模型厂商", "vendor"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true}, + {Key: "name", Keywords: []string{"名称", "名字", "name"}, ValueType: "name", ManualEditable: true, AgentUpdatable: true}, + {Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true}, + {Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "custom_api_url", Keywords: []string{"url", "endpoint", "地址", "接口"}, ValueType: "url", ManualEditable: true, AgentUpdatable: true}, + {Key: "custom_model_name", Keywords: []string{"model name", "模型名称", "模型名"}, ValueType: "model_name", ManualEditable: true, AgentUpdatable: true}, +} + +var exchangeFieldCatalog = []entityFieldMeta{ + {Key: "exchange_type", Keywords: []string{"交易所类型", "交易所", "exchange type", "exchange"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true}, + {Key: "account_name", Keywords: []string{"账户名", "account name"}, ValueType: "account_name", ManualEditable: true, AgentUpdatable: true}, + {Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true}, + {Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "secret_key", Keywords: []string{"secret key", "secret", "secret_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "passphrase", Keywords: []string{"passphrase", "密码短语"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "testnet", Keywords: []string{"testnet", "测试网"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, + {Key: "hyperliquid_wallet_addr", Keywords: []string{"hyperliquid wallet", "hyperliquid钱包", "主钱包地址", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "aster_user", Keywords: []string{"aster user", "aster用户", "用户地址", "user"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "aster_signer", Keywords: []string{"aster signer", "signer"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "aster_private_key", Keywords: []string{"aster private key", "aster私钥", "private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "lighter_wallet_addr", Keywords: []string{"lighter wallet", "lighter钱包", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "lighter_api_key_private_key", Keywords: []string{"lighter api key private key", "lighter api key", "api key private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true}, + {Key: "lighter_api_key_index", Keywords: []string{"lighter api key index", "lighter索引", "api key index"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true}, +} + +func fieldKeysByCapability(catalog []entityFieldMeta, include func(entityFieldMeta) bool) []string { + keys := make([]string, 0, len(catalog)) + for _, field := range catalog { + if include(field) { + keys = append(keys, field.Key) + } + } + return keys +} + +func keywordsForField(catalog []entityFieldMeta, field string) []string { + for _, item := range catalog { + if item.Key == field { + return item.Keywords + } + } + return nil +} + +func manualTraderEditableFieldKeys() []string { + return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool { + return field.ManualEditable + }) +} + +func agentTraderUpdatableFieldKeys() []string { + return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool { + return field.AgentUpdatable + }) +} + +func manualModelEditableFieldKeys() []string { + return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool { + return field.ManualEditable + }) +} + +func agentModelUpdatableFieldKeys() []string { + return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool { + return field.AgentUpdatable + }) +} + +func manualExchangeEditableFieldKeys() []string { + return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool { + return field.ManualEditable + }) +} + +func agentExchangeUpdatableFieldKeys() []string { + return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool { + return field.AgentUpdatable + }) +} + +func traderFieldKeywords(field string) []string { + return keywordsForField(traderFieldCatalog, field) +} + +func modelFieldKeywords(field string) []string { + return keywordsForField(modelFieldCatalog, field) +} + +func exchangeFieldKeywords(field string) []string { + return keywordsForField(exchangeFieldCatalog, field) +} diff --git a/agent/execution_state.go b/agent/execution_state.go index fe6e7540..fc82176d 100644 --- a/agent/execution_state.go +++ b/agent/execution_state.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + + "github.com/google/uuid" ) const ( @@ -30,22 +32,38 @@ const ( ) type ExecutionState struct { - SessionID string `json:"session_id"` - UserID int64 `json:"user_id"` - Goal string `json:"goal"` - Status string `json:"status"` - PlanID string `json:"plan_id"` - Steps []PlanStep `json:"steps,omitempty"` - CurrentStepID string `json:"current_step_id,omitempty"` + SessionID string `json:"session_id"` + UserID int64 `json:"user_id"` + Goal string `json:"goal"` + Status string `json:"status"` + PlanID string `json:"plan_id"` + Steps []PlanStep `json:"steps,omitempty"` + CurrentStepID string `json:"current_step_id,omitempty"` CurrentReferences *CurrentReferences `json:"current_references,omitempty"` - DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"` - ExecutionLog []Observation `json:"execution_log,omitempty"` - SummaryNotes []Observation `json:"summary_notes,omitempty"` - Waiting *WaitingState `json:"waiting,omitempty"` - Observations []Observation `json:"observations,omitempty"` - FinalAnswer string `json:"final_answer,omitempty"` - LastError string `json:"last_error,omitempty"` - UpdatedAt string `json:"updated_at"` + ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"` + DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"` + ExecutionLog []Observation `json:"execution_log,omitempty"` + SummaryNotes []Observation `json:"summary_notes,omitempty"` + Waiting *WaitingState `json:"waiting,omitempty"` + Observations []Observation `json:"observations,omitempty"` + FinalAnswer string `json:"final_answer,omitempty"` + LastError string `json:"last_error,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +type SuspendedTask struct { + SnapshotID string `json:"snapshot_id,omitempty"` + IntentID string `json:"intent_id,omitempty"` + ParentIntentID string `json:"parent_intent_id,omitempty"` + Kind string `json:"kind,omitempty"` + ResumeHint string `json:"resume_hint,omitempty"` + ResumeOnSuccess bool `json:"resume_on_success,omitempty"` + ResumeTriggers []string `json:"resume_triggers,omitempty"` + SkillSession *skillSession `json:"skill_session,omitempty"` + WorkflowSession *WorkflowSession `json:"workflow_session,omitempty"` + ExecutionState *ExecutionState `json:"execution_state,omitempty"` + LocalHistory []chatMessage `json:"local_history,omitempty"` + SuspendedAt string `json:"suspended_at,omitempty"` } type PlanStep struct { @@ -78,8 +96,18 @@ type WaitingState struct { } type EntityReference struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type ReferenceRecord struct { + Kind string `json:"kind,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + CreatedAt string `json:"created_at,omitempty"` } type CurrentReferences struct { @@ -89,6 +117,20 @@ type CurrentReferences struct { Exchange *EntityReference `json:"exchange,omitempty"` } +type SnapshotSummary struct { + SnapshotID string `json:"snapshot_id,omitempty"` + IntentID string `json:"intent_id,omitempty"` + ParentIntentID string `json:"parent_intent_id,omitempty"` + Kind string `json:"kind,omitempty"` + ResumeHint string `json:"resume_hint,omitempty"` + SuspendedAt string `json:"suspended_at,omitempty"` +} + +type SnapshotManager struct { + agent *Agent + userID int64 +} + type executionPlan struct { Goal string `json:"goal"` Steps []PlanStep `json:"steps"` @@ -103,6 +145,82 @@ func ExecutionStateConfigKey(userID int64) string { return fmt.Sprintf("agent_execution_state_%d", userID) } +func taskStackConfigKey(userID int64) string { + return fmt.Sprintf("agent_task_stack_%d", userID) +} + +func (a *Agent) SnapshotManager(userID int64) SnapshotManager { + return SnapshotManager{agent: a, userID: userID} +} + +func (m SnapshotManager) Save(task SuspendedTask) { + if m.agent == nil { + return + } + m.agent.pushTaskStack(m.userID, task) +} + +func (m SnapshotManager) Load() (SuspendedTask, bool) { + if m.agent == nil { + return SuspendedTask{}, false + } + return m.agent.popTaskStack(m.userID) +} + +func (m SnapshotManager) Peek() (SuspendedTask, bool) { + if m.agent == nil { + return SuspendedTask{}, false + } + return m.agent.peekTaskStack(m.userID) +} + +func (m SnapshotManager) List() []SnapshotSummary { + if m.agent == nil { + return nil + } + stack := m.agent.getTaskStack(m.userID) + out := make([]SnapshotSummary, 0, len(stack)) + for _, item := range stack { + out = append(out, SnapshotSummary{ + SnapshotID: strings.TrimSpace(item.SnapshotID), + IntentID: strings.TrimSpace(item.IntentID), + ParentIntentID: strings.TrimSpace(item.ParentIntentID), + Kind: strings.TrimSpace(item.Kind), + ResumeHint: strings.TrimSpace(item.ResumeHint), + SuspendedAt: strings.TrimSpace(item.SuspendedAt), + }) + } + return out +} + +func (m SnapshotManager) Stack() []SuspendedTask { + if m.agent == nil { + return nil + } + return m.agent.getTaskStack(m.userID) +} + +func (m SnapshotManager) RemoveAt(index int) (SuspendedTask, bool) { + if m.agent == nil { + return SuspendedTask{}, false + } + stack := m.agent.getTaskStack(m.userID) + if index < 0 || index >= len(stack) { + return SuspendedTask{}, false + } + task := stack[index] + stack = append(stack[:index], stack[index+1:]...) + m.agent.saveTaskStack(m.userID, stack) + return task, true +} + +func (m SnapshotManager) Clear() { + if m.agent == nil { + return + } + m.agent.clearTaskStack(m.userID) +} + func (a *Agent) getExecutionState(userID int64) ExecutionState { if a.store == nil { return ExecutionState{} @@ -133,6 +251,9 @@ func (a *Agent) saveExecutionState(state ExecutionState) error { if state.SessionID == "" { return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "") } + if state.UserID != 0 && (state.CurrentReferences != nil || len(state.ReferenceHistory) > 0) { + a.saveReferenceMemory(state.UserID, state.CurrentReferences, state.ReferenceHistory) + } data, err := json.Marshal(state) if err != nil { return err @@ -149,6 +270,80 @@ func (a *Agent) clearExecutionState(userID int64) { } } +func (a *Agent) getTaskStack(userID int64) []SuspendedTask { + if a.store == nil { + return nil + } + raw, err := a.store.GetSystemConfig(taskStackConfigKey(userID)) + if err != nil { + a.logger.Warn("failed to load task stack", "error", err, "user_id", userID) + return nil + } + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var stack []SuspendedTask + if err := json.Unmarshal([]byte(raw), &stack); err != nil { + a.logger.Warn("failed to parse task stack", "error", err, "user_id", userID) + return nil + } + return normalizeTaskStack(stack) +} + +func (a *Agent) saveTaskStack(userID int64, stack []SuspendedTask) { + if a.store == nil { + return + } + stack = normalizeTaskStack(stack) + if len(stack) == 0 { + _ = a.store.SetSystemConfig(taskStackConfigKey(userID), "") + return + } + data, err := json.Marshal(stack) + if err != nil { + return + } + _ = a.store.SetSystemConfig(taskStackConfigKey(userID), string(data)) +} + +func (a *Agent) peekTaskStack(userID int64) (SuspendedTask, bool) { + stack := a.getTaskStack(userID) + if len(stack) == 0 { + return SuspendedTask{}, false + } + return stack[len(stack)-1], true +} + +func (a *Agent) pushTaskStack(userID int64, task SuspendedTask) { + task = normalizeSuspendedTask(task) + if task.Kind == "" { + return + } + stack := a.getTaskStack(userID) + stack = append(stack, task) + stack = normalizeTaskStack(stack) + a.saveTaskStack(userID, stack) +} + +func (a *Agent) popTaskStack(userID int64) (SuspendedTask, bool) { + stack := a.getTaskStack(userID) + if len(stack) == 0 { + return SuspendedTask{}, false + } + task := stack[len(stack)-1] + stack = stack[:len(stack)-1] + a.saveTaskStack(userID, stack) + return task, true +} + +func (a *Agent) clearTaskStack(userID int64) { + if a.store == nil { + return + } + _ = a.store.SetSystemConfig(taskStackConfigKey(userID), "") +} + func newExecutionState(userID int64, goal string) ExecutionState { now := time.Now().UTC().Format(time.RFC3339) return normalizeExecutionState(ExecutionState{ @@ -168,6 +363,7 @@ func normalizeExecutionState(state ExecutionState) ExecutionState { state.FinalAnswer = strings.TrimSpace(state.FinalAnswer) state.LastError = strings.TrimSpace(state.LastError) state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences) + state.ReferenceHistory = normalizeReferenceHistory(state.ReferenceHistory) state.Waiting = normalizeWaitingState(state.Waiting) if state.Status == "" && state.SessionID != "" { state.Status = executionStatusPlanning @@ -201,6 +397,88 @@ func normalizeExecutionState(state ExecutionState) ExecutionState { return state } +func normalizeSuspendedTask(task SuspendedTask) SuspendedTask { + task.SnapshotID = strings.TrimSpace(task.SnapshotID) + task.IntentID = strings.TrimSpace(task.IntentID) + task.ParentIntentID = strings.TrimSpace(task.ParentIntentID) + task.Kind = strings.TrimSpace(task.Kind) + task.ResumeHint = strings.TrimSpace(task.ResumeHint) + task.ResumeTriggers = cleanStringList(task.ResumeTriggers) + task.SuspendedAt = strings.TrimSpace(task.SuspendedAt) + if task.SkillSession != nil { + session := normalizeSkillSession(*task.SkillSession) + if session.Name == "" { + task.SkillSession = nil + } else { + task.SkillSession = &session + } + } + if task.WorkflowSession != nil { + session := normalizeWorkflowSession(*task.WorkflowSession) + if len(session.Tasks) == 0 { + task.WorkflowSession = nil + } else { + task.WorkflowSession = &session + } + } + if task.ExecutionState != nil { + state := normalizeExecutionState(*task.ExecutionState) + if strings.TrimSpace(state.SessionID) == "" { + task.ExecutionState = nil + } else { + task.ExecutionState = &state + } + } + if task.Kind == "" { + switch { + case task.SkillSession != nil: + task.Kind = "skill_session" + case task.WorkflowSession != nil: + task.Kind = "workflow_session" + case task.ExecutionState != nil: + task.Kind = "execution_state" + } + } + if task.Kind == "" { + return SuspendedTask{} + } + if task.SnapshotID == "" { + task.SnapshotID = "snap_" + uuid.NewString() + } + if task.IntentID == "" { + task.IntentID = "intent_" + uuid.NewString() + } + if task.SuspendedAt == "" { + task.SuspendedAt = time.Now().UTC().Format(time.RFC3339) + } + return task +} + +func normalizeTaskStack(stack []SuspendedTask) []SuspendedTask { + if len(stack) == 0 { + return nil + } + now := time.Now().UTC() + out := make([]SuspendedTask, 0, len(stack)) + for _, item := range stack { + item = normalizeSuspendedTask(item) + if item.Kind == "" { + continue + } + if t, err := time.Parse(time.RFC3339, item.SuspendedAt); err == nil && now.Sub(t) > 24*time.Hour { + continue + } + out = append(out, item) + } + if len(out) == 0 { + return nil + } + if len(out) > 5 { + out = out[len(out)-5:] + } + return out +} + func normalizeWaitingState(waiting *WaitingState) *WaitingState { if waiting == nil { return nil @@ -224,9 +502,14 @@ func normalizeEntityReference(ref *EntityReference) *EntityReference { } ref.ID = strings.TrimSpace(ref.ID) ref.Name = strings.TrimSpace(ref.Name) + ref.Source = strings.TrimSpace(ref.Source) + ref.UpdatedAt = strings.TrimSpace(ref.UpdatedAt) if ref.ID == "" && ref.Name == "" { return nil } + if ref.UpdatedAt == "" { + ref.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + } return ref } @@ -244,6 +527,34 @@ func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences { return refs } +func normalizeReferenceHistory(history []ReferenceRecord) []ReferenceRecord { + if len(history) == 0 { + return nil + } + out := make([]ReferenceRecord, 0, len(history)) + for _, item := range history { + item.Kind = strings.TrimSpace(item.Kind) + item.ID = strings.TrimSpace(item.ID) + item.Name = strings.TrimSpace(item.Name) + item.Source = strings.TrimSpace(item.Source) + item.CreatedAt = strings.TrimSpace(item.CreatedAt) + if item.Kind == "" || (item.ID == "" && item.Name == "") { + continue + } + if item.CreatedAt == "" { + item.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + out = append(out, item) + } + if len(out) == 0 { + return nil + } + if len(out) > 12 { + out = out[len(out)-12:] + } + return out +} + func normalizeObservationList(values []Observation) []Observation { if len(values) == 0 { return nil @@ -332,8 +643,8 @@ func buildObservationContext(state ExecutionState) map[string]any { state = normalizeExecutionState(state) return map[string]any{ "current_references": state.CurrentReferences, - "dynamic_snapshots": state.DynamicSnapshots, - "execution_log": state.ExecutionLog, - "summary_notes": state.SummaryNotes, + "dynamic_snapshots": state.DynamicSnapshots, + "execution_log": state.ExecutionLog, + "summary_notes": state.SummaryNotes, } } diff --git a/agent/history.go b/agent/history.go index 662bbd31..cad912d1 100644 --- a/agent/history.go +++ b/agent/history.go @@ -1,6 +1,7 @@ package agent import ( + "strings" "sync" "time" ) @@ -101,3 +102,16 @@ func (h *chatHistory) CleanOld(maxAge time.Duration) { } } } + +func (a *Agent) getLastAssistantReply(userID int64) string { + if a == nil || a.history == nil { + return "" + } + msgs := a.history.Get(userID) + for i := len(msgs) - 1; i >= 0; i-- { + if strings.EqualFold(strings.TrimSpace(msgs[i].Role), "assistant") { + return strings.TrimSpace(msgs[i].Content) + } + } + return "" +} diff --git a/agent/i18n.go b/agent/i18n.go index 47425cab..3e5af0b8 100644 --- a/agent/i18n.go +++ b/agent/i18n.go @@ -3,20 +3,22 @@ package agent var i18nMessages = map[string]map[string]string{ "help": { "zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" + - "*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" + + "*交易:* 做多 BTC 0.01 x10 · 做空 ETH 0.1 · 平多 BTC · 平空 ETH\n" + + " 也支持 /buy /sell /long /short + 交易对 数量 杠杆\n" + "*查询:* /positions /balance /pnl /traders\n" + "*分析:* /analyze BTC\n" + "*监控:* /watch BTC · /unwatch BTC\n" + "*策略:* /strategy\n" + - "*系统:* /status /help\n\n" + + "*系统:* /status /clear /help\n\n" + "直接跟我说话就行,中英文都可以 💬", "en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" + - "*Trade:* /buy /sell /long /short + symbol qty leverage\n" + + "*Trade:* long BTC 0.01 x10 · short ETH 0.1 · close long BTC · close short ETH\n" + + " Also supports /buy /sell /long /short + symbol qty leverage\n" + "*Query:* /positions /balance /pnl /traders\n" + "*Analyze:* /analyze BTC\n" + "*Monitor:* /watch BTC · /unwatch BTC\n" + "*Strategy:* /strategy\n" + - "*System:* /status /help\n\n" + + "*System:* /status /clear /help\n\n" + "Just talk to me in any language 💬", }, "status": { @@ -52,8 +54,8 @@ var i18nMessages = map[string]map[string]string{ "en": "🤖 *Traders*\n\n", }, "trade_usage": { - "zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`", - "en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`", + "zh": "手动下单示例:`做多 BTC 0.01 x10`、`做空 ETH 0.1`、`平多 BTC`、`平空 ETH`。也支持 `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`。下单后需要确认;大额订单要用“确认大额 trade_xxx”。", + "en": "Manual trade examples: `long BTC 0.01 x10`, `short ETH 0.1`, `close long BTC`, `close short ETH`. Also supports `/buy BTC 0.01` or `/sell ETH 0.5 3x`. Orders require confirmation; large orders use `confirm large trade_xxx`.", }, "invalid_qty": { "zh": "❓ 无效数量: %s", @@ -68,8 +70,8 @@ var i18nMessages = map[string]map[string]string{ "en": "⚠️ Sentinel not enabled.", }, "system_prompt": { - "zh": "你是 NOFXi,一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。", - "en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.", + "zh": "你是 NOFXi,一个专业的 AI 交易 Agent。把用户当交易小白,用简单清楚的大白话回复,先说结论,再说下一步。使用少量交易相关 emoji。", + "en": "You are NOFXi, a professional AI trading agent. Treat the user like a trading beginner, use plain language, lead with the conclusion, then the next step. Use a small amount of trading emojis.", }, } diff --git a/agent/llm_flow_extractor.go b/agent/llm_flow_extractor.go new file mode 100644 index 00000000..33e44094 --- /dev/null +++ b/agent/llm_flow_extractor.go @@ -0,0 +1,527 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "nofx/mcp" +) + +type llmFlowExtractionTask struct { + Skill string `json:"skill,omitempty"` + Action string `json:"action,omitempty"` + Fields map[string]string `json:"fields,omitempty"` +} + +type llmFlowExtractionResult struct { + Intent string `json:"intent,omitempty"` + TargetSnapshotID string `json:"target_snapshot_id,omitempty"` + InlineSubIntent string `json:"inline_sub_intent,omitempty"` + Fields map[string]string `json:"fields,omitempty"` + Tasks []llmFlowExtractionTask `json:"tasks,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type llmFlowFieldSpec struct { + Key string `json:"key"` + Description string `json:"description"` + Required bool `json:"required,omitempty"` +} + +func buildActiveFlowExtractionPrompt(lang, flowLabel, flowContext string, text string, recentConversationCtx string, currentRefs any, suspendedSnapshots any, extraSections []string) (string, string) { + systemPrompt := `You extract structured continuation input for an active NOFXi flow. +Return JSON only. No markdown. + +You must decide one of: +- "continue": the user is continuing the current flow and may have supplied fields +- "switch": the user is switching away to another task +- "cancel": the user is cancelling the current flow +- "instant_reply": the user is only chatting / greeting and no task fields should be written + +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 "前面那个".` + + sections := []string{ + fmt.Sprintf("Language: %s", lang), + fmt.Sprintf("Active flow label: %s", flowLabel), + flowContext, + fmt.Sprintf("Current references JSON: %s", mustMarshalJSON(currentRefs)), + fmt.Sprintf("Suspended snapshots JSON: %s", mustMarshalJSON(suspendedSnapshots)), + } + sections = append(sections, extraSections...) + sections = append(sections, fmt.Sprintf("User message: %s", text), fmt.Sprintf("Recent conversation:\n%s", recentConversationCtx)) + return systemPrompt, strings.Join(sections, "\n") +} + +func (a *Agent) extractSkillSessionFieldsWithLLM(ctx context.Context, userID int64, lang, text string, session skillSession) llmFlowExtractionResult { + if a == nil || a.aiClient == nil { + return llmFlowExtractionResult{} + } + text = strings.TrimSpace(text) + if text == "" { + return llmFlowExtractionResult{} + } + + flowSummary, fieldSpecs, currentValues, missingFields := skillSessionExtractionContext(session, lang) + recentConversationCtx := a.buildRecentConversationContext(userID, text) + state := a.getExecutionState(userID) + currentRefs := state.CurrentReferences + if currentRefs == nil { + currentRefs = a.semanticCurrentReferences(userID) + } + skillContext := buildCurrentSkillExecutionContext(lang, session) + systemPrompt, userPrompt := buildActiveFlowExtractionPrompt( + lang, + "skill_session", + flowSummary, + text, + recentConversationCtx, + currentRefs, + a.SnapshotManager(userID).List(), + []string{ + skillContext, + fmt.Sprintf("Allowed field spec JSON: %s", mustMarshalJSON(fieldSpecs)), + fmt.Sprintf("Current flow field values JSON: %s", mustMarshalJSON(currentValues)), + fmt.Sprintf("Current missing fields JSON: %s", mustMarshalJSON(missingFields)), + }, + ) + waitingHint := "" + if len(missingFields) > 0 { + waitingHint = fmt.Sprintf("\n- The flow is currently waiting for the user to provide: [%s]. Before deciding \"switch\", first check whether the user message can fill any of these fields — even without an explicit prefix or keyword.", strings.Join(missingFields, ", ")) + } + systemPrompt += ` +- 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. +- 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. +- When the user clearly wants to create a new dependency resource (e.g. "选(2)", "新建策略", "创建交易所"), set inline_sub_intent="create_sub_resource". +- When the user clearly wants to edit/update an existing dependency resource (e.g. "编辑策略", "改一下模型"), set inline_sub_intent="edit_sub_resource". +- For "switch", you may return tasks for the new request if they are clear enough. +- If no field can be safely extracted for the current flow, return "switch" or "instant_reply", not fake fields.` + waitingHint + ` + +Return JSON with this shape: +{"intent":"continue|switch|cancel|instant_reply","target_snapshot_id":"","inline_sub_intent":"","tasks":[{"skill":"","action":"","fields":{}}],"reason":""}` + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return llmFlowExtractionResult{} + } + return parseLLMFlowExtractionResult(raw) +} + +func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult { + out, ok := parseRawFlowExtractionEnvelope(raw) + if !ok { + return llmFlowExtractionResult{} + } + switch out.Intent { + case "continue", "switch", "cancel", "instant_reply": + return out + default: + return llmFlowExtractionResult{} + } +} + +func parseRawFlowExtractionEnvelope(raw string) (llmFlowExtractionResult, bool) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var out llmFlowExtractionResult + if err := json.Unmarshal([]byte(raw), &out); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil { + return llmFlowExtractionResult{}, false + } + } + + out.Intent = strings.TrimSpace(strings.ToLower(out.Intent)) + out.TargetSnapshotID = strings.TrimSpace(out.TargetSnapshotID) + out.Reason = strings.TrimSpace(out.Reason) + if len(out.Fields) > 0 { + clean := make(map[string]string, len(out.Fields)) + for key, value := range out.Fields { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + continue + } + clean[key] = value + } + out.Fields = clean + } + cleanTasks := make([]llmFlowExtractionTask, 0, len(out.Tasks)) + for _, task := range out.Tasks { + task.Skill = strings.TrimSpace(task.Skill) + task.Action = strings.TrimSpace(task.Action) + if len(task.Fields) > 0 { + clean := make(map[string]string, len(task.Fields)) + for key, value := range task.Fields { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + continue + } + clean[key] = value + } + task.Fields = clean + } + cleanTasks = append(cleanTasks, task) + } + out.Tasks = cleanTasks + return out, out.Intent != "" +} + +func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) { + currentStep, _ := currentSkillDAGStep(session) + fieldSpecs := allowedFieldSpecsForSkillSession(session, lang) + currentValues := currentFieldValuesForSkillSession(session) + missing := missingFieldKeysForSkillSession(session) + summary := fmt.Sprintf("Active flow type: skill_session\nSkill: %s\nAction: %s\nCurrent DAG step: %s", session.Name, session.Action, currentStep.ID) + return summary, fieldSpecs, currentValues, missing +} + +func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFlowFieldSpec { + add := func(out *[]llmFlowFieldSpec, key, description string, required bool) { + *out = append(*out, llmFlowFieldSpec{Key: key, Description: description, Required: required}) + } + out := make([]llmFlowFieldSpec, 0, 24) + switch session.Name { + case "model_management": + required := map[string]bool{"provider": true} + if strings.HasPrefix(session.Action, "update") { + add(&out, "update_field", displayCatalogFieldName("update_field", lang), false) + } + add(&out, "provider", slotDisplayName("provider", lang), required["provider"]) + add(&out, "name", displayCatalogFieldName("name", lang), required["name"]) + add(&out, "custom_model_name", displayCatalogFieldName("custom_model_name", lang), required["custom_model_name"]) + add(&out, "api_key", displayCatalogFieldName("api_key", lang), required["api_key"]) + add(&out, "custom_api_url", displayCatalogFieldName("custom_api_url", lang), false) + add(&out, "enabled", displayCatalogFieldName("enabled", lang), false) + case "exchange_management": + required := map[string]bool{"exchange_type": true, "account_name": true} + if strings.HasPrefix(session.Action, "update") { + add(&out, "update_field", displayCatalogFieldName("update_field", lang), false) + } + add(&out, "exchange_type", slotDisplayName("exchange_type", lang), required["exchange_type"]) + add(&out, "account_name", displayCatalogFieldName("account_name", lang), required["account_name"]) + add(&out, "api_key", displayCatalogFieldName("api_key", lang), false) + add(&out, "secret_key", displayCatalogFieldName("secret_key", lang), false) + add(&out, "passphrase", displayCatalogFieldName("passphrase", lang), false) + add(&out, "testnet", displayCatalogFieldName("testnet", lang), false) + add(&out, "enabled", displayCatalogFieldName("enabled", lang), false) + add(&out, "hyperliquid_wallet_addr", displayCatalogFieldName("hyperliquid_wallet_addr", lang), false) + add(&out, "aster_user", displayCatalogFieldName("aster_user", lang), false) + add(&out, "aster_signer", displayCatalogFieldName("aster_signer", lang), false) + add(&out, "aster_private_key", displayCatalogFieldName("aster_private_key", lang), false) + add(&out, "lighter_wallet_addr", displayCatalogFieldName("lighter_wallet_addr", lang), false) + add(&out, "lighter_api_key_private_key", displayCatalogFieldName("lighter_api_key_private_key", lang), false) + add(&out, "lighter_api_key_index", displayCatalogFieldName("lighter_api_key_index", lang), false) + case "trader_management": + if strings.HasPrefix(session.Action, "update") { + add(&out, "update_field", displayCatalogFieldName("update_field", lang), false) + } + add(&out, "name", slotDisplayName("name", lang), true) + add(&out, "exchange_id", slotDisplayName("exchange", lang)+" ID", false) + add(&out, "exchange_name", slotDisplayName("exchange", lang), true) + add(&out, "model_id", slotDisplayName("model", lang)+" ID", false) + add(&out, "model_name", slotDisplayName("model", lang), true) + add(&out, "strategy_id", slotDisplayName("strategy", lang)+" ID", false) + add(&out, "strategy_name", slotDisplayName("strategy", lang), true) + add(&out, "auto_start", "auto_start", false) + add(&out, "initial_balance", displayCatalogFieldName("initial_balance", lang), false) + add(&out, "scan_interval_minutes", displayCatalogFieldName("scan_interval_minutes", lang), false) + add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false) + add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false) + case "strategy_management": + if session.Action == "update_config" { + add(&out, "config_field", strategyConfigFieldDisplayName("config_field", lang), false) + add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false) + } + add(&out, "name", slotDisplayName("name", lang), true) + for _, key := range []string{ + "source_type", "static_coins", "excluded_coins", "use_ai500", "ai500_limit", + "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "max_positions", + "min_risk_reward_ratio", "min_confidence", "leverage", "btceth_max_leverage", + "altcoin_max_leverage", "primary_timeframe", "primary_count", "selected_timeframes", + "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "enable_ema", + "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", + "enable_oi", "enable_funding_rate", "enable_all_core_indicators", "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", + "role_definition", "trading_frequency", "entry_standards", "decision_process", "custom_prompt", + } { + add(&out, key, strategyConfigFieldDisplayName(key, lang), false) + } + } + return out +} + +func currentFieldValuesForSkillSession(session skillSession) map[string]string { + values := map[string]string{} + for key, value := range session.Fields { + if trimmed := strings.TrimSpace(value); trimmed != "" { + values[key] = trimmed + } + } + if session.TargetRef != nil { + if session.TargetRef.ID != "" { + values["target_ref_id"] = session.TargetRef.ID + } + if session.TargetRef.Name != "" { + values["target_ref_name"] = session.TargetRef.Name + } + } + for _, key := range []string{"name", "exchange_id", "exchange_name", "model_id", "model_name", "strategy_id", "strategy_name", "auto_start"} { + if value := fieldValue(session, key); value != "" { + values[key] = value + } + } + return values +} + +func missingFieldKeysForSkillSession(session skillSession) []string { + missing := make([]string, 0, 8) + switch session.Name { + case "model_management": + if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil { + missing = append(missing, "target_ref") + } + if strings.HasPrefix(session.Action, "update") { + if session.Action == "update_status" { + if fieldValue(session, "enabled") == "" { + missing = append(missing, "enabled") + } + } else if session.Action == "update_endpoint" { + if fieldValue(session, "custom_api_url") == "" { + missing = append(missing, "custom_api_url") + } + } else { + if fieldValue(session, "update_field") == "" { + missing = append(missing, "update_field") + } + } + } else { + for _, key := range []string{"provider"} { + if fieldValue(session, key) == "" { + missing = append(missing, key) + } + } + if fieldValue(session, "api_key") == "" { + missing = append(missing, "api_key") + } + } + case "exchange_management": + if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil { + missing = append(missing, "target_ref") + } + if strings.HasPrefix(session.Action, "update") { + if session.Action == "update_status" { + if fieldValue(session, "enabled") == "" { + missing = append(missing, "enabled") + } + } else { + if fieldValue(session, "update_field") == "" { + missing = append(missing, "update_field") + } + } + } else { + for _, key := range []string{"exchange_type", "account_name", "api_key", "secret_key"} { + if fieldValue(session, key) == "" { + missing = append(missing, key) + } + } + } + case "trader_management": + if strings.HasPrefix(session.Action, "update") || strings.HasPrefix(session.Action, "configure_") { + if session.TargetRef == nil { + missing = append(missing, "target_ref") + } + if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" { + switch session.Action { + case "configure_strategy": + if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" { + missing = append(missing, "strategy_name") + } + break + case "configure_exchange": + if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" { + missing = append(missing, "exchange_name") + } + break + case "configure_model": + if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" { + missing = append(missing, "model_name") + } + break + } + if len(missing) > 0 { + break + } + if fieldValue(session, "model_id") == "" && fieldValue(session, "exchange_id") == "" && fieldValue(session, "strategy_id") == "" && + fieldValue(session, "model_name") == "" && fieldValue(session, "exchange_name") == "" && fieldValue(session, "strategy_name") == "" { + missing = append(missing, "update_field") + } + } else { + if fieldValue(session, "update_field") == "" { + missing = append(missing, "update_field") + } + } + } else { + if fieldValue(session, "name") == "" { + missing = append(missing, "name") + } + if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" { + missing = append(missing, "exchange_name") + } + if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" { + missing = append(missing, "model_name") + } + if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" { + missing = append(missing, "strategy_name") + } + } + case "strategy_management": + if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil { + missing = append(missing, "target_ref") + } + switch session.Action { + case "update_config": + if fieldValue(session, "config_field") == "" { + missing = append(missing, "config_field") + } else if fieldValue(session, "config_value") == "" { + missing = append(missing, "config_value") + } + default: + if fieldValue(session, "name") == "" { + missing = append(missing, "name") + } + } + } + sort.Strings(missing) + return missing +} + +func providerExplicitlyMentionedInText(provider, text string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + lower := strings.ToLower(strings.TrimSpace(text)) + if provider == "" || lower == "" { + return false + } + spec, _ := modelProviderSpecByID(provider) + candidates := []string{provider, strings.ToLower(strings.TrimSpace(spec.DisplayName))} + switch provider { + case "blockrun-base": + candidates = append(candidates, "blockrun", "blockrun base", "base wallet") + case "blockrun-sol": + candidates = append(candidates, "blockrun", "blockrun sol", "solana wallet") + case "claw402": + candidates = append(candidates, "claw 402") + } + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate != "" && strings.Contains(lower, candidate) { + return true + } + } + return false +} + +func sanitizeLLMExtractionForSkillSession(text string, session skillSession, result llmFlowExtractionResult) llmFlowExtractionResult { + if session.Name != "model_management" || len(result.Tasks) == 0 { + return result + } + task := result.Tasks[0] + if task.Fields == nil { + return result + } + if provider := strings.TrimSpace(task.Fields["provider"]); provider != "" && !providerExplicitlyMentionedInText(provider, text) { + delete(task.Fields, "provider") + result.Tasks[0] = task + } + return result +} + +func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *skillSession, result llmFlowExtractionResult, lang string, text string) { + if session == nil { + return + } + result = sanitizeLLMExtractionForSkillSession(text, *session, result) + if sub := strings.TrimSpace(result.InlineSubIntent); sub == "create_sub_resource" || sub == "edit_sub_resource" { + setField(session, "inline_sub_intent", sub) + } + if len(result.Tasks) == 0 { + return + } + task := result.Tasks[0] + if task.Skill != "" && task.Skill != session.Name { + return + } + if task.Action != "" && session.Action != "" && task.Action != session.Action { + return + } + for key, value := range task.Fields { + value = strings.TrimSpace(value) + if value == "" { + 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" { + setField(session, key, value) + } + case "exchange_management": + switch key { + case "exchange_type", "account_name", "api_key", "secret_key", "passphrase", "testnet", "enabled", "update_field": + setField(session, key, value) + } + case "trader_management": + switch key { + case "update_field": + setField(session, key, value) + case "name", "exchange_id", "exchange_name", "model_id", "ai_model_id", "model_name", "strategy_id", "strategy_name", "auto_start": + setField(session, key, value) + case "initial_balance", "scan_interval_minutes", "is_cross_margin", "show_in_competition": + setField(session, key, value) + } + case "strategy_management": + if key == "name" { + setField(session, "name", value) + continue + } + if key == "config_field" || key == "config_value" { + setField(session, key, value) + continue + } + if session.Action == "update_config" { + setField(session, "config_field", key) + setField(session, "config_value", value) + continue + } + cfg := unmarshalStrategyCreateDraft(fieldValue(*session, strategyCreateDraftConfigField), lang) + if err := applyStrategyConfigPatch(&cfg, key, value); err == nil { + setField(session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg)) + } + } + } +} diff --git a/agent/llm_flow_extractor_test.go b/agent/llm_flow_extractor_test.go new file mode 100644 index 00000000..d0d1d9e4 --- /dev/null +++ b/agent/llm_flow_extractor_test.go @@ -0,0 +1,42 @@ +package agent + +import "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", + }, + }}, + } + + sanitized := sanitizeLLMExtractionForSkillSession("新建一个模型", session, result) + if got := sanitized.Tasks[0].Fields["provider"]; got != "" { + t.Fatalf("expected provider guess to be stripped, got %q", got) + } +} + +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_conversation.go b/agent/llm_skill_conversation.go new file mode 100644 index 00000000..9c69d08c --- /dev/null +++ b/agent/llm_skill_conversation.go @@ -0,0 +1,237 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "nofx/mcp" +) + +type skillConversationResult struct { + Ready bool `json:"ready"` + Question string `json:"question,omitempty"` + Extracted map[string]string `json:"extracted,omitempty"` + DraftGeneratedFields map[string]string `json:"draft_generated_fields,omitempty"` + RequiresConfirmationBeforeApply bool `json:"requires_confirmation_before_apply,omitempty"` + UserRejectedFlow bool `json:"user_rejected_flow,omitempty"` + Cancel bool `json:"cancel,omitempty"` + NeedsClarification bool `json:"needs_clarification,omitempty"` +} + +// llmSkillConversationDriver replaces rule-based field collection. +// It gives the LLM the skill schema, current collected fields, available resources, +// and the current waiting fields — then lets LLM decide what to ask or whether to proceed. +func (a *Agent) llmSkillConversationDriver( + ctx context.Context, + storeUserID string, + userID int64, + lang, text string, + session skillSession, + availableResources map[string]any, +) skillConversationResult { + if a == nil || a.aiClient == nil { + return skillConversationResult{} + } + + currentFields := currentFieldValuesForSkillSession(session) + missingFields := missingFieldKeysForSkillSession(session) + recentCtx := a.buildRecentConversationContext(userID, text) + skillJSON := loadSkillJSON(session.Name) + skillContext := buildCurrentSkillExecutionContext(lang, session) + relevantResources := filterConversationResourcesForSession(session, missingFields, availableResources) + missingSummary := formatConversationMissingFields(lang, missingFields) + domainPrimer := buildSkillDomainPrimer(lang, session.Name) + + resourcesJSON, _ := json.Marshal(relevantResources) + currentFieldsJSON, _ := json.Marshal(currentFields) + + waitingHint := "" + if len(missingFields) > 0 { + waitingHint = fmt.Sprintf("\nCurrently waiting for: [%s]. The user's message may be answering one of these fields directly — recognize it even without a keyword prefix.", strings.Join(missingFields, ", ")) + } + + systemPrompt := fmt.Sprintf(`You are the conversation driver for NOFXi skill: %s / %s. +Your job: first understand what the user means in this exact turn, then decide how to continue the current skill action. +You are not a keyword matcher. Infer whether the user is filling a slot, choosing an existing resource, asking to create/enable a dependency, clarifying an earlier answer, or cancelling. + +Active skill/action contract: +%s + +Skill schema JSON (field constraints and action definitions): +%s + +Skill domain primer: +%s + +Only the currently relevant resource groups are disclosed below. Use them only when they help resolve the current missing slots. Do not assume omitted resource groups are unavailable globally. +Available resources (each resource includes an ID and display name; return the ID when you can resolve it): +%s + +Current collected fields: +%s +%s +Rules: +- Highest-priority safety rule: before extracting any field, first judge whether the user is rejecting, correcting, or denying the current task itself. +- If the current flow is wrong, the user is saying things like "不是交易员,是策略", "弄错了", "不是这个", "I mean the strategy, not the trader", or the core entity has clearly crossed into another domain, do NOT extract any field. +- In those rejection/correction/cross-domain cases, immediately return {"user_rejected_flow":true,"ready":false,"question":"","extracted":{}}. +- Any user-facing question or reply must be simple, clear, and beginner-friendly. +- Treat the user like a trading beginner, not a developer. +- Prefer short sentences and plain language. +- Do not expose internal field names, JSON keys, tool names, or backend terminology to the user unless the user explicitly asks. +- If the user is cancelling, return {"cancel":true} +- If the user answer is ambiguous, return {"ready":false,"needs_clarification":true,"question":"","extracted":{...any newly extracted fields...}} +- If disclosed resources include an ambiguity/conflict list for the current target, do not repeat a robotic stock phrase. Use the disclosed distinguishing details to ask a natural clarifying question. +- If the user clearly delegates content generation to you (for example: "交给你", "你帮我写", "你自己设计", "you decide", "draft it for me", "所有字段都由你来定", "你帮我配置好"), do not mechanically ask for the same text again. +- In those delegation cases, when the missing slot is a text-like field such as custom_prompt, role_definition, trading_frequency, entry_standards, decision_process, description, or name, you should draft a strong candidate yourself, put that draft into draft_generated_fields, keep ready false if confirmation is still needed, set requires_confirmation_before_apply=true, and use question to show the draft and ask for confirmation. +- When the user delegates ALL fields (e.g. "所有字段都由你来定", "你帮我全部配好", "all fields up to you"), also infer reasonable values for structured fields (such as static_coins, primary_timeframe, selected_timeframes, btceth_max_leverage, altcoin_max_leverage, min_confidence, source_type, etc.) based on the strategy name and stated goal. Put all inferred structured values into draft_generated_fields as well. Present a concise summary of ALL drafted fields in the question and ask for one confirmation before applying. +- If all required fields are collected and there is no ambiguity, return {"ready":true,"extracted":{...all newly resolved fields for this turn...}} +- Otherwise, return {"ready":false,"question":"","extracted":{...any newly extracted fields...}} +- Extract fields from the user message even without keyword prefixes +- When asking for a field that has available options, list them concisely in the question +- Never ask for fields that are already collected +- For entity refs (exchange, model, strategy): if the user clearly means one option from available resources, use its ID and put it in extracted as exchange_id/ai_model_id/strategy_id +- For target object selection: if the user clearly means one option from available targets, return target_ref_id and target_ref_name +- If the user says to use an existing/current/already-configured resource and there is exactly one usable option in the disclosed resource group, resolve it automatically to that ID +- If multiple disclosed options fit and the user did not disambiguate, ask a clarifying question instead of guessing +- "ready" must stay false if any DAG-required slot is still missing or ambiguous. Current missing field summary: %s +- Distinguish between user-supplied values (put in extracted) and AI-drafted proposal values (put in draft_generated_fields). Do not pretend AI-generated drafts were literal user input. + +Return JSON only. No markdown.`, + session.Name, session.Action, + defaultIfEmpty(skillContext, "No active contract available."), + skillJSON, + defaultIfEmpty(domainPrimer, "No extra domain primer."), + defaultIfEmpty(string(resourcesJSON), "{}"), + string(currentFieldsJSON), + waitingHint, + lang, + lang, + missingSummary, + ) + + userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s", lang, text, recentCtx) + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return skillConversationResult{} + } + + return parseSkillConversationResult(raw) +} + +func filterConversationResourcesForSession(session skillSession, missingFields []string, availableResources map[string]any) map[string]any { + if len(availableResources) == 0 { + return nil + } + + need := map[string]bool{} + for _, field := range missingFields { + switch strings.TrimSpace(field) { + case "target_ref": + need["targets"] = true + case "exchange", "exchange_id", "exchange_name": + need["exchanges"] = true + case "model", "model_id", "model_name", "ai_model_id": + need["models"] = true + case "strategy", "strategy_id", "strategy_name": + need["strategies"] = true + } + } + + if len(need) == 0 { + switch session.Action { + case "configure_exchange": + need["exchanges"] = true + case "configure_model": + need["models"] = true + case "configure_strategy": + need["strategies"] = true + } + } + + if len(need) == 0 { + return nil + } + + filtered := make(map[string]any, len(need)) + for key := range need { + if value, ok := availableResources[key]; ok { + filtered[key] = value + } + } + if len(filtered) == 0 { + return nil + } + return filtered +} + +func formatConversationMissingFields(lang string, missingFields []string) string { + if len(missingFields) == 0 { + if lang == "zh" { + return "当前没有缺失槽位。" + } + return "There are currently no missing slots." + } + display := make([]string, 0, len(missingFields)) + for _, field := range missingFields { + display = append(display, slotDisplayName(field, lang)) + } + if lang == "zh" { + return "当前仍缺这些槽位:" + strings.Join(display, "、") + } + return "Current missing slots: " + strings.Join(display, ", ") +} + +func parseSkillConversationResult(raw string) skillConversationResult { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var out skillConversationResult + if err := json.Unmarshal([]byte(raw), &out); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start >= 0 && end > start { + json.Unmarshal([]byte(raw[start:end+1]), &out) + } + } + if !out.Cancel && !out.UserRejectedFlow && !out.Ready && out.Question == "" && len(out.Extracted) == 0 && len(out.DraftGeneratedFields) == 0 { + var flow llmFlowExtractionResult + if err := json.Unmarshal([]byte(raw), &flow); err == nil { + if strings.TrimSpace(flow.Intent) == "continue" { + if len(flow.Fields) > 0 { + out.Extracted = flow.Fields + } else if len(flow.Tasks) > 0 { + out.Extracted = flow.Tasks[0].Fields + } + if len(out.Extracted) > 0 { + out.Ready = true + } + } + } + } + out.Question = strings.TrimSpace(out.Question) + return out +} + +// loadSkillJSON returns the raw skill JSON bytes for the given skill name. +func loadSkillJSON(skillName string) string { + data, err := embeddedSkillDefinitions.ReadFile("skills/" + skillName + ".json") + if err != nil { + return "{}" + } + return string(data) +} diff --git a/agent/llm_skill_router.go b/agent/llm_skill_router.go index 3e53a699..be4bef83 100644 --- a/agent/llm_skill_router.go +++ b/agent/llm_skill_router.go @@ -10,13 +10,23 @@ import ( ) type llmSkillRouteDecision struct { - Route string `json:"route"` - Skill string `json:"skill,omitempty"` - Action string `json:"action,omitempty"` - Filter string `json:"filter,omitempty"` + Intent string `json:"intent,omitempty"` + TargetSnapshotID string `json:"target_snapshot_id,omitempty"` + TargetSkill string `json:"target_skill,omitempty"` + ExtractedFields map[string]any `json:"extracted_fields,omitempty"` + NeedPlannerHelp bool `json:"need_planner_help,omitempty"` + Route string `json:"route"` + Track string `json:"track,omitempty"` + Skill string `json:"skill,omitempty"` + Action string `json:"action,omitempty"` + Filter string `json:"filter,omitempty"` + InlineSubIntent string `json:"inline_sub_intent,omitempty"` + Tasks []WorkflowTask `json:"tasks,omitempty"` + ContextSwitch bool `json:"context_switch,omitempty"` + Confidence float64 `json:"confidence,omitempty"` } -func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { +func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { if a.aiClient == nil { return "", false, nil } @@ -26,89 +36,106 @@ func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID return "", false, nil } - recentConversationCtx := a.buildRecentConversationContext(userID, text) - taskStateCtx := buildTaskStateContext(a.getTaskState(userID)) - executionState := normalizeExecutionState(a.getExecutionState(userID)) - executionJSON, _ := json.Marshal(executionState) - systemPrompt := `You are the lightweight skill router for NOFXi. -Decide whether the user's message should go to a structured skill or continue to the planner. -Return JSON only. Do not return markdown. - -Use route "skill" only when the user intent is clear enough to send directly to one structured skill. -Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests. - -Available skills: -- trader_management -- exchange_management -- model_management -- strategy_management -- trader_diagnosis -- exchange_diagnosis -- model_diagnosis -- strategy_diagnosis - -For management skills, choose one atomic action from: -- query_list -- query_detail -- query_running -- create -- update_name -- update_bindings -- update_status -- update_endpoint -- update_config -- update_prompt -- delete -- start -- stop -- activate -- duplicate - -Set filter only when it is clearly implied by the user. Use values like: -- running_only -- stopped_only -- enabled_only -- disabled_only -- active_only -- default_only - -Rules: -- Prefer route "planner" when uncertain. -- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis. -- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities. -- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running". -- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail". -- Do not use route "skill" for casual chat. -- Consider Recent conversation, Task state, and Execution state JSON before deciding. - -Return JSON with this exact shape: -{"route":"skill|planner","skill":"","action":"","filter":""}` - userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON)) - - stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) - defer cancel() - - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - if err != nil { - return "", false, nil + decision, ok, err := a.routeTurnWithLLM(ctx, userID, lang, text) + if err != nil || !ok { + return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) } - decision, err := parseLLMSkillRouteDecision(raw) - if err != nil || decision.Route != "skill" { + switch decision.Intent { + case "continue", "continue_active": + if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) { + return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent) + } + if _, has := a.getActiveSkillSession(userID); has { + return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) + } + if a.hasAnyActiveContext(userID) { + return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) + } return "", false, nil + case "cancel": + a.clearPendingProposalSession(userID) + if a.hasAnyActiveContext(userID) { + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + } + return "", false, nil + case "resume_snapshot": + a.clearPendingProposalSession(userID) + if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) { + if _, has := a.getActiveSkillSession(userID); has { + return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) + } + return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) + } + return "", false, nil + case "instant_reply": + if a.hasAnyActiveContext(userID) { + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + } + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok { + return answer, true, nil + } + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + return answer, true, err } + if a.hasAnyActiveContext(userID) { + a.clearPendingProposalSession(userID) + return a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + } + + switch decision.Route { + case "workflow": + a.clearPendingProposalSession(userID) + answer, handled, execErr := a.executeWorkflowDecomposition(ctx, storeUserID, userID, lang, text, workflowDecomposition{Tasks: decision.Tasks}, onEvent) + return answer, handled, execErr + case "skill": + a.clearPendingProposalSession(userID) + return a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent) + case "planner": + a.clearPendingProposalSession(userID) + answer, execErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + 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) + return answer, true, execErr + } + } + + return "", false, nil +} + +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 { return "", false, nil } + if isReadOnlyAtomicSkillAction(outcome.Skill, outcome.Action) { + answer := strings.TrimSpace(outcome.UserMessage) + if answer == "" { + return "", false, nil + } + a.recordSkillInteraction(userID, text, answer) + if onEvent != nil { + label := "llm_intent_plan" + if decision.Skill != "" { + label += ":" + decision.Skill + } + if decision.Action != "" { + label += ":" + decision.Action + } + onEvent(StreamEventTool, label) + emitStreamText(onEvent, answer) + } + return answer, true, nil + } + review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome) if err != nil { if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled { @@ -131,7 +158,7 @@ Return JSON with this exact shape: a.recordSkillInteraction(userID, text, answer) if onEvent != nil { - label := "llm_skill_route" + label := "llm_intent_plan" if decision.Skill != "" { label += ":" + decision.Skill } @@ -139,11 +166,20 @@ Return JSON with this exact shape: label += ":" + decision.Action } onEvent(StreamEventTool, label) - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true, nil } +func isReadOnlyAtomicSkillAction(skill, action string) bool { + action = strings.TrimSpace(strings.ToLower(action)) + switch action { + case "query", "query_list", "query_detail", "query_running", "query_strategy_binding", "query_exchange_binding", "query_model_binding": + return true + } + return false +} + func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) { raw = strings.TrimSpace(raw) raw = strings.TrimPrefix(raw, "```json") @@ -166,9 +202,70 @@ func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) { } func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision { + decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent)) + decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID) + decision.TargetSkill = strings.TrimSpace(strings.ToLower(decision.TargetSkill)) decision.Route = strings.TrimSpace(strings.ToLower(decision.Route)) + decision.Track = strings.TrimSpace(strings.ToLower(decision.Track)) decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill)) decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter)) + decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks + if decision.Confidence < 0 { + decision.Confidence = 0 + } + if decision.Confidence > 1 { + decision.Confidence = 1 + } + if decision.Route == "" { + switch { + case len(decision.Tasks) > 1: + decision.Route = "workflow" + case decision.TargetSkill != "": + decision.Route = "skill" + case decision.Skill != "" || decision.Action != "": + decision.Route = "skill" + case decision.Track == "planning_track": + decision.Route = "planner" + } + } + if decision.Track == "" { + switch decision.Route { + case "skill", "workflow": + decision.Track = "fast_track" + case "planner": + decision.Track = "planning_track" + } + } + if decision.Intent == "" { + switch { + case decision.Route == "instant_reply": + decision.Intent = "instant_reply" + case decision.TargetSnapshotID != "" && decision.Route == "" && decision.Skill == "" && decision.Action == "" && len(decision.Tasks) == 0: + decision.Intent = "resume_snapshot" + case decision.Route != "" || decision.Track != "" || decision.Skill != "" || decision.Action != "" || decision.TargetSkill != "" || len(decision.Tasks) > 0: + decision.Intent = "start_new" + } + } + if decision.Skill == "" && decision.Action == "" && decision.TargetSkill != "" { + decision.Skill, decision.Action = parseTargetSkill(decision.TargetSkill) + } + if decision.Route == "" && decision.NeedPlannerHelp { + decision.Route = "planner" + } + if decision.Route == "workflow" { + decision.Skill = "" + decision.Action = "" + decision.Filter = "" + return decision + } + if decision.Route != "skill" { + decision.Action = "" + decision.Skill = "" + decision.Filter = "" + decision.Tasks = nil + return decision + } + decision.Tasks = nil if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" { decision.Action = "query_running" } else { @@ -177,79 +274,330 @@ func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRout return decision } -func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) { - session := skillSession{Name: decision.Skill, Action: decision.Action} +func (a *Agent) routeTurnWithLLM(ctx context.Context, userID int64, lang, text string) (llmSkillRouteDecision, bool, error) { + systemPrompt, userPrompt := a.buildTopLevelRouterPrompt(userID, lang, text) + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() - switch decision.Skill { - case "trader_management": - if decision.Action == "create" { - answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session) - if !handled { - return skillOutcome{}, false - } - return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true - } - answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session) - if handled && decision.Action == "query_running" { - answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only") - } - if !handled { - return skillOutcome{}, false - } - return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true - case "exchange_management": - answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session) - if !handled { - return skillOutcome{}, false - } - return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true - case "model_management": - answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session) - if !handled { - return skillOutcome{}, false - } - return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true - case "strategy_management": - answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session) - if !handled { - return skillOutcome{}, false - } - return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true - case "model_diagnosis": - return skillOutcome{ - Skill: decision.Skill, - Action: defaultIfEmpty(decision.Action, "diagnose"), - Status: skillOutcomeSuccess, - GoalAchieved: true, - UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text), - }, true - case "exchange_diagnosis": - return skillOutcome{ - Skill: decision.Skill, - Action: defaultIfEmpty(decision.Action, "diagnose"), - Status: skillOutcomeSuccess, - GoalAchieved: true, - UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text), - }, true - case "trader_diagnosis": - return skillOutcome{ - Skill: decision.Skill, - Action: defaultIfEmpty(decision.Action, "diagnose"), - Status: skillOutcomeSuccess, - GoalAchieved: true, - UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text), - }, true - case "strategy_diagnosis": - return skillOutcome{ - Skill: decision.Skill, - Action: defaultIfEmpty(decision.Action, "diagnose"), - Status: skillOutcomeSuccess, - GoalAchieved: true, - UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text), - }, true - default: - return skillOutcome{}, false + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return llmSkillRouteDecision{}, false, err } + decision, err := parseLLMSkillRouteDecision(raw) + if err != nil { + return llmSkillRouteDecision{}, false, err + } + return decision, true, nil +} + +func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (string, string) { + activeSkill := a.getSkillSession(userID) + activeTask, hasActiveTask := a.getActiveSkillSession(userID) + activeWorkflow := a.getWorkflowSession(userID) + activeExec := a.getExecutionState(userID) + pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID) + previousAssistantReply := a.currentPendingHintText(userID) + snapshots := a.SnapshotManager(userID).List() + snapshotJSON, _ := json.Marshal(snapshots) + + currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) + managementSummary := buildManagementSkillRoutingContextWithSession(lang, &activeSkill) + recentConversation := a.buildRecentConversationContext(userID, text) + if strings.TrimSpace(recentConversation) == "" { + recentConversation = "(empty)" + } + + activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal) + if strings.TrimSpace(activeFlowSummary) == "" { + activeFlowSummary = "none" + } + + systemPrompt := prependNOFXiAdvisorPreamble(`You are the lightweight intent planner for NOFXi. +Return JSON only. + +You are deciding what the current user turn should do at the top level. +You must classify every message into exactly one of these intents before any execution layer takes over. + +Valid intents: +- "continue_active": the user is still working on the current active flow +- "start_new": the user is starting or switching to a new task +- "resume_snapshot": the user wants to resume one suspended snapshot +- "cancel": the user wants to cancel the current active flow +- "instant_reply": the user is greeting, chatting, thanking, or asking for a direct explanation without changing task state + +Valid routes when intent=start_new: +- "skill" +- "workflow" +- "planner" + +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. +- If the user is clearly answering the previous question, prefer "continue_active". +- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active". +- If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id. +- If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply". +- 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". +- 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":"","route":"skill|workflow|planner","track":"fast_track|planning_track","skill":"","action":"","target_skill":"","filter":"","tasks":[],"context_switch":false,"need_planner_help":false,"confidence":0.0}`) + + if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal { + systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass semantic gateway for NOFXi. +Return JSON only. + +You are deciding whether the user is continuing the current active flow, switching to a new task, resuming a suspended snapshot, cancelling, or simply asking for a direct reply. + +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 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. +- You may set target_skill when intent=start_new and the next task is clear. + +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}`) + } + + 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", + lang, + text, + defaultIfEmpty(previousAssistantReply, "(empty)"), + defaultIfEmpty(managementSummary, "(empty)"), + defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"), + currentRefs, + activeFlowSummary, + defaultIfEmpty(string(snapshotJSON), "[]"), + recentConversation, + ) + + return systemPrompt, userPrompt +} + +func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string { + lines := make([]string, 0, 8) + if hasActiveTask { + lines = append(lines, fmt.Sprintf("Active task session: %s / %s / phase=%s", activeTask.SkillName, activeTask.ActionName, defaultIfEmpty(activeTask.LegacyPhase, "collecting"))) + if strings.TrimSpace(activeTask.Goal) != "" { + lines = append(lines, "Active task goal: "+strings.TrimSpace(activeTask.Goal)) + } + if activeTask.PendingHint != nil && strings.TrimSpace(activeTask.PendingHint.Prompt) != "" { + lines = append(lines, "Active task pending hint: "+strings.TrimSpace(activeTask.PendingHint.Prompt)) + } + if len(activeTask.CollectedFields) > 0 { + fieldsJSON, _ := json.Marshal(activeTask.CollectedFields) + lines = append(lines, "Active task collected_fields: "+string(fieldsJSON)) + } + } + if strings.TrimSpace(skill.Name) != "" { + lines = append(lines, fmt.Sprintf("Active skill session: %s / %s / phase=%s", skill.Name, skill.Action, defaultIfEmpty(skill.Phase, "collecting"))) + if routing := buildSkillActionRoutingSummary(lang, skill); routing != "" { + lines = append(lines, routing) + } + } + if hasActiveWorkflowSession(workflow) { + lines = append(lines, fmt.Sprintf("Active workflow: original_request=%s pending_tasks=%d", workflow.OriginalRequest, countPendingWorkflowTasks(workflow))) + } + if hasActiveExecutionState(state) { + lines = append(lines, fmt.Sprintf("Active execution state: status=%s goal=%s", state.Status, state.Goal)) + if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" { + lines = append(lines, "Waiting question: "+strings.TrimSpace(state.Waiting.Question)) + } + } + if hasPendingProposal { + lines = append(lines, "Pending assistant proposal awaiting user response.") + if strings.TrimSpace(pendingProposal.SourceUserText) != "" { + lines = append(lines, "Proposal source request: "+strings.TrimSpace(pendingProposal.SourceUserText)) + } + lines = append(lines, "Proposal text: "+strings.TrimSpace(pendingProposal.ProposalText)) + } + return strings.Join(lines, "\n") +} + +func (a *Agent) handlePendingProposalResponse(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { + proposal, ok := a.getPendingProposalSession(userID) + if !ok { + return "", false, nil + } + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("The user is replying to the assistant's previous proposal.\n\nOriginal user request:\n%s\n\nPrevious assistant proposal:\n%s\n\nCurrent user reply:\n%s", proposal.SourceUserText, proposal.ProposalText, text), onEvent) + if err == nil && strings.TrimSpace(answer) != "" { + a.clearPendingProposalSession(userID) + } + return answer, true, err +} + +func countPendingWorkflowTasks(session WorkflowSession) int { + count := 0 + for _, task := range session.Tasks { + switch task.Status { + case workflowTaskPending, workflowTaskRunning: + count++ + } + } + return count +} + +func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) { + session := skillSession{Name: decision.Skill, Action: decision.Action, Phase: "collecting"} + applyExtractedFieldsToSkillSession(&session, decision.ExtractedFields, "llm_router") + return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, session, nil) +} + +func applyExtractedFieldsToSkillSession(session *skillSession, values map[string]any, source string) { + if session == nil || len(values) == 0 { + return + } + ensureSkillFields(session) + for key, raw := range values { + value := strings.TrimSpace(fmt.Sprint(raw)) + if value == "" { + continue + } + switch key { + case "target_ref_id": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.ID = value + if source != "" { + session.TargetRef.Source = source + } + case "target_ref_name": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.Name = value + if source != "" { + session.TargetRef.Source = source + } + default: + setField(session, key, value) + } + } +} + +func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string { + if refs == nil { + if lang == "zh" { + return "- 当前没有明确锁定的操作对象。" + } + return "- No current entity references are locked yet." + } + + lines := make([]string, 0, 4) + appendLine := func(kind string, ref *EntityReference) { + if ref == nil { + return + } + name := strings.TrimSpace(defaultIfEmpty(ref.Name, ref.ID)) + if name == "" { + return + } + source := formatReferenceSourceLabel(lang, ref.Source) + if lang == "zh" { + line := fmt.Sprintf("- 当前%s: %s", referenceKindDisplayName(lang, kind), name) + if source != "" { + line += fmt.Sprintf("(来源: %s)", source) + } + if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name { + line += fmt.Sprintf(" [id=%s]", ref.ID) + } + lines = append(lines, line) + return + } + + line := fmt.Sprintf("- Current %s: %s", referenceKindDisplayName(lang, kind), name) + if source != "" { + line += fmt.Sprintf(" (source: %s)", source) + } + if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name { + line += fmt.Sprintf(" [id=%s]", ref.ID) + } + lines = append(lines, line) + } + + appendLine("strategy", refs.Strategy) + appendLine("trader", refs.Trader) + appendLine("model", refs.Model) + appendLine("exchange", refs.Exchange) + + if len(lines) == 0 { + if lang == "zh" { + return "- 当前没有明确锁定的操作对象。" + } + return "- No current entity references are locked yet." + } + return strings.Join(lines, "\n") +} + +func formatReferenceSourceLabel(lang, source string) string { + source = strings.TrimSpace(source) + if source == "" { + return "" + } + if lang == "zh" { + switch source { + case "user_mention": + return "用户提及" + case "tool_output": + return "工具结果" + case "inferred_from_context": + return "上下文推断" + default: + return source + } + } + switch source { + case "user_mention": + return "user mention" + case "tool_output": + return "tool output" + case "inferred_from_context": + return "context inference" + default: + return source + } +} + +func hasAnyActiveContext(a *Agent, userID int64) bool { + if a == nil { + return false + } + return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) +} + +func (a *Agent) clearAnyActiveContext(userID int64) bool { + cleared := false + if a.hasActiveSkillSession(userID) { + a.clearSkillSession(userID) + cleared = true + } + if hasActiveWorkflowSession(a.getWorkflowSession(userID)) { + a.clearWorkflowSession(userID) + cleared = true + } + if hasActiveExecutionState(a.getExecutionState(userID)) { + a.clearExecutionState(userID) + cleared = true + } + if cleared { + a.SnapshotManager(userID).Clear() + } + return cleared } func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any { diff --git a/agent/memory.go b/agent/memory.go index 4b274648..7ffe5c36 100644 --- a/agent/memory.go +++ b/agent/memory.go @@ -11,8 +11,9 @@ import ( ) const ( - recentConversationRounds = 3 + recentConversationRounds = 6 recentConversationMessages = recentConversationRounds * 2 + chatHistoryMaxTurns = recentConversationMessages * 2 // fallback cap when compression is unavailable taskStateSummaryTokenLimit = 1200 shortTermCompressThreshold = 900 incrementalTaskStateMessages = 6 diff --git a/agent/memory_test.go b/agent/memory_test.go deleted file mode 100644 index ed772be9..00000000 --- a/agent/memory_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package agent - -import ( - "context" - "log/slog" - "path/filepath" - "strings" - "testing" - "time" - - "nofx/mcp" - "nofx/store" -) - -type fakeAIClient struct { - callCount int -} - -func (f *fakeAIClient) SetAPIKey(string, string, string) {} -func (f *fakeAIClient) SetTimeout(time.Duration) {} -func (f *fakeAIClient) CallWithMessages(string, string) (string, error) { - return "", nil -} -func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) { - f.callCount++ - return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil -} -func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) { - return "", nil -} -func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) { - return nil, nil -} - -func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) { - st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db")) - if err != nil { - t.Fatalf("store.New() error = %v", err) - } - - fakeClient := &fakeAIClient{} - a := &Agent{ - store: st, - logger: slog.Default(), - history: newChatHistory(100), - aiClient: fakeClient, - } - - userID := int64(42) - payload := strings.Repeat("BTC ETH market context ", 20) - for i := 0; i < 6; i++ { - a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload) - a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload) - } - - a.maybeCompressHistory(context.Background(), userID) - - msgs := a.history.Get(userID) - if len(msgs) != recentConversationMessages { - t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs)) - } - if fakeClient.callCount != 1 { - t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount) - } - - state := a.getTaskState(userID) - if state.CurrentGoal != "continue setup" { - t.Fatalf("expected persisted task state goal, got %#v", state) - } - if state.LastDecision == nil || state.LastDecision.Action != "paused setup" { - t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision) - } - if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" { - t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops) - } - if strings.Contains(msgs[0].Content, "#0") { - t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content) - } - if !strings.Contains(msgs[0].Content, "#3") { - t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content) - } - if !strings.Contains(msgs[len(msgs)-1].Content, "#5") { - t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content) - } -} - -func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) { - state := normalizeTaskState(TaskState{ - OpenLoops: []string{ - "wait for API secret", - "call get_exchange_configs", - "finish trader setup after external configuration is ready", - }, - }) - - if len(state.OpenLoops) != 1 { - t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops) - } - if state.OpenLoops[0] != "finish trader setup after external configuration is ready" { - t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops) - } -} - -func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) { - st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db")) - if err != nil { - t.Fatalf("store.New() error = %v", err) - } - - fakeClient := &fakeAIClient{} - a := &Agent{ - store: st, - logger: slog.Default(), - history: newChatHistory(100), - aiClient: fakeClient, - } - - userID := int64(7) - a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的,错误是运行时出现的") - a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误") - - a.maybeUpdateTaskStateIncrementally(context.Background(), userID) - - if fakeClient.callCount != 1 { - t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount) - } - - state := a.getTaskState(userID) - if state.CurrentGoal != "continue setup" { - t.Fatalf("expected incrementally persisted task state, got %#v", state) - } -} diff --git a/agent/model_create_flow_test.go b/agent/model_create_flow_test.go new file mode 100644 index 00000000..1304f229 --- /dev/null +++ b/agent/model_create_flow_test.go @@ -0,0 +1,45 @@ +package agent + +import ( + "log/slog" + "path/filepath" + "strings" + "testing" + + "nofx/store" +) + +func TestHandleModelCreateSkillAsksProviderFirstWithClaw402Recommendation(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "agent-model-create.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + + a := New(nil, st, DefaultConfig(), slog.Default()) + reply := a.handleModelCreateSkill("default", 42, "zh", "请帮我创建一个模型", skillSession{}) + + for _, want := range []string{ + "还缺这些字段:模型提供商", + "可选模型 provider", + "推荐 `claw402`", + "并列可选", + "按次付费", + "Base USDC 钱包支付", + "直接创建 Base 钱包", + "直接扫码充值/支付", + } { + if !strings.Contains(reply, want) { + t.Fatalf("expected reply to contain %q, got: %s", want, reply) + } + } + for _, unexpected := range []string{ + "还缺这些字段:模型提供商、API Key", + "还缺这些字段:模型提供商、钱包私钥", + "还缺这些字段:模型提供商、wallet private key", + } { + if strings.Contains(reply, unexpected) { + t.Fatalf("provider-first reply should not ask for credentials yet: %s", reply) + } + } +} diff --git a/agent/model_provider_catalog.go b/agent/model_provider_catalog.go new file mode 100644 index 00000000..a42eedc6 --- /dev/null +++ b/agent/model_provider_catalog.go @@ -0,0 +1,242 @@ +package agent + +import ( + "fmt" + "strings" +) + +type modelProviderSpec struct { + ID string + DisplayName string + DefaultModel string + CredentialLabelZH string + CredentialLabelEN string + SupportsCustomAPIURL bool + SupportsCustomModel bool + UsesWalletCredential bool + Recommended bool + RecommendedModelHints []string +} + +func supportedModelProviders() []modelProviderSpec { + return []modelProviderSpec{ + {ID: "deepseek", DisplayName: "DeepSeek", DefaultModel: "deepseek-chat", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "qwen", DisplayName: "Qwen", DefaultModel: "qwen3-max", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "openai", DisplayName: "OpenAI", DefaultModel: "gpt-5.1", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "claude", DisplayName: "Claude", DefaultModel: "claude-opus-4-6", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "gemini", DisplayName: "Google Gemini", DefaultModel: "gemini-3-pro-preview", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "grok", DisplayName: "Grok (xAI)", DefaultModel: "grok-3-latest", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "kimi", DisplayName: "Kimi (Moonshot)", DefaultModel: "moonshot-v1-auto", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + {ID: "minimax", DisplayName: "MiniMax", DefaultModel: "MiniMax-M2.5", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true}, + { + ID: "claw402", + DisplayName: "Claw402 (Base USDC)", + DefaultModel: "deepseek", + CredentialLabelZH: "钱包私钥", + CredentialLabelEN: "wallet private key", + SupportsCustomAPIURL: false, + SupportsCustomModel: true, + UsesWalletCredential: true, + Recommended: true, + RecommendedModelHints: []string{"deepseek", "glm-5", "gpt-5.4", "claude-opus", "qwen-max", "grok-4.1"}, + }, + { + ID: "blockrun-base", + DisplayName: "BlockRun (Base Wallet)", + DefaultModel: "auto", + CredentialLabelZH: "钱包私钥", + CredentialLabelEN: "wallet private key", + SupportsCustomAPIURL: false, + SupportsCustomModel: false, + UsesWalletCredential: true, + }, + { + ID: "blockrun-sol", + DisplayName: "BlockRun (Solana Wallet)", + DefaultModel: "auto", + CredentialLabelZH: "钱包私钥", + CredentialLabelEN: "wallet private key", + SupportsCustomAPIURL: false, + SupportsCustomModel: false, + UsesWalletCredential: true, + }, + } +} + +func modelProviderSpecByID(provider string) (modelProviderSpec, bool) { + provider = strings.ToLower(strings.TrimSpace(provider)) + for _, spec := range supportedModelProviders() { + if spec.ID == provider { + return spec, true + } + } + return modelProviderSpec{}, false +} + +func supportedModelProviderIDs() []string { + specs := supportedModelProviders() + out := make([]string, 0, len(specs)) + for _, spec := range specs { + out = append(out, spec.ID) + } + return out +} + +func defaultModelNameForProvider(provider string) string { + spec, ok := modelProviderSpecByID(provider) + if !ok { + return "" + } + return strings.TrimSpace(spec.DefaultModel) +} + +func defaultModelConfigName(provider string) string { + spec, ok := modelProviderSpecByID(provider) + if !ok { + provider = strings.TrimSpace(provider) + if provider == "" { + return "" + } + return provider + " AI" + } + return spec.DisplayName +} + +func modelProviderSupportsCustomAPIURL(provider string) bool { + spec, ok := modelProviderSpecByID(provider) + return ok && spec.SupportsCustomAPIURL +} + +func modelProviderSupportsCustomModel(provider string) bool { + spec, ok := modelProviderSpecByID(provider) + return ok && spec.SupportsCustomModel +} + +func modelProviderCredentialLabel(lang, provider string) string { + spec, ok := modelProviderSpecByID(provider) + if !ok { + if lang == "zh" { + return "API Key" + } + return "API key" + } + if lang == "zh" { + return spec.CredentialLabelZH + } + return spec.CredentialLabelEN +} + +func modelProviderSummaryList(lang string) string { + parts := make([]string, 0, len(supportedModelProviders())) + for _, spec := range supportedModelProviders() { + if lang == "zh" { + item := fmt.Sprintf("%s(默认 %s)", spec.ID, spec.DefaultModel) + if spec.Recommended { + item += " [推荐]" + } + parts = append(parts, item) + continue + } + item := fmt.Sprintf("%s (default %s)", spec.ID, spec.DefaultModel) + if spec.Recommended { + item += " [recommended]" + } + parts = append(parts, item) + } + if lang == "zh" { + return strings.Join(parts, "、") + } + return strings.Join(parts, ", ") +} + +func modelProviderChoicePrompt(lang string) string { + if lang == "zh" { + return "可选模型 provider:" + modelProviderSummaryList(lang) + "。这些 provider 是并列可选的:你可以直接选 `claw402`、DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax 这类 API Key provider,或者选 `blockrun-base` / `blockrun-sol` 这类钱包 provider。我们优先推荐 `claw402`,因为它按次付费、用 Base USDC 钱包支付、默认配置更省事。对于第一次使用的新手,也可以直接去产品配置页的模型配置里选择 `claw402`:那里支持直接创建 Base 钱包,并且可以直接扫码充值/支付。请先告诉我你想用哪个 provider。" + } + return "Available model providers: " + modelProviderSummaryList(lang) + ". These providers are peer options: you can choose `claw402`, an API-key provider such as DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax, or a wallet-based provider such as `blockrun-base` / `blockrun-sol`. We recommend `claw402` first because it is pay-per-use, uses Base USDC wallet payment, and has the simplest default setup. If this is your first time, you can also open the product's model config page, choose `claw402`, create a Base wallet there directly, and pay by scanning the QR/deposit flow. Tell me which provider you want first." +} + +func modelProviderDetailedGuidance(lang, provider string) string { + spec, ok := modelProviderSpecByID(provider) + if !ok { + return "" + } + if lang == "zh" { + lines := []string{ + fmt.Sprintf("你现在选的是 %s。", spec.DisplayName), + fmt.Sprintf("- 默认模型名:%s", spec.DefaultModel), + fmt.Sprintf("- 凭证类型:%s", spec.CredentialLabelZH), + } + if spec.SupportsCustomModel { + lines = append(lines, "- `custom_model_name` 可选;留空时默认用上面的默认模型。") + } else { + lines = append(lines, "- 这个 provider 不需要单独填写 `custom_model_name`。") + } + if spec.SupportsCustomAPIURL { + lines = append(lines, "- `custom_api_url` 可选;留空时使用官方默认地址。") + } else { + lines = append(lines, "- 这个 provider 不需要 `custom_api_url`。") + } + if len(spec.RecommendedModelHints) > 0 { + lines = append(lines, "- 常见可选模型:"+strings.Join(spec.RecommendedModelHints, "、")) + } + if provider == "claw402" { + lines = append(lines, "- 这是我们优先推荐的 provider:按次付费、Base USDC 钱包支付,对新手最省事。") + lines = append(lines, "- 如果你是第一次用,也可以直接去配置页的模型配置里选择 `claw402`,那里支持直接创建 Base 钱包,并可直接扫码充值/支付。") + } + return strings.Join(lines, "\n") + } + lines := []string{ + fmt.Sprintf("You selected %s.", spec.DisplayName), + fmt.Sprintf("- Default model: %s", spec.DefaultModel), + fmt.Sprintf("- Credential type: %s", spec.CredentialLabelEN), + } + if spec.SupportsCustomModel { + lines = append(lines, "- `custom_model_name` is optional; if omitted, the default model will be used.") + } else { + lines = append(lines, "- This provider does not need a separate `custom_model_name`.") + } + if spec.SupportsCustomAPIURL { + lines = append(lines, "- `custom_api_url` is optional; if omitted, the official default endpoint will be used.") + } else { + lines = append(lines, "- This provider does not need `custom_api_url`.") + } + if len(spec.RecommendedModelHints) > 0 { + lines = append(lines, "- Common model choices: "+strings.Join(spec.RecommendedModelHints, ", ")) + } + if provider == "claw402" { + lines = append(lines, "- This is our recommended provider: pay-per-use, Base USDC wallet payment, and the easiest setup for first-time users.") + lines = append(lines, "- If this is your first time, you can also open the model config page, choose `claw402`, create a Base wallet there directly, and pay through the QR/deposit flow.") + } + return strings.Join(lines, "\n") +} + +func modelProviderCredentialGuidance(lang, provider string) string { + spec, ok := modelProviderSpecByID(provider) + if !ok { + return "" + } + provider = strings.TrimSpace(spec.ID) + if lang == "zh" { + switch provider { + case "claw402": + return "claw402 这里要填的是 Base 链 EVM 钱包私钥。\n- 如果你是第一次用,最省事的方式是直接去配置页的模型配置里选择 `claw402`。\n- 那里可以一键快速创建钱包,界面会直接展示新钱包私钥,并且提供 Base USDC 充值入口。\n- 创建后请立刻备份私钥;系统会用它完成 claw402 支付和模型调用。\n- 如果你已经有 MetaMask、Rabby、Coinbase Wallet 这类 Base/EVM 钱包,也可以从钱包里导出现有私钥再发我。" + case "blockrun-base": + return "blockrun-base 这里要填的是 Base 链 EVM 钱包私钥。你可以从现有 EVM 钱包导出私钥后发我。" + case "blockrun-sol": + return "blockrun-sol 这里要填的是 Solana 钱包私钥。你可以从现有 Solana 钱包导出私钥后发我。" + default: + return fmt.Sprintf("%s 这里要填的是 %s。你把完整值发我就行,我会继续当前模型草稿。", spec.DisplayName, spec.CredentialLabelZH) + } + } + switch provider { + case "claw402": + return "For claw402, this field expects a Base-chain EVM wallet private key.\n- If this is your first time, the easiest path is to open the model config page and choose `claw402`.\n- That flow can quickly create a wallet for you, show the new private key, and provide a Base USDC deposit path.\n- Back up the key immediately after creation; the system uses it for claw402 payments and model access.\n- If you already use MetaMask, Rabby, or Coinbase Wallet, you can also export an existing Base/EVM wallet private key and send it to me." + case "blockrun-base": + return "For blockrun-base, this field expects a Base-chain EVM wallet private key. You can export it from an existing EVM wallet and send it to me." + case "blockrun-sol": + return "For blockrun-sol, this field expects a Solana wallet private key. You can export it from an existing Solana wallet and send it to me." + default: + return fmt.Sprintf("For %s, this field expects your %s. Send me the full value and I'll continue the current model draft.", spec.DisplayName, spec.CredentialLabelEN) + } +} diff --git a/agent/model_provider_catalog_test.go b/agent/model_provider_catalog_test.go new file mode 100644 index 00000000..8921b598 --- /dev/null +++ b/agent/model_provider_catalog_test.go @@ -0,0 +1,57 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestModelProviderChoicePromptIncludesRecommendationWithoutAutoSelection(t *testing.T) { + msg := modelProviderChoicePrompt("zh") + for _, want := range []string{ + "可选模型 provider", + "claw402", + "DeepSeek", + "OpenAI", + "并列可选", + "blockrun-base", + "直接创建 Base 钱包", + "直接扫码充值/支付", + "请先告诉我你想用哪个 provider", + } { + if !strings.Contains(msg, want) { + t.Fatalf("expected prompt to contain %q, got: %s", want, msg) + } + } + if strings.Contains(msg, "把私钥发给我") { + t.Fatalf("provider choice prompt should not jump ahead to credential collection: %s", msg) + } +} + +func TestModelProviderCredentialGuidanceForClaw402MentionsConfigPageWalletFlow(t *testing.T) { + msg := modelProviderCredentialGuidance("zh", "claw402") + for _, want := range []string{ + "Base 链 EVM 钱包私钥", + "配置页的模型配置里选择 `claw402`", + "快速创建钱包", + "充值入口", + } { + if !strings.Contains(msg, want) { + t.Fatalf("expected guidance to contain %q, got: %s", want, msg) + } + } +} + +func TestModelProviderDetailedGuidanceForClaw402MentionsBeginnerFlow(t *testing.T) { + msg := modelProviderDetailedGuidance("zh", "claw402") + for _, want := range []string{ + "优先推荐", + "按次付费", + "Base USDC 钱包支付", + "直接创建 Base 钱包", + "直接扫码充值/支付", + } { + if !strings.Contains(msg, want) { + t.Fatalf("expected detailed guidance to contain %q, got: %s", want, msg) + } + } +} diff --git a/agent/onboard.go b/agent/onboard.go index aa7fe436..b89e5b18 100644 --- a/agent/onboard.go +++ b/agent/onboard.go @@ -11,6 +11,7 @@ import ( ) var titleCaser = cases.Title(language.English) + const setupExchangeAccountName = "Default" // Onboard handles first-time setup through natural language. @@ -41,6 +42,11 @@ func (a *Agent) needsSetup() bool { // getSetupState loads the current setup state from user preferences. func (a *Agent) getSetupState(userID int64) *SetupState { + if cached, ok := a.setupStates.Load(userID); ok { + if state, ok := cached.(*SetupState); ok && state != nil { + return cloneSetupState(state) + } + } step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID)) if step == "" { return &SetupState{} @@ -49,35 +55,30 @@ func (a *Agent) getSetupState(userID int64) *SetupState { Step: step, Exchange: getConfig(a.store, userID, "exchange"), ExchangeID: getConfig(a.store, userID, "exchange_id"), - APIKey: getConfig(a.store, userID, "api_key"), - APISecret: getConfig(a.store, userID, "api_secret"), - Passphrase: getConfig(a.store, userID, "passphrase"), AIProvider: getConfig(a.store, userID, "ai_provider"), AIModel: getConfig(a.store, userID, "ai_model"), AIModelID: getConfig(a.store, userID, "ai_model_id"), - AIKey: getConfig(a.store, userID, "ai_key"), AIBaseURL: getConfig(a.store, userID, "ai_base_url"), } } func (a *Agent) saveSetupState(userID int64, s *SetupState) { + a.setupStates.Store(userID, cloneSetupState(s)) a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step) setConfig(a.store, userID, "exchange", s.Exchange) setConfig(a.store, userID, "exchange_id", s.ExchangeID) - setConfig(a.store, userID, "api_key", s.APIKey) - setConfig(a.store, userID, "api_secret", s.APISecret) - setConfig(a.store, userID, "passphrase", s.Passphrase) setConfig(a.store, userID, "ai_provider", s.AIProvider) setConfig(a.store, userID, "ai_model", s.AIModel) setConfig(a.store, userID, "ai_model_id", s.AIModelID) - setConfig(a.store, userID, "ai_key", s.AIKey) setConfig(a.store, userID, "ai_base_url", s.AIBaseURL) } func (a *Agent) clearSetupState(userID int64) { - for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} { + a.setupStates.Delete(userID) + for _, k := range []string{"step", "exchange", "exchange_id", "ai_provider", "ai_model", "ai_model_id", "ai_base_url"} { a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), "") } + a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), "") } func getConfig(st *store.Store, uid int64, key string) string { @@ -89,6 +90,14 @@ func setConfig(st *store.Store, uid int64, key, val string) { st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val) } +func cloneSetupState(s *SetupState) *SetupState { + if s == nil { + return &SetupState{} + } + copy := *s + return © +} + // handleSetupFlow processes the setup conversation. // Returns (response, handled). If handled=false, continue to normal routing. func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) { @@ -152,7 +161,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te if L == "zh" { return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true } - return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true + return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true } state.ExchangeID = exchangeID state.Step = "await_ai_model" @@ -169,7 +178,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te if L == "zh" { return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true } - return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true + return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true } state.ExchangeID = exchangeID state.Step = "await_ai_model" @@ -188,7 +197,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te if L == "zh" { return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true } - return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true + return fmt.Sprintf("⚠️ I could not save the AI model settings just now: %v\nPlease try again, or continue later on the web page.", err), true } state.AIModelID = aiModelID return a.finishSetup(storeUserID, userID, state, L) @@ -252,19 +261,19 @@ func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, st lower := strings.ToLower(strings.TrimSpace(text)) models := map[string]struct{ provider, model, url string }{ - "deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"}, - "1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"}, - "qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"}, + "1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"}, + "qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"}, "通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "openai": {"openai", "gpt-4o", "https://api.openai.com/v1"}, - "gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"}, - "3": {"openai", "gpt-4o", "https://api.openai.com/v1"}, - "claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"}, - "4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"}, - "skip": {"", "", ""}, + "2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "openai": {"openai", "gpt-4o", "https://api.openai.com/v1"}, + "gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"}, + "3": {"openai", "gpt-4o", "https://api.openai.com/v1"}, + "claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"}, + "4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"}, + "skip": {"", "", ""}, "跳过": {"", "", ""}, - "5": {"", "", ""}, + "5": {"", "", ""}, } choice, ok := models[lower] diff --git a/agent/onboard_test.go b/agent/onboard_test.go deleted file mode 100644 index 0529650c..00000000 --- a/agent/onboard_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package agent - -import "testing" - -func TestIsDirectSetupCommand(t *testing.T) { - cases := []struct { - text string - want bool - }{ - {text: "setup", want: true}, - {text: "/setup", want: true}, - {text: "开始配置", want: false}, - {text: "/开始配置", want: false}, - {text: "创建全新的配置,杠杆你定", want: false}, - {text: "帮我配置一个 deepseek 模型", want: false}, - {text: "绑定交易所 okx", want: false}, - {text: "配置", want: false}, - } - - for _, tc := range cases { - if got := isDirectSetupCommand(tc.text); got != tc.want { - t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want) - } - } -} diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 5db56a64..c4fcc554 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "time" @@ -14,7 +15,7 @@ import ( const ( plannerMaxSteps = 8 plannerMaxIterations = 12 - observationMaxLength = 400 + observationMaxLength = 1000 ) var ( @@ -198,6 +199,28 @@ func isRealtimeAccountIntent(text string) bool { func snapshotKindsForIntent(userText string) []string { kinds := make([]string, 0, 6) + lower := strings.ToLower(strings.TrimSpace(userText)) + if lower == "" || isRealtimeAccountIntent(lower) { + return nil + } + + configKeywords := []string{ + "交易员", "trader", "traders", + "交易所", "exchange", "exchanges", + "模型", "model", "models", "llm", "ai model", + "策略", "strategy", "strategies", + "配置", "config", "setup", "create", "创建", "修改", "更新", "删除", "delete", + } + if containsAnyKeyword(lower, configKeywords) { + kinds = append(kinds, + "current_model_configs", + "current_exchange_configs", + "current_traders", + ) + if strings.Contains(lower, "策略") || strings.Contains(lower, "strategy") { + kinds = append(kinds, "current_strategies") + } + } return uniqueStrings(kinds) } @@ -277,9 +300,10 @@ func ensureCurrentReferences(state *ExecutionState) { } } -func preferReference(current **EntityReference, id, name string) { +func preferReference(current **EntityReference, id, name, source string) { id = strings.TrimSpace(id) name = strings.TrimSpace(name) + source = strings.TrimSpace(source) if id == "" && name == "" { return } @@ -292,6 +316,31 @@ func preferReference(current **EntityReference, id, name string) { if name != "" { (*current).Name = name } + if source != "" { + (*current).Source = source + } + (*current).UpdatedAt = time.Now().UTC().Format(time.RFC3339) +} + +func appendReferenceHistory(state *ExecutionState, kind, id, name, source string) { + if state == nil { + return + } + kind = strings.TrimSpace(kind) + id = strings.TrimSpace(id) + name = strings.TrimSpace(name) + source = strings.TrimSpace(source) + if kind == "" || (id == "" && name == "") { + return + } + state.ReferenceHistory = append(state.ReferenceHistory, ReferenceRecord{ + Kind: kind, + ID: id, + Name: name, + Source: source, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + state.ReferenceHistory = normalizeReferenceHistory(state.ReferenceHistory) } func matchEntityReference(text string, candidates []EntityReference) *EntityReference { @@ -329,7 +378,8 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st candidates = append(candidates, EntityReference{ID: strategy.ID, Name: strategy.Name}) } if ref := matchEntityReference(text, candidates); ref != nil { - preferReference(&state.CurrentReferences.Strategy, ref.ID, ref.Name) + preferReference(&state.CurrentReferences.Strategy, ref.ID, ref.Name, "user_mention") + appendReferenceHistory(state, "strategy", ref.ID, ref.Name, "user_mention") } } if traders, err := a.store.Trader().List(storeUserID); err == nil { @@ -338,7 +388,8 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st candidates = append(candidates, EntityReference{ID: trader.ID, Name: trader.Name}) } if ref := matchEntityReference(text, candidates); ref != nil { - preferReference(&state.CurrentReferences.Trader, ref.ID, ref.Name) + preferReference(&state.CurrentReferences.Trader, ref.ID, ref.Name, "user_mention") + appendReferenceHistory(state, "trader", ref.ID, ref.Name, "user_mention") } } if models, err := a.store.AIModel().List(storeUserID); err == nil { @@ -354,7 +405,8 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st candidates = append(candidates, EntityReference{ID: model.ID, Name: name}) } if ref := matchEntityReference(text, candidates); ref != nil { - preferReference(&state.CurrentReferences.Model, ref.ID, ref.Name) + preferReference(&state.CurrentReferences.Model, ref.ID, ref.Name, "user_mention") + appendReferenceHistory(state, "model", ref.ID, ref.Name, "user_mention") } } if exchanges, err := a.store.Exchange().List(storeUserID); err == nil { @@ -367,7 +419,8 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st candidates = append(candidates, EntityReference{ID: exchange.ID, Name: name}) } if ref := matchEntityReference(text, candidates); ref != nil { - preferReference(&state.CurrentReferences.Exchange, ref.ID, ref.Name) + preferReference(&state.CurrentReferences.Exchange, ref.ID, ref.Name, "user_mention") + appendReferenceHistory(state, "exchange", ref.ID, ref.Name, "user_mention") } } } @@ -386,14 +439,18 @@ func updateCurrentReferencesFromToolResult(state *ExecutionState, toolName, raw switch toolName { case "manage_strategy": if item, ok := payload["strategy"].(map[string]any); ok { - preferReference(&state.CurrentReferences.Strategy, asString(item["id"]), asString(item["name"])) + id, name := asString(item["id"]), asString(item["name"]) + preferReference(&state.CurrentReferences.Strategy, id, name, "tool_output") + appendReferenceHistory(state, "strategy", id, name, "tool_output") } case "manage_trader": if item, ok := payload["trader"].(map[string]any); ok { - preferReference(&state.CurrentReferences.Trader, asString(item["id"]), asString(item["name"])) - preferReference(&state.CurrentReferences.Model, asString(item["ai_model_id"]), "") - preferReference(&state.CurrentReferences.Exchange, asString(item["exchange_id"]), "") - preferReference(&state.CurrentReferences.Strategy, asString(item["strategy_id"]), "") + id, name := asString(item["id"]), asString(item["name"]) + preferReference(&state.CurrentReferences.Trader, id, name, "tool_output") + appendReferenceHistory(state, "trader", id, name, "tool_output") + preferReference(&state.CurrentReferences.Model, asString(item["ai_model_id"]), "", "tool_output") + preferReference(&state.CurrentReferences.Exchange, asString(item["exchange_id"]), "", "tool_output") + preferReference(&state.CurrentReferences.Strategy, asString(item["strategy_id"]), "", "tool_output") } case "manage_model_config": if item, ok := payload["model"].(map[string]any); ok { @@ -401,7 +458,9 @@ func updateCurrentReferencesFromToolResult(state *ExecutionState, toolName, raw if name == "" { name = asString(item["provider"]) } - preferReference(&state.CurrentReferences.Model, asString(item["id"]), name) + id := asString(item["id"]) + preferReference(&state.CurrentReferences.Model, id, name, "tool_output") + appendReferenceHistory(state, "model", id, name, "tool_output") } case "manage_exchange_config": if item, ok := payload["exchange"].(map[string]any); ok { @@ -409,12 +468,33 @@ func updateCurrentReferencesFromToolResult(state *ExecutionState, toolName, raw if name == "" { name = asString(item["exchange_type"]) } - preferReference(&state.CurrentReferences.Exchange, asString(item["id"]), name) + id := asString(item["id"]) + preferReference(&state.CurrentReferences.Exchange, id, name, "tool_output") + appendReferenceHistory(state, "exchange", id, name, "tool_output") } case "get_strategies": - if items, ok := payload["strategies"].([]any); ok && len(items) == 1 { - if item, ok := items[0].(map[string]any); ok { - preferReference(&state.CurrentReferences.Strategy, asString(item["id"]), asString(item["name"])) + if items, ok := payload["strategies"].([]any); ok { + var matched map[string]any + if len(items) == 1 { + matched, _ = items[0].(map[string]any) + } else { + goal := strings.ToLower(strings.TrimSpace(state.Goal)) + for _, it := range items { + item, ok := it.(map[string]any) + if !ok { + continue + } + name := strings.ToLower(strings.TrimSpace(asString(item["name"]))) + if name != "" && goal != "" && strings.Contains(goal, name) { + matched = item + break + } + } + } + if matched != nil { + id, name := asString(matched["id"]), asString(matched["name"]) + preferReference(&state.CurrentReferences.Strategy, id, name, "tool_output") + appendReferenceHistory(state, "strategy", id, name, "tool_output") } } } @@ -459,30 +539,21 @@ func detectReadFastPath(text string) *readFastPathRequest { case "/history", "/trades": return &readFastPathRequest{Kind: "get_trade_history", ArgsJSON: `{"limit":10}`} default: - return nil + switch { + case containsAnyKeyword(lower, []string{"列出", "查看", "看看", "查询", "list", "show"}) && containsAnyKeyword(lower, []string{"策略", "strategy"}): + return &readFastPathRequest{Kind: "get_strategies"} + case containsAnyKeyword(lower, []string{"列出", "查看", "看看", "查询", "list", "show"}) && containsAnyKeyword(lower, []string{"交易员", "trader"}): + return &readFastPathRequest{Kind: "list_traders"} + case containsAnyKeyword(lower, []string{"列出", "查看", "看看", "查询", "list", "show"}) && containsAnyKeyword(lower, []string{"模型", "model"}): + return &readFastPathRequest{Kind: "get_model_configs"} + case containsAnyKeyword(lower, []string{"列出", "查看", "看看", "查询", "list", "show"}) && containsAnyKeyword(lower, []string{"交易所", "exchange"}): + return &readFastPathRequest{Kind: "get_exchange_configs"} + default: + return nil + } } } -func (a *Agent) tryReadFastPath(storeUserID string, userID int64, lang, text string) (string, bool) { - req := detectReadFastPath(text) - if req == nil { - return "", false - } - if a.history == nil { - a.history = newChatHistory(100) - } - - a.history.Add(userID, "user", text) - raw := a.executeReadFastPath(storeUserID, userID, req) - answer := formatReadFastPathResponse(lang, req.Kind, raw) - a.history.Add(userID, "assistant", answer) - if !isEphemeralReadFastPathKind(req.Kind) { - a.maybeUpdateTaskStateIncrementally(context.Background(), userID) - a.maybeCompressHistory(context.Background(), userID) - } - return answer, true -} - func isEphemeralReadFastPathKind(kind string) bool { switch kind { case "get_balance", "get_positions", "get_trade_history": @@ -743,101 +814,79 @@ func formatReadFastPathResponse(lang, kind, raw string) string { } func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int64, lang, text string) (string, error) { - if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { - return answer, err - } - if answer, ok := tryInstantDirectReply(lang, text); ok { - return answer, nil - } - if answer, ok := a.tryReadFastPath(storeUserID, userID, lang, text); ok { - return answer, nil - } - if answer, ok, err := a.tryWorkflowIntent(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { - return answer, err - } - if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok { - return answer, nil + lock := a.flowLock(userID) + lock.Lock() + defer lock.Unlock() + if a.aiClient != nil { + if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), err + } + } else if a.hasAnyActiveContext(userID) { + if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), err + } } if a.aiClient == nil { + if !a.hasAnyActiveContext(userID) { + if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), err + } + } + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, nil); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), nil + } + if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), nil + } return a.noAIFallback(lang, text) } - return a.runPlannedAgent(ctx, storeUserID, userID, lang, text, nil) + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, nil) + return a.maybeAppendResumePrompt(userID, lang, text, answer), err } func (a *Agent) thinkAndActStream(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { - if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { - return answer, err - } - if answer, ok := tryInstantDirectReply(lang, text); ok { - if onEvent != nil { - onEvent(StreamEventDelta, answer) + lock := a.flowLock(userID) + lock.Lock() + defer lock.Unlock() + if a.aiClient != nil { + if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + return answer, err } - return answer, nil - } - if answer, ok := a.tryReadFastPath(storeUserID, userID, lang, text); ok { - if onEvent != nil { - onEvent(StreamEventTool, "read_fast_path") - onEvent(StreamEventDelta, answer) + } else if a.hasAnyActiveContext(userID) { + if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + return answer, err } - return answer, nil - } - if answer, ok, err := a.tryWorkflowIntent(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { - return answer, err - } - if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { - return answer, nil } if a.aiClient == nil { + if !a.hasAnyActiveContext(userID) { + if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + return answer, err + } + } + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok { + answer = a.maybeAppendResumePrompt(userID, lang, text, answer) + return answer, nil + } + if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), nil + } return a.noAIFallback(lang, text) } - return a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + return a.maybeAppendResumePrompt(userID, lang, text, answer), err } -func tryInstantDirectReply(lang, text string) (string, bool) { +func isInstantDirectReplyText(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return "", false + switch lower { + case "hi", "hello", "hey", "你好", "嗨", "在吗", "你好吗", "最近怎么样", "最近还好吗", "谢谢", "多谢", "谢了", "ok", "好的", "收到", "thanks", "thank you", "okay", "got it", "how are you": + return true + default: + return false } - - zhReplies := map[string]string{ - "hi": "在,有什么我帮你看的?", - "hello": "在,有什么我帮你看的?", - "hey": "在,有什么我帮你看的?", - "你好": "在,有什么我帮你看的?", - "嗨": "在,有什么我帮你看的?", - "在吗": "在,有什么我帮你看的?", - "谢谢": "不客气。", - "多谢": "不客气。", - "谢了": "不客气。", - "ok": "好。", - "好的": "好。", - "收到": "好。", - } - enReplies := map[string]string{ - "hi": "I'm here. What should we look at?", - "hello": "I'm here. What should we look at?", - "hey": "I'm here. What should we look at?", - "thanks": "You're welcome.", - "thank you": "You're welcome.", - "ok": "Okay.", - "okay": "Okay.", - "got it": "Got it.", - } - - if lang == "zh" { - if reply, ok := zhReplies[lower]; ok { - return reply, true - } - if reply, ok := enReplies[lower]; ok { - return reply, true - } - return "", false - } - - if reply, ok := enReplies[lower]; ok { - return reply, true - } - return "", false } func (a *Agent) hasActiveSkillSession(userID int64) bool { @@ -845,6 +894,16 @@ func (a *Agent) hasActiveSkillSession(userID int64) bool { return strings.TrimSpace(session.Name) != "" } +func (a *Agent) hasAnyActiveContext(userID int64) bool { + if a.hasActiveSkillSession(userID) { + return true + } + if hasActiveWorkflowSession(a.getWorkflowSession(userID)) { + return true + } + return hasActiveExecutionState(a.getExecutionState(userID)) +} + func hasActiveExecutionState(state ExecutionState) bool { if strings.TrimSpace(state.SessionID) == "" { return false @@ -858,6 +917,14 @@ func hasActiveExecutionState(state ExecutionState) bool { } func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { + if answer, ok := a.tryResumeSuspendedTask(userID, lang, text); ok { + return answer, true, nil + } + if !a.hasActiveSkillSession(userID) && !hasActiveWorkflowSession(a.getWorkflowSession(userID)) && !hasActiveExecutionState(a.getExecutionState(userID)) { + if a.tryRestoreSuspendedTaskFromIdle(ctx, userID, lang, text) { + return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) + } + } if workflow := a.getWorkflowSession(userID); hasActiveWorkflowSession(workflow) { answer, handled, err := a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, workflow, onEvent) if handled || err != nil { @@ -865,18 +932,39 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us } } if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" { - switch a.classifySkillSessionInput(ctx, userID, lang, session, text) { + if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, session, text); ok { + return answer, true, nil + } + decision, extraction := a.resolveSkillSessionTurn(ctx, userID, lang, text, session) + switch decision.Intent { case "cancel": a.clearSkillSession(userID) a.clearWorkflowSession(userID) - if lang == "zh" { - return "已取消当前流程。", true, nil - } - return "Cancelled the current flow.", true, nil - case "interrupt": - a.clearSkillSession(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + case "instant_reply": + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + case "resume_snapshot", "start_new": + answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + return answer, handled, err default: - if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { + if extraction.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, extraction, lang, text) + a.saveSkillSession(userID, session) + } + if answer, ok := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session); ok { + if onEvent != nil && strings.TrimSpace(answer) != "" { + switch session.Name { + case "trader_management": + onEvent(StreamEventTool, "hard_skill:trader_management") + case "model_management": + onEvent(StreamEventTool, "hard_skill:model_management") + case "exchange_management": + onEvent(StreamEventTool, "hard_skill:exchange_management") + case "strategy_management": + onEvent(StreamEventTool, "hard_skill:strategy_management") + } + emitStreamText(onEvent, answer) + } return answer, true, nil } } @@ -884,16 +972,29 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us state := a.getExecutionState(userID) if hasActiveExecutionState(state) { - switch classifyExecutionStateInput(state, text) { + decision, extraction := a.resolveExecutionStateTurn(ctx, userID, lang, state, text) + switch decision.Intent { case "cancel": a.clearExecutionState(userID) - if lang == "zh" { - return "已取消当前流程。", true, nil - } - return "Cancelled the current flow.", true, nil - case "interrupt": - a.clearExecutionState(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + case "instant_reply": + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + case "resume_snapshot", "start_new": + answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + return answer, handled, err default: + if decision.Intent == "continue_active" { + if session, ok := a.bridgeExecutionStateToSkillSession(storeUserID, userID, text, state, extraction); ok { + answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session) + return answer, handled, nil + } + } + if extraction.Intent == "continue" { + a.applyExecutionStateExtraction(&state, extraction) + if err := a.saveExecutionState(state); err != nil { + return "", true, err + } + } answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) return answer, true, err } @@ -902,6 +1003,452 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us return "", false, nil } +func isTraderCreateWaitingState(state ExecutionState) bool { + lowerGoal := strings.ToLower(strings.TrimSpace(state.Goal)) + if strings.Contains(lowerGoal, "创建交易员") || strings.Contains(lowerGoal, "新建交易员") || strings.Contains(lowerGoal, "create trader") { + return true + } + if state.Waiting == nil { + return false + } + lowerIntent := strings.ToLower(strings.TrimSpace(state.Waiting.Intent)) + lowerTarget := strings.ToLower(strings.TrimSpace(state.Waiting.ConfirmationTarget)) + return lowerIntent == "complete_trader_setup" || (lowerIntent == "confirm_action" && lowerTarget == "trader") +} + +func hasSkillBridgeSignal(a *Agent, storeUserID, skillName, action, text string, extraction executionFlowExtractionResult) bool { + if len(extraction.Fields) > 0 { + return true + } + lower := strings.ToLower(strings.TrimSpace(text)) + if isYesReply(text) || isNoReply(text) { + return true + } + switch skillName { + case "trader_management": + if containsAny(lower, []string{"名称", "名字", "name", "交易所", "exchange", "模型", "model", "策略", "strategy"}) { + return true + } + case "model_management": + if containsAny(lower, []string{"provider", "模型名", "模型名称", "api key", "api_key", "apikey", "url", "endpoint", "名称", "名字", "name"}) { + return true + } + case "exchange_management": + if containsAny(lower, []string{"交易所", "exchange", "账户名", "account", "api key", "secret", "passphrase", "testnet", "名称", "名字", "name"}) { + return true + } + case "strategy_management": + if containsAny(lower, []string{"策略", "strategy", "名称", "名字", "name", "prompt", "提示词", "配置", "参数"}) { + return true + } + } + if action == "create" && containsAny(lower, []string{"名称", "名字", "name"}) { + return true + } + if a == nil { + return false + } + return hasStrictOptionMention(text, a.loadEnabledModelOptions(storeUserID)) || + hasStrictOptionMention(text, a.loadExchangeOptions(storeUserID)) || + hasStrictOptionMention(text, a.loadStrategyOptions(storeUserID)) +} + +func inferExecutionStateSkillBridge(state ExecutionState, text string) (string, string) { + lowerGoal := strings.ToLower(strings.TrimSpace(state.Goal)) + waitingIntent := "" + waitingTarget := "" + if state.Waiting != nil { + waitingIntent = strings.ToLower(strings.TrimSpace(state.Waiting.Intent)) + waitingTarget = strings.ToLower(strings.TrimSpace(state.Waiting.ConfirmationTarget)) + } + switch waitingIntent { + case "complete_trader_setup": + return "trader_management", "create" + case "complete_model_config": + return "model_management", "create" + case "complete_exchange_config": + return "exchange_management", "create" + } + switch waitingTarget { + case "trader": + if containsAny(lowerGoal, []string{"创建", "新建", "create", "setup", "配置"}) || hasExplicitCreateIntentForDomain(state.Goal, "trader") { + return "trader_management", "create" + } + return "trader_management", "create" + case "model", "model_config": + return "model_management", "create" + case "exchange", "exchange_config": + return "exchange_management", "create" + case "strategy", "manage_strategy": + return "strategy_management", "create" + } + switch { + case hasExplicitCreateIntentForDomain(state.Goal, "trader"): + return "trader_management", "create" + } + return "", "" +} + +func traderCreateFieldsFromExecutionExtraction(result executionFlowExtractionResult) map[string]string { + if len(result.Fields) == 0 { + return nil + } + fields := make(map[string]string, len(result.Fields)) + for key, value := range result.Fields { + value = strings.TrimSpace(value) + if value == "" { + continue + } + switch strings.TrimSpace(key) { + case "name": + fields["name"] = value + case "model", "model_id", "ai_model_id": + fields["model_id"] = value + case "model_name": + fields["model_name"] = value + case "exchange", "exchange_id": + fields["exchange_id"] = value + case "exchange_name": + fields["exchange_name"] = value + case "strategy", "strategy_id": + fields["strategy_id"] = value + case "strategy_name": + fields["strategy_name"] = value + case "auto_start", "initial_balance", "scan_interval_minutes", "is_cross_margin", "show_in_competition": + fields[key] = value + } + } + if len(fields) == 0 { + return nil + } + return fields +} + +func executionStateCurrentReference(state ExecutionState, skillName string) *EntityReference { + if state.CurrentReferences == nil { + return nil + } + switch skillName { + case "trader_management": + return state.CurrentReferences.Trader + case "model_management": + return state.CurrentReferences.Model + case "exchange_management": + return state.CurrentReferences.Exchange + case "strategy_management": + return state.CurrentReferences.Strategy + default: + return nil + } +} + +func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID int64, text string, state ExecutionState, extraction executionFlowExtractionResult) (skillSession, bool) { + skillName, action := inferExecutionStateSkillBridge(state, text) + if a == nil || skillName == "" || action == "" || !hasSkillBridgeSignal(a, storeUserID, skillName, action, text, extraction) { + return skillSession{}, false + } + + session := a.getSkillSession(userID) + if session.Name != "" && (session.Name != skillName || session.Action != action) { + return skillSession{}, false + } + if session.Name == "" { + session = skillSession{ + Name: skillName, + Action: action, + Phase: "collecting", + } + } + if len(extraction.Fields) > 0 { + fields := extraction.Fields + if skillName == "trader_management" { + fields = traderCreateFieldsFromExecutionExtraction(extraction) + } + if len(fields) > 0 { + a.applyLLMExtractionToSkillSession(storeUserID, &session, llmFlowExtractionResult{ + Tasks: []llmFlowExtractionTask{{ + Skill: skillName, + Action: action, + Fields: fields, + }}, + }, "zh", text) + } + } + + switch skillName { + case "trader_management": + if action != "create" && session.TargetRef == nil { + session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "trader_management")) + } + a.hydrateCreateTraderSlotReferences(storeUserID, &session) + case "model_management": + if session.TargetRef == nil && action != "create" { + session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "model_management")) + } + case "exchange_management": + if session.TargetRef == nil && action != "create" { + session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "exchange_management")) + } + case "strategy_management": + if session.TargetRef == nil && action != "create" { + session.TargetRef = normalizeEntityReference(executionStateCurrentReference(state, "strategy_management")) + } + } + a.saveSkillSession(userID, session) + a.clearExecutionState(userID) + return session, true +} + +func (a *Agent) dispatchBridgedSkillSession(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { + switch session.Name { + case "trader_management": + if session.Action == "create" { + return a.handleCreateTraderSkill(storeUserID, userID, lang, text, session) + } + return a.handleTraderManagementSkill(storeUserID, userID, lang, text, session) + case "model_management": + if session.Action == "create" { + return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true + } + return a.handleModelManagementSkill(storeUserID, userID, lang, text, session) + case "exchange_management": + if session.Action == "create" { + return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true + } + return a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session) + case "strategy_management": + if session.Action == "create" { + return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true + } + return a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session) + default: + return "", false + } +} + +func (a *Agent) resolveSkillSessionTurn(ctx context.Context, userID int64, lang, text string, session skillSession) (unifiedFlowDecision, llmFlowExtractionResult) { + text = strings.TrimSpace(text) + if text == "" { + return unifiedFlowDecision{Intent: "continue_active"}, llmFlowExtractionResult{} + } + if isInstantDirectReplyText(text) { + return unifiedFlowDecision{Intent: "instant_reply"}, llmFlowExtractionResult{Intent: "instant_reply"} + } + if a.aiClient != nil { + result := a.extractSkillSessionFieldsWithLLM(ctx, userID, lang, text, session) + if decision := unifiedFlowDecisionFromIntent(result.Intent, result.TargetSnapshotID); decision.Intent != "" { + return decision, result + } + } + return a.classifySkillSessionDecision(ctx, userID, lang, session, text), llmFlowExtractionResult{} +} + +func (a *Agent) resolveExecutionStateTurn(ctx context.Context, userID int64, lang string, state ExecutionState, text string) (unifiedFlowDecision, executionFlowExtractionResult) { + text = strings.TrimSpace(text) + if text == "" { + return unifiedFlowDecision{Intent: "continue_active"}, executionFlowExtractionResult{} + } + if isInstantDirectReplyText(text) { + return unifiedFlowDecision{Intent: "instant_reply"}, executionFlowExtractionResult{Intent: "instant_reply"} + } + if a.aiClient != nil { + result := a.extractExecutionStateContinuationWithLLM(ctx, userID, lang, state, text) + if decision := unifiedFlowDecisionFromIntent(result.Intent, result.TargetSnapshotID); decision.Intent != "" { + return decision, result + } + } + return a.classifyExecutionStateDecision(ctx, userID, lang, state, text), executionFlowExtractionResult{} +} + +func unifiedFlowDecisionFromIntent(intent, targetSnapshotID string) unifiedFlowDecision { + intent = strings.TrimSpace(strings.ToLower(intent)) + targetSnapshotID = strings.TrimSpace(targetSnapshotID) + switch intent { + case "continue", "continue_active": + return unifiedFlowDecision{Intent: "continue_active"} + case "cancel": + return unifiedFlowDecision{Intent: "cancel"} + case "instant_reply": + return unifiedFlowDecision{Intent: "instant_reply"} + case "switch", "interrupt", "start_new", "resume_snapshot": + if targetSnapshotID != "" { + return unifiedFlowDecision{Intent: "resume_snapshot", TargetSnapshotID: targetSnapshotID} + } + return unifiedFlowDecision{Intent: "start_new"} + default: + return unifiedFlowDecision{} + } +} + +func (a *Agent) replyToActiveFlowInstantReply(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) string { + a.suspendActiveContexts(userID, lang) + if a.aiClient != nil { + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer) + } + } + if lang == "zh" { + return a.maybeAppendResumePrompt(userID, lang, text, "在,有什么我帮你看的?") + } + return a.maybeAppendResumePrompt(userID, lang, text, "I'm here. What would you like me to help you with?") +} + +func (a *Agent) handoffFromActiveFlow(ctx context.Context, storeUserID string, userID int64, lang, text, targetSnapshotID string, onEvent func(event, data string)) (string, bool, error) { + if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, targetSnapshotID) { + return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) + } + if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, err + } + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, nil + } + if a.aiClient == nil { + if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, "") { + if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { + return answer, true, nil + } + } + if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, nil + } + answer, err := a.noAIFallback(lang, text) + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, err + } + answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, err +} + +func (a *Agent) extractExecutionStateContinuationWithLLM(ctx context.Context, userID int64, lang string, state ExecutionState, text string) executionFlowExtractionResult { + if a == nil || a.aiClient == nil || strings.TrimSpace(text) == "" { + return executionFlowExtractionResult{} + } + recentConversationCtx := a.buildRecentConversationContext(userID, text) + flowContext := fmt.Sprintf( + "Active flow type: execution_state\nGoal: %s\nStatus: %s", + state.Goal, + state.Status, + ) + waitingSummary := "" + if state.Waiting != nil { + waitingSummary = fmt.Sprintf("Waiting summary: question=%s pending_fields=%s", strings.TrimSpace(state.Waiting.Question), strings.Join(state.Waiting.PendingFields, ", ")) + } + systemPrompt, userPrompt := buildActiveFlowExtractionPrompt( + lang, + "execution_state", + flowContext, + text, + recentConversationCtx, + state.CurrentReferences, + a.SnapshotManager(userID).List(), + []string{ + fmt.Sprintf("Waiting JSON: %s", mustMarshalJSON(state.Waiting)), + waitingSummary, + }, + ) + systemPrompt += ` +- This is the structured continuation input for an active NOFXi execution flow. +- Prefer "continue" only when the message clearly contributes to the current waiting question or active execution goal. +- Use "switch" for read-only queries, unrelated requests, explanation requests, or clear topic changes. +- For "continue", extract only explicit field values that answer the waiting question or pending fields. +- Do not invent fields. If no field can be safely extracted, you may still return "continue" when the message is a meaningful free-form answer. + +Return JSON with this exact shape: +{"intent":"continue|switch|cancel|instant_reply","target_snapshot_id":"","fields":{},"reason":""}` + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return executionFlowExtractionResult{} + } + envelope, ok := parseRawFlowExtractionEnvelope(raw) + if !ok { + return executionFlowExtractionResult{} + } + out := executionFlowExtractionResult{ + Intent: envelope.Intent, + TargetSnapshotID: envelope.TargetSnapshotID, + Reason: envelope.Reason, + } + if len(envelope.Fields) > 0 { + out.Fields = envelope.Fields + } else if len(envelope.Tasks) > 0 { + out.Fields = envelope.Tasks[0].Fields + } + switch out.Intent { + case "continue", "switch", "cancel", "instant_reply", "interrupt": + return out + default: + return executionFlowExtractionResult{} + } +} + +func parseSuspendedTaskSelectionResult(raw string) (suspendedTaskSelectionResult, bool) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var out suspendedTaskSelectionResult + if err := json.Unmarshal([]byte(raw), &out); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil { + return suspendedTaskSelectionResult{}, false + } + } + out.TargetSnapshotID = strings.TrimSpace(out.TargetSnapshotID) + if out.TargetSnapshotID == "" { + return suspendedTaskSelectionResult{}, false + } + return out, true +} + +func (a *Agent) applyExecutionStateExtraction(state *ExecutionState, result executionFlowExtractionResult) { + if state == nil || result.Intent != "continue" { + return + } + if len(result.Fields) == 0 && strings.TrimSpace(result.Reason) == "" { + return + } + fieldBits := make([]string, 0, len(result.Fields)) + for key, value := range result.Fields { + fieldBits = append(fieldBits, fmt.Sprintf("%s=%s", key, value)) + } + sort.Strings(fieldBits) + summary := "User continued the active execution flow." + if len(fieldBits) > 0 { + summary = "User supplied continuation fields: " + strings.Join(fieldBits, ", ") + } + appendExecutionLog(state, Observation{ + Kind: "waiting_user_input", + Summary: summary, + RawJSON: mustMarshalJSON(result), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + if state.Waiting != nil && len(state.Waiting.PendingFields) > 0 && len(result.Fields) > 0 { + remaining := make([]string, 0, len(state.Waiting.PendingFields)) + for _, field := range state.Waiting.PendingFields { + if _, ok := result.Fields[field]; ok { + continue + } + remaining = append(remaining, field) + } + state.Waiting.PendingFields = cleanStringList(remaining) + } +} + +func (a *Agent) classifySkillSessionDecision(ctx context.Context, userID int64, lang string, session skillSession, text string) unifiedFlowDecision { + return unifiedFlowDecisionFromIntent(a.classifySkillSessionInput(ctx, userID, lang, session, text), "") +} + func (a *Agent) classifySkillSessionInput(ctx context.Context, userID int64, lang string, session skillSession, text string) string { lower := strings.ToLower(strings.TrimSpace(text)) if lower == "" { @@ -916,13 +1463,25 @@ func (a *Agent) classifySkillSessionInput(ctx context.Context, userID int64, lan if shouldContinueSkillSessionByExpectedSlot(session, text) { return "continue" } - if decision := a.classifySkillSessionIntentWithLLM(ctx, userID, lang, session, text); decision != "" { - return decision + if strings.TrimSpace(session.Name) == "trader_management" && strings.TrimSpace(session.Action) == "create" { + if detectReadFastPath(text) == nil { + switch detectMentionedSkillDomain(text) { + case "exchange_management", "model_management", "strategy_management": + return "continue" + } + } } - if isNewSkillRootIntent(session, text) { - return "interrupt" + if a != nil && a.aiClient != nil { + if decision := a.classifySkillSessionIntentWithLLM(ctx, userID, lang, session, text); decision != "" { + return decision + } + return "continue" } - if isSkillFlowDeflection(session, text) { + if strings.TrimSpace(session.Name) != "" && strings.TrimSpace(session.Action) != "" && + !looksLikeNewTopLevelIntent(text) { + return "continue" + } + if shouldInterruptSkillSessionBySnapshot(session, text) || shouldInterruptSkillSessionByExplicitDomainMention(session, text) || isNewSkillRootIntent(session, text) || isSkillFlowDeflection(session, text) { return "interrupt" } if belongsToSkillDomain(session.Name, text) || !looksLikeNewTopLevelIntent(text) { @@ -931,10 +1490,79 @@ func (a *Agent) classifySkillSessionInput(ctx context.Context, userID int64, lan return "interrupt" } -type skillSessionIntentDecision struct { +type activeFlowIntentDecision struct { Decision string `json:"decision"` } +type unifiedFlowDecision struct { + Intent string + TargetSnapshotID string +} + +type executionFlowExtractionResult struct { + Intent string `json:"intent,omitempty"` + TargetSnapshotID string `json:"target_snapshot_id,omitempty"` + Fields map[string]string `json:"fields,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type suspendedTaskSelectionResult struct { + TargetSnapshotID string `json:"target_snapshot_id,omitempty"` +} + +func buildActiveFlowClassifierPrompt(lang, flowLabel, flowContext, text, recentConversationCtx string, currentRefs any, suspendedSnapshots any) (string, string) { + systemPrompt := `You classify one user message while an active NOFXi flow is in progress. +Return JSON only. No markdown. + +Possible decisions: +- "continue": the user is still continuing the current active flow +- "cancel": the user wants to stop the current active flow +- "interrupt": the user wants to leave the current active flow for another task, query, explanation, or topic +- "instant_reply": the user is only greeting, chatting, or thanking + +Be conservative: +- Prefer "continue" only when the message still contributes to the current active flow. +- Use "cancel" for explicit abandonment. +- Use "instant_reply" for greetings, thanks, and simple social chat. +- Use "interrupt" for unrelated requests, explanation requests, read-only queries, or clear topic shifts. +- Consider Current references JSON and Suspended snapshots JSON when resolving vague phrases like "那个", "刚才那个", or "前面那个". + +Return JSON with this exact shape: +{"decision":"continue|cancel|interrupt|instant_reply"}` + return systemPrompt, fmt.Sprintf( + "Language: %s\nActive flow label: %s\n%s\nCurrent references JSON: %s\nSuspended snapshots JSON: %s\nUser message: %s\n\nRecent conversation:\n%s", + lang, + flowLabel, + flowContext, + mustMarshalJSON(currentRefs), + mustMarshalJSON(suspendedSnapshots), + text, + recentConversationCtx, + ) +} + +func parseActiveFlowIntentDecision(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + var decision activeFlowIntentDecision + if err := json.Unmarshal([]byte(raw), &decision); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &decision) != nil { + return "" + } + } + switch strings.TrimSpace(decision.Decision) { + case "continue", "cancel", "interrupt", "instant_reply": + return decision.Decision + default: + return "" + } +} + func shouldUseLLMSkillSessionClassifier(session skillSession, text string) bool { if strings.TrimSpace(text) == "" { return false @@ -948,46 +1576,252 @@ func shouldUseLLMSkillSessionClassifier(session skillSession, text string) bool return true } -func shouldContinueSkillSessionByExpectedSlot(session skillSession, text string) bool { - text = strings.TrimSpace(text) - if text == "" { +func detectRootSkillIntent(text string) string { + return "" +} + +func shouldInterruptSkillSessionBySnapshot(session skillSession, text string) bool { + currentSkill := strings.TrimSpace(session.Name) + if currentSkill == "" { return false } - currentStep, ok := currentSkillDAGStep(session) - if !ok { + rootSkill := detectRootSkillIntent(text) + if rootSkill == "" { return false } - switch currentStep.ID { - case "await_start_confirmation", "await_confirmation": - return isYesReply(text) || isNoReply(text) - case "resolve_config_value": - if fieldValue(session, "config_field") == "selected_timeframes" { - return timeframeTokenRE.MatchString(strings.ToLower(text)) + if rootSkill != currentSkill && looksLikeNewTopLevelIntent(text) { + return true + } + return false +} + +func detectMentionedSkillDomain(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + switch { + case containsAny(lower, []string{"交易员", "trader", "agent"}): + return "trader_management" + case containsAny(lower, []string{"策略", "strategy"}): + return "strategy_management" + case containsAny(lower, []string{"模型", "model"}): + return "model_management" + case containsAny(lower, []string{"交易所", "exchange"}): + return "exchange_management" + default: + return "" + } +} + +func shouldInterruptSkillSessionByExplicitDomainMention(session skillSession, text string) bool { + currentSkill := strings.TrimSpace(session.Name) + if currentSkill == "" { + return false + } + if currentSkill == "trader_management" { + if currentStep, ok := currentSkillDAGStep(session); ok { + switch currentStep.ID { + case "resolve_exchange", "resolve_model", "resolve_strategy", "collect_bindings": + return false + } } - return firstIntegerPattern.MatchString(text) - case "collect_enabled": - _, ok := parseEnabledValue(text) - return ok - case "collect_custom_api_url": - return extractURL(text) != "" - case "resolve_exchange_type": - return exchangeTypeFromText(text) != "" - case "resolve_provider": - return providerFromText(text) != "" - case "resolve_name", "collect_name", "collect_prompt", "collect_account_name", "collect_custom_model_name": - return !looksLikeNewTopLevelIntent(text) } - for _, field := range currentStep.RequiredFields { + mentioned := detectMentionedSkillDomain(text) + if mentioned == "" || mentioned == currentSkill { + return false + } + return looksLikeNewTopLevelIntent(text) +} + +func looksLikeExplanationQuestion(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + return containsAny(lower, []string{ + "怎么", "什么意思", "为什么", "格式", "要求", "示例", "例子", "怎么填", "怎么写", "是啥", "是什么", "有哪些", "有什么", "可选", "选项", + "how", "what", "why", "format", "requirement", "example", "examples", "what is", "how should", "which options", "available", + }) +} + +func explanationFieldForSession(session skillSession, text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + switch strings.TrimSpace(session.Name) { + case "model_management": + if containsAny(lower, []string{"provider", "提供商", "模型类型", "模型有哪些类型"}) { + return "provider" + } + if containsAny(lower, []string{"api key", "apikey", "api_key", "密钥"}) { + return "api_key" + } + if containsAny(lower, []string{"model name", "模型名称", "模型名"}) { + return "custom_model_name" + } + if containsAny(lower, []string{"url", "endpoint", "地址", "接口"}) { + return "custom_api_url" + } + case "exchange_management": + if containsAny(lower, []string{"交易所类型", "交易所有哪些类型", "支持哪些交易所", "哪些交易所", "exchange type", "exchange types", "which exchanges", "supported exchanges"}) { + return "exchange_type" + } + if containsAny(lower, []string{"api key", "apikey", "api_key"}) { + return "api_key" + } + if containsAny(lower, []string{"secret", "secret key", "secret_key", "密钥"}) { + return "secret_key" + } + if containsAny(lower, []string{"passphrase", "密码短语"}) { + return "passphrase" + } + case "trader_management": + if containsAny(lower, []string{"扫描间隔", "scan interval", "scan frequency"}) { + return "scan_interval_minutes" + } + case "strategy_management": + if containsAny(lower, []string{"杠杆", "leverage"}) { + return "leverage" + } + } + if currentStep, ok := currentSkillDAGStep(session); ok { + switch currentStep.ID { + case "resolve_provider": + return "provider" + case "resolve_exchange_type": + return "exchange_type" + case "resolve_name", "collect_name": + return "name" + case "collect_custom_api_url": + return "custom_api_url" + case "collect_custom_model_name": + return "custom_model_name" + case "collect_enabled": + return "enabled" + case "collect_field_value": + return fieldValue(session, "update_field") + } + } + return "" +} + +func (a *Agent) answerSkillSessionExplanation(storeUserID, lang string, session skillSession, text string) (string, bool) { + if !looksLikeExplanationQuestion(text) { + return "", false + } + lower := strings.ToLower(strings.TrimSpace(text)) + if containsAny(lower, []string{"选项", "有哪些", "有什么", "可选", "available options", "which options"}) { + if session.Name == "model_management" { + provider := strings.TrimSpace(fieldValue(session, "provider")) + if provider == "" { + return modelProviderChoicePrompt(lang), true + } + if lang == "zh" { + return fmt.Sprintf("当前 provider 是 %s。这里要填的是这个 provider 实际支持的模型名称,例如 OpenAI 常见是 `gpt-5`、`gpt-5-mini`,DeepSeek 常见是 `deepseek-chat`。你也可以直接告诉我“用默认推荐模型”。", provider), true + } + return fmt.Sprintf("The current provider is %s. This field expects a real runtime model ID for that provider, for example `gpt-5` or `gpt-5-mini` for OpenAI, or `deepseek-chat` for DeepSeek. You can also tell me to use the default recommended model.", provider), true + } + if summary := a.skillVisibleOptionSummary(storeUserID, lang, session.Name, session.Action); summary != "" { + return summary, true + } + } + if detectSkillQuestionField(session.Name, text, skillSession{Name: session.Name}) == "" && + containsAny(lower, []string{"字段", "界面", "可配", "能配", "what fields", "which fields", "available fields"}) { + if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" { + return summary, true + } + } + field := explanationFieldForSession(session, text) + if lang == "zh" { switch field { - case "config_value": - return firstIntegerPattern.MatchString(text) - case "enabled": - _, ok := parseEnabledValue(text) - return ok + case "api_key": + if session.Name == "model_management" { + provider := strings.TrimSpace(fieldValue(session, "provider")) + if provider != "" { + if spec, ok := modelProviderSpecByID(provider); ok && spec.UsesWalletCredential { + return modelProviderCredentialGuidance(lang, provider), true + } + } + return "API Key 一般是你在模型提供商后台生成的密钥。以 OpenAI 为例,通常以 `sk-` 开头,后面跟一长串字母数字。不要带引号外的说明文字,也不要把别的字段一起贴进来。你直接把完整 API Key 发我就行,我会继续当前模型草稿。", true + } + return "API Key 是交易所/服务商发给你的访问密钥,一般是一长串字母数字。你直接把完整值发我就行,不用附带说明文字。", true + case "secret_key": + return "Secret 是和 API Key 配套的密钥,通常也是一长串字符串。直接把完整 Secret 发我就行,不要和 API Key 填反。", true + case "passphrase": + return "Passphrase 是部分交易所额外要求的密码短语。像 OKX 这类账户除了 API Key 和 Secret 之外,还需要这个字段。直接把完整 Passphrase 发我就行。", true + case "custom_model_name": + return "模型名称填提供商实际可调用的模型 ID 就行,比如 OpenAI 常见是 `gpt-5`、`gpt-5-mini` 这类。你直接告诉我要用哪个模型名,我继续当前草稿。", true case "custom_api_url": - return extractURL(text) != "" + return "接口地址填这个 provider 对应的 Base URL。OpenAI 常见是 `https://api.openai.com/v1`。如果你用官方地址,也可以直接告诉我“用默认地址”。", true + case "provider": + if options := a.modelSkillOptionSummary(lang); options != "" { + return options + "。你直接说其中一个就行。", true + } + return "这里要填模型提供商,比如 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax。你直接说其中一个就行。", true + case "exchange_type": + if options := a.exchangeSkillOptionSummary(lang); options != "" { + return options + "。你直接说要接哪个交易所就行。", true + } + return "这里要填交易所类型,比如 OKX、Binance、Bybit、Gate、KuCoin、Hyperliquid。你直接说要接哪个交易所就行。", true + case "name": + return "这里要填你想给这个对象起的名字,方便后面识别和管理。你直接说“叫 XXX”就行。", true + case "enabled": + return "启用状态就是创建后是否立即生效。你可以说“启用/开启”或“禁用/先不开启”。", true + case "scan_interval_minutes": + return "扫描间隔是交易员多久扫描一次市场,单位是分钟。你直接给我一个数字就行,比如 `5`、`15`。", true + case "leverage": + return "杠杆就是策略允许使用的倍数。你可以直接给我数字,但系统会按安全上限做约束。", true + case "source_type": + return "选币来源就是策略最初从哪里拿候选币。手动面板里常见可选项有:`static`(你自己指定静态币池)、`ai500`、`oi_top`、`oi_low`。如果你要自己指定币,就继续告诉我“静态币用 BTC、ETH”;如果你想排除某些币,也可以直接补“排除币不要 SOL、DOGE”。", true + case "max_positions": + return "最大持仓就是同一时间最多允许开几个仓位。你直接给我整数就行,比如 `3`、`5`、`10`。", true + case "min_confidence": + return "最小置信度是策略允许开仓的最低信心门槛,通常填 `0-100` 的整数。数值越高,开单会更谨慎。", true + case "min_risk_reward_ratio": + return "最小盈亏比是每笔交易至少要满足的风险收益比,比如 `1.5`、`2.0`。数值越高,策略会更挑机会。", true + case "primary_timeframe": + return "主周期是策略主要参考的 K 线周期,比如 `1m`、`5m`、`15m`、`1h`。如果你偏高频,一般会更短;偏稳一些就会更长。", true + case "selected_timeframes": + return "多周期时间框架就是除了主周期之外,还一起参考哪些周期。常见填法像 `1m,5m,15m` 或 `5m,15m,1h`,直接按逗号列给我就行。", true } + if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" { + return summary, true + } + return "", false } + switch field { + case "api_key": + return "The API key is the secret token issued by the provider. For OpenAI it usually starts with `sk-` followed by a long string. Just send the full API key and I'll keep the current draft going.", true + case "secret_key": + return "The secret key is the credential paired with your API key. Send the full secret value directly, and make sure it isn't swapped with the API key.", true + case "passphrase": + return "The passphrase is an extra credential required by some exchanges such as OKX. Send the full passphrase value and I'll continue the current draft.", true + case "custom_model_name": + return "The model name should be the real runtime model ID exposed by the provider, such as `gpt-5` or `gpt-5-mini`. Tell me which one you want and I'll continue the draft.", true + case "custom_api_url": + return "The API URL should be the provider's base endpoint. For OpenAI, a common value is `https://api.openai.com/v1`. You can also tell me to use the default endpoint.", true + case "provider": + if options := a.modelSkillOptionSummary(lang); options != "" { + return options + ". You can reply with any one of them.", true + } + return "The provider should be one of the supported vendors like OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, or Minimax.", true + case "exchange_type": + if options := a.exchangeSkillOptionSummary(lang); options != "" { + return options + ". You can reply with the one you want to connect.", true + } + return "The exchange type should be the venue you want to connect, such as OKX, Binance, Bybit, Gate, KuCoin, or Hyperliquid.", true + case "name": + return "This field is just the display name for the object. You can answer with something like 'call it X'.", true + case "enabled": + return "Enabled means whether the config should be active right away. You can answer with enable/disable.", true + case "scan_interval_minutes": + return "The scan interval is the number of minutes between trader scans. Just send a number such as `5` or `15`.", true + case "leverage": + return "Leverage is the multiplier the strategy is allowed to use. You can send a number, and the system will still enforce safety limits.", true + } + if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" { + return summary, true + } + return "", false +} + +func shouldContinueSkillSessionByExpectedSlot(session skillSession, text string) bool { return false } @@ -1000,27 +1834,26 @@ func (a *Agent) classifySkillSessionIntentWithLLM(ctx context.Context, userID in } currentStep, _ := currentSkillDAGStep(session) recentConversationCtx := a.buildRecentConversationContext(userID, text) - systemPrompt := `You classify one user message while a NOFXi structured management flow is active. -Return JSON only. No markdown. - -Possible decisions: -- "continue": the user is still answering the current flow -- "cancel": the user wants to stop the current flow -- "interrupt": the user changed topic, wants diagnosis/query/new task, or should leave the current flow - -Be conservative: -- Prefer "continue" only when the message clearly answers the current slot/question. -- Use "cancel" for explicit abandonment like "算了", "不改了", "换话题", "别弄了". -- Use "interrupt" for diagnosis, query, new requests, or topic shifts.` - userPrompt := fmt.Sprintf( - "Language: %s\nActive skill: %s\nAction: %s\nCurrent DAG step: %s\nExpected required fields: %s\nUser message: %s\n\nRecent conversation:\n%s", - lang, + state := a.getExecutionState(userID) + flowContext := fmt.Sprintf( + "Active skill: %s\nAction: %s\nCurrent DAG step: %s\nExpected required fields: %s\nSkill session fields JSON: %s", session.Name, session.Action, currentStep.ID, strings.Join(currentStep.RequiredFields, ", "), + mustMarshalJSON(session.Fields), + ) + if skillContext := buildCurrentSkillExecutionContext(lang, session); skillContext != "" { + flowContext += "\n" + skillContext + } + systemPrompt, userPrompt := buildActiveFlowClassifierPrompt( + lang, + "skill_session", + flowContext, text, recentConversationCtx, + state.CurrentReferences, + a.SnapshotManager(userID).List(), ) stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) defer cancel() @@ -1034,25 +1867,45 @@ Be conservative: if err != nil { return "" } - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - var decision skillSessionIntentDecision - if err := json.Unmarshal([]byte(raw), &decision); err != nil { - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &decision) != nil { - return "" - } - } - switch strings.TrimSpace(decision.Decision) { - case "continue", "cancel", "interrupt": - return decision.Decision - default: + return parseActiveFlowIntentDecision(raw) +} + +func (a *Agent) classifyExecutionStateIntentWithLLM(ctx context.Context, userID int64, lang string, state ExecutionState, text string) string { + if a == nil || a.aiClient == nil { return "" } + if strings.TrimSpace(text) == "" || isExplicitFlowAbort(text) || isYesReply(text) || isNoReply(text) || shouldResetExecutionStateForNewAttempt(text, state) { + return "" + } + recentConversationCtx := a.buildRecentConversationContext(userID, text) + flowContext := fmt.Sprintf( + "Goal: %s\nStatus: %s\nWaiting JSON: %s", + state.Goal, + state.Status, + mustMarshalJSON(state.Waiting), + ) + systemPrompt, userPrompt := buildActiveFlowClassifierPrompt( + lang, + "execution_state", + flowContext, + text, + recentConversationCtx, + state.CurrentReferences, + a.SnapshotManager(userID).List(), + ) + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return "" + } + return parseActiveFlowIntentDecision(raw) } func isSkillFlowDeflection(session skillSession, text string) bool { @@ -1068,13 +1921,13 @@ func isSkillFlowDeflection(session skillSession, text string) bool { } switch strings.TrimSpace(session.Name) { case "exchange_management": - return detectModelDiagnosisSkill(text) || detectTraderDiagnosisSkill(text) || detectStrategyDiagnosisSkill(text) + return hasExplicitDiagnosisIntentForDomain(text, "model") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "strategy") case "model_management": - return detectExchangeDiagnosisSkill(text) || detectTraderDiagnosisSkill(text) || detectStrategyDiagnosisSkill(text) + return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "strategy") case "strategy_management": - return detectExchangeDiagnosisSkill(text) || detectTraderDiagnosisSkill(text) || detectModelDiagnosisSkill(text) + return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "model") case "trader_management": - return detectExchangeDiagnosisSkill(text) || detectModelDiagnosisSkill(text) || detectStrategyDiagnosisSkill(text) + return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "model") || hasExplicitDiagnosisIntentForDomain(text, "strategy") default: return false } @@ -1086,31 +1939,57 @@ func isNewSkillRootIntent(session skillSession, text string) bool { if currentSkill == "" { return false } + if currentSkill != "trader_management" && hasExplicitManagementDomainCue(text, "trader") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) { + return true + } + if currentSkill != "strategy_management" && hasExplicitManagementDomainCue(text, "strategy") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) { + return true + } + if currentSkill != "model_management" && hasExplicitManagementDomainCue(text, "model") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) { + return true + } + if currentSkill != "exchange_management" && hasExplicitManagementDomainCue(text, "exchange") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) { + return true + } switch currentSkill { case "trader_management": - if detectCreateTraderSkill(text) && currentAction != "create" { - return true - } - if action := normalizeAtomicSkillAction("trader_management", detectManagementAction(text, "trader")); action == "create" && currentAction != "create" { - return true - } + return hasExplicitCreateIntentForDomain(text, "trader") && currentAction != "create" case "strategy_management": - if action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(text, "strategy")); action == "create" && currentAction != "create" { - return true - } + return hasExplicitManagementDomainCue(text, "strategy") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" case "model_management": - if action := normalizeAtomicSkillAction("model_management", detectManagementAction(text, "model")); action == "create" && currentAction != "create" { - return true - } + return hasExplicitManagementDomainCue(text, "model") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" case "exchange_management": - if action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(text, "exchange")); action == "create" && currentAction != "create" { - return true - } + return hasExplicitManagementDomainCue(text, "exchange") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" } return false } -func classifyExecutionStateInput(state ExecutionState, text string) string { +func shouldSuspendInterruptedTask(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if isConfigOrTraderIntent(text) || detectRootSkillIntent(text) != "" { + return false + } + if hasExplicitManagementDomainCue(text, "trader") || hasExplicitManagementDomainCue(text, "model") || + hasExplicitManagementDomainCue(text, "exchange") || hasExplicitManagementDomainCue(text, "strategy") { + return false + } + if req := detectReadFastPath(text); req != nil { + return isEphemeralReadFastPathKind(req.Kind) + } + return containsAny(lower, []string{ + "btc", "eth", "sol", "价格", "行情", "balance", "position", "positions", "portfolio", + "market", "price", "仓位", "持仓", "余额", "账户", "trade history", "历史成交", + }) +} + +func (a *Agent) classifyExecutionStateDecision(ctx context.Context, userID int64, lang string, state ExecutionState, text string) unifiedFlowDecision { + return unifiedFlowDecisionFromIntent(a.classifyExecutionStateInput(ctx, userID, lang, state, text), "") +} + +func (a *Agent) classifyExecutionStateInput(ctx context.Context, userID int64, lang string, state ExecutionState, text string) string { lower := strings.ToLower(strings.TrimSpace(text)) if lower == "" { return "continue" @@ -1121,6 +2000,12 @@ func classifyExecutionStateInput(state ExecutionState, text string) string { if isYesReply(text) || isNoReply(text) || shouldResetExecutionStateForNewAttempt(text, state) { return "continue" } + if a != nil && a.aiClient != nil { + if decision := a.classifyExecutionStateIntentWithLLM(ctx, userID, lang, state, text); decision != "" { + return decision + } + return "continue" + } if state.Waiting != nil && !looksLikeNewTopLevelIntent(text) { return "continue" } @@ -1130,6 +2015,435 @@ func classifyExecutionStateInput(state ExecutionState, text string) string { return "continue" } +func isResumeFlowReply(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + switch lower { + case "继续", "继续吧", "继续刚才的", "恢复", "恢复刚才的", "resume", "continue", "继续创建", "继续配置": + return true + default: + return false + } +} + +func isCancelParentFlowReply(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + switch lower { + case "一并取消", "也取消", "都取消", "全部取消", "取消父任务", "cancel all", "cancel parent", "drop all": + return true + default: + return false + } +} + +func suspendedTaskResumePrompt(lang string, task SuspendedTask) string { + hint := strings.TrimSpace(task.ResumeHint) + if hint == "" { + if lang == "zh" { + hint = "刚才未完成的任务还在,要继续吗?" + } else { + hint = "Your previous unfinished task is still here. Do you want to continue?" + } + } + return hint +} + +func (a *Agent) maybeOfferParentTaskAfterCancel(userID int64, lang string) string { + task, ok := a.SnapshotManager(userID).Peek() + if !ok { + if lang == "zh" { + return "已取消当前流程。" + } + return "Cancelled the current flow." + } + if lang == "zh" { + return "已取消当前流程。\n" + suspendedTaskResumePrompt(lang, task) + "\n如果父任务也不要了,回复“一并取消”。" + } + return "Cancelled the current flow.\n" + suspendedTaskResumePrompt(lang, task) + "\nReply 'cancel all' if you want to cancel the parent task too." +} + +func suspendedTaskDomain(task SuspendedTask) string { + task = normalizeSuspendedTask(task) + if task.SkillSession != nil { + return strings.TrimSpace(task.SkillSession.Name) + } + if task.WorkflowSession != nil { + for _, item := range task.WorkflowSession.Tasks { + if strings.TrimSpace(item.Skill) != "" { + return strings.TrimSpace(item.Skill) + } + } + } + return "" +} + +func (a *Agent) buildSuspendedTask(userID int64, lang string) SuspendedTask { + task := SuspendedTask{} + if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" { + sessionCopy := normalizeSkillSession(session) + task.Kind = "skill_session" + task.SkillSession = &sessionCopy + task.ResumeHint = buildSkillResumeHint(lang, sessionCopy) + if sessionCopy.Name == "trader_management" && sessionCopy.Action == "create" { + task.ResumeOnSuccess = true + task.ResumeTriggers = []string{"exchange_management", "model_management", "strategy_management"} + } + } + if workflow := a.getWorkflowSession(userID); hasActiveWorkflowSession(workflow) { + workflowCopy := normalizeWorkflowSession(workflow) + task.Kind = "workflow_session" + task.WorkflowSession = &workflowCopy + if task.ResumeHint == "" { + task.ResumeHint = buildWorkflowResumeHint(lang, workflowCopy) + } + } + if state := a.getExecutionState(userID); hasActiveExecutionState(state) { + stateCopy := normalizeExecutionState(state) + if task.Kind == "" { + task.Kind = "execution_state" + } + task.ExecutionState = &stateCopy + if task.ResumeHint == "" { + task.ResumeHint = buildExecutionResumeHint(lang, stateCopy) + } + } + if a.history != nil { + if msgs := a.history.Get(userID); len(msgs) > 0 { + if len(msgs) > chatHistoryMaxTurns { + msgs = msgs[len(msgs)-chatHistoryMaxTurns:] + } + task.LocalHistory = msgs + } + } + return normalizeSuspendedTask(task) +} + +func buildSkillResumeHint(lang string, session skillSession) string { + target := "" + if session.TargetRef != nil { + target = defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID) + } + if lang == "zh" { + switch session.Name { + case "strategy_management": + if target != "" { + return fmt.Sprintf("刚才关于策略“%s”的流程还没完成,要继续吗?", target) + } + return "刚才的策略配置流程还没完成,要继续吗?" + case "model_management": + if target != "" { + return fmt.Sprintf("刚才关于模型“%s”的流程还没完成,要继续吗?", target) + } + return "刚才的模型配置流程还没完成,要继续吗?" + case "exchange_management": + if target != "" { + return fmt.Sprintf("刚才关于交易所“%s”的流程还没完成,要继续吗?", target) + } + return "刚才的交易所配置流程还没完成,要继续吗?" + case "trader_management": + if target != "" { + return fmt.Sprintf("刚才关于交易员“%s”的流程还没完成,要继续吗?", target) + } + return "刚才的交易员配置流程还没完成,要继续吗?" + } + } + if target != "" { + return fmt.Sprintf("The flow for %s is still unfinished. Do you want to continue?", target) + } + return "The previous configuration flow is still unfinished. Do you want to continue?" +} + +func buildWorkflowResumeHint(lang string, session WorkflowSession) string { + req := strings.TrimSpace(session.OriginalRequest) + if req == "" { + if lang == "zh" { + return "刚才的多步任务还没完成,要继续吗?" + } + return "The previous workflow is still unfinished. Do you want to continue?" + } + if lang == "zh" { + return fmt.Sprintf("刚才关于“%s”的多步任务还没完成,要继续吗?", req) + } + return fmt.Sprintf("The workflow for %q is still unfinished. Do you want to continue?", req) +} + +func buildExecutionResumeHint(lang string, state ExecutionState) string { + if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" { + if lang == "zh" { + return fmt.Sprintf("刚才我们停在这个问题:%s 回复“继续”我就接着来。", state.Waiting.Question) + } + return fmt.Sprintf("We paused at this question: %s Reply 'continue' and I'll resume.", state.Waiting.Question) + } + goal := strings.TrimSpace(state.Goal) + if goal == "" { + if lang == "zh" { + return "刚才未完成的任务还在,要继续吗?" + } + return "The previous unfinished task is still here. Do you want to continue?" + } + if lang == "zh" { + return fmt.Sprintf("刚才关于“%s”的任务还没完成,要继续吗?", goal) + } + return fmt.Sprintf("The task for %q is still unfinished. Do you want to continue?", goal) +} + +func (a *Agent) suspendActiveContexts(userID int64, lang string) bool { + task := a.buildSuspendedTask(userID, lang) + if task.Kind == "" { + return false + } + a.SnapshotManager(userID).Save(task) + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + return true +} + +func (a *Agent) restoreSuspendedTask(userID int64, task SuspendedTask) bool { + task = normalizeSuspendedTask(task) + if task.Kind == "" { + return false + } + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + if a.history != nil && len(task.LocalHistory) > 0 { + a.history.Replace(userID, task.LocalHistory) + } + if task.ExecutionState != nil { + _ = a.saveExecutionState(*task.ExecutionState) + } + if task.WorkflowSession != nil { + a.saveWorkflowSession(userID, *task.WorkflowSession) + } + if task.SkillSession != nil { + a.saveSkillSession(userID, *task.SkillSession) + } + return true +} + +func (a *Agent) restoreSuspendedTaskByID(userID int64, snapshotID string) bool { + snapshotID = strings.TrimSpace(snapshotID) + if snapshotID == "" { + return false + } + manager := a.SnapshotManager(userID) + stack := manager.Stack() + if len(stack) == 0 { + return false + } + match := -1 + for i := len(stack) - 1; i >= 0; i-- { + if strings.TrimSpace(stack[i].SnapshotID) == snapshotID { + match = i + break + } + } + if match < 0 { + return false + } + task, ok := manager.RemoveAt(match) + if !ok { + return false + } + return a.restoreSuspendedTask(userID, task) +} + +func (a *Agent) tryRestoreSuspendedTaskAfterSwitch(userID int64, text, targetSnapshotID string) bool { + if a.restoreSuspendedTaskByID(userID, targetSnapshotID) { + return true + } + return a.restoreMatchingSuspendedTask(userID, text) +} + +func (a *Agent) suspendAndTryRestoreSuspendedTask(userID int64, lang, text, targetSnapshotID string) bool { + a.suspendActiveContexts(userID, lang) + return a.tryRestoreSuspendedTaskAfterSwitch(userID, text, targetSnapshotID) +} + +func (a *Agent) tryResumeSuspendedTask(userID int64, lang, text string) (string, bool) { + if isCancelParentFlowReply(text) && !a.hasActiveSkillSession(userID) && !hasActiveWorkflowSession(a.getWorkflowSession(userID)) && !hasActiveExecutionState(a.getExecutionState(userID)) { + a.SnapshotManager(userID).Clear() + if lang == "zh" { + return "已把之前挂起的父任务也一并取消。", true + } + return "Cancelled the previously suspended parent tasks as well.", true + } + if !isResumeFlowReply(text) { + return "", false + } + if a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) { + return "", false + } + task, ok := a.SnapshotManager(userID).Load() + if !ok { + return "", false + } + if !a.restoreSuspendedTask(userID, task) { + return "", false + } + return suspendedTaskResumePrompt(lang, task), true +} + +func (a *Agent) tryRestoreSuspendedTaskWithLLM(ctx context.Context, userID int64, lang, text string) bool { + if a == nil || a.aiClient == nil || strings.TrimSpace(text) == "" { + return false + } + snapshots := a.SnapshotManager(userID).List() + if len(snapshots) == 0 { + return false + } + snapshotsJSON, _ := json.Marshal(snapshots) + recentConversationCtx := a.buildRecentConversationContext(userID, text) + systemPrompt := `You select whether a user message refers to one suspended NOFXi snapshot that should be restored now. +Return JSON only. No markdown. + +Rules: +- Choose target_snapshot_id only when the user clearly refers to exactly one suspended snapshot. +- Prefer empty target_snapshot_id when uncertain. +- Use the snapshot resume hint, kind, and recent conversation to resolve references like "刚才那个", "the model one", or "继续那个策略". +- Never invent snapshot ids. + +Return JSON with this exact shape: +{"target_snapshot_id":""}` + userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\nSuspended snapshots JSON: %s\n\nRecent conversation:\n%s", lang, text, string(snapshotsJSON), recentConversationCtx) + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return false + } + selection, ok := parseSuspendedTaskSelectionResult(raw) + if !ok { + return false + } + return a.restoreSuspendedTaskByID(userID, selection.TargetSnapshotID) +} + +func (a *Agent) tryRestoreSuspendedTaskFromIdle(ctx context.Context, userID int64, lang, text string) bool { + if a.tryRestoreAwaitingConfirmationSnapshot(userID, text) { + return true + } + if a.tryRestoreSuspendedTaskWithLLM(ctx, userID, lang, text) { + return true + } + return a.restoreMatchingSuspendedTask(userID, text) +} + +func (a *Agent) tryRestoreAwaitingConfirmationSnapshot(userID int64, text string) bool { + if !isYesReply(text) && !isNoReply(text) && !createConfirmationReply(text) { + return false + } + stack := a.SnapshotManager(userID).Stack() + if len(stack) != 1 { + return false + } + task := stack[0] + if task.Kind != "skill_session" || task.SkillSession == nil { + return false + } + phase := strings.TrimSpace(task.SkillSession.Phase) + switch phase { + case "await_confirmation", "await_create_confirmation", "await_start_confirmation": + return a.restoreSuspendedTask(userID, task) + default: + return false + } +} + +func (a *Agent) restoreMatchingSuspendedTask(userID int64, text string) bool { + wanted := detectRootSkillIntent(text) + if wanted == "" { + wanted = detectMentionedSkillDomain(text) + } + if wanted == "" { + return false + } + manager := a.SnapshotManager(userID) + fullStack := manager.Stack() + if len(fullStack) == 0 { + return false + } + match := -1 + for i := len(fullStack) - 1; i >= 0; i-- { + if suspendedTaskDomain(fullStack[i]) == wanted { + match = i + break + } + } + if match < 0 { + return false + } + task, ok := manager.RemoveAt(match) + if !ok { + return false + } + return a.restoreSuspendedTask(userID, task) +} + +func (a *Agent) maybeAppendResumePrompt(userID int64, lang, text, answer string) string { + a.trackPendingProposalSession(userID, lang, text, answer) + if strings.TrimSpace(answer) == "" || !shouldSuspendInterruptedTask(text) { + return answer + } + if a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) { + return answer + } + task, ok := a.SnapshotManager(userID).Peek() + if !ok { + return answer + } + prompt := suspendedTaskResumePrompt(lang, task) + if prompt == "" || strings.Contains(answer, prompt) { + return answer + } + return strings.TrimSpace(answer) + "\n\n" + prompt +} + +func (a *Agent) trackPendingProposalSession(userID int64, lang, sourceUserText, answer string) { + answer = strings.TrimSpace(answer) + if answer == "" { + return + } + if looksLikePendingProposalReply(answer) { + if a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) { + a.suspendActiveContexts(userID, lang) + } + a.clearActiveSkillSession(userID) + a.savePendingProposalSession(PendingProposalSession{ + UserID: userID, + SourceUserText: strings.TrimSpace(sourceUserText), + ProposalText: answer, + }) + return + } + a.clearPendingProposalSession(userID) +} + +func looksLikePendingProposalReply(answer string) bool { + lower := strings.ToLower(strings.TrimSpace(answer)) + if lower == "" { + return false + } + return containsAny(lower, []string{ + "需要我按这个方案操作吗", + "按这个方案操作吗", + "你想选哪个", + "请选择", + "两个选择", + "直接使用已有的", + "which option do you want", + "do you want me to follow this plan", + "should i proceed with this plan", + }) +} + func isExplicitFlowAbort(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) if lower == "" { @@ -1147,13 +2461,15 @@ func isExplicitFlowAbort(text string) bool { func belongsToSkillDomain(skillName, text string) bool { switch strings.TrimSpace(skillName) { case "trader_management": - return detectCreateTraderSkill(text) || detectTraderManagementIntent(text) || detectTraderDiagnosisSkill(text) + return hasExplicitCreateIntentForDomain(text, "trader") || detectTraderManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "trader") case "strategy_management": - return detectStrategyManagementIntent(text) || detectStrategyDiagnosisSkill(text) + return detectStrategyManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "strategy") case "model_management": - return detectModelManagementIntent(text) || detectModelDiagnosisSkill(text) + return detectModelManagementIntent(text) || + hasExplicitDiagnosisIntentForDomain(text, "model") case "exchange_management": - return detectExchangeManagementIntent(text) || detectExchangeDiagnosisSkill(text) + return detectExchangeManagementIntent(text) || + hasExplicitDiagnosisIntentForDomain(text, "exchange") default: return false } @@ -1167,15 +2483,15 @@ func looksLikeNewTopLevelIntent(text string) bool { if strings.HasPrefix(lower, "/") { return true } - if detectCreateTraderSkill(text) || + if hasExplicitCreateIntentForDomain(text, "trader") || detectTraderManagementIntent(text) || detectExchangeManagementIntent(text) || detectModelManagementIntent(text) || detectStrategyManagementIntent(text) || - detectTraderDiagnosisSkill(text) || - detectExchangeDiagnosisSkill(text) || - detectModelDiagnosisSkill(text) || - detectStrategyDiagnosisSkill(text) { + hasExplicitDiagnosisIntentForDomain(text, "trader") || + hasExplicitDiagnosisIntentForDomain(text, "exchange") || + hasExplicitDiagnosisIntentForDomain(text, "model") || + hasExplicitDiagnosisIntentForDomain(text, "strategy") { return true } if detectReadFastPath(text) != nil { @@ -1197,10 +2513,8 @@ func (a *Agent) tryDirectAnswer(ctx context.Context, userID int64, lang, text st return "", false } - recentConversationCtx := a.buildRecentConversationContext(userID, text) - taskStateCtx := buildTaskStateContext(a.getTaskState(userID)) - executionState := normalizeExecutionState(a.getExecutionState(userID)) - executionJSON, _ := json.Marshal(executionState) + currentTurnCtx := a.buildCurrentTurnContext(userID, lang, text) + activeTaskCtx := a.buildActiveTaskStateContext(userID, lang) systemPrompt := `You are the first-pass router for NOFXi. Decide whether the assistant can answer the user's message directly without using skills, tools, or planning. Return JSON only. Do not return markdown. @@ -1217,17 +2531,24 @@ Use "defer" when the message likely needs: - tool reads - multi-step planning - continuation of an active execution flow that needs stateful follow-up +- interpretation of current product state, observations, counts, duplicates, balances, configuration-page findings, or anything that sounds like "I see / I noticed / there are still ..." Rules: -- Consider Recent conversation, Task state, and Execution state JSON before deciding. +- If you choose direct_answer, write for a trading beginner, not a developer. +- Keep the answer simple, clear, and easy to act on. +- Lead with the conclusion first, then one or two concrete next steps when helpful. +- Avoid internal jargon, architecture talk, tool names, or implementation detail unless the user explicitly asks. +- Use Current turn context as the primary memory for this turn. +- Use Active task state only as a compact summary of any unfinished operational flow. - Default to direct_answer for greetings, thanks, identity questions, and other lightweight conversational turns unless there is a clearly unfinished operational flow that the user is continuing. - If the user is clearly continuing an unfinished operational flow, choose defer. +- If the user mentions concrete operational entities or observations such as traders, strategies, models, exchanges, balances, counts, duplicate items, config pages, or numeric account facts, choose defer. - If you choose direct_answer, provide the final user-facing answer in the same language as the user. - Prefer defer when uncertain. Return JSON with this exact shape: {"action":"direct_answer|defer","answer":""}` - userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON)) + userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nCurrent turn context:\n%s\n\nActive task state:\n%s", lang, text, defaultIfEmpty(currentTurnCtx, "(empty)"), defaultIfEmpty(activeTaskCtx, "(empty)")) stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) defer cancel() @@ -1257,14 +2578,14 @@ Return JSON with this exact shape: } if a.history == nil { - a.history = newChatHistory(100) + a.history = newChatHistory(chatHistoryMaxTurns) } a.history.Add(userID, "user", text) a.history.Add(userID, "assistant", answer) a.maybeUpdateTaskStateIncrementally(ctx, userID) a.maybeCompressHistory(ctx, userID) if onEvent != nil { - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true } @@ -1296,6 +2617,123 @@ func normalizeDirectReplyDecision(decision directReplyDecision) directReplyDecis return decision } +func looksLikeInternalAgentJSON(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || !strings.HasPrefix(raw, "{") || !strings.HasSuffix(raw, "}") { + return false + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return false + } + if _, ok := payload["intent"]; ok { + if _, hasTasks := payload["tasks"]; hasTasks { + return true + } + if _, hasFields := payload["fields"]; hasFields { + return true + } + if _, hasReason := payload["reason"]; hasReason { + return true + } + } + return false +} + +func firstFlowExtractionFields(result llmFlowExtractionResult) map[string]string { + if len(result.Fields) > 0 { + return result.Fields + } + if len(result.Tasks) > 0 && len(result.Tasks[0].Fields) > 0 { + return result.Tasks[0].Fields + } + return nil +} + +func (a *Agent) tryRecoverFromInternalAgentJSON(ctx context.Context, storeUserID string, userID int64, lang, text, raw string, onEvent func(event, data string)) (string, bool, error) { + result := parseLLMFlowExtractionResult(raw) + if result.Intent == "" { + return "", false, nil + } + switch result.Intent { + case "instant_reply": + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + case "cancel": + if a.hasActiveSkillSession(userID) { + a.clearSkillSession(userID) + } + if hasActiveExecutionState(a.getExecutionState(userID)) { + a.clearExecutionState(userID) + } + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + case "continue": + if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, result, lang, text) + a.saveSkillSession(userID, session) + if answer, ok := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session); ok { + return answer, true, nil + } + } + if len(result.Tasks) > 0 { + task := result.Tasks[0] + if strings.TrimSpace(task.Skill) != "" { + recovered := skillSession{ + Name: strings.TrimSpace(task.Skill), + Action: strings.TrimSpace(task.Action), + Phase: "collecting", + Fields: map[string]string{}, + } + if suspended, ok := a.SnapshotManager(userID).Peek(); ok && suspended.SkillSession != nil { + suspendedSkill := strings.TrimSpace(suspended.SkillSession.Name) + suspendedAction := strings.TrimSpace(suspended.SkillSession.Action) + if suspendedSkill == recovered.Name && (recovered.Action == "" || suspendedAction == recovered.Action) { + recovered = *suspended.SkillSession + } + } + for key, value := range task.Fields { + setField(&recovered, key, value) + } + recovered = normalizeSkillSession(recovered) + if recovered.Name == "trader_management" && recovered.Action == "create" { + a.hydrateCreateTraderSlotReferences(storeUserID, &recovered) + } + if recovered.Name == "trader_management" && recovered.Action == "create" && len(missingFieldKeysForSkillSession(recovered)) == 0 { + if fieldValue(recovered, "auto_start") == "true" { + recovered.Phase = "await_start_confirmation" + a.saveSkillSession(userID, recovered) + if lang == "zh" { + return fmt.Sprintf("准备创建交易员并立即启动。\n交易所:%s\n模型:%s\n策略:%s\n\n回复确认继续,回复先不用则只创建不启动。", + traderCreateExchangeNameOrID(recovered), traderCreateModelNameOrID(recovered), traderCreateStrategyNameOrID(recovered)), true, nil + } + return fmt.Sprintf("Ready to create trader and start it immediately.\nExchange: %s\nModel: %s\nStrategy: %s\n\nReply confirm to continue, or no to create without starting.", + traderCreateExchangeNameOrID(recovered), traderCreateModelNameOrID(recovered), traderCreateStrategyNameOrID(recovered)), true, nil + } + recovered.Phase = "await_create_confirmation" + a.saveSkillSession(userID, recovered) + return formatTraderCreateDraftSummary(lang, recovered), true, nil + } + a.saveSkillSession(userID, recovered) + if answer, ok := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, recovered); ok { + return answer, true, nil + } + } + } + if state := a.getExecutionState(userID); hasActiveExecutionState(state) { + extraction := executionFlowExtractionResult{ + Intent: "continue", + TargetSnapshotID: result.TargetSnapshotID, + Fields: firstFlowExtractionFields(result), + Reason: result.Reason, + } + if session, ok := a.bridgeExecutionStateToSkillSession(storeUserID, userID, text, state, extraction); ok { + answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session) + return answer, handled, nil + } + } + } + return "", false, nil +} + func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { a.history.Add(userID, "user", text) if onEvent != nil { @@ -1310,12 +2748,12 @@ func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID msg := plannerTimeoutMessage(lang) if onEvent != nil { onEvent(StreamEventError, msg) - onEvent(StreamEventDelta, msg) + emitStreamText(onEvent, msg) } return msg, nil } a.logger.Warn("planner failed, falling back to legacy loop", "error", err, "user_id", userID) - return a.thinkAndActLegacy(ctx, userID, lang, text, onEvent) + return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent) } a.logPlannerTiming(state.SessionID, userID, "prepare_execution_state", requestStartedAt, nil) @@ -1327,12 +2765,15 @@ func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID msg := plannerTimeoutMessage(lang) if onEvent != nil { onEvent(StreamEventError, msg) - onEvent(StreamEventDelta, msg) + emitStreamText(onEvent, msg) } return msg, nil } + if answer, ok := a.tryExecutionSummaryFallbackOnAIError(lang, &state, err, onEvent); ok { + return answer, nil + } a.logger.Warn("plan execution failed, falling back to legacy loop", "error", err, "user_id", userID) - return a.thinkAndActLegacy(ctx, userID, lang, text, onEvent) + return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent) } a.history.Add(userID, "assistant", answer) @@ -1376,6 +2817,10 @@ 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 + } a.refreshCurrentReferencesForUserText(storeUserID, text, &state) state = a.refreshStateForDynamicRequests(storeUserID, text, state) state.Status = executionStatusRunning @@ -1393,11 +2838,10 @@ type nextStepDecision struct { func (a *Agent) decideNextStep(ctx context.Context, userID int64, lang string, state ExecutionState) (nextStepDecision, error) { toolDefs, _ := json.Marshal(agentTools()) - stateJSON, _ := json.Marshal(normalizeExecutionState(state)) obsJSON, _ := json.Marshal(buildObservationContext(state)) recentlyFetchedJSON, _ := json.Marshal(buildRecentlyFetchedData(state, time.Now().UTC())) - taskStateCtx := buildTaskStateContext(a.getTaskState(userID)) - recentConversationCtx := a.buildRecentConversationContext(userID, state.Goal) + currentTurnCtx := a.buildCurrentTurnContext(userID, lang, state.Goal) + activeTaskCtx := a.buildActiveTaskStateContext(userID, lang) systemPrompt := `You are the step selector for NOFXi. Return JSON only. Do not return markdown. @@ -1412,9 +2856,10 @@ Allowed step types: - respond Rules: -- Use all available memory layers: Execution state JSON, Observations JSON, Recent conversation, and Task state. +- Use Current turn context and Active task state as the main conversational memory. +- Use Observations JSON as the source of truth for what tools already revealed in this execution. - Use Recently fetched data JSON as the deduplication source of truth for fresh tool results. -- Prefer the freshest evidence in this order: execution state, observations, recent conversation, then task state. +- Prefer the freshest evidence in this order: observations, current turn context, active task state. - If fresh external or system data is needed, choose a tool step. - If the user is blocked on a missing parameter, choose ask_user. - If there is enough information to answer now, choose respond. @@ -1436,7 +2881,7 @@ Rules: Return JSON with this exact shape: {"goal":"","steps":[{"id":"step_1","type":"tool|reason|ask_user|respond","title":"","tool_name":"","tool_args":{},"instruction":"","requires_confirmation":false}]}` - userPrompt := fmt.Sprintf("Language: %s\nGoal: %s\n\nRecent conversation:\n%s\n\nAvailable tools JSON:\n%s\n\nPersistent preferences:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s\n\nObservations JSON:\n%s\n\nRecently fetched data JSON:\n%s", lang, state.Goal, recentConversationCtx, string(toolDefs), a.buildPersistentPreferencesContext(userID), taskStateCtx, string(stateJSON), string(obsJSON), string(recentlyFetchedJSON)) + userPrompt := fmt.Sprintf("Language: %s\nGoal: %s\n\nCurrent turn context:\n%s\n\nActive task state:\n%s\n\nAvailable tools JSON:\n%s\n\nPersistent preferences:\n%s\n\nObservations JSON:\n%s\n\nRecently fetched data JSON:\n%s", lang, state.Goal, defaultIfEmpty(currentTurnCtx, "(empty)"), defaultIfEmpty(activeTaskCtx, "(empty)"), string(toolDefs), a.buildPersistentPreferencesContext(userID), string(obsJSON), string(recentlyFetchedJSON)) stageCtx, cancel := withPlannerStageTimeout(ctx, plannerCreateTimeout) defer cancel() @@ -1580,16 +3025,17 @@ func (a *Agent) buildRecentConversationContext(userID int64, currentUserText str func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) { toolDefs, _ := json.Marshal(agentTools()) - stateJSON, _ := json.Marshal(normalizeExecutionState(state)) - taskStateCtx := buildTaskStateContext(a.getTaskState(userID)) - recentConversationCtx := a.buildRecentConversationContext(userID, userText) + currentTurnCtx := a.buildCurrentTurnContext(userID, lang, userText) + activeTaskCtx := a.buildActiveTaskStateContext(userID, lang) + currentReferenceSummary := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) + skillContext := buildManagementSkillRoutingContext(lang) if isConfigOrTraderIntent(userText) { // Configuration and trader setup requests are especially sensitive to stale - // summaries like "this capability does not exist". Prefer fresh tool checks. - taskStateCtx = "" + // durable summaries. Prefer the current turn context plus fresh tool checks. + activeTaskCtx = "" } - systemPrompt := `You are the planning module for NOFXi. + systemPrompt := prependNOFXiAdvisorPreamble(`You are the planning module for NOFXi. Return JSON only. Do not return markdown. Create a minimal safe execution plan using these step types only: @@ -1599,21 +3045,24 @@ Create a minimal safe execution plan using these step types only: - respond Rules: -- Use all available memory layers when planning: Execution state JSON, Recent conversation, and Task state. +- Use a compact memory layout when planning: Current reference summary, Current turn context, and Active task state. - Memory priority order: - 1. Execution state JSON = current operational truth for the active task. - 2. Recent conversation = the best source for what was said in the last few turns. - 3. Task state = compressed durable background only. -- If these memory layers conflict, prefer execution state first, then recent conversation. Do not let task state override fresher evidence. -- Do not ask the user to repeat a fact that is already explicit in execution state or recent conversation unless the inputs are contradictory. + 1. Current reference summary = the currently locked entity/object memory for follow-up turns. + 2. Current turn context = the best source for what was just said, especially the last assistant reply and latest turns. + 3. Active task state = compact unfinished-task memory only. +- If these memory layers conflict, prefer current reference summary first for the target entity, then current turn context, then active task state. +- Do not ask the user to repeat a fact that is already explicit in current reference summary, current turn context, or active task state unless the inputs are contradictory. - Use tool steps whenever fresh external data is required. - Use ask_user if required parameters are missing. +- For config or create flows, prefer multi-slot ask_user prompts: ask for the main missing fields together instead of one field per turn whenever practical. +- When safe defaults are common and the user has not expressed a preference, offer those defaults in the same ask_user turn instead of forcing a separate follow-up for every slot. - Never place a trade unless the user intent is explicit. - For exchange binding or exchange credential requests, prefer get_exchange_configs/manage_exchange_config. - For AI model binding or model credential requests, prefer get_model_configs/manage_model_config. - For strategy template creation or editing requests, prefer get_strategies/manage_strategy. - For trader creation or trader lifecycle requests, prefer manage_trader. - A strategy template is independent and does not require exchange/model bindings unless the user explicitly asks to run or deploy it through a trader. +- Do NOT expand the goal beyond what the user explicitly requested. When the user's request is fulfilled, respond and stop. Do not proactively suggest or ask about the next logical step (e.g. do not ask "should I bind this to a trader?" after a strategy update unless the user asked for that). - If these tools exist, never answer that the system lacks exchange/model/trader management capability. - When configuration, strategy, or trader creation is requested, gather missing required fields via ask_user, then call the appropriate tool. - Before concluding that exchange/model/trader/strategy setup is impossible or missing, first inspect current state with the relevant tools. @@ -1626,7 +3075,7 @@ Rules: - For ask_user steps, put the exact follow-up question in instruction. - For respond steps, put either a short instruction or leave instruction empty. - If resuming after a waiting_user state, incorporate the new user reply and return a fresh full plan. -- Never invent tools.` +- Never invent tools.`) resumeContext := "" if state.SessionID != "" { @@ -1639,7 +3088,7 @@ Rules: } } - userPrompt := fmt.Sprintf("Language: %s\nUser request: %s%s\n\nRecent conversation:\n%s\n\nAvailable tools JSON:\n%s\n\nPersistent preferences:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s\n\nReturn JSON with this exact shape:\n{\"goal\":\"\",\"steps\":[{\"id\":\"step_1\",\"type\":\"tool|reason|ask_user|respond\",\"title\":\"\",\"tool_name\":\"\",\"tool_args\":{},\"instruction\":\"\",\"requires_confirmation\":false}]}", lang, userText, resumeContext, recentConversationCtx, string(toolDefs), a.buildPersistentPreferencesContext(userID), taskStateCtx, string(stateJSON)) + userPrompt := fmt.Sprintf("Language: %s\nUser request: %s%s\n\n%s\n\nCurrent reference summary:\n%s\n\nCurrent turn context:\n%s\n\nActive task state:\n%s\n\nAvailable tools JSON:\n%s\n\nPersistent preferences:\n%s\n\nReturn JSON with this exact shape:\n{\"goal\":\"\",\"steps\":[{\"id\":\"step_1\",\"type\":\"tool|reason|ask_user|respond\",\"title\":\"\",\"tool_name\":\"\",\"tool_args\":{},\"instruction\":\"\",\"requires_confirmation\":false}]}", lang, userText, resumeContext, skillContext, currentReferenceSummary, defaultIfEmpty(currentTurnCtx, "(empty)"), defaultIfEmpty(activeTaskCtx, "(empty)"), string(toolDefs), a.buildPersistentPreferencesContext(userID)) stageCtx, cancel := withPlannerStageTimeout(ctx, plannerCreateTimeout) defer cancel() @@ -1721,16 +3170,7 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6 } steps := filterFreshDuplicateToolSteps(decision.Steps, *state, time.Now().UTC()) if len(steps) == 0 { - appendExecutionLog(state, Observation{ - Kind: "decision_note", - Summary: "Skipped duplicate fresh tool calls from next-step decision", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - continue + return "", fmt.Errorf("all next steps are duplicate fresh tool calls") } if hasRepeatedReasonLoop(*state, steps) { return "", fmt.Errorf("repeated reasoning loop detected") @@ -1838,7 +3278,7 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6 } if onEvent != nil { onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - onEvent(StreamEventDelta, question) + emitStreamText(onEvent, question) } return question, nil case planStepTypeRespond: @@ -1860,7 +3300,7 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6 } if onEvent != nil { onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - onEvent(StreamEventDelta, finalText) + emitStreamText(onEvent, finalText) } return finalText, nil default: @@ -2010,7 +3450,7 @@ func parseRFC3339(value string) time.Time { func (a *Agent) replanAfterStep(ctx context.Context, userID int64, lang string, state ExecutionState, completedStep PlanStep) (replannerDecision, error) { obsJSON, _ := json.Marshal(buildObservationContext(state)) stepsJSON, _ := json.Marshal(state.Steps) - systemPrompt := `You are the replanning module for NOFXi. + systemPrompt := prependNOFXiAdvisorPreamble(`You are the replanning module for NOFXi. Return JSON only. Decide what to do after a plan step completed. @@ -2027,7 +3467,7 @@ Rules: - Use finish when there is enough information to answer and remaining steps are unnecessary. - If action=replace_remaining, return a fresh list of remaining steps only. - Keep plans short and safe. -- Never invent tools.` +- Never invent tools.`) userPrompt := fmt.Sprintf("Language: %s\nGoal: %s\nCompleted step: %s (%s)\nCompleted summary: %s\n\nCurrent steps JSON:\n%s\n\nObservations JSON:\n%s\n\nPersistent preferences:\n%s\n\nTask state:\n%s\n\nReturn JSON with this exact shape:\n{\"action\":\"continue|replace_remaining|ask_user|finish\",\"goal\":\"\",\"instruction\":\"\",\"question\":\"\",\"steps\":[{\"id\":\"step_x\",\"type\":\"tool|reason|ask_user|respond\",\"title\":\"\",\"tool_name\":\"\",\"tool_args\":{},\"instruction\":\"\",\"requires_confirmation\":false}]}", lang, state.Goal, completedStep.ID, completedStep.Type, completedStep.OutputSummary, string(stepsJSON), string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID))) @@ -2367,7 +3807,128 @@ func summarizeObservation(value string) string { return strings.TrimSpace(value[:observationMaxLength]) + "..." } +func isAIServiceFailureError(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(strings.TrimSpace(err.Error())) + if lower == "" { + return false + } + return strings.Contains(lower, "api returned error") || + strings.Contains(lower, "rate_limit_error") || + strings.Contains(lower, "upstream_empty_output") || + strings.Contains(lower, "insufficient balance") || + strings.Contains(lower, "context deadline exceeded") +} + +func planStepFallbackLabel(step PlanStep) string { + for _, candidate := range []string{ + strings.TrimSpace(step.Title), + strings.TrimSpace(step.Instruction), + strings.TrimSpace(step.ToolName), + } { + if candidate != "" { + return candidate + } + } + return strings.TrimSpace(step.ID) +} + +func formatCompletedPlanFallback(lang string, steps []PlanStep) string { + labels := make([]string, 0, len(steps)) + for _, step := range steps { + if label := planStepFallbackLabel(step); label != "" { + labels = append(labels, label) + } + } + if len(labels) == 0 { + return "" + } + if lang == "zh" { + lines := []string{"当前 AI 在最后生成总结时失败了,但我已经完成这些步骤:"} + for _, label := range labels { + lines = append(lines, "- "+label) + } + lines = append(lines, "你现在可以先去页面确认结果;如果需要,我稍后可以继续补一版确认说明。") + return strings.Join(lines, "\n") + } + lines := []string{"The AI failed while generating the final summary, but I already completed these steps:"} + for _, label := range labels { + lines = append(lines, "- "+label) + } + lines = append(lines, "You can verify the result in the UI now, and I can provide a follow-up confirmation summary later.") + return strings.Join(lines, "\n") +} + +func (a *Agent) tryExecutionSummaryFallbackOnAIError(lang string, state *ExecutionState, err error, onEvent func(event, data string)) (string, bool) { + if a == nil || state == nil || !isAIServiceFailureError(err) { + return "", false + } + completed := make([]PlanStep, 0, len(state.Steps)) + for _, step := range state.Steps { + if step.Status == planStepStatusCompleted && step.Type == planStepTypeTool { + completed = append(completed, step) + } + } + if len(completed) == 0 { + return "", false + } + answer := formatCompletedPlanFallback(lang, completed) + if answer == "" { + return "", false + } + currentStepID := state.CurrentStepID + state.Status = executionStatusCompleted + state.Waiting = nil + state.FinalAnswer = answer + state.LastError = strings.TrimSpace(err.Error()) + state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + for i := range state.Steps { + if state.Steps[i].ID == currentStepID || (state.Steps[i].Status == planStepStatusRunning && state.Steps[i].Type == planStepTypeRespond) { + state.Steps[i].Status = planStepStatusCompleted + state.Steps[i].OutputSummary = answer + state.Steps[i].Error = "" + } + } + state.CurrentStepID = "" + appendExecutionLog(state, Observation{ + Kind: "respond_fallback", + Summary: summarizeObservation(answer), + RawJSON: err.Error(), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + _ = a.saveExecutionState(*state) + if onEvent != nil { + emitStreamText(onEvent, answer) + } + return answer, true +} + +func (a *Agent) tryDeterministicFallbackAfterAIServiceFailure(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { + storeUserID := storeUserIDFromContext(ctx) + if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, nil + } + if state := a.getExecutionState(userID); hasActiveExecutionState(state) || len(state.Steps) > 0 { + completed := make([]PlanStep, 0, len(state.Steps)) + for _, step := range state.Steps { + if step.Status == planStepStatusCompleted && step.Type == planStepTypeTool { + completed = append(completed, step) + } + } + if answer := formatCompletedPlanFallback(lang, completed); answer != "" { + return a.maybeAppendResumePrompt(userID, lang, text, answer), true, nil + } + } + return "", false, nil +} + func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { + return a.thinkAndActLegacyWithStore(ctx, storeUserIDFromContext(ctx), userID, lang, text, onEvent) +} + +func (a *Agent) thinkAndActLegacyWithStore(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { systemPrompt := a.buildSystemPrompt(lang) enrichment := a.gatherContext(text) preferencesCtx := a.buildPersistentPreferencesContext(userID) @@ -2417,20 +3978,52 @@ func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text plainResp, plainErr := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx}) if plainErr != nil { a.logger.Warn("legacy AI plain fallback failed", "error", plainErr, "user_id", userID) + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } return a.aiServiceFailure(lang, plainErr) } + if looksLikeInternalAgentJSON(plainResp) { + a.logger.Warn("legacy AI plain fallback returned internal orchestration json; attempting active-flow recovery", "user_id", userID) + if answer, ok, err := a.tryRecoverFromInternalAgentJSON(ctx, storeUserID, userID, lang, text, plainResp, onEvent); ok || err != nil { + return answer, err + } + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } + if lang == "zh" { + return "我理解到你还在继续刚才的操作,但这次内部回复格式不对。你再说一次刚才想做的那一步,我继续接着帮你。", nil + } + return "I can tell you're continuing the previous task, but the internal response format was invalid. Please repeat that step and I'll keep going.", nil + } if onEvent != nil { - onEvent(StreamEventDelta, plainResp) + emitStreamText(onEvent, plainResp) } return plainResp, nil } a.logger.Warn("legacy AI tool round failed", "error", err, "user_id", userID, "round", round) + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } return a.aiServiceFailure(lang, err) } if len(resp.ToolCalls) == 0 { + if looksLikeInternalAgentJSON(resp.Content) { + a.logger.Warn("legacy AI returned internal orchestration json; attempting active-flow recovery", "user_id", userID) + if answer, ok, err := a.tryRecoverFromInternalAgentJSON(ctx, storeUserID, userID, lang, text, resp.Content, onEvent); ok || err != nil { + return answer, err + } + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } + if lang == "zh" { + return "我理解到你还在继续刚才的操作,但这次内部回复格式不对。你再说一次刚才想做的那一步,我继续接着帮你。", nil + } + return "I can tell you're continuing the previous task, but the internal response format was invalid. Please repeat that step and I'll keep going.", nil + } if onEvent != nil { - onEvent(StreamEventDelta, resp.Content) + emitStreamText(onEvent, resp.Content) } return resp.Content, nil } @@ -2445,7 +4038,7 @@ func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text if onEvent != nil { onEvent(StreamEventTool, tc.Function.Name) } - result := a.handleToolCall(ctx, storeUserIDFromContext(ctx), userID, lang, tc) + result := a.handleToolCall(ctx, storeUserID, userID, lang, tc) messages = append(messages, mcp.Message{ Role: "tool", Content: result, @@ -2457,10 +4050,26 @@ func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text finalResp, err := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx}) if err != nil { a.logger.Warn("legacy AI final response failed", "error", err, "user_id", userID) + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } return a.aiServiceFailure(lang, err) } + if looksLikeInternalAgentJSON(finalResp) { + a.logger.Warn("legacy AI final response returned internal orchestration json; attempting active-flow recovery", "user_id", userID) + if answer, ok, err := a.tryRecoverFromInternalAgentJSON(ctx, storeUserID, userID, lang, text, finalResp, onEvent); ok || err != nil { + return answer, err + } + if answer, ok, fallbackErr := a.tryDeterministicFallbackAfterAIServiceFailure(ctx, userID, lang, text, onEvent); ok || fallbackErr != nil { + return answer, fallbackErr + } + if lang == "zh" { + return "我理解到你还在继续刚才的操作,但这次内部回复格式不对。你再说一次刚才想做的那一步,我继续接着帮你。", nil + } + return "I can tell you're continuing the previous task, but the internal response format was invalid. Please repeat that step and I'll keep going.", nil + } if onEvent != nil { - onEvent(StreamEventDelta, finalResp) + emitStreamText(onEvent, finalResp) } return finalResp, nil } diff --git a/agent/planner_runtime_state_test.go b/agent/planner_runtime_state_test.go deleted file mode 100644 index ed1b08da..00000000 --- a/agent/planner_runtime_state_test.go +++ /dev/null @@ -1,807 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "log/slog" - "strings" - "testing" - "time" - - "nofx/mcp" -) - -func TestIsConfigOrTraderIntent(t *testing.T) { - cases := []struct { - text string - want bool - }{ - {text: "帮我创建一个交易员", want: true}, - {text: "我已经配置好了 OKX 和 DeepSeek", want: true}, - {text: "List my traders", want: true}, - {text: "BTC 接下来怎么看", want: false}, - } - for _, tc := range cases { - if got := isConfigOrTraderIntent(tc.text); got != tc.want { - t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want) - } - } -} - -func TestIsRealtimeAccountIntent(t *testing.T) { - cases := []struct { - text string - want bool - }{ - {text: "现在余额多少", want: true}, - {text: "我的仓位还在吗", want: true}, - {text: "show recent trade history", want: true}, - {text: "帮我创建交易员", want: false}, - } - for _, tc := range cases { - if got := isRealtimeAccountIntent(tc.text); got != tc.want { - t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want) - } - } -} - -func TestDetectReadFastPath(t *testing.T) { - cases := []struct { - text string - want string - }{ - {text: "/traders", want: "list_traders"}, - {text: "/strategies", want: "get_strategies"}, - {text: "/models", want: "get_model_configs"}, - {text: "/exchanges", want: "get_exchange_configs"}, - {text: "/balance", want: "get_balance"}, - {text: "/positions", want: "get_positions"}, - {text: "/history", want: "get_trade_history"}, - {text: "/trades", want: "get_trade_history"}, - {text: "列出我当前的策略", want: ""}, - {text: "查看当前交易员", want: ""}, - {text: "现在余额多少", want: ""}, - {text: "我的仓位还在吗", want: ""}, - {text: "我现在有哪些账户", want: ""}, - {text: "我的余额", want: ""}, - {text: "根据我的余额帮我分析我应该买什么", want: ""}, - {text: "我的策略是AI100,但是No candidate coins available, cycle skipped", want: ""}, - {text: "帮我创建一个 trader", want: ""}, - } - for _, tc := range cases { - req := detectReadFastPath(tc.text) - got := "" - if req != nil { - got = req.Kind - } - if got != tc.want { - t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want) - } - } -} - -func TestShouldResetExecutionStateForNewAttempt(t *testing.T) { - state := ExecutionState{ - SessionID: "sess_1", - Status: executionStatusWaitingUser, - } - if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) { - t.Fatalf("expected retry-style config request to reset execution state") - } - if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) { - t.Fatalf("did not expect generic market query to reset execution state") - } -} - -func TestLatestAskedQuestion(t *testing.T) { - state := ExecutionState{ - Status: executionStatusWaitingUser, - Steps: []PlanStep{ - {ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted}, - {ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"}, - }, - } - got := latestAskedQuestion(state) - want := "需要我用正确的参数重试创建交易员 lky 吗?" - if got != want { - t.Fatalf("latestAskedQuestion() = %q, want %q", got, want) - } -} - -func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) { - state := ExecutionState{ - Status: executionStatusWaitingUser, - Waiting: &WaitingState{ - Question: "请确认是否继续创建交易员 lky", - Intent: "confirm_action", - }, - Steps: []PlanStep{ - {ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"}, - }, - } - if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" { - t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got) - } -} - -func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) { - a := newTestAgentWithStore(t) - - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5-mini" - }`) - _ = a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"Main", - "enabled":true - }`) - - state := ExecutionState{ - SessionID: "sess_1", - UserID: 1, - DynamicSnapshots: []Observation{ - {Kind: "current_model_configs", Summary: "stale"}, - }, - ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}}, - } - - refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state) - - if len(refreshed.DynamicSnapshots) < 3 { - t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots) - } - - var foundModel, foundExchange, foundTraders bool - for _, obs := range refreshed.DynamicSnapshots { - switch obs.Kind { - case "current_model_configs": - foundModel = strings.Contains(obs.RawJSON, "openai") - case "current_exchange_configs": - foundExchange = strings.Contains(obs.RawJSON, "okx") - case "current_traders": - foundTraders = strings.Contains(obs.RawJSON, `"traders"`) - } - } - - if !foundModel || !foundExchange || !foundTraders { - t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots) - } -} - -func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) { - a := newTestAgentWithStore(t) - - state := ExecutionState{ - SessionID: "sess_2", - UserID: 1, - DynamicSnapshots: []Observation{ - {Kind: "current_balances", Summary: "stale balances"}, - {Kind: "current_positions", Summary: "stale positions"}, - }, - ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}}, - } - - refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state) - - var keptBalances, keptPositions, foundHistory bool - for _, obs := range refreshed.DynamicSnapshots { - switch obs.Kind { - case "current_balances": - keptBalances = strings.Contains(obs.Summary, "stale balances") - case "current_positions": - keptPositions = strings.Contains(obs.Summary, "stale positions") - case "recent_trade_history": - foundHistory = obs.RawJSON != "" - } - } - - if !keptBalances || !keptPositions || foundHistory { - t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots) - } -} - -func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) { - a := newTestAgentWithStore(t) - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"激进", - "description":"激进策略模板", - "lang":"zh" - }`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") { - t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp) - } -} - -func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) { - state := normalizeExecutionState(ExecutionState{ - SessionID: "sess_legacy", - UserID: 1, - Observations: []Observation{ - {Kind: "tool_result", Summary: "legacy tool result"}, - }, - }) - - if len(state.Observations) != 0 { - t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations) - } - if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" { - t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog) - } -} - -func TestBuildWaitingStateForTraderConfirmation(t *testing.T) { - state := ExecutionState{Goal: "创建交易员 lky"} - step := PlanStep{ - ID: "step_ask_1", - Type: planStepTypeAskUser, - Instruction: "需要我用正确的参数重试创建交易员 lky 吗?", - RequiresConfirmation: true, - } - - waiting := buildWaitingState(state, step, step.Instruction) - if waiting == nil { - t.Fatal("expected waiting state") - } - if waiting.Intent != "confirm_action" { - t.Fatalf("unexpected waiting intent: %+v", waiting) - } - if waiting.ConfirmationTarget != "trader" { - t.Fatalf("unexpected confirmation target: %+v", waiting) - } -} - -func TestNormalizeWaitingStateCleansFields(t *testing.T) { - state := normalizeExecutionState(ExecutionState{ - SessionID: "sess_waiting", - UserID: 1, - Waiting: &WaitingState{ - Question: " 请提供 strategy_id ", - Intent: " complete_trader_setup ", - PendingFields: []string{" strategy_id ", "strategy_id"}, - ConfirmationTarget: " trader ", - }, - }) - - if state.Waiting == nil { - t.Fatal("expected normalized waiting state") - } - if state.Waiting.Question != "请提供 strategy_id" { - t.Fatalf("unexpected normalized question: %+v", state.Waiting) - } - if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" { - t.Fatalf("unexpected pending fields: %+v", state.Waiting) - } - if state.Waiting.ConfirmationTarget != "trader" { - t.Fatalf("unexpected confirmation target: %+v", state.Waiting) - } -} - -func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) { - a := newTestAgentWithStore(t) - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"激进", - "description":"激进策略模板", - "lang":"zh" - }`) - - state := newExecutionState(1, "帮我改一下激进这个策略") - a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state) - - if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil { - t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences) - } - if state.CurrentReferences.Strategy.Name != "激进" { - t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy) - } -} - -func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) { - state := newExecutionState(1, "创建策略") - changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{ - "status":"ok", - "action":"create", - "strategy":{"id":"strategy_1","name":"激进"} - }`) - - if !changed { - t.Fatalf("expected reference update to report changed") - } - if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil { - t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences) - } - if state.CurrentReferences.Strategy.ID != "strategy_1" { - t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy) - } -} - -func TestShouldAttemptReplan(t *testing.T) { - state := ExecutionState{ - Steps: []PlanStep{ - {ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted}, - {ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending}, - }, - } - - if !shouldAttemptReplan(state, PlanStep{ - Type: planStepTypeTool, - ToolName: "manage_trader", - ToolArgs: map[string]any{"action": "create"}, - OutputSummary: `{"status":"ok","action":"create"}`, - }, false) { - t.Fatalf("expected create trader step to trigger replan") - } - - if shouldAttemptReplan(state, PlanStep{ - Type: planStepTypeTool, - ToolName: "get_balance", - OutputSummary: `{"balances":[]}`, - }, false) { - t.Fatalf("did not expect read-only balance step to trigger replan") - } - - if !shouldAttemptReplan(state, PlanStep{ - Type: planStepTypeTool, - ToolName: "get_balance", - OutputSummary: `{"error":"ai_model_id is required"}`, - }, false) { - t.Fatalf("expected dependency/error result to trigger replan") - } -} - -type failingAIClient struct{} - -func (f *failingAIClient) SetAPIKey(string, string, string) {} -func (f *failingAIClient) SetTimeout(_ time.Duration) {} -func (f *failingAIClient) CallWithMessages(string, string) (string, error) { - return "", errors.New("unexpected CallWithMessages") -} -func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) { - return "", errors.New("API returned error (status 402): insufficient balance") -} -func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) { - return "", errors.New("unexpected CallWithRequestStream") -} -func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) { - return nil, errors.New("API returned error (status 402): insufficient balance") -} - -type capturePlannerAIClient struct { - systemPrompt string - userPrompt string -} - -func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {} -func (c *capturePlannerAIClient) SetTimeout(time.Duration) {} -func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) { - return "", errors.New("unexpected CallWithMessages") -} -func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) { - if len(req.Messages) > 0 { - c.systemPrompt = req.Messages[0].Content - } - if len(req.Messages) > 1 { - c.userPrompt = req.Messages[1].Content - } - return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil -} -func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) { - return "", errors.New("unexpected CallWithRequestStream") -} -func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) { - return nil, errors.New("unexpected CallWithRequestFull") -} - -type blockingAIClient struct{} - -func (b *blockingAIClient) SetAPIKey(string, string, string) {} -func (b *blockingAIClient) SetTimeout(time.Duration) {} -func (b *blockingAIClient) CallWithMessages(string, string) (string, error) { - return "", errors.New("unexpected CallWithMessages") -} -func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) { - <-req.Ctx.Done() - return "", req.Ctx.Err() -} -func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) { - return "", errors.New("unexpected CallWithRequestStream") -} -func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) { - return nil, errors.New("unexpected CallWithRequestFull") -} - -type directReplyAIClient struct { - lastSystemPrompt string - lastUserPrompt string - routerPrompt string - skillRouterPrompt string - plannerPrompt string -} - -func (d *directReplyAIClient) SetAPIKey(string, string, string) {} -func (d *directReplyAIClient) SetTimeout(time.Duration) {} -func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) { - return "", errors.New("unexpected CallWithMessages") -} -func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) { - if len(req.Messages) > 0 { - d.lastSystemPrompt = req.Messages[0].Content - } - if len(req.Messages) > 1 { - d.lastUserPrompt = req.Messages[1].Content - } - if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") { - d.routerPrompt = d.lastSystemPrompt - if strings.Contains(d.lastUserPrompt, "你好") { - return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil - } - return `{"action":"defer","answer":""}`, nil - } - if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") { - d.skillRouterPrompt = d.lastSystemPrompt - if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") { - return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil - } - return `{"route":"planner","skill":"","action":"","filter":""}`, nil - } - if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") { - d.plannerPrompt = d.lastSystemPrompt - } - return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil -} -func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) { - return "", errors.New("unexpected CallWithRequestStream") -} -func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) { - return nil, errors.New("unexpected CallWithRequestFull") -} - -func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) { - a := &Agent{ - aiClient: &failingAIClient{}, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil) - if err != nil { - t.Fatalf("thinkAndActLegacy() error = %v", err) - } - if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") { - t.Fatalf("expected provider failure message, got fallback: %q", resp) - } - if !strings.Contains(resp, "AI 服务调用失败") { - t.Fatalf("expected provider failure message, got %q", resp) - } -} - -func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) { - client := &directReplyAIClient{} - a := &Agent{ - aiClient: client, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "你好,我在") { - t.Fatalf("expected direct reply response, got %q", resp) - } - if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") { - t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt) - } -} - -func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) { - a := newTestAgentWithStore(t) - a.aiClient = &directReplyAIClient{} - - resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "已创建模型配置") { - t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp) - } -} - -func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) { - client := &directReplyAIClient{} - a := newTestAgentWithStore(t) - a.aiClient = client - a.history = newChatHistory(10) - - modelResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5-mini" - }`) - var modelCreated struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil { - t.Fatalf("unmarshal model response: %v", err) - } - - exchangeResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Main", - "enabled":true - }`) - var exchangeCreated struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil { - t.Fatalf("unmarshal exchange response: %v", err) - } - - createResp := a.toolManageTrader("user-1", `{ - "action":"create", - "name":"Momentum Trader", - "ai_model_id":"`+modelCreated.Model.ID+`", - "exchange_id":"`+exchangeCreated.Exchange.ID+`", - "scan_interval_minutes":5 - }`) - var created struct { - Trader safeTraderToolConfig `json:"trader"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp) - } - if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil { - t.Fatalf("update trader status: %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") { - t.Fatalf("expected routed running-trader answer, got %q", resp) - } - if client.skillRouterPrompt == "" { - t.Fatal("expected lightweight skill router prompt to be used") - } - if client.plannerPrompt != "" { - t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt) - } -} - -func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) { - client := &directReplyAIClient{} - a := newTestAgentWithStore(t) - a.aiClient = client - a.history = newChatHistory(10) - a.logger = slog.Default() - - userID := int64(90) - state := newExecutionState(userID, "继续完成当前任务") - state.Status = executionStatusWaitingUser - state.Waiting = &WaitingState{ - Question: "请确认是否继续", - Intent: "confirm_action", - } - if err := a.saveExecutionState(state); err != nil { - t.Fatalf("saveExecutionState() error = %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if strings.Contains(resp, "你好,我在") { - t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp) - } - if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") { - t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt) - } -} - -func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) { - a := newTestAgentWithStore(t) - a.history = newChatHistory(10) - - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"激进", - "lang":"zh" - }`) - - userID := int64(91) - state := newExecutionState(userID, "创建交易员") - state.Status = executionStatusWaitingUser - state.Waiting = &WaitingState{ - Question: "请告诉我交易员名称", - PendingFields: []string{"name"}, - } - if err := a.saveExecutionState(state); err != nil { - t.Fatalf("saveExecutionState() error = %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") { - t.Fatalf("expected new topic to be handled, got %q", resp) - } - if got := a.getExecutionState(userID); got.SessionID != "" { - t.Fatalf("expected execution state to be cleared, got %+v", got) - } -} - -func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) { - client := &capturePlannerAIClient{} - a := &Agent{ - aiClient: client, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - userID := int64(42) - a.history.Add(userID, "user", "先帮我看一下当前trader") - a.history.Add(userID, "assistant", "当前只有测试1这个trader。") - a.history.Add(userID, "user", "好的,那就按当前trader来") - - _, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的,那就按当前trader来", newExecutionState(userID, "好的,那就按当前trader来")) - if err != nil { - t.Fatalf("createExecutionPlan() error = %v", err) - } - if !strings.Contains(client.userPrompt, "Recent conversation:") { - t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt) - } - if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") { - t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt) - } - if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") { - t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt) - } - recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n") - toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:") - if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx { - t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt) - } - recentBlock := client.userPrompt[recentIdx:toolsIdx] - if strings.Contains(recentBlock, "好的,那就按当前trader来") { - t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock) - } - if !strings.Contains(client.systemPrompt, "Memory priority order:") { - t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt) - } - if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") { - t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt) - } - if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") { - t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt) - } -} - -func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) { - client := &capturePlannerAIClient{} - a := &Agent{ - aiClient: client, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - userID := int64(99) - a.history.Add(userID, "user", "先帮我看一下当前trader") - a.history.Add(userID, "assistant", "当前只有测试1这个trader。") - - _, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{}) - if err != nil { - t.Fatalf("createExecutionPlan() error = %v", err) - } - if !strings.Contains(client.userPrompt, "Recent conversation:") { - t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt) - } - if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") { - t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt) - } - if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") { - t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt) - } -} - -func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) { - client := &capturePlannerAIClient{} - a := &Agent{ - aiClient: client, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - userID := int64(100) - a.history.Add(userID, "user", "配置页怎么只有三个交易所") - a.history.Add(userID, "assistant", "目前你看到的是三个交易所。") - - _, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{}) - if err != nil { - t.Fatalf("createExecutionPlan() error = %v", err) - } - if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") { - t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt) - } - if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") { - t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt) - } -} - -func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) { - oldTimeout := plannerCreateTimeout - plannerCreateTimeout = 10 * time.Millisecond - defer func() { plannerCreateTimeout = oldTimeout }() - - a := &Agent{ - aiClient: &blockingAIClient{}, - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - } - - resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil) - if err != nil { - t.Fatalf("runPlannedAgent() error = %v", err) - } - if !strings.Contains(resp, "处理超时") { - t.Fatalf("expected timeout message, got %q", resp) - } -} - -func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) { - a := &Agent{ - config: DefaultConfig(), - logger: slog.Default(), - history: newChatHistory(10), - pending: newPendingTrades(), - } - - resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing") - if err != nil { - t.Fatalf("handleMessageForStoreUser() error = %v", err) - } - if !strings.Contains(resp, "交易已过期或不存在") { - t.Fatalf("expected direct trade confirmation handling, got %q", resp) - } -} - -func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) { - url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek") - if url != "https://api.deepseek.com/v1" { - t.Fatalf("unexpected deepseek default url: %q", url) - } - if model != "deepseek-chat" { - t.Fatalf("unexpected deepseek default model: %q", model) - } - - url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek") - if url != "https://api.deepseek.com/v1" { - t.Fatalf("unexpected resolved url: %q", url) - } - if model != "deepseek1" { - t.Fatalf("expected existing custom model name to win, got %q", model) - } -} diff --git a/agent/preferences.go b/agent/preferences.go index af43c9e8..5b834ecf 100644 --- a/agent/preferences.go +++ b/agent/preferences.go @@ -8,6 +8,8 @@ import ( "time" ) +const maxPersistentPreferenceLength = 500 + // PersistentPreference is a durable user instruction shown in the UI and // injected into the agent context for future conversations. type PersistentPreference struct { @@ -21,6 +23,9 @@ func NewPersistentPreference(text string) (PersistentPreference, error) { if text == "" { return PersistentPreference{}, fmt.Errorf("text required") } + if len([]rune(text)) > maxPersistentPreferenceLength { + return PersistentPreference{}, fmt.Errorf("text too long (max %d characters)", maxPersistentPreferenceLength) + } now := time.Now().UTC() return PersistentPreference{ diff --git a/agent/preferences_test.go b/agent/preferences_test.go deleted file mode 100644 index 5c45e2c5..00000000 --- a/agent/preferences_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package agent - -import ( - "strings" - "testing" -) - -func TestNewPersistentPreference(t *testing.T) { - pref, err := NewPersistentPreference(" Always answer in Chinese. ") - if err != nil { - t.Fatalf("expected preference to be created, got error: %v", err) - } - if pref.ID == "" { - t.Fatal("expected non-empty preference id") - } - if pref.Text != "Always answer in Chinese." { - t.Fatalf("expected trimmed text, got %q", pref.Text) - } - if pref.CreatedAt == "" { - t.Fatal("expected created_at to be set") - } - if strings.Contains(pref.ID, "Always") { - t.Fatalf("expected generated id, got %q", pref.ID) - } -} - -func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) { - if _, err := NewPersistentPreference(" "); err == nil { - t.Fatal("expected empty text to be rejected") - } -} diff --git a/agent/prompt_context.go b/agent/prompt_context.go new file mode 100644 index 00000000..23f7af52 --- /dev/null +++ b/agent/prompt_context.go @@ -0,0 +1,74 @@ +package agent + +import ( + "fmt" + "strings" +) + +func (a *Agent) buildCurrentTurnContext(userID int64, lang, currentUserText string) string { + var parts []string + previousAssistantReply := strings.TrimSpace(a.currentPendingHintText(userID)) + if previousAssistantReply != "" { + parts = append(parts, "Previous assistant reply:\n"+previousAssistantReply) + } + recentConversation := strings.TrimSpace(a.buildRecentConversationContext(userID, currentUserText)) + if recentConversation != "" { + parts = append(parts, "Recent conversation:\n"+recentConversation) + } + currentRefs := strings.TrimSpace(buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))) + if currentRefs != "" { + parts = append(parts, "Current references:\n"+currentRefs) + } + return strings.Join(parts, "\n\n") +} + +func (a *Agent) buildActiveTaskStateContext(userID int64, lang string) string { + activeSkill := a.getSkillSession(userID) + activeTask, hasActiveTask := a.getActiveSkillSession(userID) + activeWorkflow := a.getWorkflowSession(userID) + activeExec := normalizeExecutionState(a.getExecutionState(userID)) + pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID) + + lines := []string{} + if hasActiveTask || strings.TrimSpace(activeSkill.Name) != "" || hasActiveWorkflowSession(activeWorkflow) || hasActiveExecutionState(activeExec) || hasPendingProposal { + summary := strings.TrimSpace(buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal)) + if summary != "" { + lines = append(lines, summary) + } + } + + taskState := normalizeTaskState(a.getTaskState(userID)) + if taskState.CurrentGoal != "" { + lines = append(lines, "Durable goal: "+taskState.CurrentGoal) + } + if taskState.ActiveFlow != "" { + lines = append(lines, "Durable active flow: "+taskState.ActiveFlow) + } + if len(taskState.OpenLoops) > 0 { + limit := len(taskState.OpenLoops) + if limit > 3 { + limit = 3 + } + for _, loop := range taskState.OpenLoops[:limit] { + lines = append(lines, "Open loop: "+loop) + } + } + + if hasActiveExecutionState(activeExec) { + lines = append(lines, fmt.Sprintf("Execution status: %s", activeExec.Status)) + if strings.TrimSpace(activeExec.Goal) != "" { + lines = append(lines, "Execution goal: "+strings.TrimSpace(activeExec.Goal)) + } + if activeExec.Waiting != nil && strings.TrimSpace(activeExec.Waiting.Question) != "" { + lines = append(lines, "Waiting question: "+strings.TrimSpace(activeExec.Waiting.Question)) + } + if strings.TrimSpace(activeExec.CurrentStepID) != "" { + lines = append(lines, "Current step id: "+strings.TrimSpace(activeExec.CurrentStepID)) + } + } + + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} diff --git a/agent/prompt_persona.go b/agent/prompt_persona.go new file mode 100644 index 00000000..9ae3337a --- /dev/null +++ b/agent/prompt_persona.go @@ -0,0 +1,25 @@ +package agent + +import "strings" + +const nofxiAdvisorSystemPreamble = `You are NOFXi, the core intelligence hub of the NOFX platform. +You understand NOFX's underlying logic, feature boundaries, and quantitative operating model. +Your first duty is not blind execution. You act as the user's senior quantitative advisor so every NOFX configuration is correct, safe, and logically consistent. +When the user runs into a problem, combine the current state with NOFX platform constraints, proactively diagnose what is wrong, and provide concrete next steps. + +User-facing response style rules: +- Treat the user like a trading beginner, not a developer. +- Prefer simple, plain language over technical jargon. +- Lead with the conclusion first, then one or two concrete next steps. +- Keep sentences short and easy to scan. +- If you must use a technical term, explain it in everyday words immediately. +- Do not expose internal architecture, tool names, JSON fields, or implementation details unless the user explicitly asks for them. +- When asking follow-up questions, make them specific, friendly, and easy to answer.` + +func prependNOFXiAdvisorPreamble(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return nofxiAdvisorSystemPreamble + } + return nofxiAdvisorSystemPreamble + "\n\n" + body +} diff --git a/agent/reference_memory.go b/agent/reference_memory.go new file mode 100644 index 00000000..e07f037f --- /dev/null +++ b/agent/reference_memory.go @@ -0,0 +1,101 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type ReferenceMemory struct { + CurrentReferences *CurrentReferences `json:"current_references,omitempty"` + ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func referenceMemoryConfigKey(userID int64) string { + return fmt.Sprintf("agent_reference_memory_%d", userID) +} + +func (a *Agent) getReferenceMemory(userID int64) ReferenceMemory { + if a == nil || a.store == nil { + return ReferenceMemory{} + } + raw, err := a.store.GetSystemConfig(referenceMemoryConfigKey(userID)) + if err != nil { + return ReferenceMemory{} + } + raw = strings.TrimSpace(raw) + if raw == "" { + return ReferenceMemory{} + } + var memory ReferenceMemory + if err := json.Unmarshal([]byte(raw), &memory); err != nil { + return ReferenceMemory{} + } + memory.CurrentReferences = normalizeCurrentReferences(memory.CurrentReferences) + memory.ReferenceHistory = normalizeReferenceHistory(memory.ReferenceHistory) + return memory +} + +func (a *Agent) saveReferenceMemory(userID int64, refs *CurrentReferences, history []ReferenceRecord) { + if a == nil || a.store == nil { + return + } + memory := ReferenceMemory{ + CurrentReferences: normalizeCurrentReferences(refs), + ReferenceHistory: normalizeReferenceHistory(history), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + if memory.CurrentReferences == nil && len(memory.ReferenceHistory) == 0 { + _ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "") + return + } + data, err := json.Marshal(memory) + if err != nil { + return + } + _ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), string(data)) +} + +func (a *Agent) clearReferenceMemory(userID int64) { + if a == nil || a.store == nil { + return + } + _ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "") +} + +func (a *Agent) semanticCurrentReferences(userID int64) *CurrentReferences { + state := a.getExecutionState(userID) + if refs := normalizeCurrentReferences(state.CurrentReferences); refs != nil { + return refs + } + return a.getReferenceMemory(userID).CurrentReferences +} + +func (a *Agent) semanticReferenceHistory(userID int64) []ReferenceRecord { + state := a.getExecutionState(userID) + if history := normalizeReferenceHistory(state.ReferenceHistory); len(history) > 0 { + return history + } + return a.getReferenceMemory(userID).ReferenceHistory +} + +func (a *Agent) rememberReferencesFromToolResult(userID int64, toolName, raw string) { + if a == nil { + return + } + memory := a.getReferenceMemory(userID) + state := ExecutionState{ + UserID: userID, + CurrentReferences: memory.CurrentReferences, + ReferenceHistory: memory.ReferenceHistory, + } + if !updateCurrentReferencesFromToolResult(&state, toolName, raw) { + return + } + a.saveReferenceMemory(userID, state.CurrentReferences, state.ReferenceHistory) + execState := a.getExecutionState(userID) + execState.CurrentReferences = state.CurrentReferences + a.saveExecutionState(execState) +} diff --git a/agent/scheduler.go b/agent/scheduler.go index 41021c7a..b2a8da2b 100644 --- a/agent/scheduler.go +++ b/agent/scheduler.go @@ -6,13 +6,15 @@ import ( "log/slog" "nofx/safe" "strings" + "sync" "time" ) type Scheduler struct { - agent *Agent - logger *slog.Logger - stopCh chan struct{} + agent *Agent + logger *slog.Logger + stopCh chan struct{} + stopOnce sync.Once } func NewScheduler(a *Agent, l *slog.Logger) *Scheduler { @@ -27,8 +29,10 @@ func (s *Scheduler) Start(ctx context.Context) { lastCheck := time.Time{} for { select { - case <-ctx.Done(): return - case <-s.stopCh: return + case <-ctx.Done(): + return + case <-s.stopCh: + return case now := <-ticker.C: // Daily report at 21:00 if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour { @@ -51,13 +55,21 @@ func (s *Scheduler) Start(ctx context.Context) { }) } -func (s *Scheduler) Stop() { close(s.stopCh) } +func (s *Scheduler) Stop() { + s.stopOnce.Do(func() { + close(s.stopCh) + }) +} func (s *Scheduler) dailyReport() { - if s.agent.traderManager == nil { return } + if s.agent.traderManager == nil { + return + } traders := s.agent.traderManager.GetAllTraders() - if len(traders) == 0 { return } + if len(traders) == 0 { + return + } var sb strings.Builder sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02"))) @@ -65,30 +77,40 @@ func (s *Scheduler) dailyReport() { totalPnL := 0.0 for _, t := range traders { info, err := t.GetAccountInfo() - if err != nil { continue } + if err != nil { + continue + } equity := toFloat(info["total_equity"]) pnl := toFloat(info["unrealized_pnl"]) sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl)) totalPnL += pnl } e := "📈" - if totalPnL < 0 { e = "📉" } + if totalPnL < 0 { + e = "📉" + } sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL)) s.agent.notifyAll(sb.String()) } func (s *Scheduler) riskCheck() { - if s.agent.traderManager == nil { return } + if s.agent.traderManager == nil { + return + } var alerts []string for _, t := range s.agent.traderManager.GetAllTraders() { positions, err := t.GetPositions() - if err != nil { continue } + if err != nil { + continue + } for _, p := range positions { pnl := toFloat(p["unrealizedPnl"]) size := toFloat(p["size"]) - if size == 0 { continue } + if size == 0 { + continue + } entry := toFloat(p["entryPrice"]) if entry > 0 { pnlPct := (pnl / (entry * size)) * 100 diff --git a/agent/sentinel.go b/agent/sentinel.go index 3c5f0f22..91d76385 100644 --- a/agent/sentinel.go +++ b/agent/sentinel.go @@ -41,6 +41,7 @@ type Sentinel struct { http *http.Client logger *slog.Logger stopCh chan struct{} + stopOnce sync.Once } type pricePt struct { @@ -76,20 +77,51 @@ func (s *Sentinel) Start() { }) } -func (s *Sentinel) Stop() { close(s.stopCh) } -func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) } -func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) } -func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } } +func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) } +func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) } +func (s *Sentinel) Symbols() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, len(s.symbols)) + copy(out, s.symbols) + return out +} +func (s *Sentinel) AddSymbol(sym string) { + s.mu.Lock() + defer s.mu.Unlock() + for _, x := range s.symbols { + if x == sym { + return + } + } + s.symbols = append(s.symbols, sym) +} +func (s *Sentinel) RemoveSymbol(sym string) { + s.mu.Lock() + defer s.mu.Unlock() + for i, x := range s.symbols { + if x == sym { + s.symbols = append(s.symbols[:i], s.symbols[i+1:]...) + return + } + } +} func (s *Sentinel) FormatWatchlist(L string) string { s.mu.RLock() defer s.mu.RUnlock() if len(s.symbols) == 0 { - if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" } + if L == "zh" { + return "📭 监控列表为空。用 `/watch BTC` 添加。" + } return "📭 Watchlist empty. Use `/watch BTC` to add." } var sb strings.Builder - if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") } + if L == "zh" { + sb.WriteString("👁️ *监控列表*\n\n") + } else { + sb.WriteString("👁️ *Watchlist*\n\n") + } for _, sym := range s.symbols { if pts, ok := s.history[sym]; ok && len(pts) > 0 { last := pts[len(pts)-1] @@ -113,16 +145,22 @@ func (s *Sentinel) scan() { func (s *Sentinel) check(symbol string) { resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)) - if err != nil { return } + if err != nil { + return + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode) return } body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit - if err != nil { return } + if err != nil { + return + } var t map[string]interface{} - if err := json.Unmarshal(body, &t); err != nil { return } + if err := json.Unmarshal(body, &t); err != nil { + return + } price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64) vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64) @@ -132,41 +170,53 @@ func (s *Sentinel) check(symbol string) { s.mu.Lock() h := s.history[symbol] h = append(h, pt) - if len(h) > 60 { h = h[len(h)-60:] } + if len(h) > 60 { + h = h[len(h)-60:] + } s.history[symbol] = h s.mu.Unlock() - if len(h) < 5 { return } + if len(h) < 5 { + return + } // Price breakout (>3% in 5 min) old := h[len(h)-5] pct := ((price - old.Price) / old.Price) * 100 if math.Abs(pct) >= 3.0 { sev := "warning" - if math.Abs(pct) >= 6.0 { sev = "critical" } + if math.Abs(pct) >= 6.0 { + sev = "critical" + } dir := "📈 拉升" - if pct < 0 { dir = "📉 下跌" } + if pct < 0 { + dir = "📉 下跌" + } s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev, - Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)), + Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)), Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg), - Price: price, Change: pct}) + Price: price, Change: pct}) } // Volume spike (>3x avg) if len(h) >= 10 { var avg float64 - for i := 0; i < len(h)-1; i++ { avg += h[i].Volume } + for i := 0; i < len(h)-1; i++ { + avg += h[i].Volume + } avg /= float64(len(h) - 1) if avg > 0 && vol > avg*3 { s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning", - Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg), + Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg), Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg), - Price: price, Change: chg}) + Price: price, Change: chg}) } } } func (s *Sentinel) emit(sig Signal) { s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title) - if s.onSignal != nil { s.onSignal(sig) } + if s.onSignal != nil { + s.onSignal(sig) + } } diff --git a/agent/skill_catalog_test.go b/agent/skill_catalog_test.go deleted file mode 100644 index 36d96fbe..00000000 --- a/agent/skill_catalog_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package agent - -import ( - "log/slog" - "strings" - "testing" -) - -func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) { - got := skillCatalogPrompt("zh") - for _, want := range []string{ - "多轮与 Skill-First 工作模式", - "skill_model_config_diagnosis", - "skill_exchange_api_diagnosis", - "skill_trader_start_diagnosis", - } { - if !strings.Contains(got, want) { - t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got) - } - } -} - -func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) { - a := New(nil, nil, DefaultConfig(), slog.Default()) - got := a.buildSystemPrompt("zh") - for _, want := range []string{ - "多轮与 Skill-First 工作模式", - "skill_exchange_api_setup", - "skill_order_execution_diagnosis", - } { - if !strings.Contains(got, want) { - t.Fatalf("buildSystemPrompt(zh) missing %q", want) - } - } -} diff --git a/agent/skill_dag.go b/agent/skill_dag.go index ad026115..a87592e5 100644 --- a/agent/skill_dag.go +++ b/agent/skill_dag.go @@ -53,6 +53,33 @@ func buildSkillDAGRegistry() map[string]SkillDAG { {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true}, }, }, + { + SkillName: "trader_management", + Action: "configure_strategy", + Steps: []SkillDAGStep{ + {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}}, + {ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"strategy_id"}, Next: []string{"execute_update"}}, + {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "strategy_id"}, Terminal: true}, + }, + }, + { + SkillName: "trader_management", + Action: "configure_exchange", + Steps: []SkillDAGStep{ + {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}}, + {ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"exchange_id"}, Next: []string{"execute_update"}}, + {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "exchange_id"}, Terminal: true}, + }, + }, + { + SkillName: "trader_management", + Action: "configure_model", + Steps: []SkillDAGStep{ + {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}}, + {ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id"}, Next: []string{"execute_update"}}, + {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "ai_model_id"}, Terminal: true}, + }, + }, { SkillName: "trader_management", Action: "start", @@ -274,4 +301,3 @@ func listSkillDAGs() []SkillDAG { } return out } - diff --git a/agent/skill_dag_runtime_test.go b/agent/skill_dag_runtime_test.go deleted file mode 100644 index 8085ceee..00000000 --- a/agent/skill_dag_runtime_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package agent - -import "testing" - -func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) { - session := skillSession{Name: "strategy_management", Action: "update_config"} - step, ok := currentSkillDAGStep(session) - if !ok { - t.Fatal("expected dag step") - } - if step.ID != "resolve_target" { - t.Fatalf("expected first step resolve_target, got %s", step.ID) - } -} - -func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) { - session := skillSession{Name: "strategy_management", Action: "update_config"} - setSkillDAGStep(&session, "resolve_config_field") - advanceSkillDAGStep(&session, "resolve_config_field") - step, ok := currentSkillDAGStep(session) - if !ok { - t.Fatal("expected dag step") - } - if step.ID != "resolve_config_value" { - t.Fatalf("expected resolve_config_value, got %s", step.ID) - } -} diff --git a/agent/skill_dag_test.go b/agent/skill_dag_test.go deleted file mode 100644 index 73707474..00000000 --- a/agent/skill_dag_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package agent - -import "testing" - -func TestGetSkillDAGForStructuredActions(t *testing.T) { - tests := []struct { - skill string - action string - }{ - {skill: "trader_management", action: "create"}, - {skill: "trader_management", action: "update_bindings"}, - {skill: "strategy_management", action: "update_config"}, - {skill: "strategy_management", action: "update_prompt"}, - {skill: "model_management", action: "update_status"}, - {skill: "exchange_management", action: "update_name"}, - } - - for _, tt := range tests { - dag, ok := getSkillDAG(tt.skill, tt.action) - if !ok { - t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action) - } - if dag.SkillName != tt.skill || dag.Action != tt.action { - t.Fatalf("unexpected dag identity: %+v", dag) - } - if len(dag.Steps) == 0 { - t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action) - } - } -} - -func TestStructuredDAGsHaveTerminalStep(t *testing.T) { - for _, dag := range listSkillDAGs() { - hasTerminal := false - for _, step := range dag.Steps { - if step.Terminal { - hasTerminal = true - break - } - } - if !hasTerminal { - t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action) - } - } -} - -func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) { - dag, ok := getSkillDAG("strategy_management", "update_config") - if !ok { - t.Fatal("missing strategy update_config dag") - } - if len(dag.Steps) != 6 { - t.Fatalf("expected 6 steps, got %d", len(dag.Steps)) - } - if dag.Steps[0].ID != "resolve_target" { - t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID) - } - if dag.Steps[1].ID != "resolve_config_field" { - t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID) - } - if dag.Steps[2].ID != "resolve_config_value" { - t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID) - } - if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal { - t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5]) - } -} diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go index 96c2a71f..3f1c3e0d 100644 --- a/agent/skill_dispatcher.go +++ b/agent/skill_dispatcher.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "regexp" "strings" "time" ) @@ -34,13 +33,9 @@ type traderSkillOption struct { ID string Name string Enabled bool + Hint string } -var ( - quotedNamePattern = regexp.MustCompile(`[“"]([^“”"]{1,40})[”"]`) - traderNamedPattern = regexp.MustCompile(`(?:叫|名为|名字是)\s*([A-Za-z0-9_\-\p{Han}]{2,40})`) -) - func skillSessionConfigKey(userID int64) string { return fmt.Sprintf("agent_skill_session_%d", userID) } @@ -53,7 +48,7 @@ func normalizeSkillSession(session skillSession) skillSession { if len(session.Fields) > 0 { normalized := make(map[string]string, len(session.Fields)) for key, value := range session.Fields { - key = strings.TrimSpace(key) + key = normalizeFieldKey(&session, key) value = strings.TrimSpace(value) if key == "" || value == "" { continue @@ -67,6 +62,7 @@ func normalizeSkillSession(session skillSession) skillSession { } } if session.Slots != nil { + ensureSkillFields(&session) session.Slots.Name = strings.TrimSpace(session.Slots.Name) session.Slots.ExchangeID = strings.TrimSpace(session.Slots.ExchangeID) session.Slots.ExchangeName = strings.TrimSpace(session.Slots.ExchangeName) @@ -74,11 +70,43 @@ func normalizeSkillSession(session skillSession) skillSession { session.Slots.ModelName = strings.TrimSpace(session.Slots.ModelName) session.Slots.StrategyID = strings.TrimSpace(session.Slots.StrategyID) session.Slots.StrategyName = strings.TrimSpace(session.Slots.StrategyName) - if session.Slots.Name == "" && - session.Slots.ExchangeID == "" && - session.Slots.ModelID == "" && - session.Slots.StrategyID == "" && - session.Slots.AutoStart == nil { + if session.Slots.Name != "" { + session.Fields["name"] = session.Slots.Name + } + if session.Slots.ExchangeID != "" { + session.Fields["exchange_id"] = session.Slots.ExchangeID + } + if session.Slots.ExchangeName != "" { + session.Fields["exchange_name"] = session.Slots.ExchangeName + } + if session.Slots.ModelID != "" { + session.Fields["model_id"] = session.Slots.ModelID + } + if session.Slots.ModelName != "" { + session.Fields["model_name"] = session.Slots.ModelName + } + if session.Slots.StrategyID != "" { + session.Fields["strategy_id"] = session.Slots.StrategyID + } + if session.Slots.StrategyName != "" { + session.Fields["strategy_name"] = session.Slots.StrategyName + } + if session.Slots.AutoStart != nil { + if *session.Slots.AutoStart { + session.Fields["auto_start"] = "true" + } else { + session.Fields["auto_start"] = "false" + } + } + syncTraderCreateSlotMirror(&session) + if fieldValue(session, "name") == "" && + fieldValue(session, "exchange_id") == "" && + fieldValue(session, "model_id") == "" && + fieldValue(session, "strategy_id") == "" && + fieldValue(session, "exchange_name") == "" && + fieldValue(session, "model_name") == "" && + fieldValue(session, "strategy_name") == "" && + fieldValue(session, "auto_start") == "" { session.Slots = nil } } @@ -165,296 +193,28 @@ func isCancelSkillReply(text string) bool { } } -func detectCreateTraderSkill(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - hasCreate := containsAny(lower, []string{"创建", "新建", "建一个", "create", "new"}) - hasTrader := containsAny(lower, []string{"交易员", "trader", "agent"}) - return hasCreate && hasTrader -} - -func detectModelDiagnosisSkill(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if containsAny(lower, []string{"custom_api_url", "invalid custom_api_url", "ai assistant unavailable", "模型配置失败", "模型不可用", "ai unavailable"}) { - return true - } - return containsAny(lower, []string{"模型", "model", "api key", "base url", "custom_api_url"}) && - containsAny(lower, []string{"报错", "错误", "失败", "不可用", "不生效", "invalid", "error", "failed"}) -} - -func detectExchangeDiagnosisSkill(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{ - "invalid signature", "timestamp", "ip not allowed", "permission denied", - "签名错误", "签名失败", "时间戳", "白名单", "权限不足", "交易所 api 报错", "交易所连接不上", - }) -} - -func detectStartIntent(text string) bool { - lower := strings.ToLower(text) - return containsAny(lower, []string{"启动", "跑起来", "run", "start", "立即运行", "并启动"}) -} - -func looksLikeStandaloneValueReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if firstIntegerPattern.MatchString(lower) && len(strings.Fields(lower)) <= 4 { - return true - } - return containsAny(lower, []string{"启用", "禁用", "enable", "disable", "打开", "关闭"}) -} - -func detectImplicitStrategyAction(text string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"prompt", "提示词"}): - return "update_prompt" - case containsAny(lower, []string{"参数", "配置", "置信度", "持仓", "周期", "timeframe", "调到", "改到", "改成", "调整"}): - return "update_config" - default: +func normalizeTraderDraftName(value string) string { + value = strings.TrimSpace(value) + if value == "" { return "" } -} - -func detectImplicitTraderAction(text string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"启动", "开始", "run", "start"}): - return "start" - case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}): - return "stop" - case containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}): - return "update_bindings" - case containsAny(lower, []string{"改名", "重命名", "rename"}): - return "update_name" - default: - return "" - } -} - -func detectImplicitModelAction(text string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"启用", "禁用", "enable", "disable"}): - return "update_status" - case containsAny(lower, []string{"url", "endpoint", "地址", "接口"}): - return "update_endpoint" - case containsAny(lower, []string{"模型名", "模型名称", "model name", "改名", "重命名", "rename"}): - return "update_name" - default: - return "" - } -} - -func detectImplicitExchangeAction(text string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"启用", "禁用", "enable", "disable"}): - return "update_status" - case containsAny(lower, []string{"账户名", "改名", "重命名", "rename"}): - return "update_name" - default: - return "" - } -} - -func (a *Agent) inferContextualSkillSession(storeUserID string, userID int64, text string, session skillSession) skillSession { - if session.Name != "" || strings.TrimSpace(text) == "" { - return session - } - state := a.getExecutionState(userID) - lower := strings.ToLower(strings.TrimSpace(text)) - if state.CurrentReferences != nil { - if ref := state.CurrentReferences.Strategy; ref != nil { - if action := detectImplicitStrategyAction(text); action != "" || looksLikeStandaloneValueReply(text) { - return skillSession{Name: "strategy_management", Action: defaultIfEmpty(action, "update_config"), Phase: "collecting", TargetRef: ref} - } - } - if ref := state.CurrentReferences.Trader; ref != nil { - if action := detectImplicitTraderAction(text); action != "" { - return skillSession{Name: "trader_management", Action: action, Phase: "collecting", TargetRef: ref} - } - } - if ref := state.CurrentReferences.Model; ref != nil { - if action := detectImplicitModelAction(text); action != "" { - return skillSession{Name: "model_management", Action: action, Phase: "collecting", TargetRef: ref} - } - } - if ref := state.CurrentReferences.Exchange; ref != nil { - if action := detectImplicitExchangeAction(text); action != "" { - return skillSession{Name: "exchange_management", Action: action, Phase: "collecting", TargetRef: ref} - } + for _, prefix := range []string{"名称:", "名称:", "名字:", "名字:", "name:", "name:"} { + if strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)) { + value = strings.TrimSpace(value[len(prefix):]) + break } } - if containsAny(lower, []string{"调整参数", "改参数", "改配置"}) { - options := a.loadStrategyOptions(storeUserID) - if len(options) == 1 { - return skillSession{ - Name: "strategy_management", - Action: "update_config", - Phase: "collecting", - TargetRef: &EntityReference{ - ID: options[0].ID, - Name: options[0].Name, - }, - } + for _, sep := range []string{"交易所:", "交易所:", "模型:", "模型:", "策略:", "策略:", "exchange:", "model:", "strategy:"} { + if idx := strings.Index(strings.ToLower(value), strings.ToLower(sep)); idx >= 0 { + value = strings.TrimSpace(value[:idx]) } } - return session -} - -func extractTraderName(text string) string { - text = strings.TrimSpace(text) - if text == "" { - return "" - } - if matches := quotedNamePattern.FindStringSubmatch(text); len(matches) == 2 { - return strings.TrimSpace(matches[1]) - } - if matches := traderNamedPattern.FindStringSubmatch(text); len(matches) == 2 { - return strings.TrimSpace(matches[1]) - } - return "" -} - -func extractSegmentAfterKeywords(text string, keywords []string) string { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return "" - } - lower := strings.ToLower(trimmed) - for _, keyword := range keywords { - idx := strings.Index(lower, strings.ToLower(keyword)) - if idx < 0 { - continue - } - segment := strings.TrimSpace(trimmed[idx+len(keyword):]) - if segment == "" { - continue - } - cut := len(segment) - for i, r := range segment { - switch r { - case ',', ',', '。', ';', ';', '\n', '、': - cut = i - goto done - } - } - done: - segment = strings.TrimSpace(segment[:cut]) - segment = strings.Trim(segment, "“”\"':: ") - if segment != "" { - return segment + for _, sep := range []string{",", ",", "。", ";", ";", "\n"} { + if idx := strings.Index(value, sep); idx >= 0 { + value = strings.TrimSpace(value[:idx]) } } - return "" -} - -func pickMentionedOption(text string, options []traderSkillOption) *traderSkillOption { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return nil - } - bestScore := 0 - var matched *traderSkillOption - for _, option := range options { - id := strings.ToLower(strings.TrimSpace(option.ID)) - name := strings.ToLower(strings.TrimSpace(option.Name)) - if id == "" && name == "" { - continue - } - score := optionMatchScore(lower, id, name) - if score == 0 { - continue - } - if score == bestScore { - matched = nil - continue - } - if score > bestScore { - bestScore = score - copy := option - matched = © - } - } - return matched -} - -func pickOptionFromSegment(text string, keywords []string, options []traderSkillOption) *traderSkillOption { - segment := extractSegmentAfterKeywords(text, keywords) - if strings.TrimSpace(segment) == "" { - return nil - } - return pickMentionedOption(segment, options) -} - -func optionMatchScore(text, id, name string) int { - if id != "" && strings.Contains(text, id) { - return 4 - } - return optionNameMatchScore(text, name) -} - -func optionNameMatchScore(text, name string) int { - name = strings.TrimSpace(strings.ToLower(name)) - if name == "" { - return 0 - } - if strings.Contains(text, name) { - return 3 - } - fields := strings.FieldsFunc(name, func(r rune) bool { - switch r { - case ' ', ',', ',', '/', '|', '、', '(', ')', '(', ')': - return true - default: - return false - } - }) - best := 0 - for _, field := range fields { - field = strings.TrimSpace(field) - if field == "" { - continue - } - if len([]rune(field)) <= 2 && !containsHan(field) { - continue - } - if strings.Contains(text, field) { - if containsHan(field) && len([]rune(field)) >= 3 { - best = max(best, 2) - } else { - best = max(best, 1) - } - } - } - return best -} - -func containsHan(s string) bool { - for _, r := range s { - if r >= 0x4E00 && r <= 0x9FFF { - return true - } - } - return false -} - -func max(a, b int) int { - if a > b { - return a - } - return b + return strings.Trim(value, "“”\"':: ") } func choosePreferredOption(options []traderSkillOption) *traderSkillOption { @@ -560,100 +320,127 @@ func (a *Agent) loadStrategyOptions(storeUserID string) []traderSkillOption { return out } +func (a *Agent) buildTraderCreateConversationResources(storeUserID string, session skillSession) map[string]any { + missing := missingFieldKeysForSkillSession(session) + needExchange := false + needModel := false + needStrategy := false + for _, field := range missing { + switch strings.TrimSpace(field) { + case "exchange_name", "exchange_id", "exchange": + needExchange = true + case "model_name", "model_id", "ai_model_id", "model": + needModel = true + case "strategy_name", "strategy_id", "strategy": + needStrategy = true + } + } + resources := map[string]any{} + if needExchange { + resources["exchanges"] = a.loadExchangeOptions(storeUserID) + } + if needModel { + resources["models"] = a.loadEnabledModelOptions(storeUserID) + } + if needStrategy { + resources["strategies"] = a.loadStrategyOptions(storeUserID) + } + return resources +} + func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool) { if ctx != nil && ctx.Err() != nil { return "", false } - session := a.getSkillSession(userID) - session = a.inferContextualSkillSession(storeUserID, userID, text, session) - if (session.Name == "trader_management" && session.Action == "create") || detectCreateTraderSkill(text) { - answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session) + emptySession := skillSession{} + if hasExplicitCreateIntentForDomain(text, "trader") { + answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, emptySession) if handled { a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:trader_management:create") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } + return answer, true } - return answer, handled } - if (session.Name == "trader_management" && session.Action != "create") || detectTraderManagementIntent(text) { - answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session) + if detectTraderManagementIntent(text) { + answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, emptySession) if handled { a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:trader_management") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } + return answer, true } - return answer, handled } - if session.Name == "exchange_management" || detectExchangeManagementIntent(text) { - answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session) + if detectExchangeManagementIntent(text) { + answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, emptySession) if handled { a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:exchange_management") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } + return answer, true } - return answer, handled } - if session.Name == "model_management" || detectModelManagementIntent(text) { - answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session) + if detectModelManagementIntent(text) { + answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, emptySession) if handled { a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:model_management") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } + return answer, true } - return answer, handled } - if session.Name == "strategy_management" || detectStrategyManagementIntent(text) { - answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session) + if detectStrategyManagementIntent(text) { + answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, emptySession) if handled { a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:strategy_management") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } + return answer, true } - return answer, handled } - if detectModelDiagnosisSkill(text) { + if hasExplicitDiagnosisIntentForDomain(text, "model") { answer := a.handleModelDiagnosisSkill(storeUserID, lang, text) a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:model_diagnosis") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true } - if detectExchangeDiagnosisSkill(text) { + if hasExplicitDiagnosisIntentForDomain(text, "exchange") { answer := a.handleExchangeDiagnosisSkill(storeUserID, lang, text) a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:exchange_diagnosis") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true } - if detectTraderDiagnosisSkill(text) { + if hasExplicitDiagnosisIntentForDomain(text, "trader") { answer := a.handleTraderDiagnosisSkill(storeUserID, lang, text) a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:trader_diagnosis") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true } - if detectStrategyDiagnosisSkill(text) { + if hasExplicitDiagnosisIntentForDomain(text, "strategy") { answer := a.handleStrategyDiagnosisSkill(storeUserID, lang, text) a.recordSkillInteraction(userID, text, answer) if onEvent != nil { onEvent(StreamEventTool, "hard_skill:strategy_diagnosis") - onEvent(StreamEventDelta, answer) + emitStreamText(onEvent, answer) } return answer, true } @@ -662,12 +449,29 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int func (a *Agent) recordSkillInteraction(userID int64, userText, answer string) { if a.history == nil { - a.history = newChatHistory(100) + a.history = newChatHistory(chatHistoryMaxTurns) } a.history.Add(userID, "user", userText) a.history.Add(userID, "assistant", answer) } +func (a *Agent) rerouteRejectedSkillFlow(ctx context.Context, storeUserID string, userID int64, lang, text string) (string, bool) { + a.clearSkillSession(userID) + if a == nil || a.aiClient == nil { + return "", false + } + if answer, handled, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, nil); err == nil && handled { + return answer, true + } + if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, nil); ok { + return answer, true + } + if answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, nil); err == nil && strings.TrimSpace(answer) != "" { + return answer, true + } + return "", false +} + func ensureSkillFields(session *skillSession) { if session.Fields == nil { session.Fields = make(map[string]string) @@ -675,223 +479,125 @@ func ensureSkillFields(session *skillSession) { } func (a *Agent) handleCreateTraderSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - if isCancelSkillReply(text) { + if session.Name == "" { + session = skillSession{ + Name: "trader_management", + Action: "create", + Phase: "collecting", + Fields: map[string]string{}, + } + } + if session.Fields == nil { + session.Fields = map[string]string{} + } + syncTraderCreateSlotMirror(&session) + + if session.Phase == "await_start_confirmation" { + switch { + case isYesReply(text): + return a.executeCreateTraderSkill(storeUserID, userID, lang, session, true), true + case isNoReply(text): + return a.executeCreateTraderSkill(storeUserID, userID, lang, session, false), true + } + } + if session.Phase == "await_create_confirmation" { + switch { + case isYesReply(text): + return a.executeCreateTraderSkill(storeUserID, userID, lang, session, false), true + case isNoReply(text), isCancelSkillReply(text): + session.Phase = "collecting" + a.saveSkillSession(userID, session) + if lang == "zh" { + return "好的,那我先不创建。你也可以继续改名称、交易所、模型或策略。", true + } + return "Okay, I won't create it yet. You can keep adjusting the name, exchange, model, or strategy.", true + } + } + + availableResources := a.buildTraderCreateConversationResources(storeUserID, session) + result := skillConversationResult{} + if a != nil && a.aiClient != nil { + result = a.llmSkillConversationDriver(context.Background(), storeUserID, userID, lang, text, session, availableResources) + } else { + result = a.fallbackTraderCreateConversation(storeUserID, lang, text, session, availableResources) + } + if !result.Cancel && !result.UserRejectedFlow && !result.Ready && result.Question == "" && a != nil && a.aiClient != nil { + if extraction := a.extractSkillSessionFieldsWithLLM(context.Background(), userID, lang, text, session); extraction.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, extraction, lang, text) + } + } + + if result.Cancel { a.clearSkillSession(userID) if lang == "zh" { return "已取消当前创建交易员流程。", true } return "Cancelled the current trader creation flow.", true } - - if session.Name == "" { - session = skillSession{ - Name: "trader_management", - Action: "create", - Phase: "collecting", - Slots: &createTraderSkillSlots{}, - } - if detectStartIntent(text) { - autoStart := true - session.Slots.AutoStart = &autoStart - } - } - if session.Slots == nil { - session.Slots = &createTraderSkillSlots{} - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "resolve_name") + if result.UserRejectedFlow { + return a.rerouteRejectedSkillFlow(context.Background(), storeUserID, userID, lang, text) } - if session.Phase == "await_start_confirmation" { - setSkillDAGStep(&session, "await_start_confirmation") - switch { - case isYesReply(text): - answer := a.executeCreateTraderSkill(storeUserID, userID, lang, session, true) - return answer, true - case isNoReply(text): - answer := a.executeCreateTraderSkill(storeUserID, userID, lang, session, false) - return answer, true - default: + for k, v := range result.Extracted { + v = strings.TrimSpace(v) + if v == "" { + continue + } + setField(&session, k, v) + } + + a.hydrateCreateTraderSlotReferences(storeUserID, &session) + if fieldValue(session, "exchange_id") != "" && fieldValue(session, "model_id") != "" && fieldValue(session, "strategy_id") != "" { + if err := a.validateTraderDraft(storeUserID, fieldValue(session, "model_id"), fieldValue(session, "exchange_id"), fieldValue(session, "strategy_id")); err != nil { + session.Phase = "collecting" a.saveSkillSession(userID, session) - if lang == "zh" { - return "当前流程在等待你确认是否立即启动交易员。回复“确认”继续启动,回复“先不用”则只创建不启动。", true - } - return "This flow is waiting for your confirmation to start the trader. Reply 'confirm' to start it now, or 'no' to create without starting.", true + return formatValidationFeedback(lang, "trader", err), 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 + } - slots := session.Slots - if slots.Name == "" { - slots.Name = extractTraderName(text) - } - if slots.Name != "" { - setSkillDAGStep(&session, "resolve_exchange") - } - - models := a.loadEnabledModelOptions(storeUserID) - exchanges := a.loadExchangeOptions(storeUserID) - strategies := a.loadStrategyOptions(storeUserID) - - if slots.ModelID == "" { - if match := pickOptionFromSegment(text, []string{"模型用", "模型", "model"}, models); match != nil { - slots.ModelID = match.ID - slots.ModelName = match.Name - } else if match := pickMentionedOption(text, models); match != nil { - slots.ModelID = match.ID - slots.ModelName = match.Name - } else if choice := choosePreferredOption(models); choice != nil { - slots.ModelID = choice.ID - slots.ModelName = choice.Name - } - } - if slots.ExchangeID != "" { - setSkillDAGStep(&session, "resolve_model") - } - if slots.ExchangeID == "" { - if match := pickOptionFromSegment(text, []string{"交易所用", "交易所", "exchange"}, exchanges); match != nil { - if match.Enabled { - slots.ExchangeID = match.ID - slots.ExchangeName = match.Name - } else { - if lang == "zh" { - extra := "你刚才提到的交易所“" + defaultIfEmpty(match.Name, match.ID) + "”当前已禁用,请换一个已启用的交易所。" - a.saveSkillSession(userID, session) - return extra + "\n" + formatOptionList("可用交易所:", exchanges), true - } - a.saveSkillSession(userID, session) - return "The exchange you mentioned is currently disabled. Please choose an enabled exchange.\n" + formatOptionList("Available exchanges:", exchanges), true - } - } else if match := pickMentionedOption(text, exchanges); match != nil { - if match.Enabled { - slots.ExchangeID = match.ID - slots.ExchangeName = match.Name - } else { - if lang == "zh" { - extra := "你刚才提到的交易所“" + defaultIfEmpty(match.Name, match.ID) + "”当前已禁用,请换一个已启用的交易所。" - a.saveSkillSession(userID, session) - return extra + "\n" + formatOptionList("可用交易所:", exchanges), true - } - a.saveSkillSession(userID, session) - return "The exchange you mentioned is currently disabled. Please choose an enabled exchange.\n" + formatOptionList("Available exchanges:", exchanges), true - } - } else if choice := choosePreferredOption(exchanges); choice != nil { - slots.ExchangeID = choice.ID - slots.ExchangeName = choice.Name - } - } - if slots.StrategyID == "" { - if match := pickOptionFromSegment(text, []string{"策略用", "策略", "strategy"}, strategies); match != nil { - slots.StrategyID = match.ID - slots.StrategyName = match.Name - } else if match := pickMentionedOption(text, strategies); match != nil { - slots.StrategyID = match.ID - slots.StrategyName = match.Name - } else if choice := choosePreferredOption(strategies); choice != nil { - slots.StrategyID = choice.ID - slots.StrategyName = choice.Name - } - } - if slots.ModelID != "" { - setSkillDAGStep(&session, "resolve_strategy") - } - if slots.StrategyID != "" { - setSkillDAGStep(&session, "maybe_confirm_start") - } - - if slots.AutoStart == nil && detectStartIntent(text) { - autoStart := true - slots.AutoStart = &autoStart - } - - missing := make([]string, 0, 3) - extraLines := make([]string, 0, 3) - if actionRequiresSlot("trader_management", "create", "name") && slots.Name == "" { - missing = append(missing, slotDisplayName("name", lang)) - } - if actionRequiresSlot("trader_management", "create", "exchange") && slots.ExchangeID == "" { - missing = append(missing, slotDisplayName("exchange", lang)) - if len(exchanges) == 0 { - if lang == "zh" { - extraLines = append(extraLines, "当前还没有可用交易所配置,请先配置并启用一个交易所账户。") - } else { - extraLines = append(extraLines, "There is no enabled exchange config yet. Please create and enable one first.") - } - } else { - label := "Available exchanges:" - if lang == "zh" { - label = "可用交易所:" - } - extraLines = append(extraLines, formatOptionList(label, exchanges)) - } - } - if actionRequiresSlot("trader_management", "create", "model") && slots.ModelID == "" { - missing = append(missing, slotDisplayName("model", lang)) - if len(models) == 0 { - if lang == "zh" { - extraLines = append(extraLines, "当前还没有可用模型配置,请先配置并启用一个模型。") - } else { - extraLines = append(extraLines, "There is no enabled model config yet. Please create and enable one first.") - } - } else { - label := "Available models:" - if lang == "zh" { - label = "可用模型:" - } - extraLines = append(extraLines, formatOptionList(label, models)) - } - } - if slots.StrategyID == "" && (actionRequiresSlot("trader_management", "create", "strategy") || len(strategies) == 0) { - missing = append(missing, slotDisplayName("strategy", lang)) - } - if slots.StrategyID == "" { - if len(strategies) == 0 { - if lang == "zh" { - extraLines = append(extraLines, "当前还没有可用策略,请先创建一个策略。") - } else { - extraLines = append(extraLines, "There is no strategy available yet. Please create one first.") - } - } else { - label := "Available strategies:" - if lang == "zh" { - label = "可用策略:" - } - extraLines = append(extraLines, formatOptionList(label, strategies)) - } - } - - if len(missing) > 0 { + if !result.Ready { session.Phase = "collecting" a.saveSkillSession(userID, session) - if lang == "zh" { - reply := "要继续创建交易员,还缺这些信息:" + strings.Join(missing, "、") + "。" - if len(extraLines) > 0 { - reply += "\n" + strings.Join(cleanStringList(extraLines), "\n") - } - reply += "\n你可以直接一次性告诉我,例如:名称、用哪个交易所、哪个模型、哪个策略。" - return reply, true + if result.Question != "" { + return result.Question, true } - reply := "To continue creating the trader, I still need: " + strings.Join(missing, ", ") + "." - if len(extraLines) > 0 { - reply += "\n" + strings.Join(cleanStringList(extraLines), "\n") - } - reply += "\nYou can reply with all missing fields in one message." - return reply, true + return "", false } - if slots.AutoStart != nil && *slots.AutoStart { + 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 + } + + if fieldValue(session, "auto_start") == "true" { session.Phase = "await_start_confirmation" - setSkillDAGStep(&session, "await_start_confirmation") a.saveSkillSession(userID, session) if lang == "zh" { - return fmt.Sprintf("我已经准备好创建交易员“%s”,并在创建后立即启动它。\n使用的交易所:%s\n使用的模型:%s\n使用的策略:%s\n\n这是高风险动作。回复“确认”继续,回复“先不用”则只创建不启动。", - slots.Name, slots.ExchangeNameOrID(), slots.ModelNameOrID(), slots.StrategyNameOrID()), true + return fmt.Sprintf("准备创建交易员并立即启动。\n交易所:%s\n模型:%s\n策略:%s\n\n回复确认继续,回复先不用则只创建不启动。", + traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)), true } - return fmt.Sprintf("I'm ready to create trader %q and start it immediately.\nExchange: %s\nModel: %s\nStrategy: %s\n\nThis is a high-risk action. Reply 'confirm' to continue, or 'no' to create it without starting.", - slots.Name, slots.ExchangeNameOrID(), slots.ModelNameOrID(), slots.StrategyNameOrID()), true + return fmt.Sprintf("Ready to create trader and start it immediately.\nExchange: %s\nModel: %s\nStrategy: %s\n\nReply confirm to continue, or no to create without starting.", + traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)), true } - answer := a.executeCreateTraderSkill(storeUserID, userID, lang, session, false) - return answer, true + session.Phase = "await_create_confirmation" + a.saveSkillSession(userID, session) + return formatTraderCreateDraftSummary(lang, session), true } func (s *createTraderSkillSlots) ExchangeNameOrID() string { @@ -915,28 +621,159 @@ func (s *createTraderSkillSlots) StrategyNameOrID() string { return s.StrategyID } +func traderCreateExchangeNameOrID(session skillSession) string { + if value := fieldValue(session, "exchange_name"); value != "" { + return value + } + return fieldValue(session, "exchange_id") +} + +func traderCreateModelNameOrID(session skillSession) string { + if value := fieldValue(session, "model_name"); value != "" { + return value + } + return fieldValue(session, "model_id") +} + +func traderCreateStrategyNameOrID(session skillSession) string { + if value := fieldValue(session, "strategy_name"); value != "" { + return value + } + return fieldValue(session, "strategy_id") +} + +func renderSkillMissingLabels(lang string, missing []string) []string { + out := make([]string, 0, len(missing)) + for _, field := range missing { + out = append(out, slotDisplayName(field, lang)) + } + return out +} + +func (a *Agent) fallbackTraderCreateConversation(storeUserID, lang, text string, session skillSession, availableResources map[string]any) skillConversationResult { + result := skillConversationResult{Extracted: map[string]string{}} + text = strings.TrimSpace(text) + if text == "" { + result.Question = a.buildTraderCreateMissingPrompt(storeUserID, lang, session, availableResources) + return result + } + if isCancelSkillReply(text) { + result.Cancel = true + return result + } + probe := session + for k, v := range result.Extracted { + setField(&probe, k, v) + } + a.hydrateCreateTraderSlotReferences(storeUserID, &probe) + if missing := missingFieldKeysForSkillSession(probe); len(missing) > 0 { + result.Question = a.buildTraderCreateMissingPrompt(storeUserID, lang, probe, a.buildTraderCreateConversationResources(storeUserID, probe)) + return result + } + result.Ready = true + result.Question = formatTraderCreateDraftSummary(lang, probe) + return result +} + +func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session skillSession, availableResources map[string]any) string { + missing := missingFieldKeysForSkillSession(session) + missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、") + prereqs := 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") + } + } + if models, _ := availableResources["models"].([]traderSkillOption); len(models) == 0 && containsString(missing, "model_name") { + if lang == "zh" { + prereqs = append(prereqs, "当前还没有可用模型配置") + } else { + prereqs = append(prereqs, "there is no model config yet") + } + } + if strategies, _ := availableResources["strategies"].([]traderSkillOption); len(strategies) == 0 && containsString(missing, "strategy_name") { + if lang == "zh" { + prereqs = append(prereqs, "当前还没有可用策略") + } else { + prereqs = append(prereqs, "there is no strategy yet") + } + } + if lang == "zh" { + reply := "还缺这些信息:" + missingLabels + "。" + if len(prereqs) > 0 { + reply += "\n" + strings.Join(prereqs, ";") + "。" + } + return reply + } + reply := "Still missing: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "." + if len(prereqs) > 0 { + reply += "\n" + strings.Join(prereqs, "; ") + "." + } + return reply +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func shouldPreserveTraderCreateSessionOnError(errMsg string) bool { + lower := strings.ToLower(strings.TrimSpace(errMsg)) + if lower == "" { + return false + } + return strings.Contains(lower, "exchange is disabled") || + strings.Contains(lower, "exchange_id is required") || + strings.Contains(lower, "model_id is required") || + strings.Contains(lower, "strategy_id is required") +} + func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang string, session skillSession, startAfterCreate bool) string { + a.hydrateCreateTraderSlotReferences(storeUserID, &session) + normalizedArgs, _ := normalizeTraderArgsToManualLimits(lang, buildTraderUpdateArgsFromSession(session)) args := manageTraderArgs{ - Action: "create", - Name: session.Slots.Name, - AIModelID: session.Slots.ModelID, - ExchangeID: session.Slots.ExchangeID, - StrategyID: session.Slots.StrategyID, + 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, } createRaw := a.toolCreateTrader(storeUserID, args) if errMsg := parseSkillError(createRaw); errMsg != "" && strings.Contains(createRaw, `"error"`) { - session.Phase = "collecting" - a.saveSkillSession(userID, session) + if shouldPreserveTraderCreateSessionOnError(errMsg) { + session.Phase = "collecting" + a.saveSkillSession(userID, session) + } else { + a.clearSkillSession(userID) + } if strings.Contains(strings.ToLower(errMsg), "exchange is disabled") { exchanges := a.loadExchangeOptions(storeUserID) if lang == "zh" { - reply := fmt.Sprintf("创建交易员失败:你选的交易所“%s”当前已禁用,请换一个已启用的交易所。", session.Slots.ExchangeNameOrID()) + reply := fmt.Sprintf("创建交易员失败:你选的交易所“%s”当前已禁用,请换一个已启用的交易所。", traderCreateExchangeNameOrID(session)) if list := formatOptionList("可用交易所:", exchanges); list != "" { reply += "\n" + list } return reply } - reply := fmt.Sprintf("Failed to create trader: the selected exchange %q is disabled. Please choose an enabled exchange.", session.Slots.ExchangeNameOrID()) + reply := fmt.Sprintf("That trader could not be created because the exchange %q is turned off. Please choose one that is enabled.", traderCreateExchangeNameOrID(session)) if list := formatOptionList("Available exchanges:", exchanges); list != "" { reply += "\n" + list } @@ -945,7 +782,7 @@ func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang if lang == "zh" { return "创建交易员失败:" + errMsg } - return "Failed to create trader: " + errMsg + return "That create request did not go through: " + errMsg } var created struct { Trader safeTraderToolConfig `json:"trader"` @@ -963,10 +800,10 @@ func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang a.clearSkillSession(userID) if lang == "zh" { return fmt.Sprintf("已创建交易员“%s”。\n交易所:%s\n模型:%s\n策略:%s\n当前状态:未启动。", - created.Trader.Name, session.Slots.ExchangeNameOrID(), session.Slots.ModelNameOrID(), session.Slots.StrategyNameOrID()) + created.Trader.Name, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) } return fmt.Sprintf("Created trader %q.\nExchange: %s\nModel: %s\nStrategy: %s\nCurrent status: not started.", - created.Trader.Name, session.Slots.ExchangeNameOrID(), session.Slots.ModelNameOrID(), session.Slots.StrategyNameOrID()) + created.Trader.Name, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) } setSkillDAGStep(&session, "execute_create_and_start") @@ -982,10 +819,10 @@ func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang a.clearSkillSession(userID) if lang == "zh" { return fmt.Sprintf("已创建并启动交易员“%s”。\n交易所:%s\n模型:%s\n策略:%s", - created.Trader.Name, session.Slots.ExchangeNameOrID(), session.Slots.ModelNameOrID(), session.Slots.StrategyNameOrID()) + created.Trader.Name, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) } return fmt.Sprintf("Created and started trader %q.\nExchange: %s\nModel: %s\nStrategy: %s", - created.Trader.Name, session.Slots.ExchangeNameOrID(), session.Slots.ModelNameOrID(), session.Slots.StrategyNameOrID()) + created.Trader.Name, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) } func (a *Agent) handleModelDiagnosisSkill(storeUserID, lang, text string) string { @@ -1125,3 +962,235 @@ func backendLogDiagnosisExcerpt(lang, text, fallbackFilter string) string { } return "Recent matching backend error logs:\n- " + strings.Join(entries, "\n- ") } + +type targetResolution struct { + Ref *EntityReference + Ambiguous []traderSkillOption + WasMentioned bool +} + +func enabledTraderSkillOptions(options []traderSkillOption) []traderSkillOption { + out := make([]traderSkillOption, 0, len(options)) + for _, o := range options { + if o.Enabled { + out = append(out, o) + } + } + return out +} + +func resolveSemanticExistingTraderDependency(currentRef *EntityReference, options []traderSkillOption) targetResolution { + if currentRef != nil && strings.TrimSpace(currentRef.ID) != "" { + for _, opt := range options { + if opt.ID == currentRef.ID { + return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name}} + } + } + } + enabled := enabledTraderSkillOptions(options) + if len(enabled) == 1 { + return targetResolution{Ref: &EntityReference{ID: enabled[0].ID, Name: enabled[0].Name}} + } + if len(enabled) > 1 { + return targetResolution{Ambiguous: enabled} + } + return targetResolution{} +} + +func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *skillSession) { + if session == nil { + return + } + if fieldValue(*session, "exchange_id") == "" && fieldValue(*session, "exchange_name") != "" { + options := a.loadExchangeOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "exchange_name")); opt != nil { + setField(session, "exchange_id", opt.ID) + } else if opt := findUniqueContainingOption(options, fieldValue(*session, "exchange_name")); opt != nil { + setField(session, "exchange_id", opt.ID) + } + } + if fieldValue(*session, "model_id") == "" && fieldValue(*session, "model_name") != "" { + options := a.loadEnabledModelOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "model_name")); opt != nil { + setField(session, "model_id", opt.ID) + } else if opt := findUniqueContainingOption(options, fieldValue(*session, "model_name")); opt != nil { + setField(session, "model_id", opt.ID) + } + } + if fieldValue(*session, "strategy_id") == "" && fieldValue(*session, "strategy_name") != "" { + options := a.loadStrategyOptions(storeUserID) + if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_name")); opt != nil { + setField(session, "strategy_id", opt.ID) + } else if opt := findUniqueContainingOption(options, fieldValue(*session, "strategy_name")); opt != nil { + setField(session, "strategy_id", opt.ID) + } + } +} + +func (a *Agent) maybeResumeParentTaskAfterSuccessfulSkill(storeUserID string, userID int64, lang, skill, action, answer string) string { + sm := a.SnapshotManager(userID) + parent, ok := sm.Peek() + if !ok || !parent.ResumeOnSuccess { + return answer + } + triggered := false + for _, t := range parent.ResumeTriggers { + if t == skill { + triggered = true + break + } + } + if !triggered { + return answer + } + sm.Load() // pop + // restore parent history + if a.history != nil && len(parent.LocalHistory) > 0 { + a.history.Replace(userID, parent.LocalHistory) + } + // inject child result as system message + if a.history != nil && strings.TrimSpace(answer) != "" { + inject := fmt.Sprintf("[子任务 %s/%s 已完成,结果:%s]", skill, action, answer) + a.history.Add(userID, "system", inject) + } + // restore parent skill session + if parent.SkillSession != nil { + restored := *parent.SkillSession + a.hydrateCreateTraderSlotReferences(storeUserID, &restored) + a.saveSkillSession(userID, restored) + resumeNotice := "" + if lang == "zh" { + resumeNotice = "我已经切回刚才的主任务。" + } else { + resumeNotice = "I switched back to the earlier main task." + } + if restored.Name == "trader_management" && restored.Action == "create" { + followup := a.buildTraderCreateMissingPrompt(storeUserID, lang, restored, a.buildTraderCreateConversationResources(storeUserID, restored)) + if strings.TrimSpace(followup) != "" { + if strings.TrimSpace(answer) == "" { + return resumeNotice + "\n" + followup + } + return strings.TrimSpace(answer) + "\n" + resumeNotice + "\n" + followup + } + } + if strings.TrimSpace(answer) == "" { + return resumeNotice + } + return strings.TrimSpace(answer) + "\n" + resumeNotice + } + return answer +} + +func resolveTargetSelection(text string, options []traderSkillOption, existing *EntityReference) targetResolution { + if existing != nil && strings.TrimSpace(existing.ID) != "" { + for _, opt := range options { + if opt.ID == existing.ID { + return targetResolution{Ref: existing} + } + } + } + if len(options) == 1 { + return targetResolution{Ref: &EntityReference{ID: options[0].ID, Name: options[0].Name}} + } + if len(options) > 1 { + return targetResolution{Ambiguous: options} + } + return targetResolution{} +} + +func findOptionByIDOrName(options []traderSkillOption, query string) *traderSkillOption { + query = strings.TrimSpace(query) + if query == "" { + return nil + } + for i, opt := range options { + if opt.ID == query || strings.EqualFold(opt.Name, query) { + return &options[i] + } + } + return nil +} + +func findUniqueContainingOption(options []traderSkillOption, query string) *traderSkillOption { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return nil + } + matches := make([]traderSkillOption, 0, 1) + for _, opt := range options { + if strings.Contains(strings.ToLower(opt.Name), query) || strings.Contains(query, strings.ToLower(opt.Name)) { + matches = append(matches, opt) + } + } + if len(matches) != 1 { + return nil + } + return &matches[0] +} + +func formatAmbiguousTargetPrompt(lang string, options []traderSkillOption) string { + if duplicateName, ok := sharedAmbiguousOptionName(options); ok { + if lang == "zh" { + return fmt.Sprintf("你提到的是“%s”,但当前有 %d 个同名对象。请告诉我你要操作哪一个。\n%s", duplicateName, len(options), formatDisambiguationOptionList("可选对象:", options)) + } + return fmt.Sprintf("You mentioned %q, but there are %d objects with the same name. Please tell me which one to operate on.\n%s", duplicateName, len(options), formatDisambiguationOptionList("Available targets:", options)) + } + if lang == "zh" { + return "找到多个匹配对象,请告诉我你要操作哪一个。\n" + formatDisambiguationOptionList("可选对象:", options) + } + return "Multiple matches found. Please tell me which one to operate on.\n" + formatDisambiguationOptionList("Available targets:", options) +} + +func sharedAmbiguousOptionName(options []traderSkillOption) (string, bool) { + if len(options) < 2 { + return "", false + } + base := strings.TrimSpace(options[0].Name) + if base == "" { + return "", false + } + for _, option := range options[1:] { + if !strings.EqualFold(strings.TrimSpace(option.Name), base) { + return "", false + } + } + return base, true +} + +func formatDisambiguationOptionList(prefix string, options []traderSkillOption) string { + parts := make([]string, 0, len(options)) + for _, option := range options { + label := strings.TrimSpace(option.Name) + if label == "" { + label = option.ID + } + if hint := strings.TrimSpace(option.Hint); hint != "" { + label += "(" + hint + ")" + } + if suffix := shortOptionIDSuffix(option.ID); suffix != "" { + label += fmt.Sprintf("(ID后缀 %s)", suffix) + } + if option.Enabled { + label += "(已启用)" + } else { + label += "(已禁用)" + } + parts = append(parts, label) + } + if len(parts) == 0 { + return "" + } + return prefix + strings.Join(parts, "、") +} + +func shortOptionIDSuffix(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + runes := []rune(id) + if len(runes) <= 4 { + return id + } + return string(runes[len(runes)-4:]) +} diff --git a/agent/skill_dispatcher_test.go b/agent/skill_dispatcher_test.go deleted file mode 100644 index bb292156..00000000 --- a/agent/skill_dispatcher_test.go +++ /dev/null @@ -1,828 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - "nofx/mcp" -) - -func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) { - a := newTestAgentWithStore(t) - - modelResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - if strings.Contains(modelResp, `"error"`) { - t.Fatalf("failed to create model: %s", modelResp) - } - exchangeResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"主账户", - "enabled":true - }`) - if strings.Contains(exchangeResp, `"error"`) { - t.Fatalf("failed to create exchange: %s", exchangeResp) - } - strategyResp := a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"趋势策略", - "lang":"zh" - }`) - if strings.Contains(strategyResp, `"error"`) { - t.Fatalf("failed to create strategy: %s", strategyResp) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") { - t.Fatalf("expected missing-field prompt, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号") - if err != nil { - t.Fatalf("thinkAndAct() second turn error = %v", err) - } - if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") { - t.Fatalf("expected trader creation confirmation, got %q", resp) - } - - listResp := a.toolListTraders("user-1") - if !strings.Contains(listResp, "波段一号") { - t.Fatalf("expected created trader in list, got %s", listResp) - } -} - -func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - for _, want := range []string{"名称", "交易所", "模型", "策略"} { - if !strings.Contains(resp, want) { - t.Fatalf("expected response to mention %q, got %q", want, resp) - } - } - for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} { - if !strings.Contains(resp, want) { - t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp) - } - } -} - -func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) { - a := newTestAgentWithStore(t) - - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"测试策略", - "lang":"zh" - }`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "还缺这些信息") { - t.Fatalf("expected trader creation flow prompt, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略") - if err != nil { - t.Fatalf("thinkAndAct() interrupt error = %v", err) - } - if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") { - t.Fatalf("expected new topic to be handled, got %q", resp) - } - if a.hasActiveSkillSession(13) { - t.Fatal("expected skill session to be cleared after interruption") - } -} - -func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) { - a := newTestAgentWithStore(t) - - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5" - }`) - _ = a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Main", - "enabled":true - }`) - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"保守策略", - "lang":"zh" - }`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") { - t.Fatalf("expected start confirmation prompt, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用") - if err != nil { - t.Fatalf("thinkAndAct() confirmation error = %v", err) - } - if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") { - t.Fatalf("expected create-without-start response, got %q", resp) - } -} - -func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) { - a := newTestAgentWithStore(t) - resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "模型配置") { - t.Fatalf("expected model diagnosis response, got %q", resp) - } -} - -func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) { - a := newTestAgentWithStore(t) - resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") { - t.Fatalf("expected exchange diagnosis response, got %q", resp) - } -} - -func TestExchangeManagementCreateAndQuerySkill(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "已创建交易所配置") { - t.Fatalf("expected exchange create response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置") - if err != nil { - t.Fatalf("thinkAndAct() query error = %v", err) - } - if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") { - t.Fatalf("expected exchange query response, got %q", resp) - } -} - -func TestModelManagementCreateSkill(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置") - if err != nil { - t.Fatalf("thinkAndAct() error = %v", err) - } - if !strings.Contains(resp, "已创建模型配置") { - t.Fatalf("expected model create response, got %q", resp) - } -} - -func TestStrategyManagementCreateAndActivateSkill(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略") - if err != nil { - t.Fatalf("thinkAndAct() create error = %v", err) - } - if !strings.Contains(resp, "已创建策略") { - t.Fatalf("expected strategy create response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B") - if err != nil { - t.Fatalf("thinkAndAct() activate error = %v", err) - } - if !strings.Contains(resp, "已激活策略") { - t.Fatalf("expected strategy activate response, got %q", resp) - } -} - -func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略") - if err != nil { - t.Fatalf("thinkAndAct() create error = %v", err) - } - if !strings.Contains(resp, "已创建策略") { - t.Fatalf("expected strategy create response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的") - if err != nil { - t.Fatalf("thinkAndAct() detail query error = %v", err) - } - for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} { - if !strings.Contains(resp, want) { - t.Fatalf("expected response to mention %q, got %q", want, resp) - } - } -} - -func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) { - a := newTestAgentWithStore(t) - - modelResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5" - }`) - var modelCreated struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil { - t.Fatalf("unmarshal model response: %v", err) - } - - exchangeResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Main", - "enabled":true - }`) - var exchangeCreated struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil { - t.Fatalf("unmarshal exchange response: %v", err) - } - _ = a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"测试策略", - "lang":"zh" - }`) - _ = a.toolManageTrader("user-1", `{ - "action":"create", - "name":"测试交易员", - "ai_model_id":"`+modelCreated.Model.ID+`", - "exchange_id":"`+exchangeCreated.Exchange.ID+`", - "strategy_id":"" - }`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员") - if err != nil { - t.Fatalf("thinkAndAct() query error = %v", err) - } - if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") { - t.Fatalf("expected trader query response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易") - if err != nil { - t.Fatalf("thinkAndAct() diagnosis error = %v", err) - } - if !strings.Contains(resp, "交易员运行诊断") { - t.Fatalf("expected trader diagnosis response, got %q", resp) - } -} - -func TestExchangeManagementAtomicUpdates(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"主账户", - "enabled":true - }`) - var created struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal exchange response: %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户") - if err != nil { - t.Fatalf("rename exchange error = %v", err) - } - if !strings.Contains(resp, "已更新交易所配置") { - t.Fatalf("expected exchange update response, got %q", resp) - } - - raw := a.toolGetExchangeConfigs("user-1") - if !strings.Contains(raw, "备用账户") { - t.Fatalf("expected renamed exchange in list, got %s", raw) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置") - if err != nil { - t.Fatalf("disable exchange error = %v", err) - } - if !strings.Contains(resp, "已更新交易所配置") { - t.Fatalf("expected exchange status update response, got %q", resp) - } - - raw = a.toolGetExchangeConfigs("user-1") - if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") { - t.Fatalf("expected exchange to be disabled, got %s", raw) - } -} - -func TestModelManagementAtomicUpdates(t *testing.T) { - a := newTestAgentWithStore(t) - - createResp := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - var created struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(createResp), &created); err != nil { - t.Fatalf("unmarshal model response: %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner") - if err != nil { - t.Fatalf("rename model error = %v", err) - } - if !strings.Contains(resp, "已更新模型配置") { - t.Fatalf("expected model update response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta") - if err != nil { - t.Fatalf("update model endpoint error = %v", err) - } - if !strings.Contains(resp, "已更新模型配置") { - t.Fatalf("expected model endpoint update response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置") - if err != nil { - t.Fatalf("disable model error = %v", err) - } - if !strings.Contains(resp, "已更新模型配置") { - t.Fatalf("expected model status update response, got %q", resp) - } - - raw := a.toolGetModelConfigs("user-1") - if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") { - t.Fatalf("expected updated model fields, got %s", raw) - } - if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) { - t.Fatalf("expected model to be disabled, got %s", raw) - } -} - -func TestStrategyManagementAtomicUpdates(t *testing.T) { - a := newTestAgentWithStore(t) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略") - if err != nil { - t.Fatalf("create strategy error = %v", err) - } - if !strings.Contains(resp, "已创建策略") { - t.Fatalf("expected strategy create response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt,把提示词改成“优先观察BTC和ETH,信号不一致时不要开仓”") - if err != nil { - t.Fatalf("update strategy prompt error = %v", err) - } - if !strings.Contains(resp, "已更新策略 prompt") { - t.Fatalf("expected strategy prompt update response, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数,把最大持仓改成2,最低置信度改成80,主周期改成15m,并使用15m 1h 4h") - if err != nil { - t.Fatalf("update strategy config error = %v", err) - } - if !strings.Contains(resp, "已更新策略参数") { - t.Fatalf("expected strategy config update response, got %q", resp) - } - - listRaw := a.toolGetStrategies("user-1") - if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) { - t.Fatalf("expected updated strategy config, got %s", listRaw) - } -} - -func TestTraderManagementAtomicBindingUpdate(t *testing.T) { - a := newTestAgentWithStore(t) - - modelOpenAI := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"openai", - "enabled":true, - "custom_api_url":"https://api.openai.com/v1", - "custom_model_name":"gpt-5-mini" - }`) - var openAI struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil { - t.Fatalf("unmarshal openai model: %v", err) - } - modelDeepSeek := a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - var deepSeek struct { - Model safeModelToolConfig `json:"model"` - } - if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil { - t.Fatalf("unmarshal deepseek model: %v", err) - } - - exchangeBinance := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"binance", - "account_name":"Binance 主账户", - "enabled":true - }`) - var binance struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil { - t.Fatalf("unmarshal binance exchange: %v", err) - } - exchangeOKX := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"OKX 主账户", - "enabled":true - }`) - var okx struct { - Exchange safeExchangeToolConfig `json:"exchange"` - } - if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil { - t.Fatalf("unmarshal okx exchange: %v", err) - } - - strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`) - var stA struct { - Strategy safeStrategyToolConfig `json:"strategy"` - } - if err := json.Unmarshal([]byte(strategyA), &stA); err != nil { - t.Fatalf("unmarshal strategy A: %v", err) - } - strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`) - var stB struct { - Strategy safeStrategyToolConfig `json:"strategy"` - } - if err := json.Unmarshal([]byte(strategyB), &stB); err != nil { - t.Fatalf("unmarshal strategy B: %v", err) - } - - createTrader := a.toolManageTrader("user-1", `{ - "action":"create", - "name":"实盘一号", - "ai_model_id":"`+openAI.Model.ID+`", - "exchange_id":"`+binance.Exchange.ID+`", - "strategy_id":"`+stA.Strategy.ID+`" - }`) - var trader struct { - Trader safeTraderToolConfig `json:"trader"` - } - if err := json.Unmarshal([]byte(createTrader), &trader); err != nil { - t.Fatalf("unmarshal trader: %v", err) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B") - if err != nil { - t.Fatalf("update trader bindings error = %v", err) - } - if !strings.Contains(resp, "已更新交易员绑定") { - t.Fatalf("expected trader binding update response, got %q", resp) - } - - listRaw := a.toolListTraders("user-1") - if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) { - t.Fatalf("expected trader bindings to change, got %s", listRaw) - } -} - -func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) { - a := newTestAgentWithStore(t) - - for _, name := range []string{"趋势策略A", "趋势策略B"} { - resp := a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"`+name+`", - "lang":"zh" - }`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("failed to create strategy %q: %s", name, resp) - } - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除") - if err != nil { - t.Fatalf("thinkAndAct() bulk delete start error = %v", err) - } - if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") { - t.Fatalf("expected bulk delete confirmation, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认") - if err != nil { - t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err) - } - if !strings.Contains(resp, "成功删除 2 个") { - t.Fatalf("expected bulk delete success summary, got %q", resp) - } - - listResp := a.toolGetStrategies("user-1") - if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") { - t.Fatalf("expected created strategies to be deleted, got %s", listResp) - } -} - -func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) { - a := newTestAgentWithStore(t) - - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - enabledExchange := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"test", - "enabled":true - }`) - if strings.Contains(enabledExchange, `"error"`) { - t.Fatalf("failed to create enabled exchange: %s", enabledExchange) - } - anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"lky", - "enabled":true - }`) - if strings.Contains(anotherEnabledExchange, `"error"`) { - t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange) - } - disabledExchange := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"new", - "enabled":false - }`) - if strings.Contains(disabledExchange, `"error"`) { - t.Fatalf("failed to create disabled exchange: %s", disabledExchange) - } - _ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader") - if err != nil { - t.Fatalf("create trader start error = %v", err) - } - if !strings.Contains(resp, "new(已禁用)") { - t.Fatalf("expected disabled exchange to be labelled, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test,交易所用new、策略用激进") - if err != nil { - t.Fatalf("disabled exchange selection error = %v", err) - } - if !strings.Contains(resp, "当前已禁用") { - t.Fatalf("expected disabled exchange warning, got %q", resp) - } -} - -func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) { - a := newTestAgentWithStore(t) - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - - exchangeResp := a.toolManageExchangeConfig("user-1", `{ - "action":"create", - "exchange_type":"okx", - "account_name":"test", - "enabled":true - }`) - if strings.Contains(exchangeResp, `"error"`) { - t.Fatalf("failed to create exchange: %s", exchangeResp) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下") - if err != nil { - t.Fatalf("enter exchange update flow error = %v", err) - } - if !strings.Contains(resp, "请告诉我你要改什么") { - t.Fatalf("expected exchange update prompt, got %q", resp) - } - - resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改") - if err != nil { - t.Fatalf("cancel exchange flow error = %v", err) - } - if !strings.Contains(resp, "已取消当前流程") { - t.Fatalf("expected flow cancellation, got %q", resp) - } -} - -func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) { - session := skillSession{Name: "exchange_management", Action: "update"} - a := &Agent{} - - if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" { - t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got) - } - if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" { - t.Fatalf("expected topic shift to cancel current skill flow, got %q", got) - } -} - -type skillSessionClassifierAIClient struct { - lastSystemPrompt string - lastUserPrompt string - response string -} - -func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {} -func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {} -func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) { - return "", errors.New("unexpected CallWithMessages") -} -func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) { - if len(req.Messages) > 0 { - c.lastSystemPrompt = req.Messages[0].Content - } - if len(req.Messages) > 1 { - c.lastUserPrompt = req.Messages[1].Content - } - return c.response, nil -} -func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) { - return "", errors.New("unexpected CallWithRequestStream") -} -func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) { - return nil, errors.New("unexpected CallWithRequestFull") -} - -func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) { - client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`} - a := &Agent{aiClient: client} - session := skillSession{ - Name: "strategy_management", - Action: "update_config", - Fields: map[string]string{ - skillDAGStepField: "resolve_config_value", - "config_field": "min_confidence", - }, - } - - if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" { - t.Fatalf("expected numeric slot fill to continue, got %q", got) - } - if client.lastSystemPrompt != "" { - t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt) - } -} - -func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) { - client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`} - a := &Agent{ - aiClient: client, - history: newChatHistory(10), - } - session := skillSession{ - Name: "exchange_management", - Action: "update", - Fields: map[string]string{ - skillDAGStepField: "collect_account_name", - }, - } - - if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" { - t.Fatalf("expected ambiguous deflection to interrupt, got %q", got) - } - if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") { - t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt) - } -} - -func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) { - client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`} - a := &Agent{ - aiClient: client, - history: newChatHistory(10), - } - session := skillSession{ - Name: "model_management", - Action: "create", - Fields: map[string]string{ - skillDAGStepField: "collect_optional_fields", - "provider": "openai", - }, - } - - if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" { - t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got) - } - if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") { - t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt) - } -} - -func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) { - a := newTestAgentWithStore(t) - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - - resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置") - if err != nil { - t.Fatalf("thinkAndAct() default config error = %v", err) - } - if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") { - t.Fatalf("expected default strategy config response, got %q", resp) - } -} - -func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) { - a := newTestAgentWithStore(t) - _ = a.toolManageModelConfig("user-1", `{ - "action":"create", - "provider":"deepseek", - "enabled":true, - "api_key":"sk-test", - "custom_api_url":"https://api.deepseek.com/v1", - "custom_model_name":"deepseek-chat" - }`) - - createResp := a.toolManageStrategy("user-1", `{ - "action":"create", - "name":"趋势策略A", - "lang":"zh" - }`) - if strings.Contains(createResp, `"error"`) { - t.Fatalf("failed to create strategy: %s", createResp) - } - - resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70,核心指标都全选") - if err != nil { - t.Fatalf("thinkAndAct() multi-field update error = %v", err) - } - if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") { - t.Fatalf("expected multi-field update confirmation, got %q", resp) - } - - strategiesRaw := a.toolGetStrategies("user-1") - if !strings.Contains(strategiesRaw, `"min_confidence":70`) || - !strings.Contains(strategiesRaw, `"enable_ema":true`) || - !strings.Contains(strategiesRaw, `"enable_macd":true`) || - !strings.Contains(strategiesRaw, `"enable_rsi":true`) || - !strings.Contains(strategiesRaw, `"enable_atr":true`) || - !strings.Contains(strategiesRaw, `"enable_boll":true`) { - t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw) - } -} diff --git a/agent/skill_domain_context.go b/agent/skill_domain_context.go new file mode 100644 index 00000000..dec0be4f --- /dev/null +++ b/agent/skill_domain_context.go @@ -0,0 +1,136 @@ +package agent + +import "strings" + +func buildSkillDomainPrimer(lang, skillName string) string { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + return "" + } + switch skillName { + case "model_management": + fields := []string{ + fieldKnowledgeDisplayName("provider", lang), + displayCatalogFieldName("name", lang), + displayCatalogFieldName("api_key", lang), + displayCatalogFieldName("custom_api_url", lang), + displayCatalogFieldName("custom_model_name", lang), + displayCatalogFieldName("enabled", lang), + } + if lang == "zh" { + return strings.Join([]string{ + "### 模型配置领域约束", + "- 当前领域是 AI 模型配置,不是交易所配置。", + "- provider 指模型厂商,不是交易所类型。", + "- 关键字段:" + strings.Join(fields, "、"), + "- 候选 provider:" + modelProviderSummaryList(lang), + "- 推荐 provider:claw402。claw402 是 NOFXi 官方推荐方案,按次付费,使用 Base 链 EVM 钱包 + USDC 支付。", + "- 如果用户不确定选哪个 provider,可以优先推荐 claw402 并说明其优势,但绝不能替用户自动选中 claw402;必须先展示完整 provider 选项并让用户自己选择。", + "- 如果 provider 还没选定,下一步必须先让用户从完整 provider 列表里选一个,不能先收集 API Key、钱包私钥或其他凭证。", + "- 普通 provider(openai/deepseek/claude 等)通常要填 API Key;custom_model_name 和 custom_api_url 可以留空走默认值。", + "- claw402 需要钱包私钥,custom_model_name 留空时默认 deepseek。", + "- blockrun-base / blockrun-sol 走钱包私钥模式,不需要 custom_api_url,custom_model_name 默认 auto。", + }, "\n") + } + return strings.Join([]string{ + "### Model Config Domain Guard", + "- The current domain is AI model configuration, not exchange configuration.", + "- provider means the model vendor, not an exchange venue.", + "- Key fields: " + strings.Join(fields, ", "), + "- Supported providers: " + modelProviderSummaryList(lang), + "- Recommended provider: claw402. claw402 is the NOFXi recommended pay-per-use option that uses a Base chain wallet + USDC.", + "- If the user is unsure which provider to pick, you may recommend claw402 and explain its advantages, but you must not auto-select claw402 for them. Show the full provider options first and let the user choose.", + "- If provider is still missing, the next step must be to ask the user to choose one from the full provider list. Do not ask for an API key, wallet private key, or other credentials before the provider is chosen.", + "- Standard providers (openai/deepseek/claude etc.) usually require an API key; `custom_model_name` and `custom_api_url` can be omitted to use defaults.", + "- claw402 uses a wallet private key and defaults to `deepseek` if `custom_model_name` is omitted.", + "- blockrun-base / blockrun-sol use wallet private keys, do not need `custom_api_url`, and default to `auto`.", + }, "\n") + case "exchange_management": + fields := []string{ + slotDisplayName("exchange_type", lang), + displayCatalogFieldName("account_name", lang), + displayCatalogFieldName("api_key", lang), + displayCatalogFieldName("secret_key", lang), + displayCatalogFieldName("passphrase", lang), + displayCatalogFieldName("enabled", lang), + } + if lang == "zh" { + return strings.Join([]string{ + "### 交易所配置领域约束", + "- 当前领域是交易所账户配置,不是 AI 模型配置。", + "- exchange_type 指交易所类型,provider 这个词不应用来代指交易所。", + "- 关键字段:" + strings.Join(fields, "、"), + "- 支持的交易所类型:" + strings.Join(enumOptionValues("exchange_management", "exchange_type"), "、"), + }, "\n") + } + return strings.Join([]string{ + "### Exchange Config Domain Guard", + "- The current domain is exchange account configuration, not AI model configuration.", + "- exchange_type means the trading venue. Do not use provider to mean an exchange.", + "- Key fields: " + strings.Join(fields, ", "), + "- Supported exchange types: " + strings.Join(enumOptionValues("exchange_management", "exchange_type"), ", "), + }, "\n") + case "trader_management": + fields := []string{ + slotDisplayName("name", lang), + slotDisplayName("exchange", lang), + slotDisplayName("model", lang), + slotDisplayName("strategy", lang), + displayCatalogFieldName("scan_interval_minutes", lang), + displayCatalogFieldName("initial_balance", lang), + } + if lang == "zh" { + return strings.Join([]string{ + "### 交易员配置领域约束", + "- 交易员创建/修改围绕名称、绑定交易所、绑定模型、绑定策略和运行参数展开。", + "- 创建交易员时最关键的是:名称、交易所、模型、策略。", + "- 关键字段:" + strings.Join(fields, "、"), + }, "\n") + } + return strings.Join([]string{ + "### Trader Config Domain Guard", + "- Trader create/update revolves around the trader name, bound exchange, bound model, bound strategy, and runtime settings.", + "- The key create fields are name, exchange, model, and strategy.", + "- Key fields: " + strings.Join(fields, ", "), + }, "\n") + case "strategy_management": + fields := []string{ + slotDisplayName("name", lang), + displayCatalogFieldName("strategy_type", lang), + displayCatalogFieldName("source_type", lang), + displayCatalogFieldName("primary_timeframe", lang), + displayCatalogFieldName("selected_timeframes", lang), + displayCatalogFieldName("custom_prompt", lang), + } + if lang == "zh" { + return strings.Join([]string{ + "### 策略配置领域约束", + "- 策略围绕策略类型、选币来源、时间周期、风险参数和提示词展开。", + "- source_type 是选币来源,不是交易所,也不是模型。", + "- strategy_type 选项:ai_trading、grid_trading。", + "- source_type 选项:static、ai500、oi_top、oi_low、mixed。", + "- 关键字段:" + strings.Join(fields, "、"), + }, "\n") + } + return strings.Join([]string{ + "### Strategy Config Domain Guard", + "- Strategy configuration revolves around strategy type, coin source, timeframes, risk parameters, and prompts.", + "- source_type means the coin source, not an exchange or model.", + "- strategy_type options: ai_trading, grid_trading.", + "- source_type options: static, ai500, oi_top, oi_low, mixed.", + "- Key fields: " + strings.Join(fields, ", "), + }, "\n") + default: + return "" + } +} + +func buildManagementDomainPrimer(lang string) string { + parts := []string{ + buildSkillDomainPrimer(lang, "model_management"), + buildSkillDomainPrimer(lang, "exchange_management"), + buildSkillDomainPrimer(lang, "trader_management"), + buildSkillDomainPrimer(lang, "strategy_management"), + } + return strings.Join(filterNonEmptyStrings(parts), "\n\n") +} diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go index 98db45cd..94353484 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "regexp" - "sort" "strconv" "strings" @@ -13,100 +12,839 @@ import ( var ( firstIntegerPattern = regexp.MustCompile(`\d+`) + firstFloatPattern = regexp.MustCompile(`\d+(?:\.\d+)?`) timeframeTokenRE = regexp.MustCompile(`(?i)\b\d{1,2}[mhdw]\b`) + coinSymbolTokenRE = regexp.MustCompile(`(?i)^(?:xyz:)?[a-z0-9._-]{2,20}(?:usdt|usd|-usdc)?$`) + quotedContentRE = regexp.MustCompile(`[“"]([^“”"]{1,200})[”"]`) ) +const ( + strategyPendingUpdateConfigField = "_pending_strategy_update_config" + strategyPendingUpdateWarnings = "_pending_strategy_update_warnings" + strategyPendingUpdateZhMsg = "_pending_strategy_update_zh_msg" + strategyPendingUpdateEnMsg = "_pending_strategy_update_en_msg" +) + +func generatedDraftRequiresConfirmation(session skillSession) bool { + return fieldValue(session, "_requires_generated_confirmation") == "true" +} + +func clearGeneratedDraftConfirmation(session *skillSession, keys ...string) { + if session == nil || session.Fields == nil { + return + } + delete(session.Fields, "_requires_generated_confirmation") + for _, key := range keys { + if strings.TrimSpace(key) != "" { + delete(session.Fields, key) + } + } +} + func parseStandaloneInteger(text string) (int, bool) { - match := firstIntegerPattern.FindString(strings.TrimSpace(text)) - if match == "" { - return 0, false - } - value, err := strconv.Atoi(match) - if err != nil { - return 0, false - } - return value, true + return 0, false +} + +func parseStandaloneFloat(text string) (float64, bool) { + return 0, false } func parseEnabledValue(text string) (bool, bool) { - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"启用", "打开", "开启", "enable", "enabled", "on"}): - return true, true - case containsAny(lower, []string{"禁用", "关闭", "停用", "disable", "disabled", "off"}): - return false, true - default: - return false, false - } + return false, false } func parseFlagValue(text string, keywords []string) (bool, bool) { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" || !containsAny(lower, keywords) { - return false, false - } - switch { - case containsAny(lower, []string{"启用", "打开", "开启", "使用", "用", "是", "true", "enable", "enabled", "on", "use"}): - return true, true - case containsAny(lower, []string{"禁用", "关闭", "停用", "不用", "不要", "否", "false", "disable", "disabled", "off", "don't use", "do not use"}): - return false, true - default: - return false, false - } + return false, false } func extractCredentialValue(text string, keywords []string) string { - if value := extractQuotedContent(text); value != "" && containsAny(strings.ToLower(text), keywords) { - return value - } - return extractPostKeywordName(text, keywords) + return "" } func parseScanIntervalMinutes(text string) (int, bool) { - if value, ok := extractLabeledInt(text, []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}); ok { - return value, true - } - lower := strings.ToLower(strings.TrimSpace(text)) - if !containsAny(lower, []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}) { - return 0, false - } - return parseStandaloneInteger(text) + return 0, false } -func detectStrategyConfigField(text string) string { +type entityFieldPatch struct { + Field string + Value string +} + +func entityFieldPatchesFromCatalog(catalog []entityFieldMeta, values map[string]string) []entityFieldPatch { + patches := make([]entityFieldPatch, 0, len(values)) + for _, meta := range catalog { + value := strings.TrimSpace(values[meta.Key]) + if value == "" { + continue + } + patches = append(patches, entityFieldPatch{Field: meta.Key, Value: value}) + } + return patches +} + +func fieldMetaByKey(catalog []entityFieldMeta, key string) (entityFieldMeta, bool) { + for _, meta := range catalog { + if meta.Key == key { + return meta, true + } + } + return entityFieldMeta{}, false +} + +func parseFieldValueFromMeta(text string, meta entityFieldMeta) (string, bool) { + return "", false +} + +func detectCatalogField(text string, catalog []entityFieldMeta) string { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return "" + } + if strings.Contains(lower, "api key index") || strings.Contains(lower, "lighter api key index") { + for _, meta := range catalog { + if meta.Key == "lighter_api_key_index" { + return meta.Key + } + } + } + bestKey := "" + bestLen := -1 + for _, meta := range catalog { + for _, keyword := range meta.Keywords { + normalized := strings.ToLower(strings.TrimSpace(keyword)) + if normalized == "" { + continue + } + if entityFieldExplicitlyMentioned(lower, []string{normalized}) && len([]rune(normalized)) > bestLen { + bestKey = meta.Key + bestLen = len([]rune(normalized)) + } + } + } + return bestKey +} + +func looksLikeBareFieldSelection(text string, keywords []string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + trimNoise := func(s string) string { + s = strings.TrimSpace(s) + for _, prefix := range []string{"改", "改一下", "改下", "修改", "更新", "设置", "设成", "设为", "change", "update", "set"} { + s = strings.TrimSpace(strings.TrimPrefix(s, prefix)) + } + for _, suffix := range []string{"呢", "吧", "呀", "一下", "这个", "字段", "配置"} { + s = strings.TrimSpace(strings.TrimSuffix(s, suffix)) + } + return strings.Trim(s, "“”\"'::,,。.;;") + } + normalizedText := trimNoise(lower) + for _, keyword := range keywords { + normalizedKeyword := trimNoise(strings.ToLower(keyword)) + if normalizedKeyword != "" && normalizedText == normalizedKeyword { + return true + } + } + return false +} + +func displayCatalogFieldName(field, lang string) string { + switch field { + case "name": + if lang == "zh" { + return "名称" + } + return "name" + case "ai_model_id": + if lang == "zh" { + return "模型" + } + return "model" + case "exchange_id": + if lang == "zh" { + return "交易所" + } + return "exchange" + case "strategy_id": + if lang == "zh" { + return "策略" + } + return "strategy" + case "initial_balance": + if lang == "zh" { + return "初始资金" + } + return "initial balance" + case "scan_interval_minutes": + if lang == "zh" { + return "扫描间隔" + } + return "scan interval" + case "is_cross_margin": + if lang == "zh" { + return "全仓模式" + } + return "cross margin" + case "show_in_competition": + if lang == "zh" { + return "竞技场显示" + } + return "show in competition" + case "enabled": + if lang == "zh" { + return "启用状态" + } + return "enabled state" + case "api_key": + return "API Key" + case "custom_api_url": + if lang == "zh" { + return "接口地址" + } + return "API URL" + case "custom_model_name": + if lang == "zh" { + return "模型名称" + } + return "model name" + case "account_name": + if lang == "zh" { + return "账户名" + } + return "account name" + case "exchange_type": + if lang == "zh" { + return "交易所类型" + } + return "exchange type" + case "secret_key": + return "Secret" + case "passphrase": + return "Passphrase" + case "testnet": + if lang == "zh" { + return "测试网" + } + return "testnet" + case "hyperliquid_wallet_addr": + if lang == "zh" { + return "Hyperliquid 钱包地址" + } + return "Hyperliquid wallet address" + case "hyperliquid_unified_account": + if lang == "zh" { + return "Hyperliquid Unified Account" + } + return "Hyperliquid unified account" + case "aster_user": + if lang == "zh" { + return "Aster User" + } + return "Aster user" + case "aster_signer": + if lang == "zh" { + return "Aster Signer" + } + return "Aster signer" + case "aster_private_key": + if lang == "zh" { + return "Aster 私钥" + } + return "Aster private key" + case "lighter_wallet_addr": + if lang == "zh" { + return "Lighter 钱包地址" + } + return "Lighter wallet address" + case "lighter_private_key": + if lang == "zh" { + return "Lighter 私钥" + } + return "Lighter private key" + case "lighter_api_key_private_key": + if lang == "zh" { + return "Lighter API Key 私钥" + } + return "Lighter API key private key" + case "lighter_api_key_index": + if lang == "zh" { + return "Lighter API Key Index" + } + return "Lighter API key index" + default: + if lang == "zh" { + return field + } + return field + } +} + +func detectCatalogDomainFromText(text string) string { lower := strings.ToLower(strings.TrimSpace(text)) switch { - case containsAny(lower, []string{"最大持仓", "最多持仓", "max positions"}): - return "max_positions" - case containsAny(lower, []string{"最低置信度", "最小置信度", "min confidence"}): - return "min_confidence" - case containsAny(lower, []string{"btc/eth杠杆", "btc eth杠杆", "btc eth leverage", "btc/eth leverage", "主流币杠杆"}): - return "btceth_max_leverage" - case containsAny(lower, []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}): - return "altcoin_max_leverage" - case containsAny(lower, []string{"ema"}): - return "enable_ema" - case containsAny(lower, []string{"macd"}): - return "enable_macd" - case containsAny(lower, []string{"rsi"}): - return "enable_rsi" - case containsAny(lower, []string{"atr"}): - return "enable_atr" - case containsAny(lower, []string{"boll", "bollinger", "布林"}): - return "enable_boll" - case containsAny(lower, []string{"核心指标"}) && containsAny(lower, []string{"全选", "全部", "全开", "都开", "都启用", "全部启用"}): - return "enable_all_core_indicators" - case containsAny(lower, []string{"主周期", "主时间周期", "primary timeframe"}): - return "primary_timeframe" - case containsAny(lower, []string{"多周期", "时间框架", "timeframes", "selected timeframes"}): - return "selected_timeframes" + case containsAny(lower, []string{"策略", "strategy"}): + return "strategy_management" + case containsAny(lower, []string{"交易所", "exchange"}): + return "exchange_management" + case containsAny(lower, []string{"模型", "model"}): + return "model_management" default: return "" } } +func (a *Agent) executeAtomicSkillWithSession(storeUserID string, userID int64, lang, text string, session skillSession) string { + if answer, ok := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session); ok { + return answer + } + return "" +} + +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 + } + 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 +} + +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 +} + +func parseStandaloneTraderUpdateArgs(text string) traderUpdateArgs { + return traderUpdateArgs{} +} + +func mergeTraderUpdateArgs(base, patch traderUpdateArgs) traderUpdateArgs { + if patch.AIModelID != "" { + base.AIModelID = patch.AIModelID + } + if patch.ExchangeID != "" { + base.ExchangeID = patch.ExchangeID + } + if patch.StrategyID != "" { + base.StrategyID = patch.StrategyID + } + if patch.InitialBalance != nil { + base.InitialBalance = patch.InitialBalance + } + if patch.ScanIntervalMinutes != nil { + base.ScanIntervalMinutes = patch.ScanIntervalMinutes + } + if patch.IsCrossMargin != nil { + base.IsCrossMargin = patch.IsCrossMargin + } + 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) + } + if args.ExchangeID != "" { + setField(session, "exchange_id", args.ExchangeID) + } + if args.StrategyID != "" { + setField(session, "strategy_id", args.StrategyID) + } + if args.InitialBalance != nil { + setField(session, "initial_balance", strconv.FormatFloat(*args.InitialBalance, 'f', -1, 64)) + } + if args.ScanIntervalMinutes != nil { + setField(session, "scan_interval_minutes", strconv.Itoa(*args.ScanIntervalMinutes)) + } + if args.IsCrossMargin != nil { + setField(session, "is_cross_margin", strconv.FormatBool(*args.IsCrossMargin)) + } + 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 { + var args traderUpdateArgs + args.AIModelID = fieldValue(session, "ai_model_id") + args.ExchangeID = fieldValue(session, "exchange_id") + args.StrategyID = fieldValue(session, "strategy_id") + if value := fieldValue(session, "initial_balance"); value != "" { + if parsed, err := strconv.ParseFloat(value, 64); err == nil { + args.InitialBalance = &parsed + } + } + if value := fieldValue(session, "scan_interval_minutes"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + args.ScanIntervalMinutes = &parsed + } + } + if value := fieldValue(session, "is_cross_margin"); value != "" { + parsed := value == "true" + args.IsCrossMargin = &parsed + } + if value := fieldValue(session, "show_in_competition"); value != "" { + 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 +} + +type modelUpdatePatch struct { + Enabled *bool + APIKey string + CustomAPIURL string + CustomModelName string +} + +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) + } + if patch.Enabled != nil { + setField(session, "enabled", strconv.FormatBool(*patch.Enabled)) + } + if patch.APIKey != "" { + setField(session, "api_key", patch.APIKey) + } + if patch.CustomModelName != "" { + setField(session, "custom_model_name", patch.CustomModelName) + } +} + +func mergeModelUpdatePatch(base, patch modelUpdatePatch) modelUpdatePatch { + if patch.Enabled != nil { + base.Enabled = patch.Enabled + } + if patch.APIKey != "" { + base.APIKey = patch.APIKey + } + if patch.CustomAPIURL != "" { + base.CustomAPIURL = patch.CustomAPIURL + } + if patch.CustomModelName != "" { + base.CustomModelName = patch.CustomModelName + } + return base +} + +func buildModelUpdatePatchFromSession(session skillSession) modelUpdatePatch { + var patch modelUpdatePatch + if value := fieldValue(session, "enabled"); value != "" { + parsed := value == "true" + patch.Enabled = &parsed + } + patch.APIKey = fieldValue(session, "api_key") + patch.CustomAPIURL = fieldValue(session, "custom_api_url") + patch.CustomModelName = fieldValue(session, "custom_model_name") + return patch +} + +type exchangeUpdatePatch struct { + AccountName string + Enabled *bool + APIKey string + SecretKey string + Passphrase string + Testnet *bool + HyperliquidWalletAddr string + AsterUser string + AsterSigner string + AsterPrivateKey string + LighterWalletAddr string + LighterAPIKeyPrivateKey string + LighterAPIKeyIndex *int +} + +func (p exchangeUpdatePatch) hasAny() bool { + return p.AccountName != "" || p.Enabled != nil || p.APIKey != "" || p.SecretKey != "" || + p.Passphrase != "" || p.Testnet != nil || p.HyperliquidWalletAddr != "" || p.AsterUser != "" || + p.AsterSigner != "" || p.AsterPrivateKey != "" || p.LighterWalletAddr != "" || + 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) + } + if patch.Enabled != nil { + setField(session, "enabled", strconv.FormatBool(*patch.Enabled)) + } + if patch.APIKey != "" { + setField(session, "api_key", patch.APIKey) + } + if patch.SecretKey != "" { + setField(session, "secret_key", patch.SecretKey) + } + if patch.Passphrase != "" { + setField(session, "passphrase", patch.Passphrase) + } + if patch.Testnet != nil { + setField(session, "testnet", strconv.FormatBool(*patch.Testnet)) + } + if patch.HyperliquidWalletAddr != "" { + setField(session, "hyperliquid_wallet_addr", patch.HyperliquidWalletAddr) + } + if patch.AsterUser != "" { + setField(session, "aster_user", patch.AsterUser) + } + if patch.AsterSigner != "" { + setField(session, "aster_signer", patch.AsterSigner) + } + if patch.AsterPrivateKey != "" { + setField(session, "aster_private_key", patch.AsterPrivateKey) + } + if patch.LighterWalletAddr != "" { + setField(session, "lighter_wallet_addr", patch.LighterWalletAddr) + } + if patch.LighterAPIKeyPrivateKey != "" { + setField(session, "lighter_api_key_private_key", patch.LighterAPIKeyPrivateKey) + } + if patch.LighterAPIKeyIndex != nil { + setField(session, "lighter_api_key_index", strconv.Itoa(*patch.LighterAPIKeyIndex)) + } +} + +func mergeExchangeUpdatePatch(base, patch exchangeUpdatePatch) exchangeUpdatePatch { + if patch.AccountName != "" { + base.AccountName = patch.AccountName + } + if patch.Enabled != nil { + base.Enabled = patch.Enabled + } + if patch.APIKey != "" { + base.APIKey = patch.APIKey + } + if patch.SecretKey != "" { + base.SecretKey = patch.SecretKey + } + if patch.Passphrase != "" { + base.Passphrase = patch.Passphrase + } + if patch.Testnet != nil { + base.Testnet = patch.Testnet + } + if patch.HyperliquidWalletAddr != "" { + base.HyperliquidWalletAddr = patch.HyperliquidWalletAddr + } + if patch.AsterUser != "" { + base.AsterUser = patch.AsterUser + } + if patch.AsterSigner != "" { + base.AsterSigner = patch.AsterSigner + } + if patch.AsterPrivateKey != "" { + base.AsterPrivateKey = patch.AsterPrivateKey + } + if patch.LighterWalletAddr != "" { + base.LighterWalletAddr = patch.LighterWalletAddr + } + if patch.LighterAPIKeyPrivateKey != "" { + base.LighterAPIKeyPrivateKey = patch.LighterAPIKeyPrivateKey + } + if patch.LighterAPIKeyIndex != nil { + base.LighterAPIKeyIndex = patch.LighterAPIKeyIndex + } + return base +} + +func buildExchangeUpdatePatchFromSession(session skillSession) exchangeUpdatePatch { + var patch exchangeUpdatePatch + patch.AccountName = fieldValue(session, "account_name") + if value := fieldValue(session, "enabled"); value != "" { + parsed := value == "true" + patch.Enabled = &parsed + } + patch.APIKey = fieldValue(session, "api_key") + patch.SecretKey = fieldValue(session, "secret_key") + patch.Passphrase = fieldValue(session, "passphrase") + if value := fieldValue(session, "testnet"); value != "" { + parsed := value == "true" + patch.Testnet = &parsed + } + patch.HyperliquidWalletAddr = fieldValue(session, "hyperliquid_wallet_addr") + patch.AsterUser = fieldValue(session, "aster_user") + patch.AsterSigner = fieldValue(session, "aster_signer") + patch.AsterPrivateKey = fieldValue(session, "aster_private_key") + patch.LighterWalletAddr = fieldValue(session, "lighter_wallet_addr") + patch.LighterAPIKeyPrivateKey = fieldValue(session, "lighter_api_key_private_key") + if value := fieldValue(session, "lighter_api_key_index"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + patch.LighterAPIKeyIndex = &parsed + } + } + return patch +} + +func detectStrategyConfigField(text string) string { + return "" +} + func strategyConfigFieldDisplayName(field, lang string) string { switch field { + case "name": + if lang == "zh" { + return "名称" + } + return "name" + case "strategy_type": + if lang == "zh" { + return "策略类型" + } + return "strategy type" + case "symbol": + if lang == "zh" { + return "交易对" + } + return "symbol" + case "grid_count": + if lang == "zh" { + return "网格数量" + } + return "grid count" + case "total_investment": + if lang == "zh" { + return "总投资" + } + return "total investment" + case "upper_price": + if lang == "zh" { + return "上沿价格" + } + return "upper price" + case "lower_price": + if lang == "zh" { + return "下沿价格" + } + return "lower price" + case "use_atr_bounds": + if lang == "zh" { + return "ATR 自动边界" + } + return "use ATR bounds" + case "atr_multiplier": + if lang == "zh" { + return "ATR 倍数" + } + return "ATR multiplier" + case "distribution": + if lang == "zh" { + return "分布方式" + } + return "distribution" + case "enable_direction_adjust": + if lang == "zh" { + return "方向自适应" + } + return "enable direction adjust" + case "direction_bias_ratio": + if lang == "zh" { + return "方向偏置比例" + } + return "direction bias ratio" + case "max_drawdown_pct": + if lang == "zh" { + return "最大回撤" + } + return "max drawdown pct" + case "stop_loss_pct": + if lang == "zh" { + return "止损比例" + } + return "stop loss pct" + case "daily_loss_limit_pct": + if lang == "zh" { + return "日亏损限制" + } + return "daily loss limit pct" + case "use_maker_only": + if lang == "zh" { + return "仅 Maker" + } + return "use maker only" + case "description": + if lang == "zh" { + return "描述" + } + return "description" + case "is_public": + if lang == "zh" { + return "发布到市场" + } + return "publish to market" + case "config_visible": + if lang == "zh" { + return "配置可见" + } + return "config visible" case "max_positions": if lang == "zh" { return "最大持仓" @@ -117,6 +855,16 @@ func strategyConfigFieldDisplayName(field, lang string) string { return "最小置信度" } return "min confidence" + case "min_risk_reward_ratio": + if lang == "zh" { + return "最小盈亏比" + } + return "min risk reward ratio" + case "leverage": + if lang == "zh" { + return "杠杆" + } + return "leverage" case "btceth_max_leverage": if lang == "zh" { return "BTC/ETH 最大杠杆" @@ -127,6 +875,26 @@ func strategyConfigFieldDisplayName(field, lang string) string { return "山寨币最大杠杆" } return "altcoin max leverage" + case "btceth_max_position_value_ratio": + if lang == "zh" { + return "BTC/ETH 最大仓位价值倍数" + } + return "BTC/ETH max position value ratio" + case "altcoin_max_position_value_ratio": + if lang == "zh" { + return "山寨币最大仓位价值倍数" + } + return "altcoin max position value ratio" + case "max_margin_usage": + if lang == "zh" { + return "最大保证金使用率" + } + return "max margin usage" + case "min_position_size": + if lang == "zh" { + return "最小开仓金额" + } + return "min position size" case "enable_ema": if lang == "zh" { return "EMA" @@ -167,62 +935,139 @@ func strategyConfigFieldDisplayName(field, lang string) string { return "多周期时间框架" } return "selected timeframes" + case "source_type": + if lang == "zh" { + return "来源类型" + } + return "source type" + case "static_coins": + if lang == "zh" { + return "静态币种" + } + return "static coins" + case "excluded_coins": + if lang == "zh" { + return "排除币种" + } + return "excluded coins" + case "use_ai500": + if lang == "zh" { + return "AI500" + } + return "use AI500" + case "ai500_limit": + if lang == "zh" { + return "AI500 数量" + } + return "AI500 limit" + case "use_oi_top": + if lang == "zh" { + return "OI Top" + } + return "use OI Top" + case "oi_top_limit": + if lang == "zh" { + return "OI Top 数量" + } + return "OI Top limit" + case "use_oi_low": + if lang == "zh" { + return "OI Low" + } + return "use OI Low" + case "oi_low_limit": + if lang == "zh" { + return "OI Low 数量" + } + return "OI Low limit" + case "primary_count": + if lang == "zh" { + return "K线数量" + } + return "kline count" + case "ema_periods": + return "EMA periods" + case "rsi_periods": + return "RSI periods" + case "atr_periods": + return "ATR periods" + case "boll_periods": + return "BOLL periods" + case "enable_volume": + if lang == "zh" { + return "成交量" + } + return "volume" + case "enable_oi": + if lang == "zh" { + return "持仓量" + } + return "OI" + case "enable_funding_rate": + if lang == "zh" { + return "资金费率" + } + return "funding rate" + case "nofxos_api_key": + return "NofxOS API key" + case "enable_quant_data": + if lang == "zh" { + return "量化数据" + } + return "quant data" + case "enable_quant_oi": + return "quant OI" + case "enable_quant_netflow": + return "quant netflow" + case "enable_oi_ranking": + return "OI ranking" + case "oi_ranking_duration": + return "OI ranking duration" + case "oi_ranking_limit": + return "OI ranking limit" + case "enable_netflow_ranking": + return "netflow ranking" + case "netflow_ranking_duration": + return "netflow ranking duration" + case "netflow_ranking_limit": + return "netflow ranking limit" + case "enable_price_ranking": + return "price ranking" + case "price_ranking_duration": + return "price ranking duration" + case "price_ranking_limit": + return "price ranking limit" + case "role_definition": + if lang == "zh" { + return "角色定义" + } + return "role definition" + case "trading_frequency": + if lang == "zh" { + return "交易频率" + } + return "trading frequency" + case "entry_standards": + if lang == "zh" { + return "开仓标准" + } + return "entry standards" + case "decision_process": + if lang == "zh" { + return "决策流程" + } + return "decision process" + case "custom_prompt": + if lang == "zh" { + return "自定义 Prompt" + } + return "custom prompt" default: return field } } func extractStrategyConfigValue(text, field string) (string, bool) { - switch field { - case "max_positions": - if value, ok := extractLabeledInt(text, []string{"最大持仓", "最多持仓", "max positions"}); ok { - return strconv.Itoa(value), true - } - if value, ok := parseStandaloneInteger(text); ok { - return strconv.Itoa(value), true - } - case "min_confidence": - if value, ok := extractLabeledInt(text, []string{"最低置信度", "最小置信度", "min confidence"}); ok { - return strconv.Itoa(value), true - } - if value, ok := parseStandaloneInteger(text); ok { - return strconv.Itoa(value), true - } - case "btceth_max_leverage": - if value, ok := extractLabeledInt(text, []string{"btc/eth杠杆", "btc eth杠杆", "btc/eth leverage", "btc eth leverage", "主流币杠杆"}); ok { - return strconv.Itoa(value), true - } - if value, ok := parseStandaloneInteger(text); ok { - return strconv.Itoa(value), true - } - case "altcoin_max_leverage": - if value, ok := extractLabeledInt(text, []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}); ok { - return strconv.Itoa(value), true - } - if value, ok := parseStandaloneInteger(text); ok { - return strconv.Itoa(value), true - } - case "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll": - if enabled, ok := parseEnabledValue(text); ok { - return strconv.FormatBool(enabled), true - } - case "enable_all_core_indicators": - lower := strings.ToLower(strings.TrimSpace(text)) - switch { - case containsAny(lower, []string{"全选", "全部", "全开", "都开", "都启用", "全部启用"}): - return "true", true - case containsAny(lower, []string{"关闭", "停用", "禁用", "全部关闭", "全部禁用"}): - return "false", true - } - case "primary_timeframe": - if tf := extractTimeframeAfterKeywords(text, []string{"主周期", "主时间周期", "primary timeframe", "timeframe"}); tf != "" { - return tf, true - } - case "selected_timeframes": - if tfs := extractTimeframes(text); len(tfs) > 0 { - return strings.Join(tfs, ","), true - } - } return "", false } @@ -232,70 +1077,147 @@ type strategyConfigPatch struct { } func detectStrategyConfigPatches(text string) []strategyConfigPatch { - seen := map[string]string{} - addPatch := func(field, value string) { - field = strings.TrimSpace(field) - value = strings.TrimSpace(value) - if field == "" || value == "" { - return - } - seen[field] = value - } - - for _, field := range []string{ - "max_positions", - "min_confidence", - "btceth_max_leverage", - "altcoin_max_leverage", - "primary_timeframe", - "selected_timeframes", - "enable_ema", - "enable_macd", - "enable_rsi", - "enable_atr", - "enable_boll", - "enable_all_core_indicators", - } { - if value, ok := extractStrategyConfigValue(text, field); ok { - if field == "enable_all_core_indicators" { - addPatch("enable_ema", value) - addPatch("enable_macd", value) - addPatch("enable_rsi", value) - addPatch("enable_atr", value) - addPatch("enable_boll", value) - continue - } - addPatch(field, value) - } - } - - fields := make([]string, 0, len(seen)) - for field := range seen { - fields = append(fields, field) - } - sort.Strings(fields) - - patches := make([]strategyConfigPatch, 0, len(fields)) - for _, field := range fields { - patches = append(patches, strategyConfigPatch{Field: field, Value: seen[field]}) - } - return patches + return nil } func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) error { + ensureGridConfig := func() *store.GridStrategyConfig { + if cfg.GridConfig == nil { + defaults := store.GetDefaultStrategyConfig(cfg.Language) + if defaults.GridConfig != nil { + copy := *defaults.GridConfig + cfg.GridConfig = © + } else { + cfg.GridConfig = &store.GridStrategyConfig{} + } + } + return cfg.GridConfig + } + switch field { + case "strategy_type": + cfg.StrategyType = value + case "symbol": + ensureGridConfig().Symbol = value + case "grid_count": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("网格数量需要是整数") + } + ensureGridConfig().GridCount = parsed + case "total_investment": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("总投资需要是数字") + } + ensureGridConfig().TotalInvestment = parsed + case "upper_price": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("上沿价格需要是数字") + } + ensureGridConfig().UpperPrice = parsed + case "lower_price": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("下沿价格需要是数字") + } + ensureGridConfig().LowerPrice = parsed + case "use_atr_bounds": + ensureGridConfig().UseATRBounds = value == "true" + case "atr_multiplier": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("ATR 倍数需要是数字") + } + ensureGridConfig().ATRMultiplier = parsed + case "distribution": + ensureGridConfig().Distribution = value + case "enable_direction_adjust": + ensureGridConfig().EnableDirectionAdjust = value == "true" + case "direction_bias_ratio": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("方向偏置比例需要是数字") + } + ensureGridConfig().DirectionBiasRatio = parsed + case "max_drawdown_pct": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("最大回撤需要是数字") + } + ensureGridConfig().MaxDrawdownPct = parsed + case "stop_loss_pct": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("止损比例需要是数字") + } + ensureGridConfig().StopLossPct = parsed + case "daily_loss_limit_pct": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("日亏损限制需要是数字") + } + ensureGridConfig().DailyLossLimitPct = parsed + case "use_maker_only": + ensureGridConfig().UseMakerOnly = value == "true" + case "description", "is_public", "config_visible": + return nil case "max_positions": parsed, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("最大持仓需要是整数") } cfg.RiskControl.MaxPositions = parsed + case "source_type": + cfg.CoinSource.SourceType = value + case "static_coins": + cfg.CoinSource.StaticCoins = cleanStringList(strings.Split(value, ",")) + case "excluded_coins": + cfg.CoinSource.ExcludedCoins = cleanStringList(strings.Split(value, ",")) + case "use_ai500": + cfg.CoinSource.UseAI500 = value == "true" + case "ai500_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("AI500 数量需要是整数") + } + cfg.CoinSource.AI500Limit = parsed + case "use_oi_top": + cfg.CoinSource.UseOITop = value == "true" + case "oi_top_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("OI Top 数量需要是整数") + } + cfg.CoinSource.OITopLimit = parsed + case "use_oi_low": + cfg.CoinSource.UseOILow = value == "true" + case "oi_low_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("OI Low 数量需要是整数") + } + cfg.CoinSource.OILowLimit = parsed case "min_confidence": parsed, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("最小置信度需要是整数") } cfg.RiskControl.MinConfidence = parsed + case "min_risk_reward_ratio": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("最小盈亏比需要是数字") + } + cfg.RiskControl.MinRiskRewardRatio = parsed + case "leverage": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("杠杆需要是整数") + } + cfg.RiskControl.BTCETHMaxLeverage = parsed + cfg.RiskControl.AltcoinMaxLeverage = parsed case "btceth_max_leverage": parsed, err := strconv.Atoi(value) if err != nil { @@ -308,12 +1230,50 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er return fmt.Errorf("山寨币最大杠杆需要是整数") } cfg.RiskControl.AltcoinMaxLeverage = parsed + case "btceth_max_position_value_ratio": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("BTC/ETH 仓位价值倍数需要是数字") + } + cfg.RiskControl.BTCETHMaxPositionValueRatio = parsed + case "altcoin_max_position_value_ratio": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("山寨币仓位价值倍数需要是数字") + } + cfg.RiskControl.AltcoinMaxPositionValueRatio = parsed + case "max_margin_usage": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("最大保证金使用率需要是数字") + } + cfg.RiskControl.MaxMarginUsage = parsed + case "min_position_size": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("最小开仓金额需要是数字") + } + cfg.RiskControl.MinPositionSize = parsed case "primary_timeframe": cfg.Indicators.Klines.PrimaryTimeframe = value + case "primary_count": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("K线数量需要是整数") + } + cfg.Indicators.Klines.PrimaryCount = parsed case "selected_timeframes": tfs := strings.Split(value, ",") cfg.Indicators.Klines.SelectedTimeframes = tfs cfg.Indicators.Klines.EnableMultiTimeframe = len(tfs) > 1 + case "ema_periods": + cfg.Indicators.EMAPeriods = parseCSVIntegers(value) + case "rsi_periods": + cfg.Indicators.RSIPeriods = parseCSVIntegers(value) + case "atr_periods": + cfg.Indicators.ATRPeriods = parseCSVIntegers(value) + case "boll_periods": + cfg.Indicators.BOLLPeriods = parseCSVIntegers(value) case "enable_ema": cfg.Indicators.EnableEMA = value == "true" case "enable_macd": @@ -324,13 +1284,478 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er cfg.Indicators.EnableATR = value == "true" case "enable_boll": cfg.Indicators.EnableBOLL = value == "true" + case "enable_volume": + cfg.Indicators.EnableVolume = value == "true" + case "enable_oi": + cfg.Indicators.EnableOI = value == "true" + case "enable_funding_rate": + cfg.Indicators.EnableFundingRate = value == "true" + case "nofxos_api_key": + cfg.Indicators.NofxOSAPIKey = value + case "enable_quant_data": + cfg.Indicators.EnableQuantData = value == "true" + case "enable_quant_oi": + cfg.Indicators.EnableQuantOI = value == "true" + case "enable_quant_netflow": + cfg.Indicators.EnableQuantNetflow = value == "true" + case "enable_oi_ranking": + cfg.Indicators.EnableOIRanking = value == "true" + case "oi_ranking_duration": + cfg.Indicators.OIRankingDuration = value + case "oi_ranking_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("OI 排行数量需要是整数") + } + cfg.Indicators.OIRankingLimit = parsed + case "enable_netflow_ranking": + cfg.Indicators.EnableNetFlowRanking = value == "true" + case "netflow_ranking_duration": + cfg.Indicators.NetFlowRankingDuration = value + case "netflow_ranking_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("资金流排行数量需要是整数") + } + cfg.Indicators.NetFlowRankingLimit = parsed + case "enable_price_ranking": + cfg.Indicators.EnablePriceRanking = value == "true" + case "price_ranking_duration": + cfg.Indicators.PriceRankingDuration = value + case "price_ranking_limit": + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("涨跌幅排行数量需要是整数") + } + cfg.Indicators.PriceRankingLimit = parsed + case "role_definition": + cfg.PromptSections.RoleDefinition = value + case "trading_frequency": + cfg.PromptSections.TradingFrequency = value + case "entry_standards": + cfg.PromptSections.EntryStandards = value + case "decision_process": + cfg.PromptSections.DecisionProcess = value + case "custom_prompt": + cfg.CustomPrompt = value default: return fmt.Errorf("unsupported strategy config field: %s", field) } return nil } +func extractLabeledFloat(text string, labels []string) (float64, bool) { + lower := strings.ToLower(text) + for _, label := range labels { + idx := strings.Index(lower, strings.ToLower(label)) + if idx < 0 { + continue + } + sub := text[idx+len(label):] + if value, ok := parseStandaloneFloat(sub); ok { + return value, true + } + } + return 0, false +} + +func parseSourceTypeValue(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + switch { + case containsAny(lower, []string{"静态", "固定", "static"}): + return "static" + case containsAny(lower, []string{"ai500"}): + return "ai500" + case containsAny(lower, []string{"oi top"}): + return "oi_top" + case containsAny(lower, []string{"oi low"}): + return "oi_low" + default: + return "" + } +} + +func extractSymbolList(text string, labels []string) []string { + segment := extractLongSegmentAfterKeywords(text, labels) + if segment == "" { + return nil + } + parts := strings.FieldsFunc(segment, func(r rune) bool { + return r == ',' || r == ',' || r == '、' || r == ' ' || r == '\n' || r == '\t' + }) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if !looksLikeCoinSymbol(part) { + continue + } + part = normalizeCoinSymbol(part) + if part == "" { + continue + } + out = append(out, part) + } + return cleanStringList(out) +} + +func looksLikeCoinSymbol(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + value = strings.Trim(value, `"'“”‘’()[]{}<>`) + value = strings.TrimSpace(value) + if value == "" { + return false + } + return coinSymbolTokenRE.MatchString(value) +} + +func normalizeCoinSymbol(symbol string) string { + symbol = strings.TrimSpace(strings.ToUpper(symbol)) + if symbol == "" { + return "" + } + if strings.HasPrefix(symbol, "XYZ:") { + return symbol + } + if strings.HasSuffix(symbol, "USDT") || strings.HasSuffix(symbol, "USD") || strings.HasSuffix(symbol, "-USDC") { + return symbol + } + return symbol + "USDT" +} + +func extractIntegerList(text string) []string { + matches := firstIntegerPattern.FindAllString(text, -1) + if len(matches) == 0 { + return nil + } + return matches +} + +func parseCSVIntegers(value string) []int { + parts := strings.Split(value, ",") + out := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.Atoi(part) + if err != nil { + continue + } + out = append(out, n) + } + return out +} + +func extractDurationValue(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + switch { + case strings.Contains(lower, "1h,4h,24h"): + return "1h,4h,24h" + case strings.Contains(lower, "24h"): + return "24h" + case strings.Contains(lower, "4h"): + return "4h" + case strings.Contains(lower, "1h"): + return "1h" + default: + return "" + } +} + +func parseStrategyTypeValue(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + switch { + case containsAny(lower, []string{"grid", "网格"}): + return "grid_trading" + case containsAny(lower, []string{"ai trading", "ai策略", "普通策略"}): + return "ai_trading" + default: + return "" + } +} + +func extractLongSegmentAfterKeywords(text string, keywords []string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "" + } + lower := strings.ToLower(trimmed) + for _, keyword := range keywords { + idx := strings.Index(lower, strings.ToLower(keyword)) + if idx < 0 { + continue + } + segment := strings.TrimSpace(trimmed[idx+len(keyword):]) + segment = strings.TrimLeft(segment, "“”\"':: ") + for _, prefix := range []string{"改成", "改为", "设为", "设置为", "变成"} { + segment = strings.TrimSpace(strings.TrimPrefix(segment, prefix)) + } + for _, marker := range []string{"排除币", "excluded coins", "exclude coins", "ai500", "oi top", "oi low", "并且", "然后"} { + if cut := strings.Index(strings.ToLower(segment), marker); cut > 0 { + segment = strings.TrimSpace(segment[:cut]) + break + } + } + segment = strings.Trim(segment, "“”\"':: ") + if segment != "" { + return segment + } + } + return "" +} + +func extractDelimitedSegmentAfterKeywords(text string, keywords []string) string { + segment := extractLongSegmentAfterKeywords(text, keywords) + if segment == "" { + return "" + } + for _, marker := range []string{",", ",", "。", ".", ";", ";", "\n", "\t", "并且", "然后"} { + if cut := strings.Index(segment, marker); cut > 0 { + segment = strings.TrimSpace(segment[:cut]) + break + } + } + return strings.Trim(segment, "“”\"':: ") +} + +func extractModelNameValue(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + if !containsAny(lower, []string{"模型名", "模型名称", "model name"}) { + return "" + } + if value := extractDelimitedSegmentAfterKeywords(text, []string{"model name", "模型名称", "模型名"}); value != "" { + return value + } + if containsAny(lower, []string{"改成", "改为"}) { + if value := extractDelimitedSegmentAfterKeywords(text, []string{"改成", "改为"}); value != "" { + return value + } + } + if value := extractQuotedContent(text); value != "" { + return value + } + return "" +} + +func sanitizeExtractedURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + for _, marker := range []string{",", ",", "。", ";", ";", "并且", "然后"} { + if cut := strings.Index(raw, marker); cut > 0 { + raw = strings.TrimSpace(raw[:cut]) + break + } + } + return raw +} + +func strategyFieldKeywords(field string) []string { + switch field { + case "source_type": + return []string{"来源类型", "source type", "选币来源", "静态来源", "ai500来源", "oi top来源", "oi low来源"} + case "strategy_type": + return []string{"策略类型", "strategy type", "网格策略", "grid strategy", "ai策略"} + case "symbol": + return []string{"交易对", "symbol", "币对"} + case "grid_count": + return []string{"网格数量", "grid count", "grid levels"} + case "total_investment": + return []string{"总投入", "总投资", "total investment"} + case "upper_price": + return []string{"上沿价格", "上限价格", "upper price"} + case "lower_price": + return []string{"下沿价格", "下限价格", "lower price"} + case "use_atr_bounds": + return []string{"atr自动边界", "atr边界", "use atr bounds"} + case "atr_multiplier": + return []string{"atr倍数", "atr multiplier"} + case "distribution": + return []string{"分布方式", "distribution", "均匀分布", "高斯分布", "金字塔分布"} + case "enable_direction_adjust": + return []string{"方向调整", "direction adjust"} + case "direction_bias_ratio": + return []string{"方向偏置", "bias ratio", "direction bias"} + case "max_drawdown_pct": + return []string{"最大回撤", "max drawdown"} + case "stop_loss_pct": + return []string{"止损比例", "stop loss"} + case "daily_loss_limit_pct": + return []string{"日亏损限制", "daily loss limit"} + case "use_maker_only": + return []string{"maker only", "只挂maker", "仅maker"} + case "description": + return []string{"描述", "description"} + case "is_public": + return []string{"发布到市场", "公开", "publish"} + case "config_visible": + return []string{"配置可见", "显示配置", "config visible"} + case "nofxos_api_key": + return []string{"nofxos api key", "nofxos key", "api key"} + case "role_definition": + return []string{"角色定义", "role definition"} + case "trading_frequency": + return []string{"交易频率", "trading frequency"} + case "entry_standards": + return []string{"开仓标准", "入场标准", "entry standards"} + case "decision_process": + return []string{"决策流程", "decision process"} + case "custom_prompt": + return []string{"自定义prompt", "custom prompt", "提示词"} + case "ema_periods": + return []string{"ema周期", "ema periods"} + case "rsi_periods": + return []string{"rsi周期", "rsi periods"} + case "atr_periods": + return []string{"atr周期", "atr periods"} + case "boll_periods": + return []string{"boll周期", "布林周期", "boll periods"} + case "oi_ranking_duration": + return []string{"oi ranking duration", "oi排行周期"} + case "netflow_ranking_duration": + return []string{"netflow ranking duration", "资金流排行周期"} + case "price_ranking_duration": + return []string{"price ranking duration", "涨跌幅排行周期"} + case "oi_ranking_limit": + return []string{"oi ranking limit", "oi排行数量"} + case "netflow_ranking_limit": + return []string{"netflow ranking limit", "资金流排行数量"} + case "price_ranking_limit": + return []string{"price ranking limit", "涨跌幅排行数量"} + case "btceth_max_position_value_ratio": + return []string{"btc/eth仓位价值倍数", "btc eth position value", "主流币仓位价值倍数"} + case "altcoin_max_position_value_ratio": + 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 + } +} + +func matchesStrategyFieldKeywords(text, field string) bool { + keywords := strategyFieldKeywords(field) + if len(keywords) == 0 { + return true + } + return containsAny(strings.ToLower(text), keywords) +} + +func strategyFieldExplicitlyMentioned(text, field string) bool { + keywords := strategyFieldKeywords(field) + if len(keywords) == 0 { + switch field { + case "max_positions": + keywords = []string{"最大持仓", "最多持仓", "max positions"} + case "symbol": + keywords = []string{"交易对", "symbol", "币对"} + case "grid_count": + keywords = []string{"网格数量", "grid count", "grid levels"} + case "total_investment": + keywords = []string{"总投入", "总投资", "total investment"} + case "upper_price": + keywords = []string{"上沿价格", "上限价格", "upper price"} + case "lower_price": + keywords = []string{"下沿价格", "下限价格", "lower price"} + case "use_atr_bounds": + keywords = []string{"atr自动边界", "atr边界", "use atr bounds"} + case "atr_multiplier": + keywords = []string{"atr倍数", "atr multiplier"} + case "distribution": + keywords = []string{"分布方式", "distribution", "均匀分布", "高斯分布", "金字塔分布"} + case "enable_direction_adjust": + keywords = []string{"方向调整", "direction adjust"} + case "direction_bias_ratio": + keywords = []string{"方向偏置", "bias ratio", "direction bias"} + case "max_drawdown_pct": + keywords = []string{"最大回撤", "max drawdown"} + case "stop_loss_pct": + keywords = []string{"止损比例", "stop loss"} + case "daily_loss_limit_pct": + keywords = []string{"日亏损限制", "daily loss limit"} + case "use_maker_only": + keywords = []string{"maker only", "只挂maker", "仅maker"} + case "min_confidence": + keywords = []string{"最低置信度", "最小置信度", "min confidence"} + case "min_risk_reward_ratio": + keywords = []string{"最小盈亏比", "风险回报比", "risk reward", "risk/reward"} + case "leverage": + keywords = []string{"杠杆", "leverage"} + case "btceth_max_leverage": + keywords = []string{"btc/eth杠杆", "btc eth杠杆", "btc/eth leverage", "btc eth leverage", "主流币杠杆"} + case "altcoin_max_leverage": + keywords = []string{"山寨币杠杆", "altcoin leverage", "alts leverage"} + case "btceth_max_position_value_ratio": + keywords = []string{"btc/eth仓位价值倍数", "btc eth position value", "主流币仓位价值倍数"} + case "altcoin_max_position_value_ratio": + 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": + keywords = []string{"k线数量", "k线根数", "primary count", "kline count"} + case "selected_timeframes": + keywords = []string{"多周期", "时间框架", "timeframes", "selected timeframes"} + case "enable_ema": + keywords = []string{"ema"} + case "enable_macd": + keywords = []string{"macd"} + case "enable_rsi": + keywords = []string{"rsi"} + case "enable_atr": + keywords = []string{"atr"} + case "enable_boll": + keywords = []string{"boll", "bollinger", "布林"} + case "enable_volume": + keywords = []string{"成交量", "volume"} + case "enable_oi": + keywords = []string{"持仓量", "open interest", "oi"} + case "enable_funding_rate": + keywords = []string{"资金费率", "funding rate"} + case "source_type": + keywords = []string{"来源类型", "source type", "选币来源"} + case "static_coins": + keywords = []string{"静态币", "固定币", "static coins", "static symbols"} + case "excluded_coins": + keywords = []string{"排除币", "排除币种", "excluded coins", "exclude coins"} + case "use_ai500": + keywords = []string{"ai500"} + case "ai500_limit": + keywords = []string{"ai500 limit", "ai500数量", "ai500上限"} + case "use_oi_top": + keywords = []string{"oi top", "持仓量增长", "持仓量排行上涨"} + case "oi_top_limit": + keywords = []string{"oi top limit", "oi top数量", "oi top上限"} + case "use_oi_low": + keywords = []string{"oi low", "持仓量下降", "持仓量排行下跌"} + case "oi_low_limit": + keywords = []string{"oi low limit", "oi low数量", "oi low上限"} + case "enable_all_core_indicators": + keywords = []string{"核心指标"} + } + } + if len(keywords) == 0 { + return false + } + return containsAny(strings.ToLower(text), keywords) +} + func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { + if session.Action == "query_strategy_binding" || session.Action == "query_exchange_binding" || session.Action == "query_model_binding" { + if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok { + return detail + } + return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)) + } switch session.Action { case "query", "query_list": return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)) @@ -343,7 +1768,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "await_confirmation") } - if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { a.saveSkillSession(userID, session) return msg } @@ -374,20 +1799,17 @@ 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": - if session.Action == "update_bindings" { + case "update", "update_name", "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") } - args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID} - if match := pickMentionedOption(text, a.loadEnabledModelOptions(storeUserID)); match != nil { - args.AIModelID = match.ID - } - if match := pickMentionedOption(text, a.loadExchangeOptions(storeUserID)); match != nil { - args.ExchangeID = match.ID - } - if match := pickMentionedOption(text, a.loadStrategyOptions(storeUserID)); match != nil { - args.StrategyID = match.ID + args := manageTraderArgs{ + Action: "update", + TraderID: session.TargetRef.ID, + AIModelID: fieldValue(session, "ai_model_id"), + ExchangeID: fieldValue(session, "exchange_id"), + StrategyID: fieldValue(session, "strategy_id"), } if args.AIModelID != "" { setField(&session, "ai_model_id", args.AIModelID) @@ -398,104 +1820,340 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, if args.StrategyID != "" { setField(&session, "strategy_id", args.StrategyID) } - if value := fieldValue(session, "ai_model_id"); value != "" { - args.AIModelID = value - } - if value := fieldValue(session, "exchange_id"); value != "" { - args.ExchangeID = value - } - if value := fieldValue(session, "strategy_id"); value != "" { - args.StrategyID = value + selectedField := fieldValue(session, "update_field") + if selectedField == "" { + switch session.Action { + case "configure_strategy": + selectedField = "strategy_id" + case "configure_exchange": + selectedField = "exchange_id" + case "configure_model": + selectedField = "ai_model_id" + default: + if args.AIModelID == "" && args.ExchangeID == "" && args.StrategyID == "" { + selectedField = detectCatalogField(text, traderFieldCatalog) + } + } + if selectedField == "name" || selectedField == "initial_balance" || selectedField == "scan_interval_minutes" || selectedField == "is_cross_margin" || selectedField == "show_in_competition" { + selectedField = "" + } + if selectedField != "" { + setField(&session, "update_field", selectedField) + } } if args.AIModelID == "" && args.ExchangeID == "" && args.StrategyID == "" { + if fieldValue(session, "inline_sub_intent") == "create_sub_resource" { + delete(session.Fields, "inline_sub_intent") + a.saveSkillSession(userID, session) + task := a.buildSuspendedTask(userID, lang) + if task.Kind != "" && task.SkillSession != nil { + task.ResumeOnSuccess = true + var childSkill, childResumeTrigger string + switch session.Action { + case "configure_strategy": + childSkill = "strategy_management" + childResumeTrigger = "strategy_management" + case "configure_exchange": + childSkill = "exchange_management" + childResumeTrigger = "exchange_management" + case "configure_model": + childSkill = "model_management" + childResumeTrigger = "model_management" + case "create": + // infer child skill from which binding slot is missing + slots := session.Slots + if slots == nil || slots.StrategyID == "" { + childSkill = "strategy_management" + childResumeTrigger = "strategy_management" + } else if slots.ExchangeID == "" { + childSkill = "exchange_management" + childResumeTrigger = "exchange_management" + } else if slots.ModelID == "" { + childSkill = "model_management" + childResumeTrigger = "model_management" + } + } + if childSkill != "" { + task.ResumeTriggers = []string{childResumeTrigger} + a.SnapshotManager(userID).Save(task) + a.clearSkillSession(userID) + child := skillSession{Name: childSkill, Action: "create", Phase: "collecting"} + var answer string + var handled bool + switch childSkill { + case "strategy_management": + answer, handled = a.handleStrategyManagementSkill(storeUserID, userID, lang, text, child) + case "exchange_management": + answer, handled = a.handleExchangeManagementSkill(storeUserID, userID, lang, text, child) + case "model_management": + answer, handled = a.handleModelManagementSkill(storeUserID, userID, lang, text, child) + } + if !handled { + answer = "" + } + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, childSkill, "create", answer) + } + } + } + if fieldValue(session, "inline_sub_intent") == "edit_sub_resource" { + delete(session.Fields, "inline_sub_intent") + a.saveSkillSession(userID, session) + task := a.buildSuspendedTask(userID, lang) + if task.Kind != "" && task.SkillSession != nil { + task.ResumeOnSuccess = true + var childSkill string + switch session.Action { + case "configure_strategy": + childSkill = "strategy_management" + case "configure_exchange": + childSkill = "exchange_management" + case "configure_model": + childSkill = "model_management" + case "create", "update_bindings": + childSkill = detectCatalogDomainFromText(text) + } + if childSkill != "" { + task.ResumeTriggers = []string{childSkill} + a.SnapshotManager(userID).Save(task) + a.clearSkillSession(userID) + child := skillSession{Name: childSkill, Action: "update", Phase: "collecting"} + var answer string + var handled bool + switch childSkill { + case "strategy_management": + answer, handled = a.handleStrategyManagementSkill(storeUserID, userID, lang, text, child) + case "exchange_management": + answer, handled = a.handleExchangeManagementSkill(storeUserID, userID, lang, text, child) + case "model_management": + answer, handled = a.handleModelManagementSkill(storeUserID, userID, lang, text, child) + } + if !handled { + answer = "" + } + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, childSkill, "update", answer) + } + } + } setSkillDAGStep(&session, "collect_bindings") a.saveSkillSession(userID, session) if lang == "zh" { - return "这次是更新交易员绑定,请直接说要换成哪个模型、交易所或策略。" + if selectedField != "" { + return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang)) + } + switch session.Action { + case "configure_strategy": + return "好,我来帮你换策略。直接告诉我想用哪个策略就行。" + case "configure_exchange": + return "好,我来帮你换交易所。直接告诉我想用哪个交易所就行。" + case "configure_model": + return "好,我来帮你换模型。直接告诉我想用哪个模型就行。" + default: + return "好,我来帮你调整交易员绑定。你直接告诉我想换成哪个模型、交易所或策略就行。" + } + } + if selectedField != "" { + return fmt.Sprintf("One more thing: tell me which %s you want to use.", displayCatalogFieldName(selectedField, lang)) + } + switch session.Action { + case "configure_strategy": + return "Sure. Tell me which strategy you want to use." + case "configure_exchange": + return "Sure. Tell me which exchange you want to use." + case "configure_model": + return "Sure. Tell me which model you want to use." + default: + return "Sure. Tell me which model, exchange, or strategy you want to switch to." } - return "This action updates trader bindings. Tell me which model, exchange, or strategy to switch to." } 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 + return "这次没改成功:" + errMsg } - return "Failed to update trader bindings: " + errMsg + return "That change did not go through: " + errMsg } + a.rememberReferencesFromToolResult(userID, "manage_trader", resp) if lang == "zh" { - return "已更新交易员绑定。" + switch session.Action { + case "configure_strategy": + return "已更新交易员策略。" + case "configure_exchange": + return "已更新交易员交易所。" + case "configure_model": + return "已更新交易员模型。" + default: + return "已更新交易员绑定。" + } + } + switch session.Action { + case "configure_strategy": + return "Updated the trader strategy." + case "configure_exchange": + return "Updated the trader exchange." + case "configure_model": + return "Updated the trader model." + default: + return "Updated trader bindings." } - return "Updated trader bindings." } if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "collect_name") } - args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID} - if minutes, ok := parseScanIntervalMinutes(text); ok && minutes > 0 { - args.ScanIntervalMinutes = &minutes + parsedArgs := buildTraderUpdateArgsFromSession(session) + if !parsedArgs.hasAny() { + parsedArgs = buildTraderUpdateArgs(a, storeUserID, text) } - if value, ok := extractStrategyConfigValue(text, "btceth_max_leverage"); ok { - if parsed, err := strconv.Atoi(value); err == nil { - args.BTCETHLeverage = &parsed + selectedField := fieldValue(session, "update_field") + selectedFieldJustChosen := false + if selectedField == "" { + if session.Action == "update_name" { + selectedField = "name" + } else if !parsedArgs.hasAny() { + selectedField = detectCatalogField(text, traderFieldCatalog) + } + if selectedField != "" { + setField(&session, "update_field", selectedField) + selectedFieldJustChosen = true } } - if value, ok := extractStrategyConfigValue(text, "altcoin_max_leverage"); ok { - if parsed, err := strconv.Atoi(value); err == nil { - args.AltcoinLeverage = &parsed + 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 "initial_balance": + if parsed, err := strconv.ParseFloat(value, 64); err == nil { + parsedArgs.InitialBalance = &parsed + } + 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) + } } } - if prompt := extractCredentialValue(text, []string{"自定义提示词", "提示词", "custom prompt", "prompt"}); prompt != "" && - containsAny(strings.ToLower(text), []string{"提示词", "prompt"}) { - args.CustomPrompt = prompt - } - if enabled, ok := parseFlagValue(text, []string{"ai500"}); ok { - args.UseAI500 = &enabled - } - if enabled, ok := parseFlagValue(text, []string{"oi top", "oitop", "持仓量排名"}); ok { - args.UseOITop = &enabled - } - if args.ScanIntervalMinutes != nil || args.BTCETHLeverage != nil || args.AltcoinLeverage != nil || args.CustomPrompt != "" || args.UseAI500 != nil || args.UseOITop != nil { + applyTraderUpdateArgsToSession(&session, parsedArgs) + parsedArgs = mergeTraderUpdateArgs(buildTraderUpdateArgsFromSession(session), parsedArgs) + if parsedArgs.hasAny() { + 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, + } 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 + return "这次没改成功:" + errMsg } - return "Failed to update trader: " + errMsg + return "That change did not go through: " + errMsg } if lang == "zh" { - return "已更新交易员配置。" + reply := "已更新交易员配置。" + if len(warnings) > 0 { + reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") + } + return reply } - return "Updated trader config." - } - newName := extractTraderName(text) - if newName == "" { - newName = extractPostKeywordName(text, []string{"改成", "改为", "rename to"}) + reply := "Updated trader config." + if len(warnings) > 0 { + reply += "\n\nAdjusted to stay within the manual editor limits:\n- " + strings.Join(warnings, "\n- ") + } + return reply } + newName := "" if newName != "" { setField(&session, "name", newName) } newName = fieldValue(session, "name") if newName == "" { - setSkillDAGStep(&session, "collect_name") + if selectedField != "" { + setSkillDAGStep(&session, "collect_field_value") + } else { + setSkillDAGStep(&session, "collect_name") + } a.saveSkillSession(userID, session) if lang == "zh" { - return "目前更新交易员这条 skill 先支持改名。请直接告诉我新的名字。" + 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 "你可以直接告诉我想改哪一项,比如名称、扫描频率、初始资金、杠杆,或者绑定的模型、交易所、策略。" } - return "This trader update skill currently supports renaming first. Tell me the new name." + 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("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang)) + } + return "Tell me what you want to change first, for example the name, scan interval, balance, leverage, or the linked model, exchange, or strategy." } - args = manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID, Name: newName} + 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 + return "这次没改成功:" + errMsg } - return "Failed to update trader: " + errMsg + return "That change did not go through: " + errMsg } if lang == "zh" { return fmt.Sprintf("已将交易员改名为“%s”。", newName) @@ -517,7 +2175,7 @@ func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64 if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "await_confirmation") } - if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { a.saveSkillSession(userID, session) return msg } @@ -531,14 +2189,14 @@ func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "删除交易所配置失败:" + errMsg + return "这次没删成功:" + errMsg } - return "Failed to delete exchange config: " + errMsg + return "That delete did not go through: " + errMsg } if lang == "zh" { - return "已删除交易所配置。" + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "delete", "已删除交易所配置。") } - return "Deleted exchange config." + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "delete", "Deleted exchange config.") case "update", "update_name", "update_status": if fieldValue(session, skillDAGStepField) == "" { if session.Action == "update_status" { @@ -547,48 +2205,69 @@ func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64 setSkillDAGStep(&session, "collect_account_name") } } - accountName := extractTraderName(text) - if accountName == "" { - accountName = extractPostKeywordName(text, []string{"改成", "改为", "账户名改成", "rename to"}) - } - if accountName != "" { - setField(&session, "account_name", accountName) - } - if enabled, ok := parseEnabledValue(text); ok { - setField(&session, "enabled", strconv.FormatBool(enabled)) - } - if value := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); value != "" { - setField(&session, "api_key", value) - } - if value := extractCredentialValue(text, []string{"secret key", "secret", "secret_key"}); value != "" { - setField(&session, "secret_key", value) - } - if value := extractCredentialValue(text, []string{"passphrase", "密码短语"}); value != "" { - setField(&session, "passphrase", value) - } - if testnet, ok := parseFlagValue(text, []string{"testnet", "测试网"}); ok { - setField(&session, "testnet", strconv.FormatBool(testnet)) + patch := buildExchangeUpdatePatchFromSession(session) + selectedField := fieldValue(session, "update_field") + if selectedField == "" && session.Action == "update_status" { + selectedField = "enabled" + setField(&session, "update_field", selectedField) } + applyExchangeUpdatePatchToSession(&session, patch) + patch = mergeExchangeUpdatePatch(buildExchangeUpdatePatchFromSession(session), patch) + patch, warnings := normalizeExchangePatchToManualLimits(lang, patch) + applyExchangeUpdatePatchToSession(&session, patch) payload := map[string]any{"action": "update", "exchange_id": session.TargetRef.ID} - accountName = fieldValue(session, "account_name") + accountName := defaultIfEmpty(patch.AccountName, fieldValue(session, "account_name")) if accountName != "" && session.Action != "update_status" { payload["account_name"] = accountName } - if enabledRaw := fieldValue(session, "enabled"); enabledRaw != "" { + enabledRaw := fieldValue(session, "enabled") + if patch.Enabled != nil { + enabledRaw = strconv.FormatBool(*patch.Enabled) + } + if enabledRaw != "" { payload["enabled"] = enabledRaw == "true" } - if value := fieldValue(session, "api_key"); value != "" { + if value := defaultIfEmpty(patch.APIKey, fieldValue(session, "api_key")); value != "" { payload["api_key"] = value } - if value := fieldValue(session, "secret_key"); value != "" { + if value := defaultIfEmpty(patch.SecretKey, fieldValue(session, "secret_key")); value != "" { payload["secret_key"] = value } - if value := fieldValue(session, "passphrase"); value != "" { + if value := defaultIfEmpty(patch.Passphrase, fieldValue(session, "passphrase")); value != "" { payload["passphrase"] = value } - if value := fieldValue(session, "testnet"); value != "" { + testnetRaw := fieldValue(session, "testnet") + if patch.Testnet != nil { + testnetRaw = strconv.FormatBool(*patch.Testnet) + } + if value := testnetRaw; value != "" { payload["testnet"] = value == "true" } + if value := defaultIfEmpty(patch.HyperliquidWalletAddr, fieldValue(session, "hyperliquid_wallet_addr")); value != "" { + payload["hyperliquid_wallet_addr"] = value + } + if value := defaultIfEmpty(patch.AsterUser, fieldValue(session, "aster_user")); value != "" { + payload["aster_user"] = value + } + if value := defaultIfEmpty(patch.AsterSigner, fieldValue(session, "aster_signer")); value != "" { + payload["aster_signer"] = value + } + if value := defaultIfEmpty(patch.AsterPrivateKey, fieldValue(session, "aster_private_key")); value != "" { + payload["aster_private_key"] = value + } + if value := defaultIfEmpty(patch.LighterWalletAddr, fieldValue(session, "lighter_wallet_addr")); value != "" { + payload["lighter_wallet_addr"] = value + } + if value := defaultIfEmpty(patch.LighterAPIKeyPrivateKey, fieldValue(session, "lighter_api_key_private_key")); value != "" { + payload["lighter_api_key_private_key"] = value + } + if patch.LighterAPIKeyIndex != nil { + payload["lighter_api_key_index"] = *patch.LighterAPIKeyIndex + } else if value := fieldValue(session, "lighter_api_key_index"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + payload["lighter_api_key_index"] = parsed + } + } if session.Action == "update_status" { delete(payload, "account_name") } @@ -596,13 +2275,41 @@ func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64 if session.Action == "update_status" { setSkillDAGStep(&session, "collect_enabled") } else { - setSkillDAGStep(&session, "collect_account_name") + if selectedField != "" { + setSkillDAGStep(&session, "collect_field_value") + } else { + setSkillDAGStep(&session, "collect_account_name") + } } a.saveSkillSession(userID, session) if lang == "zh" { - return "目前更新交易所 skill 支持改账户名、启用状态、API Key、Secret、Passphrase 和 testnet。请告诉我你要改什么。" + if selectedField != "" { + return fmt.Sprintf("还差一步:请告诉我你想把交易所配置里的%s改成什么。", displayCatalogFieldName(selectedField, lang)) + } + return "你可以直接告诉我想改交易所配置里的哪一项,比如账户名、启用开关、API Key、Passphrase、钱包地址或 testnet。" } - return "This exchange update skill supports account name, enabled state, API key, secret, passphrase, and testnet." + if selectedField != "" { + return fmt.Sprintf("One more thing: tell me what you want to change the exchange config %s to.", displayCatalogFieldName(selectedField, lang)) + } + return "Tell me which exchange config field you want to change, for example the account name, enabled switch, API key, passphrase, wallet address, or testnet." + } + if err := a.validateExchangeDraft( + storeUserID, + session.TargetRef.ID, + "", + payload["enabled"] == true, + asString(payload["api_key"]), + asString(payload["secret_key"]), + asString(payload["passphrase"]), + asString(payload["hyperliquid_wallet_addr"]), + asString(payload["aster_user"]), + asString(payload["aster_signer"]), + asString(payload["aster_private_key"]), + asString(payload["lighter_wallet_addr"]), + asString(payload["lighter_api_key_private_key"]), + ); err != nil { + a.saveSkillSession(userID, session) + return formatValidationFeedback(lang, "exchange", err) } setSkillDAGStep(&session, "execute_update") raw, _ := json.Marshal(payload) @@ -610,14 +2317,23 @@ func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "更新交易所配置失败:" + errMsg + return "这次没改成功:" + errMsg } - return "Failed to update exchange config: " + errMsg + return "That change did not go through: " + errMsg } + a.rememberReferencesFromToolResult(userID, "manage_exchange_config", resp) if lang == "zh" { - return "已更新交易所配置。" + reply := "已更新交易所配置。" + if len(warnings) > 0 { + reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") + } + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "update", reply) } - return "Updated exchange config." + reply := "Updated exchange config." + if len(warnings) > 0 { + reply += "\n\nAdjusted to stay within the manual editor limits:\n- " + strings.Join(warnings, "\n- ") + } + return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "update", reply) default: return "" } @@ -634,7 +2350,7 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "await_confirmation") } - if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { a.saveSkillSession(userID, session) return msg } @@ -648,9 +2364,9 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "删除模型配置失败:" + errMsg + return "这次没删成功:" + errMsg } - return "Failed to delete model config: " + errMsg + return "That delete did not go through: " + errMsg } if lang == "zh" { return "已删除模型配置。" @@ -668,28 +2384,38 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l } } payload := map[string]any{"action": "update", "model_id": session.TargetRef.ID} - if url := extractURL(text); url != "" { - setField(&session, "custom_api_url", url) + patch := buildModelUpdatePatchFromSession(session) + selectedField := fieldValue(session, "update_field") + if selectedField == "" { + switch session.Action { + case "update_status": + selectedField = "enabled" + case "update_endpoint": + selectedField = "custom_api_url" + } + if selectedField != "" { + setField(&session, "update_field", selectedField) + } } - if enabled, ok := parseEnabledValue(text); ok { - setField(&session, "enabled", strconv.FormatBool(enabled)) + applyModelUpdatePatchToSession(&session, patch) + patch = mergeModelUpdatePatch(buildModelUpdatePatchFromSession(session), patch) + urlValue := patch.CustomAPIURL + enabledValue := "" + if patch.Enabled != nil { + enabledValue = strconv.FormatBool(*patch.Enabled) } - if apiKey := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); apiKey != "" { - setField(&session, "api_key", apiKey) - } - if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成"}); modelName != "" { - setField(&session, "custom_model_name", modelName) - } - if value := fieldValue(session, "custom_api_url"); value != "" { + apiKeyValue := patch.APIKey + modelNameValue := patch.CustomModelName + if value := defaultIfEmpty(urlValue, fieldValue(session, "custom_api_url")); value != "" { payload["custom_api_url"] = value } - if value := fieldValue(session, "enabled"); value != "" { + if value := defaultIfEmpty(enabledValue, fieldValue(session, "enabled")); value != "" { payload["enabled"] = value == "true" } - if value := fieldValue(session, "api_key"); value != "" { + if value := defaultIfEmpty(apiKeyValue, fieldValue(session, "api_key")); value != "" { payload["api_key"] = value } - if value := fieldValue(session, "custom_model_name"); value != "" { + if value := defaultIfEmpty(modelNameValue, fieldValue(session, "custom_model_name")); value != "" { payload["custom_model_name"] = value } if session.Action == "update_name" { @@ -714,13 +2440,35 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l case "update_endpoint": setSkillDAGStep(&session, "collect_custom_api_url") default: - setSkillDAGStep(&session, "collect_custom_model_name") + if selectedField != "" { + setSkillDAGStep(&session, "collect_field_value") + } else { + setSkillDAGStep(&session, "collect_custom_model_name") + } } a.saveSkillSession(userID, session) if lang == "zh" { - return "目前更新模型 skill 支持改 API Key、URL、模型名和启用状态。请告诉我你要改什么。" + if selectedField != "" { + return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang)) + } + return "你可以直接告诉我想改哪一项,比如模型名称、接口地址,或者开关状态。" } - return "This model update skill supports API key, URL, model name, and enabled state." + if selectedField != "" { + return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang)) + } + return "Tell me what you want to change, for example the model name, endpoint URL, or on or off status." + } + if err := a.validateModelDraft( + storeUserID, + session.TargetRef.ID, + "", + payload["enabled"] == true, + asString(payload["api_key"]), + asString(payload["custom_api_url"]), + asString(payload["custom_model_name"]), + ); err != nil { + a.saveSkillSession(userID, session) + return formatValidationFeedback(lang, "model", err) } setSkillDAGStep(&session, "execute_update") raw, _ := json.Marshal(payload) @@ -731,12 +2479,13 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l if strings.Contains(errMsg, "cannot enable model config before API key is configured") { return "更新模型配置失败:这个模型还没有配置 API Key,暂时不能启用。你可以直接把 API Key 发给我,我帮你继续配置。" } - return "更新模型配置失败:" + errMsg + return "这次没改成功:" + errMsg } a.saveSkillSession(userID, session) - return "Failed to update model config: " + errMsg + return "That change did not go through: " + errMsg } a.clearSkillSession(userID) + a.rememberReferencesFromToolResult(userID, "manage_model_config", resp) if lang == "zh" { if session.Action == "update_status" { return "已更新模型配置启用状态。" @@ -764,9 +2513,9 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "激活策略失败:" + errMsg + return "这次没激活成功:" + errMsg } - return "Failed to activate strategy: " + errMsg + return "That activation did not go through: " + errMsg } if lang == "zh" { return "已激活策略。" @@ -776,21 +2525,17 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "collect_name") } - newName := extractTraderName(text) - if newName == "" { - newName = extractPostKeywordName(text, []string{"叫", "名为", "改成", "rename to"}) - } + newName := fieldValue(session, "name") if newName != "" { setField(&session, "name", newName) } - newName = fieldValue(session, "name") if newName == "" { setSkillDAGStep(&session, "collect_name") a.saveSkillSession(userID, session) if lang == "zh" { - return "复制策略时,我还需要一个新名称。" + return "还差一步:请给这个新策略起个名字。" } - return "I still need a new name for the duplicated strategy." + return "One more thing: give the new strategy a name." } setSkillDAGStep(&session, "execute_duplicate") raw, _ := json.Marshal(map[string]any{"action": "duplicate", "strategy_id": session.TargetRef.ID, "name": newName}) @@ -798,9 +2543,9 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "复制策略失败:" + errMsg + return "这次没复制成功:" + errMsg } - return "Failed to duplicate strategy: " + errMsg + return "That copy did not go through: " + errMsg } if lang == "zh" { return fmt.Sprintf("已复制策略,新名称为“%s”。", newName) @@ -814,9 +2559,9 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 strategies, err := a.store.Strategy().List(storeUserID) if err != nil { if lang == "zh" { - return "读取策略列表失败:" + err.Error() + return "我这边暂时没读到策略列表:" + err.Error() } - return "Failed to load strategies: " + err.Error() + return "I could not load the strategy list just now: " + err.Error() } deletable := make([]*store.Strategy, 0, len(strategies)) @@ -840,7 +2585,7 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 } targetLabel := fmt.Sprintf("全部自定义策略(共 %d 个)", len(deletable)) - if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting { + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting { a.saveSkillSession(userID, session) return msg } @@ -869,7 +2614,7 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 parts = append(parts, fmt.Sprintf("已跳过系统默认策略 %d 个。", skippedDefault)) } if len(failedNames) > 0 { - parts = append(parts, "删除失败:"+strings.Join(failedNames, ";")) + parts = append(parts, "这些没删成功:"+strings.Join(failedNames, ";")) } if len(deletedNames) > 0 { parts = append(parts, "已删除:"+strings.Join(deletedNames, "、")) @@ -882,14 +2627,14 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 parts = append(parts, fmt.Sprintf("Skipped %d default strategy(ies).", skippedDefault)) } if len(failedNames) > 0 { - parts = append(parts, "Failed: "+strings.Join(failedNames, "; ")) + 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") } - if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { + if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { a.saveSkillSession(userID, session) return msg } @@ -903,9 +2648,9 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "删除策略失败:" + errMsg + return "这次没删成功:" + errMsg } - return "Failed to delete strategy: " + errMsg + return "That delete did not go through: " + errMsg } if lang == "zh" { return "已删除策略。" @@ -915,27 +2660,26 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 if session.Action == "update_prompt" { return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session) } - if session.Action == "update_config" { + if session.Action == "update_config" || + fieldValue(session, strategyPendingUpdateConfigField) != "" || + fieldValue(session, "config_field") != "" || + fieldValue(session, "config_value") != "" { return a.executeStrategyConfigUpdate(storeUserID, userID, lang, text, session) } if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "collect_name") } - newName := extractTraderName(text) - if newName == "" { - newName = extractPostKeywordName(text, []string{"改成", "改为", "rename to"}) - } + newName := fieldValue(session, "name") if newName != "" { setField(&session, "name", newName) } - newName = fieldValue(session, "name") if newName == "" { setSkillDAGStep(&session, "collect_name") a.saveSkillSession(userID, session) if lang == "zh" { - return "目前更新策略 skill 先支持改名。请告诉我新的策略名称。" + return "目前这里先支持改策略名称。你直接把新名字发给我就行。" } - return "This strategy update skill currently supports renaming first." + return "For now, this step supports renaming the strategy. Just send me the new name." } setSkillDAGStep(&session, "execute_update") raw, _ := json.Marshal(map[string]any{"action": "update", "strategy_id": session.TargetRef.ID, "name": newName}) @@ -943,9 +2687,9 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64 a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "更新策略失败:" + errMsg + return "这次没改成功:" + errMsg } - return "Failed to update strategy: " + errMsg + return "That change did not go through: " + errMsg } if lang == "zh" { return fmt.Sprintf("已将策略改名为“%s”。", newName) @@ -963,26 +2707,46 @@ func (a *Agent) executeStrategyPromptUpdate(storeUserID string, userID int64, la strategy, cfg, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID) if err != nil { if lang == "zh" { - return "读取策略失败:" + err.Error() + return "我这边暂时没读到这份策略:" + err.Error() } - return "Failed to load strategy: " + err.Error() + return "I could not load that strategy just now: " + err.Error() } - prompt := extractQuotedContent(text) + prompt := fieldValue(session, "prompt") if prompt == "" { - prompt = extractPostKeywordName(text, []string{"prompt改成", "prompt 改成", "提示词改成", "提示词改为", "custom prompt 改成"}) + prompt = fieldValue(session, "custom_prompt") + if prompt != "" { + setField(&session, "prompt", prompt) + } } - if prompt != "" { - setField(&session, "prompt", prompt) + if generatedDraftRequiresConfirmation(session) { + switch { + case createConfirmationReply(text): + clearGeneratedDraftConfirmation(&session) + case isNoReply(text): + clearGeneratedDraftConfirmation(&session, "prompt", "custom_prompt") + setSkillDAGStep(&session, "collect_prompt") + session.Phase = "collecting" + a.saveSkillSession(userID, session) + if lang == "zh" { + return "好,我先不用这版草稿。你可以告诉我想保留的风格,或者直接让我重新设计一版 prompt。" + } + return "Okay, I won't use that draft. Tell me the style you want to keep, or ask me to draft another prompt." + } + } + if prompt == "" { + prompt = extractQuotedContent(text) + if prompt != "" { + setField(&session, "prompt", prompt) + } } - prompt = fieldValue(session, "prompt") if prompt == "" { setSkillDAGStep(&session, "collect_prompt") a.saveSkillSession(userID, session) if lang == "zh" { - return "这次是更新策略 prompt,请直接把新的 prompt 内容发给我,最好放在引号里。" + return "还差一步:请把新的提示词内容发给我,直接发正文就行。" } - return "This action updates the strategy prompt. Send me the new prompt text, ideally inside quotes." + return "One more thing: send me the new prompt text." } cfg.CustomPrompt = prompt @@ -991,6 +2755,36 @@ func (a *Agent) executeStrategyPromptUpdate(storeUserID string, userID int64, la } func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, lang, text string, session skillSession) string { + if rawPending := fieldValue(session, strategyPendingUpdateConfigField); rawPending != "" { + if createConfirmationReply(text) { + var pendingCfg store.StrategyConfig + if err := json.Unmarshal([]byte(rawPending), &pendingCfg); err != nil { + if session.Fields != nil { + delete(session.Fields, strategyPendingUpdateConfigField) + delete(session.Fields, strategyPendingUpdateWarnings) + delete(session.Fields, strategyPendingUpdateZhMsg) + delete(session.Fields, strategyPendingUpdateEnMsg) + } + session.Phase = "collecting" + a.saveSkillSession(userID, session) + if lang == "zh" { + return "我这边暂时没读到刚才那版草稿。你再告诉我想改哪一项,我马上继续。" + } + return "I could not read that draft just now. Tell me what you want to change and I will continue." + } + zhMsg := defaultIfEmpty(fieldValue(session, strategyPendingUpdateZhMsg), "已更新策略参数。") + enMsg := defaultIfEmpty(fieldValue(session, strategyPendingUpdateEnMsg), "Updated strategy config.") + return a.persistPendingStrategyConfigUpdate(storeUserID, userID, lang, session, pendingCfg, zhMsg, enMsg) + } + if session.Fields != nil { + delete(session.Fields, strategyPendingUpdateConfigField) + delete(session.Fields, strategyPendingUpdateWarnings) + delete(session.Fields, strategyPendingUpdateZhMsg) + delete(session.Fields, strategyPendingUpdateEnMsg) + } + session.Phase = "collecting" + } + if _, ok := getSkillDAG("strategy_management", "update_config"); ok { if fieldValue(session, skillDAGStepField) == "" { setSkillDAGStep(&session, "resolve_config_field") @@ -1001,9 +2795,31 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la strategy, cfg, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID) if err != nil { if lang == "zh" { - return "读取策略失败:" + err.Error() + return "我这边暂时没读到这份策略:" + err.Error() + } + return "I could not load that strategy just now: " + err.Error() + } + + if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" { + if generated := fieldValue(session, "custom_prompt"); generated != "" { + setField(&session, "config_field", "custom_prompt") + setField(&session, "config_value", generated) + } + } + if generatedDraftRequiresConfirmation(session) { + switch { + case createConfirmationReply(text): + clearGeneratedDraftConfirmation(&session) + case isNoReply(text): + clearGeneratedDraftConfirmation(&session, "config_field", "config_value", "custom_prompt") + setSkillDAGStep(&session, "resolve_config_field") + session.Phase = "collecting" + a.saveSkillSession(userID, session) + if lang == "zh" { + return "好,我先不用这版草稿。你可以直接告诉我要改哪个配置,或者继续让我重新设计一版。" + } + return "Okay, I won't use that draft. Tell me which config to change, or ask me to draft another version." } - return "Failed to load strategy: " + err.Error() } if fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" { @@ -1014,17 +2830,29 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la if err := applyStrategyConfigPatch(&cfg, patch.Field, patch.Value); err != nil { a.saveSkillSession(userID, session) if lang == "zh" { - return "更新策略参数失败:" + err.Error() + return "这次没改成功:" + err.Error() } - return "Failed to update strategy config: " + err.Error() + return "That change did not go through: " + err.Error() + } + switch patch.Field { + case "description": + strategy.Description = patch.Value + case "is_public": + strategy.IsPublic = patch.Value == "true" + case "config_visible": + strategy.ConfigVisible = patch.Value == "true" } changed = append(changed, strategyConfigFieldDisplayName(patch.Field, lang)) } + beforeClamp := cfg cfg.ClampLimits() setSkillDAGStep(&session, "apply_field_update") - setSkillDAGStep(&session, "execute_update") msgZH := "已更新策略参数:" + strings.Join(changed, "、") + "。" msgEN := "Updated strategy config fields: " + strings.Join(changed, ", ") + "." + if warnings := store.StrategyClampWarnings(beforeClamp, cfg, lang); len(warnings) > 0 { + return a.deferStrategyRiskControlledUpdate(userID, lang, &session, cfg, warnings, msgZH, msgEN) + } + setSkillDAGStep(&session, "execute_update") return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN) } } @@ -1044,16 +2872,18 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la setSkillDAGStep(&session, "resolve_config_field") a.saveSkillSession(userID, session) if lang == "zh" { - return "这次是更新策略参数。我当前先支持这些字段:最大持仓、最低置信度、主周期、多周期时间框架。请先告诉我要改哪个字段。" + return "你可以直接告诉我想改哪一项,比如币种来源、杠杆、时间周期、技术指标,或者提示词。" } - return "This action updates strategy config. I currently support max positions, min confidence, primary timeframe, and selected timeframes. Tell me which field to change first." + return "Tell me what you want to change, for example coin source, leverage, timeframes, indicators, or the prompt." } - if value, ok := extractStrategyConfigValue(text, field); ok { - setField(&session, "config_value", value) - if currentStep.ID == "resolve_config_value" { - advanceSkillDAGStep(&session, currentStep.ID) - currentStep, _ = currentSkillDAGStep(session) + if fieldValue(session, "config_value") == "" { + if value, ok := extractStrategyConfigValue(text, field); ok { + setField(&session, "config_value", value) + if currentStep.ID == "resolve_config_value" { + advanceSkillDAGStep(&session, currentStep.ID) + currentStep, _ = currentSkillDAGStep(session) + } } } value := fieldValue(session, "config_value") @@ -1061,9 +2891,9 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la setSkillDAGStep(&session, "resolve_config_value") a.saveSkillSession(userID, session) if lang == "zh" { - return fmt.Sprintf("要更新策略参数,我还需要 %s 的目标值。", strategyConfigFieldDisplayName(field, lang)) + return fmt.Sprintf("还差一步:请告诉我新的%s。", strategyConfigFieldDisplayName(field, lang)) } - return fmt.Sprintf("I still need the target value for %s.", strategyConfigFieldDisplayName(field, lang)) + return fmt.Sprintf("One more thing: tell me the new %s.", strategyConfigFieldDisplayName(field, lang)) } if err := applyStrategyConfigPatch(&cfg, field, value); err != nil { @@ -1074,7 +2904,16 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la } return err.Error() } + switch field { + case "description": + strategy.Description = value + case "is_public": + strategy.IsPublic = value == "true" + case "config_visible": + strategy.ConfigVisible = value == "true" + } + beforeClamp := cfg cfg.ClampLimits() changed := []string{field} displayChanged := make([]string, 0, len(changed)) @@ -1084,6 +2923,9 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la msgZH := "已更新策略参数:" + strings.Join(displayChanged, "、") + "。" msgEN := "Updated strategy config fields: " + strings.Join(displayChanged, ", ") + "." setSkillDAGStep(&session, "apply_field_update") + if warnings := store.StrategyClampWarnings(beforeClamp, cfg, lang); len(warnings) > 0 { + return a.deferStrategyRiskControlledUpdate(userID, lang, &session, cfg, warnings, msgZH, msgEN) + } setSkillDAGStep(&session, "execute_update") return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN) } @@ -1100,26 +2942,75 @@ func (a *Agent) loadStrategyConfigForUpdate(storeUserID, strategyID string) (*st return strategy, cfg, nil } +func (a *Agent) deferStrategyRiskControlledUpdate(userID int64, lang string, session *skillSession, cfg store.StrategyConfig, warnings []string, zhMsg, enMsg string) string { + rawConfig, _ := json.Marshal(cfg) + setField(session, strategyPendingUpdateConfigField, string(rawConfig)) + setField(session, strategyPendingUpdateWarnings, marshalStringList(warnings)) + setField(session, strategyPendingUpdateZhMsg, zhMsg) + setField(session, strategyPendingUpdateEnMsg, enMsg) + session.Phase = "await_confirmation" + setSkillDAGStep(session, "await_confirmation") + a.saveSkillSession(userID, *session) + task := SuspendedTask{ + Kind: "skill_session", + SkillSession: func() *skillSession { + copy := normalizeSkillSession(*session) + return © + }(), + ResumeHint: buildSkillResumeHint(lang, *session), + } + a.SnapshotManager(userID).Save(task) + return formatRiskControlAcceptancePrompt(lang, warnings, "确认应用") +} + +func (a *Agent) persistPendingStrategyConfigUpdate(storeUserID string, userID int64, lang string, session skillSession, cfg store.StrategyConfig, zhMsg, enMsg string) string { + if session.Fields != nil { + delete(session.Fields, strategyPendingUpdateConfigField) + delete(session.Fields, strategyPendingUpdateWarnings) + delete(session.Fields, strategyPendingUpdateZhMsg) + delete(session.Fields, strategyPendingUpdateEnMsg) + } + strategy, _, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID) + if err != nil { + if lang == "zh" { + return "我这边暂时没读到这份策略:" + err.Error() + } + return "I could not load that strategy just now: " + err.Error() + } + return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, zhMsg, enMsg) +} + func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, lang string, strategy *store.Strategy, cfg store.StrategyConfig, zhMsg, enMsg string) string { rawConfig, err := json.Marshal(cfg) if err != nil { if lang == "zh" { - return "序列化策略配置失败:" + err.Error() + return "我这边整理这份策略配置时出了点问题:" + err.Error() } - return "Failed to serialize strategy config: " + err.Error() + return "I ran into a problem while preparing that strategy config: " + err.Error() } raw, _ := json.Marshal(map[string]any{ - "action": "update", - "strategy_id": strategy.ID, - "config": json.RawMessage(rawConfig), + "action": "update", + "strategy_id": strategy.ID, + "name": strategy.Name, + "description": strategy.Description, + "is_public": strategy.IsPublic, + "config_visible": strategy.ConfigVisible, + "config": json.RawMessage(rawConfig), }) resp := a.toolManageStrategy(storeUserID, string(raw)) a.clearSkillSession(userID) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if lang == "zh" { - return "更新策略失败:" + errMsg + return "这次没改成功:" + errMsg + } + return "That change did not go through: " + errMsg + } + if warnings := parseToolWarnings(resp); len(warnings) > 0 { + if lang == "zh" { + zhMsg += "\n\n已按安全范围自动调整:\n- " + strings.Join(warnings, "\n- ") + } else { + enMsg += "\n\nAdjusted to stay within safe limits:\n- " + strings.Join(warnings, "\n- ") } - return "Failed to update strategy: " + errMsg } if lang == "zh" { return zhMsg @@ -1127,8 +3018,18 @@ func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, la return enMsg } +func parseToolWarnings(raw string) []string { + var payload struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + return payload.Warnings +} + func extractQuotedContent(text string) string { - if matches := quotedNamePattern.FindStringSubmatch(text); len(matches) == 2 { + if matches := quotedContentRE.FindStringSubmatch(text); len(matches) == 2 { return strings.TrimSpace(matches[1]) } return "" diff --git a/agent/skill_management_handlers.go b/agent/skill_management_handlers.go index ce7bba2b..8f261ab5 100644 --- a/agent/skill_management_handlers.go +++ b/agent/skill_management_handlers.go @@ -1,10 +1,12 @@ package agent import ( + "context" "encoding/json" "fmt" "regexp" "sort" + "strconv" "strings" "nofx/store" @@ -13,137 +15,49 @@ import ( var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`) func detectTraderManagementIntent(text string) bool { + return false +} + +func hasExplicitCreateIntentForDomain(text, domain string) bool { lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { + if lower == "" || !hasExplicitManagementDomainCue(text, domain) { return false } - return containsAny(lower, []string{"交易员", "trader", "agent"}) && - containsAny(lower, []string{"修改", "编辑", "更新", "改", "改一下", "删除", "删了", "启动", "停止", "查看", "查询", "列出", "rename", "update", "delete", "start", "stop", "list", "show"}) + return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"}) } func detectExchangeManagementIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) && - containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"}) + return false } func detectModelManagementIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{"模型", "model", "provider", "deepseek", "openai", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}) && - containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"}) + return false } func detectStrategyManagementIntent(text string) bool { + return false +} + +func hasExplicitDiagnosisIntentForDomain(text, domain string) bool { lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { + if lower == "" || !hasExplicitManagementDomainCue(text, domain) { return false } - if wantsDefaultStrategyConfig(text) { - return true - } - return containsAny(lower, []string{"策略", "strategy"}) && - containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "改成", "改为", "删除", "删了", "查询", "查看", "列出", "激活", "复制", "参数", "配置", "详情", "详细", "prompt", "提示词", "什么样", "怎么样", "create", "update", "delete", "list", "show", "activate", "duplicate", "detail", "details", "config", "configuration", "parameter", "prompt", "what kind"}) -} - -func detectTraderDiagnosisSkill(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - return containsAny(lower, []string{"交易员", "trader"}) && - containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "异常", "失败", "diagnose", "error", "not trading"}) -} - -func detectStrategyDiagnosisSkill(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - return containsAny(lower, []string{"策略", "strategy", "prompt"}) && - containsAny(lower, []string{"不生效", "没生效", "异常", "失败", "不一致", "失效", "diagnose", "error"}) -} - -func detectManagementAction(text string, domain string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return "" - } - hasUpdateVerb := containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update", "切换", "换成", "换到"}) - switch { - case containsAny(lower, []string{"删除", "删掉", "删了", "remove", "delete"}): - return "delete" - case containsAny(lower, []string{"启动", "开始", "run", "start"}) && domain == "trader": - return "start" - case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) && domain == "trader": - return "stop" - case containsAny(lower, []string{"激活", "activate"}) && domain == "strategy": - return "activate" - case containsAny(lower, []string{"复制", "duplicate"}) && domain == "strategy": - return "duplicate" - case containsAny(lower, []string{"改名", "重命名", "rename"}): - return "update_name" - case domain == "trader" && containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}): - return "update_bindings" - case (domain == "exchange" || domain == "model") && containsAny(lower, []string{"启用", "禁用", "enable", "disable"}): - return "update_status" - case domain == "model" && hasUpdateVerb && containsAny(lower, []string{"url", "endpoint", "地址", "接口"}): - return "update_endpoint" - case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{"prompt", "提示词"}): - return "update_prompt" - case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{ - "参数", "配置", "config", "configuration", "parameter", - "最大持仓", "最小置信度", "最低置信度", "主周期", "多周期", "时间框架", - "btc/eth杠杆", "btc eth杠杆", "山寨币杠杆", - "核心指标", "ema", "macd", "rsi", "atr", "boll", "bollinger", "布林", - }): - return "update_config" - case containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update"}): - return "update" - case domain == "trader" && containsAny(lower, []string{"运行中的", "在跑", "running"}): - return "query_running" - case !containsAny(lower, []string{"创建", "新建", "create", "new"}) && - containsAny(lower, []string{"详情", "详细", "prompt", "提示词", "什么样", "怎么样", "detail", "details", "what kind"}): - return "query_detail" - case containsAny(lower, []string{"查询", "查看", "列出", "list", "show", "有哪些"}): - return "query_list" - case containsAny(lower, []string{"创建", "新建", "加一个", "create", "new"}): - return "create" + switch strings.TrimSpace(domain) { + case "trader": + return containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "诊断", "报错", "错误", "diagnose", "not trading"}) + case "strategy": + return containsAny(lower, []string{"不生效", "没生效", "失效", "不一致", "诊断", "报错", "错误", "diagnose"}) + case "model": + return containsAny(lower, []string{"api key", "base url", "custom_api_url", "模型配置失败", "模型不可用", "ai unavailable", "无效", "报错", "错误", "失败", "不可用", "invalid", "error", "failed", "诊断", "diagnose"}) + case "exchange": + return containsAny(lower, []string{"invalid signature", "timestamp", "ip not allowed", "permission denied", "签名错误", "签名失败", "时间戳", "白名单", "权限不足", "交易所 api 报错", "交易所连接不上", "报错", "错误", "失败", "诊断", "diagnose"}) default: - return "" + return false } } -func exchangeTypeFromText(text string) string { - lower := strings.ToLower(text) - candidates := []string{"binance", "okx", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter"} - for _, candidate := range candidates { - if strings.Contains(lower, candidate) { - return candidate - } - } - switch { - case strings.Contains(text, "币安"): - return "binance" - case strings.Contains(text, "欧易"): - return "okx" - case strings.Contains(text, "库币"): - return "kucoin" - default: - return "" - } -} - -func providerFromText(text string) string { - lower := strings.ToLower(text) - candidates := []string{"openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax"} - for _, candidate := range candidates { - if strings.Contains(lower, candidate) { - return candidate - } - } - if strings.Contains(text, "通义") { - return "qwen" - } +func inferExplicitManagementAction(text string, domain string) string { return "" } @@ -151,34 +65,106 @@ func extractURL(text string) string { return strings.TrimSpace(urlPattern.FindString(text)) } -func extractPostKeywordName(text string, keywords []string) string { - trimmed := strings.TrimSpace(text) - for _, keyword := range keywords { - if idx := strings.Index(trimmed, keyword); idx >= 0 { - name := strings.TrimSpace(trimmed[idx+len(keyword):]) - name = strings.Trim(name, "“”\"':: ") - if name != "" && len([]rune(name)) <= 50 { - return name +func setField(session *skillSession, key, value string) { + ensureSkillFields(session) + key = normalizeFieldKey(session, key) + value = strings.TrimSpace(value) + if value == "" { + return + } + if session != nil && session.Name == "trader_management" && key == "name" { + value = normalizeTraderDraftName(value) + if value == "" { + return + } + } + session.Fields[key] = value + syncTraderCreateSlotMirror(session) +} + +func fieldValue(session skillSession, key string) string { + key = normalizeFieldKey(&session, key) + if session.Fields != nil { + if value := strings.TrimSpace(session.Fields[key]); value != "" { + return value + } + } + if session.Name == "trader_management" && session.Slots != nil { + switch key { + case "name": + return strings.TrimSpace(session.Slots.Name) + case "exchange_id": + return strings.TrimSpace(session.Slots.ExchangeID) + case "exchange_name": + return strings.TrimSpace(session.Slots.ExchangeName) + case "model_id": + return strings.TrimSpace(session.Slots.ModelID) + case "model_name": + return strings.TrimSpace(session.Slots.ModelName) + case "strategy_id": + return strings.TrimSpace(session.Slots.StrategyID) + case "strategy_name": + return strings.TrimSpace(session.Slots.StrategyName) + case "auto_start": + if session.Slots.AutoStart != nil { + if *session.Slots.AutoStart { + return "true" + } + return "false" } } } return "" } -func setField(session *skillSession, key, value string) { - ensureSkillFields(session) - value = strings.TrimSpace(value) - if value == "" { - return +func normalizeFieldKey(session *skillSession, key string) string { + key = strings.TrimSpace(key) + if session == nil || session.Name != "trader_management" { + return key + } + switch key { + case "ai_model_id": + return "model_id" + default: + return key } - session.Fields[key] = value } -func fieldValue(session skillSession, key string) string { - if session.Fields == nil { - return "" +func syncTraderCreateSlotMirror(session *skillSession) { + if session == nil || session.Name != "trader_management" { + return + } + if session.Slots == nil { + session.Slots = &createTraderSkillSlots{} + } + if session.Fields == nil { + return + } + if value := strings.TrimSpace(session.Fields["name"]); value != "" { + session.Slots.Name = value + } + if value := strings.TrimSpace(session.Fields["exchange_id"]); value != "" { + session.Slots.ExchangeID = value + } + if value := strings.TrimSpace(session.Fields["exchange_name"]); value != "" { + session.Slots.ExchangeName = value + } + if value := strings.TrimSpace(session.Fields["model_id"]); value != "" { + session.Slots.ModelID = value + } + if value := strings.TrimSpace(session.Fields["model_name"]); value != "" { + session.Slots.ModelName = value + } + if value := strings.TrimSpace(session.Fields["strategy_id"]); value != "" { + session.Slots.StrategyID = value + } + if value := strings.TrimSpace(session.Fields["strategy_name"]); value != "" { + session.Slots.StrategyName = value + } + if value := strings.TrimSpace(session.Fields["auto_start"]); value != "" { + b := strings.EqualFold(value, "true") + session.Slots.AutoStart = &b } - return strings.TrimSpace(session.Fields[key]) } func textMeansAllTargets(text string) bool { @@ -187,7 +173,8 @@ func textMeansAllTargets(text string) bool { return false } return containsAny(lower, []string{ - "全部", "所有", "全都", "全部策略", "所有策略", + "全部", "所有", "全都", "全部策略", "所有策略", "全部删除", "全部删掉", "全部删了", + "全删", "全删了", "都删", "都删了", "全清", "全清掉", "all", "all strategies", "every strategy", }) } @@ -197,34 +184,211 @@ func supportsBulkTargetSelection(skillName, action string) bool { } func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference { - if existing != nil && (existing.ID != "" || existing.Name != "") { - return existing + return resolveTargetSelection(text, options, existing).Ref +} + +func hasStrictOptionMention(text string, options []traderSkillOption) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false } - if match := pickMentionedOption(text, options); match != nil { - return &EntityReference{ID: match.ID, Name: match.Name} + for _, option := range options { + name := strings.ToLower(strings.TrimSpace(option.Name)) + if name != "" && strings.Contains(lower, name) { + return true + } + id := strings.ToLower(strings.TrimSpace(option.ID)) + if id != "" && strings.Contains(lower, id) { + return true + } } - if choice := choosePreferredOption(options); choice != nil { - return &EntityReference{ID: choice.ID, Name: choice.Name} + return false +} + +func shouldUsePatchFallbackForTargetedUpdate(text string, options []traderSkillOption, existing *EntityReference) bool { + if existing != nil && strings.TrimSpace(existing.ID) != "" { + return true + } + if hasStrictOptionMention(text, options) { + return true + } + lower := strings.ToLower(strings.TrimSpace(text)) + if containsAny(lower, []string{"这个", "当前", "该", "this", "current"}) { + return true + } + return len(options) == 1 +} + +func isSimpleEntityMutationAction(action string) bool { + switch strings.TrimSpace(action) { + case "update", "update_name", "update_status", "update_endpoint", "update_bindings", + "configure_strategy", "configure_exchange", "configure_model", + "update_prompt", "update_config", "activate", "duplicate": + return true + default: + return false + } +} + +func hasExplicitManagementDomainCue(text, domain string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + switch strings.TrimSpace(domain) { + case "trader": + return containsAny(lower, []string{"交易员", "trader", "agent"}) + case "exchange": + return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) + case "model": + return containsAny(lower, []string{"模型", "model"}) + case "strategy": + return containsAny(lower, []string{"策略", "strategy"}) + default: + return false + } +} + +func ensureLiveTargetReference(session *skillSession, options []traderSkillOption) bool { + if session == nil || session.TargetRef == nil { + return true + } + match := findOptionByIDOrName(options, defaultIfEmpty(session.TargetRef.ID, session.TargetRef.Name)) + if match == nil { + session.TargetRef = nil + return false + } + session.TargetRef.ID = match.ID + session.TargetRef.Name = defaultIfEmpty(match.Name, session.TargetRef.Name) + return true +} + +func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, session skillSession, options []traderSkillOption) map[string]any { + missing := missingFieldKeysForSkillSession(session) + resources := map[string]any{} + for _, field := range missing { + switch strings.TrimSpace(field) { + case "target_ref": + if len(options) > 0 { + resources["targets"] = options + } + case "exchange_name", "exchange_id", "exchange": + resources["exchanges"] = a.loadExchangeOptions(storeUserID) + case "model_name", "model_id", "ai_model_id", "model": + resources["models"] = a.loadEnabledModelOptions(storeUserID) + case "strategy_name", "strategy_id", "strategy": + resources["strategies"] = a.loadStrategyOptions(storeUserID) + } + } + return resources +} + +func applySkillConversationResultToSession(session *skillSession, result skillConversationResult) { + if session == nil { + return + } + ensureSkillFields(session) + mergeFieldSet := func(values map[string]string, source string) { + for key, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + switch key { + case "target_ref_id": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.ID = value + if source != "" { + session.TargetRef.Source = source + } + case "target_ref_name": + if session.TargetRef == nil { + session.TargetRef = &EntityReference{} + } + session.TargetRef.Name = value + if source != "" { + session.TargetRef.Source = source + } + default: + setField(session, key, value) + } + } + } + mergeFieldSet(result.Extracted, "llm_conversation") + mergeFieldSet(result.DraftGeneratedFields, "llm_generated_draft") + if result.RequiresConfirmationBeforeApply { + setField(session, "_requires_generated_confirmation", "true") + } else if fieldValue(*session, "_requires_generated_confirmation") != "" { + delete(session.Fields, "_requires_generated_confirmation") + } + if session.TargetRef != nil && session.TargetRef.Source == "" { + session.TargetRef.Source = "llm_conversation" + } +} + +func shouldUseLLMConversationForSimpleEntity(skillName, action string) bool { + switch skillName { + case "trader_management": + switch action { + case "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": + return true + } + case "exchange_management": + switch action { + case "update", "update_name", "update_status": + return true + } + case "model_management": + switch action { + case "update", "update_name", "update_status", "update_endpoint": + return true + } + case "strategy_management": + switch action { + case "update", "update_name", "update_prompt", "update_config", "activate", "duplicate": + return true + } + } + switch action { + case "update_bindings", "configure_strategy", "configure_exchange", "configure_model": + return true + default: + return false + } +} + +func (a *Agent) inferredCurrentReferenceForSkill(userID int64, skillName string) *EntityReference { + refs := a.semanticCurrentReferences(userID) + if refs == nil { + return nil + } + switch skillName { + case "trader_management": + return normalizeEntityReference(refs.Trader) + case "exchange_management": + return normalizeEntityReference(refs.Exchange) + case "model_management": + return normalizeEntityReference(refs.Model) + case "strategy_management": + return normalizeEntityReference(refs.Strategy) + default: + return nil } - return nil } func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - action := detectManagementAction(text, "trader") - if session.Name == "trader_management" && session.Action != "" { - action = session.Action - } - if action == "" || action == "create" { + if session.Name != "trader_management" || session.Action == "" { return "", false } + action := session.Action if action == "query_running" { answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)) return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true } if action == "query_detail" { - options := a.loadTraderOptions(storeUserID) - target := resolveTargetFromText(text, options, session.TargetRef) - if detail, ok := a.describeTrader(storeUserID, lang, target); ok { + if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok { return detail, true } return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true @@ -233,20 +397,16 @@ func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, la } func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - action := detectManagementAction(text, "exchange") - if session.Name == "exchange_management" && session.Action != "" { - action = session.Action - } - if action == "" { + if session.Name != "exchange_management" || session.Action == "" { return "", false } + action := session.Action options := a.loadExchangeOptions(storeUserID) switch action { case "query_list": return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true case "query_detail": - target := resolveTargetFromText(text, options, session.TargetRef) - if detail, ok := a.describeExchange(storeUserID, lang, target); ok { + if detail, ok := a.describeExchange(storeUserID, lang, session.TargetRef); ok { return detail, true } return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true @@ -258,20 +418,16 @@ func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, } func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - action := detectManagementAction(text, "model") - if session.Name == "model_management" && session.Action != "" { - action = session.Action - } - if action == "" { + if session.Name != "model_management" || session.Action == "" { return "", false } + action := session.Action options := a.loadEnabledModelOptions(storeUserID) switch action { case "query_list": return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true case "query_detail": - target := resolveTargetFromText(text, options, session.TargetRef) - if detail, ok := a.describeModel(storeUserID, lang, target); ok { + if detail, ok := a.describeModel(storeUserID, lang, session.TargetRef); ok { return detail, true } return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true @@ -283,24 +439,14 @@ func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lan } func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - action := detectManagementAction(text, "strategy") - if session.Name == "strategy_management" && session.Action != "" { - action = session.Action - } - if action == "" && wantsStrategyDetails(text) { - action = "query_detail" - } - if action == "" { + if session.Name != "strategy_management" || session.Action == "" { return "", false } + action := session.Action options := a.loadStrategyOptions(storeUserID) switch action { case "query_detail": - if wantsDefaultStrategyConfig(text) { - return a.describeDefaultStrategyConfig(lang), true - } - target := resolveTargetFromText(text, options, session.TargetRef) - if detail, ok := a.describeStrategy(storeUserID, lang, target); ok { + if detail, ok := a.describeStrategy(storeUserID, lang, session.TargetRef); ok { return detail, true } return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true @@ -313,17 +459,466 @@ func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, } } -func wantsStrategyDetails(text string) bool { +const strategyCreateDraftConfigField = "strategy_create_draft_config" + +func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string { + return nil +} + +func marshalStrategyCreateDraft(cfg store.StrategyConfig) string { + raw, err := json.Marshal(cfg) + if err != nil { + return "" + } + return string(raw) +} + +func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig { + cfg := store.GetDefaultStrategyConfig(lang) + if strings.TrimSpace(raw) == "" { + return cfg + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return store.GetDefaultStrategyConfig(lang) + } + return cfg +} + +func strategyCreateConfirmationReply(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) if lower == "" { return false } + return isYesReply(text) || lower == "确认创建" || lower == "创建吧" || lower == "就按这个创建" || lower == "按这个创建" || lower == "确认应用" || lower == "应用" || lower == "就按这个应用" +} + +func formatStrategyCreateDraftSummary(lang, name string, changedFields, warnings []string) string { + name = strings.TrimSpace(name) + if name == "" { + if lang == "zh" { + name = "未命名策略" + } else { + name = "unnamed strategy" + } + } + if lang == "zh" { + lines := []string{ + fmt.Sprintf("我先把策略草稿整理成了“%s”。", name), + } + if len(changedFields) > 0 { + lines = append(lines, "我已经识别到这些配置意图:") + for _, field := range changedFields { + lines = append(lines, "- "+field) + } + } + if len(warnings) > 0 { + lines = append(lines, "其中有些参数超出了当前安全范围,我先拦下来了:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + lines = append(lines, "你可以继续告诉我其他字段怎么设计;如果接受当前安全范围,也可以直接回复“确认创建”。") + return strings.Join(lines, "\n") + } + lines = append(lines, "你可以继续补充其他字段,比如选币来源、最大持仓、置信度、盈亏比、多周期;如果现在就创建,直接回复“确认创建”。") + return strings.Join(lines, "\n") + } + + lines := []string{ + fmt.Sprintf("I turned that into a draft strategy named %q.", name), + } + if len(changedFields) > 0 { + lines = append(lines, "Recognized fields:") + for _, field := range changedFields { + lines = append(lines, "- "+field) + } + } + if len(warnings) > 0 { + lines = append(lines, "Some values exceeded the current safety limits, so I stopped before creating it:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + lines = append(lines, "You can keep refining the draft, or reply 'confirm' to create it with the safe adjusted values.") + return strings.Join(lines, "\n") + } + lines = append(lines, "You can keep refining the draft, or reply 'confirm' to create it now.") + return strings.Join(lines, "\n") +} + +func createConfirmationReply(text string) bool { + return strategyCreateConfirmationReply(text) +} + +func formatMissingFieldList(lang string, fields []string) string { + if len(fields) == 0 { + return "" + } + if lang == "zh" { + return strings.Join(fields, "、") + } + return strings.Join(fields, ", ") +} + +func availableModelProvidersMessage(lang string) string { + return modelProviderChoicePrompt(lang) +} + +func inferCreateDisplayName(text string) string { + clean := func(value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, "“”\"':: ,,。.;;") + for _, sep := range []string{",", ",", "。", ";", ";", "\n"} { + if idx := strings.Index(value, sep); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + } + for _, marker := range []string{" 交易所", " 模型", " 策略", " exchange", " model", " strategy"} { + if idx := strings.Index(value, marker); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + } + for _, suffix := range []string{"的交易员", "的模型", "的策略", "的交易所", "这个交易员", "这个模型", "这个策略", "这个交易所"} { + if strings.HasSuffix(value, suffix) { + value = strings.TrimSpace(strings.TrimSuffix(value, suffix)) + } + } + return strings.TrimSpace(value) + } + if value := extractDelimitedSegmentAfterKeywords(text, []string{"名称叫", "名字叫", "配置名", "叫", "名为", "名称", "名字是", "called"}); value != "" { + return clean(value) + } + if value := extractQuotedContent(text); value != "" && !containsAny(strings.ToLower(text), []string{"api key", "apikey", "api_key", "secret", "passphrase"}) { + return clean(value) + } + return "" +} + +func formatModelCreateDraftSummary(lang string, session skillSession) string { + providerID := fieldValue(session, "provider") + name := defaultIfEmpty(fieldValue(session, "name"), defaultIfEmpty(defaultModelConfigName(providerID), "未命名模型")) + provider := defaultIfEmpty(providerID, "未选择") + modelName := defaultIfEmpty(fieldValue(session, "custom_model_name"), defaultIfEmpty(defaultModelNameForProvider(providerID), "未设置")) + apiURL := defaultIfEmpty(fieldValue(session, "custom_api_url"), "默认官方地址") + if lang != "zh" { + apiURL = defaultIfEmpty(fieldValue(session, "custom_api_url"), "provider default endpoint") + } + enabled := fieldValue(session, "enabled") == "true" + if lang == "zh" { + lines := []string{ + fmt.Sprintf("我先整理了一份模型配置草稿“%s”。", name), + fmt.Sprintf("- Provider:%s", provider), + fmt.Sprintf("- 配置名称:%s", name), + fmt.Sprintf("- 模型名称:%s", modelName), + fmt.Sprintf("- 接口地址:%s", apiURL), + fmt.Sprintf("- 启用状态:%t(未指定时默认 false)", enabled), + modelProviderDetailedGuidance(lang, providerID), + "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。", + } + return strings.Join(lines, "\n") + } + lines := []string{ + fmt.Sprintf("I prepared a draft model config %q.", name), + fmt.Sprintf("- Provider: %s", provider), + 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), + modelProviderDetailedGuidance(lang, providerID), + "Reply 'confirm' to create it, or keep refining any field.", + } + return strings.Join(lines, "\n") +} + +func formatExchangeCreateDraftSummary(lang string, session skillSession) string { + exType := defaultIfEmpty(fieldValue(session, "exchange_type"), "未选择") + accountName := defaultIfEmpty(fieldValue(session, "account_name"), "未命名账户") + enabled := fieldValue(session, "enabled") == "true" + 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(未指定时默认 false)", testnet), + } + switch exType { + case "binance", "bybit", "gate", "indodax": + lines = append(lines, + fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""), + ) + case "okx", "bitget", "kucoin": + lines = append(lines, + fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""), + fmt.Sprintf("- 已提供 Passphrase:%t", fieldValue(session, "passphrase") != ""), + ) + case "hyperliquid": + lines = append(lines, + fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- Hyperliquid 钱包地址:%s", defaultIfEmpty(fieldValue(session, "hyperliquid_wallet_addr"), "未设置")), + ) + case "aster": + lines = append(lines, + fmt.Sprintf("- Aster User:%s", defaultIfEmpty(fieldValue(session, "aster_user"), "未设置")), + fmt.Sprintf("- Aster Signer:%s", defaultIfEmpty(fieldValue(session, "aster_signer"), "未设置")), + fmt.Sprintf("- 已提供 Aster 私钥:%t", fieldValue(session, "aster_private_key") != ""), + ) + case "lighter": + lines = append(lines, + fmt.Sprintf("- Lighter 钱包地址:%s", defaultIfEmpty(fieldValue(session, "lighter_wallet_addr"), "未设置")), + fmt.Sprintf("- 已提供 Lighter API Key 私钥:%t", fieldValue(session, "lighter_api_key_private_key") != ""), + ) + if value := fieldValue(session, "lighter_api_key_index"); value != "" { + lines = append(lines, fmt.Sprintf("- Lighter API Key Index:%s", value)) + } + default: + lines = append(lines, + fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""), + ) + } + lines = append(lines, "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。") + return strings.Join(lines, "\n") + } + lines := []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("- Testnet: %t (defaults to false if omitted)", testnet), + } + switch exType { + case "binance", "bybit", "gate", "indodax": + lines = append(lines, + fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""), + ) + case "okx", "bitget", "kucoin": + lines = append(lines, + fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""), + fmt.Sprintf("- Passphrase provided: %t", fieldValue(session, "passphrase") != ""), + ) + case "hyperliquid": + lines = append(lines, + fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- Hyperliquid wallet address: %s", defaultIfEmpty(fieldValue(session, "hyperliquid_wallet_addr"), "not set")), + ) + case "aster": + lines = append(lines, + fmt.Sprintf("- Aster user: %s", defaultIfEmpty(fieldValue(session, "aster_user"), "not set")), + fmt.Sprintf("- Aster signer: %s", defaultIfEmpty(fieldValue(session, "aster_signer"), "not set")), + fmt.Sprintf("- Aster private key provided: %t", fieldValue(session, "aster_private_key") != ""), + ) + case "lighter": + lines = append(lines, + fmt.Sprintf("- Lighter wallet address: %s", defaultIfEmpty(fieldValue(session, "lighter_wallet_addr"), "not set")), + fmt.Sprintf("- Lighter API key private key provided: %t", fieldValue(session, "lighter_api_key_private_key") != ""), + ) + if value := fieldValue(session, "lighter_api_key_index"); value != "" { + lines = append(lines, fmt.Sprintf("- Lighter API key index: %s", value)) + } + default: + lines = append(lines, + fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""), + fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""), + ) + } + lines = append(lines, "Reply 'confirm' to create it, or keep refining any field.") + return strings.Join(lines, "\n") +} + +func formatTraderCreateDraftSummary(lang string, session skillSession) string { + args := buildTraderUpdateArgsFromSession(session) + args, warnings := normalizeTraderArgsToManualLimits(lang, args) + scanInterval := 3 + if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 { + scanInterval = *args.ScanIntervalMinutes + } + initialBalance := 0.0 + if args.InitialBalance != nil && *args.InitialBalance > 0 { + initialBalance = *args.InitialBalance + } + isCrossMargin := true + if args.IsCrossMargin != nil { + isCrossMargin = *args.IsCrossMargin + } + showInCompetition := true + if args.ShowInCompetition != nil { + showInCompetition = *args.ShowInCompetition + } + autoStart := fieldValue(session, "auto_start") == "true" + name := defaultIfEmpty(fieldValue(session, "name"), "未命名交易员") + if lang != "zh" { + name = defaultIfEmpty(fieldValue(session, "name"), "unnamed trader") + } + if lang == "zh" { + lines := []string{ + fmt.Sprintf("我先整理了一份交易员草稿“%s”。", name), + fmt.Sprintf("- 名称:%s", name), + fmt.Sprintf("- 交易所:%s", traderCreateExchangeNameOrID(session)), + fmt.Sprintf("- 模型:%s", traderCreateModelNameOrID(session)), + fmt.Sprintf("- 策略:%s", traderCreateStrategyNameOrID(session)), + fmt.Sprintf("- 扫描间隔:%d 分钟(未指定时默认 3)", scanInterval), + fmt.Sprintf("- 初始资金:%.2f(未指定时默认 0)", initialBalance), + fmt.Sprintf("- 全仓模式:%t(未指定时默认 true)", isCrossMargin), + fmt.Sprintf("- 竞技场显示:%t(未指定时默认 true)", showInCompetition), + } + if autoStart { + lines = append(lines, "- 创建后立即启动:true") + if len(warnings) > 0 { + lines = append(lines, "这些字段里有超出手动面板范围的值,我已经先按风控范围收敛:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + } + lines = append(lines, "如果这些字段没问题,直接回复“确认创建并启动”;也可以继续补充或修改任意字段。") + } else { + if len(warnings) > 0 { + lines = append(lines, "这些字段里有超出手动面板范围的值,我已经先按风控范围收敛:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + } + lines = append(lines, "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。") + } + return strings.Join(lines, "\n") + } + lines := []string{ + fmt.Sprintf("I prepared a draft trader %q.", name), + fmt.Sprintf("- Name: %s", name), + fmt.Sprintf("- Exchange: %s", traderCreateExchangeNameOrID(session)), + fmt.Sprintf("- Model: %s", traderCreateModelNameOrID(session)), + fmt.Sprintf("- Strategy: %s", traderCreateStrategyNameOrID(session)), + fmt.Sprintf("- Scan interval: %d minutes (defaults to 3)", scanInterval), + fmt.Sprintf("- Initial balance: %.2f (defaults to 0)", initialBalance), + fmt.Sprintf("- Cross margin: %t (defaults to true)", isCrossMargin), + fmt.Sprintf("- Show in competition: %t (defaults to true)", showInCompetition), + } + if autoStart { + lines = append(lines, "- Start immediately after creation: true") + if len(warnings) > 0 { + lines = append(lines, "Some values exceeded the manual editor limits, so I normalized them first:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + } + lines = append(lines, "Reply 'confirm' to create and start it, or keep refining any field.") + } else { + if len(warnings) > 0 { + lines = append(lines, "Some values exceeded the manual editor limits, so I normalized them first:") + for _, warning := range warnings { + lines = append(lines, "- "+warning) + } + } + lines = append(lines, "Reply 'confirm' to create it, or keep refining any field.") + } + return strings.Join(lines, "\n") +} + +func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, lang, text string, session skillSession) string { + if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, session, text); ok { + a.saveSkillSession(userID, session) + return answer + } + name := fieldValue(session, "name") + if actionRequiresSlot("strategy_management", "create", "name") && strings.TrimSpace(name) == "" { + setSkillDAGStep(&session, "resolve_name") + a.saveSkillSession(userID, session) + if lang == "zh" { + return "要创建策略,我还需要策略名。你可以直接说:创建一个叫“趋势策略A”的策略。" + } + return "One more thing: give this strategy a name." + } + + cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang) + changedFields := applyStrategyCreateIntentToConfig(&cfg, text, lang) + if fieldValue(session, strategyCreateDraftConfigField) == "" && len(changedFields) == 0 { + cfg = store.GetDefaultStrategyConfig(lang) + } + beforeClamp := cfg + cfg.ClampLimits() + warnings := store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language) + + setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg)) + setSkillDAGStep(&session, "await_create_confirmation") + session.Phase = "draft_create" + + if strategyCreateConfirmationReply(text) { + args := map[string]any{ + "action": "create", + "name": name, + "lang": defaultIfEmpty(lang, "zh"), + } + rawCfg, _ := json.Marshal(cfg) + var configMap map[string]any + if err := json.Unmarshal(rawCfg, &configMap); err == nil && len(configMap) > 0 { + args["config"] = configMap + } + raw, _ := json.Marshal(args) + resp := a.toolManageStrategy(storeUserID, string(raw)) + if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { + a.saveSkillSession(userID, session) + if lang == "zh" { + return "创建策略失败:" + errMsg + } + return "That create request did not go through: " + errMsg + } + a.clearSkillSession(userID) + if lang == "zh" { + return fmt.Sprintf("已按当前草稿创建策略“%s”。后续如果还想继续细化参数,直接告诉我就行。", name) + } + return fmt.Sprintf("Created strategy %q from the current draft.", name) + } + + a.saveSkillSession(userID, session) + return formatStrategyCreateDraftSummary(lang, name, changedFields, warnings) +} + +func hasExplicitStrategyDetailIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if !hasExplicitManagementDomainCue(text, "strategy") { + return false + } return containsAny(lower, []string{ - "什么样", "怎么样", "详情", "详细", "参数", "配置", "prompt", "提示词", - "what kind", "details", "detail", "config", "configuration", "parameter", "prompt", + "什么样", "怎么样", "详情", "详细", "prompt", "提示词", + "哪个策略", "哪一个策略", "你改的是哪个策略", "你把哪个策略", + "what kind", "details", "detail", "prompt", "which strategy", }) } +func shouldPreferStrategyQueryDetail(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if !containsAny(lower, []string{"?", "?", "哪个", "哪一个", "哪条", "which"}) { + return false + } + return containsAny(lower, []string{"策略", "strategy"}) +} + +func shouldExplainStrategyRuntimeBoundary(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if !containsAny(lower, []string{"策略", "strategy"}) { + return false + } + if !containsAny(lower, []string{"启动", "运行", "run", "start", "deploy"}) { + return false + } + if containsAny(lower, []string{"交易员", "trader", "机器人", "bot"}) { + return false + } + return true +} + func wantsDefaultStrategyConfig(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) if lower == "" { @@ -580,12 +1175,74 @@ func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReferen } exchange = &payload.ExchangeConfigs[0] } - if lang == "zh" { - return fmt.Sprintf("交易所配置“%s”详情:\n- 交易所:%s\n- 已启用:%t\n- API Key:%t\n- Secret:%t\n- Passphrase:%t\n- Testnet:%t", - defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true + name := defaultIfEmpty(exchange.AccountName, exchange.ID) + credentialLinesZh := make([]string, 0, 8) + credentialLinesEn := make([]string, 0, 8) + addCredentialLine := func(labelZh, labelEn string, present bool) { + credentialLinesZh = append(credentialLinesZh, fmt.Sprintf("- %s:%t", labelZh, present)) + credentialLinesEn = append(credentialLinesEn, fmt.Sprintf("- %s: %t", labelEn, present)) } - return fmt.Sprintf("Exchange config %q details:\n- Exchange: %s\n- Enabled: %t\n- API key present: %t\n- Secret present: %t\n- Passphrase present: %t\n- Testnet: %t", - defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true + switch exchange.ExchangeType { + case "binance", "bybit", "gate", "indodax": + addCredentialLine("API Key", "API key present", exchange.HasAPIKey) + addCredentialLine("Secret", "Secret present", exchange.HasSecretKey) + case "okx", "bitget", "kucoin": + addCredentialLine("API Key", "API key present", exchange.HasAPIKey) + addCredentialLine("Secret", "Secret present", exchange.HasSecretKey) + addCredentialLine("Passphrase", "Passphrase present", exchange.HasPassphrase) + case "hyperliquid": + addCredentialLine("API Key", "API key present", exchange.HasAPIKey) + credentialLinesZh = append(credentialLinesZh, fmt.Sprintf("- Hyperliquid 钱包地址:%s", defaultIfEmpty(exchange.HyperliquidWalletAddr, "未设置"))) + credentialLinesEn = append(credentialLinesEn, fmt.Sprintf("- Hyperliquid wallet address: %s", defaultIfEmpty(exchange.HyperliquidWalletAddr, "not set"))) + case "aster": + credentialLinesZh = append(credentialLinesZh, + fmt.Sprintf("- Aster User:%s", defaultIfEmpty(exchange.AsterUser, "未设置")), + fmt.Sprintf("- Aster Signer:%s", defaultIfEmpty(exchange.AsterSigner, "未设置")), + fmt.Sprintf("- Aster 私钥:%t", exchange.HasAsterPrivateKey), + ) + credentialLinesEn = append(credentialLinesEn, + fmt.Sprintf("- Aster user: %s", defaultIfEmpty(exchange.AsterUser, "not set")), + fmt.Sprintf("- Aster signer: %s", defaultIfEmpty(exchange.AsterSigner, "not set")), + fmt.Sprintf("- Aster private key present: %t", exchange.HasAsterPrivateKey), + ) + case "lighter": + credentialLinesZh = append(credentialLinesZh, + fmt.Sprintf("- Lighter 钱包地址:%s", defaultIfEmpty(exchange.LighterWalletAddr, "未设置")), + fmt.Sprintf("- Lighter API Key 私钥:%t", exchange.HasLighterAPIKey), + fmt.Sprintf("- Lighter API Key Index:%d", exchange.LighterAPIKeyIndex), + ) + credentialLinesEn = append(credentialLinesEn, + fmt.Sprintf("- Lighter wallet address: %s", defaultIfEmpty(exchange.LighterWalletAddr, "not set")), + fmt.Sprintf("- Lighter API key private key present: %t", exchange.HasLighterAPIKey), + fmt.Sprintf("- Lighter API key index: %d", exchange.LighterAPIKeyIndex), + ) + default: + addCredentialLine("API Key", "API key present", exchange.HasAPIKey) + addCredentialLine("Secret", "Secret present", exchange.HasSecretKey) + if exchange.HasPassphrase { + addCredentialLine("Passphrase", "Passphrase present", true) + } + } + if lang == "zh" { + lines := []string{ + fmt.Sprintf("交易所配置“%s”详情:", name), + fmt.Sprintf("- 交易所:%s", exchange.ExchangeType), + fmt.Sprintf("- 账户名:%s", name), + fmt.Sprintf("- 已启用:%t", exchange.Enabled), + fmt.Sprintf("- Testnet:%t", exchange.Testnet), + } + lines = append(lines, credentialLinesZh...) + return strings.Join(lines, "\n"), true + } + lines := []string{ + fmt.Sprintf("Exchange config %q details:", name), + fmt.Sprintf("- Exchange: %s", exchange.ExchangeType), + fmt.Sprintf("- Account name: %s", name), + fmt.Sprintf("- Enabled: %t", exchange.Enabled), + fmt.Sprintf("- Testnet: %t", exchange.Testnet), + } + lines = append(lines, credentialLinesEn...) + return strings.Join(lines, "\n"), true } func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) { @@ -665,9 +1322,45 @@ func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption { if err != nil { return nil } + exchangeNames := map[string]string{} + if exchanges, err := a.store.Exchange().List(storeUserID); err == nil { + for _, exchange := range exchanges { + name := strings.TrimSpace(exchange.AccountName) + if name == "" { + name = strings.TrimSpace(exchange.ExchangeType) + } + if name != "" { + exchangeNames[exchange.ID] = name + } + } + } + modelNames := map[string]string{} + if models, err := a.store.AIModel().List(storeUserID); err == nil { + for _, model := range models { + name := strings.TrimSpace(model.Name) + if name == "" { + name = strings.TrimSpace(model.CustomModelName) + } + if name != "" { + modelNames[model.ID] = name + } + } + } out := make([]traderSkillOption, 0, len(traders)) for _, trader := range traders { - out = append(out, traderSkillOption{ID: trader.ID, Name: trader.Name, Enabled: trader.IsRunning}) + hints := make([]string, 0, 2) + if exchangeName := strings.TrimSpace(exchangeNames[trader.ExchangeID]); exchangeName != "" { + hints = append(hints, "交易所 "+exchangeName) + } + if modelName := strings.TrimSpace(modelNames[trader.AIModelID]); modelName != "" { + hints = append(hints, "模型 "+modelName) + } + out = append(out, traderSkillOption{ + ID: trader.ID, + Name: trader.Name, + Enabled: trader.IsRunning, + Hint: strings.Join(hints, ","), + }) } return out } @@ -686,24 +1379,82 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang } return "Cancelled the current exchange creation flow." } - if v := exchangeTypeFromText(text); fieldValue(session, "exchange_type") == "" && v != "" { - setField(&session, "exchange_type", v) + if result := a.extractSkillSessionFieldsWithLLM(context.Background(), userID, lang, text, session); result.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, result, lang, text) } - if v := extractTraderName(text); fieldValue(session, "account_name") == "" && v != "" { + 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) if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" { + missing = append(missing, slotDisplayName("exchange_type", lang)) + } + if accountName == "" { + missing = append(missing, displayCatalogFieldName("account_name", lang)) + } + if fieldValue(session, "api_key") == "" { + missing = append(missing, displayCatalogFieldName("api_key", lang)) + } + if fieldValue(session, "secret_key") == "" { + missing = append(missing, displayCatalogFieldName("secret_key", lang)) + } + switch exType { + case "okx": + if fieldValue(session, "passphrase") == "" { + missing = append(missing, displayCatalogFieldName("passphrase", lang)) + } + case "hyperliquid": + if fieldValue(session, "hyperliquid_wallet_addr") == "" { + missing = append(missing, "Hyperliquid Wallet") + } + } + if len(missing) > 0 { setSkillDAGStep(&session, "resolve_exchange_type") a.saveSkillSession(userID, session) if lang == "zh" { - return "要创建交易所配置,我还需要:" + slotDisplayName("exchange_type", lang) + "。例如:OKX、Binance、Bybit。" + reply := "要创建交易所配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。" + if exType == "" { + reply += "\n例如:OKX、Binance、Bybit。" + } + return reply } - return "To create an exchange config, tell me which exchange to use, for example OKX, Binance, or Bybit." + return "One more thing: please tell me these details: " + formatMissingFieldList(lang, missing) + "." } - accountName := fieldValue(session, "account_name") - if accountName == "" { - accountName = "Default" + validator := exchangeConfigValidator{ + exchangeType: exType, + enabled: fieldValue(session, "enabled") == "true", + apiKey: fieldValue(session, "api_key"), + secretKey: fieldValue(session, "secret_key"), + passphrase: fieldValue(session, "passphrase"), + hyperliquidWalletAddr: fieldValue(session, "hyperliquid_wallet_addr"), + asterUser: fieldValue(session, "aster_user"), + asterSigner: fieldValue(session, "aster_signer"), + asterPrivateKey: fieldValue(session, "aster_private_key"), + lighterWalletAddr: fieldValue(session, "lighter_wallet_addr"), + lighterAPIKeyPrivateKey: fieldValue(session, "lighter_api_key_private_key"), + } + if err := validator.Validate(); err != nil { + a.saveSkillSession(userID, session) + return formatValidationFeedback(lang, "exchange", err) + } + if !createConfirmationReply(text) { + 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 } setSkillDAGStep(&session, "execute_create") args := map[string]any{ @@ -711,6 +1462,22 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang "exchange_type": exType, "account_name": accountName, } + for _, field := range []string{"api_key", "secret_key", "passphrase", "hyperliquid_wallet_addr", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_api_key_private_key"} { + if value := fieldValue(session, field); value != "" { + args[field] = value + } + } + if value := fieldValue(session, "enabled"); value != "" { + args["enabled"] = value == "true" + } + if value := fieldValue(session, "testnet"); value != "" { + args["testnet"] = value == "true" + } + if value := fieldValue(session, "lighter_api_key_index"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + args["lighter_api_key_index"] = parsed + } + } raw, _ := json.Marshal(args) resp := a.toolManageExchangeConfig(storeUserID, string(raw)) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { @@ -718,13 +1485,22 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang if lang == "zh" { return "创建交易所配置失败:" + errMsg } - return "Failed to create exchange config: " + errMsg + return "That create request did not go through: " + errMsg } a.clearSkillSession(userID) + a.rememberReferencesFromToolResult(userID, "manage_exchange_config", resp) if lang == "zh" { - return fmt.Sprintf("已创建交易所配置:%s(%s)。如需继续补 API Key、Secret 或 Passphrase,可以直接继续说。", accountName, exType) + reply := fmt.Sprintf("已创建交易所配置:%s(%s)。", accountName, exType) + if len(warnings) > 0 { + reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") + } + return reply } - return fmt.Sprintf("Created exchange config %s (%s). You can continue by adding API key, secret, or passphrase.", 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 } func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { @@ -741,32 +1517,83 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t } return "Cancelled the current model creation flow." } - if v := providerFromText(text); fieldValue(session, "provider") == "" && v != "" { - setField(&session, "provider", v) + if result := a.extractSkillSessionFieldsWithLLM(context.Background(), userID, lang, text, session); result.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, result, lang, text) } - if v := extractTraderName(text); fieldValue(session, "name") == "" && v != "" { + if v := inferCreateDisplayName(text); fieldValue(session, "name") == "" && v != "" { setField(&session, "name", v) } - if v := extractURL(text); fieldValue(session, "custom_api_url") == "" && v != "" { - setField(&session, "custom_api_url", v) - } + patch := buildModelUpdatePatch(text) + applyModelUpdatePatchToSession(&session, patch) provider := fieldValue(session, "provider") - if actionRequiresSlot("model_management", "create", "provider") && provider == "" { + if provider != "" { + if fieldValue(session, "name") == "" { + setField(&session, "name", defaultModelConfigName(provider)) + } + if modelProviderSupportsCustomModel(provider) && fieldValue(session, "custom_model_name") == "" { + if defaultModel := defaultModelNameForProvider(provider); defaultModel != "" { + setField(&session, "custom_model_name", defaultModel) + } + } + if !modelProviderSupportsCustomAPIURL(provider) { + setField(&session, "custom_api_url", "") + } + } + missing := make([]string, 0, 4) + providerMissing := actionRequiresSlot("model_management", "create", "provider") && provider == "" + if providerMissing { + missing = append(missing, slotDisplayName("provider", lang)) + } + if !providerMissing && fieldValue(session, "api_key") == "" { + missing = append(missing, modelProviderCredentialLabel(lang, provider)) + } + if len(missing) > 0 { setSkillDAGStep(&session, "resolve_provider") a.saveSkillSession(userID, session) if lang == "zh" { - return "要创建模型配置,我还需要:" + slotDisplayName("provider", lang) + ",例如:OpenAI、DeepSeek、Claude、Gemini。" + reply := "要创建模型配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。" + if provider == "" { + reply += "\n" + availableModelProvidersMessage(lang) + } else { + reply += "\n" + modelProviderDetailedGuidance(lang, provider) + } + return reply } - return "To create a model config, I need the provider first, for example OpenAI, DeepSeek, Claude, or Gemini." + reply := "One more thing: please tell me these details: " + formatMissingFieldList(lang, missing) + "." + if provider != "" { + reply += "\n" + modelProviderDetailedGuidance(lang, provider) + } + return reply + } + validator := modelConfigValidator{ + provider: provider, + enabled: fieldValue(session, "enabled") == "true", + apiKey: fieldValue(session, "api_key"), + customAPIURL: fieldValue(session, "custom_api_url"), + customModelName: fieldValue(session, "custom_model_name"), + } + if err := validator.Validate(); err != nil { + a.saveSkillSession(userID, session) + return formatValidationFeedback(lang, "model", err) + } + if !createConfirmationReply(text) { + session.Phase = "await_create_confirmation" + setSkillDAGStep(&session, "await_create_confirmation") + a.saveSkillSession(userID, session) + return formatModelCreateDraftSummary(lang, session) } setSkillDAGStep(&session, "execute_create") args := map[string]any{ "action": "create", "provider": provider, - "name": defaultIfEmpty(fieldValue(session, "name"), provider), + "name": fieldValue(session, "name"), + "api_key": fieldValue(session, "api_key"), "custom_api_url": fieldValue(session, "custom_api_url"), "custom_model_name": fieldValue(session, "custom_model_name"), } + if value := fieldValue(session, "enabled"); value != "" { + args["enabled"] = value == "true" + } raw, _ := json.Marshal(args) resp := a.toolManageModelConfig(storeUserID, string(raw)) if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { @@ -774,13 +1601,14 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t if lang == "zh" { return "创建模型配置失败:" + errMsg } - return "Failed to create model config: " + errMsg + return "That create request did not go through: " + errMsg } a.clearSkillSession(userID) + a.rememberReferencesFromToolResult(userID, "manage_model_config", resp) if lang == "zh" { - return fmt.Sprintf("已创建模型配置:%s。你后续还可以继续补 API Key、URL 或模型名。", provider) + return fmt.Sprintf("已创建模型配置:%s。", fieldValue(session, "name")) } - return fmt.Sprintf("Created model config for %s. You can continue by adding API key, URL, or model name.", provider) + return fmt.Sprintf("Created model config %s.", fieldValue(session, "name")) } func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { @@ -797,15 +1625,13 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang } return "Cancelled the current strategy creation flow." } + if result := a.extractSkillSessionFieldsWithLLM(context.Background(), userID, lang, text, session); result.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &session, result, lang, text) + } name := fieldValue(session, "name") - if name == "" { - name = extractTraderName(text) - if name == "" { - name = extractPostKeywordName(text, []string{"叫", "名为", "策略叫", "strategy called"}) - } - if name != "" { - setField(&session, "name", name) - } + hasDescriptiveDraftIntent := session.Phase == "draft_create" + if hasDescriptiveDraftIntent { + return a.continueStrategyCreateDraft(storeUserID, userID, lang, text, session) } if actionRequiresSlot("strategy_management", "create", "name") && name == "" { setSkillDAGStep(&session, "resolve_name") @@ -824,9 +1650,10 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang if lang == "zh" { return "创建策略失败:" + errMsg } - return "Failed to create strategy: " + errMsg + return "That create request did not go through: " + errMsg } a.clearSkillSession(userID) + a.rememberReferencesFromToolResult(userID, "manage_strategy", resp) if lang == "zh" { return fmt.Sprintf("已创建策略“%s”。默认配置已就绪,你后续可以继续让我帮你改细节。", name) } @@ -834,28 +1661,36 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang } func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) { - if isCancelSkillReply(text) { - a.clearSkillSession(userID) - if lang == "zh" { - return "已取消当前流程。", true - } - return "Cancelled the current flow.", true - } if session.Name == "" { session = skillSession{Name: skillName, Action: action, Phase: "collecting"} } if session.Name != skillName || session.Action != action { return "", false } + if shouldUseLLMConversationForSimpleEntity(skillName, action) { + result := a.llmSkillConversationDriver(context.Background(), storeUserID, userID, lang, text, session, a.buildSimpleEntityConversationResources(storeUserID, session, options)) + if result.Cancel { + a.clearSkillSession(userID) + if lang == "zh" { + return "已取消当前流程。", true + } + return "Cancelled the current flow.", true + } + if result.UserRejectedFlow { + return a.rerouteRejectedSkillFlow(context.Background(), storeUserID, userID, lang, text) + } + applySkillConversationResultToSession(&session, result) + if !result.Ready && result.Question != "" { + a.saveSkillSession(userID, session) + return result.Question, true + } + } if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 { currentStep, _ := currentSkillDAGStep(session) if currentStep.ID == "resolve_target" { - if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) { - setField(&session, "bulk_scope", "all") - advanceSkillDAGStep(&session, currentStep.ID) - } else { - session.TargetRef = resolveTargetFromText(text, options, session.TargetRef) + if session.TargetRef == nil { + session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) } if session.TargetRef == nil { if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") { @@ -873,7 +1708,7 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, } return reply, true } - reply := "This step needs a target object first. Tell me which one to operate on." + reply := "One more thing: tell me which one you want me to work on." if optionList != "" { reply += "\n" + optionList } @@ -885,10 +1720,8 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, } } } else { - if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) { - setField(&session, "bulk_scope", "all") - } else { - session.TargetRef = resolveTargetFromText(text, options, session.TargetRef) + if session.TargetRef == nil { + session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName) } if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" { a.saveSkillSession(userID, session) @@ -900,7 +1733,26 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, } return reply, true } - reply := "I still need you to specify which object to operate on." + reply := "One more thing: tell me which one you want to work on." + if label != "" { + reply += "\n" + label + } + return reply, true + } + } + + if session.TargetRef != nil && action != "create" && action != "query_list" && action != "query_running" { + if !ensureLiveTargetReference(&session, options) { + a.saveSkillSession(userID, session) + label := formatOptionList("可选对象:", options) + if lang == "zh" { + reply := "我刚检查了一下,刚才记住的对象已经不存在或已失效了。请重新告诉我要操作哪一个对象。" + if label != "" { + reply += "\n" + label + } + return reply, true + } + reply := "The object remembered from earlier no longer exists. Please tell me which object to operate on now." if label != "" { reply += "\n" + label } @@ -922,6 +1774,30 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, } } +func (a *Agent) askLLMAmbiguousTargetQuestion(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, allOptions, ambiguous []traderSkillOption) string { + if a.aiClient != nil { + ambiguousSession := session + ambiguousSession.Name = skillName + ambiguousSession.Action = action + ambiguousSession.TargetRef = nil + setSkillDAGStep(&ambiguousSession, "resolve_target") + + resources := a.buildSimpleEntityConversationResources(storeUserID, ambiguousSession, allOptions) + if resources == nil { + resources = map[string]any{} + } + resources["targets"] = ambiguous + resources["target_conflict"] = ambiguous + + result := a.llmSkillConversationDriver(context.Background(), storeUserID, userID, lang, text, ambiguousSession, resources) + if question := strings.TrimSpace(result.Question); question != "" { + a.saveSkillSession(userID, ambiguousSession) + return question + } + } + return formatAmbiguousTargetPrompt(lang, ambiguous) +} + func defaultIfEmpty(value, fallback string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/agent/skill_outcome.go b/agent/skill_outcome.go index 1075a434..99d4079d 100644 --- a/agent/skill_outcome.go +++ b/agent/skill_outcome.go @@ -42,11 +42,13 @@ func normalizeAtomicSkillAction(skill, action string) string { return "query_list" case "query_running": return "query_running" - case "query_detail": + case "query_detail", "query_strategy_binding", "query_exchange_binding", "query_model_binding": + return action + case "query_binding": return "query_detail" case "update": return "update_name" - case "update_name", "update_bindings": + case "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": return action } case "exchange_management": diff --git a/agent/skill_registry.go b/agent/skill_registry.go index a74b3cbf..f9fa27b0 100644 --- a/agent/skill_registry.go +++ b/agent/skill_registry.go @@ -6,19 +6,38 @@ import ( "fmt" "sort" "strings" + "sync" ) //go:embed skills/*.json var embeddedSkillDefinitions embed.FS type SkillDefinition struct { - Name string `json:"name"` - Kind string `json:"kind"` - Domain string `json:"domain"` - Description string `json:"description"` - Intents []string `json:"intents,omitempty"` - Actions map[string]SkillActionDefinition `json:"actions,omitempty"` - ToolMapping map[string]string `json:"tool_mapping,omitempty"` + Name string `json:"name"` + Kind string `json:"kind"` + Domain string `json:"domain"` + Description string `json:"description"` + Intents []string `json:"intents,omitempty"` + Actions map[string]SkillActionDefinition `json:"actions,omitempty"` + ToolMapping map[string]string `json:"tool_mapping,omitempty"` + FieldConstraints map[string]SkillFieldConstraint `json:"field_constraints,omitempty"` + ValidationRules []string `json:"validation_rules,omitempty"` + PerExchangeRequiredFields map[string][]string `json:"per_exchange_required_fields,omitempty"` +} + +type SkillFieldConstraint struct { + Type string `json:"type,omitempty"` + Required bool `json:"required,omitempty"` + Values []string `json:"values,omitempty"` + Aliases map[string]string `json:"aliases,omitempty"` + Description string `json:"description,omitempty"` + RequiredFor []string `json:"required_for,omitempty"` + Default any `json:"default,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + MaxLength int `json:"max_length,omitempty"` + MustBeHTTPS bool `json:"must_be_https,omitempty"` + Pattern string `json:"pattern,omitempty"` } type SkillActionDefinition struct { @@ -26,9 +45,14 @@ type SkillActionDefinition struct { RequiredSlots []string `json:"required_slots,omitempty"` OptionalSlots []string `json:"optional_slots,omitempty"` NeedsConfirmation bool `json:"needs_confirmation,omitempty"` + Goal string `json:"goal,omitempty"` + DynamicRules []string `json:"dynamic_rules,omitempty"` + SuccessOutput string `json:"success_output,omitempty"` + FailureOutput string `json:"failure_output,omitempty"` } var skillRegistry = mustLoadSkillRegistry() +var skillContextCache sync.Map func mustLoadSkillRegistry() map[string]SkillDefinition { registry, err := loadSkillRegistry() @@ -83,6 +107,10 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition { action.Description = strings.TrimSpace(action.Description) action.RequiredSlots = cleanStringList(action.RequiredSlots) action.OptionalSlots = cleanStringList(action.OptionalSlots) + action.Goal = strings.TrimSpace(action.Goal) + action.DynamicRules = cleanStringList(action.DynamicRules) + action.SuccessOutput = strings.TrimSpace(action.SuccessOutput) + action.FailureOutput = strings.TrimSpace(action.FailureOutput) normalized[key] = action } def.Actions = normalized @@ -101,6 +129,46 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition { def.ToolMapping = normalized } + if len(def.FieldConstraints) > 0 { + normalized := make(map[string]SkillFieldConstraint, len(def.FieldConstraints)) + for key, constraint := range def.FieldConstraints { + key = strings.TrimSpace(key) + if key == "" { + continue + } + constraint.Type = strings.TrimSpace(constraint.Type) + constraint.Values = cleanStringList(constraint.Values) + constraint.RequiredFor = cleanStringList(constraint.RequiredFor) + constraint.Description = strings.TrimSpace(constraint.Description) + if len(constraint.Aliases) > 0 { + aliases := make(map[string]string, len(constraint.Aliases)) + for alias, value := range constraint.Aliases { + alias = strings.TrimSpace(alias) + value = strings.TrimSpace(value) + if alias == "" || value == "" { + continue + } + aliases[alias] = value + } + constraint.Aliases = aliases + } + normalized[key] = constraint + } + def.FieldConstraints = normalized + } + def.ValidationRules = cleanStringList(def.ValidationRules) + if len(def.PerExchangeRequiredFields) > 0 { + normalized := make(map[string][]string, len(def.PerExchangeRequiredFields)) + for key, fields := range def.PerExchangeRequiredFields { + key = strings.TrimSpace(key) + if key == "" { + continue + } + normalized[key] = cleanStringList(fields) + } + def.PerExchangeRequiredFields = normalized + } + return def } @@ -117,3 +185,514 @@ func listSkillNames() []string { sort.Strings(names) return names } + +func buildSkillRoutingSummary(lang string, skillNames []string) string { + lines := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + def, ok := getSkillDefinition(name) + if !ok { + continue + } + parts := []string{strings.TrimSpace(def.Description)} + switch name { + case "trader_management": + if lang == "zh" { + parts = append(parts, "这个 skill 负责交易员本体,以及交易员绑定的模型、交易所、策略配置。") + } else { + parts = append(parts, "This skill owns the trader itself plus its bound model, exchange, and strategy.") + } + case "strategy_management": + if lang == "zh" { + parts = append(parts, "策略模板不能直接启动;只有绑定了该策略的交易员可以启动。") + } else { + parts = append(parts, "Strategy templates do not run directly; only traders bound to a strategy can run.") + } + } + lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " "))) + } + return strings.Join(lines, "\n") +} + +func buildSkillDefinitionSummary(lang string, skillNames []string) string { + lines := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + def, ok := getSkillDefinition(name) + if !ok { + continue + } + parts := []string{strings.TrimSpace(def.Description)} + if action, ok := def.Actions["create"]; ok && len(action.RequiredSlots) > 0 { + if lang == "zh" { + parts = append(parts, "创建必填: "+formatRequiredSlotList(lang, action.RequiredSlots)) + } else { + parts = append(parts, "create requires: "+formatRequiredSlotList(lang, action.RequiredSlots)) + } + } + switch name { + case "trader_management": + if lang == "zh" { + parts = append(parts, "这个 skill 负责交易员本体,以及交易员绑定的模型、交易所、策略配置。") + } else { + parts = append(parts, "This skill owns the trader itself plus its bound model, exchange, and strategy.") + } + case "strategy_management": + if lang == "zh" { + parts = append(parts, "策略模板不能直接启动;只有绑定了该策略的交易员可以启动。") + } else { + parts = append(parts, "Strategy templates do not run directly; only traders bound to a strategy can run.") + } + } + lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " "))) + } + return strings.Join(lines, "\n") +} + +func defaultManagementSkillNames() []string { + return []string{ + "trader_management", + "exchange_management", + "model_management", + "strategy_management", + } +} + +func buildSkillDependencySummary(lang string, session skillSession) string { + if strings.TrimSpace(session.Name) == "" { + return "" + } + switch session.Name { + case "trader_management": + if session.Action == "create" { + if lang == "zh" { + return "trader_management:create 必须收齐 4 个核心槽位:交易员名称、交易所、模型、策略。后 3 个依赖项都允许两种补法:直接选用户已有可用资源,或在当前主流程里立即新建/启用后再回流继续创建交易员。若用户是在启用、修复或新建这些依赖资源,这仍然是在继续创建交易员主流程,不是新开平级任务。" + } + return "trader_management:create requires 4 core slots: trader name, exchange, model, and strategy. The last 3 dependencies can be satisfied in two ways: choose an existing usable resource, or create/enable one inline and then resume trader creation. If the user is enabling, fixing, or creating one of those dependencies, that is still continuation of the trader creation flow, not a new peer task." + } + if lang == "zh" { + return "当当前对象是交易员时,配置模型、交易所、策略都属于 trader_management 的继续操作。" + } + return "When the current object is a trader, configuring its model, exchange, or strategy remains inside trader_management." + default: + return "" + } +} + +func buildSkillActionContractSummary(lang string, session skillSession) string { + if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" { + return "" + } + + def, ok := getSkillDefinition(session.Name) + if !ok { + return "" + } + action, ok := def.Actions[session.Action] + if !ok { + return "" + } + + required := defaultIfEmpty(formatRequiredSlotList(lang, action.RequiredSlots), "无") + goal := strings.TrimSpace(action.Goal) + if goal == "" { + goal = strings.TrimSpace(action.Description) + } + + lines := []string{ + fmt.Sprintf("### Active Skill Contract: %s:%s", session.Name, session.Action), + } + if lang == "zh" { + lines = append(lines, "- 目标:"+defaultIfEmpty(goal, "按该动作的业务规则完成当前请求。")) + lines = append(lines, "- 必填输入:"+required) + if len(action.DynamicRules) > 0 { + lines = append(lines, "- 动态逻辑规则:") + for i, rule := range action.DynamicRules { + lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule)) + } + } + if action.SuccessOutput != "" || action.FailureOutput != "" { + lines = append(lines, "- 预期输出:"+strings.TrimSpace(strings.Join(cleanStringList([]string{ + ifThenElse(action.SuccessOutput != "", "成功:"+action.SuccessOutput, ""), + ifThenElse(action.FailureOutput != "", "失败:"+action.FailureOutput, ""), + }), ";"))) + } + } else { + lines = append(lines, "- Goal: "+defaultIfEmpty(goal, "Complete the current request under this action's business rules.")) + lines = append(lines, "- Required input: "+required) + if len(action.DynamicRules) > 0 { + lines = append(lines, "- Dynamic rules:") + for i, rule := range action.DynamicRules { + lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule)) + } + } + if action.SuccessOutput != "" || action.FailureOutput != "" { + lines = append(lines, "- Expected output: "+strings.TrimSpace(strings.Join(cleanStringList([]string{ + ifThenElse(action.SuccessOutput != "", "success: "+action.SuccessOutput, ""), + ifThenElse(action.FailureOutput != "", "failure: "+action.FailureOutput, ""), + }), "; "))) + } + } + return strings.Join(lines, "\n") +} + +func ifThenElse[T any](cond bool, a, b T) T { + if cond { + return a + } + return b +} + +func buildSkillForbiddenSummary(lang string, skillNames []string) string { + lines := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + switch name { + case "trader_management": + if lang == "zh" { + lines = append(lines, "- trader_management 不能直接设计赚钱/不亏钱方案;那类目标应交给 planner。") + } else { + lines = append(lines, "- trader_management must not invent a profit-seeking plan; those requests belong to the planner.") + } + case "exchange_management": + if lang == "zh" { + lines = append(lines, "- exchange_management 只负责保存和修改交易所配置,不负责行情查询、交易执行或诊断 API 报错。") + } else { + lines = append(lines, "- exchange_management only saves and updates exchange configs; it does not do market reads, trading, or API diagnosis.") + } + case "model_management": + if lang == "zh" { + lines = append(lines, "- model_management 只负责保存和修改模型配置,不负责测试连接、诊断上游错误或生成策略方案。") + } else { + lines = append(lines, "- model_management only saves and updates model configs; it does not test connectivity, diagnose upstream failures, or design strategies.") + } + case "strategy_management": + if lang == "zh" { + lines = append(lines, "- strategy_management 只负责模板管理;策略模板不能直接启动运行,运行态属于 trader。") + } else { + lines = append(lines, "- strategy_management only manages templates; strategy templates do not run directly and runtime belongs to traders.") + } + } + } + return strings.Join(lines, "\n") +} + +func buildManagementSkillContext(lang string, session *skillSession) string { + key := fmt.Sprintf("full|%s|", lang) + if session != nil { + key = fmt.Sprintf("full|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action)) + } + return cachedSkillContext(key, func() string { + parts := make([]string, 0, 3) + if summary := buildSkillDefinitionSummary(lang, defaultManagementSkillNames()); summary != "" { + parts = append(parts, "Management skill summary:\n"+summary) + } + if forbidden := buildSkillForbiddenSummary(lang, defaultManagementSkillNames()); forbidden != "" { + parts = append(parts, "Management skill negative constraints:\n"+forbidden) + } + if session != nil { + if dependency := buildSkillDependencySummary(lang, *session); dependency != "" { + parts = append(parts, "Active skill dependency summary:\n"+dependency) + } + if contract := buildSkillActionContractSummary(lang, *session); contract != "" { + parts = append(parts, contract) + } + } + return strings.Join(parts, "\n\n") + }) +} + +func buildManagementSkillRoutingContext(lang string) string { + return buildManagementSkillRoutingContextWithSession(lang, nil) +} + +func buildSkillActionRoutingSummary(lang string, session skillSession) string { + if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" { + return "" + } + def, ok := getSkillDefinition(session.Name) + if !ok { + return "" + } + action, ok := def.Actions[session.Action] + if !ok { + return "" + } + + lines := []string{ + fmt.Sprintf("### Active skill routing hints: %s:%s", session.Name, session.Action), + } + if goal := strings.TrimSpace(action.Goal); goal != "" { + if lang == "zh" { + lines = append(lines, "- 当前动作目标:"+goal) + } else { + lines = append(lines, "- Current action goal: "+goal) + } + } + if dependency := buildSkillDependencySummary(lang, session); dependency != "" { + if lang == "zh" { + lines = append(lines, "- 当前 flow 依赖提示:"+dependency) + } else { + lines = append(lines, "- Flow dependency hint: "+dependency) + } + } + if len(action.DynamicRules) > 0 { + if lang == "zh" { + lines = append(lines, "- 当前动作动态规则:") + } else { + lines = append(lines, "- Current action dynamic rules:") + } + for i, rule := range action.DynamicRules { + lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule)) + } + } + return strings.Join(lines, "\n") +} + +func buildManagementSkillRoutingContextWithSession(lang string, session *skillSession) string { + key := fmt.Sprintf("routing|%s|", lang) + if session != nil { + key = fmt.Sprintf("routing|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action)) + } + return cachedSkillContext(key, func() string { + parts := make([]string, 0, 1) + if summary := buildSkillRoutingSummary(lang, defaultManagementSkillNames()); summary != "" { + parts = append(parts, "Management skill summary:\n"+summary) + } + if session != nil { + if summary := buildSkillActionRoutingSummary(lang, *session); summary != "" { + parts = append(parts, summary) + } + } + return strings.Join(parts, "\n\n") + }) +} + +func buildCurrentSkillExecutionContext(lang string, session skillSession) string { + key := fmt.Sprintf("current|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action)) + return cachedSkillContext(key, func() string { + parts := make([]string, 0, 3) + if dependency := buildSkillDependencySummary(lang, session); dependency != "" { + parts = append(parts, "Active skill dependency summary:\n"+dependency) + } + if contract := buildSkillActionContractSummary(lang, session); contract != "" { + parts = append(parts, contract) + } + if knowledge := buildSkillFieldKnowledgeSummary(lang, session); knowledge != "" { + parts = append(parts, knowledge) + } + return strings.Join(parts, "\n\n") + }) +} + +func buildSkillFieldKnowledgeSummary(lang string, session skillSession) string { + def, ok := getSkillDefinition(session.Name) + if !ok { + return "" + } + action, hasAction := def.Actions[session.Action] + relevant := orderedSkillFieldKeys(def, action, hasAction) + lines := make([]string, 0, len(relevant)+6) + title := "### Active Field Knowledge" + if lang == "zh" { + title = "### 当前字段知识" + } + lines = append(lines, title) + for _, field := range relevant { + constraint, ok := def.FieldConstraints[field] + if !ok { + continue + } + lines = append(lines, formatFieldKnowledgeLine(lang, field, constraint)) + } + if len(def.PerExchangeRequiredFields) > 0 { + if lang == "zh" { + lines = append(lines, "- 按交易所类型的必填字段:") + } else { + lines = append(lines, "- Required fields by exchange type:") + } + keys := make([]string, 0, len(def.PerExchangeRequiredFields)) + for key := range def.PerExchangeRequiredFields { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fields := make([]string, 0, len(def.PerExchangeRequiredFields[key])) + for _, field := range def.PerExchangeRequiredFields[key] { + fields = append(fields, fieldKnowledgeDisplayName(field, lang)) + } + lines = append(lines, fmt.Sprintf(" - %s: %s", key, strings.Join(fields, "、"))) + } + } + if len(def.ValidationRules) > 0 { + if lang == "zh" { + lines = append(lines, "- 关键校验规则:") + } else { + lines = append(lines, "- Key validation rules:") + } + for i, rule := range def.ValidationRules { + lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule)) + } + } + if len(lines) == 1 { + return "" + } + return strings.Join(lines, "\n") +} + +func orderedSkillFieldKeys(def SkillDefinition, action SkillActionDefinition, hasAction bool) []string { + keys := make([]string, 0, len(def.FieldConstraints)) + seen := map[string]struct{}{} + add := func(field string) { + field = strings.TrimSpace(field) + if field == "" { + return + } + if _, ok := def.FieldConstraints[field]; !ok { + return + } + if _, ok := seen[field]; ok { + return + } + seen[field] = struct{}{} + keys = append(keys, field) + } + if hasAction { + for _, field := range action.RequiredSlots { + add(field) + } + for _, field := range action.OptionalSlots { + add(field) + } + } + if len(keys) == 0 { + for field := range def.FieldConstraints { + add(field) + } + } + return keys +} + +func formatFieldKnowledgeLine(lang, field string, constraint SkillFieldConstraint) string { + parts := make([]string, 0, 8) + if constraint.Description != "" { + parts = append(parts, constraint.Description) + } + if constraint.Type != "" { + if lang == "zh" { + parts = append(parts, "类型="+constraint.Type) + } else { + parts = append(parts, "type="+constraint.Type) + } + } + if constraint.Required { + if lang == "zh" { + parts = append(parts, "当前全局必填") + } else { + parts = append(parts, "globally required") + } + } + if len(constraint.Values) > 0 { + label := "可选值=" + if lang != "zh" { + label = "values=" + } + parts = append(parts, label+strings.Join(constraint.Values, "/")) + } + if len(constraint.RequiredFor) > 0 { + label := "仅这些类型必填=" + if lang != "zh" { + label = "required_for=" + } + parts = append(parts, label+strings.Join(constraint.RequiredFor, "/")) + } + if len(constraint.Aliases) > 0 { + aliasPairs := make([]string, 0, len(constraint.Aliases)) + keys := make([]string, 0, len(constraint.Aliases)) + for alias := range constraint.Aliases { + keys = append(keys, alias) + } + sort.Strings(keys) + for _, alias := range keys { + aliasPairs = append(aliasPairs, alias+"->"+constraint.Aliases[alias]) + } + label := "别名=" + if lang != "zh" { + label = "aliases=" + } + parts = append(parts, label+strings.Join(aliasPairs, ", ")) + } + if constraint.MustBeHTTPS { + if lang == "zh" { + parts = append(parts, "必须是 HTTPS") + } else { + parts = append(parts, "must be HTTPS") + } + } + if constraint.Min != nil || constraint.Max != nil { + rangeText := "" + switch { + case constraint.Min != nil && constraint.Max != nil: + rangeText = fmt.Sprintf("%.0f~%.0f", *constraint.Min, *constraint.Max) + case constraint.Min != nil: + rangeText = fmt.Sprintf(">=%.0f", *constraint.Min) + case constraint.Max != nil: + rangeText = fmt.Sprintf("<=%.0f", *constraint.Max) + } + if rangeText != "" { + label := "范围=" + if lang != "zh" { + label = "range=" + } + parts = append(parts, label+rangeText) + } + } + return fmt.Sprintf("- %s: %s", fieldKnowledgeDisplayName(field, lang), strings.Join(cleanStringList(parts), ";")) +} + +func fieldKnowledgeDisplayName(field, lang string) string { + if lang == "zh" { + switch field { + case "exchange_type": + return "交易所类型" + case "account_name": + return "账户名" + case "provider": + return "模型提供商" + case "custom_model_name": + return "模型名称" + case "custom_api_url": + return "接口地址" + } + } + return displayCatalogFieldName(field, lang) +} + +func formatRequiredSlotList(lang string, slots []string) string { + display := make([]string, 0, len(slots)) + for _, slot := range cleanStringList(slots) { + display = append(display, slotDisplayName(slot, lang)) + } + return strings.Join(display, "、") +} + +func missingRequiredActionSlots(skillName, action string, values map[string]string) []string { + runtime, ok := getSkillActionRuntime(skillName, action) + if !ok { + return nil + } + missing := make([]string, 0, len(runtime.Action.RequiredSlots)) + for _, slot := range runtime.Action.RequiredSlots { + if strings.TrimSpace(values[slot]) == "" { + missing = append(missing, slot) + } + } + return missing +} +func cachedSkillContext(key string, build func() string) string { + if cached, ok := skillContextCache.Load(key); ok { + if s, ok := cached.(string); ok { + return s + } + } + value := build() + skillContextCache.Store(key, value) + return value +} diff --git a/agent/skill_registry_test.go b/agent/skill_registry_test.go deleted file mode 100644 index 99a14987..00000000 --- a/agent/skill_registry_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package agent - -import "testing" - -func TestSkillRegistryLoadsDefinitions(t *testing.T) { - names := listSkillNames() - if len(names) < 4 { - t.Fatalf("expected skill registry to load definitions, got %v", names) - } - - for _, name := range []string{ - "trader_management", - "exchange_management", - "model_management", - "strategy_management", - "exchange_diagnosis", - "model_diagnosis", - } { - if _, ok := getSkillDefinition(name); !ok { - t.Fatalf("missing skill definition %q", name) - } - } -} - -func TestTraderManagementDefinitionHasCreateAction(t *testing.T) { - def, ok := getSkillDefinition("trader_management") - if !ok { - t.Fatalf("missing trader_management definition") - } - action, ok := def.Actions["create"] - if !ok { - t.Fatalf("missing create action in trader_management") - } - if len(action.RequiredSlots) == 0 { - t.Fatalf("expected required slots for trader_management create action") - } -} - -func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) { - if !actionNeedsConfirmation("exchange_management", "delete") { - t.Fatalf("expected exchange_management delete to require confirmation") - } - if actionNeedsConfirmation("exchange_management", "query") { - t.Fatalf("did not expect exchange_management query to require confirmation") - } -} - -func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) { - if !actionRequiresSlot("model_management", "create", "provider") { - t.Fatalf("expected model_management create to require provider") - } - if actionRequiresSlot("model_management", "create", "target_ref") { - t.Fatalf("did not expect model_management create to require target_ref") - } -} diff --git a/agent/skill_runner.go b/agent/skill_runner.go index a2b7fdbf..38a1940a 100644 --- a/agent/skill_runner.go +++ b/agent/skill_runner.go @@ -89,7 +89,7 @@ func slotDisplayName(slot, lang string) string { case "exchange_type": return "交易所类型" case "provider": - return "provider" + return "模型提供商" default: return slot } @@ -115,6 +115,39 @@ func formatAwaitConfirmationMessage(lang, action, targetLabel string) string { return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel) } +func formatTargetConfirmationLabel(lang string, session *skillSession, targetLabel string) string { + targetLabel = strings.TrimSpace(targetLabel) + if session == nil || session.TargetRef == nil || targetLabel == "" { + return targetLabel + } + source := strings.TrimSpace(session.TargetRef.Source) + if source == "" { + return targetLabel + } + if lang == "zh" { + sourceLabel := "系统上下文" + switch source { + case "user_mention": + sourceLabel = "你刚才点名的对象" + case "tool_output": + sourceLabel = "刚刚工具返回的对象" + case "inferred_from_context": + sourceLabel = "上下文推断对象" + } + return fmt.Sprintf("%s(当前识别来源:%s)", targetLabel, sourceLabel) + } + sourceLabel := "context" + switch source { + case "user_mention": + sourceLabel = "your explicit mention" + case "tool_output": + sourceLabel = "recent tool output" + case "inferred_from_context": + sourceLabel = "context inference" + } + return fmt.Sprintf("%s (current reference source: %s)", targetLabel, sourceLabel) +} + func formatStillWaitingConfirmationMessage(lang string) string { if lang == "zh" { return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。" @@ -122,13 +155,80 @@ func formatStillWaitingConfirmationMessage(lang string) string { return "This flow is still waiting for your confirmation." } -func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) { +func referenceKindForSkill(skillName string) string { + switch strings.TrimSpace(skillName) { + case "strategy_management": + return "strategy" + case "trader_management": + return "trader" + case "model_management": + return "model" + case "exchange_management": + return "exchange" + default: + return "" + } +} + +func referenceKindDisplayName(lang, kind string) string { + if lang == "zh" { + switch kind { + case "strategy": + return "策略" + case "trader": + return "交易员" + case "model": + return "模型" + case "exchange": + return "交易所" + } + return "对象" + } + return kind +} + +func (a *Agent) formatConfirmationTargetLabel(userID int64, lang string, session *skillSession, targetLabel string) string { + label := formatTargetConfirmationLabel(lang, session, targetLabel) + if session == nil || session.TargetRef == nil { + return label + } + kind := referenceKindForSkill(session.Name) + if kind == "" { + return label + } + state := a.getExecutionState(userID) + recentNames := map[string]struct{}{} + for _, item := range state.ReferenceHistory { + if item.Kind != kind { + continue + } + name := strings.TrimSpace(defaultIfEmpty(item.Name, item.ID)) + if name == "" { + continue + } + recentNames[name] = struct{}{} + } + targetName := strings.TrimSpace(defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)) + _, inferred := recentNames[targetName] + if targetName == "" { + return label + } + if len(recentNames) <= 1 && strings.TrimSpace(session.TargetRef.Source) != "inferred_from_context" && inferred { + return label + } + if lang == "zh" { + return fmt.Sprintf("%s。系统当前理解你要操作的%s是“%s”。", label, referenceKindDisplayName(lang, kind), targetName) + } + return fmt.Sprintf("%s. The current %s I'm about to operate on is %q.", label, referenceKindDisplayName(lang, kind), targetName) +} + +func (a *Agent) beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) { if session == nil || !actionNeedsConfirmation(session.Name, session.Action) { return "", false } if session.Phase != "await_confirmation" { session.Phase = "await_confirmation" - return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true + return formatAwaitConfirmationMessage(lang, session.Action, a.formatConfirmationTargetLabel(userID, lang, session, targetLabel)), true } return "", false } diff --git a/agent/skill_semantic_gate.go b/agent/skill_semantic_gate.go new file mode 100644 index 00000000..c2325f8a --- /dev/null +++ b/agent/skill_semantic_gate.go @@ -0,0 +1,409 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "nofx/mcp" +) + +type skillSemanticGateDecision struct { + Decision string `json:"decision,omitempty"` + Field string `json:"field,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (a *Agent) evaluateHardSkillCandidate(ctx context.Context, storeUserID string, userID int64, lang string, session skillSession, skillName, action, text string) skillSemanticGateDecision { + fallback := fallbackSkillSemanticGate(skillName, action, session, text) + if a == nil || a.aiClient == nil { + return fallback + } + + systemPrompt := `You are the second-stage semantic gate for one NOFXi hard skill. +Return JSON only. No markdown. + +Decide exactly one: +- "execute": this request is concrete enough for this hard skill to handle now +- "explain": the user is asking about visible UI fields, options, requirements, or how to fill them +- "planner": this request is too open-ended, strategic, subjective, ambiguous, or not yet UI-ready for this hard skill + +Rules: +- Use "execute" when the request maps to the current skill's visible fields, existing entity options, or a normal multi-turn form flow. +- Use "explain" when the user asks what fields/options exist, what a field means, or how to fill it. +- Use "planner" when the user asks for outcome-seeking advice like "make money", "don't lose", "best", "optimize", or otherwise asks you to design the solution before UI-level parameters are clear. +- Use "planner" when semantic readiness is not met: the route points to a skill/action, but the request is still missing core required fields and would otherwise mostly result in a mechanical missing-field error. +- Be conservative. If this hard skill would mostly respond with a misleading missing-field error, choose "planner" instead. + +Return JSON: +{"decision":"execute|explain|planner","field":"","reason":""}` + + userPrompt := fmt.Sprintf( + "Language: %s\nSkill: %s\nAction: %s\nActive skill session JSON: %s\nSemantic readiness summary:\n%s\nVisible field summary:\n%s\nVisible option summary:\n%s\nDomain primer:\n%s\nUser message: %s", + lang, + skillName, + action, + mustMarshalJSON(session), + a.skillSemanticReadinessSummary(lang, session, skillName, action, text), + a.skillVisibleFieldSummary(storeUserID, lang, skillName, action), + a.skillVisibleOptionSummary(storeUserID, lang, skillName, action), + buildSkillDomainPrimer(lang, skillName), + text, + ) + + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return fallback + } + if parsed, ok := parseSkillSemanticGateDecision(raw); ok { + if parsed.Field == "" { + parsed.Field = detectSkillQuestionField(skillName, text, session) + } + return parsed + } + return fallback +} + +func parseSkillSemanticGateDecision(raw string) (skillSemanticGateDecision, bool) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var out skillSemanticGateDecision + if err := json.Unmarshal([]byte(raw), &out); err != nil { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil { + return skillSemanticGateDecision{}, false + } + } + out.Decision = strings.TrimSpace(strings.ToLower(out.Decision)) + out.Field = strings.TrimSpace(out.Field) + out.Reason = strings.TrimSpace(out.Reason) + switch out.Decision { + case "execute", "explain", "planner": + return out, true + default: + return skillSemanticGateDecision{}, false + } +} + +func fallbackSkillSemanticGate(skillName, action string, session skillSession, text string) skillSemanticGateDecision { + if looksLikeExplanationQuestion(text) { + return skillSemanticGateDecision{ + Decision: "explain", + Field: "", + } + } + if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" { + return skillSemanticGateDecision{Decision: "execute"} + } + if isSimpleEntityMutationAction(action) && !hasExplicitSkillCueForSemanticGate(skillName, text) { + return skillSemanticGateDecision{Decision: "planner", Reason: "requires_llm_intent_resolution"} + } + if missing := semanticReadinessMissingSlots(skillName, action, session, text); len(missing) > 0 { + return skillSemanticGateDecision{Decision: "planner", Reason: "semantic_readiness_missing_core_fields"} + } + return skillSemanticGateDecision{Decision: "execute"} +} + +func hasExplicitSkillCueForSemanticGate(skillName, text string) bool { + switch strings.TrimSpace(skillName) { + case "trader_management": + return hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader") + case "exchange_management": + return hasExplicitManagementDomainCue(text, "exchange") + case "model_management": + return hasExplicitManagementDomainCue(text, "model") + case "strategy_management": + return hasExplicitManagementDomainCue(text, "strategy") + default: + return false + } +} + +func semanticReadinessMissingSlots(skillName, action string, session skillSession, text string) []string { + if strings.TrimSpace(action) == "" { + return nil + } + if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" { + return nil + } + values := map[string]string{} + missing := missingRequiredActionSlots(skillName, action, values) + if action != "create" { + return nil + } + if skillName == "strategy_management" { + return nil + } + if skillName == "model_management" { + coreMissing := missingCoreReadinessSlots([]string{"provider"}, values) + if len(coreMissing) > 0 { + return coreMissing + } + return nil + } + if skillName == "exchange_management" { + coreMissing := missingCoreReadinessSlots([]string{"exchange_type", "account_name", "api_key", "secret_key"}, values) + if len(coreMissing) > 0 { + return coreMissing + } + return nil + } + if len(missing) == 0 || len(missing) == len(missingRequiredActionSlots(skillName, action, map[string]string{})) { + if hasExplicitCreateIntentForDomain(text, "trader") || containsAny(strings.ToLower(text), []string{"创建", "新建", "create", "new"}) { + return nil + } + } + if len(missing) >= 2 { + return missing + } + return nil +} + +func missingCoreReadinessSlots(keys []string, values map[string]string) []string { + missing := make([]string, 0, len(keys)) + for _, key := range keys { + if strings.TrimSpace(values[key]) == "" { + missing = append(missing, key) + } + } + return missing +} + +func (a *Agent) skillSemanticReadinessSummary(lang string, session skillSession, skillName, action, text string) string { + missing := semanticReadinessMissingSlots(skillName, action, session, text) + if len(missing) == 0 { + if lang == "zh" { + return "当前语义已足够进入该 skill。" + } + return "Semantic readiness is sufficient for this skill." + } + display := make([]string, 0, len(missing)) + for _, slot := range missing { + display = append(display, slotDisplayName(slot, lang)) + } + if lang == "zh" { + return "当前语义还缺核心字段:" + strings.Join(display, "、") + "。如果直接执行只会变成程序式缺字段提示,应优先走 planner/ask_user。" + } + return "Core semantic fields are still missing: " + strings.Join(display, ", ") + ". Prefer planner/ask_user before direct execution." +} + +func detectSkillQuestionField(skillName, text string, session skillSession) string { + return "" +} + +func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action string) string { + fieldNames := make([]string, 0, 20) + add := func(field string) { + field = strings.TrimSpace(field) + if field == "" { + return + } + for _, existing := range fieldNames { + if existing == field { + return + } + } + fieldNames = append(fieldNames, field) + } + + switch skillName { + case "model_management": + if lang == "zh" { + add("Provider") + } else { + add("provider") + } + add(displayCatalogFieldName("name", lang)) + for _, field := range manualModelEditableFieldKeys() { + add(displayCatalogFieldName(field, lang)) + } + case "exchange_management": + add(slotDisplayName("exchange_type", lang)) + for _, field := range manualExchangeEditableFieldKeys() { + add(displayCatalogFieldName(field, lang)) + } + case "trader_management": + add(slotDisplayName("name", lang)) + add(slotDisplayName("exchange", lang)) + add(slotDisplayName("model", lang)) + add(slotDisplayName("strategy", lang)) + for _, field := range manualTraderEditableFieldKeys() { + add(displayCatalogFieldName(field, lang)) + } + case "strategy_management": + add(slotDisplayName("name", lang)) + for _, field := range manualStrategyEditableFieldKeys() { + add(strategyConfigFieldDisplayName(field, lang)) + } + } + if len(fieldNames) == 0 { + return "" + } + prefix := "Visible UI fields" + if lang == "zh" { + prefix = "当前可见字段" + } + return prefix + ":" + strings.Join(fieldNames, "、") +} + +func (a *Agent) skillVisibleOptionSummary(storeUserID, lang, skillName, action string) string { + switch skillName { + case "model_management": + return a.modelSkillOptionSummary(lang) + case "exchange_management": + return a.exchangeSkillOptionSummary(lang) + case "trader_management": + return a.traderSkillOptionSummary(storeUserID, lang) + case "strategy_management": + return a.strategySkillOptionSummary(storeUserID, lang) + default: + return "" + } +} + +func (a *Agent) modelSkillOptionSummary(lang string) string { + if lang == "zh" { + return modelProviderChoicePrompt(lang) + } + return modelProviderChoicePrompt(lang) +} + +func (a *Agent) exchangeSkillOptionSummary(lang string) string { + options := enumOptionValues("exchange_management", "exchange_type") + if len(options) == 0 { + options = []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"} + } + if lang == "zh" { + return "交易所类型选项:" + strings.Join(options, "、") + } + return "Exchange type options: " + strings.Join(options, ", ") +} + +func enumOptionValues(skillName, field string) []string { + def, ok := getSkillDefinition(skillName) + if !ok { + return nil + } + constraint, ok := def.FieldConstraints[field] + if !ok || len(constraint.Values) == 0 { + return nil + } + values := make([]string, 0, len(constraint.Values)) + for _, value := range constraint.Values { + if value == "" { + continue + } + switch value { + case "openai": + values = append(values, "OpenAI") + case "deepseek": + values = append(values, "DeepSeek") + case "claude": + values = append(values, "Claude") + case "gemini": + values = append(values, "Gemini") + case "qwen": + values = append(values, "Qwen") + case "kimi": + values = append(values, "Kimi") + case "grok": + values = append(values, "Grok") + case "minimax": + values = append(values, "Minimax") + case "binance": + values = append(values, "Binance") + case "okx": + values = append(values, "OKX") + case "bybit": + values = append(values, "Bybit") + case "gate": + values = append(values, "Gate") + case "kucoin": + values = append(values, "KuCoin") + case "bitget": + values = append(values, "Bitget") + case "hyperliquid": + values = append(values, "Hyperliquid") + case "aster": + values = append(values, "Aster") + case "lighter": + values = append(values, "Lighter") + case "indodax": + values = append(values, "Indodax") + default: + values = append(values, value) + } + } + return values +} + +func (a *Agent) traderSkillOptionSummary(storeUserID, lang string) string { + parts := []string{ + formatSkillOptionList(lang, "可选模型", "Available models", a.loadEnabledModelOptions(storeUserID)), + formatSkillOptionList(lang, "可选交易所", "Available exchanges", a.loadExchangeOptions(storeUserID)), + formatSkillOptionList(lang, "可选策略", "Available strategies", a.loadStrategyOptions(storeUserID)), + } + return strings.Join(filterNonEmptyStrings(parts), "\n") +} + +func (a *Agent) strategySkillOptionSummary(storeUserID, lang string) string { + parts := []string{ + "", + formatSkillOptionList(lang, "现有策略", "Existing strategies", a.loadStrategyOptions(storeUserID)), + } + sourceOptions := []string{"static", "ai500", "oi_top", "oi_low"} + if lang == "zh" { + parts[0] = "选币来源选项:static、ai500、oi_top、oi_low" + } else { + parts[0] = "Coin source options: static, ai500, oi_top, oi_low" + } + _ = sourceOptions + return strings.Join(filterNonEmptyStrings(parts), "\n") +} + +func formatSkillOptionList(lang, zhPrefix, enPrefix string, options []traderSkillOption) string { + names := make([]string, 0, len(options)) + for _, option := range options { + label := strings.TrimSpace(defaultIfEmpty(option.Name, option.ID)) + if label == "" { + continue + } + names = append(names, label) + } + if len(names) == 0 { + if lang == "zh" { + return zhPrefix + ":暂无" + } + return enPrefix + ": none" + } + if lang == "zh" { + return zhPrefix + ":" + strings.Join(names, "、") + } + return enPrefix + ": " + strings.Join(names, ", ") +} + +func filterNonEmptyStrings(items []string) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + out = append(out, item) + } + return out +} diff --git a/agent/skills/exchange_management.json b/agent/skills/exchange_management.json index 1baf26ce..e4a04616 100644 --- a/agent/skills/exchange_management.json +++ b/agent/skills/exchange_management.json @@ -3,30 +3,205 @@ "kind": "management", "domain": "exchange", "description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。", + "field_constraints": { + "exchange_type": { + "type": "enum", + "required": true, + "values": ["binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax"], + "aliases": {"币安": "binance", "欧易": "okx", "必安": "binance", "bitget": "bitget", "bitget futures": "bitget", "bitget合约": "bitget", "库币": "kucoin", "gate.io": "gate", "hyper": "hyperliquid", "印尼站": "indodax"}, + "description": "交易所类型,必填,决定后续需要哪些凭证字段。" + }, + "account_name": { + "type": "string", + "max_length": 50, + "description": "账户显示名称,可选,用于区分同一交易所的多个账户。" + }, + "api_key": { + "type": "credential", + "pattern": "^[A-Za-z0-9_\\-]{8,}$", + "description": "交易所 API Key,至少 8 位字母数字。" + }, + "secret_key": { + "type": "credential", + "pattern": "^([A-Za-z0-9_\\-]{8,}|(0x)?[A-Fa-f0-9]{16,})$", + "description": "交易所 Secret Key,至少 8 位字母数字,或十六进制格式。" + }, + "passphrase": { + "type": "credential", + "required_for": ["okx"], + "description": "OKX 专用 Passphrase,OKX 账户启用前必须填写,其他交易所不需要。" + }, + "testnet": { + "type": "bool", + "default": false, + "description": "是否使用测试网(沙盒环境),默认 false(主网)。" + }, + "enabled": { + "type": "bool", + "default": false, + "description": "是否启用该交易所配置。启用前必须通过凭证完整性校验。" + }, + "hyperliquid_wallet_addr": { + "type": "credential", + "required_for": ["hyperliquid"], + "description": "Hyperliquid 主钱包地址,Hyperliquid 账户启用前必须填写。" + }, + "hyperliquid_unified_account": { + "type": "bool", + "default": false, + "required_for": ["hyperliquid"], + "description": "是否启用 Hyperliquid unified account 模式。" + }, + "aster_user": { + "type": "credential", + "required_for": ["aster"], + "description": "Aster 用户地址,Aster 账户启用前必须填写。" + }, + "aster_signer": { + "type": "credential", + "required_for": ["aster"], + "description": "Aster Signer 地址,Aster 账户启用前必须填写。" + }, + "aster_private_key": { + "type": "credential", + "required_for": ["aster"], + "description": "Aster 私钥,Aster 账户启用前必须填写。" + }, + "lighter_wallet_addr": { + "type": "credential", + "required_for": ["lighter"], + "description": "Lighter 钱包地址,Lighter 账户启用前必须填写。" + }, + "lighter_private_key": { + "type": "credential", + "required_for": ["lighter"], + "description": "Lighter 私钥,某些 Lighter 账户模式下启用前必须填写。" + }, + "lighter_api_key_private_key": { + "type": "credential", + "required_for": ["lighter"], + "description": "Lighter API Key 私钥,Lighter 账户启用前必须填写。" + }, + "lighter_api_key_index": { + "type": "int", + "min": 0, + "max": 255, + "required_for": ["lighter"], + "description": "Lighter API Key Index,范围 0~255,超出范围自动收敛并告知用户。" + } + }, + "validation_rules": [ + "api_key 格式:至少 8 位字母数字,不符合时提示用户重新输入完整 Key。", + "secret_key 格式:至少 8 位字母数字,或十六进制格式,不符合时提示用户重新输入。", + "OKX 账户启用前必须填写 passphrase,否则拒绝启用并提示补填。", + "Bitget/KuCoin 页面流程里也需要 passphrase,缺失时应明确提示补填。", + "Hyperliquid 创建/更新时应与手动页面保持一致:至少收集 api_key + hyperliquid_wallet_addr。", + "Hyperliquid 账户启用前必须填写 hyperliquid_wallet_addr。", + "若用户使用 Hyperliquid unified account 模式,应明确记录 hyperliquid_unified_account 开关状态。", + "Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段。", + "Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段,任一缺失都不能启用。", + "Lighter 账户启用前必须填写 lighter_wallet_addr + lighter_api_key_private_key;若当前账户模式还依赖 lighter_private_key,也要先补齐后再启用。", + "lighter_api_key_index 超出 0~255 时自动收敛到边界值并告知用户。", + "删除操作不可逆,必须先向用户确认再执行。" + ], + "per_exchange_required_fields": { + "binance": ["api_key", "secret_key"], + "okx": ["api_key", "secret_key", "passphrase"], + "bybit": ["api_key", "secret_key"], + "bitget": ["api_key", "secret_key", "passphrase"], + "gate": ["api_key", "secret_key"], + "kucoin": ["api_key", "secret_key", "passphrase"], + "indodax": ["api_key", "secret_key"], + "hyperliquid": ["api_key", "hyperliquid_wallet_addr"], + "aster": ["aster_user", "aster_signer", "aster_private_key"], + "lighter": ["lighter_wallet_addr", "lighter_api_key_private_key"] + }, "actions": { "create": { - "description": "创建新的交易所配置。", - "required_slots": ["exchange_type"], - "optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"] + "description": "创建新的交易所配置。根据 exchange_type 决定需要收集哪些凭证字段。", + "required_slots": ["exchange_type", "account_name"], + "optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"], + "goal": "创建一个可供 trader 绑定使用的交易所配置。", + "dynamic_rules": [ + "确认 exchange_type 后,根据 per_exchange_required_fields 决定需要追问哪些凭证字段。", + "Binance/Bybit/Gate/Indodax 需要 API Key + Secret;OKX/Bitget/KuCoin 还必须追问 passphrase;Hyperliquid 必须追问 api_key + 钱包地址,并允许记录 unified account 开关;Aster 必须追问 user/signer/private_key;Lighter 必须追问钱包地址和 api_key_private_key。", + "凭证字段格式不符时,用人话告知用户正确格式,不要静默丢弃。", + "若当前父任务只是缺一个可用交易所,本动作完成后应允许父任务恢复并消费新的 exchange_id。", + "若请求只是在启用已有交易所,不应误走 create,应改走 update_status。" + ], + "success_output": "返回新 exchange_id 和创建后的交易所配置摘要(类型、账户名、是否启用)。", + "failure_output": "明确指出缺失的必填字段或非法凭证格式,禁止返回含糊的成功信息。" }, "update": { - "description": "更新已有交易所配置。", + "description": "更新已有交易所配置的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"] + "optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"], + "goal": "更新一个已有交易所配置的指定字段,而不影响未提及字段。", + "dynamic_rules": [ + "只更新用户明确提到的字段,不要覆盖未提及的字段。", + "更新凭证字段时,格式不符则提示用户重新输入。" + ], + "success_output": "返回 exchange_id 和更新后的交易所配置摘要。", + "failure_output": "明确指出目标交易所不存在、凭证格式非法,或仍缺哪个字段。" + }, + "update_name": { + "description": "修改交易所配置中的账户显示名称字段。", + "required_slots": ["target_ref", "account_name"], + "goal": "修改交易所配置中的账户显示名称,而不影响其他字段。", + "dynamic_rules": [ + "若用户同时提到其他字段,应优先走更通用的 update。" + ], + "success_output": "返回 exchange_id,并明确告知交易所配置已更新。", + "failure_output": "明确指出目标交易所不存在,或新的账户名称仍缺失。" + }, + "update_status": { + "description": "修改交易所配置中的启用开关。启用前系统会校验凭证完整性。", + "required_slots": ["target_ref", "enabled"], + "goal": "修改交易所配置中的启用状态字段。", + "dynamic_rules": [ + "启用前根据 exchange_type 校验必填凭证是否齐全,不齐全则提示用户补填后再启用。" + ], + "success_output": "返回 exchange_id,并明确告知交易所配置已更新。", + "failure_output": "明确指出目标交易所不存在、缺少必填凭证,或当前状态切换失败。" }, "delete": { - "description": "删除交易所配置。", + "description": "删除交易所配置,不可逆操作,必须确认。", "required_slots": ["target_ref"], - "needs_confirmation": true + "needs_confirmation": true, + "goal": "删除一个交易所配置。", + "dynamic_rules": [ + "必须在确认后执行,并明确提醒删除不可逆。" + ], + "success_output": "返回删除成功结果,并明确告知该交易所配置已被移除。", + "failure_output": "明确指出缺少确认、目标交易所不存在,或删除失败原因。" }, - "query": { - "description": "查询交易所配置。" + "query_list": { + "description": "查询所有交易所配置列表,包含类型、账户名、启用状态。", + "goal": "列出当前用户可用的交易所配置,便于后续绑定或选择。", + "dynamic_rules": [ + "优先返回类型、账户名、启用状态,不返回敏感凭证明文。" + ], + "success_output": "返回交易所配置列表摘要。", + "failure_output": "若列表为空,应明确告知当前没有交易所配置。" + }, + "query_detail": { + "description": "查询某个交易所配置的详细信息。", + "required_slots": ["target_ref"], + "goal": "读取一个交易所配置的详细信息和当前状态。", + "dynamic_rules": [ + "详情返回中只能暴露凭证存在性,不得返回凭证明文。" + ], + "success_output": "返回目标交易所配置的详细摘要。", + "failure_output": "明确指出目标交易所不存在,或当前引用已经失效。" } }, "tool_mapping": { "create": "manage_exchange_config:create", "update": "manage_exchange_config:update", + "update_name": "manage_exchange_config:update", + "update_status": "manage_exchange_config:update", "delete": "manage_exchange_config:delete", - "query": "get_exchange_configs" + "query_list": "get_exchange_configs", + "query_detail": "get_exchange_configs" } } diff --git a/agent/skills/model_management.json b/agent/skills/model_management.json index 98b159ee..12677dc2 100644 --- a/agent/skills/model_management.json +++ b/agent/skills/model_management.json @@ -3,30 +3,146 @@ "kind": "management", "domain": "model", "description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。", + "field_constraints": { + "provider": { + "type": "enum", + "required": true, + "values": ["openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax", "claw402", "blockrun-base", "blockrun-sol"], + "description": "模型提供商,必填。决定默认模型、凭证类型以及可选配置项。" + }, + "name": { + "type": "string", + "max_length": 50, + "description": "模型配置显示名称,可选,用于区分同一 provider 的多个配置。" + }, + "api_key": { + "type": "credential", + "description": "模型凭证。普通 provider 使用 API Key;claw402 和 blockrun 使用钱包私钥。启用前必须填写。" + }, + "custom_api_url": { + "type": "url", + "must_be_https": true, + "description": "自定义 API Base URL,必须是合法的 HTTPS 地址。普通 provider 可留空走默认地址;claw402 / blockrun 不需要。" + }, + "custom_model_name": { + "type": "string", + "description": "实际调用的模型 ID,例如 gpt-5.1、deepseek-chat。若 provider 有默认模型,可留空走默认值。" + }, + "enabled": { + "type": "bool", + "default": false, + "description": "是否启用该模型配置。启用前必须填写 provider 对应的凭证;若 provider 没有默认模型,还需要 custom_model_name。" + } + }, + "validation_rules": [ + "provider 必须是支持列表之一:openai、deepseek、claude、gemini、qwen、kimi、grok、minimax、claw402、blockrun-base、blockrun-sol。", + "OpenAI 的 api_key 格式校验:必须以 sk- 开头,不符合时提示用户检查 Key 是否完整。", + "custom_api_url 若填写,必须是合法 HTTPS 地址,系统拒绝 HTTP 地址,提示用户改用 HTTPS。", + "启用(enabled=true)前必须填写 provider 对应的凭证;如果 custom_model_name 留空,则系统应先尝试使用 provider 默认模型。", + "启用(enabled=true)前,custom_api_url 若填写必须是合法 HTTPS 地址;不允许用 HTTP 地址硬启用。", + "删除操作不可逆,必须先向用户确认再执行。" + ], "actions": { "create": { "description": "创建新的模型配置。", "required_slots": ["provider"], - "optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"] + "optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"], + "goal": "创建一个可供 trader 绑定使用的模型配置。", + "dynamic_rules": [ + "确认 provider 后,先说明该 provider 的默认模型和凭证类型,再按 provider 特性补充追问。", + "普通 provider(openai、deepseek、claude 等)通常需要 api_key;custom_api_url 和 custom_model_name 可留空走默认值。", + "claw402 需要钱包私钥,不需要 custom_api_url;custom_model_name 留空时默认 deepseek。", + "blockrun-base 和 blockrun-sol 需要钱包私钥,不需要 custom_api_url;custom_model_name 留空时默认 auto。", + "若用户提供了 custom_api_url,校验是否为合法 HTTPS 地址,不合法则提示修正。", + "OpenAI 的 api_key 不以 sk- 开头时,提示用户检查 Key 格式。", + "若用户要在父任务里使用现有模型,应优先选择当前已启用模型,而不是误开新的 create。", + "若当前父任务只是缺一个可用模型,本动作完成后应允许父任务恢复并消费新的 model_id。" + ], + "success_output": "返回 model_id 和创建后的模型配置摘要(provider、名称、是否启用)。", + "failure_output": "明确指出缺失字段、非法 endpoint 或不支持的 provider,禁止只说泛化失败。" }, "update": { - "description": "更新已有模型配置。", + "description": "更新已有模型配置的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"] + "optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"], + "goal": "更新一个已有模型配置的指定字段,而不覆盖未提及字段。", + "dynamic_rules": [ + "只更新用户明确提到的字段,不要覆盖未提及的字段。", + "更新 custom_api_url 时校验 HTTPS 格式。", + "更新 api_key 时对 OpenAI 校验 sk- 前缀。" + ], + "success_output": "返回 model_id 和更新后的模型配置摘要。", + "failure_output": "明确指出目标模型不存在、provider/endpoint 非法,或仍缺哪个关键字段。" + }, + "update_status": { + "description": "启用或禁用模型配置。启用前系统会校验 api_key 和 custom_model_name 是否已填写。", + "required_slots": ["target_ref", "enabled"], + "goal": "切换模型配置的启用状态。", + "dynamic_rules": [ + "启用前必须确保 api_key 和 custom_model_name 已经齐全;若 provider 有特殊规则,也要在提示中体现。" + ], + "success_output": "返回 model_id,并明确告知该模型已启用或已禁用。", + "failure_output": "明确指出目标模型不存在、缺少启用前必填项,或当前状态切换失败。" + }, + "update_endpoint": { + "description": "仅修改模型的 custom_api_url。", + "required_slots": ["target_ref", "custom_api_url"], + "goal": "仅更新模型配置的 custom_api_url。", + "dynamic_rules": [ + "custom_api_url 必须是合法 HTTPS 地址;若不合法,先让用户修正而不是继续执行。" + ], + "success_output": "返回 model_id,并明确告知新的接口地址。", + "failure_output": "明确指出目标模型不存在,或接口地址仍不合法。" + }, + "update_name": { + "description": "仅修改模型配置的 custom_model_name(实际调用的模型 ID)。", + "required_slots": ["target_ref", "custom_model_name"], + "goal": "仅更新模型配置的实际调用模型 ID。", + "dynamic_rules": [ + "若用户其实是在改显示名称或 provider,应转去更通用的 update,而不是误用本动作。" + ], + "success_output": "返回 model_id,并明确告知新的 custom_model_name。", + "failure_output": "明确指出目标模型不存在,或新的模型 ID 仍未收齐。" }, "delete": { - "description": "删除模型配置。", + "description": "删除模型配置,不可逆操作,必须确认。", "required_slots": ["target_ref"], - "needs_confirmation": true + "needs_confirmation": true, + "goal": "删除一个模型配置。", + "dynamic_rules": [ + "必须在确认后执行,并明确提醒删除不可逆。" + ], + "success_output": "返回删除成功结果,并明确告知该模型配置已被移除。", + "failure_output": "明确指出缺少确认、目标模型不存在,或删除失败原因。" }, - "query": { - "description": "查询模型配置。" + "query_list": { + "description": "查询所有模型配置列表,包含 provider、名称、启用状态。", + "goal": "列出当前用户可见的模型配置,便于后续选择或绑定。", + "dynamic_rules": [ + "优先返回 provider、名称、启用状态,不返回 API Key 明文。" + ], + "success_output": "返回模型配置列表摘要。", + "failure_output": "若列表为空,应明确告知当前没有模型配置。" + }, + "query_detail": { + "description": "查询某个模型配置的详细信息。", + "required_slots": ["target_ref"], + "goal": "读取一个模型配置的详细信息。", + "dynamic_rules": [ + "详情返回中只能暴露 API Key 是否存在,不得返回明文凭证。" + ], + "success_output": "返回目标模型配置的详细摘要。", + "failure_output": "明确指出目标模型不存在,或当前引用已经失效。" } }, "tool_mapping": { "create": "manage_model_config:create", "update": "manage_model_config:update", + "update_status": "manage_model_config:update", + "update_endpoint": "manage_model_config:update", + "update_name": "manage_model_config:update", "delete": "manage_model_config:delete", - "query": "get_model_configs" + "query_list": "get_model_configs", + "query_detail": "get_model_configs" } } diff --git a/agent/skills/strategy_management.json b/agent/skills/strategy_management.json index a6ce0465..91b59aec 100644 --- a/agent/skills/strategy_management.json +++ b/agent/skills/strategy_management.json @@ -2,41 +2,479 @@ "name": "strategy_management", "kind": "management", "domain": "strategy", - "description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。", + "description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。策略模板不能直接启动运行;只有绑定了该策略的交易员可以启动。", + "field_constraints": { + "name": { + "type": "string", + "required": true, + "max_length": 50, + "description": "策略模板名称,必填,最多 50 个字符。" + }, + "description": { + "type": "string", + "description": "策略描述,可选。" + }, + "lang": { + "type": "enum", + "values": ["zh", "en"], + "default": "zh", + "description": "策略语言,zh 或 en,影响 AI 决策时使用的语言。" + }, + "strategy_type": { + "type": "enum", + "values": ["ai_trading", "grid_trading"], + "default": "ai_trading", + "description": "策略类型:ai_trading(AI 量化)或 grid_trading(网格策略)。" + }, + "source_type": { + "type": "enum", + "values": ["static", "ai500", "oi_top", "oi_low", "mixed"], + "description": "选币来源类型。static=用户指定静态币池,ai500=AI500榜单,oi_top=持仓量增长,oi_low=持仓量下降,mixed=混合。" + }, + "static_coins": { + "type": "string_array", + "description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"],source_type=static 时使用。" + }, + "excluded_coins": { + "type": "string_array", + "description": "排除币列表,所有来源均会排除这些币。" + }, + "primary_timeframe": { + "type": "string", + "values": ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"], + "description": "主 K 线周期,例如 5m、15m、1h。" + }, + "selected_timeframes": { + "type": "string_array", + "description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"]。" + }, + "btceth_max_leverage": { + "type": "int", + "min": 1, + "max": 20, + "description": "BTC/ETH 最大杠杆倍数,范围 1~20。" + }, + "altcoin_max_leverage": { + "type": "int", + "min": 1, + "max": 20, + "description": "山寨币最大杠杆倍数,范围 1~20。" + }, + "max_positions": { + "type": "int", + "min": 1, + "description": "最大同时持仓数量,最小 1。" + }, + "min_confidence": { + "type": "int", + "min": 0, + "max": 100, + "description": "最小开仓置信度,范围 0~100,数值越高开单越谨慎。" + }, + "min_risk_reward_ratio": { + "type": "float", + "min": 0.1, + "description": "最小盈亏比,例如 1.5 表示每笔交易至少 1.5 倍风险收益比。" + }, + "custom_prompt": { + "type": "text", + "description": "自定义 AI 提示词,追加到策略基础提示词之后。" + }, + "role_definition": { + "type": "text", + "description": "AI 角色定义,描述 AI 的交易风格和定位。" + }, + "trading_frequency": { + "type": "text", + "description": "交易频率描述,例如:每天最多开 3 笔。" + }, + "entry_standards": { + "type": "text", + "description": "入场标准描述,例如:只在趋势明确时开仓。" + }, + "decision_process": { + "type": "text", + "description": "决策流程描述,例如:先看大周期趋势,再看小周期入场点。" + }, + "grid_count": { + "type": "int", + "min": 2, + "description": "网格数量,grid_trading 类型专用,最小 2。" + }, + "total_investment": { + "type": "float", + "min": 0, + "description": "网格总投入金额,grid_trading 类型专用。" + }, + "upper_price": { + "type": "float", + "description": "网格上边界价格,grid_trading 类型专用。" + }, + "lower_price": { + "type": "float", + "description": "网格下边界价格,grid_trading 类型专用,必须小于 upper_price。" + }, + "distribution": { + "type": "enum", + "values": ["uniform", "gaussian", "pyramid"], + "description": "网格分布方式:uniform=均匀,gaussian=正态,pyramid=金字塔。" + }, + "use_atr_bounds": { + "type": "bool", + "default": false, + "description": "网格边界是否改为按 ATR 动态计算。" + }, + "atr_multiplier": { + "type": "float", + "min": 0, + "description": "ATR 边界倍数,use_atr_bounds=true 时使用。" + }, + "enable_direction_adjust": { + "type": "bool", + "default": false, + "description": "是否启用方向偏置调整。" + }, + "direction_bias_ratio": { + "type": "float", + "min": 0, + "description": "方向偏置比例,决定多空倾向强弱。" + }, + "max_drawdown_pct": { + "type": "float", + "min": 0, + "max": 100, + "description": "最大回撤百分比止损,范围 0~100。" + }, + "stop_loss_pct": { + "type": "float", + "min": 0, + "max": 100, + "description": "止损百分比,范围 0~100。" + }, + "daily_loss_limit_pct": { + "type": "float", + "min": 0, + "max": 100, + "description": "每日最大亏损比例,达到后当天停止新开仓。" + }, + "use_maker_only": { + "type": "bool", + "default": false, + "description": "是否优先只挂 maker 单。" + }, + "use_ai500": { + "type": "bool", + "default": false, + "description": "是否启用 AI500 榜单作为候选币来源。" + }, + "ai500_limit": { + "type": "int", + "min": 1, + "description": "AI500 榜单选取数量。" + }, + "use_oi_top": { + "type": "bool", + "default": false, + "description": "是否启用 OI Top 作为候选币来源。" + }, + "oi_top_limit": { + "type": "int", + "min": 1, + "description": "OI Top 选取数量。" + }, + "use_oi_low": { + "type": "bool", + "default": false, + "description": "是否启用 OI Low 作为候选币来源。" + }, + "oi_low_limit": { + "type": "int", + "min": 1, + "description": "OI Low 选取数量。" + }, + "primary_count": { + "type": "int", + "min": 1, + "description": "主周期样本数量。" + }, + "enable_ema": { + "type": "bool", + "default": false, + "description": "是否启用 EMA 指标。" + }, + "enable_macd": { + "type": "bool", + "default": false, + "description": "是否启用 MACD 指标。" + }, + "enable_rsi": { + "type": "bool", + "default": false, + "description": "是否启用 RSI 指标。" + }, + "enable_atr": { + "type": "bool", + "default": false, + "description": "是否启用 ATR 指标。" + }, + "enable_boll": { + "type": "bool", + "default": false, + "description": "是否启用布林带指标。" + }, + "enable_volume": { + "type": "bool", + "default": false, + "description": "是否启用成交量指标。" + }, + "enable_oi": { + "type": "bool", + "default": false, + "description": "是否启用持仓量指标。" + }, + "enable_funding_rate": { + "type": "bool", + "default": false, + "description": "是否启用资金费率指标。" + }, + "ema_periods": { + "type": "int_array", + "description": "EMA 周期列表,例如 [9,21,55]。" + }, + "rsi_periods": { + "type": "int_array", + "description": "RSI 周期列表。" + }, + "atr_periods": { + "type": "int_array", + "description": "ATR 周期列表。" + }, + "boll_periods": { + "type": "int_array", + "description": "布林带周期列表。" + }, + "nofxos_api_key": { + "type": "credential", + "description": "量化数据 API Key。" + }, + "enable_quant_data": { + "type": "bool", + "default": false, + "description": "是否启用量化数据增强。" + }, + "enable_quant_oi": { + "type": "bool", + "default": false, + "description": "是否启用量化持仓量数据。" + }, + "enable_quant_netflow": { + "type": "bool", + "default": false, + "description": "是否启用量化净流入数据。" + }, + "enable_oi_ranking": { + "type": "bool", + "default": false, + "description": "是否启用 OI 排行榜。" + }, + "oi_ranking_duration": { + "type": "string", + "description": "OI 排行榜统计周期。" + }, + "oi_ranking_limit": { + "type": "int", + "min": 1, + "description": "OI 排行榜返回数量。" + }, + "enable_netflow_ranking": { + "type": "bool", + "default": false, + "description": "是否启用净流入排行榜。" + }, + "netflow_ranking_duration": { + "type": "string", + "description": "净流入排行榜统计周期。" + }, + "netflow_ranking_limit": { + "type": "int", + "min": 1, + "description": "净流入排行榜返回数量。" + }, + "enable_price_ranking": { + "type": "bool", + "default": false, + "description": "是否启用价格波动排行榜。" + }, + "price_ranking_duration": { + "type": "string", + "description": "价格排行榜统计周期。" + }, + "price_ranking_limit": { + "type": "int", + "min": 1, + "description": "价格排行榜返回数量。" + }, + "btceth_max_position_value_ratio": { + "type": "float", + "min": 0, + "description": "BTC/ETH 单仓最大仓位价值占比。" + }, + "altcoin_max_position_value_ratio": { + "type": "float", + "min": 0, + "description": "山寨币单仓最大仓位价值占比。" + }, + "max_margin_usage": { + "type": "float", + "min": 0, + "description": "最大保证金占用比例。" + }, + "min_position_size": { + "type": "float", + "min": 0, + "description": "最小下单金额。" + } + }, + "validation_rules": [ + "btceth_max_leverage 和 altcoin_max_leverage 范围均为 1~20,超出时自动收敛并告知用户。", + "min_confidence 范围 0~100,超出时自动收敛并告知用户。", + "grid_trading 类型时,lower_price 必须小于 upper_price,否则提示用户修正。", + "grid_count 最小为 2,低于 2 时提示用户修正。", + "策略模板不能直接启动运行,只有绑定了该策略的交易员才能启动。", + "删除操作不可逆,必须先向用户确认再执行。", + "激活(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 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。", + "启用量化数据相关开关时,若需要 nofxos_api_key,应主动提醒用户补齐。", + "启用排行榜相关能力时,只修改用户明确提到的 enable_*、duration、limit 字段,不要偷偷打开其他排行榜。" + ], "actions": { "create": { - "description": "创建策略模板。", + "description": "创建策略模板。至少需要名称,其他配置可按需追问或按默认值补齐。", "required_slots": ["name"], - "optional_slots": ["config", "description", "lang"] + "optional_slots": ["description", "lang", "strategy_type", "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", "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"], + "goal": "创建一个可供 trader 绑定使用的策略模板。", + "dynamic_rules": [ + "若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。", + "若用户明确要求新建策略,至少先收齐名称;其他配置可继续追问或按默认值协助补齐。", + "策略模板不能直接启动运行;本动作成功后通常返回 strategy_id 供后续 trader 绑定使用。", + "杠杆超出 1~20 范围时,自动收敛并告知用户。" + ], + "success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。", + "failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。" }, "update": { - "description": "更新策略模板。", + "description": "更新策略模板的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["name", "config", "description"] + "optional_slots": ["name", "description", "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", "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"], + "goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。", + "dynamic_rules": [ + "只更新用户明确提到的字段,不要覆盖未提及的字段。", + "杠杆超出 1~20 范围时,自动收敛并告知用户。", + "grid_trading 类型时,lower_price 必须小于 upper_price。" + ], + "success_output": "返回 strategy_id 和更新后的策略摘要。", + "failure_output": "明确指出目标策略不存在、参数非法,或仍缺哪个关键字段。" }, - "delete": { - "description": "删除策略模板。", + "update_name": { + "description": "仅修改策略模板名称。", + "required_slots": ["target_ref", "name"], + "goal": "仅修改策略模板名称。", + "dynamic_rules": [ + "若输入里还包含其他配置项,应优先转去更通用的 update 或 update_config。" + ], + "success_output": "返回 strategy_id,并明确告知新的策略名称。", + "failure_output": "明确指出目标策略不存在,或新的名称仍未收齐。" + }, + "update_prompt": { + "description": "仅修改策略的 custom_prompt 或 prompt_sections(role_definition、trading_frequency、entry_standards、decision_process)。", "required_slots": ["target_ref"], - "needs_confirmation": true + "optional_slots": ["custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process"], + "goal": "更新策略模板的提示词相关内容,而不改动其他配置。", + "dynamic_rules": [ + "若用户一次修改多个 prompt section,应整体应用并在结果里清楚说明。", + "若用户实际是在改纯配置项,应转去 update_config。", + "当需要收集 custom_prompt 或 prompt_sections 等长文本槽位,而用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次机械索要原文。", + "此时你必须直接以量化专家身份先拟出一版高质量文本,将生成内容写入对应字段,并在回复里展示草稿让用户确认是否直接采用。" + ], + "success_output": "返回 strategy_id,并明确告知哪些 prompt 字段已更新。", + "failure_output": "明确指出目标策略不存在,或新的 prompt 内容仍不完整。" + }, + "update_config": { + "description": "修改策略的某个具体配置参数(选币来源、指标、风控参数等)。", + "required_slots": ["target_ref", "config_field", "config_value"], + "goal": "修改策略模板中的一个或一组具体配置参数。", + "dynamic_rules": [ + "配置值超出手动面板边界时,应先自动收敛并明确告知用户。", + "若用户一次提到多个配置 patch,可在同一轮内整体应用,但要明确说明最终修改了哪些字段。", + "当配置更新涉及 custom_prompt、role_definition、trading_frequency、entry_standards、decision_process、description、name 等文本槽位,且用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次向用户索要正文。", + "此时你必须直接生成一版可用文本,写入对应 extracted 字段,并用确认式问题向用户展示:“我先为你拟了一版……,要直接按这版更新吗?”" + ], + "success_output": "返回 strategy_id,并明确告知已修改的配置字段及其最终值。", + "failure_output": "明确指出目标策略不存在、配置字段非法,或值仍需用户澄清。" }, "activate": { - "description": "激活策略模板。", - "required_slots": ["target_ref"] + "description": "将策略模板设为默认模板(激活)。注意:这不是启动运行,只是设为默认。", + "required_slots": ["target_ref"], + "goal": "将某个策略模板设为默认模板,而不是直接运行它。", + "dynamic_rules": [ + "必须明确区分“激活模板”和“启动交易员运行”,不要把 activate 解释成运行。" + ], + "success_output": "返回 strategy_id,并明确告知该策略已被设为默认模板。", + "failure_output": "明确指出目标策略不存在,或激活失败原因。" }, "duplicate": { - "description": "复制策略模板。", - "required_slots": ["target_ref", "name"] + "description": "复制策略模板,生成一个新的同配置模板。", + "required_slots": ["target_ref", "name"], + "goal": "复制一个现有策略模板并生成新的模板名称。", + "dynamic_rules": [ + "新名称必须单独收齐;若名称有歧义或为空,应先继续追问。" + ], + "success_output": "返回新的 strategy_id,并明确告知复制后的策略名称。", + "failure_output": "明确指出目标策略不存在,或新名称仍未收齐。" }, - "query": { - "description": "查询策略模板。" + "delete": { + "description": "删除策略模板,不可逆操作,必须确认。", + "required_slots": ["target_ref"], + "needs_confirmation": true, + "goal": "删除一个策略模板。", + "dynamic_rules": [ + "必须在确认后执行,并明确提醒删除不可逆。", + "若策略是默认模板或受系统保护,应向用户解释限制。" + ], + "success_output": "返回删除成功结果,并明确告知该策略模板已被移除。", + "failure_output": "明确指出缺少确认、目标策略不存在,或删除失败原因。" + }, + "query_list": { + "description": "查询所有策略模板列表,包含名称、类型、是否为默认模板。", + "goal": "列出当前用户可见的策略模板,便于后续选择或绑定。", + "dynamic_rules": [ + "优先返回名称、类型、默认状态,不必展开全部详细配置。" + ], + "success_output": "返回策略模板列表摘要。", + "failure_output": "若列表为空,应明确告知当前没有策略模板。" + }, + "query_detail": { + "description": "查询某个策略模板的详细配置,包括选币来源、指标、风控参数、提示词等。", + "required_slots": ["target_ref"], + "goal": "读取一个策略模板的详细配置。", + "dynamic_rules": [ + "若目标有歧义,应先澄清再返回详情。" + ], + "success_output": "返回目标策略模板的详细配置摘要。", + "failure_output": "明确指出目标策略不存在,或当前引用已经失效。" } }, "tool_mapping": { "create": "manage_strategy:create", "update": "manage_strategy:update", - "delete": "manage_strategy:delete", + "update_name": "manage_strategy:update", + "update_prompt": "manage_strategy:update", + "update_config": "manage_strategy:update", "activate": "manage_strategy:activate", "duplicate": "manage_strategy:duplicate", - "query": "get_strategies" + "delete": "manage_strategy:delete", + "query_list": "get_strategies", + "query_detail": "get_strategies" } } diff --git a/agent/skills/trade_execution.json b/agent/skills/trade_execution.json new file mode 100644 index 00000000..40cdc3eb --- /dev/null +++ b/agent/skills/trade_execution.json @@ -0,0 +1,63 @@ +{ + "name": "trade_execution", + "kind": "execution", + "domain": "trade", + "description": "当用户明确要求开仓、平仓、买入、卖出,或确认待执行的大额订单时调用。负责真实下单前的安全校验、待确认订单、确认执行与交易历史查询。", + "intents": [ + "下单交易", + "开多开空", + "平仓", + "确认大额订单", + "查询交易历史" + ], + "actions": { + "execute": { + "description": "创建一笔待确认交易。不会直接成交,而是先做风险检查,再给用户确认指令。", + "required_slots": ["action", "symbol", "quantity"], + "optional_slots": ["leverage", "trader_id"], + "needs_confirmation": true, + "goal": "在真实执行前先做风险检查,并给用户一个可确认的待执行订单。", + "dynamic_rules": [ + "只有当用户明确要求交易时才允许进入本动作;分析、建议、解释行情都不应触发下单。", + "开仓数量必须大于 0,单笔数量硬上限为 1000000,超过时直接拒绝。", + "会先按实时价格估算名义价值;单笔名义价值硬上限为 100000 USDT,超过时直接拒绝。", + "若单笔名义价值达到 5000 USDT,或达到账户权益的 25%,必须标记为大额订单,要求用户发送“确认大额 trade_xxx”后才执行。", + "若单笔名义价值超过账户权益的 100%,直接拒绝,不允许创建待确认订单。", + "加密货币订单的杠杆上限受策略 btceth_max_leverage / altcoin_max_leverage 约束,默认上限为 5x;超出时直接拒绝。", + "BTC/ETH 单笔最大仓位价值默认不超过 5 倍账户权益,山寨币默认不超过 1 倍账户权益;若策略里有自定义比例,以策略为准。", + "最小仓位价值默认 12 USDT;若策略配置了 min_position_size,以策略为准。低于最小值时直接拒绝。", + "创建后的待确认订单默认 5 分钟有效,超时自动失效。" + ], + "success_output": "返回 trade_id、估算仓位价值、是否触发大额确认、确认命令和 5 分钟有效期。", + "failure_output": "用简单清楚的话说明是哪条风控挡住了,例如数量过大、仓位太小、杠杆过高、超过权益上限。" + }, + "confirm_large_order": { + "description": "确认一笔已创建的大额待执行订单。", + "required_slots": ["trade_id"], + "needs_confirmation": true, + "goal": "在用户明确确认后,执行已通过初步检查的大额订单。", + "dynamic_rules": [ + "用户必须发送“确认大额 trade_xxx”或“confirm large trade_xxx”才能执行大额订单。", + "若订单已过期、已不存在,或 trade_id 无效,要直接说明这笔订单已经失效。", + "若用户只发送普通确认,但订单被标记为大额订单,必须继续要求“大额确认”,不能直接放行。" + ], + "success_output": "明确告知订单已执行,并展示方向、品种、数量。", + "failure_output": "明确说明订单已过期、风控未通过,或执行失败原因。" + }, + "query_history": { + "description": "查询最近的交易历史。", + "optional_slots": ["limit", "trader_id"], + "goal": "让用户快速查看最近成交记录和交易结果。", + "dynamic_rules": [ + "优先返回最近几笔最重要的交易,不要一次性给太长的开发者原始日志。", + "若当前没有交易记录,要直接说明当前还没有成交记录。" + ], + "success_output": "返回最近交易记录摘要,包括方向、品种、时间和结果。", + "failure_output": "若没有记录或查询失败,要明确告知用户。" + } + }, + "tool_mapping": { + "execute": "execute_trade", + "query_history": "get_trade_history" + } +} diff --git a/agent/skills/trader_diagnosis.json b/agent/skills/trader_diagnosis.json index ae263145..7adc673d 100644 --- a/agent/skills/trader_diagnosis.json +++ b/agent/skills/trader_diagnosis.json @@ -2,5 +2,23 @@ "name": "trader_diagnosis", "kind": "diagnosis", "domain": "trader", - "description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。" + "description": "当用户反馈交易员无法启动、启动后不交易、反复报错、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。", + "capabilities": [ + "读取交易员当前状态、账户、持仓和最近决策记录", + "在用户明确指定目标交易员后,读取该交易员最近的后端日志", + "把错误日志、运行状态和绑定信息合并成适合新手理解的诊断结论" + ], + "dynamic_rules": [ + "当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。", + "如果已经能唯一确定目标交易员,应优先结合 get_backend_logs、持仓、状态和决策记录一起分析,而不是只看配置。", + "当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 get_backend_logs。", + "当日志中已经出现明确错误原因时,直接用人话解释原因和下一步,不要只复述原始日志。" + ], + "tool_mapping": { + "query_runtime_state": "get_trader_system_status", + "query_positions": "get_positions", + "query_account": "get_account_info", + "query_recent_decisions": "get_decisions", + "query_backend_logs": "get_backend_logs" + } } diff --git a/agent/skills/trader_management.json b/agent/skills/trader_management.json index babd251d..d23f2271 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": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。", + "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、保证金模式、扫描频率、竞技场显示、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。", "intents": [ "创建交易员", "修改交易员", @@ -11,42 +11,266 @@ "停止交易员", "查询交易员" ], + "field_constraints": { + "name": { + "type": "string", + "required": true, + "max_length": 50, + "description": "交易员名称,用于识别和管理,最多 50 个字符。" + }, + "exchange_id": { + "type": "entity_ref", + "required": true, + "description": "绑定的交易所配置 ID,必须是已存在且已启用的交易所配置。" + }, + "ai_model_id": { + "type": "entity_ref", + "required": true, + "description": "绑定的 AI 模型配置 ID,必须是已存在且已启用的模型配置。" + }, + "strategy_id": { + "type": "entity_ref", + "required": true, + "description": "绑定的策略模板 ID,必须是已存在的策略模板。" + }, + "scan_interval_minutes": { + "type": "int", + "min": 3, + "max": 60, + "default": 5, + "description": "AI 扫描决策间隔,单位分钟,手动面板可配置范围 3~60 分钟。超出范围会自动收敛到边界值并告知用户。" + }, + "initial_balance": { + "type": "float", + "min": 100.0, + "description": "初始资金,手动面板最低 100。超出范围会自动收敛到最低值并告知用户。" + }, + "is_cross_margin": { + "type": "bool", + "default": true, + "description": "保证金模式。true = 全仓(cross margin),false = 逐仓(isolated margin)。" + }, + "show_in_competition": { + "type": "bool", + "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, + "description": "创建后是否立即启动交易员。启动前系统会校验绑定的交易所、模型、策略均可用。" + } + }, + "validation_rules": [ + "exchange_id 对应的交易所配置必须已启用(enabled=true),否则无法创建或启动交易员。", + "ai_model_id 对应的模型配置必须已启用(enabled=true)且配置完整(api_key、custom_model_name 不为空;custom_api_url 若填写必须为合法 HTTPS),否则无法创建或启动交易员。", + "strategy_id 对应的策略模板必须存在,否则无法创建交易员。", + "scan_interval_minutes 超出 3~60 范围时,系统自动收敛到边界值,并通过 LLM 告知用户已调整,询问是否接受。", + "initial_balance 低于 100 时,系统自动收敛到 100,并通过 LLM 告知用户已调整。", + "启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。", + "若绑定的是 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)操作不可逆,必须先向用户确认再执行。" + ], "actions": { "create": { - "description": "创建新的交易员。", - "required_slots": ["name", "exchange", "model"], - "optional_slots": ["strategy", "auto_start"] + "description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。", + "required_slots": ["name", "exchange", "model", "strategy"], + "optional_slots": ["auto_start", "scan_interval_minutes", "initial_balance", "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"], + "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),启动前再次确认。" + ], + "success_output": "返回 trader_id,并给出创建结果摘要(名称、绑定的交易所/模型/策略、是否已启动)。", + "failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。" }, "update": { - "description": "更新已有交易员。", + "description": "更新已有交易员的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"] + "optional_slots": ["name", "exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "initial_balance", "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"], + "goal": "更新一个已有交易员的配置,但只修改用户明确要求变更的字段。", + "dynamic_rules": [ + "只更新用户明确提到的字段,不要覆盖未提及的字段。", + "换绑交易所/模型/策略时,新的资源必须已存在且已启用,否则提示用户先启用或新建。", + "scan_interval_minutes 超出 3~60 时,自动收敛并告知用户。", + "若用户修改的是交易员级杠杆、指定币对、提示词或选币来源,也应走 update,而不是误导去改策略。" + ], + "success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。", + "failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。" }, - "delete": { - "description": "删除交易员。", + "update_name": { + "description": "仅修改交易员名称。", + "required_slots": ["target_ref", "name"], + "goal": "把指定交易员改成新的名称,不影响其他配置。", + "dynamic_rules": [ + "若当前输入里同时包含别的配置字段,应优先转去更通用的 update,而不是只改名。" + ], + "success_output": "返回 trader_id,并明确告知新的交易员名称。", + "failure_output": "明确指出目标交易员不存在,或新的名称仍未收齐。" + }, + "update_bindings": { + "description": "修改交易员绑定的交易所、模型或策略(可同时修改多个)。", "required_slots": ["target_ref"], - "needs_confirmation": true + "optional_slots": ["exchange_id", "ai_model_id", "strategy_id"], + "goal": "调整交易员绑定的依赖资源,而不改动无关配置。", + "dynamic_rules": [ + "新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。" + ], + "success_output": "返回 trader_id,并明确展示新的模型/交易所/策略绑定结果。", + "failure_output": "明确指出缺少哪个绑定目标,或当前依赖资源为什么不可直接绑定。" + }, + "configure_strategy": { + "description": "仅修改交易员绑定的策略。", + "required_slots": ["target_ref", "strategy_id"], + "goal": "为指定交易员换绑一个策略模板。", + "dynamic_rules": [ + "若用户提到的是不存在的策略,应优先澄清或引导创建,而不是静默失败。" + ], + "success_output": "返回 trader_id,并明确告知当前生效的 strategy_id/策略名称。", + "failure_output": "明确指出目标交易员或策略不存在,或策略仍需用户澄清。" + }, + "configure_exchange": { + "description": "仅修改交易员绑定的交易所。", + "required_slots": ["target_ref", "exchange_id"], + "goal": "为指定交易员换绑一个交易所配置。", + "dynamic_rules": [ + "新的交易所配置必须已启用且可用,否则提示用户先启用或补齐凭证。" + ], + "success_output": "返回 trader_id,并明确告知当前生效的 exchange_id/交易所名称。", + "failure_output": "明确指出目标交易员或交易所不存在,或交易所当前不可用。" + }, + "configure_model": { + "description": "仅修改交易员绑定的 AI 模型。", + "required_slots": ["target_ref", "ai_model_id"], + "goal": "为指定交易员换绑一个 AI 模型配置。", + "dynamic_rules": [ + "新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。" + ], + "success_output": "返回 trader_id,并明确告知当前生效的 ai_model_id/模型名称。", + "failure_output": "明确指出目标交易员或模型不存在,或模型当前不可用。" }, "start": { - "description": "启动交易员。", + "description": "启动交易员,使其开始自动交易。高风险操作,必须确认。", "required_slots": ["target_ref"], - "needs_confirmation": true + "needs_confirmation": true, + "goal": "让一个已配置好的交易员进入运行状态。", + "dynamic_rules": [ + "启动前系统会自动校验绑定的交易所、模型、策略是否均可用。", + "若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。" + ], + "success_output": "返回 trader_id,并明确告知交易员已开始运行。", + "failure_output": "明确指出缺少确认、依赖资源不可用,或启动未通过校验。" }, "stop": { - "description": "停止交易员。", + "description": "停止交易员,使其停止自动交易。高风险操作,必须确认。", "required_slots": ["target_ref"], - "needs_confirmation": true + "needs_confirmation": true, + "goal": "让一个运行中的交易员停止自动交易。", + "dynamic_rules": [ + "若交易员当前并未运行,也应给用户清晰说明,而不是假装停止成功。" + ], + "success_output": "返回 trader_id,并明确告知交易员已停止。", + "failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。" }, - "query": { - "description": "查询交易员列表或状态。" + "delete": { + "description": "删除交易员,不可逆操作,必须确认。", + "required_slots": ["target_ref"], + "needs_confirmation": true, + "goal": "删除一个交易员及其运行入口。", + "dynamic_rules": [ + "必须在确认后执行,并明确提醒该操作不可逆。" + ], + "success_output": "返回删除成功结果,并明确告知该交易员已被移除。", + "failure_output": "明确指出缺少确认、目标交易员不存在,或删除失败原因。" + }, + "query_list": { + "description": "查询所有交易员列表,包含名称、运行状态、绑定信息。", + "goal": "列出当前用户可见的交易员,并给出足够的摘要用于后续选择。", + "dynamic_rules": [ + "优先返回名称、运行状态、绑定的模型/交易所/策略,不要冗余展开全部详情。" + ], + "success_output": "返回交易员列表摘要,便于用户继续指定目标对象。", + "failure_output": "若列表为空,应明确告知当前没有交易员,而不是返回模糊空结果。" + }, + "query_running": { + "description": "查询当前运行中的交易员列表。", + "goal": "仅列出处于运行状态的交易员。", + "dynamic_rules": [ + "若当前没有运行中的交易员,应明确告知为空。" + ], + "success_output": "返回当前运行中的交易员列表摘要。", + "failure_output": "若没有运行中的交易员,应明确返回空列表说明。" + }, + "query_detail": { + "description": "查询某个交易员的详细配置,包括绑定的交易所、模型、策略、扫描间隔、保证金模式等。", + "required_slots": ["target_ref"], + "goal": "读取一个交易员的详细配置和当前绑定信息。", + "dynamic_rules": [ + "若目标对象有歧义,应先澄清再读取详情。" + ], + "success_output": "返回目标交易员的详细配置摘要。", + "failure_output": "明确指出目标交易员不存在,或当前引用需要重新指定。" } }, "tool_mapping": { "create": "manage_trader:create", "update": "manage_trader:update", - "delete": "manage_trader:delete", + "update_name": "manage_trader:update", + "update_bindings": "manage_trader:update", + "configure_strategy": "manage_trader:update", + "configure_exchange": "manage_trader:update", + "configure_model": "manage_trader:update", "start": "manage_trader:start", "stop": "manage_trader:stop", - "query": "manage_trader:list" + "delete": "manage_trader:delete", + "query_list": "manage_trader:list", + "query_running": "manage_trader:list", + "query_detail": "manage_trader:list" } } diff --git a/agent/strategy_field_catalog.go b/agent/strategy_field_catalog.go new file mode 100644 index 00000000..e13b853e --- /dev/null +++ b/agent/strategy_field_catalog.go @@ -0,0 +1,149 @@ +package agent + +func manualStrategyEditableFieldKeys() []string { + return []string{ + "name", + "description", + "is_public", + "config_visible", + "strategy_type", + "symbol", + "grid_count", + "total_investment", + "upper_price", + "lower_price", + "use_atr_bounds", + "atr_multiplier", + "distribution", + "enable_direction_adjust", + "direction_bias_ratio", + "max_drawdown_pct", + "stop_loss_pct", + "daily_loss_limit_pct", + "use_maker_only", + "source_type", + "static_coins", + "excluded_coins", + "use_ai500", + "ai500_limit", + "use_oi_top", + "oi_top_limit", + "use_oi_low", + "oi_low_limit", + "primary_timeframe", + "primary_count", + "selected_timeframes", + "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_leverage", + "altcoin_max_leverage", + "btceth_max_position_value_ratio", + "altcoin_max_position_value_ratio", + "max_margin_usage", + "min_position_size", + "min_risk_reward_ratio", + "min_confidence", + "role_definition", + "trading_frequency", + "entry_standards", + "decision_process", + "custom_prompt", + } +} + +func agentStrategyUpdatableFieldKeys() []string { + return []string{ + "name", + "description", + "is_public", + "config_visible", + "strategy_type", + "symbol", + "grid_count", + "total_investment", + "upper_price", + "lower_price", + "use_atr_bounds", + "atr_multiplier", + "distribution", + "enable_direction_adjust", + "direction_bias_ratio", + "max_drawdown_pct", + "stop_loss_pct", + "daily_loss_limit_pct", + "use_maker_only", + "source_type", + "static_coins", + "excluded_coins", + "use_ai500", + "ai500_limit", + "use_oi_top", + "oi_top_limit", + "use_oi_low", + "oi_low_limit", + "primary_timeframe", + "primary_count", + "selected_timeframes", + "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_leverage", + "altcoin_max_leverage", + "btceth_max_position_value_ratio", + "altcoin_max_position_value_ratio", + "max_margin_usage", + "min_position_size", + "min_risk_reward_ratio", + "min_confidence", + "role_definition", + "trading_frequency", + "entry_standards", + "decision_process", + "custom_prompt", + } +} diff --git a/agent/stream_text.go b/agent/stream_text.go new file mode 100644 index 00000000..f57e8f52 --- /dev/null +++ b/agent/stream_text.go @@ -0,0 +1,49 @@ +package agent + +import "strings" + +func emitStreamText(onEvent func(event, data string), text string) { + if onEvent == nil { + return + } + for _, chunk := range splitStreamText(text) { + onEvent(StreamEventDelta, chunk) + } +} + +func splitStreamText(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + + lines := strings.Split(text, "\n") + chunks := make([]string, 0, len(lines)*2) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + start := 0 + for i, r := range line { + switch r { + case '。', '!', '?', '.', '!', '?', ';', ';', ':', ':', ',', ',': + part := strings.TrimSpace(line[start : i+len(string(r))]) + if part != "" { + chunks = append(chunks, part) + } + start = i + len(string(r)) + } + } + if start < len(line) { + part := strings.TrimSpace(line[start:]) + if part != "" { + chunks = append(chunks, part) + } + } + } + if len(chunks) == 0 { + return []string{text} + } + return chunks +} diff --git a/agent/tools.go b/agent/tools.go index be7e1f24..6b644c5a 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "path/filepath" "sort" @@ -24,6 +25,300 @@ var cachedTools = buildAgentTools() // agentTools returns the tools available to the LLM for autonomous action. func agentTools() []mcp.Tool { return cachedTools } +func normalizedEntityName(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func sameEntityName(a, b string) bool { + return normalizedEntityName(a) != "" && normalizedEntityName(a) == normalizedEntityName(b) +} + +func (a *Agent) ensureUniqueModelName(storeUserID, name, excludeID string) error { + models, err := a.store.AIModel().List(storeUserID) + if err != nil { + return err + } + for _, model := range models { + if model == nil || strings.TrimSpace(model.ID) == strings.TrimSpace(excludeID) { + continue + } + if sameEntityName(model.Name, name) { + return fmt.Errorf("model name %q already exists", strings.TrimSpace(name)) + } + } + return nil +} + +func (a *Agent) ensureUniqueExchangeAccountName(storeUserID, accountName, excludeID string) error { + exchanges, err := a.store.Exchange().List(storeUserID) + if err != nil { + return err + } + for _, exchange := range exchanges { + if exchange == nil || strings.TrimSpace(exchange.ID) == strings.TrimSpace(excludeID) { + continue + } + if sameEntityName(exchange.AccountName, accountName) { + return fmt.Errorf("exchange account name %q already exists", strings.TrimSpace(accountName)) + } + } + return nil +} + +func (a *Agent) ensureUniqueStrategyName(storeUserID, name, excludeID string) error { + strategies, err := a.store.Strategy().List(storeUserID) + if err != nil { + return err + } + for _, strategy := range strategies { + if strategy == nil || strings.TrimSpace(strategy.ID) == strings.TrimSpace(excludeID) { + continue + } + if sameEntityName(strategy.Name, name) { + return fmt.Errorf("strategy name %q already exists", strings.TrimSpace(name)) + } + } + return nil +} + +func (a *Agent) ensureUniqueTraderName(storeUserID, name, excludeID string) error { + traders, err := a.store.Trader().List(storeUserID) + if err != nil { + return err + } + for _, trader := range traders { + if trader == nil || strings.TrimSpace(trader.ID) == strings.TrimSpace(excludeID) { + continue + } + if sameEntityName(trader.Name, name) { + return fmt.Errorf("trader name %q already exists", strings.TrimSpace(name)) + } + } + return nil +} + +func stringArraySchema(description string) map[string]any { + return map[string]any{ + "type": "array", + "description": description, + "items": map[string]any{"type": "string"}, + } +} + +func intArraySchema(description string) map[string]any { + return map[string]any{ + "type": "array", + "description": description, + "items": map[string]any{"type": "number"}, + } +} + +func strategyConfigSchema() map[string]any { + return map[string]any{ + "type": "object", + "description": "Full or partial strategy config. Only include the fields you want to create or update.", + "properties": map[string]any{ + "strategy_type": map[string]any{"type": "string", "enum": []string{"ai_trading", "grid_trading"}}, + "language": map[string]any{"type": "string", "enum": []string{"zh", "en"}}, + "coin_source": map[string]any{ + "type": "object", + "properties": map[string]any{ + "source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}}, + "static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT."), + "excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."), + "use_ai500": map[string]any{"type": "boolean"}, + "ai500_limit": map[string]any{"type": "number"}, + "use_oi_top": map[string]any{"type": "boolean"}, + "oi_top_limit": map[string]any{"type": "number"}, + "use_oi_low": map[string]any{"type": "boolean"}, + "oi_low_limit": map[string]any{"type": "number"}, + "use_hyper_all": map[string]any{"type": "boolean"}, + "use_hyper_main": map[string]any{"type": "boolean"}, + "hyper_main_limit": map[string]any{"type": "number"}, + }, + }, + "indicators": map[string]any{ + "type": "object", + "properties": map[string]any{ + "klines": map[string]any{ + "type": "object", + "properties": map[string]any{ + "primary_timeframe": map[string]any{"type": "string"}, + "primary_count": map[string]any{"type": "number"}, + "longer_timeframe": map[string]any{"type": "string"}, + "longer_count": map[string]any{"type": "number"}, + "enable_multi_timeframe": map[string]any{"type": "boolean"}, + "selected_timeframes": stringArraySchema("Selected analysis timeframes, e.g. 5m,15m,1h."), + }, + }, + "enable_raw_klines": map[string]any{"type": "boolean"}, + "enable_ema": map[string]any{"type": "boolean"}, + "enable_macd": map[string]any{"type": "boolean"}, + "enable_rsi": map[string]any{"type": "boolean"}, + "enable_atr": map[string]any{"type": "boolean"}, + "enable_boll": map[string]any{"type": "boolean"}, + "enable_volume": map[string]any{"type": "boolean"}, + "enable_oi": map[string]any{"type": "boolean"}, + "enable_funding_rate": map[string]any{"type": "boolean"}, + "ema_periods": intArraySchema("EMA periods such as [20,50]."), + "rsi_periods": intArraySchema("RSI periods such as [7,14]."), + "atr_periods": intArraySchema("ATR periods such as [14]."), + "boll_periods": intArraySchema("BOLL periods such as [20]."), + "nofxos_api_key": map[string]any{"type": "string"}, + "enable_quant_data": map[string]any{"type": "boolean"}, + "enable_quant_oi": map[string]any{"type": "boolean"}, + "enable_quant_netflow": map[string]any{"type": "boolean"}, + "enable_oi_ranking": map[string]any{"type": "boolean"}, + "oi_ranking_duration": map[string]any{"type": "string"}, + "oi_ranking_limit": map[string]any{"type": "number"}, + "enable_netflow_ranking": map[string]any{"type": "boolean"}, + "netflow_ranking_duration": map[string]any{"type": "string"}, + "netflow_ranking_limit": map[string]any{"type": "number"}, + "enable_price_ranking": map[string]any{"type": "boolean"}, + "price_ranking_duration": map[string]any{"type": "string"}, + "price_ranking_limit": map[string]any{"type": "number"}, + }, + }, + "custom_prompt": map[string]any{"type": "string"}, + "risk_control": map[string]any{ + "type": "object", + "properties": map[string]any{ + "max_positions": map[string]any{"type": "number"}, + "btc_eth_max_leverage": map[string]any{"type": "number"}, + "altcoin_max_leverage": map[string]any{"type": "number"}, + "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"}, + }, + }, + "prompt_sections": map[string]any{ + "type": "object", + "properties": map[string]any{ + "role_definition": map[string]any{"type": "string"}, + "trading_frequency": map[string]any{"type": "string"}, + "entry_standards": map[string]any{"type": "string"}, + "decision_process": map[string]any{"type": "string"}, + }, + }, + "grid_config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "symbol": map[string]any{"type": "string"}, + "grid_count": map[string]any{"type": "number"}, + "total_investment": map[string]any{"type": "number"}, + "leverage": map[string]any{"type": "number"}, + "upper_price": map[string]any{"type": "number"}, + "lower_price": map[string]any{"type": "number"}, + "use_atr_bounds": map[string]any{"type": "boolean"}, + "atr_multiplier": map[string]any{"type": "number"}, + "distribution": map[string]any{"type": "string", "enum": []string{"uniform", "gaussian", "pyramid"}}, + "max_drawdown_pct": map[string]any{"type": "number"}, + "stop_loss_pct": map[string]any{"type": "number"}, + "daily_loss_limit_pct": map[string]any{"type": "number"}, + "use_maker_only": map[string]any{"type": "boolean"}, + "enable_direction_adjust": map[string]any{"type": "boolean"}, + "direction_bias_ratio": map[string]any{"type": "number"}, + }, + }, + }, + } +} + +func modelConfigFieldsSchema() map[string]any { + return map[string]any{ + "model_id": map[string]any{ + "type": "string", + "description": "Existing model id for update/delete, or the desired id for create.", + }, + "provider": map[string]any{ + "type": "string", + "description": "Provider slug such as openai, claude, gemini, deepseek, qwen, kimi, grok, minimax, claw402, blockrun-base, or blockrun-sol.", + }, + "name": map[string]any{ + "type": "string", + "description": "Display name for the model binding.", + }, + "enabled": map[string]any{ + "type": "boolean", + "description": "Whether this model binding is enabled.", + }, + "api_key": map[string]any{ + "type": "string", + "description": "Provider credential. For standard providers this is an API key; for claw402/blockrun it is the wallet private key. Sensitive and never returned in full.", + }, + "custom_api_url": map[string]any{ + "type": "string", + "description": "Custom API base URL or endpoint override. Optional for standard providers; not used by claw402/blockrun.", + }, + "custom_model_name": map[string]any{ + "type": "string", + "description": "Actual upstream model name to send to the provider. Optional when the provider has a default model.", + }, + } +} + +func exchangeConfigFieldsSchema() map[string]any { + return map[string]any{ + "exchange_id": map[string]any{ + "type": "string", + "description": "Existing exchange account id. Required for update and delete.", + }, + "exchange_type": map[string]any{ + "type": "string", + "description": "Exchange type such as binance, bybit, okx, bitget, gate, kucoin, hyperliquid, aster, lighter, or indodax.", + }, + "account_name": map[string]any{ + "type": "string", + "description": "User-visible account name like Main, Testnet, or Mom Account.", + }, + "enabled": map[string]any{ + "type": "boolean", + "description": "Whether this exchange binding should be enabled.", + }, + "api_key": map[string]any{"type": "string", "description": "API key for CEX-style exchanges."}, + "secret_key": map[string]any{"type": "string", "description": "Secret key for CEX-style exchanges."}, + "passphrase": map[string]any{"type": "string", "description": "Optional passphrase, required by exchanges like OKX, Bitget, and KuCoin."}, + "testnet": map[string]any{"type": "boolean", "description": "Whether to use the exchange testnet/sandbox."}, + "hyperliquid_wallet_addr": map[string]any{"type": "string", "description": "Hyperliquid wallet address."}, + "hyperliquid_unified_account": map[string]any{"type": "boolean", "description": "Whether Hyperliquid unified account mode is enabled."}, + "aster_user": map[string]any{"type": "string", "description": "Aster user address."}, + "aster_signer": map[string]any{"type": "string", "description": "Aster signer address."}, + "aster_private_key": map[string]any{"type": "string", "description": "Aster private key."}, + "lighter_wallet_addr": map[string]any{"type": "string", "description": "LIGHTER wallet address."}, + "lighter_private_key": map[string]any{"type": "string", "description": "LIGHTER private key."}, + "lighter_api_key_private_key": map[string]any{"type": "string", "description": "LIGHTER API key private key."}, + "lighter_api_key_index": map[string]any{"type": "number", "description": "LIGHTER API key index."}, + } +} + +func traderConfigFieldsSchema() map[string]any { + return map[string]any{ + "trader_id": 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."}, + "initial_balance": map[string]any{"type": "number", "description": "Initial balance / bankroll."}, + "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."}, + } +} + func buildAgentTools() []mcp.Tool { return []mcp.Tool{ { @@ -64,14 +359,12 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "get_backend_logs", - Description: "Get recent backend log lines for a trader diagnosis. Prefer this when the user asks why a specific trader failed, stopped, or behaved unexpectedly. Returns recent matching log lines for the authenticated user's trader.", + Description: "Get recent backend log lines for a trader diagnosis. Prefer this when the user asks why a specific trader failed, stopped, or behaved unexpectedly. Returns recent matching log lines for the authenticated user's trader. You can identify the trader by name or id — name is preferred when the user provides it.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ - "trader_id": map[string]any{ - "type": "string", - "description": "Trader id to diagnose. The backend verifies that this trader belongs to the authenticated user before returning logs.", - }, + "trader_id": map[string]any{"type": "string", "description": "Trader id to diagnose."}, + "trader_name": map[string]any{"type": "string", "description": "Trader name to diagnose. Used to look up the trader when id is not known."}, "limit": map[string]any{"type": "number", "description": "Maximum number of recent log lines to return. Default 30."}, "errors_only": map[string]any{"type": "boolean", "description": "When true, only return error-like log lines. Default true."}, }, @@ -90,7 +383,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "manage_exchange_config", - Description: "Create, update, or delete an exchange account binding. Use this when the user asks to add/edit/remove an exchange account, API key, secret, passphrase, wallet address, or account name. Sensitive fields are stored securely and are never returned in full.", + Description: "Create, update, or delete an exchange account binding. Use this when the user asks to add/edit/remove an exchange account, API key, secret, passphrase, wallet address, or account name. Prefer passing exact field values instead of vague summaries. Sensitive fields are stored securely and are never returned in full.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -98,35 +391,23 @@ func buildAgentTools() []mcp.Tool { "type": "string", "enum": []string{"create", "update", "delete"}, }, - "exchange_id": map[string]any{ - "type": "string", - "description": "Existing exchange account id. Required for update and delete.", - }, - "exchange_type": map[string]any{ - "type": "string", - "description": "Exchange type for a new binding, such as binance, bybit, okx, hyperliquid, aster, lighter, gate, kucoin, alpaca, forex, or metals.", - }, - "account_name": map[string]any{ - "type": "string", - "description": "User-visible account name like Main, Testnet, or Mom Account.", - }, - "enabled": map[string]any{ - "type": "boolean", - "description": "Whether this exchange binding should be enabled.", - }, - "api_key": map[string]any{"type": "string"}, - "secret_key": map[string]any{"type": "string"}, - "passphrase": map[string]any{"type": "string"}, - "testnet": map[string]any{"type": "boolean"}, - "hyperliquid_wallet_addr": map[string]any{"type": "string"}, - "hyperliquid_unified_account": map[string]any{"type": "boolean"}, - "aster_user": map[string]any{"type": "string"}, - "aster_signer": map[string]any{"type": "string"}, - "aster_private_key": map[string]any{"type": "string"}, - "lighter_wallet_addr": map[string]any{"type": "string"}, - "lighter_private_key": map[string]any{"type": "string"}, - "lighter_api_key_private_key": map[string]any{"type": "string"}, - "lighter_api_key_index": map[string]any{"type": "number"}, + "exchange_id": exchangeConfigFieldsSchema()["exchange_id"], + "exchange_type": exchangeConfigFieldsSchema()["exchange_type"], + "account_name": exchangeConfigFieldsSchema()["account_name"], + "enabled": exchangeConfigFieldsSchema()["enabled"], + "api_key": exchangeConfigFieldsSchema()["api_key"], + "secret_key": exchangeConfigFieldsSchema()["secret_key"], + "passphrase": exchangeConfigFieldsSchema()["passphrase"], + "testnet": exchangeConfigFieldsSchema()["testnet"], + "hyperliquid_wallet_addr": exchangeConfigFieldsSchema()["hyperliquid_wallet_addr"], + "hyperliquid_unified_account": exchangeConfigFieldsSchema()["hyperliquid_unified_account"], + "aster_user": exchangeConfigFieldsSchema()["aster_user"], + "aster_signer": exchangeConfigFieldsSchema()["aster_signer"], + "aster_private_key": exchangeConfigFieldsSchema()["aster_private_key"], + "lighter_wallet_addr": exchangeConfigFieldsSchema()["lighter_wallet_addr"], + "lighter_private_key": exchangeConfigFieldsSchema()["lighter_private_key"], + "lighter_api_key_private_key": exchangeConfigFieldsSchema()["lighter_api_key_private_key"], + "lighter_api_key_index": exchangeConfigFieldsSchema()["lighter_api_key_index"], }, "required": []string{"action"}, }, @@ -144,7 +425,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "manage_model_config", - Description: "Create, update, or delete an AI model binding. Use this when the user asks to add/edit/remove a model provider, API key, custom API URL, or custom model name. Sensitive fields are stored securely and are never returned in full.", + Description: "Create, update, or delete an AI model binding. Use this when the user asks to add/edit/remove a model provider, API key, custom API URL, or custom model name. Prefer passing exact field values instead of vague summaries. Sensitive fields are stored securely and are never returned in full.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -152,22 +433,13 @@ func buildAgentTools() []mcp.Tool { "type": "string", "enum": []string{"create", "update", "delete"}, }, - "model_id": map[string]any{ - "type": "string", - "description": "Existing model id for update/delete, or the desired id for create.", - }, - "provider": map[string]any{ - "type": "string", - "description": "Provider slug such as openai, claude, gemini, deepseek, qwen, kimi, grok, minimax, claw402, or blockrun-base.", - }, - "name": map[string]any{ - "type": "string", - "description": "Display name for a newly created model binding.", - }, - "enabled": map[string]any{"type": "boolean"}, - "api_key": map[string]any{"type": "string"}, - "custom_api_url": map[string]any{"type": "string"}, - "custom_model_name": map[string]any{"type": "string"}, + "model_id": modelConfigFieldsSchema()["model_id"], + "provider": modelConfigFieldsSchema()["provider"], + "name": modelConfigFieldsSchema()["name"], + "enabled": modelConfigFieldsSchema()["enabled"], + "api_key": modelConfigFieldsSchema()["api_key"], + "custom_api_url": modelConfigFieldsSchema()["custom_api_url"], + "custom_model_name": modelConfigFieldsSchema()["custom_model_name"], }, "required": []string{"action"}, }, @@ -185,7 +457,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "manage_strategy", - Description: "List, create, update, delete, activate, duplicate strategies, or get the default strategy config template. Use this when the user asks to create or edit a strategy template. Strategy templates are independent assets and do not require exchange/model bindings unless the user asks to run them via a trader.", + Description: "List, create, update, delete, activate, duplicate strategies, or get the default strategy config template. Use this when the user asks to create or edit a strategy template. Prefer passing precise field-level config patches in `config` instead of vague natural-language summaries.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -199,7 +471,7 @@ func buildAgentTools() []mcp.Tool { "lang": map[string]any{"type": "string", "enum": []string{"zh", "en"}}, "is_public": map[string]any{"type": "boolean"}, "config_visible": map[string]any{"type": "boolean"}, - "config": map[string]any{"type": "object", "description": "Full or partial strategy config JSON object, depending on action."}, + "config": strategyConfigSchema(), }, "required": []string{"action"}, }, @@ -209,7 +481,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, delete it, or control its running state.", + 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, tune leverage, prompts, symbol scope, scan interval, or control its running state.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -217,26 +489,23 @@ func buildAgentTools() []mcp.Tool { "type": "string", "enum": []string{"list", "create", "update", "delete", "start", "stop"}, }, - "trader_id": map[string]any{ - "type": "string", - "description": "Required for update, delete, start, and stop.", - }, - "name": map[string]any{"type": "string"}, - "ai_model_id": map[string]any{"type": "string"}, - "exchange_id": map[string]any{"type": "string"}, - "strategy_id": map[string]any{"type": "string"}, - "initial_balance": map[string]any{"type": "number"}, - "scan_interval_minutes": map[string]any{"type": "number"}, - "is_cross_margin": map[string]any{"type": "boolean"}, - "show_in_competition": map[string]any{"type": "boolean"}, - "btc_eth_leverage": map[string]any{"type": "number"}, - "altcoin_leverage": map[string]any{"type": "number"}, - "trading_symbols": map[string]any{"type": "string"}, - "custom_prompt": map[string]any{"type": "string"}, - "override_base_prompt": map[string]any{"type": "boolean"}, - "system_prompt_template": map[string]any{"type": "string"}, - "use_ai500": map[string]any{"type": "boolean"}, - "use_oi_top": map[string]any{"type": "boolean"}, + "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"], + "initial_balance": traderConfigFieldsSchema()["initial_balance"], + "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"], }, "required": []string{"action"}, }, @@ -263,7 +532,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "execute_trade", - Description: "Execute a trade order (crypto or US stocks). Use this when the user explicitly asks to open/close a position. For stocks (e.g. AAPL, TSLA), use open_long to buy and close_long to sell. This creates a pending trade that requires user confirmation.", + Description: "Execute a trade order (crypto or US stocks). Use this only when the user explicitly asks to trade. For stocks (e.g. AAPL, TSLA), use open_long to buy and close_long to sell. This creates a pending trade first; it does not execute immediately. Large orders require an extra confirmation with 确认大额 trade_xxx / confirm large trade_xxx, and pending trades expire after 5 minutes.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -322,6 +591,31 @@ func buildAgentTools() []mcp.Tool { }, }, }, + { + Type: "function", + Function: mcp.FunctionDef{ + Name: "get_kline", + Description: "Get recent kline/candlestick data for a crypto symbol. Use this when the user asks for recent candles, K 线, recent price structure, or a short-term chart context.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "symbol": map[string]any{ + "type": "string", + "description": "Crypto trading symbol, for example BTC, ETH, BTCUSDT, or ETHUSDT.", + }, + "interval": map[string]any{ + "type": "string", + "description": "Kline interval, for example 1m, 5m, 15m, 1h, 4h, or 1d. Defaults to 15m.", + }, + "limit": map[string]any{ + "type": "number", + "description": "Number of recent candles to fetch. Defaults to 50 and is capped at 300.", + }, + }, + "required": []string{"symbol"}, + }, + }, + }, { Type: "function", Function: mcp.FunctionDef{ @@ -358,6 +652,36 @@ func buildAgentTools() []mcp.Tool { }, }, }, + { + Type: "function", + Function: mcp.FunctionDef{ + Name: "get_watchlist", + Description: "Get the current Sentinel watchlist of monitored crypto symbols. Use this when the user asks which coins are being watched or monitored right now.", + Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, + }, + }, + { + Type: "function", + Function: mcp.FunctionDef{ + Name: "manage_watchlist", + Description: "Add or remove a monitored crypto symbol from the Sentinel watchlist at runtime. Use this when the user asks to watch, monitor, unwatch, or stop monitoring a coin.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"add", "remove"}, + "description": "Whether to add or remove the symbol from the watchlist.", + }, + "symbol": map[string]any{ + "type": "string", + "description": "Crypto symbol to watch, such as BTC, ETH, SOL, BTCUSDT, or ETHUSDT.", + }, + }, + "required": []string{"action", "symbol"}, + }, + }, + }, } } @@ -394,10 +718,16 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i return a.toolGetBalance() case "get_market_price": return a.toolGetMarketPrice(tc.Function.Arguments) + case "get_kline": + return a.toolGetKline(tc.Function.Arguments) case "get_trade_history": return a.toolGetTradeHistory(tc.Function.Arguments) case "get_candidate_coins": return a.toolGetCandidateCoins(storeUserID, userID, tc.Function.Arguments) + case "get_watchlist": + return a.toolGetWatchlist(lang) + case "manage_watchlist": + return a.toolManageWatchlist(lang, tc.Function.Arguments) default: return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name) } @@ -419,6 +749,7 @@ type safeExchangeToolConfig struct { AsterUser string `json:"aster_user,omitempty"` AsterSigner string `json:"aster_signer,omitempty"` LighterWalletAddr string `json:"lighter_wallet_addr,omitempty"` + LighterAPIKeyIndex int `json:"lighter_api_key_index,omitempty"` HasLighterPrivateKey bool `json:"has_lighter_private_key"` HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"` } @@ -463,6 +794,37 @@ type safeStrategyToolConfig struct { HasConfig bool `json:"has_config"` } +var sensitiveToolKeys = map[string]struct{}{ + "api_key": {}, + "secret_key": {}, + "passphrase": {}, + "private_key": {}, + "password_hash": {}, + "lighter_api_key_private_key": {}, +} + +func stripSensitiveToolFields(value any) any { + switch typed := value.(type) { + case map[string]any: + cleaned := make(map[string]any, len(typed)) + for key, inner := range typed { + if _, blocked := sensitiveToolKeys[strings.ToLower(strings.TrimSpace(key))]; blocked { + continue + } + cleaned[key] = stripSensitiveToolFields(inner) + } + return cleaned + case []any: + out := make([]any, 0, len(typed)) + for _, inner := range typed { + out = append(out, stripSensitiveToolFields(inner)) + } + return out + default: + return value + } +} + type manageTraderArgs struct { Action string `json:"action"` TraderID string `json:"trader_id"` @@ -501,6 +863,7 @@ func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig { AsterUser: ex.AsterUser, AsterSigner: ex.AsterSigner, LighterWalletAddr: ex.LighterWalletAddr, + LighterAPIKeyIndex: ex.LighterAPIKeyIndex, HasLighterPrivateKey: ex.LighterPrivateKey != "", HasLighterAPIKey: ex.LighterAPIKeyPrivateKey != "", } @@ -576,12 +939,19 @@ func (a *Agent) toolGetExchangeConfigs(storeUserID string) string { } safe := make([]safeExchangeToolConfig, 0, len(exchanges)) for _, ex := range exchanges { + if !store.IsVisibleExchange(ex) { + continue + } safe = append(safe, safeExchangeForTool(ex)) } result, _ := json.Marshal(map[string]any{ "exchange_configs": safe, "count": len(safe), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) } @@ -644,9 +1014,38 @@ func readBackendLogEntries(limit int, contains string, errorsOnly bool) (string, return path, matches, nil } +func filterBackendLogEntriesAny(entries []string, needles ...string) []string { + if len(entries) == 0 { + return nil + } + normalized := make([]string, 0, len(needles)) + for _, needle := range needles { + needle = strings.ToLower(strings.TrimSpace(needle)) + if needle == "" { + continue + } + normalized = append(normalized, needle) + } + if len(normalized) == 0 { + return entries + } + filtered := make([]string, 0, len(entries)) + for _, entry := range entries { + lower := strings.ToLower(entry) + for _, needle := range normalized { + if strings.Contains(lower, needle) { + filtered = append(filtered, entry) + break + } + } + } + return filtered +} + func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string { var args struct { TraderID string `json:"trader_id"` + TraderName string `json:"trader_name"` Limit int `json:"limit"` ErrorsOnly *bool `json:"errors_only"` } @@ -655,30 +1054,58 @@ func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string { return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) } } + if a.store == nil { + return `{"error":"store unavailable"}` + } errorsOnly := true if args.ErrorsOnly != nil { errorsOnly = *args.ErrorsOnly } traderID := strings.TrimSpace(args.TraderID) + traderName := strings.TrimSpace(args.TraderName) + if traderID == "" && traderName == "" { + return `{"error":"trader_id or trader_name is required"}` + } + // resolve by name if id not provided if traderID == "" { - return `{"error":"trader_id is required"}` + traders, err := a.store.Trader().List(storeUserID) + if err != nil { + return fmt.Sprintf(`{"error":"failed to list traders: %s"}`, err) + } + for _, t := range traders { + if strings.EqualFold(strings.TrimSpace(t.Name), traderName) { + traderID = t.ID + traderName = t.Name + break + } + } + if traderID == "" { + return fmt.Sprintf(`{"error":"trader %q not found"}`, traderName) + } + } else { + trader, err := a.store.Trader().GetByID(traderID) + if err != nil { + return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err) + } + if trader.UserID != storeUserID { + return `{"error":"trader not found for current user"}` + } + traderName = trader.Name } - if a.store == nil { - return `{"error":"store unavailable"}` - } - trader, err := a.store.Trader().GetByID(traderID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err) - } - if trader.UserID != storeUserID { - return `{"error":"trader not found for current user"}` - } - path, entries, err := readBackendLogEntries(args.Limit, traderID, errorsOnly) + path, entries, err := readBackendLogEntries(args.Limit, "", errorsOnly) if err != nil { return fmt.Sprintf(`{"error":"failed to read backend logs: %s"}`, err) } + entries = filterBackendLogEntriesAny(entries, traderID, traderName) + if args.Limit <= 0 { + args.Limit = 30 + } + if len(entries) > args.Limit { + entries = entries[len(entries)-args.Limit:] + } result, _ := json.Marshal(map[string]any{ "trader_id": traderID, + "trader_name": traderName, "log_file": path, "entries": entries, "count": len(entries), @@ -717,7 +1144,17 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { action := strings.TrimSpace(args.Action) switch action { case "create": - if strings.TrimSpace(args.ExchangeType) == "" { + missing := missingRequiredActionSlots("exchange_management", "create", map[string]string{ + "exchange_type": strings.TrimSpace(args.ExchangeType), + "account_name": strings.TrimSpace(args.AccountName), + "api_key": strings.TrimSpace(args.APIKey), + "secret_key": strings.TrimSpace(args.SecretKey), + }) + if len(missing) > 0 { + return fmt.Sprintf(`{"error":"missing required fields for create: %s"}`, strings.Join(missing, ", ")) + } + exchangeType := strings.TrimSpace(args.ExchangeType) + if exchangeType == "" { return `{"error":"exchange_type is required for create"}` } enabled := false @@ -736,9 +1173,28 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if args.LighterAPIKeyIndex != nil { lighterIndex = *args.LighterAPIKeyIndex } + if err := (exchangeConfigValidator{ + exchangeType: exchangeType, + enabled: enabled, + apiKey: strings.TrimSpace(args.APIKey), + secretKey: strings.TrimSpace(args.SecretKey), + passphrase: strings.TrimSpace(args.Passphrase), + hyperliquidWalletAddr: strings.TrimSpace(args.HyperliquidWalletAddr), + asterUser: strings.TrimSpace(args.AsterUser), + asterSigner: strings.TrimSpace(args.AsterSigner), + asterPrivateKey: strings.TrimSpace(args.AsterPrivateKey), + lighterWalletAddr: strings.TrimSpace(args.LighterWalletAddr), + lighterPrivateKey: strings.TrimSpace(args.LighterPrivateKey), + lighterAPIKeyPrivateKey: strings.TrimSpace(args.LighterAPIKeyPrivateKey), + }).Validate(); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + if err := a.ensureUniqueExchangeAccountName(storeUserID, strings.TrimSpace(args.AccountName), ""); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } id, err := a.store.Exchange().Create( storeUserID, - strings.TrimSpace(args.ExchangeType), + exchangeType, strings.TrimSpace(args.AccountName), enabled, strings.TrimSpace(args.APIKey), @@ -767,6 +1223,28 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { "action": "create", "exchange": safeExchangeForTool(created), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } + return string(result) + case "query": + if strings.TrimSpace(args.ExchangeID) == "" { + return `{"error":"exchange_id is required for query"}` + } + existing, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(args.ExchangeID)) + if err != nil { + return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err) + } + result, _ := json.Marshal(map[string]any{ + "status": "ok", + "action": "query", + "exchange": safeExchangeForTool(existing), + }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) case "update": if strings.TrimSpace(args.ExchangeID) == "" { @@ -808,6 +1286,46 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if strings.TrimSpace(args.LighterWalletAddr) != "" { lighterWallet = strings.TrimSpace(args.LighterWalletAddr) } + effectiveAPIKey := strings.TrimSpace(string(existing.APIKey)) + if trimmed := strings.TrimSpace(args.APIKey); trimmed != "" { + effectiveAPIKey = trimmed + } + effectiveSecretKey := strings.TrimSpace(string(existing.SecretKey)) + if trimmed := strings.TrimSpace(args.SecretKey); trimmed != "" { + effectiveSecretKey = trimmed + } + effectivePassphrase := strings.TrimSpace(string(existing.Passphrase)) + if trimmed := strings.TrimSpace(args.Passphrase); trimmed != "" { + effectivePassphrase = trimmed + } + effectiveAsterPrivateKey := strings.TrimSpace(string(existing.AsterPrivateKey)) + if trimmed := strings.TrimSpace(args.AsterPrivateKey); trimmed != "" { + effectiveAsterPrivateKey = trimmed + } + effectiveLighterPrivateKey := strings.TrimSpace(string(existing.LighterPrivateKey)) + if trimmed := strings.TrimSpace(args.LighterPrivateKey); trimmed != "" { + effectiveLighterPrivateKey = trimmed + } + effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey)) + if trimmed := strings.TrimSpace(args.LighterAPIKeyPrivateKey); trimmed != "" { + effectiveLighterAPIKeyPrivateKey = trimmed + } + if err := (exchangeConfigValidator{ + exchangeType: existing.ExchangeType, + enabled: enabled, + apiKey: effectiveAPIKey, + secretKey: effectiveSecretKey, + passphrase: effectivePassphrase, + hyperliquidWalletAddr: hyperWallet, + asterUser: asterUser, + asterSigner: asterSigner, + asterPrivateKey: effectiveAsterPrivateKey, + lighterWalletAddr: lighterWallet, + lighterPrivateKey: effectiveLighterPrivateKey, + lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey, + }).Validate(); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } if err := a.store.Exchange().Update( storeUserID, existing.ID, @@ -829,6 +1347,9 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { return fmt.Sprintf(`{"error":"failed to update exchange config: %s"}`, err) } if trimmed := strings.TrimSpace(args.AccountName); trimmed != "" && trimmed != existing.AccountName { + if err := a.ensureUniqueExchangeAccountName(storeUserID, trimmed, existing.ID); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } if err := a.store.Exchange().UpdateAccountName(storeUserID, existing.ID, trimmed); err != nil { return fmt.Sprintf(`{"error":"exchange updated but failed to rename account: %s"}`, err) } @@ -842,6 +1363,10 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { "action": "update", "exchange": safeExchangeForTool(updated), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) case "delete": if strings.TrimSpace(args.ExchangeID) == "" { @@ -871,12 +1396,19 @@ func (a *Agent) toolGetModelConfigs(storeUserID string) string { } safe := make([]safeModelToolConfig, 0, len(models)) for _, model := range models { + if !store.IsVisibleAIModel(model) { + continue + } safe = append(safe, safeModelForTool(model)) } result, _ := json.Marshal(map[string]any{ "model_configs": safe, "count": len(safe), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) } @@ -905,10 +1437,19 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { action := strings.TrimSpace(args.Action) switch action { case "create": + missing := missingRequiredActionSlots("model_management", "create", map[string]string{ + "provider": strings.TrimSpace(args.Provider), + }) + if len(missing) > 0 { + return fmt.Sprintf(`{"error":"missing required fields for create: %s"}`, strings.Join(missing, ", ")) + } provider := strings.TrimSpace(args.Provider) if provider == "" { return `{"error":"provider is required for create"}` } + if strings.TrimSpace(args.APIKey) == "" { + return `{"error":"api_key is required for create"}` + } modelID := strings.TrimSpace(args.ModelID) if modelID == "" { modelID = provider @@ -917,7 +1458,40 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { if args.Enabled != nil { enabled = *args.Enabled } - if err := a.store.AIModel().Update(storeUserID, modelID, enabled, strings.TrimSpace(args.APIKey), strings.TrimSpace(args.CustomAPIURL), strings.TrimSpace(args.CustomModelName)); err != nil { + name := strings.TrimSpace(args.Name) + if name == "" { + name = defaultModelConfigName(provider) + } + customModelName := strings.TrimSpace(args.CustomModelName) + if customModelName == "" && modelProviderSupportsCustomModel(provider) { + customModelName = defaultModelNameForProvider(provider) + } + customAPIURL := strings.TrimSpace(args.CustomAPIURL) + if !modelProviderSupportsCustomAPIURL(provider) { + customAPIURL = "" + } + if err := (modelConfigValidator{ + provider: provider, + enabled: enabled, + apiKey: strings.TrimSpace(args.APIKey), + customAPIURL: customAPIURL, + customModelName: customModelName, + modelID: modelID, + }).Validate(); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + if err := a.ensureUniqueModelName(storeUserID, name, ""); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + if err := a.store.AIModel().UpdateWithName( + storeUserID, + modelID, + name, + enabled, + strings.TrimSpace(args.APIKey), + customAPIURL, + customModelName, + ); err != nil { return fmt.Sprintf(`{"error":"failed to create model config: %s"}`, err) } createdID := modelID @@ -936,6 +1510,10 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { "action": "create", "model": safeModelForTool(model), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) case "update": modelID := strings.TrimSpace(args.ModelID) @@ -963,10 +1541,30 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { if apiKey != "" { effectiveAPIKey = apiKey } - if enabled && !modelConfigUsable(existing.Provider, existing.ID, effectiveAPIKey, customAPIURL, customModelName) { - return `{"error":"cannot enable model config before API key is configured"}` + if err := (modelConfigValidator{ + provider: existing.Provider, + enabled: enabled, + apiKey: effectiveAPIKey, + customAPIURL: customAPIURL, + customModelName: customModelName, + modelID: existing.ID, + }).Validate(); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) } - if err := a.store.AIModel().Update(storeUserID, existing.ID, enabled, apiKey, customAPIURL, customModelName); err != nil { + if trimmed := strings.TrimSpace(args.Name); trimmed != "" && !sameEntityName(trimmed, existing.Name) { + if err := a.ensureUniqueModelName(storeUserID, trimmed, existing.ID); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + } + if err := a.store.AIModel().UpdateWithName( + storeUserID, + existing.ID, + strings.TrimSpace(args.Name), + enabled, + apiKey, + customAPIURL, + customModelName, + ); err != nil { return fmt.Sprintf(`{"error":"failed to update model config: %s"}`, err) } updated, err := a.store.AIModel().Get(storeUserID, existing.ID) @@ -978,6 +1576,10 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { "action": "update", "model": safeModelForTool(updated), }) + var payload any + if err := json.Unmarshal(result, &payload); err == nil { + result, _ = json.Marshal(stripSensitiveToolFields(payload)) + } return string(result) case "delete": modelID := strings.TrimSpace(args.ModelID) @@ -1008,6 +1610,9 @@ func (a *Agent) toolGetStrategies(storeUserID string) string { } safeStrategies := make([]safeStrategyToolConfig, 0, len(strategies)) for _, strategy := range strategies { + if !store.IsVisibleStrategy(strategy) { + continue + } safeStrategies = append(safeStrategies, safeStrategyForTool(strategy)) } result, _ := json.Marshal(map[string]any{ @@ -1055,9 +1660,21 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if name == "" { return `{"error":"name is required for create"}` } - var cfg any = store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang)) + if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang)) + var cfg any = defaultConfig + var warnings []string if len(args.Config) > 0 { - cfg = args.Config + merged, err := store.MergeStrategyConfig(defaultConfig, args.Config) + if err != nil { + return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) + } + before := merged + merged.ClampLimits() + warnings = store.StrategyClampWarnings(before, merged, merged.Language) + cfg = merged } configJSON, err := json.Marshal(cfg) if err != nil { @@ -1081,6 +1698,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { "status": "ok", "action": "create", "strategy": safeStrategyForTool(record), + "warnings": warnings, }) return string(payload) case "update": @@ -1099,6 +1717,11 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if trimmed := strings.TrimSpace(args.Name); trimmed != "" { name = trimmed } + if !sameEntityName(name, existing.Name) { + if err := a.ensureUniqueStrategyName(storeUserID, name, existing.ID); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } + } description := existing.Description if trimmed := strings.TrimSpace(args.Description); trimmed != "" { description = trimmed @@ -1112,12 +1735,26 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { configVisible = *args.ConfigVisible } configJSON := existing.Config + var warnings []string if len(args.Config) > 0 { - raw, err := json.Marshal(args.Config) + var existingConfig store.StrategyConfig + if strings.TrimSpace(existing.Config) != "" { + if err := json.Unmarshal([]byte(existing.Config), &existingConfig); err != nil { + return fmt.Sprintf(`{"error":"failed to load existing strategy config: %s"}`, err) + } + } + merged, err := store.MergeStrategyConfig(existingConfig, args.Config) + if err != nil { + return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err) + } + before := merged + merged.ClampLimits() + warnings = store.StrategyClampWarnings(before, merged, merged.Language) + normalized, err := json.Marshal(merged) if err != nil { return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err) } - configJSON = string(raw) + configJSON = string(normalized) } record := &store.Strategy{ ID: existing.ID, @@ -1139,6 +1776,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { "status": "ok", "action": "update", "strategy": safeStrategyForTool(updated), + "warnings": warnings, }) return string(payload) case "delete": @@ -1229,6 +1867,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { if name == "" { return `{"error":"name is required for duplicate"}` } + if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } newID := fmt.Sprintf("strategy_%d", time.Now().UnixNano()) if err := a.store.Strategy().Duplicate(storeUserID, sourceID, newID, name); err != nil { return fmt.Sprintf(`{"error":"failed to duplicate strategy: %s"}`, err) @@ -1280,8 +1921,27 @@ func (a *Agent) toolListTraders(storeUserID string) string { if err != nil { return fmt.Sprintf(`{"error":"failed to list traders: %s"}`, err) } + if len(traders) == 0 && a != nil && a.store != nil { + if all, listErr := a.store.Trader().ListAll(); listErr == nil && len(all) > 0 { + counts := make(map[string]int) + for _, trader := range all { + uid := strings.TrimSpace(trader.UserID) + if uid == "" { + uid = "default" + } + counts[uid]++ + } + a.log().Warn("toolListTraders returned empty for current store user while traders exist under other user scopes", + "store_user_id", storeUserID, + "known_user_scopes", counts, + ) + } + } safeTraders := make([]safeTraderToolConfig, 0, len(traders)) for _, traderCfg := range traders { + if !store.IsVisibleTrader(traderCfg) { + continue + } isRunning := traderCfg.IsRunning if a.traderManager != nil { if memTrader, err := a.traderManager.GetTrader(traderCfg.ID); err == nil { @@ -1300,32 +1960,13 @@ func (a *Agent) toolListTraders(storeUserID string) string { } func (a *Agent) validateTraderReferences(storeUserID, aiModelID, exchangeID, strategyID string) error { - if strings.TrimSpace(aiModelID) == "" { - return fmt.Errorf("ai_model_id is required") - } - if strings.TrimSpace(exchangeID) == "" { - return fmt.Errorf("exchange_id is required") - } - model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(aiModelID)) - if err != nil { - return fmt.Errorf("invalid ai_model_id: %w", err) - } - if !model.Enabled { - return fmt.Errorf("ai model is disabled") - } - exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID)) - if err != nil { - return fmt.Errorf("invalid exchange_id: %w", err) - } - if !exchange.Enabled { - return fmt.Errorf("exchange is disabled") - } - if trimmed := strings.TrimSpace(strategyID); trimmed != "" { - if _, err := a.store.Strategy().Get(storeUserID, trimmed); err != nil { - return fmt.Errorf("invalid strategy_id: %w", err) - } - } - return nil + return (traderBindingValidator{ + store: a.store, + storeUserID: storeUserID, + aiModelID: aiModelID, + exchangeID: exchangeID, + strategyID: strategyID, + }).Validate() } func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) string { @@ -1333,6 +1974,9 @@ func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) stri if name == "" { return `{"error":"name is required for create"}` } + if err := a.ensureUniqueTraderName(storeUserID, name, ""); err != nil { + return fmt.Sprintf(`{"error":"%s"}`, err) + } if err := a.validateTraderReferences(storeUserID, args.AIModelID, args.ExchangeID, args.StrategyID); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } @@ -1356,29 +2000,11 @@ func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) stri showInCompetition = *args.ShowInCompetition } btcEthLeverage := 10 - if args.BTCETHLeverage != nil && *args.BTCETHLeverage > 0 { - btcEthLeverage = *args.BTCETHLeverage - } altcoinLeverage := 5 - if args.AltcoinLeverage != nil && *args.AltcoinLeverage > 0 { - altcoinLeverage = *args.AltcoinLeverage - } overrideBasePrompt := false - if args.OverrideBasePrompt != nil { - overrideBasePrompt = *args.OverrideBasePrompt - } useAI500 := false - if args.UseAI500 != nil { - useAI500 = *args.UseAI500 - } useOITop := false - if args.UseOITop != nil { - useOITop = *args.UseOITop - } - systemPromptTemplate := strings.TrimSpace(args.SystemPromptTemplate) - if systemPromptTemplate == "" { - systemPromptTemplate = "default" - } + systemPromptTemplate := "default" exchangeIDShort := strings.TrimSpace(args.ExchangeID) if len(exchangeIDShort) > 8 { exchangeIDShort = exchangeIDShort[:8] @@ -1398,10 +2024,10 @@ func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) stri ShowInCompetition: showInCompetition, BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, - TradingSymbols: strings.TrimSpace(args.TradingSymbols), + TradingSymbols: "", UseAI500: useAI500, UseOITop: useOITop, - CustomPrompt: strings.TrimSpace(args.CustomPrompt), + CustomPrompt: "", OverrideBasePrompt: overrideBasePrompt, SystemPromptTemplate: systemPromptTemplate, } @@ -1442,6 +2068,11 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri 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) + } + } aiModelID := existing.AIModelID if trimmed := strings.TrimSpace(args.AIModelID); trimmed != "" { aiModelID = trimmed @@ -1737,7 +2368,15 @@ func (a *Agent) toolSearchStock(argsJSON string) string { return string(result) } -func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string { +func (a *Agent) toolExecuteTrade(ctx context.Context, userID int64, lang, argsJSON string) string { + policy := sessionPolicyFromContext(ctx) + if !policy.Authenticated { + return `{"error": "trade execution requires an authenticated session"}` + } + if !policy.CanExecuteTrade || a == nil || a.config == nil || !a.config.AllowTradeExecution { + return `{"error": "trade execution is blocked by server policy for this session"}` + } + var args struct { Action string `json:"action"` Symbol string `json:"symbol"` @@ -1802,20 +2441,33 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON Status: "pending_confirmation", CreatedAt: time.Now().Unix(), } + if _, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade); err != nil { + return fmt.Sprintf(`{"error": %q}`, err.Error()) + } else if err := validateTradeAction(trade, isStockSymbol(sym), selectedTrader, underlyingTrader); err != nil { + return fmt.Sprintf(`{"error": %q}`, err.Error()) + } a.pending.Add(trade) a.pending.CleanExpired() + confirmMessage := fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID) + if trade.RequiresLargeOrderConfirmation { + confirmMessage = fmt.Sprintf("Trade created but flagged as high-risk. User must confirm with: 确认大额 %s (or: confirm large %s)", trade.ID, trade.ID) + } + // Return confirmation info to LLM so it can present it to the user resultMap := map[string]any{ - "status": "pending_confirmation", - "trade_id": trade.ID, - "action": trade.Action, - "symbol": trade.Symbol, - "quantity": trade.Quantity, - "leverage": trade.Leverage, - "message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID), - "expires": "5 minutes", + "status": "pending_confirmation", + "trade_id": trade.ID, + "action": trade.Action, + "symbol": trade.Symbol, + "quantity": trade.Quantity, + "leverage": trade.Leverage, + "estimated_price": trade.EstimatedPrice, + "estimated_notional": trade.EstimatedNotional, + "requires_large_order_confirmation": trade.RequiresLargeOrderConfirmation, + "message": confirmMessage, + "expires": "5 minutes", } if marketWarning != "" { resultMap["market_warning"] = marketWarning @@ -1953,6 +2605,97 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym) } +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": + return true + default: + return false + } +} + +func (a *Agent) toolGetKline(argsJSON string) string { + var args struct { + Symbol string `json:"symbol"` + Interval string `json:"interval"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err) + } + + symbol := strings.ToUpper(strings.TrimSpace(args.Symbol)) + if symbol == "" { + return `{"error": "symbol is required"}` + } + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + + interval := strings.TrimSpace(strings.ToLower(args.Interval)) + if interval == "" { + interval = "15m" + } + if !validKlineInterval(interval) { + return fmt.Sprintf(`{"error":"invalid interval %q"}`, interval) + } + + limit := args.Limit + switch { + case limit <= 0: + limit = 50 + case limit > 300: + limit = 300 + } + + url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=%d", symbol, interval, limit) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Sprintf(`{"error":"failed to create request: %s"}`, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Sprintf(`{"error":"failed to fetch kline for %s: %s"}`, symbol, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf(`{"error":"kline source returned status %d for %s"}`, resp.StatusCode, symbol) + } + + var raw [][]any + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return fmt.Sprintf(`{"error":"failed to parse kline response: %s"}`, err) + } + + candles := make([]map[string]any, 0, len(raw)) + for _, row := range raw { + if len(row) < 7 { + continue + } + candles = append(candles, map[string]any{ + "open_time": row[0], + "open": row[1], + "high": row[2], + "low": row[3], + "close": row[4], + "volume": row[5], + "close_time": row[6], + }) + } + + out, _ := json.Marshal(map[string]any{ + "symbol": symbol, + "interval": interval, + "limit": limit, + "klines": candles, + }) + return string(out) +} + func (a *Agent) toolGetTradeHistory(argsJSON string) string { if a.store == nil { return `{"error": "store not available"}` @@ -2187,6 +2930,94 @@ func candidateCoinDetails(coins []kernel.CandidateCoin) []map[string]any { return out } +func normalizeWatchSymbol(raw string) string { + symbol := strings.ToUpper(strings.TrimSpace(raw)) + symbol = strings.ReplaceAll(symbol, " ", "") + if symbol == "" { + return "" + } + hasQuoteSuffix := strings.HasSuffix(symbol, "USDT") || strings.HasSuffix(symbol, "BUSD") || strings.HasSuffix(symbol, "USDC") + if !hasQuoteSuffix && isStockSymbol(symbol) == false { + return symbol + "USDT" + } + return symbol +} + +func (a *Agent) toolGetWatchlist(lang string) string { + if a.sentinel == nil { + return fmt.Sprintf(`{"error":"%s"}`, a.msg(lang, "sentinel_off")) + } + symbols := a.sentinel.Symbols() + payload := map[string]any{ + "enabled": true, + "count": len(symbols), + "symbols": symbols, + "text": a.sentinel.FormatWatchlist(lang), + } + raw, _ := json.Marshal(payload) + return string(raw) +} + +func (a *Agent) toolManageWatchlist(lang, argsJSON string) string { + if a.sentinel == nil { + return fmt.Sprintf(`{"error":"%s"}`, a.msg(lang, "sentinel_off")) + } + + var args struct { + Action string `json:"action"` + Symbol string `json:"symbol"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) + } + + action := strings.ToLower(strings.TrimSpace(args.Action)) + symbol := normalizeWatchSymbol(args.Symbol) + if symbol == "" { + return `{"error":"symbol is required"}` + } + + switch action { + case "add": + a.sentinel.AddSymbol(symbol) + case "remove": + a.sentinel.RemoveSymbol(symbol) + default: + return `{"error":"unsupported action"}` + } + + symbols := a.sentinel.Symbols() + if a.config != nil { + a.config.WatchSymbols = symbols + } + + message := "" + if lang == "zh" { + if action == "add" { + message = fmt.Sprintf("已把 %s 加入监控。", symbol) + } else { + message = fmt.Sprintf("已把 %s 移出监控。", symbol) + } + } else { + if action == "add" { + message = fmt.Sprintf("Added %s to the watchlist.", symbol) + } else { + message = fmt.Sprintf("Removed %s from the watchlist.", symbol) + } + } + + payload := map[string]any{ + "ok": true, + "action": action, + "symbol": symbol, + "count": len(symbols), + "symbols": symbols, + "message": message, + } + raw, _ := json.Marshal(payload) + return string(raw) +} + // knownCryptoSymbols is a set of well-known cryptocurrency base symbols. // Without this, isStockSymbol("BTC") would incorrectly return true because // "BTC" is 3 uppercase letters and the suffix check only catches "BTCUSDT"-style pairs. diff --git a/agent/tools_test.go b/agent/tools_test.go deleted file mode 100644 index d7ea4918..00000000 --- a/agent/tools_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package agent - -import "testing" - -func TestIsStockSymbol(t *testing.T) { - tests := []struct { - sym string - want bool - }{ - // Known crypto base symbols — must NOT be detected as stock - {"BTC", false}, - {"ETH", false}, - {"SOL", false}, - {"BNB", false}, - {"XRP", false}, - {"DOGE", false}, - {"ADA", false}, - {"AVAX", false}, - {"DOT", false}, - {"LINK", false}, - {"PEPE", false}, - {"SHIB", false}, - {"TRUMP", false}, - {"USDT", false}, - {"USDC", false}, - {"W", false}, // single letter crypto - - // Crypto pairs — must NOT be stock - {"BTCUSDT", false}, - {"ETHUSDT", false}, - {"SOLUSDT", false}, - {"DOGEUSDT", false}, - - // Real stock tickers — must be detected as stock - {"AAPL", true}, - {"TSLA", true}, - {"NVDA", true}, - {"MSFT", true}, - {"GOOGL", true}, - {"AMZN", true}, - {"META", true}, - {"AMD", true}, - {"PLTR", true}, - {"BA", true}, - {"F", true}, // Ford — 1 letter - {"GM", true}, // 2 letters - {"JPM", true}, // 3 letters - - // Mixed / edge cases - {"btc", false}, // lowercase crypto - {"aapl", true}, // lowercase stock (uppercased internally) - {"BTC123", false}, // not pure letters - {"123456", false}, // digits - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.sym, func(t *testing.T) { - got := isStockSymbol(tt.sym) - if got != tt.want { - t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want) - } - }) - } -} diff --git a/agent/trade.go b/agent/trade.go index 6a987a7d..6c48c0ce 100644 --- a/agent/trade.go +++ b/agent/trade.go @@ -5,22 +5,50 @@ import ( "encoding/json" "fmt" "log/slog" + "math" + "nofx/store" "strings" "sync" "time" ) +const ( + tradeAbsoluteMaxQuantity = 1_000_000.0 + tradeLargeOrderNotionalUSDT = 5_000.0 + tradeHardMaxOrderNotionalUSDT = 100_000.0 + tradeLargeOrderEquityRatio = 0.25 + tradeHardMaxOrderEquityRatio = 1.00 + tradeLargeOrderConfirmCommandZH = "确认大额 %s" + tradeLargeOrderConfirmCommandEN = "confirm large %s" +) + +type tradeSelectedTrader interface { + GetStrategyConfig() *store.StrategyConfig + GetAccountInfo() (map[string]interface{}, error) +} + +type tradeUnderlyingTrader interface { + OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + CloseLong(symbol string, quantity float64) (map[string]interface{}, error) + CloseShort(symbol string, quantity float64) (map[string]interface{}, error) + GetMarketPrice(symbol string) (float64, error) +} + // TradeAction represents a parsed trade intent from the LLM or user. type TradeAction struct { - ID string `json:"id"` - Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short" - Symbol string `json:"symbol"` // e.g. "BTCUSDT" - Quantity float64 `json:"quantity"` // amount - Leverage int `json:"leverage"` // leverage multiplier - TraderID string `json:"trader_id"` // which trader to use - Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired" - CreatedAt int64 `json:"created_at"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short" + Symbol string `json:"symbol"` // e.g. "BTCUSDT" + Quantity float64 `json:"quantity"` // amount + Leverage int `json:"leverage"` // leverage multiplier + TraderID string `json:"trader_id"` // which trader to use + Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired" + CreatedAt int64 `json:"created_at"` + EstimatedPrice float64 `json:"estimated_price,omitempty"` + EstimatedNotional float64 `json:"estimated_notional,omitempty"` + RequiresLargeOrderConfirmation bool `json:"requires_large_order_confirmation,omitempty"` + Error string `json:"error,omitempty"` } // pendingTrades stores pending trade confirmations. @@ -149,49 +177,12 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error { return fmt.Errorf("no trader manager available") } - traders := a.traderManager.GetAllTraders() - if len(traders) == 0 { - return fmt.Errorf("no traders configured") + wantStock, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade) + if err != nil { + return err } - - // Determine if this is a stock trade to route to the right exchange - wantStock := isStockSymbol(trade.Symbol) - - // Find a running trader's underlying exchange interface - var underlyingTrader interface { - OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) - OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) - CloseLong(symbol string, quantity float64) (map[string]interface{}, error) - CloseShort(symbol string, quantity float64) (map[string]interface{}, error) - } - - for _, t := range traders { - s := t.GetStatus() - running, _ := s["is_running"].(bool) - if running { - ut := t.GetUnderlyingTrader() - if ut == nil { - continue - } - // Route stock symbols to alpaca traders, crypto to others - exchange := t.GetExchange() - isAlpaca := exchange == "alpaca" - if wantStock && !isAlpaca { - continue // Skip non-stock traders for stock symbols - } - if !wantStock && isAlpaca { - continue // Skip stock traders for crypto symbols - } - underlyingTrader = ut - break - } - } - - if underlyingTrader == nil { - if wantStock { - return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks") - } - return fmt.Errorf("no running trader supports trade execution") + if err := validateTradeAction(trade, wantStock, selectedTrader, underlyingTrader); err != nil { + return err } switch trade.Action { @@ -218,6 +209,172 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error { } } +func (a *Agent) resolveTradeExecutionContext(trade *TradeAction) (bool, tradeSelectedTrader, tradeUnderlyingTrader, error) { + if a.traderManager == nil { + return false, nil, nil, fmt.Errorf("no trader manager available") + } + traders := a.traderManager.GetAllTraders() + if len(traders) == 0 { + return false, nil, nil, fmt.Errorf("no traders configured") + } + + wantStock := isStockSymbol(trade.Symbol) + for _, t := range traders { + s := t.GetStatus() + running, _ := s["is_running"].(bool) + if !running { + continue + } + ut := t.GetUnderlyingTrader() + if ut == nil { + continue + } + exchange := t.GetExchange() + isAlpaca := exchange == "alpaca" + if wantStock && !isAlpaca { + continue + } + if !wantStock && isAlpaca { + continue + } + return wantStock, t, ut, nil + } + + if wantStock { + return true, nil, nil, fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks") + } + return false, nil, nil, fmt.Errorf("no running trader supports trade execution") +} + +func validateTradeAction( + trade *TradeAction, + wantStock bool, + selectedTrader tradeSelectedTrader, + underlyingTrader tradeUnderlyingTrader, +) error { + if trade == nil { + return fmt.Errorf("trade is required") + } + if math.IsNaN(trade.Quantity) || math.IsInf(trade.Quantity, 0) { + return fmt.Errorf("quantity must be a finite number") + } + if !strings.HasPrefix(trade.Action, "open_") { + return nil + } + if trade.Quantity <= 0 { + return fmt.Errorf("quantity must be > 0") + } + if trade.Quantity > tradeAbsoluteMaxQuantity { + return fmt.Errorf("quantity %.4f exceeds hard sanity cap %.0f", trade.Quantity, tradeAbsoluteMaxQuantity) + } + + price, err := underlyingTrader.GetMarketPrice(trade.Symbol) + if err != nil { + return fmt.Errorf("failed to fetch market price for %s: %w", trade.Symbol, err) + } + if price <= 0 { + return fmt.Errorf("invalid market price for %s", trade.Symbol) + } + positionValue := trade.Quantity * price + trade.EstimatedPrice = price + trade.EstimatedNotional = positionValue + + if positionValue > tradeHardMaxOrderNotionalUSDT { + return fmt.Errorf("position value %.2f exceeds hard safety cap %.2f USDT", positionValue, tradeHardMaxOrderNotionalUSDT) + } + + var equity float64 + if selectedTrader != nil { + accountInfo, err := selectedTrader.GetAccountInfo() + if err != nil { + return fmt.Errorf("failed to load trader account info: %w", err) + } + equity = toFloat(accountInfo["total_equity"]) + if equity <= 0 { + equity = toFloat(accountInfo["totalEquity"]) + } + if equity <= 0 { + return fmt.Errorf("invalid trader equity for risk validation") + } + if positionValue > equity*tradeHardMaxOrderEquityRatio { + return fmt.Errorf( + "position value %.2f USDT exceeds hard safety cap %.2f USDT (equity %.2f x %.2f)", + positionValue, + equity*tradeHardMaxOrderEquityRatio, + equity, + tradeHardMaxOrderEquityRatio, + ) + } + if positionValue >= equity*tradeLargeOrderEquityRatio { + trade.RequiresLargeOrderConfirmation = true + } + } + if positionValue >= tradeLargeOrderNotionalUSDT { + trade.RequiresLargeOrderConfirmation = true + } + + if wantStock { + if trade.Leverage < 0 { + return fmt.Errorf("leverage must be >= 0") + } + return nil + } + + cfg := store.GetDefaultStrategyConfig("zh") + if selectedTrader != nil && selectedTrader.GetStrategyConfig() != nil { + cfg = *selectedTrader.GetStrategyConfig() + } + riskControl := cfg.RiskControl + + maxLeverage := riskControl.AltcoinMaxLeverage + maxPositionValueRatio := riskControl.AltcoinMaxPositionValueRatio + if isBTCETHSymbol(trade.Symbol) { + maxLeverage = riskControl.BTCETHMaxLeverage + maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio + } + if maxLeverage <= 0 { + maxLeverage = 5 + } + if trade.Leverage <= 0 { + return fmt.Errorf("leverage must be > 0") + } + if trade.Leverage > maxLeverage { + return fmt.Errorf("leverage exceeds configured limit (%dx > %dx)", trade.Leverage, maxLeverage) + } + + minPositionSize := riskControl.MinPositionSize + if minPositionSize <= 0 { + minPositionSize = 12 + } + if positionValue < minPositionSize { + return fmt.Errorf("position value %.2f USDT is below configured minimum %.2f USDT", positionValue, minPositionSize) + } + + if maxPositionValueRatio <= 0 { + if isBTCETHSymbol(trade.Symbol) { + maxPositionValueRatio = 5.0 + } else { + maxPositionValueRatio = 1.0 + } + } + maxPositionValue := equity * maxPositionValueRatio + if positionValue > maxPositionValue { + return fmt.Errorf( + "position value %.2f USDT exceeds configured limit %.2f USDT (equity %.2f x %.2f)", + positionValue, + maxPositionValue, + equity, + maxPositionValueRatio, + ) + } + return nil +} + +func isBTCETHSymbol(symbol string) bool { + symbol = strings.ToUpper(strings.TrimSpace(symbol)) + return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH") +} + // formatTradeConfirmation creates a confirmation message for a pending trade. func formatTradeConfirmation(trade *TradeAction, lang string) string { actionNames := map[string]string{ @@ -246,6 +403,13 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string { if trade.Leverage > 0 { msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage) } + if trade.EstimatedNotional > 0 { + msg += fmt.Sprintf("估算仓位价值: %.2f USDT\n", trade.EstimatedNotional) + } + if trade.RequiresLargeOrderConfirmation { + msg += fmt.Sprintf("\n⚠️ 该订单已触发大额风控,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 执行交易,或忽略取消。", trade.ID) + return msg + } msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID) return msg } @@ -259,6 +423,13 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string { if trade.Leverage > 0 { msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage) } + if trade.EstimatedNotional > 0 { + msg += fmt.Sprintf("Estimated notional: %.2f USDT\n", trade.EstimatedNotional) + } + if trade.RequiresLargeOrderConfirmation { + msg += fmt.Sprintf("\n⚠️ This order triggered high-risk protection. Send `"+tradeLargeOrderConfirmCommandEN+"` to execute, or ignore to cancel.", trade.ID) + return msg + } msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID) return msg } @@ -268,7 +439,14 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, upper := strings.ToUpper(strings.TrimSpace(text)) var tradeID string - if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") { + largeConfirm := false + if strings.HasPrefix(upper, "确认大额 ") || strings.HasPrefix(upper, "CONFIRM LARGE ") { + largeConfirm = true + parts := strings.Fields(text) + if len(parts) >= 2 { + tradeID = parts[len(parts)-1] + } + } else if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") { parts := strings.Fields(text) if len(parts) >= 2 { tradeID = parts[1] @@ -290,6 +468,12 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, } return "❌ Trade expired or not found.", true } + if trade.RequiresLargeOrderConfirmation && !largeConfirm { + if lang == "zh" { + return fmt.Sprintf("⚠️ 这是一笔大额订单,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 继续执行。", trade.ID), true + } + return fmt.Sprintf("⚠️ This is a high-risk order. Send `"+tradeLargeOrderConfirmCommandEN+"` to continue.", trade.ID), true + } a.pending.Remove(tradeID) trade.Status = "confirmed" diff --git a/agent/web.go b/agent/web.go index 12865d84..ce290f65 100644 --- a/agent/web.go +++ b/agent/web.go @@ -13,6 +13,14 @@ import ( ) type storeUserIDContextKey struct{} +type sessionPolicyContextKey struct{} + +type SessionPolicy struct { + Authenticated bool + IsAdmin bool + CanExecuteTrade bool + CanViewSensitiveSecrets bool +} // WithStoreUserID annotates an HTTP request context with the authenticated store user ID. func WithStoreUserID(ctx context.Context, storeUserID string) context.Context { @@ -26,6 +34,17 @@ func storeUserIDFromContext(ctx context.Context) string { return "default" } +func WithSessionPolicy(ctx context.Context, policy SessionPolicy) context.Context { + return context.WithValue(ctx, sessionPolicyContextKey{}, policy) +} + +func sessionPolicyFromContext(ctx context.Context) SessionPolicy { + if v, ok := ctx.Value(sessionPolicyContextKey{}).(SessionPolicy); ok { + return v + } + return SessionPolicy{} +} + // validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD). var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`) @@ -80,7 +99,7 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) { return } if req.UserID == 0 { - req.UserID = SessionUserIDFromKey(req.UserKey) + req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context())) } msg := req.Message if req.Lang != "" { @@ -93,7 +112,7 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) { resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg) if err != nil { w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID) - writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."}) + writeJSON(rw, 500, map[string]string{"error": "I ran into a problem while handling that message. Please try again."}) return } writeJSON(rw, 200, map[string]string{"response": resp}) @@ -122,7 +141,7 @@ func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) { return } if req.UserID == 0 { - req.UserID = SessionUserIDFromKey(req.UserKey) + req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context())) } msg := req.Message if req.Lang != "" { @@ -150,7 +169,7 @@ func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) { }) if err != nil { w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID) - writeSSE(rw, flusher, "error", "Failed to process message. Please try again.") + writeSSE(rw, flusher, "error", "I ran into a problem while handling that message. Please try again.") return } // Send final done event with complete response diff --git a/agent/workflow.go b/agent/workflow.go index fa704c3f..35506c54 100644 --- a/agent/workflow.go +++ b/agent/workflow.go @@ -161,10 +161,14 @@ func supportedWorkflowSkill(skill, action string) bool { if _, ok := getSkillDAG(skill, action); ok { return true } + if def, ok := getSkillDefinition(skill); ok { + if _, ok := def.Actions[action]; ok { + return true + } + } switch skill { case "trader_management", "strategy_management", "model_management", "exchange_management": - switch action { - case "create", "query_list", "query_detail", "query_running", "activate": + if action == "query_running" { return true } } @@ -189,21 +193,54 @@ func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userI return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent) } +func (a *Agent) executeWorkflowDecomposition(ctx context.Context, storeUserID string, userID int64, lang, text string, decomposition workflowDecomposition, onEvent func(event, data string)) (string, bool, error) { + if len(decomposition.Tasks) <= 1 { + return "", false, nil + } + session := WorkflowSession{ + UserID: userID, + OriginalRequest: text, + Tasks: decomposition.Tasks, + } + a.saveWorkflowSession(userID, session) + return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent) +} + func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) { if isExplicitFlowAbort(text) { a.clearSkillSession(userID) a.clearWorkflowSession(userID) - if lang == "zh" { - return "已取消当前任务流。", true, nil - } - return "Cancelled the current workflow.", true, nil + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil } if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" { - answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent) + decision, extraction := a.resolveSkillSessionTurn(ctx, userID, lang, text, activeSkill) + switch decision.Intent { + case "cancel": + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + case "instant_reply": + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + case "resume_snapshot", "start_new": + if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" { + answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + return answer, handled, err + } + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + return "", false, nil + case "continue_active": + if extraction.Intent == "continue" { + a.applyLLMExtractionToSkillSession(storeUserID, &activeSkill, extraction, lang, text) + a.saveSkillSession(userID, activeSkill) + } + } + answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, text, activeSkill.Name, activeSkill.Action, onEvent) if !handled { return "", false, nil } + a.recordSkillInteraction(userID, text, answer) session = a.getWorkflowSession(userID) if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" { session = markCurrentWorkflowTask(session, workflowTaskCompleted, "") @@ -221,9 +258,78 @@ func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, u return answer, true, nil } + if decision := a.classifyWorkflowSessionInput(ctx, userID, lang, session, text); decision.Intent != "" && decision.Intent != "continue_active" { + switch decision.Intent { + case "cancel": + a.clearWorkflowSession(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + case "instant_reply": + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + case "resume_snapshot", "start_new": + if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" { + answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) + return answer, handled, err + } + a.clearWorkflowSession(userID) + return "", false, nil + } + } + return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent) } +func (a *Agent) classifyWorkflowSessionInput(ctx context.Context, userID int64, lang string, session WorkflowSession, text string) unifiedFlowDecision { + text = strings.TrimSpace(text) + if text == "" { + return unifiedFlowDecision{Intent: "continue_active"} + } + if isExplicitFlowAbort(text) { + return unifiedFlowDecision{Intent: "cancel"} + } + if isInstantDirectReplyText(text) { + return unifiedFlowDecision{Intent: "instant_reply"} + } + if a == nil || a.aiClient == nil { + if looksLikeNewTopLevelIntent(text) && !strings.EqualFold(text, strings.TrimSpace(session.OriginalRequest)) { + return unifiedFlowDecision{Intent: "start_new"} + } + return unifiedFlowDecision{Intent: "continue_active"} + } + currentTask, _, _ := nextRunnableWorkflowTask(session) + recentConversationCtx := a.buildRecentConversationContext(userID, text) + flowContext := fmt.Sprintf( + "Workflow original request: %s\nCurrent runnable task: %s / %s / %s\nWorkflow tasks JSON: %s", + session.OriginalRequest, + currentTask.Skill, + currentTask.Action, + currentTask.Request, + mustMarshalJSON(session.Tasks), + ) + state := a.getExecutionState(userID) + systemPrompt, userPrompt := buildActiveFlowClassifierPrompt( + lang, + "workflow_session", + flowContext, + text, + recentConversationCtx, + state.CurrentReferences, + a.SnapshotManager(userID).List(), + ) + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return unifiedFlowDecision{} + } + return unifiedFlowDecisionFromIntent(parseActiveFlowIntentDecision(raw), "") +} + func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) { task, index, ok := nextRunnableWorkflowTask(session) if !ok { @@ -238,7 +344,7 @@ func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, us } if onEvent != nil { onEvent(StreamEventPlan, summary) - onEvent(StreamEventDelta, summary) + emitStreamText(onEvent, summary) } return summary, true, nil } @@ -253,13 +359,14 @@ func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, us onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action) } - answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent) + answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, task.Request, task.Skill, task.Action, onEvent) if !handled { session.Tasks[index].Status = workflowTaskFailed session.Tasks[index].Error = "task_not_handled" a.saveWorkflowSession(userID, session) return "", false, nil } + a.recordSkillInteraction(userID, task.Request, answer) if strings.TrimSpace(a.getSkillSession(userID).Name) == "" { session = a.getWorkflowSession(userID) @@ -374,24 +481,88 @@ func looksLikeMultiTaskIntent(text string) bool { count++ } } - return count > 0 + if count > 0 { + return true + } + if looksLikeCompoundStrategyIntent(text) || looksLikeCompoundTraderIntent(text) || + looksLikeCompoundModelIntent(text) || looksLikeCompoundExchangeIntent(text) { + return true + } + return false +} + +func looksLikeCompoundStrategyIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if !hasExplicitManagementDomainCue(text, "strategy") { + return false + } + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"}) + hasConfigUpdate := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"}) + hasLifecycle := containsAny(lower, []string{"激活", "activate", "复制", "duplicate", "删除", "删了", "删掉", "delete"}) + hasMetaUpdate := containsAny(lower, []string{"发布", "公开", "可见", "描述", "改成", "改为"}) + return (hasCreate && (hasConfigUpdate || hasLifecycle || hasMetaUpdate)) || + (hasConfigUpdate && hasLifecycle) +} + +func looksLikeCompoundTraderIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) { + return false + } + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasLifecycle := containsAny(lower, []string{"启动", "开始", "start", "停止", "stop"}) + return (hasCreate && (hasBindingsOrConfig || hasLifecycle)) || + (hasBindingsOrConfig && hasLifecycle) +} + +func looksLikeCompoundModelIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if !hasExplicitManagementDomainCue(text, "model") { + return false + } + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "启用", "禁用", "api key"}) + hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"}) + return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle) +} + +func looksLikeCompoundExchangeIntent(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if !hasExplicitManagementDomainCue(text, "exchange") { + return false + } + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包", "启用", "禁用"}) + hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"}) + return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle) } func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) { stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) defer cancel() - systemPrompt := `You decompose one NOFXi user request into a small task graph. + systemPrompt := `You decompose one NOFXi user request into a small task graph for execution. Return JSON only. No markdown. Only use these skills: trader_management, strategy_management, model_management, exchange_management. Only use one atomic action per task. +You are the action decomposition layer. Split complex requests into atomic management steps and decide dependencies. Each task must include: - id - skill - action - request - depends_on (array, may be empty) -If the request is effectively a single task, return one task only.` +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. +- 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. +- Do not merge two actions into one task. +- If the request is effectively a single task, return one task only.` userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text) + if skillContext := buildManagementSkillRoutingContext(lang); skillContext != "" { + userPrompt += "\n\n" + skillContext + } raw, err := a.aiClient.CallWithRequest(&mcp.Request{ Messages: []mcp.Message{ mcp.NewSystemMessage(systemPrompt), @@ -451,21 +622,269 @@ func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposi func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition { segments := splitWorkflowSegments(text) tasks := make([]WorkflowTask, 0, len(segments)) - for i, segment := range segments { - task, ok := classifyWorkflowTask(segment) - if !ok { - continue - } - task.ID = fmt.Sprintf("task_%d", i+1) - task.Status = workflowTaskPending + nextID := 1 + for _, segment := range segments { + prevSkill := "" if len(tasks) > 0 { - task.DependsOn = []string{tasks[len(tasks)-1].ID} + prevSkill = tasks[len(tasks)-1].Skill + } + compound := classifyCompoundWorkflowTasksWithContext(segment, prevSkill) + if len(compound) == 0 { + task, ok := classifyWorkflowTaskWithContext(segment, prevSkill) + if !ok { + continue + } + compound = []WorkflowTask{task} + } + for i := range compound { + compound[i].ID = fmt.Sprintf("task_%d", nextID) + compound[i].Status = workflowTaskPending + if len(tasks) > 0 && len(compound[i].DependsOn) == 0 { + compound[i].DependsOn = []string{tasks[len(tasks)-1].ID} + } + if i > 0 { + compound[i].DependsOn = []string{compound[i-1].ID} + } + tasks = append(tasks, compound[i]) + nextID++ } - tasks = append(tasks, task) } return workflowDecomposition{Tasks: tasks} } +func classifyCompoundWorkflowTasksWithContext(text, previousSkill string) []WorkflowTask { + if tasks := classifyCompoundWorkflowTasks(text); len(tasks) > 1 { + return tasks + } + switch strings.TrimSpace(previousSkill) { + case "strategy_management": + return classifyContextualStrategyWorkflowTasks(text) + case "trader_management": + return classifyContextualTraderWorkflowTasks(text) + } + return nil +} + +func classifyCompoundWorkflowTasks(text string) []WorkflowTask { + segment := strings.TrimSpace(text) + if segment == "" { + return nil + } + + if tasks := classifyCompoundStrategyWorkflowTasks(segment); len(tasks) > 1 { + return tasks + } + if tasks := classifyCompoundTraderWorkflowTasks(segment); len(tasks) > 1 { + return tasks + } + if tasks := classifyCompoundModelWorkflowTasks(segment); len(tasks) > 1 { + return tasks + } + if tasks := classifyCompoundExchangeWorkflowTasks(segment); len(tasks) > 1 { + return tasks + } + return nil +} + +func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask { + lower := strings.ToLower(strings.TrimSpace(text)) + hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"}) + hasActivate := containsAny(lower, []string{"激活", "activate"}) + hasDuplicate := containsAny(lower, []string{"复制", "duplicate"}) + if !hasConfig && !hasActivate && !hasDuplicate { + return nil + } + var tasks []WorkflowTask + if hasConfig { + action := "update_config" + if containsAny(lower, []string{"prompt", "提示词"}) { + action = "update_prompt" + } + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text}) + } + if hasActivate { + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text}) + } + if hasDuplicate { + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text}) + } + if len(tasks) == 0 { + return nil + } + return tasks +} + +func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask { + lower := strings.ToLower(strings.TrimSpace(text)) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) + hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) + if !hasUpdate && !hasStart && !hasStop { + return nil + } + var tasks []WorkflowTask + if hasUpdate { + action := "update_bindings" + if containsAny(lower, []string{"扫描间隔", "杠杆", "提示词", "修改", "更新"}) && + !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { + action = "update" + } + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) + } + if hasStart { + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) + } + if hasStop { + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text}) + } + if len(tasks) == 0 { + return nil + } + return tasks +} + +func classifyWorkflowTaskWithContext(text, previousSkill string) (WorkflowTask, bool) { + if task, ok := classifyWorkflowTask(text); ok { + return task, true + } + switch strings.TrimSpace(previousSkill) { + case "strategy_management": + if tasks := classifyContextualStrategyWorkflowTasks(text); len(tasks) > 0 { + return tasks[0], true + } + case "trader_management": + if tasks := classifyContextualTraderWorkflowTasks(text); len(tasks) > 0 { + return tasks[0], true + } + } + return WorkflowTask{}, false +} + +func classifyCompoundStrategyWorkflowTasks(text string) []WorkflowTask { + if !hasExplicitManagementDomainCue(text, "strategy") { + return nil + } + lower := strings.ToLower(strings.TrimSpace(text)) + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"}) + hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"}) + hasActivate := containsAny(lower, []string{"激活", "activate"}) + hasDuplicate := containsAny(lower, []string{"复制", "duplicate"}) + + if !hasCreate && !hasConfig && !hasActivate && !hasDuplicate { + return nil + } + + var tasks []WorkflowTask + if hasCreate { + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "create", Request: text}) + } + if hasConfig { + action := "update_config" + if containsAny(lower, []string{"prompt", "提示词"}) { + action = "update_prompt" + } + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text}) + } + if hasActivate { + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text}) + } + if hasDuplicate { + tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text}) + } + if len(tasks) <= 1 { + return nil + } + return tasks +} + +func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask { + if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) { + return nil + } + lower := strings.ToLower(strings.TrimSpace(text)) + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) + hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) + + var tasks []WorkflowTask + if hasCreate { + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "create", Request: text}) + } + if hasUpdate { + action := "update_bindings" + if containsAny(lower, []string{"扫描间隔", "杠杆", "提示词", "修改", "更新"}) && + !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { + action = "update" + } + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) + } + if hasStart { + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) + } + if hasStop { + tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text}) + } + if len(tasks) <= 1 { + return nil + } + return tasks +} + +func classifyCompoundModelWorkflowTasks(text string) []WorkflowTask { + if !hasExplicitManagementDomainCue(text, "model") { + return nil + } + lower := strings.ToLower(strings.TrimSpace(text)) + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "api key"}) + hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"}) + + var tasks []WorkflowTask + if hasCreate { + tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "create", Request: text}) + } + 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 { + tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "update_status", Request: text}) + } + if len(tasks) <= 1 { + return nil + } + return tasks +} + +func classifyCompoundExchangeWorkflowTasks(text string) []WorkflowTask { + if !hasExplicitManagementDomainCue(text, "exchange") { + return nil + } + lower := strings.ToLower(strings.TrimSpace(text)) + hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) + hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"}) + hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"}) + + var tasks []WorkflowTask + if hasCreate { + tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "create", Request: text}) + } + if hasConfig { + tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_name", Request: text}) + } + if hasStatus { + tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_status", Request: text}) + } + if len(tasks) <= 1 { + return nil + } + return tasks +} + func splitWorkflowSegments(text string) []string { parts := []string{strings.TrimSpace(text)} separators := []string{",", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "} @@ -490,27 +909,94 @@ func classifyWorkflowTask(text string) (WorkflowTask, bool) { if segment == "" { return WorkflowTask{}, false } + lower := strings.ToLower(segment) switch { - case detectCreateTraderSkill(segment): + case hasExplicitCreateIntentForDomain(segment, "trader"): return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true - case detectTraderManagementIntent(segment): - action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader")) + case hasExplicitManagementDomainCue(segment, "trader"): + action := "" + switch { + case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}): + action = "create" + case containsAny(lower, []string{"启动", "开始", "run", "start"}): + action = "start" + case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}): + action = "stop" + case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}): + action = "delete" + case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}): + action = "update_bindings" + case containsAny(lower, []string{"修改", "更新", "改", "扫描间隔", "杠杆", "提示词"}): + action = "update" + case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}): + action = "query_detail" + case containsAny(lower, []string{"列表", "全部", "哪些", "list"}): + action = "query_list" + } if supportedWorkflowSkill("trader_management", action) { return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true } - case detectExchangeManagementIntent(segment): - action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange")) + case hasExplicitManagementDomainCue(segment, "exchange"): + action := "" + switch { + case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}): + action = "create" + case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}): + action = "update_status" + case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}): + action = "delete" + case containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"}): + action = "update" + case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}): + action = "query_detail" + case containsAny(lower, []string{"列表", "全部", "哪些", "list"}): + action = "query_list" + } if supportedWorkflowSkill("exchange_management", action) { return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true } - case detectModelManagementIntent(segment): - action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model")) + case hasExplicitManagementDomainCue(segment, "model"): + action := "" + switch { + case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}): + action = "create" + case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}): + action = "update_status" + case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}): + action = "delete" + case containsAny(lower, []string{"接口地址", "endpoint", "url"}): + action = "update_endpoint" + case containsAny(lower, []string{"修改", "更新", "改", "模型名", "api key"}): + action = "update" + case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}): + action = "query_detail" + case containsAny(lower, []string{"列表", "全部", "哪些", "list"}): + action = "query_list" + } if supportedWorkflowSkill("model_management", action) { return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true } - case detectStrategyManagementIntent(segment): - action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy")) - if action == "" && wantsStrategyDetails(segment) { + case hasExplicitManagementDomainCue(segment, "strategy"): + action := "" + switch { + case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}): + action = "create" + case containsAny(lower, []string{"激活", "activate"}): + action = "activate" + case containsAny(lower, []string{"复制", "duplicate"}): + action = "duplicate" + case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}): + action = "delete" + case containsAny(lower, []string{"prompt", "提示词"}): + action = "update_prompt" + case containsAny(lower, []string{"修改", "更新", "改", "参数", "配置"}): + action = "update_config" + case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}) || hasExplicitStrategyDetailIntent(segment): + action = "query_detail" + case containsAny(lower, []string{"列表", "全部", "哪些", "list"}): + action = "query_list" + } + if action == "" && hasExplicitStrategyDetailIntent(segment) { action = "query_detail" } if supportedWorkflowSkill("strategy_management", action) { diff --git a/agent/workflow_test.go b/agent/workflow_test.go deleted file mode 100644 index bffed9bb..00000000 --- a/agent/workflow_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package agent - -import "testing" - -func TestSplitWorkflowSegments(t *testing.T) { - got := splitWorkflowSegments("把策略删了,再把交易所改名") - if len(got) != 2 { - t.Fatalf("expected 2 segments, got %d: %#v", len(got), got) - } -} - -func TestClassifyWorkflowTask(t *testing.T) { - task, ok := classifyWorkflowTask("把策略删了") - if !ok { - t.Fatal("expected task") - } - if task.Skill != "strategy_management" || task.Action != "delete" { - t.Fatalf("unexpected task: %+v", task) - } -} - -func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) { - a := &Agent{} - out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名") - if len(out.Tasks) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(out.Tasks)) - } - if out.Tasks[0].Skill != "strategy_management" { - t.Fatalf("unexpected first task: %+v", out.Tasks[0]) - } - if out.Tasks[1].Skill != "exchange_management" { - t.Fatalf("unexpected second task: %+v", out.Tasks[1]) - } - if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID { - t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn) - } -} diff --git a/agents.md b/agents.md index 684def9f..f0096928 100644 --- a/agents.md +++ b/agents.md @@ -1,922 +1,159 @@ -# NOFXi 交易智能助手规范 +# NOFXi Agent 实现状态文档 -## 使命 +> 更新时间:2026-04-23 -NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。 +--- -它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情: +## 一、架构概述 -- 创建、启动、查询、编辑、删除 agent -- 管理交易所配置 -- 管理策略 -- 管理大模型配置 -- 排查配置问题与运行问题 -- 回答交易相关问题,并提供可执行的建议 +ReAct 模式:LLM 做意图识别和路由,代码执行具体 skill action。 -助手的价值不在于“会聊天”,而在于: +### 主要流程 -- 降低用户操作成本 -- 减少配置错误和误操作 -- 提高问题定位效率 -- 让交易过程更专业、更可靠 - -## 核心理念 - -本助手采用 `80% skill + 20% 动态规划` 的设计思路。 - -这意味着: - -- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理 -- 不应让模型对已知流程重复思考 -- 动态规划只用于少数复杂、跨领域、未知或开放性任务 -- 能确定的事情就不要交给模型自由发挥 - -默认优先级如下: - -1. 优先匹配 skill -2. 如果用户仍在当前任务中,则继续当前 skill -3. 只有当没有合适 skill 时,才进入动态规划 - -## 设计原则 - -### 1. 以 Skill 为主,不以自由推理为主 - -对于高频任务和高风险任务,必须优先使用 skill,而不是通用 agent 自行规划。 - -尤其是以下场景: - -- 创建 agent -- 启动或停止 agent -- 新增或修改交易所配置 -- 新增或修改策略 -- 新增或修改模型配置 -- 常见报错排查 -- API 配置指导 - -这些任务都应有稳定、明确、可重复执行的处理路径。 - -### 2. 以用户任务为中心,不以内部对象或 API 为中心 - -skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。 - -好的拆分方式: - -- 创建一个 agent -- 启动或停止一个 agent -- 排查交易所 API 连接失败 -- 指导用户配置某个模型的 API -- 解释某条报错并给出下一步 - -不好的拆分方式: - -- exchange skill -- strategy 对象 skill -- 通用 REST 调用 skill -- 纯接口包装型 skill - -用户关注的是任务结果,不是内部实现。 - -### 3. 多轮对话的目标是推进任务,不是维持聊天感 - -多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。 - -每一轮都应围绕以下问题展开: - -- 当前正在处理什么任务 -- 当前任务已经确认了哪些信息 -- 还缺什么关键信息 -- 下一步最合理的推进动作是什么 - -### 4. 只追问必要信息 - -当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。 - -助手只应追问: - -- 当前任务必需但缺失的字段 -- 影响结果的重要选择项 -- 涉及风险、删除、替换、启动、停止等动作时的确认信息 - -不要要求用户重复已经确认过的信息。 - -### 5. 尽量减少不必要的思考 - -对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。 - -不要把模型能力浪费在这些事情上: - -- 猜测标准流程 -- 重新设计高频任务执行顺序 -- 对常见配置问题进行开放式发散分析 -- 对结构化任务做不必要的“创造性理解” - -### 6. 高风险动作优先保证安全 - -任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。 - -以下动作通常需要明确确认: - -- 删除 agent -- 删除交易所配置 -- 删除策略 -- 覆盖已有配置 -- 启动实盘 agent -- 停止正在运行的 agent -- 修改可能影响下单行为的关键参数 - -当用户意图不够明确时,宁可先确认,不要直接执行。 - -### 7. 回答要以可执行为目标 - -当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。 - -尽量围绕这三个问题组织回答: - -- 发生了什么 -- 为什么会这样 -- 现在该怎么做 - -## 任务分类 - -### 一、执行类任务 - -执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。 - -例如: - -- 创建 agent -- 编辑 agent -- 启动 agent -- 停止 agent -- 删除 agent -- 创建交易所配置 -- 修改交易所配置 -- 删除交易所配置 -- 创建策略 -- 编辑策略 -- 激活策略 -- 复制策略 -- 删除策略 -- 创建模型配置 -- 修改模型配置 -- 删除模型配置 - -这类任务应优先通过 skill 实现,避免自由规划。 - -### 二、诊断类任务 - -诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。 - -例如: - -- 某条报错是什么意思 -- 为什么模型 API 配置失败 -- 为什么交易所 API 连接不上 -- 为什么 agent 启动失败 -- 为什么策略没有执行 -- 为什么余额、仓位、收益统计不对 -- 为什么某个配置在前端能保存,但运行时报错 - -这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。 - -### 三、指导类任务 - -指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。 - -例如: - -- 某个模型的 API key 去哪里申请 -- 某个模型的 base URL 和 model name 怎么填 -- 某个交易所 API key 怎么创建 -- 某个交易所权限应该怎么勾选 -- 某种策略适合什么市场环境 -- 某些交易指标怎么理解 - -这类任务应提供步骤化、实操型指导。 - -### 四、动态规划类任务 - -动态规划不是默认模式,而是兜底模式。 - -只有在以下情况下,才允许进入动态规划: - -- 用户请求跨越多个 skill -- 用户描述模糊,需要先探索再判断 -- 用户提出的是开放式交易问题 -- 用户的问题不属于已有 skill 覆盖范围 -- 需要组合查询、分析、判断和建议 - -动态规划可以存在,但必须受控,不能覆盖主路径。 - -## 多轮对话策略 - -### 一、优先延续当前任务 - -如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。 - -例如: - -- 用户:帮我创建一个新的 BTC agent -- 助手:请提供交易所和模型配置 -- 用户:用我刚配的 DeepSeek - -这时应继续“创建 agent”这个任务,而不是重新理解成一个新的需求。 - -### 二、多轮对话以任务状态推进为核心 - -每个任务在多轮中都应该有明确状态,例如: - -- 已识别任务 -- 信息收集中 -- 等待用户确认 -- 执行中 -- 已完成 -- 执行失败,待修复 -- 已中断或已切换 - -助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。 - -### 三、只补齐缺失参数,不重复收集已有信息 - -如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。 - -例如创建 agent 时,可能需要: - -- 名称 -- 交易所 -- 策略 -- 模型 -- 是否立即启动 - -如果其中三个字段已经确认,就不要重新追问这三个字段。 - -### 四、允许用户中途切换任务 - -如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。 - -例如: - -- 当前任务:创建 agent -- 用户突然说:为什么我的交易所 API 报 invalid signature - -这时应切换到诊断类任务,而不是强行把用户拉回创建流程。 - -### 五、允许短暂插问,但尽量回到主任务 - -如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。 - -例如: - -- 用户正在创建策略 -- 中途问:逐仓和全仓有什么区别 - -助手可以先给简洁解释,再继续原任务。 - -### 六、对高风险动作单独确认 - -即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。 - -例如: - -- 删除策略前确认 -- 启动实盘前确认 -- 覆盖已有配置前确认 - -## 记忆策略 - -### 一、记住对当前任务有用的信息 - -当前会话中,应保留以下内容: - -- 当前活跃任务 -- 已确认的参数 -- 用户明确表达过的选择 -- 仍然缺失的关键字段 -- 当前排障上下文 -- 最近一次确认结果 - -### 二、不把猜测当成记忆 - -以下内容不应被高强度依赖: - -- 助手自行推断但用户未确认的偏好 -- 早前对话中的过时信息 -- 与当前任务无关的旧上下文 -- 仅基于模糊表达做出的假设 - -如果有不确定性,应明确标注为“推测”或重新确认。 - -### 三、敏感信息只在必要范围内使用 - -对于 API key、密钥、凭证、账户等敏感信息: - -- 不要在回答中完整复述 -- 不要在无关任务中再次提起 -- 仅在当前任务确有需要时使用 -- 默认进行脱敏展示 - -## Skill 设计规范 - -每个 skill 都应服务于一个真实、完整、可交付的用户任务。 - -一个好的 skill 应当具备以下特点: - -- 范围足够聚焦,执行稳定 -- 范围又不能过小,能够完成完整任务 -- 输入要求清晰 -- 流程尽量确定 -- 成功和失败条件明确 -- 容易扩展和维护 - -每个 skill 至少应定义以下内容: - -- 处理的意图 -- 适用场景 -- 必填输入 -- 可选输入 -- 前置条件 -- 执行步骤 -- 缺少信息时如何追问 -- 哪些步骤需要确认 -- 成功后的输出格式 -- 常见失败情况 -- 对应的恢复建议 - -## 工具使用原则 - -工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。 - -助手不应表现为: - -- 一个通用 API 调用器 -- 一个只会函数路由的壳 -- 一个对常规任务也反复规划的自治代理 - -默认顺序应为: - -1. 先判断是否有合适 skill -2. 在 skill 内部调用所需工具 -3. 如果没有 skill,再进入受限动态规划 -4. 最后才考虑通用探索式工具调用 - -## Skill 与 Tool 的分层原则 - -Skill 和 tool 不是同一层概念。 - -tool 是底层执行能力,skill 是面向用户任务的稳定流程。 - -默认架构应为: - -用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果 - -而不是: - -用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划 - -### 一、Skill 是面向任务的 - -skill 应围绕用户目标设计,例如: - -- 创建 agent -- 启动或停止 agent -- 配置交易所 API -- 诊断模型配置失败 -- 解释某类报错 - -skill 负责定义: - -- 要处理什么任务 -- 需要哪些输入 -- 缺信息时怎么追问 -- 执行顺序是什么 -- 哪些动作需要确认 -- 失败时怎么恢复 - -### 二、Tool 是面向执行的 - -tool 负责具体动作,不负责完整任务语义。 - -例如: - -- 读取当前模型配置 -- 保存交易所配置 -- 查询 trader 列表 -- 启动某个 trader -- 获取余额 -- 获取持仓 - -tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。 - -### 三、优先把底层 tool 收敛到 skill 内部 - -在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。 - -更合理的做法是: - -- 大模型优先决定使用哪个 skill -- skill 内部自己决定需要调用哪些 tool -- 用户不需要面对底层能力拆分 -- 模型也不需要在每次请求中重新拼装流程 - -### 四、可以直接暴露给大模型的,应当是高层 skill 化能力 - -如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。 - -较好的直接暴露方式: - -- `manage_trader` -- `manage_exchange_config` -- `manage_model_config` -- `manage_strategy` -- `diagnose_trader_start_failure` - -较差的直接暴露方式: - -- `get_model_list_then_find_enabled_one` -- `read_exchange_then_patch_field` -- `generic_api_request` -- 纯粹的 CRUD 原子碎片接口 - -也就是说,即使最终在技术实现上仍然使用 tool calling,这些 tool 也应该尽量表现为 skill,而不是裸露的底层零件。 - -### 五、只有在以下情况,才允许直接使用底层 tool - -- 当前请求没有匹配 skill -- 请求属于探索式、一次性、低频问题 -- 需要动态组合多个能力处理未知问题 -- 当前是在做诊断型探索,而不是执行标准流程 - -即使如此,也应优先限制范围,避免进入无边界的自由调用。 - -### 六、设计目标 - -引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。 - -因此分层目标应是: - -- 高频任务由 skill 固化 -- 低层动作沉到 skill 内部 -- 大模型少接触原子化 tool -- 只有少数未知问题才进入动态规划 - -## 交易场景下的行为要求 - -交易助手必须让整体体验显得专业、谨慎、清晰。 - -这意味着: - -- 操作建议要结构化 -- 配置指导要准确 -- 风险提示要明确 -- 不确定性要说清楚 -- 不应伪装成对市场有绝对把握 - -当涉及交易建议时,应尽量区分: - -- 客观事实 -- 助手判断 -- 用户可执行的下一步 - -对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。 - -例如应更倾向于: - -- 如果你是震荡思路,可以考虑…… -- 如果当前目标是降低回撤,优先检查…… -- 这个现象更像是配置问题,不一定是策略本身失效 - -而不是: - -- 这个市场一定会涨 -- 你应该马上开多 -- 这个策略就是最优解 - -## 默认处理流程 - -当用户发来请求时,助手默认按以下顺序处理: - -1. 先判断这是不是一个已知高频任务 -2. 如果是,直接进入对应 skill -3. 如果任务信息不完整,只追问继续执行所需的最少字段 -4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径 -5. 如果属于开放式问题或跨 skill 问题,才进入动态规划 -6. 如果涉及高风险动作,在执行前单独确认 -7. 完成后给出简洁、明确、可执行的结果反馈 - -## 总结原则 - -本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。 - -应当 skill 化的事情,就不要交给模型自由发挥。 -应当标准化的流程,就不要每次重新规划。 -应当确认的风险动作,就不要直接执行。 - -多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。 - -## 当前落地状态 - -第一批诊断与配置类 skill 已开始沉淀,见: - -- `docs/agent-skills/diagnostic-skills.zh-CN.md` - -当前实现优先覆盖: - -- 模型 API 配置与诊断 -- 交易所 API 配置与诊断 -- trader 启动与运行诊断 -- 下单与仓位异常诊断 -- 策略与 prompt 生效问题诊断 - -## 当前能力分层建议 - -下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。 - -### 一、建议保留为高层 skill 的能力 - -这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。 - -- `manage_trader` -- `manage_exchange_config` -- `manage_model_config` -- `manage_strategy` -- `execute_trade` -- `get_positions` -- `get_balance` -- `get_trade_history` -- `search_stock` - -原因: - -- 用户会直接表达这类任务 -- 这些能力已经具备较完整的业务语义 -- 它们天然适合作为 skill 或 skill-like tool - -后续建议: - -- 保持这些能力对外稳定 -- 在其上继续补充确认规则、缺参追问规则和诊断分支 - -### 二、建议下沉到 skill 内部的能力 - -这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。 - -- 读取某个资源后再 patch 某个字段 -- 各类配置查询后再拼装参数 -- 针对单一字段的修改动作 -- 仅为执行中间步骤服务的查询动作 -- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力 - -原因: - -- 这类能力更像流程零件 -- 一旦直接暴露给大模型,会导致每次都重新规划 -- 会让高频任务变得不稳定且冗长 - -原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。 - -### 三、建议弱化的能力形态 - -以下设计方向应尽量弱化: - -- 通用 `generic_api_request` -- 纯 CRUD 原子接口直接暴露给大模型 -- 没有任务语义的“万能工具” -- 需要模型自己理解完整调用顺序的碎片化接口 - -原因: - -- 这类能力过于底层 -- 会把流程控制权交还给模型 -- 与“80%% skill + 20%% 动态规划”的目标相冲突 - -### 四、建议新增的高层 skill 结构 - -后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。 - -更合理的方式是按“资源管理域”收敛为少量 management skill: - -- `trader_management` -- `exchange_management` -- `model_management` -- `strategy_management` - -这些 management skill 可以在内部继续复用现有: - -- `manage_trader` -- `manage_exchange_config` -- `manage_model_config` -- `manage_strategy` - -也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。 - -#### management skill 的统一协议 - -每个 management skill 都应至少定义: - -- `action` -- `target_ref` -- `slots` -- `needs_confirmation` - -推荐结构如下: - -```json -{ - "skill": "exchange_management", - "action": "update", - "target_ref": { - "id": "optional", - "name": "主账户", - "alias": "optional" - }, - "slots": { - "passphrase": "xxx" - }, - "needs_confirmation": false -} +``` +用户消息 + → tryOnePassSemanticGateway(快速单次 LLM 路由) + 命中 → 直接执行 + 未命中 → tryLLMIntentRoute(完整 LLM 路由) + → skill(单个结构化操作) + → workflow(多步骤跨 skill 操作) + → planner(复杂/模糊请求) ``` -#### action 规则 - -不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。 - -- `trader_management` - - `create` - - `update` - - `delete` - - `start` - - `stop` - - `query` -- `exchange_management` - - `create` - - `update` - - `delete` - - `query` -- `model_management` - - `create` - - `update` - - `delete` - - `query` -- `strategy_management` - - `create` - - `update` - - `delete` - - `activate` - - `duplicate` - - `query` - -#### reference 规则 - -management skill 不应要求用户总是提供精确 id,而应支持分层定位目标: - -1. 优先使用 `id` -2. 其次使用 `name` -3. 再其次使用 alias / 最近上下文引用 -4. 若命中多个对象,则要求用户明确选择 -5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行 - -#### slot 规则 - -每个 action 都应定义: - -- 必填 slots -- 可选 slots -- 自动推断规则 -- 缺失字段时的最小追问规则 - -例如: - -- `exchange_management.create` - - 必填:`exchange_type` - - 常见必填:`account_name`、凭证字段 -- `exchange_management.update` - - 必填:`target_ref` - - 其余只需要用户明确要改的字段 -- `trader_management.create` - - 必填:`name`、`exchange`、`model` - - 常见可选:`strategy`、`auto_start` +### Intent 类型 + +| Intent | 含义 | +|--------|------| +| `continue_active` | 继续当前激活流程 | +| `resume_snapshot` | 恢复某个挂起快照 | +| `start_new` | 开启新的顶层请求 | +| `cancel` | 取消当前流程 | +| `instant_reply` | 纯聊天,不触发任务 | + +--- + +## 二、Skill 体系 + +### 4 个 Management Skills + +#### trader_management +- **触发:** 用户提到"交易员/trader/agent" + 操作动词 +- **创建必填:** name、exchange_id、ai_model_id、strategy_id +- **字段约束:** + - `name`:最多 50 字符 + - `scan_interval_minutes`:3~60 分钟,超出自动收敛 + - `initial_balance`:最低 100,超出自动收敛 + - `is_cross_margin`:bool,默认 true(全仓) + - `show_in_competition`:bool,默认 false + - `auto_start`:bool,默认 false +- **支持操作:** create、update、update_name、update_bindings、configure_strategy、configure_exchange、configure_model、start(需确认)、stop(需确认)、delete(需确认)、query_list、query_running、query_detail + +#### exchange_management +- **触发:** 用户提到交易所名称 + 操作动词 +- **创建必填(按交易所):** + +| 交易所 | 必填字段 | +|--------|---------| +| binance / bybit / gate | api_key、secret_key | +| okx / kucoin | api_key、secret_key、passphrase | +| hyperliquid | hyperliquid_wallet_addr | +| aster | aster_user、aster_signer、aster_private_key | +| lighter | lighter_wallet_addr、lighter_api_key_private_key | + +- **其他约束:** + - `api_key`:至少 8 位字母数字 + - `secret_key`:至少 8 位字母数字或十六进制 + - `lighter_api_key_index`:0~255,超出自动收敛 + - `testnet`:bool,默认 false +- **支持操作:** create、update、update_name、update_status、delete(需确认)、query_list、query_detail + +#### model_management +- **触发:** 用户提到模型/provider 名称 + 操作动词 +- **支持 provider:** openai、deepseek、claude、gemini、qwen、kimi、grok、minimax、claw402、blockrun-base +- **字段约束:** + - `api_key`:OpenAI 必须以 `sk-` 开头 + - `custom_api_url`:必须是合法 HTTPS 地址 + - `enabled=true` 前必须填写 api_key 和 custom_model_name +- **支持操作:** create、update、update_status、update_endpoint、update_name、delete(需确认)、query_list、query_detail + +#### strategy_management +- **触发:** 用户提到"策略/strategy" + 操作动词 +- **字段约束:** + - `btceth_max_leverage`:1~20,超出自动收敛 + - `altcoin_max_leverage`:1~20,超出自动收敛 + - `min_confidence`:0~100,超出自动收敛 + - `grid_count`:最小 2 + - `lower_price` 必须小于 `upper_price` + - 策略模板**不能直接启动**,只有绑定了该策略的交易员才能启动 +- **支持操作:** create、update、update_name、update_prompt、update_config、activate、duplicate、delete(需确认)、query_list、query_detail + +### 4 个 Diagnosis Skills + +- `trader_diagnosis`:交易员启动失败、未下单、收益异常等诊断 +- `exchange_diagnosis`:invalid signature、timestamp、权限不足等诊断 +- `model_diagnosis`:模型调用失败、接口不兼容、鉴权错误等诊断 +- `strategy_diagnosis`:策略不生效、参数不一致等诊断 + +--- + +## 三、LLM 收到的 Prompt 内容 + +路由阶段 LLM 收到: +1. Skill 摘要(名称、描述、创建必填) +2. Skill 禁止规则(各 skill 不能做什么) +3. 近期对话上下文 +4. 当前任务状态 +5. 当前激活流程摘要 + JSON +6. 当前引用摘要 +7. 执行状态 JSON +8. 挂起快照 JSON + +> 注意:`buildManagementSkillContext(lang, nil)` 传 nil,active skill 的 dynamic_rules 不会注入路由 LLM。 + +--- + +## 四、Snapshot / Resume 机制 + +- `SnapshotManager.Save(task)` 压栈挂起任务 +- `task.ResumeOnSuccess = true` + `task.ResumeTriggers` 控制子任务完成后自动回流 +- `maybeResumeParentTaskAfterSuccessfulSkill` 在子任务成功后检查栈,自动恢复父任务 + +--- + +## 五、本次改动记录(2026-04-23) + +### 1. 4 个 skill JSON 补全字段约束 + +文件:`agent/skills/*.json` + +- `trader_management.json`:补全 field_constraints、validation_rules、完整 actions +- `exchange_management.json`:补全各交易所必填字段、per_exchange_required_fields +- `model_management.json`:补全 provider 枚举、API key 格式、HTTPS 校验 +- `strategy_management.json`:补全杠杆/置信度/网格约束,修复中文弯引号 JSON 错误 -#### confirmation 规则 +### 2. 路由层加 inline_sub_intent + +文件:`agent/llm_skill_router.go` + +- `llmSkillRouteDecision` 和 `onePassGatewayDecision` 加 `InlineSubIntent` 字段 +- 两个 gateway 的 JSON shape 加 `inline_sub_intent` +- Rules 加:configure_strategy/exchange/model 流程里用户想新建依赖资源时,判断 `continue_active` + `inline_sub_intent=create_sub_resource` +- `continue_active` 分支把 `inline_sub_intent` 写入 session fields + +### 3. 执行层消费 inline_sub_intent + +文件:`agent/skill_execution_handlers.go` -management skill 内部必须按 action 级别区分风险,而不是统一处理。 +- `configure_strategy/exchange/model` 分支检测到 `inline_sub_intent=create_sub_resource` 时: + 1. 压栈当前 session(`ResumeOnSuccess=true`) + 2. 切换到对应子任务(strategy/exchange/model create) + 3. 子任务完成后自动回流父任务 -- `delete` 默认必须确认 -- `start` / `stop` 视场景确认 -- `create` 通常可直接执行 -- `update` 若涉及关键配置变更,可要求确认 -- `query` 不需要确认 - -### 五、建议新增的诊断类 skill - -诊断类 skill 是交易助手体验差异化的关键。 +--- -建议优先固定以下能力: - -- `model_diagnosis` -- `exchange_diagnosis` -- `trader_diagnosis` -- `order_execution_diagnosis` -- `strategy_diagnosis` -- `balance_position_diagnosis` +## 六、已知问题 -这些 skill 应优先基于: - -- 已有代码中的真实约束 -- 现有 troubleshooting 文档 -- 真实常见错误文案 -- 当前系统的实际运行逻辑 - -### 六、建议保留给动态规划的少数场景 - -以下场景仍然可以保留给 planner / ReAct: - -- 跨多个 skill 的复合任务 -- 用户目标表述模糊,需要先澄清再决定流程 -- 开放式交易问题 -- 一次性、低频、尚未固化的问题 -- 涉及诊断探索但还没有稳定 skill 的场景 - -动态规划应始终作为兜底层,而不是主路径。 - -### 七、最终目标分层 - -理想结构如下: - -1. 用户表达需求 -2. 系统先判断是否命中高频 skill -3. 若命中,则进入对应 skill 流程 -4. skill 内部调用现有管理类能力或查询能力 -5. 只有未命中 skill 时,才进入 planner - -长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。 - -## `agent/tools.go` 重构清单 - -当前 `agent/tools.go` 中主要暴露了以下工具: - -- `get_preferences` -- `manage_preferences` -- `get_exchange_configs` -- `manage_exchange_config` -- `get_model_configs` -- `manage_model_config` -- `get_strategies` -- `manage_strategy` -- `manage_trader` -- `search_stock` -- `execute_trade` -- `get_positions` -- `get_balance` -- `get_market_price` -- `get_trade_history` - -下面给出按当前设计目标的建议分类。 - -### 一、建议继续保留为高层入口的工具 - -这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。 - -- `manage_exchange_config` -- `manage_model_config` -- `manage_strategy` -- `manage_trader` -- `execute_trade` - -原因: - -- 它们都对应明确的用户任务 -- 内部已经承载了一定业务语义 -- 后续可以直接继续向 skill 演进,而不是推倒重来 - -重构建议: - -- 保持接口稳定 -- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用 -- 后续逐步把对话语义前移到 `xxx_management` - -### 二、建议保留为“只读能力”但弱化对外存在感的工具 - -这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。 - -- `get_exchange_configs` -- `get_model_configs` -- `get_strategies` -- `get_positions` -- `get_balance` -- `get_market_price` -- `get_trade_history` -- `search_stock` - -原因: - -- 它们更适合做信息补充和状态验证 -- 对诊断问题很有价值 -- 但不应该替代 task-level skill - -重构建议: - -- 继续保留 -- 主要用于: - - skill 内部验证 - - 诊断类 skill 查询当前状态 - - 明确的只读用户请求 -- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合 - -### 三、建议进一步收敛使用边界的工具 - -以下工具容易把模型带回到底层操作思维,应该明确边界。 - -- `get_preferences` -- `manage_preferences` - -原因: - -- 长期偏好记忆是辅助能力,不是交易任务主线 -- 如果让模型频繁自由改偏好,容易污染上下文 - -重构建议: - -- 仅在用户明确表达“记住/修改/删除长期偏好”时使用 -- 不要把偏好系统混进交易执行和排障主流程 - -### 四、建议前移为 management / diagnosis skill 的现有高层工具 - -下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。 - -#### 1. `manage_trader` - -建议逐步前移为: - -- `trader_management` -- `trader_diagnosis` - -原因: - -- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域 -- 诊断路径和执行路径应分开 - -#### 2. `manage_exchange_config` - -建议逐步前移为: - -- `exchange_management` -- `exchange_diagnosis` - -原因: - -- CRUD / query 属于同一资源管理域 -- invalid signature / timestamp / IP 白名单问题需要单独诊断路径 - -#### 3. `manage_model_config` - -建议逐步前移为: - -- `model_management` -- `model_diagnosis` - -原因: - -- 模型对象管理应集中到一个 management skill -- provider 配置失败和运行失败应集中到 diagnosis skill - -#### 4. `manage_strategy` - -建议逐步前移为: - -- `strategy_management` -- `strategy_diagnosis` - -原因: - -- 策略模板管理和策略问题排查是两类不同任务 -- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理 - -### 五、当前最适合直接做成硬 skill 的第一批对象 - -如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”,建议优先落以下几类: - -1. `create_trader` -2. `trader_management` -3. `exchange_management` -4. `model_management` -5. `exchange_diagnosis` -6. `model_diagnosis` -7. `trader_diagnosis` - -原因: - -- 这些最常见 -- 多轮价值最高 -- 失败成本高 -- 用户对稳定性的感知最强 - -### 六、最终目标 - -`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。 - -也就是说,长期理想状态是: - -- 文档层:按 skill 组织 -- 对话层:先匹配 skill -- 执行层:skill 内部复用现有 tool -- planner 层:只兜底少数复杂情况 +| 问题 | 状态 | +|------|------| +| 创建交易员时直接用现有配置,未询问用户确认 | 未修复 | +| 路由 LLM 缺 active session context(buildManagementSkillContext 传 nil) | 未修复 | diff --git a/api/agent_preferences.go b/api/agent_preferences.go index 1c188840..f73c3e5f 100644 --- a/api/agent_preferences.go +++ b/api/agent_preferences.go @@ -39,6 +39,10 @@ func (s *Server) handleCreateAgentPreference(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "text required"}) return } + if len([]rune(strings.TrimSpace(req.Text))) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"error": "text too long"}) + return + } created, err := agent.NewPersistentPreference(req.Text) if err != nil { diff --git a/api/agent_routes.go b/api/agent_routes.go index 91d09a0c..d2c2ccc0 100644 --- a/api/agent_routes.go +++ b/api/agent_routes.go @@ -11,11 +11,27 @@ import ( func (s *Server) RegisterAgentHandler(h *agent.WebHandler) { // Chat requires auth — can trigger trades and access account data s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) { - req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))) + isAdmin := c.GetString("user_id") == "admin" + ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")) + ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{ + Authenticated: true, + IsAdmin: isAdmin, + CanExecuteTrade: isAdmin, + CanViewSensitiveSecrets: false, + }) + req := c.Request.WithContext(ctx) h.HandleChat(c.Writer, req) }) s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) { - req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))) + isAdmin := c.GetString("user_id") == "admin" + ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")) + ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{ + Authenticated: true, + IsAdmin: isAdmin, + CanExecuteTrade: isAdmin, + CanViewSensitiveSecrets: false, + }) + req := c.Request.WithContext(ctx) h.HandleChatStream(c.Writer, req) }) // Public endpoints — read-only market data diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index 91178dde..6d1f89c1 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -10,6 +10,7 @@ import ( "nofx/crypto" "nofx/logger" "nofx/security" + "nofx/store" "nofx/wallet" "github.com/gin-gonic/gin" @@ -77,8 +78,11 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { logger.Infof("✅ Found %d AI model configs", len(models)) // Convert to safe response structure, remove sensitive information - safeModels := make([]SafeModelConfig, len(models)) - for i, model := range models { + safeModels := make([]SafeModelConfig, 0, len(models)) + for _, model := range models { + if !store.IsVisibleAIModel(model) { + continue + } safeModel := SafeModelConfig{ ID: model.ID, Name: model.Name, @@ -100,7 +104,23 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { } } - safeModels[i] = safeModel + safeModels = append(safeModels, safeModel) + } + + if len(safeModels) == 0 { + logger.Infof("⚠️ No visible AI models in database, returning defaults") + defaultModels := []SafeModelConfig{ + {ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false}, + {ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false}, + {ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false}, + {ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false}, + {ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false}, + {ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false}, + {ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false}, + {ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false}, + } + c.JSON(http.StatusOK, defaultModels) + return } c.JSON(http.StatusOK, safeModels) diff --git a/api/handler_exchange.go b/api/handler_exchange.go index 0fb8650a..33c9a4c4 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -8,6 +8,7 @@ import ( "nofx/config" "nofx/crypto" "nofx/logger" + "nofx/store" "github.com/gin-gonic/gin" ) @@ -96,9 +97,12 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { logger.Infof("✅ Found %d exchange configs", len(exchanges)) // Convert to safe response structure, remove sensitive information - safeExchanges := make([]SafeExchangeConfig, len(exchanges)) - for i, exchange := range exchanges { - safeExchanges[i] = SafeExchangeConfig{ + safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges)) + for _, exchange := range exchanges { + if !store.IsVisibleExchange(exchange) { + continue + } + safeExchanges = append(safeExchanges, SafeExchangeConfig{ ID: exchange.ID, ExchangeType: exchange.ExchangeType, AccountName: exchange.AccountName, @@ -110,7 +114,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { AsterUser: exchange.AsterUser, AsterSigner: exchange.AsterSigner, LighterWalletAddr: exchange.LighterWalletAddr, - } + }) } c.JSON(http.StatusOK, safeExchanges) diff --git a/api/handler_trader.go b/api/handler_trader.go index 6b503e61..67d3157e 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -14,6 +14,11 @@ import ( "gorm.io/gorm" ) +const ( + maxManualBTCETHLeverage = 20 + maxManualAltLeverage = 20 +) + // AI trader management related structures type CreateTraderRequest struct { Name string `json:"name" binding:"required"` @@ -65,6 +70,16 @@ func traderCreationRequestError(reason string) string { return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交") } +func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) { + if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage { + return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage" + } + if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage { + return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage" + } + return "", "" +} + func exchangeDisplayName(exchange *store.Exchange) string { if exchange == nil { return "所选交易所账户" @@ -306,13 +321,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) { return } - // Validate leverage values - if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 { - SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil) - return - } - if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 { - SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil) + // Validate leverage values against the same limits exposed by manual user config. + if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" { + SafeBadRequestWithDetails(c, errMsg, errCode, nil) return } @@ -574,6 +585,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { return } + if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" { + SafeBadRequestWithDetails(c, errMsg, errCode, nil) + return + } + // Set default values isCrossMargin := existingTrader.IsCrossMargin // Keep original value if req.IsCrossMargin != nil { diff --git a/api/handler_trader_test.go b/api/handler_trader_test.go new file mode 100644 index 00000000..aee46644 --- /dev/null +++ b/api/handler_trader_test.go @@ -0,0 +1,17 @@ +package api + +import "testing" + +func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) { + if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" { + t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code) + } + + if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" { + t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code) + } + + if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" { + t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code) + } +} diff --git a/api/strategy.go b/api/strategy.go index 64105fa5..72b5ce2c 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -182,6 +182,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { defaultCfg := store.GetDefaultStrategyConfig(lang) req.Config = &defaultCfg } + beforeClamp := *req.Config + req.Config.ClampLimits() // Serialize configuration configJSON, err := json.Marshal(req.Config) @@ -207,6 +209,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { // Validate configuration and collect warnings warnings := validateStrategyConfig(req.Config) + warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...) response := gin.H{ "id": strategy.ID, @@ -263,14 +266,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { mergedConfig = store.StrategyConfig{} } - // Apply incoming config on top: top-level sections present in the request overwrite - // their corresponding existing section; absent sections remain unchanged. + // Apply incoming config on top while preserving nested fields that were not sent. if len(req.Config) > 0 && string(req.Config) != "null" { - if err := json.Unmarshal(req.Config, &mergedConfig); err != nil { + var patch map[string]any + if err := json.Unmarshal(req.Config, &patch); err != nil { + SafeBadRequest(c, "Invalid config JSON") + return + } + mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch) + if err != nil { SafeBadRequest(c, "Invalid config JSON") return } } + beforeClamp := mergedConfig + mergedConfig.ClampLimits() // Preserve existing name/description when not supplied name := req.Name @@ -324,6 +334,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { // Validate merged configuration and collect warnings warnings := validateStrategyConfig(&mergedConfig) + warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...) response := gin.H{"message": "Strategy updated successfully"} if len(warnings) > 0 { diff --git a/docs/architecture/AGENT_ARCHITECTURE_STATUS.zh-CN.md b/docs/architecture/AGENT_ARCHITECTURE_STATUS.zh-CN.md new file mode 100644 index 00000000..262df8a8 --- /dev/null +++ b/docs/architecture/AGENT_ARCHITECTURE_STATUS.zh-CN.md @@ -0,0 +1,926 @@ +# NOFXi Agent 架构现状说明 + +本文档说明当前 `nofxi-dev/agent` 的整体设计、关键执行链路、内存与快照机制、skill 协作方式,以及这套实现是怎么逐步收成现在这个样子的。 + +适用范围: +- `nofxi-dev/agent` +- 与 Agent 强相关的 tool / store / workflow / frontend chat 行为 + +## 1. 当前目标 + +当前 Agent 的目标不是“单轮问答机器人”,而是一个可持续管理配置、跨多轮续接、可在多个对象之间切换上下文的任务型 Agent。 + +核心要求有 4 个: + +1. 用户一句模糊话,也要先判断是在继续当前任务、切回旧任务、开新任务,还是取消。 +2. `model / exchange / trader / strategy` 四个 management skill 都要能被统一路由、统一理解、统一执行。 +3. 用户说的是名称,系统执行的是 ID,中间必须有稳定映射。 +4. 快照、当前引用对象、最近对话、执行状态,不能只是“存着”,而要真的被 LLM 当成决策输入。 + +--- + +## 2. 顶层执行链路 + +当前主入口在: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:820) + +整体顺序是: + +1. `tryLLMIntentRoute` +2. `tryStatePriorityPath` +3. `tryInstantDirectReply` +4. `tryReadFastPath` +5. `tryHardSkill` +6. `runPlannedAgent` + +也就是说,现在系统优先做“统一语义判断”,然后才看 active flow、direct reply、hard skill 和 planner。 + +### 2.1 统一语义网关 + +顶层语义网关在: +- [llm_skill_router.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_skill_router.go:25) + +LLM 目前先判断一条消息属于哪种 intent: + +- `continue_active` +- `resume_snapshot` +- `start_new` +- `cancel` +- `instant_reply` + +如果是 `start_new`,再继续判断 route: + +- `skill` +- `workflow` +- `planner` + +这层的意义是:先判断“这句话在和哪个上下文说话”,再判断“具体怎么做”。 + +### 2.2 顾问式系统前缀 + +现在统一语义网关和 Planner 共享同一份顾问式系统前缀,而不再只是“路由器”或“计划器”口吻。 + +这份前缀的核心基调是: + +- 你是 NOFX 的核心智能中枢 `NOFXi` +- 你的首要目标不是盲目执行命令 +- 你需要以资深量化顾问身份,确保每一次配置都正确、安全且符合逻辑 +- 当用户遇到问题时,你要结合当前状态和平台边界主动诊断,并给出具体解决方案 + +统一前缀已经抽成共享 helper: +- [prompt_persona.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/prompt_persona.go) + +当前已注入: + +- 顶层统一语义网关 +- `one-pass` 统一语义网关 +- planner +- replanner + +这样做的目的,是让“理解用户意图”和“后续制定执行计划”都遵守同一套顾问式人格、安全边界和诊断基调。 + +### 2.3 兼容式 One-Pass JSON 网关 + +为了减少 `router -> classifier -> extractor` 的串行 LLM 调用次数,当前已经在统一语义网关前面接入了一个兼容式 `One-Pass JSON` 网关: +- [llm_skill_router.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_skill_router.go) + +它会在一次调用里尝试同时输出: + +- `intent` +- `target_skill` +- `target_snapshot_id` +- `extracted_fields` +- `need_planner_help` + +典型返回形状如下: + +```json +{ + "intent": "continue_active", + "target_skill": "trader_management:create", + "target_snapshot_id": "draft_7788", + "extracted_fields": {"leverage": "100"}, + "need_planner_help": false +} +``` + +当前它采用的是“兼容式接入”,不是硬切: + +1. 先尝试 `one-pass` 网关 +2. 如果输出不可用,回退到原有统一语义网关 +3. 原有 `planner / workflow / hard skill` 分层继续保留 + +这意味着系统已经开始减少多次串行 LLM 往返,但不会因为一次新网关输出失误就直接把旧链路全部推翻。 + +--- + +## 3. 状态优先层 + +状态优先层在: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:954) + +它负责优先处理这些场景: + +- 是否要恢复挂起任务 +- 是否已有 active workflow +- 是否已有 active skill session +- 是否已有 active execution state + +这层不是重新发明语义,而是消费上层已经决定好的“继续当前 / 切回旧快照 / 新开任务”。 + +如果当前有 active skill session,它会进一步进入: + +- `resolveSkillSessionTurn` +- `classifySkillSessionIntentWithLLM` +- `extractSkillSessionFieldsWithLLM` + +如果当前是 execution state,则会尝试: + +- `bridgeExecutionStateToSkillSession` + +这一层的目标,是把“planner 等待态”或者“执行等待态”桥接成真实 skill session,而不是让后续执行时丢上下文。 + +--- + +## 4. 四层上下文 + +当前 Agent 在规划和路由时,主要使用四层上下文: + +1. `Current reference summary` +2. `Execution state JSON` +3. `Recent conversation` +4. `Task state` + +它们现在被显式写进 planner prompt,相关逻辑在: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:2950) + +当前优先级是: + +1. 当前引用对象 +2. 当前执行状态 +3. 最近对话 +4. 持久化压缩背景 + +这解决的是“明明刚才就在说某个 trader / strategy,但后面又像不认识了一样”的问题。 + +--- + +## 5. CurrentReferences、快照和持久引用记忆 + +### 5.1 CurrentReferences + +`CurrentReferences` 表示当前锁定的对象,例如: + +- 当前 trader +- 当前 model +- 当前 exchange +- 当前 strategy + +它会进入: + +- router prompt +- planner prompt +- active flow classifier +- flow extraction + +相关读取点包括: +- [llm_skill_router.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_skill_router.go:38) +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:2950) +- [llm_flow_extractor.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_flow_extractor.go:73) + +### 5.2 Suspended snapshots + +挂起任务快照用于: + +- 打断当前流程 +- 以后恢复到具体旧流程 +- 让 LLM 在“刚才那个”“前面那个”这种模糊指代下仍能选对上下文 + +快照信息会进入: + +- top-level router +- active flow classifier +- flow extraction + +### 5.3 Persistent reference memory + +现在 `CurrentReferences` 不只存在于 `ExecutionState` 里。 + +新增了持久引用记忆: +- [reference_memory.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/reference_memory.go) + +核心函数: + +- `semanticCurrentReferences` +- `semanticReferenceHistory` +- `rememberReferencesFromToolResult` + +这层的作用是: + +- 即使 `ExecutionState` 被清掉 +- 当前对象记忆仍可延续 +- 后续 follow-up 仍能命中“当前 trader / 当前 strategy” + +### 5.4 DB 活性校验 + +持久化记忆不能被 100% 信任,因为真实对象可能已经被前端或其他入口删除。 + +因此现在在真正执行实体更新前,会先做一次轻量级活性校验: + +- 若当前 `TargetRef` 指向的对象已经不存在 +- 不再盲目继续执行 +- 会清掉失效引用,并要求用户重新指定目标对象 + +这解决的是: + +- Agent 记得“当前策略 A” +- 但真实数据库里的 `Strategy A` 已被网页前端删掉 +- 后续再说“就按当前策略来”时,不会直接拿悬空 ID 去执行 + +--- + +## 6. Skill 体系 + +当前正式 management skill 有四个: + +- `trader_management` +- `exchange_management` +- `model_management` +- `strategy_management` + +Skill 定义的正式来源在: +- [agent/skills/trader_management.json](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skills/trader_management.json) +- [agent/skills/exchange_management.json](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skills/exchange_management.json) +- [agent/skills/model_management.json](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skills/model_management.json) +- [agent/skills/strategy_management.json](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skills/strategy_management.json) + +--- + +## 7. 统一 skill 上下文:单一真源 + +之前的问题是: + +- skill JSON 有一份简介 +- router prompt 又手写一份 +- workflow prompt 再手写一份 +- classifier / extraction 又各有自己的上下文说明 + +这会导致不同层看到的 skill 描述不一致。 + +现在已经收成统一 helper: +- [skill_registry.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_registry.go) + +关键函数: + +- `buildSkillDefinitionSummary` +- `buildSkillDependencySummary` +- `buildSkillForbiddenSummary` +- `buildManagementSkillContext` + +### 7.1 buildManagementSkillContext + +这是现在 management skill 上下文的统一入口。 + +它输出两类信息: + +1. 四个 management skill 的简要说明 +2. 四个 management skill 的负向约束 +3. 当前 active skill 的依赖说明 + +例如对于 `trader_management:create`,它现在会明确告诉模型: + +- 创建 trader 依赖已启用交易所 +- 依赖已启用模型 +- 依赖可用策略 +- 修复这些依赖时,仍属于 trader create 的主流程 + +同时它也会告诉模型一些“不能做什么”的边界,例如: + +- `model_management` 不负责测试连接和诊断上游错误 +- `exchange_management` 不负责行情和交易执行 +- `strategy_management` 只负责模板管理,不负责直接运行 + +### 7.2 已接入的层 + +这个统一 helper 现在已经接入: + +- [llm_skill_router.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_skill_router.go:39) +- [workflow.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/workflow.go:569) +- [llm_flow_extractor.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_flow_extractor.go:73) +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:2010) +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:2951) + +也就是说,现在 router、workflow、classifier、extraction、planner 使用的是同一套 management skill 说明。 + +--- + +## 7.3 语义就绪检查 + +仅仅把消息路由到某个 skill 还不够,还要判断这条消息在“语义上是否已经准备好进入执行层”。 + +这层现在在: +- [skill_semantic_gate.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_semantic_gate.go) + +关键点: + +- `evaluateHardSkillCandidate` +- `semanticReadinessMissingSlots` +- `skillSemanticReadinessSummary` + +设计目标是: + +- 如果 LLM/规则已经判到某个 skill/action +- 但当前消息还明显缺少核心必填字段 +- 就不要直接往 hard skill 执行层掉 + +例如: + +- 用户说“帮我新建一个模型配置” +- 但还没有 `provider / api_key / custom_model_name` + +这时系统会优先把控制权交给 planner / ask_user,而不是直接返回程序式“缺字段”提示。 + +这样做的价值是: + +- 减少生硬的 hard skill 报错 +- 让交互更像“LLM 正在推进表单” +- 避免路由下坠到执行层后再回滚 + +--- + +## 8. Active flow 内部是怎么继续的 + +如果顶层判断是 `continue_active`,当前消息不会直接执行 tool,而是进入当前 flow 的续接过程。 + +进一步地,当前只要已经进入某个 active `skill:action`,系统会优先沿着当前 action 继续推进。 +旧的 `detectManagementAction / has*Patch / detect*Patch` 这类文本 heuristics 仍然保留,但已经更明确地退到: + +1. 没有 active skill session 时,用于粗路由和兜底识别 +2. active skill session 内,只有在 session/patch 都无法给出结果时,才作为 fallback 参与判断 + +这保证了: + +- 先尊重已经由 LLM 和状态机确定下来的当前 flow +- 再在必要时使用旧 heuristics 补洞 +- 避免“已经在当前 skill 里了,却又被文本规则抢去改 action”的抖动 + +现在 active flow 的打断条件也更收紧了: + +- 单纯在当前 flow 里提到 `model / exchange / strategy` +- 或者在补依赖时顺带提到其他 domain 名词 + +不再默认视为“跳到新任务”。 + +只有当消息整体更像一个新的顶层请求时,跨 domain 提及才会触发中断和重新路由。 + +### 8.1 Skill session 续接 + +相关逻辑: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:1583) +- [llm_flow_extractor.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_flow_extractor.go:61) + +主要分两步: + +1. `classifySkillSessionIntentWithLLM` + - 判断是继续、取消、打断还是闲聊 +2. `extractSkillSessionFieldsWithLLM` + - 把这条消息抽成结构化字段 + +然后把结构化字段 merge 回当前 skill session。 + +### 8.1.1 对话驱动式 skill 收集 + +对于高价值的多轮 management flow,当前已经开始把“补槽”从代码猜测迁到 LLM 对话驱动器: +- [llm_skill_conversation.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_skill_conversation.go) + +目前最先落地的是: +- `trader_management:create` +- `trader_management:update_bindings` +- `trader_management:configure_strategy` +- `trader_management:configure_exchange` +- `trader_management:configure_model` +- `model_management:update / update_status / update_endpoint / update_name` +- `exchange_management:update / update_status / update_name` +- `strategy_management:update / update_name / update_prompt / update_config / activate / duplicate` + +这层的设计目标不是“让代码先把用户的话拆碎”,而是: + +1. 先由 LLM 理解当前这句话在当前 flow 里到底是什么意思 +2. 再按需披露当前 `skill:action` 的规则书 +3. 再按当前缺失槽位,动态注入最相关的资源列表 +4. 最后由代码校验结果并落地执行 + +当前 `llmSkillConversationDriver` 会显式拿到: + +- 当前 active `skill:action` 的 contract +- 当前已收集字段 +- 当前缺失槽位 +- 最近一轮对话 +- 只和当前缺失槽位相关的资源列表(例如只缺 `model` 时,只注入模型列表) + +它返回的核心结果是: + +- `ready` +- `question` +- `extracted` +- `needs_clarification` +- `cancel` + +也就是说,现在 active skill 内部已经开始从: + +- `代码先猜字段` +- `模型后补救` + +迁移到: + +- `模型先理解当前回答的语义` +- `代码只做 guardrail 与执行` + +进一步地,执行层现在也开始优先消费 `skillSession` 里已经由 LLM 提取好的字段和目标对象。 +只有当 session 中还没有对应值时,才会退回到旧的文本解析 fallback。 + +更具体地说,当前高频 management update 动作的执行顺序已经开始统一成: + +1. 先消费 `session` 中已经由 LLM 提取好的字段/patch +2. 若 `session` 仍为空,再看当前整句是否能直接形成结构化 patch +3. 只有前两步都失败时,才退回到 `update_field + 单字段值` 这类旧文本猜测 + +这意味着旧的 `detect... / parse... / pick...` 路径仍然存在,但已经逐步退到真正的兜底层。 + +### 8.1.2 按需资源披露 + +对话驱动器不会每轮都把用户所有模型、交易所、策略全量塞进 Prompt。 + +现在这层已经改成: + +- 缺 `exchange` 才查并注入交易所列表 +- 缺 `model` 才查并注入模型列表 +- 缺 `strategy` 才查并注入策略列表 +- 某个槽位填完后,下一轮立即把对应资源列表从 Prompt 中移除 + +这就是“按需喂饭(Just-In-Time Context Injection)”: + +- 节省 token +- 降低延迟 +- 避免注意力稀释 +- 减少无关资源对当前推理的干扰 + +### 8.2 Execution state 到 skill session 的桥接 + +如果当前是 planner / execution waiting 状态,会尝试桥接成 skill session: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:1211) + +这解决的是: + +- planner 已经问到一半 +- 用户回复了字段 +- 但后续 hard skill 执行时又像“没收到” + +现在系统已经能把 execution waiting 中收集到的信息投影回 skill session。 + +### 8.3 子任务成功后的父任务回流 + +现在快照不只是“可恢复存档”,还带有父任务信息。 + +相关结构在: +- [execution_state.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/execution_state.go) + +新增字段包括: + +- `intent_id` +- `parent_intent_id` +- `resume_on_success` +- `resume_triggers` + +构建点在: +- [planner_runtime.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/planner_runtime.go:2214) + +执行成功后的回流点在: +- [skill_dispatcher.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_dispatcher.go) + +这解决的是: + +- 用户本来在 `trader_management:create` +- 中途去启用一个被禁用的交易所或模型 +- 子任务成功后,系统不再“断片” +- 会自动恢复父任务上下文,并继续提示主流程剩余缺失项 + +因此现在的 suspended snapshot 已更接近“带返回指针的任务栈”。 + +### 8.4 取消时的任务栈回溯清理 + +如果用户在子任务中途说: + +- `算了` +- `不改了` +- `换话题` + +系统现在不会只取消当前子任务就结束,而是会检查栈里是否还有父任务挂起。 + +如果存在父任务,会明确追问: + +- 当前子任务已经取消 +- 之前的父任务是否还要继续 +- 或者是否“一并取消” + +这样做是为了防止: + +- 父任务长期挂在栈底 +- 子任务被取消后无人接管 +- 最终形成僵尸任务和状态堆积 + +--- + +## 9. Trader create 为什么特殊重要 + +`trader_management:create` 是当前最复杂的 management flow 之一,因为它天然依赖另外三个 skill 的资源状态: + +- exchange +- model +- strategy + +因此它不是一个封闭 skill,而是一个“父 skill”。 + +用户在创建交易员时说: + +- 启用某个交易所 +- 换一个模型 +- 使用现有策略 + +这些都不应该默认被理解成新的平级 top-level 任务,而应优先理解成: + +- 为 `trader create` 补齐依赖 +- 然后继续主流程 + +目前这一层的 prompt 级理解已经通过统一 skill dependency summary 接入,但执行层的“修复依赖后自动回流主流程”还需要继续补强。 + +--- + +## 10. 名称和 ID 的连接 + +用户说的是自然语言名称,比如: + +- `test` +- `DeepSeek AI` +- `高频做空策略` +- `白开水` + +执行层需要的是稳定 ID。 + +当前这层连接主要做在: +- [skill_dispatcher.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_dispatcher.go) + +核心函数: + +- `hydrateCreateTraderSlotReferences` +- `findOptionByIDOrName` + +设计原则是: + +1. 用户层允许说名字 +2. 系统尽快解析出唯一对象 +3. 一旦唯一,就落成真实 ID +4. 展示给用户时仍然优先显示友好名字 + +这解决的是“确认文案看起来正确,但真正执行又说缺字段”的问题。 + +### 10.1 歧义引用澄清 + +除了“名字映射到 ID”,系统现在也开始处理“多个候选对象同名或近似”的情况。 + +相关逻辑在: + +- [skill_dispatcher.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_dispatcher.go) +- [skill_management_handlers.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_management_handlers.go) + +核心做法是: + +1. 若唯一命中,直接解析成 ID +2. 若多个候选同时命中,不再静默选择 +3. 统一返回澄清问题,让用户明确要操作哪一个对象 + +这比“猜一个”更安全,也避免了对象误绑。 + +--- + +## 10.2 用户级串行化 + +同一个用户可能在网络卡顿或前端重发的情况下,几乎同时发出两条修改消息。 + +为了避免: + +- 两条消息并发进入同一个 active flow +- extraction 结果交叉 merge +- `skillSession` / `ExecutionState` 变成缝合状态 + +现在 `thinkAndAct` 和 `thinkAndActStream` 已经在用户维度上做了串行化处理。 + +也就是说: + +- 同一个 `userID` +- 任意时刻只允许一条主消息进入 flow merge/execute 链路 + +这比只在单个 `save*` 调用上加锁更有效,因为它保护的是整条“读状态 -> 理解 -> merge -> 执行 -> 写状态”的事务链。 + +--- + +## 11. Tool 层约束 + +之前一个根问题是:上层像是“创建成功”了,但底层实际上没拿到完整必填字段。 + +现在部分 create 约束已经下沉到 tool 层: +- [tools.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/tools.go) + +当前明确加了必填约束的包括: + +- `model_management:create` +- `exchange_management:create` + +这意味着: + +- 不再只靠上层 prompt 判断“够不够建” +- tool 自己也会拒绝缺关键字段的 create + +这能防止“草稿像成功了,但对象其实是半残”的情况。 + +### 11.1 Tool 层安全硬隔离 + +安全不能只靠 Prompt。 + +因此现在 Tool 层已经补了两类后端硬边界: + +1. 敏感凭证永不明文返回 +说明: +- `toolGetModelConfigs` +- `toolGetExchangeConfigs` +- 以及对应 create / update 响应 + +都会先走安全视图,再做一层递归敏感字段剥离。 +也就是说,像 `api_key`、`secret_key`、`passphrase`、私钥这类字段,不会因为 LLM 被注入而通过 Tool 明文吐回去。 + +系统只允许返回类似: + +- `has_api_key` +- `has_secret_key` +- `has_passphrase` + +这种布尔存在性信息。 + +2. 交易执行必须通过会话级授权 +说明: +- `execute_trade` 不再只靠“模型说要下单”就能执行 +- Tool 层现在会检查当前请求上下文里的会话权限 +- 没有交易执行权限的 session,会被后端直接拒绝 + +这意味着即使 Prompt 被注入,模型生成了合法的 `execute_trade` 调用,只要当前 token/session 没有对应权限,后端仍然不会执行。 + +当前实现上,这条边界先采用: + +- 已认证会话 +- 明确的 session policy +- 服务端 `AllowTradeExecution` 开关 + +的组合约束。 + +也就是说,真正的安全边界现在开始下沉到了 Tool / Session / Server Policy,而不是停留在提示词层。 + +--- + +## 11.1 Planner 的多槽补齐策略 + +语义就绪检查把“准备不足”的请求挡回 planner 后,如果 planner 还是一轮只问一个槽位,用户体验会很差。 + +因此现在 planner prompt 已经明确被要求: + +- 优先一次性询问多个核心缺失字段 +- 在安全且常见的场景下,可以同时提出合理默认值 + +目标不是简单的“防止 hard skill 报错”,而是: + +- 让补槽更像一次有组织的表单引导 +- 减少挤牙膏式的一问一答 + +--- + +## 12. 为什么要保留 planner、workflow、hard skill 三层 + +当前不是所有请求都应该直接落到 hard skill。 + +### 12.1 Hard skill + +适合: + +- 结构明确 +- skill 明确 +- action 明确 +- 必填足够 + +### 12.2 Workflow + +适合: + +- 多个 management action 串联 +- 存在依赖关系 + +### 12.3 Planner + +适合: + +- 开放式目标 +- 需要先探索当前状态 +- 结构还不够稳定 + +当前的设计方向是: + +- LLM 先判断当前在和哪个上下文说话 +- 再决定 route +- 再进入 skill / workflow / planner + +而不是一开始就靠 hard skill 猜。 + +### 12.4 Planner 的人格与职责 + +Planner 现在不只是“拆步骤”的模块,也共享了同一份 `NOFXi` 顾问式系统前缀。 + +这意味着 Planner 在生成计划时,会优先遵守这些原则: + +- 先保证配置正确、安全、逻辑一致 +- 先做状态诊断,而不是机械执行 +- 缺信息时,优先组织更像顾问的多槽追问和默认值建议 + +因此 Planner 现在承担的是: + +- 任务澄清 +- 风险控制 +- 配置诊断 +- 计划生成 + +这也是为什么统一语义网关和 Planner 必须共用同一份系统前缀。 + +--- + +## 13. 前端聊天页的运行形态 + +前端聊天页之前的问题是: + +- 切页就 abort 流式请求 +- 正在生成的消息会消失 + +现在这部分已经调整成: + +- 流式回复由更全局的 runtime/store 托管 +- 站内切页不会立刻中断流 +- 已生成内容会保留 + +这让 Agent 更接近“后台持续回复”,而不是“仅页面内临时回复”。 + +--- + +## 14. 这套结构是怎么一步步收出来的 + +当前架构不是一次性设计出来的,而是沿着这些问题逐步收口: + +### 阶段 1:先把快照恢复链打通 + +目标: + +- 挂起任务可恢复 +- `target_snapshot_id` 真能驱动恢复 + +结果: + +- router、flow extraction、runtime 都开始理解 snapshot + +### 阶段 2:把状态续接和全局路由收成统一语义网关 + +目标: + +- 不再一层判断“是不是当前流程”,另一层再重新猜一遍 + +结果: + +- 先做 `continue_active / resume_snapshot / start_new / cancel / instant_reply` +- 再进入具体执行层 + +### 阶段 3:让 CurrentReferences 真正成为“参考书” + +目标: + +- 当前对象不能只是埋在 JSON 里 +- 要显式进入 prompt 决策 + +结果: + +- router、planner、classifier、extraction 都看当前引用对象 + +### 阶段 4:把 skill 说明和依赖说明收成单一真源 + +目标: + +- 不再在每层 prompt 写一份不同的 skill 描述 + +结果: + +- `buildManagementSkillContext` 成为统一入口 + +### 阶段 5:把名字和 ID 连接起来 + +目标: + +- 用户交互说名字 +- 系统执行用 ID + +结果: + +- draft -> resolved object -> ID 的链路更稳 + +--- + +## 15. 当前已经验证过的方向 + +当前已经补过定向测试的方向包括: + +- 顶层 router prompt 包含 management skill summary +- 顶层 router / one-pass gateway / planner prompt 共享顾问式系统前缀 +- 顶层/flow prompt 包含 management skill negative constraints +- 顶层 router prompt 包含 current reference summary +- active flow extraction prompt 包含 suspended snapshots +- `trader create` 的依赖说明进入统一 skill context +- semantic readiness 会把未准备好的 create 请求挡回 planner +- Tool 层不会明文返回配置秘钥,只返回存在性标记 +- `execute_trade` 必须通过会话级授权和服务端开关 +- 子任务成功后会自动恢复父任务上下文 +- 名称歧义会触发澄清,而不是静默命中 +- execution waiting state 能桥接回 skill session +- persistent reference memory 在 execution state 清掉后仍能命中当前对象 +- `model/exchange` create 的 tool 必填约束生效 + +相关测试文件主要包括: + +- [llm_intent_router_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/llm_intent_router_test.go) +- [skill_dispatcher_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_dispatcher_test.go) +- [skill_registry_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_registry_test.go) +- [config_tools_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/config_tools_test.go) + +另外,仓库里现在已经有一套“AI 对练”种子回放骨架: + +- [self_play_replay_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/self_play_replay_test.go) +- [agent_self_play_seed.zh-CN.json](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/docs/qa/fixtures/agent_self_play_seed.zh-CN.json) +- [AGENT_AI_SELF_PLAY.zh-CN.md](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/docs/qa/AGENT_AI_SELF_PLAY.zh-CN.md) + +它的用途是: + +- 让代码助手或大模型根据产品说明生成极端对话场景 +- 把这些场景写成 JSON fixture +- 用统一回放器批量喂给 `thinkAndAct` +- 再把暴露的问题沉淀为: + - Skill JSON 说明 + - Validator / Resolver / Readiness Gate + - 按需上下文注入规则 + +--- + +## 16. 当前仍需要继续收的点 + +虽然主链已经比之前完整很多,但还有几块需要继续收: + +1. `configure_strategy / configure_exchange / configure_model` 这类 action 的语义落地 +说明: +这些已经在动作语义上更清晰,但要继续让 LLM 和执行层稳定对齐。 + +2. 更完整的 create 约束下沉 +说明: +`strategy / trader` 的部分约束还可以继续更严格地下沉到执行层和 tool 层。 + +3. 更完整的跨 skill 依赖图 +说明: +现在重点收了 `trader create` 的依赖图,未来可以继续扩展到其他多 skill 依赖场景。 + +4. 歧义消除的 LLM 参与度 +说明: +当前歧义澄清、活性校验、父任务回溯已经有了规则级保护;后续可以继续让 LLM 参与“如何问得更自然、如何结合上下文缩小候选范围”。 + +5. 更细粒度的事务型状态版本控制 +说明: +当前已经做了用户级串行化,足以挡住同一用户的大部分并发污染;后续如果要支持更复杂的多端并发或后台异步写入,可以继续升级成显式版本号或乐观锁。 + +--- + +## 17. 一句话总结 + +当前 NOFXi Agent 已经从“多个局部 if-else 叠起来的 chat handler”,逐步收成了一套: + +- 统一语义网关 +- 快照恢复 +- 当前对象引用记忆 +- 单一真源 skill context +- skill/workflow/planner 分层执行 + +的任务型 Agent 架构。 + +它现在最核心的设计原则是: + +- 先判断用户在和哪个上下文说话 +- 再判断在当前上下文里要做什么 +- 再把自然语言解析成结构化状态 +- 最后由对应 skill/workflow/tool 去安全执行 diff --git a/docs/qa/AGENT_SKILL_ACCEPTANCE_CHECKLIST.zh-CN.md b/docs/qa/AGENT_SKILL_ACCEPTANCE_CHECKLIST.zh-CN.md new file mode 100644 index 00000000..97390544 --- /dev/null +++ b/docs/qa/AGENT_SKILL_ACCEPTANCE_CHECKLIST.zh-CN.md @@ -0,0 +1,272 @@ +# Agent 4 Skill 验收清单 + +本文档用于验收 Agent 对 4 个管理类 skill 的字段认知、工具调用和用户可见行为是否与页面编辑能力对齐。 + +当前范围: +- `model_management` +- `exchange_management` +- `trader_management` +- `strategy_management` + +验收目标: +- 页面上能手动改的核心字段,Agent 也能稳定改 +- Agent 能回答页面上可见的字段与选项 +- 模糊请求不会被硬塞进错误 skill +- 多字段一句话更新时,不会被窄动作截断 + +## 0. 前置条件 + +- 已完成登录 +- 后端已启动 +- 至少准备 1 条可编辑的模型、交易所、交易员、策略数据 +- 测试前如果有旧上下文,先在 Agent 会话里执行 `/clear` + +建议先跑自动化回归: + +```bash +go test ./agent -run 'Test(ManageModelToolSchemaExposesEditableFields|ManageExchangeToolSchemaExposesEditableFields|ManageTraderToolSchemaExposesAdvancedFields|ManageStrategyToolSchemaExposesFieldLevelConfig|ModelManagementManualEditableFieldsAreCoveredByAgent|ExchangeManagementManualEditableFieldsAreCoveredByAgent|TraderManagementManualEditableFieldsAreCoveredByAgent|StrategyManagementManualEditableFieldsAreCoveredByAgent|ExchangeManagementUpdateSupportsManualFields|ModelManagementThinkAndActSupportsCompositeFieldUpdates|TraderManagementUpdateSupportsAdvancedManualFields|StrategyManagementThinkAndActSupportsGridAndRiskFields)' +``` + +对应测试主要在: +- [skill_dispatcher_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/skill_dispatcher_test.go) +- [config_tools_test.go](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/agent/config_tools_test.go) + +## 1. 自动化覆盖基线 + +通过以下检查后,才进入手工验收: + +- [ ] 4 个 skill 的 tool schema 已暴露字段级参数 +- [ ] 4 个 skill 的 manual editable field 集合都被 agent 字段目录覆盖 +- [ ] `model` 支持一句话同时改 `enabled + custom_api_url + custom_model_name` +- [ ] `exchange` 支持一句话同时改 `account_name + hyperliquid_wallet_addr + testnet` +- [ ] `trader` 支持高级字段更新 +- [ ] `strategy` 支持 grid/risk 多字段更新 + +## 2. Model Skill + +页面参考: +- [ModelConfigModal.tsx](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/web/src/components/trader/ModelConfigModal.tsx) + +核心字段: +- `provider` +- `name` +- `api_key` +- `custom_api_url` +- `custom_model_name` +- `enabled` + +手工验收: + +- [ ] 说“列出我的模型配置”时,能列出当前模型 +- [ ] 说“这个模型的接口地址改成 xxx,模型名称改成 yyy,并且禁用”时,能一次成功更新 +- [ ] 说“这个模型有哪些字段能改”时,回答至少覆盖 `API Key / 接口地址 / 模型名称 / 启用状态` +- [ ] 说“把这个模型启用”时,不会误触发重命名流程 +- [ ] 说“把这个模型改成最好的”这类抽象诉求时,不应硬造字段值;应该解释或引导 + +通过标准: +- 回复文本明确说明已更新模型配置 +- 页面刷新后字段真实变化 +- 不出现“我还需要你明确要操作哪个对象”这种错误兜底 + +## 3. Exchange Skill + +页面参考: +- [ExchangeConfigModal.tsx](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/web/src/components/trader/ExchangeConfigModal.tsx) + +核心字段: +- 公共字段: + - `exchange_type` + - `account_name` + - `enabled` + - `testnet` +- CEX: + - `api_key` + - `secret_key` + - `passphrase` +- Hyperliquid: + - `api_key` + - `hyperliquid_wallet_addr` +- Aster: + - `aster_user` + - `aster_signer` + - `aster_private_key` +- Lighter: + - `lighter_wallet_addr` + - `lighter_api_key_private_key` + - `lighter_api_key_index` + +手工验收: + +- [ ] 说“把 Dex 的账户名改成 Dex Pro,Hyperliquid 钱包改成 0xabc,testnet 打开”时,能一次成功更新 +- [ ] 说“这个交易所有哪些字段能改”时,能按当前交易所类型回答差异字段 +- [ ] 说“把这个交易所禁用”时,不会误进入改名分支 +- [ ] 说“列出我的交易所配置”时,能读出当前配置 +- [ ] 对缺少必填凭证的创建请求,会明确指出缺哪一项,而不是模糊失败 + +通过标准: +- 回复文本明确说明已更新交易所配置 +- 页面刷新后对应字段真实变化 +- 不因为对象解析失败而掉到“请明确对象” + +## 4. Trader Skill + +页面参考: +- [TraderConfigModal.tsx](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/web/src/components/trader/TraderConfigModal.tsx) + +页面核心字段: +- `name` +- `ai_model_id` +- `exchange_id` +- `strategy_id` +- `is_cross_margin` +- `show_in_competition` +- `scan_interval_minutes` +- `initial_balance` + +Agent 扩展字段: +- `btc_eth_leverage` +- `altcoin_leverage` +- `trading_symbols` +- `custom_prompt` +- `override_base_prompt` +- `system_prompt_template` +- `use_ai500` +- `use_oi_top` + +手工验收: + +- [ ] 说“把交易员 A 切换到策略 B,扫描间隔改成 8 分钟,全仓关闭,竞技场显示关闭”时,能一次成功更新 +- [ ] 说“把高级交易员的 BTC/ETH 杠杆改成 8,山寨币杠杆改成 4,交易对改成 BTC、ETH,自定义 prompt 改成 xxx,启用 AI500”时,能成功更新 +- [ ] 说“这个交易员有哪些字段能改”时,至少能回答页面核心字段和 Agent 扩展字段 +- [ ] 说“启动这个交易员”时,仍会保留高风险确认链路 +- [ ] 说“为什么我的交易员不交易”时,仍能走诊断 skill,不会被错误识别成 update + +通过标准: +- 回复文本明确说明更新了交易员配置或绑定 +- 页面刷新或查询结果能看到真实变化 +- `交易对` 提取不会误吞后半句自然语言 + +## 5. Strategy Skill + +页面参考: +- [StrategyStudioPage.tsx](/Users/zheweifang/Desktop/Nofx2/nofxi-dev/web/src/pages/StrategyStudioPage.tsx) + +编辑器模块: +- `grid_config` +- `coin_source` +- `indicators` +- `risk_control` +- `prompt_sections` +- `custom_prompt` +- `publish_settings` + +重点字段: +- 元信息: + - `name` + - `description` + - `strategy_type` + - `is_public` + - `config_visible` +- Grid: + - `symbol` + - `grid_count` + - `total_investment` + - `upper_price` + - `lower_price` + - `use_atr_bounds` + - `atr_multiplier` + - `distribution` + - `enable_direction_adjust` + - `direction_bias_ratio` + - `max_drawdown_pct` + - `stop_loss_pct` + - `daily_loss_limit_pct` + - `use_maker_only` +- Coin source: + - `source_type` + - `static_coins` + - `excluded_coins` + - `use_ai500` + - `ai500_limit` + - `use_oi_top` + - `oi_top_limit` + - `use_oi_low` + - `oi_low_limit` +- Risk: + - `max_positions` + - `min_confidence` + - `min_risk_reward_ratio` + - `btceth_max_leverage` + - `altcoin_max_leverage` + - `btceth_max_position_value_ratio` + - `altcoin_max_position_value_ratio` + - `max_margin_usage` + - `min_position_size` +- Indicators / timeframe: + - `primary_timeframe` + - `primary_count` + - `selected_timeframes` + - `ema_periods` + - `rsi_periods` + - `atr_periods` + - `boll_periods` + - `enable_ema` + - `enable_macd` + - `enable_rsi` + - `enable_atr` + - `enable_boll` + - `enable_volume` + - `enable_oi` + - `enable_funding_rate` +- Prompt: + - `role_definition` + - `trading_frequency` + - `entry_standards` + - `decision_process` + - `custom_prompt` + +手工验收: + +- [ ] 说“把策略 A 改成网格策略,网格数量改成 14,ATR 倍数改成 2.5,最大保证金使用率改成 0.6”时,能一次成功更新 +- [ ] 说“把选币来源改成静态,静态币改成 BTC、ETH,排除 DOGE,AI500 关闭”时,能成功更新 +- [ ] 说“选币来源有哪些”时,能回答当前面板的来源类型与相关选项,而不是重复草稿摘要 +- [ ] 说“这个策略里面的参数和 prompt 分别是什么样的”时,能走 explain/detail,不会误更新 +- [ ] 说“帮我创建一个不亏钱的策略”这类抽象请求时,不应直接强绑到字段创建;应该回退 planner 或引导细化 + +通过标准: +- 回复文本明确说明已更新策略参数或进入合理引导 +- Strategy Studio 刷新后真实反映更新 +- 不会把开放式目标误当作已可执行的精确配置 + +## 6. 跨 Skill 语义验收 + +- [ ] 模糊输入先过统一语义网关,再决定 `continue_active / resume_snapshot / start_new` +- [ ] 一个 skill 进行中时,问页面字段选项,优先走 explain,不要硬落 execute +- [ ] 开放式目标型请求在参数不足时,优先回 planner,不要强行进 hard skill +- [ ] 同一句话改多个字段时,不会只改其中一个窄字段 +- [ ] `/clear` 后,旧的 skill session / workflow / execution state / snapshots 都被清空 +- [ ] 切回旧话题时,snapshot restore 能恢复到正确对象,而不是凭 heuristics 误接 + +## 7. 回归记录模板 + +每次验收建议记录: + +- 日期: +- 提交版本: +- 后端 PID: +- 前端地址: +- 本轮执行人: + +逐项记录: +- 用例: +- 用户原话: +- 预期: +- 实际: +- 是否通过: +- 备注: + +## 8. 当前结论口径 + +当本文档第 1 节自动化基线和第 2-6 节核心手工项全部通过后,才建议对外宣称: + +“Agent 对 4 个 skill 已基本对齐当前页面可编辑能力,并具备稳定的 explain / execute / planner fallback 行为。” diff --git a/docs/qa/fixtures/agent_self_play_seed.zh-CN.json b/docs/qa/fixtures/agent_self_play_seed.zh-CN.json new file mode 100644 index 00000000..7acf7a09 --- /dev/null +++ b/docs/qa/fixtures/agent_self_play_seed.zh-CN.json @@ -0,0 +1,38 @@ +{ + "scenarios": [ + { + "name": "trader_create_reports_all_missing_prereqs", + "desc": "空白环境下创建交易员,应一次性报告所有必填槽位和依赖缺口。", + "turns": [ + { + "user": "帮我创建一个交易员", + "want_all": ["名称", "交易所", "模型", "策略", "当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"] + } + ] + }, + { + "name": "strategy_update_risk_control_clamp_requires_acceptance", + "desc": "策略参数越界时,应先给出风控收敛说明,再等待确认应用。", + "setup": [ + { + "tool": "manage_strategy", + "args": { + "action": "create", + "name": "风险策略", + "lang": "zh" + } + } + ], + "turns": [ + { + "user": "把风险策略的杠杆改成100", + "want_any": ["手动面板允许的范围", "按风控范围收敛"] + }, + { + "user": "确认应用", + "want_any": ["确认应用", "风控范围", "已更新策略参数"] + } + ] + } + ] +} diff --git a/main.go b/main.go index dfd74c88..f3e0e4d5 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,18 @@ package main import ( "log/slog" - "nofx/api" nofxiagent "nofx/agent" + "nofx/api" "nofx/auth" "nofx/config" "nofx/crypto" "nofx/logger" "nofx/manager" - "nofx/telemetry" _ "nofx/mcp/payment" _ "nofx/mcp/provider" "nofx/store" "nofx/telegram" + "nofx/telemetry" "os" "os/signal" "path/filepath" @@ -121,10 +121,10 @@ func main() { status = "✅ Running" } idShort := t.ID - if len(idShort) > 8 { - idShort = idShort[:8] - } - logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", + if len(idShort) > 8 { + idShort = idShort[:8] + } + logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", t.Name, idShort, status, t.AIModelID, t.ExchangeID) } } @@ -137,20 +137,19 @@ func main() { telegramReloadCh := make(chan struct{}, 1) server.SetTelegramReloadCh(telegramReloadCh) + // Start the NOFXi web agent on top of the current dev branch services. + nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default()) + agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default()) + server.RegisterAgentHandler(agentWeb) + nofxiAgent.Start() + defer nofxiAgent.Stop() + go func() { if err := server.Start(); err != nil { logger.Fatalf("❌ Failed to start API server: %v", err) } }() - // Start the NOFXi web agent on top of the current dev branch services. - nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default()) - nofxiAgent.Start() - defer nofxiAgent.Stop() - - agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default()) - server.RegisterAgentHandler(agentWeb) - // Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured) go telegram.Start(cfg, st, telegramReloadCh) @@ -169,7 +168,6 @@ func main() { } logger.Info("✅ HTTP server stopped") - nofxiAgent.Stop() logger.Info("✅ NOFXi agent stopped") // Stop all traders diff --git a/store/ai_model.go b/store/ai_model.go index 7cd08e2a..f991d540 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -5,6 +5,7 @@ import ( "fmt" "nofx/crypto" "nofx/logger" + "os" "strings" "time" @@ -18,16 +19,16 @@ type AIModelStore struct { // AIModel AI model configuration type AIModel struct { - ID string `gorm:"primaryKey" json:"id"` - UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"` - Name string `gorm:"not null" json:"name"` - Provider string `gorm:"not null" json:"provider"` - Enabled bool `gorm:"default:false" json:"enabled"` + ID string `gorm:"primaryKey" json:"id"` + UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"` + Name string `gorm:"not null" json:"name"` + Provider string `gorm:"not null" json:"provider"` + Enabled bool `gorm:"default:false" json:"enabled"` APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"` - CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"` - CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"` + CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AIModel) TableName() string { return "ai_models" } @@ -145,32 +146,64 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) { } func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) { - var model AIModel - err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true). + var models []AIModel + err := s.db.Where("user_id = ? AND enabled = ?", userID, true). Order("updated_at DESC, id ASC"). - First(&model).Error + Find(&models).Error if err != nil { return nil, err } - return &model, nil + for i := range models { + if hasUsableAPIKey(models[i]) { + return &models[i], nil + } + } + return nil, gorm.ErrRecordNotFound } // GetAnyEnabled returns the first enabled AI model across all users. // Used by single-user features (e.g. Telegram bot) that need any working LLM client. func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) { - var model AIModel - err := s.db.Where("enabled = ? AND api_key != ''", true). + var models []AIModel + err := s.db.Where("enabled = ?", true). Order("updated_at DESC, id ASC"). - First(&model).Error + Find(&models).Error if err != nil { return nil, err } - return &model, nil + for i := range models { + if hasUsableAPIKey(models[i]) { + return &models[i], nil + } + } + return nil, gorm.ErrRecordNotFound +} + +func hasUsableAPIKey(model AIModel) bool { + if strings.TrimSpace(string(model.APIKey)) != "" { + return true + } + envKeyByProvider := map[string]string{ + "deepseek": "DEEPSEEK_API_KEY", + "openai": "OPENAI_API_KEY", + "claude": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", + "grok": "XAI_API_KEY", + "kimi": "MOONSHOT_API_KEY", + "minimax": "MINIMAX_API_KEY", + "qwen": "DASHSCOPE_API_KEY", + } + envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))] + return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != "" } // Update updates AI model, creates if not exists // IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten) func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { + return s.UpdateWithName(userID, id, "", enabled, apiKey, customAPIURL, customModelName) +} + +func (s *AIModelStore) UpdateWithName(userID, id, name string, enabled bool, apiKey, customAPIURL, customModelName string) error { // Try exact ID match first var existingModel AIModel err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error @@ -182,6 +215,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI "custom_model_name": customModelName, "updated_at": time.Now().UTC(), } + if strings.TrimSpace(name) != "" { + updates["name"] = strings.TrimSpace(name) + } // If apiKey is not empty, update it (encryption handled by crypto.EncryptedString) if apiKey != "" { updates["api_key"] = crypto.EncryptedString(apiKey) @@ -200,6 +236,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI "custom_model_name": customModelName, "updated_at": time.Now().UTC(), } + if strings.TrimSpace(name) != "" { + updates["name"] = strings.TrimSpace(name) + } if apiKey != "" { updates["api_key"] = crypto.EncryptedString(apiKey) } @@ -218,31 +257,35 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI } } - // Try to get name from existing model with same provider + // Try to get a sensible default name from an existing model with the same provider. var refModel AIModel - var name string + defaultName := "" if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil { - name = refModel.Name + defaultName = refModel.Name } else { if provider == "deepseek" { - name = "DeepSeek AI" + defaultName = "DeepSeek AI" } else if provider == "qwen" { - name = "Qwen AI" + defaultName = "Qwen AI" } else { - name = provider + " AI" + defaultName = provider + " AI" } } + finalName := strings.TrimSpace(name) + if finalName == "" { + finalName = strings.TrimSpace(defaultName) + } newModelID := id if id == provider { newModelID = fmt.Sprintf("%s_%s", userID, provider) } - logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name) + logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, finalName) newModel := &AIModel{ ID: newModelID, UserID: userID, - Name: name, + Name: finalName, Provider: provider, Enabled: enabled, APIKey: crypto.EncryptedString(apiKey), diff --git a/store/strategy.go b/store/strategy.go index 8860e8fe..7d8c4d73 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -17,6 +17,19 @@ const ( MaxTimeframes = 4 MinKlineCount = 10 MaxKlineCount = 30 + MinLeverage = 1 + MaxBTCETHLeverage = 20 + MaxAltLeverage = 20 + MinPositionRatio = 0.5 + MaxPositionRatio = 10.0 + MinRiskReward = 1.0 + MaxRiskReward = 10.0 + MinMarginUsage = 0.1 + MaxMarginUsage = 1.0 + MinPositionSize = 10.0 + MaxPositionSize = 1000.0 + MinConfidence = 50 + MaxConfidence = 100 ) // ClampLimits enforces product-level limits on strategy config to prevent token overflow. @@ -54,10 +67,143 @@ func (c *StrategyConfig) ClampLimits() { } // Clamp max positions + if c.RiskControl.MaxPositions < 1 { + c.RiskControl.MaxPositions = 1 + } if c.RiskControl.MaxPositions > MaxPositions { c.RiskControl.MaxPositions = MaxPositions } + // Clamp leverage limits to the same bounds as the manual config UI. + if c.RiskControl.BTCETHMaxLeverage < MinLeverage { + c.RiskControl.BTCETHMaxLeverage = MinLeverage + } + if c.RiskControl.BTCETHMaxLeverage > MaxBTCETHLeverage { + c.RiskControl.BTCETHMaxLeverage = MaxBTCETHLeverage + } + if c.RiskControl.AltcoinMaxLeverage < MinLeverage { + c.RiskControl.AltcoinMaxLeverage = MinLeverage + } + if c.RiskControl.AltcoinMaxLeverage > MaxAltLeverage { + c.RiskControl.AltcoinMaxLeverage = MaxAltLeverage + } + + // Clamp position value ratio limits. + if c.RiskControl.BTCETHMaxPositionValueRatio < MinPositionRatio { + c.RiskControl.BTCETHMaxPositionValueRatio = MinPositionRatio + } + if c.RiskControl.BTCETHMaxPositionValueRatio > MaxPositionRatio { + c.RiskControl.BTCETHMaxPositionValueRatio = MaxPositionRatio + } + if c.RiskControl.AltcoinMaxPositionValueRatio < MinPositionRatio { + c.RiskControl.AltcoinMaxPositionValueRatio = MinPositionRatio + } + if c.RiskControl.AltcoinMaxPositionValueRatio > MaxPositionRatio { + c.RiskControl.AltcoinMaxPositionValueRatio = MaxPositionRatio + } + + // Clamp risk parameters and entry requirements. + if c.RiskControl.MinRiskRewardRatio < MinRiskReward { + c.RiskControl.MinRiskRewardRatio = MinRiskReward + } + if c.RiskControl.MinRiskRewardRatio > MaxRiskReward { + c.RiskControl.MinRiskRewardRatio = MaxRiskReward + } + if c.RiskControl.MaxMarginUsage < MinMarginUsage { + c.RiskControl.MaxMarginUsage = MinMarginUsage + } + if c.RiskControl.MaxMarginUsage > MaxMarginUsage { + c.RiskControl.MaxMarginUsage = MaxMarginUsage + } + if c.RiskControl.MinPositionSize < MinPositionSize { + c.RiskControl.MinPositionSize = MinPositionSize + } + if c.RiskControl.MinPositionSize > MaxPositionSize { + c.RiskControl.MinPositionSize = MaxPositionSize + } + if c.RiskControl.MinConfidence < MinConfidence { + c.RiskControl.MinConfidence = MinConfidence + } + if c.RiskControl.MinConfidence > MaxConfidence { + c.RiskControl.MinConfidence = MaxConfidence + } +} + +// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config. +// Nested objects are merged recursively so omitted fields keep their previous values. +func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) { + baseJSON, err := json.Marshal(base) + if err != nil { + return StrategyConfig{}, err + } + + var mergedMap map[string]any + if err := json.Unmarshal(baseJSON, &mergedMap); err != nil { + return StrategyConfig{}, err + } + + mergeJSONMaps(mergedMap, patch) + + mergedJSON, err := json.Marshal(mergedMap) + if err != nil { + return StrategyConfig{}, err + } + + var merged StrategyConfig + if err := json.Unmarshal(mergedJSON, &merged); err != nil { + return StrategyConfig{}, err + } + return merged, nil +} + +func mergeJSONMaps(dst, src map[string]any) { + for key, srcVal := range src { + srcMap, srcIsMap := srcVal.(map[string]any) + dstMap, dstIsMap := dst[key].(map[string]any) + if srcIsMap && dstIsMap { + mergeJSONMaps(dstMap, srcMap) + continue + } + dst[key] = srcVal + } +} + +func StrategyClampWarnings(before, after StrategyConfig, lang string) []string { + if lang != "zh" { + lang = "en" + } + warnings := make([]string, 0, 8) + appendInt := func(labelZH, labelEN string, from, to int) { + if from == to { + return + } + if lang == "zh" { + warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to)) + return + } + warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to)) + } + appendFloat := func(labelZH, labelEN string, from, to float64) { + if from == to { + return + } + if lang == "zh" { + warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to)) + return + } + warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to)) + } + + appendInt("最大持仓数", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions) + appendInt("BTC/ETH 最大杠杆", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage) + appendInt("山寨币最大杠杆", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage) + appendFloat("BTC/ETH 最大仓位价值倍数", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio) + appendFloat("山寨币最大仓位价值倍数", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio) + appendFloat("最小盈亏比", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio) + appendFloat("最大保证金使用率", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage) + appendFloat("最小开仓金额", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize) + appendInt("最低置信度", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence) + return warnings } // StrategyStore strategy storage diff --git a/store/trader.go b/store/trader.go index 8b983baa..9a4bd780 100644 --- a/store/trader.go +++ b/store/trader.go @@ -110,12 +110,20 @@ func (s *TraderStore) Update(trader *Trader) error { trader.ID, trader.Name, trader.AIModelID, trader.StrategyID) updates := map[string]interface{}{ - "name": trader.Name, - "ai_model_id": trader.AIModelID, - "exchange_id": trader.ExchangeID, - "strategy_id": trader.StrategyID, - "is_cross_margin": trader.IsCrossMargin, - "show_in_competition": trader.ShowInCompetition, + "name": trader.Name, + "ai_model_id": trader.AIModelID, + "exchange_id": trader.ExchangeID, + "strategy_id": trader.StrategyID, + "is_cross_margin": trader.IsCrossMargin, + "show_in_competition": trader.ShowInCompetition, + "btc_eth_leverage": trader.BTCETHLeverage, + "altcoin_leverage": trader.AltcoinLeverage, + "trading_symbols": trader.TradingSymbols, + "use_coin_pool": trader.UseAI500, + "use_oi_top": trader.UseOITop, + "custom_prompt": trader.CustomPrompt, + "override_base_prompt": trader.OverrideBasePrompt, + "system_prompt_template": trader.SystemPromptTemplate, } // Only update these if > 0 diff --git a/store/visibility.go b/store/visibility.go new file mode 100644 index 00000000..e37d6c43 --- /dev/null +++ b/store/visibility.go @@ -0,0 +1,48 @@ +package store + +import "strings" + +func IsVisibleAIModel(model *AIModel) bool { + if model == nil { + return false + } + return model.Enabled || + strings.TrimSpace(string(model.APIKey)) != "" || + strings.TrimSpace(model.CustomAPIURL) != "" || + strings.TrimSpace(model.CustomModelName) != "" +} + +func IsVisibleExchange(exchange *Exchange) bool { + if exchange == nil { + return false + } + return exchange.Enabled || + strings.TrimSpace(string(exchange.APIKey)) != "" || + strings.TrimSpace(string(exchange.SecretKey)) != "" || + strings.TrimSpace(string(exchange.Passphrase)) != "" || + strings.TrimSpace(exchange.HyperliquidWalletAddr) != "" || + strings.TrimSpace(exchange.AsterUser) != "" || + strings.TrimSpace(exchange.AsterSigner) != "" || + strings.TrimSpace(string(exchange.AsterPrivateKey)) != "" || + strings.TrimSpace(exchange.LighterWalletAddr) != "" || + strings.TrimSpace(string(exchange.LighterPrivateKey)) != "" || + strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)) != "" || + exchange.LighterAPIKeyIndex != 0 +} + +func IsVisibleTrader(trader *Trader) bool { + if trader == nil { + return false + } + return strings.TrimSpace(trader.Name) != "" && + strings.TrimSpace(trader.AIModelID) != "" && + strings.TrimSpace(trader.ExchangeID) != "" +} + +func IsVisibleStrategy(strategy *Strategy) bool { + if strategy == nil { + return false + } + return strings.TrimSpace(strategy.Name) != "" +} + diff --git a/web/node_modules b/web/node_modules new file mode 120000 index 00000000..b6f5f7e8 --- /dev/null +++ b/web/node_modules @@ -0,0 +1 @@ +/Users/zheweifang/Desktop/Nofx2/nofxi/web/node_modules \ No newline at end of file diff --git a/web/src/components/agent/AgentStepPanel.tsx b/web/src/components/agent/AgentStepPanel.tsx index 9e999bb7..d2bf6202 100644 --- a/web/src/components/agent/AgentStepPanel.tsx +++ b/web/src/components/agent/AgentStepPanel.tsx @@ -1,9 +1,4 @@ -interface AgentStep { - id: string - label: string - status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned' - detail?: string -} +import type { AgentStep } from '../../types/agent' interface AgentStepPanelProps { steps?: AgentStep[] diff --git a/web/src/components/agent/ChatMessages.tsx b/web/src/components/agent/ChatMessages.tsx index 17b5de82..a53cc4c1 100644 --- a/web/src/components/agent/ChatMessages.tsx +++ b/web/src/components/agent/ChatMessages.tsx @@ -2,22 +2,7 @@ import { forwardRef } from 'react' import { motion } from 'framer-motion' import { AgentStepPanel } from './AgentStepPanel' import { renderMessageContent } from './MessageRenderer' - -interface AgentStep { - id: string - label: string - status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned' - detail?: string -} - -interface Message { - id: string - role: 'user' | 'bot' - text: string - time: string - streaming?: boolean - steps?: AgentStep[] -} +import type { AgentMessage as Message, AgentStep } from '../../types/agent' interface ChatMessagesProps { messages: Message[] diff --git a/web/src/components/agent/MarketTicker.tsx b/web/src/components/agent/MarketTicker.tsx index 5e6e7430..6a6f7562 100644 --- a/web/src/components/agent/MarketTicker.tsx +++ b/web/src/components/agent/MarketTicker.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { httpClient } from '../../lib/httpClient' // icons reserved for future use interface TickerData { @@ -25,8 +26,11 @@ export function MarketTicker() { const fetchTickers = async () => { try { // Batch fetch: single API call for all symbols - const res = await fetch(`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`) - const data = await res.json() + const res = await httpClient.request( + `/api/agent/tickers?symbols=${SYMBOLS.join(',')}`, + { silent: true } + ) + const data = res.data const map: Record = {} if (Array.isArray(data)) { data.forEach((r: TickerData) => { @@ -49,7 +53,11 @@ export function MarketTicker() { const formatPrice = (price: string) => { const n = parseFloat(price) - if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + if (n >= 1000) + return n.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) if (n >= 1) return n.toFixed(2) return n.toFixed(4) } @@ -76,13 +84,15 @@ export function MarketTicker() { height: 56, }} > -
+
))}