diff --git a/agent/active_session.go b/agent/active_session.go deleted file mode 100644 index 8483392b..00000000 --- a/agent/active_session.go +++ /dev/null @@ -1,272 +0,0 @@ -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 - case "exchange": - value, ok := s.CollectedFields["exchange_id"] - return ok && strings.TrimSpace(fmt.Sprint(value)) != "" - case "model": - for _, key := range []string{"model_id", "ai_model_id"} { - if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { - return true - } - } - return false - case "strategy": - value, ok := s.CollectedFields["strategy_id"] - return ok && strings.TrimSpace(fmt.Sprint(value)) != "" - 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 deleted file mode 100644 index 16e39057..00000000 --- a/agent/agent.go +++ /dev/null @@ -1,1158 +0,0 @@ -// Package agent implements the NOFXi Agent Core. -// -// Architecture: ALL user messages go to the LLM. The LLM understands intent -// and calls tools to execute actions. No regex routing, no pattern matching. -// The LLM IS the brain — just like how OpenClaw works. -package agent - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "sort" - "strconv" - "strings" - "sync" - "time" - - gethcrypto "github.com/ethereum/go-ethereum/crypto" - - "nofx/manager" - "nofx/market" - "nofx/mcp" - "nofx/store" - "nofx/wallet" -) - -type Agent struct { - traderManager *manager.TraderManager - store *store.Store - aiClient mcp.AIClient - config *Config - sentinel *Sentinel - brain *Brain - scheduler *Scheduler - logger *slog.Logger - 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"` - AllowTradeExecution bool `json:"allow_trade_execution"` - BriefTimes []int `json:"brief_times"` -} - -var ( - agentWalletAddressFromPrivateKey = walletAddressFromPrivateKey - agentQueryUSDCBalanceCached = wallet.QueryUSDCBalanceCached -) - -func DefaultConfig() *Config { - return &Config{ - Language: "zh", - WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"}, - EnableBriefs: true, - EnableNews: true, - EnableSentinel: true, - AllowTradeExecution: true, - BriefTimes: []int{8, 20}, - } -} - -func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent { - if cfg == nil { - cfg = DefaultConfig() - } - 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 } - -func (a *Agent) ensureHistory() { - if a.history == nil { - a.history = newChatHistory(100) - } -} - -func (a *Agent) log() *slog.Logger { - if a != nil && a.logger != nil { - return a.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") -} - -func (a *Agent) ensureAIClientForStoreUser(storeUserID string) { - if storeUserID == "" { - storeUserID = "default" - } - if a.store != nil { - if client, modelName, ok := a.loadAIClientFromStoreUser(storeUserID); ok { - a.aiClient = client - a.log().Info("agent AI client ready", "store_user_id", storeUserID, "model", modelName) - return - } - } - if a.aiClient != nil { - a.log().Warn("clearing stale AI client for store user", "store_user_id", storeUserID) - a.aiClient = nil - } - a.log().Warn("no AI client — agent will have limited capabilities", "store_user_id", storeUserID) -} - -func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, string, bool) { - if a.store == nil { - a.log().Warn("cannot load AI client: store unavailable", "store_user_id", storeUserID) - return nil, "", false - } - - 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 - } - candidates := rankAgentModelCandidates(models) - for _, candidate := range candidates { - model := candidate.model - if model == nil || !model.Enabled || !agentModelHasUsableAPIKey(model) { - continue - } - - 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), - "prefer_model_with_balance", candidate.preferModelWithBalance, - "wallet_balance_usdc", candidate.balanceUSDC, - ) - - apiKey := strings.TrimSpace(string(model.APIKey)) - customAPIURL := strings.TrimSpace(model.CustomAPIURL) - modelName := strings.TrimSpace(model.CustomModelName) - provider := strings.ToLower(strings.TrimSpace(model.Provider)) - - // Use the provider registry for providers like claw402 that have their own - // client implementation (x402 payment, custom auth, etc.). - if client := mcp.NewAIClientByProvider(provider); client != nil { - if modelName == "" { - modelName = model.ID - } - client.SetAPIKey(apiKey, customAPIURL, modelName) - a.log().Info("agent AI client selected (provider registry)", "store_user_id", candidateUserID, "model_id", model.ID, "provider", provider, "model", modelName) - return client, modelName, true - } - - customAPIURL, modelName = resolveModelRuntimeConfig(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", 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().Warn("no enabled AI model found for store user", "store_user_id", storeUserID) - return nil, "", false -} - -type agentModelCandidate struct { - model *store.AIModel - preferModelWithBalance bool - balanceUSDC float64 -} - -func rankAgentModelCandidates(models []*store.AIModel) []agentModelCandidate { - candidates := make([]agentModelCandidate, 0, len(models)) - for _, model := range models { - if model == nil { - continue - } - candidate := agentModelCandidate{model: model} - if balance, ok := agentModelUSDCBalance(model); ok && balance > 0 { - candidate.preferModelWithBalance = true - candidate.balanceUSDC = balance - } - candidates = append(candidates, candidate) - } - - sort.SliceStable(candidates, func(i, j int) bool { - left := candidates[i] - right := candidates[j] - if left.preferModelWithBalance != right.preferModelWithBalance { - return left.preferModelWithBalance - } - if left.balanceUSDC != right.balanceUSDC { - return left.balanceUSDC > right.balanceUSDC - } - leftUpdatedAt := time.Time{} - rightUpdatedAt := time.Time{} - if left.model != nil { - leftUpdatedAt = left.model.UpdatedAt - } - if right.model != nil { - rightUpdatedAt = right.model.UpdatedAt - } - if !leftUpdatedAt.Equal(rightUpdatedAt) { - return leftUpdatedAt.After(rightUpdatedAt) - } - leftID := "" - rightID := "" - if left.model != nil { - leftID = left.model.ID - } - if right.model != nil { - rightID = right.model.ID - } - return leftID < rightID - }) - - return candidates -} - -func agentModelUSDCBalance(model *store.AIModel) (float64, bool) { - if model == nil || !agentProviderSupportsUSDCBalance(model.Provider) { - return 0, false - } - privateKey := strings.TrimSpace(string(model.APIKey)) - if privateKey == "" { - return 0, false - } - walletAddress, err := agentWalletAddressFromPrivateKey(privateKey) - if err != nil || strings.TrimSpace(walletAddress) == "" { - return 0, false - } - balance, err := agentQueryUSDCBalanceCached(walletAddress) - if err != nil || balance <= 0 { - return 0, false - } - return balance, true -} - -func agentProviderSupportsUSDCBalance(provider string) bool { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "claw402", "blockrun-base": - return true - default: - return false - } -} - -func agentModelHasUsableAPIKey(model *store.AIModel) bool { - if model == nil { - return false - } - 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 walletAddressFromPrivateKey(privateKey string) (string, error) { - key := strings.TrimSpace(privateKey) - if !strings.HasPrefix(key, "0x") { - return "", fmt.Errorf("private key must start with 0x") - } - if len(key) != 66 { - return "", fmt.Errorf("private key must be 66 characters") - } - - privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x")) - if err != nil { - return "", err - } - - return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil -} - -func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) { - provider = strings.ToLower(strings.TrimSpace(provider)) - customAPIURL = strings.TrimSpace(customAPIURL) - customModelName = strings.TrimSpace(customModelName) - fallbackModelID = strings.TrimSpace(fallbackModelID) - - type providerDefaults struct { - url string - model string - } - defaults := map[string]providerDefaults{ - "deepseek": {url: "https://api.deepseek.com/v1", model: "deepseek-chat"}, - "qwen": {url: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3-max"}, - "openai": {url: "https://api.openai.com/v1", model: "gpt-5.2"}, - "claude": {url: "https://api.anthropic.com/v1", model: "claude-opus-4-6"}, - "gemini": {url: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3-pro-preview"}, - "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 == "" { - if cfg, ok := defaults[provider]; ok { - customAPIURL = cfg.url - } - } - if customModelName == "" { - if cfg, ok := defaults[provider]; ok { - customModelName = cfg.model - } - } - if customModelName == "" { - customModelName = fallbackModelID - } - return customAPIURL, customModelName -} - -func (a *Agent) Start() { - a.logger.Info("starting NOFXi agent...") - a.EnsureAIClient() - - if a.config.EnableSentinel { - a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger) - a.sentinel.Start() - } - a.brain = NewBrain(a, a.logger) - if a.config.EnableNews { - a.brain.StartNewsScan(5 * time.Minute) - } - if a.config.EnableBriefs { - a.brain.StartMarketBriefs(a.config.BriefTimes) - } - a.scheduler = NewScheduler(a, a.logger) - a.scheduler.Start(context.Background()) - - a.logger.Info("NOFXi agent is online 🚀") -} - -func (a *Agent) Stop() { - // Signal all background goroutines (e.g. chat-history-cleanup) to exit. - select { - case <-a.stopCh: - // Already closed - default: - close(a.stopCh) - } - if a.sentinel != nil { - a.sentinel.Stop() - } - if a.brain != nil { - a.brain.Stop() - } - if a.scheduler != nil { - a.scheduler.Stop() - } -} - -// HandleMessage — the core. Everything goes through the LLM. -func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) { - a.EnsureAIClient() - return a.handleMessageForStoreUser(ctx, "default", userID, text) -} - -// HandleMessageForStoreUser is like HandleMessage but stores setup artifacts -// (exchange/model) under the provided authenticated store user ID. -func (a *Agent) HandleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) { - return a.handleMessageForStoreUser(ctx, storeUserID, userID, text) -} - -func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) { - a.ensureAIClientForStoreUser(storeUserID) - - lang := a.config.Language - if strings.HasPrefix(text, "[lang:") { - if end := strings.Index(text, "] "); end > 0 { - lang = text[6:end] - text = text[end+2:] - } - } - - a.logger.Info("message", "user_id", userID, "text", text) - - // Only keep a tiny command surface outside the planner. - if text == "/status" { - return a.handleStatus(lang), nil - } - if text == "/clear" { - a.clearConversationState(userID) - if lang == "zh" { - return "🧹 对话记忆已清除。", nil - } - return "🧹 Conversation history cleared.", nil - } - if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled { - return reply, nil - } - if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled { - return reply, nil - } - - // Everything else goes through the planner and tool system. - return a.thinkAndAct(ctx, storeUserID, userID, lang, text) -} - -// HandleMessageStream is like HandleMessage but streams the final LLM response via SSE. -// onEvent is called with (eventType, data) — see StreamEvent* constants. -// Non-streamable responses (commands, trade confirmations) return immediately without events. -func (a *Agent) HandleMessageStream(ctx context.Context, userID int64, text string, onEvent func(event, data string)) (string, error) { - a.EnsureAIClient() - return a.handleMessageStreamForStoreUser(ctx, "default", userID, text, onEvent) -} - -// HandleMessageStreamForStoreUser mirrors HandleMessageForStoreUser for SSE responses. -func (a *Agent) HandleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) { - return a.handleMessageStreamForStoreUser(ctx, storeUserID, userID, text, onEvent) -} - -func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) { - a.ensureAIClientForStoreUser(storeUserID) - - lang := a.config.Language - if strings.HasPrefix(text, "[lang:") { - if end := strings.Index(text, "] "); end > 0 { - lang = text[6:end] - text = text[end+2:] - } - } - - a.logger.Info("message (stream)", "user_id", userID, "text", text) - - if text == "/status" { - return a.handleStatus(lang), nil - } - if text == "/clear" { - a.clearConversationState(userID) - if lang == "zh" { - return "🧹 对话记忆已清除。", nil - } - return "🧹 Conversation history cleared.", nil - } - if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled { - if onEvent != nil { - emitStreamText(onEvent, reply) - } - return reply, nil - } - if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled { - if onEvent != nil { - 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" - StreamEventPlan = "plan" - StreamEventStepStart = "step_start" - StreamEventStepComplete = "step_complete" - StreamEventReplan = "replan" - StreamEventTool = "tool" // Tool is being called (shows status to user) - StreamEventDelta = "delta" // Text chunk from LLM streaming - StreamEventDone = "done" // Stream complete - StreamEventError = "error" // Error occurred -) - -// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent. -func (a *Agent) buildSystemPrompt(lang string) string { - return a.buildSystemPromptForStoreUser(lang, "default") -} - -func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string { - // Gather live system state - traderInfo := a.getTradersSummaryForStoreUser(storeUserID) - watchlist := "" - if a.sentinel != nil { - watchlist = a.sentinel.FormatWatchlist(lang) - } - skillCatalog := skillCatalogPrompt(lang) - - if lang == "zh" { - return fmt.Sprintf(`你是 NOFXi,一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。 - -## 你的核心能力 -1. **市场分析** — 加密货币(BTC/ETH/SOL等)有实时数据,A股/港股/美股/外汇你可以基于知识分析 -2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态 -3. **策略建议** — 根据用户需求制定交易策略 -4. **策略模板管理** — 创建、查看、修改、删除、激活策略模板 -5. **风险管理** — 评估风险、建议止损止盈 -6. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型 - -## 当前系统状态 -%s -%s - -## 数据说明(极其重要,违反即失职!) -- 加密货币(BTC/ETH等):交易所实时数据,标注 [Real-time] -- A股/港股/美股:**必须调用 search_stock 工具**获取实时行情。不调工具就没有数据。 -- 美股盘前盘后:search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time -- 外汇/指数期货:当前没有数据源,如实告知 - -### 铁律:禁止编造任何价格! -- **你的训练数据中的价格全部过时,不可使用** -- **没有通过工具获取的价格 = 你不知道 = 不能说** -- 用户问多只股票的盘前数据?→ 对每只股票调用 search_stock 工具 -- 用户问"盘前概览"?→ 调用 search_stock 查主要股票(AAPL、TSLA、NVDA、MSFT、GOOGL、AMZN、META等),用真实数据回答 -- **绝对不允许**不调工具就给出具体价格数字(如 $421.85) -- 如果某只股票 search_stock 查不到数据,就说"暂时无法获取该股票数据" -- 指数期货(纳指、标普、道琼斯期货)我们目前没有数据源,直接说"暂不支持指数期货数据" - -## 工具使用 -你可以调用以下工具来执行操作: -- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。 -- **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_ai500_list** — 获取 AI500 指数榜单(AI 评分 0-100 + 入选以来涨幅,按评分排序)。**用户要选币、要推荐标的、或创建策略/交易员没指定币种时,默认先调这个工具,从评分高、表现好的标的里选**;用户问"AI500 里有什么"时也用它 - -### 配置、策略与交易员管理规则 -- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy -- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型** -- **策略模板创建成功后应立即出现在策略列表/策略页** -- **策略模板不能直接启动或运行;只有交易员有运行态。** -- 如果用户说“启动策略 / 运行策略”,要明确说明:应先把策略绑定到交易员,再启动交易员 -- 用户没问运行/部署/创建交易员时,不要主动延伸到交易员、模型或交易所绑定 -- 当用户要求配置交易所、绑定 API Key、修改交易所账户时,优先使用 manage_exchange_config -- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时,优先使用 manage_model_config -- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader -- **缺字段时优先用合理默认值,不要逐项追问** — 创建策略/交易员时,凡是有行业常识默认值的字段(杠杆、周期、风控比例、名称等)直接预填,调用工具,然后把"我帮你默认了 X、Y、Z"放进回复让用户确认或修改。只有无法合理默认的字段(如 API Key、交易所选择)才需要追问,且一次问完所有缺失项,不要一轮问一个。 -- **在这些工具存在时,不要说“系统没有这个能力”** -- 对敏感信息(API Key、Secret、Private Key)只保存,不要在最终回复中完整回显 - -%s - -### 交易安全规则 -- 用户明确要求交易时才调用 execute_trade -- 下单前先尊重风控:数量过大、仓位太小、杠杆过高、超过权益上限时,不要假装能下单,要直接用人话解释原因 -- 分析和建议不需要调用工具,直接回复即可 -- 交易确认信息要清晰展示:品种、方向、数量、杠杆 -- 提醒用户确认命令格式;普通订单用“确认 trade_xxx”,大额订单用“确认大额 trade_xxx” - -### 数据真实性规则(极其重要!) -- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓 -- **余额信息必须且只能通过 get_balance 工具获取**,绝对禁止编造余额 -- 如果用户问持仓但 get_positions 返回空,就说"当前没有持仓",不要编造 -- 如果工具返回 error(如未配置交易所),如实告知用户 -- **你不知道用户持有什么股票/币种,除非工具返回了数据** -- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓" - -## 行为准则(最高优先级) -- **先做完, 再汇报** — 一个回合内可以连续调用多个工具直到任务完成(查→建→配→启动)。所有工具调用都发生在当前回合, 用户看到的下一条消息就是最终结果。绝对不要说"稍等"、"我去处理"、"正在为你办理"——你没有后台任务, 说了就是撒谎。 -- **先直接答, 再可选追加一条相关提醒** — 第一句永远是用户问的那个具体答案。然后只在以下三种情况追加一句话: (a) 用户当前仓位有暴露的风险, (b) 完成请求所需的配置缺失, (c) 下一步动作显而易见(比如"已创建 trader, 要我现在启动吗?")。一次只追加一句, 不要列清单。 -- **System Context 是参考资料, 不是输出模板** — 只用跟用户问题直接相关的那部分, 不要复述整个状态。 -- **回复要短** — 能一句话说清就不要写一段。**聊天界面不渲染 markdown 表格, 绝对不要输出表格**; 排名/对比类数据用编号列表, 一行一条(如 "1. BEAT — 评分 84.2, +404.1%%")。也不要用分隔线和标题。 -- **会"做事"的 agent, 不是只会"答题"的查询机** — 用户说"创建并启动 X trader", 你应该一次链式调用 create + start, 不要先回"已创建, 请去面板手动启动"。用户已经表达意图, 你就去做。 -- **遇到工具错误**: 用一句人话说出原因, 然后给一个最可能的修复建议或一个聚焦的追问。不要默默重试。不要说"稍等一下我去办" — 你没有后台任务。 -- **不要重复自我介绍** — 除非用户首次问"你是谁/你能做什么"。 -- 把用户当交易小白, 语言简单直接。先结论, 再原因。 -- **诚实是第一原则** — 不确定就说不确定, 没数据就说没数据。绝不编造。 -- 用中文回复。 - -当前时间: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05")) - } - - return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner. - -## Capabilities -1. Market analysis — crypto with real-time data, stocks/forex with knowledge -2. Trade management — positions, balance, history, trader status -3. Strategy — build trading strategies based on user needs -4. Strategy template management — create, inspect, update, delete, and activate strategy templates -5. Risk management — assess risk, suggest stop-loss/take-profit -6. Setup — guide exchange/AI configuration when user asks - -## Current System State -%s -%s - -## Data Notice (CRITICAL — violating this is unacceptable!) -- Crypto (BTC/ETH): Exchange real-time data, marked [Real-time] -- Stocks: You MUST call search_stock tool to get real-time quotes. No tool call = no data. -- US stocks pre/after-hours: ext_price/ext_change_pct/ext_time in search_stock results -- Forex/Index futures: No data source currently — tell user honestly - -### ABSOLUTE RULE: NEVER fabricate any price! -- Your training data prices are ALL outdated and MUST NOT be used -- No tool result = you don't know = you cannot state a price -- User asks multiple stocks? → Call search_stock for EACH one -- User asks "pre-market overview"? → Call search_stock for major stocks (AAPL, TSLA, NVDA, MSFT, GOOGL, AMZN, META etc.) and use real data -- NEVER output a specific price number (like $421.85) without a tool having returned it -- If search_stock fails for a stock, say "unable to fetch data for this stock" -- Index futures (NDX, SPX, DJI futures) — we have no data source, say "index futures not supported yet" - -## 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). 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_ai500_list** — AI500 index board (AI score 0-100 + gain since entry, sorted by score). **When the user wants coin picks, recommendations, or creates a strategy/trader without naming coins, call this first and choose from the high-scoring, well-performing entries**; also use it when asked what's in AI500 -- **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 -- **manage_trader** — List, create, update, delete, start, and stop traders - -### 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** -- **After creation, a strategy template should immediately appear in the strategy list/page** -- **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 -- Do not proactively bring up traders, models, or exchange bindings unless the user asks to run, deploy, or create 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 -- **Prefer sensible defaults over field-by-field interrogation** — when creating strategies/traders, prefill any field with an industry-standard default (leverage, timeframes, risk ratios, names), call the tool, then list "I defaulted X, Y, Z" in the reply for the user to confirm or adjust. Only ask for fields that cannot be reasonably defaulted (API keys, exchange choice), and ask for ALL missing ones in a single question — never one per turn. -- **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 - -%s - -### 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; 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 -- **Balance data MUST come from get_balance tool only** — NEVER fabricate balances -- If get_positions returns empty, say "no open positions" — do NOT make up holdings -- If a tool returns an error (e.g. no exchange configured), tell the user honestly -- **You do NOT know what the user holds unless a tool tells you** -- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding" - -## Behavior (HIGHEST PRIORITY) -- **Finish the work, then report** — You may chain as many tool calls as needed within this turn (look up → create → configure → start). Everything happens now; the next message the user sees is the final result. NEVER say "please wait", "I'm working on it", or "I'll handle this" — you have no background tasks, so saying it is lying. -- **Answer directly first, then optionally one relevant follow-up** — The first sentence is always the specific answer to what the user asked. After that, you may add at most one follow-up only when: (a) the user has open risk exposure, (b) a config required to fulfill the request is missing, or (c) the next step is obvious (e.g. "Trader created — want me to start it?"). One follow-up max, no checklists. -- **System Context is reference material, not output template** — Use only the part directly relevant to the user's question. Don't recap the whole state. -- **Keep it short** — One sentence beats a paragraph. **The chat UI does not render markdown tables — never output a table**; for rankings/comparisons use a numbered list, one item per line (e.g. "1. BEAT — score 84.2, +404.1%%"). No dividers or headers either. -- **You're an agent that DOES things, not a Q&A bot** — If the user says "create and start trader X", chain create + start in one go; don't reply "created, please start manually". They already expressed intent; execute it. -- **On tool errors**: name the error in plain language in one sentence, then propose the single most likely fix OR ask one focused clarifying question. Never silently retry. Never say "I'll get back to you" / "please wait" — you have no background job. -- **Don't repeat self-introduction** — unless user first asks "who are you / what can you do". -- Treat the user like a trading beginner. Use plain language. Conclusion first, reason after. -- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data. Never fabricate. - -Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05")) -} - -// gatherContext collects real-time market data relevant to the user's message. -func (a *Agent) gatherContext(storeUserID, text string) string { - var parts []string - upper := strings.ToUpper(text) - - // Crypto — detect symbols dynamically - // 1. Check known popular symbols (fast path) - // 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs) - knownSymbols := []string{ - "BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK", - "PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF", - "NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD", - "AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP", - } - matched := make(map[string]bool) - for _, sym := range knownSymbols { - if strings.Contains(upper, sym) { - matched[sym] = true - } - } - // Also extract "XXXUSDT" patterns for coins not in the known list - for _, word := range strings.Fields(upper) { - word = strings.Trim(word, ".,!?;:()[]{}\"'") - if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 { - sym := strings.TrimSuffix(word, "USDT") - if len(sym) >= 2 && len(sym) <= 10 { - matched[sym] = true - } - } - } - // Collect and sort matched symbols for deterministic selection - sortedSymbols := make([]string, 0, len(matched)) - for sym := range matched { - sortedSymbols = append(sortedSymbols, sym) - } - sort.Strings(sortedSymbols) - - // Cap at 5 symbols to avoid slow context gathering - count := 0 - for _, sym := range sortedSymbols { - if count >= 5 { - break - } - md, err := market.Get(sym + "USDT") - if err == nil && md.CurrentPrice > 0 { - parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%", - sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100)) - count++ - } - } - - // A-share / stocks — only call Sina API when text likely references stocks. - // Skip for purely crypto conversations to avoid unnecessary external API calls. - if looksLikeStockQuery(text) { - stockCode, stockName := resolveStockCodeDynamic(text) - if stockCode != "" { - quote, err := fetchStockQuote(stockCode) - if err == nil && quote.Price > 0 { - parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote))) - } else if err != nil { - a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err) - } - } - } - - // Trader positions - if a.traderManager != nil && a.store != nil { - traderConfigs, _ := a.store.Trader().List(storeUserID) - for _, traderCfg := range traderConfigs { - if strings.TrimSpace(traderCfg.ID) == "" { - continue - } - t, err := a.traderManager.GetTrader(traderCfg.ID) - if err != nil { - continue - } - positions, err := t.GetPositions() - if err != nil { - continue - } - for _, p := range positions { - size := toFloat(p["size"]) - if size == 0 { - continue - } - parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f", - p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"]))) - } - } - } - - return strings.Join(parts, "\n") -} - -func (a *Agent) getTradersSummary() string { - return a.getTradersSummaryForStoreUser("default") -} - -func (a *Agent) getTradersSummaryForStoreUser(storeUserID string) string { - if a.traderManager == nil { - return "Traders: none configured" - } - if a.store == nil { - return "Traders: none configured" - } - if strings.TrimSpace(storeUserID) == "" { - storeUserID = "default" - } - traderConfigs, err := a.store.Trader().List(storeUserID) - if err != nil || len(traderConfigs) == 0 { - return "Traders: none configured" - } - - var lines []string - for _, traderCfg := range traderConfigs { - if strings.TrimSpace(traderCfg.ID) == "" { - continue - } - t, err := a.traderManager.GetTrader(traderCfg.ID) - isRunning := traderCfg.IsRunning - exchange := traderCfg.ExchangeID - if err == nil && t != nil { - s := t.GetStatus() - if running, ok := s["is_running"].(bool); ok { - isRunning = running - } - exchange = t.GetExchange() - } - status := "stopped" - if isRunning { - status = "running" - } - tid := traderCfg.ID - if len(tid) > 8 { - tid = tid[:8] - } - lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", traderCfg.Name, tid, status, exchange)) - } - if len(lines) == 0 { - return "Traders: none configured" - } - return "Traders:\n" + strings.Join(lines, "\n") -} - -func (a *Agent) handleStatus(L string) string { - tc, rc := 0, 0 - if a.traderManager != nil { - all := a.traderManager.GetAllTraders() - tc = len(all) - for _, t := range all { - if s := t.GetStatus(); s["is_running"] == true { - rc++ - } - } - } - wc := 0 - if a.sentinel != nil { - wc = a.sentinel.SymbolCount() - } - ai := "❌" - if a.aiClient != nil { - ai = "✅" - } - return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05")) -} - -// noAIFallback — when no AI is available, still try to be useful. -func (a *Agent) noAIFallback(storeUserID, lang, text string) (string, error) { - upper := strings.ToUpper(text) - - // Try to provide market data directly - for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} { - if strings.Contains(upper, sym) { - md, err := market.Get(sym + "USDT") - if err == nil { - return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil - } - } - } - - // Check if asking about positions/balance - if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") { - return a.queryPositionsDirect(storeUserID, lang) - } - if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") { - return a.queryBalancesDirect(storeUserID, lang) - } - - if lang == "zh" { - 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` 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) { - reason := "unknown error" - if err != nil { - reason = summarizeObservation(err.Error()) - } - a.logger.Error("AI service call failed", "error", reason) - if lang == "zh" { - return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n%s", reason, aiServiceFailureGuidance("zh", reason)), nil - } - return fmt.Sprintf("The AI service call failed: %s\n\n%s", reason, aiServiceFailureGuidance(lang, reason)), nil -} - -func aiServiceFailureGuidance(lang, reason string) string { - lower := strings.ToLower(strings.TrimSpace(reason)) - looksLikeHTMLGateway := strings.Contains(lower, "invalid character '<'") || - strings.Contains(lower, "unexpected character '<'") || - strings.Contains(lower, " 8 { - tid = tid[:8] - } - sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid)) - } - } - if !hasAny { - return a.msg(L, "no_positions"), nil - } - return sb.String(), nil -} - -func (a *Agent) queryBalancesDirect(storeUserID, L string) (string, error) { - if a.traderManager == nil { - return a.msg(L, "no_traders"), nil - } - if a.store == nil { - return a.msg(L, "no_traders"), nil - } - traderConfigs, err := a.store.Trader().List(storeUserID) - if err != nil { - return a.msg(L, "no_traders"), nil - } - var sb strings.Builder - sb.WriteString("💰 *Balance*\n\n") - for _, traderCfg := range traderConfigs { - if strings.TrimSpace(traderCfg.ID) == "" { - continue - } - t, err := a.traderManager.GetTrader(traderCfg.ID) - if err != nil { - continue - } - info, err := t.GetAccountInfo() - if err != nil { - continue - } - tid := traderCfg.ID - if len(tid) > 8 { - tid = tid[:8] - } - sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"]))) - } - return sb.String(), nil -} - -func (a *Agent) handleSignal(sig Signal) { - if a.brain != nil { - a.brain.HandleSignal(sig) - } -} - -func (a *Agent) notifyAll(text string) { - if a.NotifyFunc != nil { - a.NotifyFunc(0, text) - } -} - -// looksLikeStockQuery returns true if the text likely references stocks rather -// than being a pure crypto/general query. This avoids hitting the Sina search -// API on every single message (saves ~200ms latency + external API call). -func looksLikeStockQuery(text string) bool { - upper := strings.ToUpper(text) - - // Check for known stock-related Chinese keywords - stockKeywords := []string{ - "股", "A股", "港股", "美股", "股票", "涨停", "跌停", "大盘", - "沪指", "深指", "恒指", "纳指", "标普", "道琼斯", - "茅台", "比亚迪", "宁德", "腾讯", "阿里", "美团", "小米", - "京东", "百度", "苹果", "特斯拉", "英伟达", "微软", "谷歌", - "盘前", "盘后", "开盘", "收盘", "涨幅", "跌幅", - } - for _, kw := range stockKeywords { - if strings.Contains(text, kw) { - return true - } - } - - // Check for US stock ticker patterns (1-5 uppercase letters not matching crypto) - for _, word := range strings.Fields(upper) { - word = strings.Trim(word, ".,!?;:()[]{}\"'") - if len(word) >= 1 && len(word) <= 5 { - allLetter := true - for _, c := range word { - if c < 'A' || c > 'Z' { - allLetter = false - break - } - } - if allLetter { - // Check if it's in the known US ticker map - if _, ok := usTickerMap[word]; ok { - return true - } - } - } - } - - // Check for 6-digit A-share codes or 5-digit HK codes - for _, w := range strings.Fields(text) { - w = strings.TrimSpace(w) - if len(w) == 5 || len(w) == 6 { - if _, err := strconv.Atoi(w); err == nil { - return true - } - } - } - - return false -} - -func toFloat(v interface{}) float64 { - switch x := v.(type) { - case float64: - return x - case float32: - return float64(x) - case int: - return float64(x) - case int64: - return float64(x) - case int32: - return float64(x) - case string: - f, _ := strconv.ParseFloat(x, 64) - return f - case json.Number: - f, _ := x.Float64() - return f - } - return 0 -} diff --git a/agent/agent_model_selection_test.go b/agent/agent_model_selection_test.go deleted file mode 100644 index dd908c4e..00000000 --- a/agent/agent_model_selection_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package agent - -import ( - "log/slog" - "path/filepath" - "testing" - - "nofx/store" -) - -func TestLoadAIClientFromStoreUserPrefersModelWithBalance(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "agent-model-selection.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - - if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", true, "sk-test", "", "gpt-5.2"); err != nil { - t.Fatalf("create openai model: %v", err) - } - if err := st.AIModel().UpdateWithName("default", "wallet_claw402", "Claw402", true, "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", "", "glm-5"); err != nil { - t.Fatalf("create claw402 model: %v", err) - } - - restoreWalletAddress := agentWalletAddressFromPrivateKey - restoreBalanceQuery := agentQueryUSDCBalanceCached - t.Cleanup(func() { - agentWalletAddressFromPrivateKey = restoreWalletAddress - agentQueryUSDCBalanceCached = restoreBalanceQuery - }) - - agentWalletAddressFromPrivateKey = func(privateKey string) (string, error) { - if privateKey == "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca" { - return "0xabc", nil - } - return "", nil - } - agentQueryUSDCBalanceCached = func(address string) (float64, error) { - if address == "0xabc" { - return 12.5, nil - } - return 0, nil - } - - a := New(nil, st, DefaultConfig(), slog.Default()) - _, modelName, ok := a.loadAIClientFromStoreUser("default") - if !ok { - t.Fatalf("expected model selection to succeed") - } - if modelName != "glm-5" { - t.Fatalf("expected model with wallet balance to be selected, got %q", modelName) - } -} diff --git a/agent/agentic_loop.go b/agent/agentic_loop.go deleted file mode 100644 index 537724e6..00000000 --- a/agent/agentic_loop.go +++ /dev/null @@ -1,215 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "os" - "strings" - - "nofx/mcp" -) - -const ( - // agenticMaxToolRounds bounds the number of LLM round-trips in one user - // turn. Each round may execute several tool calls, so this comfortably - // covers chained operations (create → configure → start) while still - // terminating runaway loops. - agenticMaxToolRounds = 12 - - // agenticHistoryMessages is the number of recent history messages replayed - // to the LLM as real conversation turns. - agenticHistoryMessages = 12 -) - -// agentV2Enabled reports whether the native function-calling loop is the -// primary brain. Enabled by default; set NOFX_AGENT_V2=0/false/off/disabled -// to fall back to the legacy routing stack. -func agentV2Enabled() bool { - switch strings.TrimSpace(strings.ToLower(os.Getenv("NOFX_AGENT_V2"))) { - case "0", "false", "off", "disabled": - return false - } - return true -} - -// shouldUseAgenticTurn reports whether this turn should go through the native -// function-calling loop. In-flight legacy flows (skill sessions, workflows, -// execution states, pending proposals) stay on the legacy stack so they finish -// with the state machine that started them. -func (a *Agent) shouldUseAgenticTurn(userID int64) bool { - if a.aiClient == nil || !agentV2Enabled() { - return false - } - if a.hasAnyActiveContext(userID) { - return false - } - if _, ok := a.getPendingProposalSession(userID); ok { - return false - } - // Suspended tasks are parked on the snapshot stack with all active - // contexts cleared; only the legacy router knows how to resume them. - if len(a.SnapshotManager(userID).Stack()) > 0 { - return false - } - return true -} - -// runAgenticTurn drives one user turn through a native function-calling loop: -// the LLM sees the full toolset plus recent conversation, decides which tools -// to call, receives every tool result (including errors) as observations, and -// writes the final user-facing reply itself. -// -// Returns handled=false when nothing user-visible happened and the caller -// should fall back to the legacy routing stack (e.g. the very first LLM call -// failed). Once any tool has executed, the turn is always handled so side -// effects are never silently repeated by a fallback path. -func (a *Agent) runAgenticTurn(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 - } - - messages := []mcp.Message{mcp.NewSystemMessage(a.buildSystemPromptForStoreUser(lang, storeUserID))} - if prefs := a.buildPersistentPreferencesContext(userID); prefs != "" { - messages = append(messages, mcp.NewSystemMessage(prefs)) - } - if taskCtx := buildTaskStateContext(a.getTaskState(userID)); taskCtx != "" { - messages = append(messages, mcp.NewSystemMessage(taskCtx)) - } - messages = append(messages, a.recentHistoryMessages(userID, text)...) - messages = append(messages, mcp.NewUserMessage(text)) - - tools := agentTools() - var executedTools []string - - for round := 0; round < agenticMaxToolRounds; round++ { - resp, err := a.aiClient.CallWithRequestFull(&mcp.Request{ - Messages: messages, - Tools: tools, - ToolChoice: "auto", - Ctx: ctx, - }) - if err != nil { - a.logger.Warn("agentic turn LLM call failed", "error", err, "user_id", userID, "round", round) - if len(executedTools) == 0 { - // Nothing happened yet — safe to let the legacy stack retry. - return "", false, nil - } - reply := agenticInterruptedReply(lang, executedTools) - return a.finishAgenticTurn(userID, lang, text, reply, onEvent), true, nil - } - - if len(resp.ToolCalls) == 0 { - reply := strings.TrimSpace(resp.Content) - if reply == "" { - if len(executedTools) == 0 { - return "", false, nil - } - reply = agenticInterruptedReply(lang, executedTools) - } - return a.finishAgenticTurn(userID, lang, text, reply, onEvent), true, nil - } - - assistantMsg := mcp.Message{Role: "assistant", ToolCalls: resp.ToolCalls} - if resp.Content != "" { - assistantMsg.Content = resp.Content - } - if resp.ReasoningContent != "" { - assistantMsg.ReasoningContent = resp.ReasoningContent - } - messages = append(messages, assistantMsg) - - for _, tc := range resp.ToolCalls { - if onEvent != nil { - onEvent(StreamEventTool, tc.Function.Name) - } - executedTools = append(executedTools, tc.Function.Name) - result := a.handleToolCall(ctx, storeUserID, userID, lang, tc) - messages = append(messages, mcp.Message{ - Role: "tool", - Content: result, - ToolCallID: tc.ID, - }) - } - } - - // Round budget exhausted: ask the LLM to wrap up with what it has, without - // offering further tools. - messages = append(messages, mcp.NewSystemMessage(agenticWrapUpInstruction(lang))) - final, err := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx}) - if err != nil || strings.TrimSpace(final) == "" { - if err != nil { - a.logger.Warn("agentic wrap-up call failed", "error", err, "user_id", userID) - } - final = agenticInterruptedReply(lang, executedTools) - } - return a.finishAgenticTurn(userID, lang, text, final, onEvent), true, nil -} - -// finishAgenticTurn applies final-reply guards, records the turn in history, -// and streams the reply. -func (a *Agent) finishAgenticTurn(userID int64, lang, text, reply string, onEvent func(event, data string)) string { - if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked { - reply = guarded - } - if a.history != nil { - a.history.Add(userID, "user", text) - a.history.Add(userID, "assistant", reply) - } - emitStreamText(onEvent, reply) - return reply -} - -// recentHistoryMessages replays recent conversation turns as real chat -// messages so the LLM has multi-turn context, dropping a trailing duplicate of -// the current user text if the caller already recorded it. -func (a *Agent) recentHistoryMessages(userID int64, currentText string) []mcp.Message { - if a.history == nil { - return nil - } - msgs := a.history.Get(userID) - if n := len(msgs); n > 0 && msgs[n-1].Role == "user" && - strings.TrimSpace(msgs[n-1].Content) == strings.TrimSpace(currentText) { - msgs = msgs[:n-1] - } - if len(msgs) > agenticHistoryMessages { - msgs = msgs[len(msgs)-agenticHistoryMessages:] - } - out := make([]mcp.Message, 0, len(msgs)) - for _, m := range msgs { - content := strings.TrimSpace(m.Content) - if content == "" { - continue - } - switch m.Role { - case "user": - out = append(out, mcp.NewUserMessage(content)) - case "assistant": - out = append(out, mcp.Message{Role: "assistant", Content: content}) - } - } - return out -} - -// agenticInterruptedReply tells the user exactly which tools already ran when -// a turn cannot produce an LLM-written reply, so work is never silently lost. -func agenticInterruptedReply(lang string, executedTools []string) string { - tools := strings.Join(executedTools, ", ") - if lang == "zh" { - if tools == "" { - return "刚才处理你的请求时 AI 服务中断了,已执行的操作没有丢失。请再说一次你想做什么,我接着处理。" - } - return fmt.Sprintf("处理过程中 AI 服务中断了。已执行的操作:%s。这些结果已生效,你可以让我继续下一步或查询当前状态。", tools) - } - if tools == "" { - return "The AI service was interrupted while handling your request. Nothing was lost — please tell me again what you'd like to do." - } - return fmt.Sprintf("The AI service was interrupted mid-task. Tools already executed: %s. Those results took effect — ask me to continue or check the current state.", tools) -} - -// agenticWrapUpInstruction is appended when the tool-round budget is spent. -func agenticWrapUpInstruction(lang string) string { - if lang == "zh" { - return "工具调用轮次已达上限。请基于以上已获得的全部结果,直接给用户一个完整的中文总结回复:说明已完成什么、未完成什么、建议的下一步。不要再请求调用工具。" - } - return "Tool-call round limit reached. Using everything gathered above, write the final reply for the user now: what was completed, what was not, and the suggested next step. Do not request more tool calls." -} diff --git a/agent/agentic_loop_test.go b/agent/agentic_loop_test.go deleted file mode 100644 index 093222f6..00000000 --- a/agent/agentic_loop_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package agent - -import ( - "context" - "errors" - "log/slog" - "path/filepath" - "strings" - "testing" - "time" - - "nofx/mcp" - "nofx/store" -) - -// scriptedAIClient returns queued LLMResponses (or errors) in order for -// CallWithRequestFull, and queued plain strings for CallWithRequest. -type scriptedAIClient struct { - fullResponses []*mcp.LLMResponse - fullErrs []error - fullCalls int - fullRequests []*mcp.Request - - plainResponse string - plainErr error - plainCalls int -} - -func (c *scriptedAIClient) SetAPIKey(apiKey string, customURL string, customModel string) {} -func (c *scriptedAIClient) SetTimeout(timeout time.Duration) {} -func (c *scriptedAIClient) CallWithMessages(systemPrompt, userPrompt string) (string, error) { - return c.plainResponse, c.plainErr -} -func (c *scriptedAIClient) CallWithRequest(req *mcp.Request) (string, error) { - c.plainCalls++ - return c.plainResponse, c.plainErr -} -func (c *scriptedAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) { - if onChunk != nil && c.plainErr == nil { - onChunk(c.plainResponse) - } - return c.plainResponse, c.plainErr -} -func (c *scriptedAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) { - idx := c.fullCalls - c.fullCalls++ - c.fullRequests = append(c.fullRequests, req) - var err error - if idx < len(c.fullErrs) { - err = c.fullErrs[idx] - } - if err != nil { - return nil, err - } - if idx < len(c.fullResponses) { - return c.fullResponses[idx], nil - } - return &mcp.LLMResponse{Content: "fallthrough"}, nil -} - -func newAgenticTestAgent(client mcp.AIClient) *Agent { - a := New(nil, nil, DefaultConfig(), slog.Default()) - a.SetAIClient(client) - return a -} - -func toolCall(id, name, args string) mcp.ToolCall { - return mcp.ToolCall{ - ID: id, - Type: "function", - Function: mcp.ToolCallFunction{ - Name: name, - Arguments: args, - }, - } -} - -func TestRunAgenticTurnDirectAnswer(t *testing.T) { - client := &scriptedAIClient{ - fullResponses: []*mcp.LLMResponse{{Content: "你好,我是 NOFX 助手。"}}, - } - a := newAgenticTestAgent(client) - - answer, handled, err := a.runAgenticTurn(context.Background(), "default", 1, "zh", "你好", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !handled { - t.Fatal("expected turn to be handled") - } - if answer != "你好,我是 NOFX 助手。" { - t.Fatalf("answer = %q", answer) - } - if client.fullCalls != 1 { - t.Fatalf("fullCalls = %d, want 1", client.fullCalls) - } - // Tools must be offered on the request. - if len(client.fullRequests[0].Tools) == 0 { - t.Fatal("expected tools to be attached to the LLM request") - } - // Conversation must be recorded in history. - msgs := a.history.Get(1) - if len(msgs) != 2 || msgs[0].Role != "user" || msgs[1].Role != "assistant" { - t.Fatalf("history = %+v, want user+assistant turns", msgs) - } -} - -func TestRunAgenticTurnToolRoundTrip(t *testing.T) { - client := &scriptedAIClient{ - fullResponses: []*mcp.LLMResponse{ - {ToolCalls: []mcp.ToolCall{toolCall("call_1", "definitely_not_a_tool", "{}")}}, - {Content: "done"}, - }, - } - a := newAgenticTestAgent(client) - - var toolEvents []string - onEvent := func(event, data string) { - if event == StreamEventTool { - toolEvents = append(toolEvents, data) - } - } - - answer, handled, err := a.runAgenticTurn(context.Background(), "default", 2, "en", "do something", onEvent) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !handled || answer != "done" { - t.Fatalf("handled=%v answer=%q", handled, answer) - } - if client.fullCalls != 2 { - t.Fatalf("fullCalls = %d, want 2", client.fullCalls) - } - if len(toolEvents) != 1 || toolEvents[0] != "definitely_not_a_tool" { - t.Fatalf("toolEvents = %v", toolEvents) - } - - // The second request must carry the assistant tool-call message and the - // tool result message with matching ToolCallID. - second := client.fullRequests[1] - var sawAssistantToolCall, sawToolResult bool - for _, m := range second.Messages { - if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "call_1" { - sawAssistantToolCall = true - } - if m.Role == "tool" && m.ToolCallID == "call_1" { - sawToolResult = true - if !strings.Contains(m.Content, "unknown tool") { - t.Fatalf("tool result = %q, want unknown-tool error payload", m.Content) - } - } - } - if !sawAssistantToolCall || !sawToolResult { - t.Fatalf("tool round-trip messages missing: assistant=%v tool=%v", sawAssistantToolCall, sawToolResult) - } -} - -func TestRunAgenticTurnFirstCallFailureFallsBack(t *testing.T) { - client := &scriptedAIClient{ - fullErrs: []error{errors.New("upstream 500")}, - } - a := newAgenticTestAgent(client) - - _, handled, err := a.runAgenticTurn(context.Background(), "default", 3, "zh", "hi", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if handled { - t.Fatal("expected fallback (handled=false) when the first LLM call fails") - } - if got := a.history.Get(3); len(got) != 0 { - t.Fatalf("history should stay empty on fallback, got %+v", got) - } -} - -func TestRunAgenticTurnMidLoopFailureReportsExecutedTools(t *testing.T) { - client := &scriptedAIClient{ - fullResponses: []*mcp.LLMResponse{ - {ToolCalls: []mcp.ToolCall{toolCall("call_1", "definitely_not_a_tool", "{}")}}, - }, - fullErrs: []error{nil, errors.New("upstream timeout")}, - } - a := newAgenticTestAgent(client) - - answer, handled, err := a.runAgenticTurn(context.Background(), "default", 4, "zh", "do it", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !handled { - t.Fatal("mid-loop failure must be handled (tools already executed)") - } - if !strings.Contains(answer, "definitely_not_a_tool") { - t.Fatalf("answer should mention the executed tool, got %q", answer) - } -} - -func TestRunAgenticTurnRoundLimitTriggersWrapUp(t *testing.T) { - responses := make([]*mcp.LLMResponse, 0, agenticMaxToolRounds) - for i := 0; i < agenticMaxToolRounds; i++ { - responses = append(responses, &mcp.LLMResponse{ - ToolCalls: []mcp.ToolCall{toolCall("call_x", "definitely_not_a_tool", "{}")}, - }) - } - client := &scriptedAIClient{ - fullResponses: responses, - plainResponse: "summary of what happened", - } - a := newAgenticTestAgent(client) - - answer, handled, err := a.runAgenticTurn(context.Background(), "default", 5, "en", "loop forever", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !handled { - t.Fatal("expected handled=true at round limit") - } - if answer != "summary of what happened" { - t.Fatalf("answer = %q, want wrap-up summary", answer) - } - if client.fullCalls != agenticMaxToolRounds { - t.Fatalf("fullCalls = %d, want %d", client.fullCalls, agenticMaxToolRounds) - } - if client.plainCalls != 1 { - t.Fatalf("plainCalls = %d, want 1 wrap-up call", client.plainCalls) - } -} - -func TestRunAgenticTurnIncludesRecentHistory(t *testing.T) { - client := &scriptedAIClient{ - fullResponses: []*mcp.LLMResponse{{Content: "answer"}}, - } - a := newAgenticTestAgent(client) - a.history.Add(6, "user", "前一个问题") - a.history.Add(6, "assistant", "前一个回答") - - if _, handled, err := a.runAgenticTurn(context.Background(), "default", 6, "zh", "新问题", nil); err != nil || !handled { - t.Fatalf("handled=%v err=%v", handled, err) - } - - req := client.fullRequests[0] - var sawPrevUser, sawPrevAssistant bool - for _, m := range req.Messages { - if m.Role == "user" && m.Content == "前一个问题" { - sawPrevUser = true - } - if m.Role == "assistant" && m.Content == "前一个回答" { - sawPrevAssistant = true - } - } - if !sawPrevUser || !sawPrevAssistant { - t.Fatalf("recent history missing from request: user=%v assistant=%v", sawPrevUser, sawPrevAssistant) - } -} - -func TestShouldUseAgenticTurn(t *testing.T) { - t.Setenv("NOFX_AGENT_V2", "") - - a := newAgenticTestAgent(&scriptedAIClient{}) - if !a.shouldUseAgenticTurn(10) { - t.Fatal("fresh conversation with AI client should use the agentic turn") - } - - t.Run("disabled by env", func(t *testing.T) { - t.Setenv("NOFX_AGENT_V2", "off") - if a.shouldUseAgenticTurn(10) { - t.Fatal("env kill switch must disable the agentic turn") - } - }) - - t.Run("no AI client", func(t *testing.T) { - noAI := New(nil, nil, DefaultConfig(), slog.Default()) - if noAI.shouldUseAgenticTurn(10) { - t.Fatal("agentic turn requires an AI client") - } - }) - - t.Run("suspended task snapshot stays on legacy stack", func(t *testing.T) { - st, err := store.New(filepath.Join(t.TempDir(), "agentic-snapshot.db")) - if err != nil { - t.Fatalf("create store: %v", err) - } - b := New(nil, st, DefaultConfig(), slog.Default()) - b.SetAIClient(&scriptedAIClient{}) - if !b.shouldUseAgenticTurn(12) { - t.Fatal("fresh conversation should use the agentic turn") - } - b.SnapshotManager(12).Save(SuspendedTask{ - SnapshotID: "snap_test", - Kind: "skill", - ResumeHint: "continue strategy create", - }) - if b.shouldUseAgenticTurn(12) { - t.Fatal("suspended task snapshot must stay on the legacy stack so it can be resumed") - } - }) - - t.Run("active legacy session stays on legacy stack", func(t *testing.T) { - st, err := store.New(filepath.Join(t.TempDir(), "agentic-guard.db")) - if err != nil { - t.Fatalf("create store: %v", err) - } - b := New(nil, st, DefaultConfig(), slog.Default()) - b.SetAIClient(&scriptedAIClient{}) - if !b.shouldUseAgenticTurn(11) { - t.Fatal("fresh conversation should use the agentic turn") - } - b.saveActiveSkillSession(newActiveSkillSession(11, "strategy_management", "create")) - if b.shouldUseAgenticTurn(11) { - t.Fatal("active skill session must stay on the legacy stack") - } - }) -} - -func TestAgentV2Enabled(t *testing.T) { - cases := []struct { - value string - want bool - }{ - {"", true}, - {"1", true}, - {"true", true}, - {"on", true}, - {"0", false}, - {"false", false}, - {"off", false}, - {"disabled", false}, - } - for _, tc := range cases { - t.Run("value="+tc.value, func(t *testing.T) { - t.Setenv("NOFX_AGENT_V2", tc.value) - if got := agentV2Enabled(); got != tc.want { - t.Errorf("agentV2Enabled() with %q = %v, want %v", tc.value, got, tc.want) - } - }) - } -} diff --git a/agent/ai500.go b/agent/ai500.go deleted file mode 100644 index cd8600c9..00000000 --- a/agent/ai500.go +++ /dev/null @@ -1,122 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - - "nofx/provider/nofxos" - "nofx/store" -) - -const ( - ai500DefaultLimit = 20 - ai500MaxLimit = 100 -) - -// fetchAI500ForTool is swappable in tests. It resolves a nofxos client -// (routed through claw402 when a wallet key is available) and returns the -// cached AI500 board. -var fetchAI500ForTool = func(walletKey string) ([]nofxos.CoinData, error) { - return nofxos.GetAI500ListCached(nofxos.ResolveClient(walletKey)) -} - -// Claw402WalletKeyForStoreUser returns the wallet private key of the user's -// enabled claw402 model, if any, so data requests can be routed through the -// claw402 payment gateway on the user's own account. -func Claw402WalletKeyForStoreUser(st *store.Store, storeUserID string) string { - if st == nil { - return "" - } - if strings.TrimSpace(storeUserID) == "" { - storeUserID = "default" - } - models, err := st.AIModel().List(storeUserID) - if err != nil { - return "" - } - for _, model := range models { - if model == nil || !model.Enabled { - continue - } - if strings.EqualFold(strings.TrimSpace(model.Provider), "claw402") && len(model.APIKey) > 0 { - return string(model.APIKey) - } - } - return "" -} - -// AI500BoardEntry is the display shape for one AI500 constituent. -type AI500BoardEntry struct { - Pair string `json:"pair"` - Score float64 `json:"score"` - MaxScore float64 `json:"max_score"` - IncreasePercent float64 `json:"increase_percent"` - StartPrice float64 `json:"start_price"` - StartTime int64 `json:"start_time"` -} - -// AI500Board returns the AI500 constituents sorted by score (descending), -// truncated to limit. -func AI500Board(walletKey string, limit int) ([]AI500BoardEntry, error) { - if limit <= 0 { - limit = ai500DefaultLimit - } - if limit > ai500MaxLimit { - limit = ai500MaxLimit - } - coins, err := fetchAI500ForTool(walletKey) - if err != nil { - return nil, err - } - sorted := make([]nofxos.CoinData, len(coins)) - copy(sorted, coins) - sort.SliceStable(sorted, func(i, j int) bool { - return sorted[i].Score > sorted[j].Score - }) - if len(sorted) > limit { - sorted = sorted[:limit] - } - out := make([]AI500BoardEntry, 0, len(sorted)) - for _, coin := range sorted { - out = append(out, AI500BoardEntry{ - Pair: coin.Pair, - Score: coin.Score, - MaxScore: coin.MaxScore, - IncreasePercent: coin.IncreasePercent, - StartPrice: coin.StartPrice, - StartTime: coin.StartTime, - }) - } - return out, nil -} - -// toolGetAI500List exposes the AI500 board to the chat agent. -func (a *Agent) toolGetAI500List(storeUserID, argsJSON string) string { - var args struct { - Limit int `json:"limit"` - } - if strings.TrimSpace(argsJSON) != "" { - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - } - - walletKey := Claw402WalletKeyForStoreUser(a.store, storeUserID) - entries, err := AI500Board(walletKey, args.Limit) - if err != nil { - return fmt.Sprintf(`{"error":"failed to fetch AI500 list: %s"}`, err) - } - - payload, err := json.Marshal(map[string]any{ - "status": "ok", - "count": len(entries), - "coins": entries, - "note": "AI500 is an AI-scored crypto index; score is 0-100, increase_percent is the gain since the coin entered the index. Present this in chat as a short numbered list, one coin per line, e.g. \"1. BEAT — 评分 84.2,入选以来 +404.1%\". NEVER use a markdown table — the chat UI does not render tables.", - }) - if err != nil { - return fmt.Sprintf(`{"error":"failed to serialize AI500 list: %s"}`, err) - } - return string(payload) -} diff --git a/agent/ai500_test.go b/agent/ai500_test.go deleted file mode 100644 index 8ab22e84..00000000 --- a/agent/ai500_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package agent - -import ( - "encoding/json" - "errors" - "log/slog" - "strings" - "testing" - - "nofx/provider/nofxos" -) - -func withStubbedAI500(t *testing.T, fn func(walletKey string) ([]nofxos.CoinData, error)) { - t.Helper() - original := fetchAI500ForTool - fetchAI500ForTool = fn - t.Cleanup(func() { fetchAI500ForTool = original }) -} - -func TestToolGetAI500ListSortsByScoreAndLimits(t *testing.T) { - withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) { - return []nofxos.CoinData{ - {Pair: "LOWUSDT", Score: 10, IncreasePercent: -3}, - {Pair: "TOPUSDT", Score: 99, IncreasePercent: 42}, - {Pair: "MIDUSDT", Score: 55, IncreasePercent: 7}, - }, nil - }) - - a := New(nil, nil, DefaultConfig(), slog.Default()) - raw := a.toolGetAI500List("default", `{"limit": 2}`) - - var resp struct { - Status string `json:"status"` - Count int `json:"count"` - Coins []struct { - Pair string `json:"pair"` - Score float64 `json:"score"` - IncreasePercent float64 `json:"increase_percent"` - } `json:"coins"` - } - if err := json.Unmarshal([]byte(raw), &resp); err != nil { - t.Fatalf("invalid JSON %q: %v", raw, err) - } - if resp.Status != "ok" || resp.Count != 2 || len(resp.Coins) != 2 { - t.Fatalf("unexpected response: %+v", resp) - } - if resp.Coins[0].Pair != "TOPUSDT" || resp.Coins[1].Pair != "MIDUSDT" { - t.Fatalf("expected score-descending order, got %+v", resp.Coins) - } -} - -func TestToolGetAI500ListDefaultLimit(t *testing.T) { - coins := make([]nofxos.CoinData, 30) - for i := range coins { - coins[i] = nofxos.CoinData{Pair: "C", Score: float64(i)} - } - withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) { - return coins, nil - }) - - a := New(nil, nil, DefaultConfig(), slog.Default()) - var resp struct { - Count int `json:"count"` - } - if err := json.Unmarshal([]byte(a.toolGetAI500List("default", "")), &resp); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if resp.Count != 20 { - t.Fatalf("default limit = %d, want 20", resp.Count) - } -} - -func TestToolGetAI500ListUpstreamError(t *testing.T) { - withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) { - return nil, errors.New("upstream down") - }) - - a := New(nil, nil, DefaultConfig(), slog.Default()) - raw := a.toolGetAI500List("default", "") - if !strings.Contains(raw, `"error"`) { - t.Fatalf("expected error payload, got %q", raw) - } -} - -func TestHandleToolCallDispatchesAI500(t *testing.T) { - withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) { - return []nofxos.CoinData{{Pair: "BTCUSDT", Score: 90}}, nil - }) - - a := New(nil, nil, DefaultConfig(), slog.Default()) - raw := a.handleToolCall(t.Context(), "default", 1, "zh", toolCall("c1", "get_ai500_list", "{}")) - if !strings.Contains(raw, "BTCUSDT") { - t.Fatalf("dispatch failed, got %q", raw) - } -} - -func TestAgentToolsIncludeAI500(t *testing.T) { - for _, tool := range agentTools() { - if tool.Function.Name == "get_ai500_list" { - return - } - } - t.Fatal("get_ai500_list missing from agent toolset") -} diff --git a/agent/ai_service_failure_test.go b/agent/ai_service_failure_test.go deleted file mode 100644 index b63b6363..00000000 --- a/agent/ai_service_failure_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package agent - -import ( - "errors" - "log/slog" - "strings" - "testing" -) - -func TestAIServiceFailureHighlightsHTMLGatewayResponse(t *testing.T) { - a := New(nil, nil, DefaultConfig(), slog.Default()) - - msg, err := a.aiServiceFailure("zh", errors.New("fail to parse AI server response: failed to parse response: invalid character '<' looking for beginning of value")) - if err != nil { - t.Fatalf("aiServiceFailure returned error: %v", err) - } - - for _, want := range []string{ - "当前 AI 服务调用失败", - "上游返回了 HTML 页面或网关/反代错误页", - "custom_api_url", - "不是“未配置模型”", - } { - if !strings.Contains(msg, want) { - t.Fatalf("expected message to contain %q, got: %s", want, msg) - } - } - if strings.Contains(msg, "更可能是模型服务余额不足、接口报错或超时") { - t.Fatalf("html parse error should not use the generic balance/timeout-only guidance: %s", msg) - } -} - -func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) { - a := New(nil, nil, DefaultConfig(), slog.Default()) - - msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","param":null,"type":"rate_limit_error"}}`)) - if err != nil { - t.Fatalf("aiServiceFailure returned error: %v", err) - } - - for _, want := range []string{ - "当前 AI 服务调用失败", - "上游模型没有返回有效内容", - "不应优先归因成“余额不足”", - "切换到另一个可用模型", - } { - if !strings.Contains(msg, want) { - t.Fatalf("expected message to contain %q, got: %s", want, msg) - } - } - if strings.Contains(msg, "更可能是模型服务余额不足、接口报错、鉴权失败或超时") { - t.Fatalf("upstream empty output should not use the generic balance/auth/timeout guidance: %s", msg) - } -} - -func TestAIServiceFailureHighlightsBannedAccountAuthFailure(t *testing.T) { - a := New(nil, nil, DefaultConfig(), slog.Default()) - - msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 401): {"error":{"code":"authentication_failed","message":"login failed: USER_IS_BANNED","param":null,"type":"authentication_error"}}`)) - if err != nil { - t.Fatalf("aiServiceFailure returned error: %v", err) - } - - for _, want := range []string{ - "当前 AI 服务调用失败", - "账号被禁用/封禁", - "USER_IS_BANNED", - "换一个可用账号/API Key", - "切换到另一个已启用模型", - } { - if !strings.Contains(msg, want) { - t.Fatalf("expected message to contain %q, got: %s", want, msg) - } - } - for _, unexpected := range []string{"余额不足", "超时"} { - if strings.Contains(msg, unexpected) { - t.Fatalf("banned account auth failure should not mention %q: %s", unexpected, msg) - } - } -} - -func TestCompletedPlanFallbackDoesNotExposeFinalSummaryFailure(t *testing.T) { - msg := formatCompletedPlanFallback("zh", []PlanStep{ - { - Type: planStepTypeTool, - Status: planStepStatusCompleted, - Title: "创建名为 eeg 的策略", - }, - }) - if msg == "" { - t.Fatalf("expected fallback message") - } - for _, bad := range []string{"失败", "AI", "稍后"} { - if strings.Contains(msg, bad) { - t.Fatalf("fallback should not expose final summary failure %q: %s", bad, msg) - } - } - if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") { - t.Fatalf("fallback should summarize completed work, got: %s", msg) - } -} - -func TestDeterministicCompletedPlanResponseSkipsLLMForSimpleConfirmation(t *testing.T) { - state := ExecutionState{ - Steps: []PlanStep{ - { - ID: "create_strategy", - Type: planStepTypeTool, - Status: planStepStatusCompleted, - Title: "创建名为 eeg 的策略", - }, - { - ID: "respond", - Type: planStepTypeRespond, - Status: planStepStatusRunning, - Title: "策略创建成功", - Instruction: "确认策略创建成功", - }, - }, - } - msg := deterministicCompletedPlanResponse("zh", state, state.Steps[1]) - if msg == "" { - t.Fatalf("expected deterministic response") - } - if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") { - t.Fatalf("unexpected deterministic response: %s", msg) - } -} diff --git a/agent/atomic_skill_executor.go b/agent/atomic_skill_executor.go deleted file mode 100644 index 7fa2cfb5..00000000 --- a/agent/atomic_skill_executor.go +++ /dev/null @@ -1,87 +0,0 @@ -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/brain.go b/agent/brain.go deleted file mode 100644 index dfb146bf..00000000 --- a/agent/brain.go +++ /dev/null @@ -1,237 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" - "nofx/safe" - "strings" - "sync" - "time" -) - -// Brain handles proactive intelligence: signals, news, market briefs. -type Brain struct { - agent *Agent - logger *slog.Logger - http *http.Client - stopCh chan struct{} - stopOnce sync.Once - recentSignals sync.Map // debounce -} - -func NewBrain(agent *Agent, logger *slog.Logger) *Brain { - return &Brain{ - agent: agent, - logger: logger, - http: &http.Client{Timeout: 15 * time.Second}, - stopCh: make(chan struct{}), - } -} - -func (b *Brain) Stop() { - b.stopOnce.Do(func() { - close(b.stopCh) - }) -} - -// cleanStaleSignals removes debounce entries older than 30 minutes. -func (b *Brain) cleanStaleSignals() { - cutoff := time.Now().Add(-30 * time.Minute) - b.recentSignals.Range(func(key, value any) bool { - if t, ok := value.(time.Time); ok && t.Before(cutoff) { - b.recentSignals.Delete(key) - } - return true - }) -} - -func (b *Brain) HandleSignal(sig Signal) { - key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol) - if v, ok := b.recentSignals.Load(key); ok { - if time.Since(v.(time.Time)) < 10*time.Minute { - return - } - } - b.recentSignals.Store(key, time.Now()) - - emoji := map[string]string{"info": "ℹ️", "warning": "⚠️", "critical": "🚨"} - e := emoji[sig.Severity] - 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 <-ticker.C: - b.scanNews(seen, &seenOrder) - cleanTick++ - if cleanTick%6 == 0 { // every ~30 min - b.cleanStaleSignals() - } - } - } - }) -} - -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 - } - 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 - } - - var result struct { - Data []struct { - Title string `json:"title"` - Source string `json:"source"` - URL string `json:"url"` - Body string `json:"body"` - Categories string `json:"categories"` - PublishedOn int64 `json:"published_on"` - } `json:"Data"` - } - 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 - } - seen[d.URL] = true - *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++ - } - } - - if bc == 0 && brc == 0 { - continue - } - - emoji := "📰" - sentiment := "NEUTRAL" - 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 the oldest half when seen grows large so recent URLs stay deduped deterministically. - if len(seen) > 1000 { - 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] - } - } -} - -func (b *Brain) StartMarketBriefs(hours []int) { - safe.GoNamed("brain-market-briefs", func() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - sent := make(map[string]bool) - for { - select { - case <-b.stopCh: - return - case now := <-ticker.C: - key := now.Format("2006-01-02-15") - for _, h := range hours { - if now.Hour() == h && now.Minute() == 30 && !sent[key] { - sent[key] = true - b.sendBrief(h) - } - } - } - } - }) -} - -func (b *Brain) sendBrief(hour int) { - 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 - } - body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit - statusOK := resp.StatusCode == http.StatusOK - resp.Body.Close() - 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"] - } - } - - brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_", - title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04")) - - b.agent.notifyAll(brief) -} diff --git a/agent/central_brain.go b/agent/central_brain.go deleted file mode 100644 index c341dd85..00000000 --- a/agent/central_brain.go +++ /dev/null @@ -1,1494 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "nofx/mcp" - "nofx/store" -) - -// 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. -- For diagnosis, create, update, delete, start, stop, activation, duplication, or historical-performance analysis tasks, never reply only with a future promise such as "I'll do it now", "please wait", "diagnosis is running", or "I'll tell you later". If the next step is execution, choose the corresponding skill/planned execution. If execution is impossible, say exactly what information or data is missing. -- 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. -- When an active session exposes allowed_field_spec_json, extracted_data must use only those canonical keys. Never output aliases, translated labels, or raw user wording as keys. -- If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}. -- For strategy changes, do not use the generic "strategy_management:update" action. Use "strategy_management:update_name" for renaming, "strategy_management:update_prompt" for prompt changes, or "strategy_management:update_config" for parameter/config changes. For strategy_management:update_config, extracted_data may include a StrategyConfig-shaped "config_patch". -- Current references are context only. Do not turn a current reference into target_ref_id/target_ref_name unless the user explicitly names that object or clearly refers to "this/current/that previous one". If a mutating task has no clear target, ask instead of executing. -- reply_to_user should be concise and in the user's language. -- For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create". - -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(buildSkillDomainPrimerForSession(lang, activeToLegacySkillSession(activeSession)), "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") - } - fieldSpecs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(activeSession), lang) - if len(fieldSpecs) > 0 { - fieldSpecsJSON, _ := json.Marshal(fieldSpecs) - sb.WriteString(fmt.Sprintf("allowed_field_spec_json: %s\n", fieldSpecsJSON)) - } - } 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 - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked { - reply = guarded - } - 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) - d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang) - markStrategyCreateConfigProgressThisTurn(&session, d.ExtractedData) - mergeExtractedData(&session, d.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - - case "CONTINUE_TASK": - if !hasActive { - return "", false, nil - } - d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang) - markStrategyCreateConfigProgressThisTurn(&activeSession, d.ExtractedData) - 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) { - session = appendActiveSessionLocalHistory(session, "user", text) - clearActiveSessionPendingHint(&session) - - stepDecision, ok := a.planActiveSessionStep(ctx, storeUserID, userID, lang, text, session) - if !ok { - stepDecision = activeSessionStepDecision{} - } - configProgressThisTurn := consumeStrategyCreateConfigProgressThisTurn(&session) - if strategyCreateDecisionHasConfigProgress(session, stepDecision.ExtractedData) { - configProgressThisTurn = true - } - mergeExtractedData(&session, stepDecision.ExtractedData) - maybeForceStrategyCreateExecutionOnConfirmation(lang, text, &session, &stepDecision) - - 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": - reply := strings.TrimSpace(stepDecision.Reply) - if guarded, blocked := guardUnexecutedActiveTaskCompletion(lang, session, reply); blocked { - session = appendActiveSessionLocalHistory(session, "assistant", guarded) - setActiveSessionPendingHint(&session, guarded) - a.saveActiveSkillSession(session) - emitBrainReply(onEvent, guarded) - a.recordSkillInteraction(userID, text, guarded) - return guarded, true, nil - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked { - session = appendActiveSessionLocalHistory(session, "assistant", guarded) - setActiveSessionPendingHint(&session, guarded) - a.saveActiveSkillSession(session) - emitBrainReply(onEvent, guarded) - a.recordSkillInteraction(userID, text, guarded) - return guarded, true, nil - } - a.clearActiveSkillSession(userID) - if reply == "" { - return "", false, nil - } - emitBrainReply(onEvent, reply) - a.recordSkillInteraction(userID, text, reply) - return reply, true, nil - - case "ask_user": - reply := "" - if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked { - session.CollectedFields["awaiting_final_confirmation"] = true - reply = guarded - } - if reply == "" && configProgressThisTurn { - if deterministic, ok := strategyCreateTemplateMissingReply(lang, text, session); ok { - reply = deterministic - } - } - if reply == "" { - reply = strings.TrimSpace(stepDecision.Reply) - if reply == "" { - reply = a.askForMissingFields(lang, session) - } - } - if guarded, blocked := guardStrategyCreateAINonTemplateQuestion(lang, session, reply); blocked { - reply = guarded - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked { - reply = guarded - } - 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": - var repairReply string - var canExecute bool - session, repairReply, canExecute = a.ensureStrategyCreateExecutableState(ctx, lang, text, session) - if !canExecute { - if strategyCreateLooseConfirmationReply(text) { - repairReply = a.askForMissingFields(lang, session) - } else { - repairReply = defaultIfEmpty(repairReply, a.askForMissingFields(lang, session)) - } - session = appendActiveSessionLocalHistory(session, "assistant", repairReply) - setActiveSessionPendingHint(&session, repairReply) - a.saveActiveSkillSession(session) - emitBrainReply(onEvent, repairReply) - a.recordSkillInteraction(userID, text, repairReply) - return repairReply, true, nil - } - if !strategyCreateLooseConfirmationReply(text) { - if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked { - session.CollectedFields["awaiting_final_confirmation"] = true - session = appendActiveSessionLocalHistory(session, "assistant", guarded) - setActiveSessionPendingHint(&session, guarded) - a.saveActiveSkillSession(session) - emitBrainReply(onEvent, guarded) - a.recordSkillInteraction(userID, text, guarded) - return guarded, true, nil - } - } - 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 - } - - if shouldTrustDeterministicSkillReply(outcome) { - answer := strings.TrimSpace(outcome.UserMessage) - if answer == "" { - return "", false, nil - } - a.clearActiveSkillSession(userID) - emitBrainReply(onEvent, answer) - a.recordSkillInteraction(userID, text, answer) - return answer, 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 strategyCreateLooseConfirmationReply(text string) bool { - if strategyCreateConfirmationReply(text) { - return true - } - lower := strings.ToLower(strings.TrimSpace(text)) - return strings.Contains(lower, "确认创建") || - strings.Contains(lower, "按这个创建") || - strings.Contains(lower, "confirm create") -} - -func (a *Agent) ensureStrategyCreateExecutableState(ctx context.Context, lang, text string, session ActiveSkillSession) (ActiveSkillSession, string, bool) { - if session.SkillName != "strategy_management" || session.ActionName != "create" { - return session, "", true - } - if strategyCreateSessionReady(lang, session) { - return session, "", true - } - if a.aiClient == nil { - return session, "", true - } - - legacy := activeToLegacySkillSession(session) - collectedJSON, _ := json.Marshal(session.CollectedFields) - fieldSpecsJSON, _ := json.Marshal(allowedFieldSpecsForSkillSession(legacy, lang)) - history := formatActiveSessionLocalHistory(session.LocalHistory) - if history == "" { - history = "(empty)" - } - systemPrompt := prependNOFXiAdvisorPreamble(`You repair structured state for one active NOFXi strategy creation task. -Return JSON only. - -Rules: -- Think from the current user message, previous assistant proposal, and active history. -- If the previous assistant already asked the user to confirm a concrete creation proposal in chat and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too. -- For each user message, decide how it relates to the currently selected strategy product template. -- If the message provides explicit values, corrections, preferences, constraints, or asks you to recommend/design, translate only the determinable template fields into extracted_data.config_patch as a StrategyConfig-shaped JSON patch. -- If the message is only a question, explanation request, greeting, or unrelated text, answer it without inventing config_patch. -- Do not silently fill missing fields when the user has not authorized it. But if the user explicitly says things like "你帮我定 / 你推荐 / 按稳健高频设计 / 其他你定", that is authorization for the Agent to design the remaining fields. In that case you must produce a recommended config_patch based on the current strategy template and field limits, and explain which values came from the user versus which values are Agent recommendations. -- The product editor template is the source of truth. Use only fields from the selected product template. -- If the user switches strategy type, set extracted_data.strategy_type to the new type and discard fields from the previous type. Keep only shared fields such as name/description/publish settings. -- In NOFXi product schema, AI500/OI Top/OI Low/static coin-source requests are ai_trading, not grid_trading. -- Strategy creation is chat-executable. Do not tell the user to click a web/app button, open a page, or manually create it elsewhere. -- Do not claim the strategy was created and do not promise future execution ("马上创建", "正在创建", "稍后通知"). This step only repairs state or asks for missing information. -- When the current user message is a confirmation, prefer route="ready" whenever the structured template can be repaired. If it cannot be repaired, route="ask_user" with only the missing fields; never reply that you are about to create it. -- If the template is still incomplete after applying determinable config_patch, ask one natural follow-up question or explain the missing fields. - -Return shape: -{"route":"ready|ask_user","reply":"","extracted_data":{}}`) - userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nCurrent collected fields JSON:\n%s\n\nAllowed field spec JSON:\n%s\n\nActive task history:\n%s", lang, text, string(collectedJSON), string(fieldSpecsJSON), history) - - 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 session, "", false - } - decision, ok := parseStrategyCreateStateRepairDecision(raw) - if !ok { - return session, "", false - } - decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) - mergeExtractedData(&session, decision.ExtractedData) - if decision.Route == "ask_user" { - return session, strings.TrimSpace(decision.Reply), false - } - if strategyCreateSessionReady(lang, session) { - return session, strings.TrimSpace(decision.Reply), true - } - return session, strings.TrimSpace(decision.Reply), false -} - -type strategyCreateStateRepairDecision struct { - Route string `json:"route"` - Reply string `json:"reply,omitempty"` - ExtractedData map[string]any `json:"extracted_data,omitempty"` -} - -func parseStrategyCreateStateRepairDecision(raw string) (strategyCreateStateRepairDecision, bool) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - var d strategyCreateStateRepairDecision - if err := json.Unmarshal([]byte(raw), &d); err != nil { - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start < 0 || end <= start { - return strategyCreateStateRepairDecision{}, false - } - if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil { - return strategyCreateStateRepairDecision{}, false - } - } - d.Route = strings.ToLower(strings.TrimSpace(d.Route)) - d.Reply = strings.TrimSpace(d.Reply) - switch d.Route { - case "ready", "ask_user": - return d, true - default: - return strategyCreateStateRepairDecision{}, false - } -} - -func strategyCreateSessionReady(lang string, session ActiveSkillSession) bool { - legacy := activeToLegacySkillSession(session) - cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang) - if err != nil { - return false - } - ready, _ := strategyCreateConfigReady(legacy, cfg, "") - return ready -} - -func strategyCreateDecisionHasConfigProgress(session ActiveSkillSession, data map[string]any) bool { - if session.SkillName != "strategy_management" || session.ActionName != "create" || len(data) == 0 { - return false - } - patch, ok := data[strategyCreateConfigPatchField] - if !ok { - return false - } - sanitized := sanitizeStrategyCreateConfigPatchForType(patch, defaultIfEmpty(strategyTypeFromExtractedData(data), strategyTypeFromCollectedFields(session.CollectedFields))) - return len(sanitized) > 0 -} - -const strategyCreateConfigProgressThisTurnField = "__strategy_create_config_progress_this_turn" - -func markStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession, data map[string]any) { - if session == nil || !strategyCreateDecisionHasConfigProgress(*session, data) { - return - } - if session.CollectedFields == nil { - session.CollectedFields = map[string]any{} - } - session.CollectedFields[strategyCreateConfigProgressThisTurnField] = true -} - -func consumeStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession) bool { - if session == nil || session.CollectedFields == nil { - return false - } - progress := activeFieldBool(session.CollectedFields[strategyCreateConfigProgressThisTurnField]) - delete(session.CollectedFields, strategyCreateConfigProgressThisTurnField) - return progress -} - -func maybeForceStrategyCreateExecutionOnConfirmation(lang, text string, session *ActiveSkillSession, decision *activeSessionStepDecision) bool { - if session == nil || decision == nil { - return false - } - if session.SkillName != "strategy_management" || session.ActionName != "create" { - return false - } - if !strategyCreateLooseConfirmationReply(text) { - return false - } - if !strategyCreateSessionReady(lang, *session) { - return false - } - if session.CollectedFields == nil { - session.CollectedFields = map[string]any{} - } - session.CollectedFields["awaiting_final_confirmation"] = true - decision.Route = "execute_skill" - decision.Reply = "" - return true -} - -func (a *Agent) activeStrategyCreateSession(userID int64) (ActiveSkillSession, bool) { - if session, ok := a.getActiveSkillSession(userID); ok && session.SkillName == "strategy_management" && session.ActionName == "create" { - return session, true - } - if legacy := a.getSkillSession(userID); legacy.Name == "strategy_management" && legacy.Action == "create" { - return activeSessionFromLegacy(ActiveSkillSession{ - UserID: userID, - SkillName: "strategy_management", - ActionName: "create", - }, legacy), true - } - return ActiveSkillSession{}, false -} - -func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkillSession) (string, bool) { - if session.SkillName != "strategy_management" || session.ActionName != "create" { - return "", false - } - if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) && strategyCreateHasPriorConfirmationPrompt(session) { - return "", false - } - legacy := activeToLegacySkillSession(session) - cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang) - if err != nil { - return "", false - } - if ready, _ := strategyCreateConfigReady(legacy, cfg, ""); !ready { - return "", false - } - return formatStrategyCreateFinalConfirmation(lang, legacy, cfg), true -} - -func strategyCreateTemplateMissingReply(lang, text string, session ActiveSkillSession) (string, bool) { - if session.SkillName != "strategy_management" || session.ActionName != "create" { - return "", false - } - legacy := activeToLegacySkillSession(session) - cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang) - if err != nil { - return "", false - } - ready, missingKind := strategyCreateConfigReady(legacy, cfg, "") - if ready || strings.TrimSpace(missingKind) == "" { - return "", false - } - if reply := formatStrategyCreateFieldOptionsReply(lang, text, missingKind); reply != "" { - return reply, true - } - return formatStrategyCreateConfigNeeded(lang, missingKind), true -} - -func strategyCreateHasPriorConfirmationPrompt(session ActiveSkillSession) bool { - for i := len(session.LocalHistory) - 1; i >= 0; i-- { - msg := session.LocalHistory[i] - if msg.Role != "assistant" { - continue - } - content := strings.TrimSpace(msg.Content) - if content == "" { - continue - } - lower := strings.ToLower(content) - return strings.Contains(content, "确认创建") || - strings.Contains(content, "确认后我再创建") || - strings.Contains(content, "配置整理好了") || - strings.Contains(content, "请确认是否按以上设置创建") || - strings.Contains(content, "如果没问题,我就执行创建") || - strings.Contains(content, "是否按以上设置创建") || - strings.Contains(lower, "confirm") || - strings.Contains(lower, "create it") - } - return false -} - -func activeFieldBool(v any) bool { - switch typed := v.(type) { - case bool: - return typed - case string: - return strings.EqualFold(strings.TrimSpace(typed), "true") - default: - return false - } -} - -func guardUnexecutedActiveTaskCompletion(lang string, session ActiveSkillSession, reply string) (string, bool) { - if !isMutatingActiveTask(session) || !looksLikeCompletionClaim(reply) { - return "", false - } - if lang == "zh" { - if session.SkillName == "strategy_management" { - return "还没有真正创建到策略列表里。刚才只是整理/确认配置方案;需要继续的话,我会先用结构化配置调用策略创建工具,再基于真实结果回复。", true - } - return "还没有真正执行完成。刚才只是继续当前配置流程;需要实际执行时,我会调用对应工具后再基于真实结果回复。", true - } - return "It has not actually been executed yet. The previous step only prepared or confirmed the draft; I need to run the structured tool before claiming completion.", true -} - -func guardStrategyCreateAINonTemplateQuestion(lang string, session ActiveSkillSession, reply string) (string, bool) { - if session.SkillName != "strategy_management" || session.ActionName != "create" { - return "", false - } - if strategyTypeFromCollectedFields(session.CollectedFields) != "ai_trading" { - return "", false - } - lower := strings.ToLower(strings.TrimSpace(reply)) - if lower == "" { - return "", false - } - if !containsAny(lower, []string{ - "投入多少", "投入资金", "总投入", "固定投入", "每笔交易", "每笔固定", "100u", "500u", "1000u", - "止损", "日亏损", "最大回撤", - "investment amount", "capital", "fixed amount", "per-trade", "stop loss", "daily loss", "max drawdown", - }) { - return "", false - } - legacy := activeToLegacySkillSession(session) - cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang) - if err != nil { - return "", false - } - _, missingKind := strategyCreateConfigReady(legacy, cfg, "") - if strings.TrimSpace(missingKind) == "" { - return "", false - } - if lang == "zh" { - return "这些不是 AI 策略创建模板里的字段。我会继续按 AI 策略模板填写;当前还需要围绕选币来源、周期、杠杆、置信度、盈亏比、交易频率和开仓标准来确定配置。你也可以直接说“全部你定,按稳健/高频/激进”。", true - } - return "Those are not fields in the AI strategy creation template. I will continue using the AI strategy template: coin source, timeframes, leverage, confidence, risk/reward, trading frequency, and entry standards.", true -} - -func guardUnsupportedAsyncPromise(lang, reply string) (string, bool) { - lower := strings.ToLower(strings.TrimSpace(reply)) - if lower == "" { - return "", false - } - promiseSignals := []string{ - "请稍等", "稍等片刻", "再稍等", "马上", "稍后", "立刻告诉", "数据一出来", "一两分钟", - "还在进行", "正在进行", "正在为", "正在帮", "一直在帮", "诊断中", "分析中", - "please wait", "give me a moment", "still running", "i'll let you know", "i will let you know", - } - taskSignals := []string{ - "诊断", "分析", "历史交易", "历史表现", "亏损原因", "创建", "修改", "删除", "启动", "停止", - "diagnos", "analyz", "history", "performance", "loss", "create", "update", "delete", "start", "stop", - } - if !containsAny(lower, promiseSignals) || !containsAny(lower, taskSignals) { - return "", false - } - if lang == "zh" { - return "我需要纠正一下:我没有后台异步任务在运行,也不会自动推送后续结果。诊断/创建/修改/启动这类任务必须在当前回复里实际执行并给出真实结果;如果还不能执行,我应该直接说明缺少哪个对象、时间范围或数据。", true - } - return "I need to correct that: there is no background task running, and I will not automatically push a later result. Diagnosis/create/update/start tasks must actually execute and return a real result in the current response; if execution is not possible, I should state which target, range, or data is missing.", true -} - -func isMutatingActiveTask(session ActiveSkillSession) bool { - if strings.TrimSpace(session.SkillName) == "" { - return false - } - switch strings.TrimSpace(session.ActionName) { - case "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": - return true - default: - return false - } -} - -func looksLikeCompletionClaim(reply string) bool { - lower := strings.ToLower(strings.TrimSpace(reply)) - if lower == "" { - return false - } - return containsAny(lower, []string{ - "已创建", "创建好了", "创建好", "已经创建", "已更新", "更新好了", "已修改", "已删除", "已启动", "已停止", "已激活", "已复制", "已经完成", "已完成", - "created", "has been created", "updated", "deleted", "started", "stopped", "activated", "duplicated", "completed", - }) -} - -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)) - fieldSpecs := allowedFieldSpecsForSkillSession(legacy, lang) - fieldSpecsJSON, _ := json.Marshal(fieldSpecs) - localHistory := formatActiveSessionLocalHistory(session.LocalHistory) - if localHistory == "" { - localHistory = "(empty)" - } - previousAssistantReply := a.currentPendingHintText(userID) - - domainPrimer := buildSkillDomainPrimerForSession(lang, legacy) - specificRules := activeSessionSpecificRules(legacy) - - 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 - -Allowed field spec JSON: -%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. -%s -- For any mutating task, a reply that only promises future execution ("now I will create/update/start it", "result soon") is not a valid finish_task or ask_user outcome. If execution is the next step, choose execute_skill. -- For diagnosis, create, update, delete, start, stop, query/history, and performance-analysis tasks, never answer with only "马上处理 / 请稍等 / 诊断中 / I'll tell you later". NOFXi has no background chat job that will later push an answer. Choose execute_skill/planned_agent when enough information exists; otherwise ask for the missing target/range/data. -- Never choose finish_task for an unfinished mutating active task by claiming it was created/updated/deleted/started/stopped. Only a real skill/tool execution outcome can support that claim. -- If the user says they do not understand the current form, choices, or required information, choose "ask_user" and explain the current pending question in plain language before asking the next easiest question. Cover the relevant concepts from the previous assistant reply; do not collapse the answer to only the first missing field. -- For beginner/confusion replies, give a safe recommended path when the domain supports one, but do not execute or create anything unless the user confirms after the explanation. -- 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. -- Current references are context for reasoning only. Do not copy a current reference into target_ref_id/target_ref_name unless the user explicitly refers to that object by name/id or clearly says "this/current/that previous one". If the target is not clear, ask instead of executing. -- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource. -- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names. -- If the current user message answers a missing field directly, extract it and continue. -- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys. -- If a user-provided value does not fit one of those canonical keys, omit it; never create another key. -- If this task is already done and the best next step is just to tell the user the result, choose "finish_task". -- If the user aborts the task, choose "cancel_task". - -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(string(fieldSpecsJSON), "[]"), - defaultIfEmpty(domainPrimer, "(none)"), - specificRules, - )) - 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 - } - decision, ok := parseActiveSessionStepDecision(raw) - if !ok { - return activeSessionStepDecision{}, false - } - decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) - return decision, true -} - -func activeSessionSpecificRules(session skillSession) string { - if session.Name != "strategy_management" { - return "" - } - switch session.Action { - case "create", "update_config": - return strings.Join([]string{ - "- For strategy_management:create/update_config, the selected product editor template is the only schema. Write values only through extracted_data.config_patch, using the current type branch only: ai_trading => strategy_type + ai_config + publish_config; grid_trading => strategy_type + grid_config + publish_config.", - "- For strategy_management:create/update_config, config_patch values must be product schema raw values, not user-facing labels. Examples: source_type=\"ai500\" not \"AI500\"; strategy_type=\"ai_trading\" not \"AI 策略\"; selected_timeframes=[\"1m\",\"5m\",\"15m\"] not a JSON string.", - "- For strategy_management:create, AI500/OI Top/OI Low/static coin-source requests imply strategy_type=\"ai_trading\". Do not leave strategy type ambiguous in that case.", - "- For strategy_management:create/update_config, judge the user's natural-language intent. Explicit values, corrections, constraints, preferences, or requests to recommend/design must become config_patch for every determinable current-template field; pure questions/greetings/acknowledgements must not invent config_patch.", - "- For strategy_management:create, the Relevant disclosed resources include product_default_template and current_missing_template_fields. Treat product_default_template as the product editor's default template and field shape.", - "- For strategy_management:create, do not ask for or present fields listed in product_default_template.non_fields. They are not part of the selected product editor template.", - "- For strategy_management:create, when the user states a strategy style/preference or authorizes the Agent to recommend/design remaining settings, use product_default_template as the base, adjust it to the user's stated preference, and output config_patch that fills every determinable missing template field. Do not ask the user to restate fields that can be responsibly selected from the default template.", - "- For grid_trading create, if the user authorizes the Agent to choose/recommend remaining settings, set grid_config.use_atr_bounds=true for the price range unless the user explicitly gives manual upper_price/lower_price. Never invent current market prices or say a symbol is currently near a price without a fresh market-data tool result.", - "- For strategy_management:create, any user-facing strategy plan must be generated from the post-merge structured config built from config_patch and the current strategy type. Do not display fields that would be filtered out or belong to the other strategy type.", - "- For strategy_management:create, once complete, ask for one chat confirmation with awaiting_final_confirmation=true; after confirmation execute synchronously with empty reply and only report success after the tool returns.", - }, "\n") - default: - return "" - } -} - -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 -} - -// shouldTrustDeterministicSkillReply controls whether a Go-generated UserMessage -// is shown verbatim to the user (true) or whether the LLM gets to review the -// tool outcome and write a natural reply (false). -// -// Historically this returned true for every successful mutation on trader / -// strategy / model / exchange — which meant the user always saw the same -// canned `fmt.Sprintf` lines (e.g. "已创建 Trader: X. 我没有自动启动..."), and -// the agent felt mechanical / "non-agentic". It now always returns false so the -// LLM owns the voice. The cost is one extra LLM call per mutation; the upside -// is that the agent can chain ("trader created — want me to start it now?"), -// apologize on errors in plain language, respect the user's language and -// tone, and behave like an actual agent instead of a settings panel. -// -// The trade-confirmation flow (execute_trade -> "确认 trade_xxx") is unaffected: -// it runs through handleTradeConfirmation in trade.go before this code path. -func shouldTrustDeterministicSkillReply(_ skillOutcome) bool { - return false -} - -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 := activeFieldString(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 - } - } - if s.SkillName == "strategy_management" && s.ActionName == "create" && legacy.Fields["name"] == "" { - for i := len(s.LocalHistory) - 1; i > 0; i-- { - msg := s.LocalHistory[i] - if msg.Role != "user" || !activeHistoryMessageAsksStrategyName(s.LocalHistory[i-1].Content) { - continue - } - if inferred := inferStandaloneStrategyName(msg.Content); inferred != "" { - legacy.Fields["name"] = inferred - break - } - } - } - return legacy -} - -func activeFieldString(value any) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case map[string]any, []any, map[string]string, []string: - raw, err := json.Marshal(v) - if err != nil { - return "" - } - return strings.TrimSpace(string(raw)) - default: - return strings.TrimSpace(fmt.Sprint(v)) - } -} - -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": - resources := a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID)) - if strategyType := explicitStrategyCreateType(session); strategyType != "" { - lang := defaultIfEmpty(a.config.Language, "zh") - resources["current_strategy_type"] = strategyType - resources["current_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType) - if session.Action == "create" || session.Action == "update_config" { - resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType) - if cfg, _, _, err := strategyCreateConfigFromSession(session, lang); err == nil { - resources["current_missing_template_fields"] = strategyCreateMissingTemplateFields(session, cfg) - } - } - } else if strategyType, ok := a.strategyTypeForTarget(storeUserID, session.TargetRef); ok { - lang := defaultIfEmpty(a.config.Language, "zh") - resources["target_strategy_type"] = strategyType - resources["target_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType) - resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType) - } - return resources - default: - return nil - } -} - -func strategyProductDefaultTemplateResource(lang, strategyType string) map[string]any { - cfg := store.GetDefaultStrategyConfig(defaultIfEmpty(lang, "zh")) - cfg.StrategyType = strings.TrimSpace(strategyType) - cfg.ClampLimits() - publish := map[string]any{ - "is_public": false, - "config_visible": true, - } - switch cfg.StrategyType { - case "grid_trading": - grid := cfg.GridConfig - if grid == nil { - defaultGrid := store.DefaultGridStrategyConfig() - grid = &defaultGrid - } - return map[string]any{ - "strategy_type": "grid_trading", - "grid_config": grid, - "publish_config": publish, - "required_fields": strategyCreateMissingGridFields(skillSession{}), - } - default: - return map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{ - "source_type": cfg.CoinSource.SourceType, - "static_coins": cfg.CoinSource.StaticCoins, - "excluded_coins": cfg.CoinSource.ExcludedCoins, - "ai500_limit": cfg.CoinSource.AI500Limit, - "oi_top_limit": cfg.CoinSource.OITopLimit, - "oi_low_limit": cfg.CoinSource.OILowLimit, - }, - "indicators": map[string]any{ - "klines": map[string]any{ - "primary_timeframe": cfg.Indicators.Klines.PrimaryTimeframe, - "primary_count": cfg.Indicators.Klines.PrimaryCount, - "selected_timeframes": cfg.Indicators.Klines.SelectedTimeframes, - }, - "enable_ema": cfg.Indicators.EnableEMA, - "enable_macd": cfg.Indicators.EnableMACD, - "enable_rsi": cfg.Indicators.EnableRSI, - "enable_atr": cfg.Indicators.EnableATR, - "enable_boll": cfg.Indicators.EnableBOLL, - "enable_volume": cfg.Indicators.EnableVolume, - "enable_oi": cfg.Indicators.EnableOI, - "enable_funding_rate": cfg.Indicators.EnableFundingRate, - }, - "risk_control": map[string]any{ - "btc_eth_max_leverage": cfg.RiskControl.BTCETHMaxLeverage, - "altcoin_max_leverage": cfg.RiskControl.AltcoinMaxLeverage, - "min_confidence": cfg.RiskControl.MinConfidence, - "min_risk_reward_ratio": cfg.RiskControl.MinRiskRewardRatio, - }, - "prompt_sections": map[string]any{ - "trading_frequency": cfg.PromptSections.TradingFrequency, - "entry_standards": cfg.PromptSections.EntryStandards, - }, - "custom_prompt": cfg.CustomPrompt, - }, - "publish_config": publish, - "non_fields": []string{ - "investment_amount", - "fixed_position_size", - "stop_loss_pct", - "daily_loss_limit_pct", - "max_drawdown_pct", - }, - "required_fields": []string{ - "source_type", - "primary_timeframe", - "selected_timeframes", - "btceth_max_leverage", - "altcoin_max_leverage", - "min_confidence", - "min_risk_reward_ratio", - "trading_frequency", - "entry_standards", - }, - } - } -} - -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{} - } - if s.SkillName == "strategy_management" && s.ActionName == "create" { - if incomingType := strategyTypeFromExtractedData(data); incomingType != "" { - currentType := strategyTypeFromCollectedFields(s.CollectedFields) - if currentType != "" && currentType != incomingType { - resetActiveStrategyCreateFieldsForType(s, incomingType) - } - } - } - for k, v := range data { - k = strings.TrimSpace(k) - if k == "" { - continue - } - s.CollectedFields[k] = v - } -} - -func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[string]any, lang string) map[string]any { - if len(data) == 0 { - return data - } - specs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(session), lang) - if len(specs) == 0 { - return nil - } - allowed := make(map[string]struct{}, len(specs)) - for _, spec := range specs { - key := strings.TrimSpace(spec.Key) - if key != "" { - allowed[key] = struct{}{} - } - } - out := make(map[string]any, len(data)) - for key, value := range data { - key = strings.TrimSpace(key) - if key == "" { - continue - } - if _, ok := allowed[key]; !ok { - continue - } - out[key] = value - } - if session.SkillName == "strategy_management" && session.ActionName == "create" { - out = filterStrategyCreateExtractedDataByTemplate(session, out) - } - if len(out) == 0 { - return nil - } - return out -} - -func strategyTypeFromExtractedData(data map[string]any) string { - if len(data) == 0 { - return "" - } - if value, ok := data["strategy_type"]; ok { - if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" { - return strategyType - } - } - if patch, ok := data[strategyCreateConfigPatchField]; ok { - if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" { - return strategyType - } - } - return "" -} - -func strategyTypeFromCollectedFields(fields map[string]any) string { - if len(fields) == 0 { - return "" - } - if value, ok := fields["strategy_type"]; ok { - if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" { - return strategyType - } - } - if patch, ok := fields[strategyCreateConfigPatchField]; ok { - if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" { - return strategyType - } - } - return "" -} - -func strategyTypeFromConfigPatchAny(value any) string { - patch := mapFromAny(value) - if len(patch) == 0 { - return "" - } - if strategyType := parseStrategyTypeValue(fmt.Sprint(patch["strategy_type"])); strategyType != "" { - return strategyType - } - if _, ok := patch["grid_config"]; ok { - return "grid_trading" - } - if _, ok := patch["ai_config"]; ok { - return "ai_trading" - } - return "" -} - -func resetActiveStrategyCreateFieldsForType(s *ActiveSkillSession, strategyType string) { - if s.CollectedFields == nil { - s.CollectedFields = map[string]any{} - } - keep := map[string]any{} - for _, key := range []string{"name", "description", "is_public", "config_visible", "lang"} { - if value, ok := s.CollectedFields[key]; ok { - keep[key] = value - } - } - keep["strategy_type"] = strategyType - s.CollectedFields = keep -} - -func filterStrategyCreateExtractedDataByTemplate(session ActiveSkillSession, data map[string]any) map[string]any { - if len(data) == 0 { - return data - } - strategyType := strategyTypeFromExtractedData(data) - if strategyType == "" { - strategyType = strategyTypeFromCollectedFields(session.CollectedFields) - } - if strategyType == "" { - return data - } - allowed := map[string]struct{}{} - for _, key := range manualStrategyEditableFieldKeysForType(strategyType) { - allowed[key] = struct{}{} - } - out := make(map[string]any, len(data)) - for key, value := range data { - if key == strategyCreateConfigPatchField { - if patch := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(patch) > 0 { - out[key] = patch - } - continue - } - if key == "awaiting_final_confirmation" { - out[key] = value - continue - } - if _, ok := allowed[key]; ok { - out[key] = value - } - } - if len(out) == 0 { - return nil - } - return out -} - -func sanitizeStrategyCreateConfigPatchForType(value any, strategyType string) map[string]any { - patch := mapFromAny(value) - if len(patch) == 0 { - return nil - } - out := map[string]any{ - "strategy_type": strategyType, - } - if publish := mapFromAny(patch["publish_config"]); len(publish) > 0 { - out["publish_config"] = publish - } - switch strategyType { - case "grid_trading": - if grid := mapFromAny(patch["grid_config"]); len(grid) > 0 { - out["grid_config"] = grid - } - case "ai_trading": - ai := mapFromAny(patch["ai_config"]) - if ai == nil { - ai = map[string]any{} - } - for _, key := range []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} { - if value, ok := patch[key]; ok { - ai[key] = value - } - } - if len(ai) > 0 { - out["ai_config"] = ai - } - } - if len(out) == 1 { - return nil - } - return out -} - -func mapFromAny(value any) map[string]any { - switch typed := value.(type) { - case map[string]any: - return typed - case string: - var out map[string]any - if err := json.Unmarshal([]byte(typed), &out); err == nil { - return out - } - } - return nil -} - -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 deleted file mode 100644 index 05b5c91e..00000000 --- a/agent/clear_memory_test.go +++ /dev/null @@ -1,116 +0,0 @@ -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_validation.go b/agent/config_validation.go deleted file mode 100644 index 7c443caa..00000000 --- a/agent/config_validation.go +++ /dev/null @@ -1,466 +0,0 @@ -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 v.enabled { - missing := store.MissingRequiredExchangeCredentialFields( - exchangeType, - v.apiKey, - v.secretKey, - v.passphrase, - v.hyperliquidWalletAddr, - v.asterUser, - v.asterSigner, - v.asterPrivateKey, - v.lighterWalletAddr, - v.lighterAPIKeyPrivateKey, - ) - if len(missing) > 0 { - return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", ")) - } - } - 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)) - } - } - } - 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 formatRiskControlRefusalPrompt(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 did not save the original request as-is:", - } - 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 deleted file mode 100644 index d5bfb0e3..00000000 --- a/agent/config_visibility_test.go +++ /dev/null @@ -1,692 +0,0 @@ -package agent - -import ( - "encoding/json" - "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 TestToolManageModelConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "model-create-enabled.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"qwen","api_key":"sk-test-qwen-123456","custom_model_name":"qwen3-max"}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected create to succeed, got: %s", resp) - } - - model, err := st.AIModel().Get("default", "default_qwen") - if err != nil { - t.Fatalf("load created model: %v", err) - } - if !model.Enabled { - t.Fatalf("expected agent-created model to default to enabled so it matches manual creation") - } -} - -func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "model-create-upsert.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_qwen", "qwen1", false, "sk-old-qwen-123456", "", "qwen3-max"); err != nil { - t.Fatalf("seed existing qwen model: %v", err) - } - - resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"Qwen","api_key":"sk-new-qwen-123456","custom_model_name":"qwen3-max"}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected create to reuse existing qwen config instead of failing, got: %s", resp) - } - - models, err := st.AIModel().List("default") - if err != nil { - t.Fatalf("list models: %v", err) - } - qwenCount := 0 - for _, model := range models { - if model != nil && model.Provider == "qwen" { - qwenCount++ - if model.ID != "default_qwen" { - t.Fatalf("expected existing qwen record to be reused, got model id %q", model.ID) - } - if model.Name != "Qwen" { - t.Fatalf("expected reused qwen record to be renamed, got %q", model.Name) - } - if !model.Enabled { - t.Fatalf("expected reused qwen record to be enabled after agent create") - } - } - } - if qwenCount != 1 { - t.Fatalf("expected exactly one qwen record after reuse, got %d", qwenCount) - } -} - -func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "exchange-create-enabled.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"binance","account_name":"Binance Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected create to succeed, got: %s", resp) - } - - exchanges, err := st.Exchange().List("default") - if err != nil { - t.Fatalf("list exchanges: %v", err) - } - if len(exchanges) != 1 || exchanges[0] == nil { - t.Fatalf("expected one created exchange, got %#v", exchanges) - } - if !exchanges[0].Enabled { - t.Fatalf("expected agent-created exchange to default to enabled so it matches manual creation") - } -} - -func TestToolManageExchangeConfigCreateRejectsIncompleteDraft(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "exchange-create-incomplete.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"okx","account_name":"OKX Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`) - if !strings.Contains(resp, `"error"`) || !strings.Contains(resp, "passphrase") { - t.Fatalf("expected incomplete create to be rejected with missing passphrase, got: %s", resp) - } - - exchanges, err := st.Exchange().List("default") - if err != nil { - t.Fatalf("list exchanges: %v", err) - } - if len(exchanges) != 0 { - t.Fatalf("expected incomplete exchange not to be persisted, got %#v", exchanges) - } -} - -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 TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-risk-guard.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - strategy := &store.Strategy{ - ID: "strategy-risk-guard", - UserID: "default", - Name: "AI500稳重策略", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - } - if err := st.Strategy().Create(strategy); err != nil { - t.Fatalf("create strategy: %v", err) - } - - resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-risk-guard","config":{"risk_control":{"btc_eth_max_leverage":100,"altcoin_max_leverage":100}}}`) - if !strings.Contains(resp, `不会按你给的原值直接保存`) { - t.Fatalf("expected out-of-range leverage update to be rejected before save, got: %s", resp) - } - - updated, err := st.Strategy().Get("default", strategy.ID) - if err != nil { - t.Fatalf("reload strategy: %v", err) - } - parsed, err := updated.ParseConfig() - if err != nil { - t.Fatalf("parse updated strategy config: %v", err) - } - if parsed.RiskControl.BTCETHMaxLeverage != 5 || parsed.RiskControl.AltcoinMaxLeverage != 5 { - t.Fatalf("expected stored leverage to remain unchanged at safe defaults, got btc_eth=%d alt=%d", parsed.RiskControl.BTCETHMaxLeverage, parsed.RiskControl.AltcoinMaxLeverage) - } -} - -func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-fixed-min-position.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - strategy := &store.Strategy{ - ID: "strategy-fixed-min-position", - UserID: "default", - Name: "固定最小开仓策略", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - } - if err := st.Strategy().Create(strategy); err != nil { - t.Fatalf("create strategy: %v", err) - } - - resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-fixed-min-position","config":{"risk_control":{"min_position_size":20}}}`) - if !strings.Contains(resp, "固定值 12 USDT") { - t.Fatalf("expected fixed min position size rejection, got: %s", resp) - } - - updated, err := st.Strategy().Get("default", strategy.ID) - if err != nil { - t.Fatalf("reload strategy: %v", err) - } - parsed, err := updated.ParseConfig() - if err != nil { - t.Fatalf("parse updated strategy config: %v", err) - } - if parsed.RiskControl.MinPositionSize != 12 { - t.Fatalf("expected stored min position size to remain fixed at 12, got %v", parsed.RiskControl.MinPositionSize) - } -} - -func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "exchange-options.db") - st, err := store.New(dbPath) - 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 TestLoadExchangeOptionsHidesInvisibleExchangeRows(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "exchange-options-visible.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := store.DB().Create(&store.Exchange{ - ID: "hidden-exchange", - UserID: "default", - ExchangeType: "okx", - AccountName: "123413", - Name: "OKX Futures", - Type: "cex", - Enabled: false, - }).Error; err != nil { - t.Fatalf("seed legacy hidden exchange: %v", err) - } - if _, err := st.Exchange().Create("default", "okx", "我的主力OKX账户", true, "api-test", "secret-test", "pass-test", false, "", false, false, "", "", "", "", "", "", 0); err != nil { - t.Fatalf("create visible exchange: %v", err) - } - - options := a.loadExchangeOptions("default") - if len(options) != 1 { - t.Fatalf("expected only the visible exchange option, got %+v", options) - } - if options[0].Name != "我的主力OKX账户" { - t.Fatalf("expected visible exchange name, got %+v", options) - } -} - -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, false, "", "", "", "", "", "", 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, false, "", "", "", "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) - } -} - -func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-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", "strategy_management", "update_config") - for _, expected := range []string{"发布到市场", "配置可见", "交易对", "杠杆", "主周期", "多周期时间框架", "NofxOS API key", "角色定义", "自定义 Prompt"} { - if !strings.Contains(summary, expected) { - t.Fatalf("expected field label %q in summary, got: %s", expected, summary) - } - } - if strings.Contains(summary, "最小开仓金额") { - t.Fatalf("strategy field summary should not expose fixed min position size editing: %s", summary) - } -} - -func TestStrategyVisibleFieldSummaryUsesTargetStrategyType(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-type-field-summary.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - cfg := store.GetDefaultStrategyConfig("zh") - cfg.StrategyType = "grid_trading" - cfg.GridConfig = &store.GridStrategyConfig{ - Symbol: "ETHUSDT", - GridCount: 12, - TotalInvestment: 1000, - Leverage: 3, - UseATRBounds: true, - ATRMultiplier: 2, - Distribution: "gaussian", - } - raw, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - strategy := &store.Strategy{ - ID: "strategy-grid-fields", - UserID: "default", - Name: "我的第一个网格策略", - Description: "", - IsPublic: false, - ConfigVisible: true, - Config: string(raw), - } - if err := st.Strategy().Create(strategy); err != nil { - t.Fatalf("create strategy: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - session := skillSession{ - Name: "strategy_management", - Action: "update_config", - TargetRef: &EntityReference{ - ID: strategy.ID, - Name: strategy.Name, - }, - } - resources := a.buildActiveSessionResources("default", session) - if got := resources["target_strategy_type"]; got != "grid_trading" { - t.Fatalf("expected grid strategy type in resources, got: %#v", got) - } - fields, ok := resources["target_editable_fields"].([]string) - if !ok { - t.Fatalf("expected editable field list in resources, got: %#v", resources["target_editable_fields"]) - } - joined := strings.Join(fields, ",") - if !strings.Contains(joined, "symbol") || strings.Contains(joined, "source_type") { - t.Fatalf("expected grid-only editable fields in resources, got: %s", joined) - } -} - -func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-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", "trader_management", "update") - for _, expected := range []string{"交易所", "模型", "策略", "扫描间隔", "全仓模式", "竞技场显示"} { - if !strings.Contains(summary, expected) { - t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary) - } - } - for _, unexpected := range []string{"名称", "初始资金", "初始余额", "杠杆", "交易对", "Prompt", "AI500", "OI Top"} { - if strings.Contains(summary, unexpected) { - t.Fatalf("trader field summary should stay within manual panel fields, got: %s", summary) - } - } -} - -func TestToolUpdateTraderRejectsRenameOutsideManualPanel(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-update-reject-rename.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed exchange: %v", err) - } - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-trader-rename", - UserID: "default", - Name: "Rename Strategy", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - if err := st.Trader().Create(&store.Trader{ - ID: "trader-rename", - UserID: "default", - Name: "原交易员", - AIModelID: "default_deepseek", - ExchangeID: exchangeID, - StrategyID: "strategy-trader-rename", - InitialBalance: 1000, - ScanIntervalMinutes: 5, - IsCrossMargin: true, - ShowInCompetition: true, - }); err != nil { - t.Fatalf("seed trader: %v", err) - } - - resp := a.toolManageTrader("default", `{"action":"update","trader_id":"trader-rename","name":"新名字"}`) - if !strings.Contains(resp, "trader rename is not supported here") { - t.Fatalf("expected rename rejection, got: %s", resp) - } -} - -func TestToolCreateTraderResponseHidesLegacyTraderTuningFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-create-response-shape.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed exchange: %v", err) - } - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-trader-shape", - UserID: "default", - Name: "Shape Strategy", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - - originalFetcher := traderInitialBalanceFetcher - traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) { - return 88.5, true, nil - } - defer func() { - traderInitialBalanceFetcher = originalFetcher - }() - - resp := a.toolManageTrader("default", `{"action":"create","name":"形状测试","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-trader-shape"}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected trader create to succeed, got: %s", resp) - } - for _, blocked := range []string{"btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "system_prompt_template"} { - if strings.Contains(resp, blocked) { - t.Fatalf("expected trader create response to hide legacy tuning field %q, got: %s", blocked, resp) - } - } -} - -func TestToolCreateTraderAutoReadsInitialBalanceFromExchange(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-auto-balance.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed exchange: %v", err) - } - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-auto-balance", - UserID: "default", - Name: "Auto Balance Strategy", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - - originalFetcher := traderInitialBalanceFetcher - traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) { - if exchangeCfg == nil || exchangeCfg.ID != exchangeID { - t.Fatalf("unexpected exchange config passed to balance fetcher: %#v", exchangeCfg) - } - if userID != "default" { - t.Fatalf("unexpected user id %q", userID) - } - return 4321.25, true, nil - } - defer func() { - traderInitialBalanceFetcher = originalFetcher - }() - - resp := a.toolManageTrader("default", `{"action":"create","name":"奶茶","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-auto-balance","initial_balance":999}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected trader create to succeed, got: %s", resp) - } - - traders, err := st.Trader().List("default") - if err != nil { - t.Fatalf("list traders: %v", err) - } - if len(traders) != 1 { - t.Fatalf("expected one trader, got %d", len(traders)) - } - if traders[0].InitialBalance != 4321.25 { - t.Fatalf("expected initial balance to be auto-read from exchange, got %.2f", traders[0].InitialBalance) - } -} - -func TestDescribeStrategyIncludesManualPageSections(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-detail.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - cfg := store.GetDefaultStrategyConfig("zh") - cfg.StrategyType = "grid_trading" - cfg.GridConfig = &store.GridStrategyConfig{ - Symbol: "BTCUSDT", - GridCount: 12, - TotalInvestment: 1500, - Leverage: 4, - UpperPrice: 120000, - LowerPrice: 90000, - UseATRBounds: false, - ATRMultiplier: 2, - Distribution: "gaussian", - MaxDrawdownPct: 15, - StopLossPct: 5, - DailyLossLimitPct: 10, - UseMakerOnly: true, - EnableDirectionAdjust: true, - DirectionBiasRatio: 0.7, - } - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - - strategy := &store.Strategy{ - ID: "strategy-detail-1", - UserID: "default", - Name: "Grid Alpha", - Description: "grid strategy for regression", - IsPublic: true, - ConfigVisible: true, - Config: string(rawCfg), - } - if err := st.Strategy().Create(strategy); err != nil { - t.Fatalf("create strategy: %v", err) - } - strategy.ConfigVisible = false - if err := st.Strategy().Update(strategy); err != nil { - t.Fatalf("update strategy visibility: %v", err) - } - - detail, ok := a.describeStrategy("default", "zh", &EntityReference{ID: strategy.ID}) - if !ok { - t.Fatal("expected describeStrategy to resolve seeded strategy") - } - for _, expected := range []string{ - "策略“Grid Alpha”概览", - "发布设置:已发布到市场;配置隐藏", - "网格参数:交易对 BTCUSDT;网格 12;总投资 1500.00;杠杆 4;分布 gaussian", - "网格边界:上沿 120000.0000,下沿 90000.0000", - } { - if !strings.Contains(detail, expected) { - t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail) - } - } - for _, unexpected := range []string{ - "标的来源:", - "NofxOS 数据:", - } { - if strings.Contains(detail, unexpected) { - t.Fatalf("expected grid strategy detail not to contain AI field %q, got: %s", unexpected, detail) - } - } -} diff --git a/agent/entity_field_catalog.go b/agent/entity_field_catalog.go deleted file mode 100644 index 7cc4712f..00000000 --- a/agent/entity_field_catalog.go +++ /dev/null @@ -1,111 +0,0 @@ -package agent - -type entityFieldMeta struct { - Key string - Keywords []string - ValueType string - ManualEditable bool - AgentUpdatable bool -} - -var traderFieldCatalog = []entityFieldMeta{ - {Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, - {Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, - {Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true}, - {Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true}, - {Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, - {Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true}, -} - -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 deleted file mode 100644 index fc82176d..00000000 --- a/agent/execution_state.go +++ /dev/null @@ -1,650 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/google/uuid" -) - -const ( - executionStatusPlanning = "planning" - executionStatusRunning = "running" - executionStatusWaitingUser = "waiting_user" - executionStatusCompleted = "completed" - executionStatusFailed = "failed" -) - -const ( - planStepTypeTool = "tool" - planStepTypeReason = "reason" - planStepTypeAskUser = "ask_user" - planStepTypeRespond = "respond" -) - -const ( - planStepStatusPending = "pending" - planStepStatusRunning = "running" - planStepStatusCompleted = "completed" - planStepStatusFailed = "failed" -) - -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"` - CurrentReferences *CurrentReferences `json:"current_references,omitempty"` - 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 { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title,omitempty"` - Status string `json:"status,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolArgs map[string]any `json:"tool_args,omitempty"` - Instruction string `json:"instruction,omitempty"` - RequiresConfirmation bool `json:"requires_confirmation,omitempty"` - OutputSummary string `json:"output_summary,omitempty"` - Error string `json:"error,omitempty"` -} - -type Observation struct { - StepID string `json:"step_id,omitempty"` - Kind string `json:"kind"` - Summary string `json:"summary"` - RawJSON string `json:"raw_json,omitempty"` - CreatedAt string `json:"created_at"` -} - -type WaitingState struct { - Question string `json:"question,omitempty"` - Intent string `json:"intent,omitempty"` - PendingFields []string `json:"pending_fields,omitempty"` - ConfirmationTarget string `json:"confirmation_target,omitempty"` - CreatedAt string `json:"created_at,omitempty"` -} - -type EntityReference struct { - 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 { - Strategy *EntityReference `json:"strategy,omitempty"` - Trader *EntityReference `json:"trader,omitempty"` - Model *EntityReference `json:"model,omitempty"` - 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"` -} - -const ( - executionLogMaxEntries = 8 - summaryNotesMaxEntries = 4 -) - -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{} - } - raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID)) - if err != nil { - a.logger.Warn("failed to load execution state", "error", err, "user_id", userID) - return ExecutionState{} - } - raw = strings.TrimSpace(raw) - if raw == "" { - return ExecutionState{} - } - - var state ExecutionState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID) - return ExecutionState{} - } - return normalizeExecutionState(state) -} - -func (a *Agent) saveExecutionState(state ExecutionState) error { - if a.store == nil { - return fmt.Errorf("store unavailable") - } - state = normalizeExecutionState(state) - 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 - } - return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data)) -} - -func (a *Agent) clearExecutionState(userID int64) { - if a.store == nil { - return - } - if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil { - a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID) - } -} - -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{ - SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()), - UserID: userID, - Goal: strings.TrimSpace(goal), - Status: executionStatusPlanning, - PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()), - UpdatedAt: now, - }) -} - -func normalizeExecutionState(state ExecutionState) ExecutionState { - state.Goal = strings.TrimSpace(state.Goal) - state.Status = strings.TrimSpace(state.Status) - state.CurrentStepID = strings.TrimSpace(state.CurrentStepID) - 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 - } - for i := range state.Steps { - state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID) - if state.Steps[i].ID == "" { - state.Steps[i].ID = fmt.Sprintf("step_%d", i+1) - } - state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type) - state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title) - state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName) - state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction) - state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary) - state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error) - if state.Steps[i].Status == "" { - state.Steps[i].Status = planStepStatusPending - } - } - if len(state.Observations) > 0 { - state.ExecutionLog = append(state.ExecutionLog, state.Observations...) - state.Observations = nil - } - state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots) - state.ExecutionLog = normalizeObservationList(state.ExecutionLog) - state.SummaryNotes = normalizeObservationList(state.SummaryNotes) - state = compactExecutionLog(state) - if state.UpdatedAt == "" && state.SessionID != "" { - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - } - 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 - } - waiting.Question = strings.TrimSpace(waiting.Question) - waiting.Intent = strings.TrimSpace(waiting.Intent) - waiting.PendingFields = cleanStringList(waiting.PendingFields) - waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget) - if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") { - waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339) - } - if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" { - return nil - } - return waiting -} - -func normalizeEntityReference(ref *EntityReference) *EntityReference { - if ref == nil { - return nil - } - 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 -} - -func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences { - if refs == nil { - return nil - } - refs.Strategy = normalizeEntityReference(refs.Strategy) - refs.Trader = normalizeEntityReference(refs.Trader) - refs.Model = normalizeEntityReference(refs.Model) - refs.Exchange = normalizeEntityReference(refs.Exchange) - if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil { - return nil - } - 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 - } - out := make([]Observation, 0, len(values)) - for _, value := range values { - value.StepID = strings.TrimSpace(value.StepID) - value.Kind = strings.TrimSpace(value.Kind) - value.Summary = strings.TrimSpace(value.Summary) - value.RawJSON = strings.TrimSpace(value.RawJSON) - if value.Kind == "" && value.Summary == "" && value.RawJSON == "" { - continue - } - if value.CreatedAt == "" { - value.CreatedAt = time.Now().UTC().Format(time.RFC3339) - } - out = append(out, value) - } - if len(out) == 0 { - return nil - } - return out -} - -func compactExecutionLog(state ExecutionState) ExecutionState { - if len(state.ExecutionLog) <= executionLogMaxEntries { - if len(state.SummaryNotes) > summaryNotesMaxEntries { - state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:] - } - return state - } - - overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries] - state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:] - summary := summarizeExecutionOverflow(overflow) - if summary != nil { - state.SummaryNotes = append(state.SummaryNotes, *summary) - if len(state.SummaryNotes) > summaryNotesMaxEntries { - state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:] - } - } - return state -} - -func summarizeExecutionOverflow(values []Observation) *Observation { - if len(values) == 0 { - return nil - } - summaries := make([]string, 0, len(values)) - for _, value := range values { - label := value.Kind - if label == "" { - label = "observation" - } - if value.Summary != "" { - summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary)) - } else if value.RawJSON != "" { - summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON)) - } - } - if len(summaries) == 0 { - return nil - } - text := strings.Join(summaries, " | ") - if len(text) > 500 { - text = text[:500] + "..." - } - return &Observation{ - Kind: "execution_summary", - Summary: text, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } -} - -func appendDynamicSnapshot(state *ExecutionState, obs Observation) { - state.DynamicSnapshots = append(state.DynamicSnapshots, obs) - state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots) -} - -func appendExecutionLog(state *ExecutionState, obs Observation) { - state.ExecutionLog = append(state.ExecutionLog, obs) - *state = normalizeExecutionState(*state) -} - -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, - } -} diff --git a/agent/history.go b/agent/history.go deleted file mode 100644 index cad912d1..00000000 --- a/agent/history.go +++ /dev/null @@ -1,117 +0,0 @@ -package agent - -import ( - "strings" - "sync" - "time" -) - -// chatMessage represents a single message in conversation history. -type chatMessage struct { - Role string `json:"role"` // "user" or "assistant" - Content string `json:"content"` - Timestamp time.Time `json:"timestamp"` -} - -// chatHistory stores conversation history per user. -type chatHistory struct { - mu sync.RWMutex - sessions map[int64][]chatMessage - maxTurns int // hard safety cap in messages per user -} - -func newChatHistory(maxTurns int) *chatHistory { - if maxTurns <= 0 { - maxTurns = 100 // default hard cap; recent-window trimming is handled separately - } - return &chatHistory{ - sessions: make(map[int64][]chatMessage), - maxTurns: maxTurns, - } -} - -// Add appends a message to the user's history. -func (h *chatHistory) Add(userID int64, role, content string) { - h.mu.Lock() - defer h.mu.Unlock() - - h.sessions[userID] = append(h.sessions[userID], chatMessage{ - Role: role, - Content: content, - Timestamp: time.Now(), - }) - - // Hard safety cap in case summarization is unavailable. - msgs := h.sessions[userID] - if len(msgs) > h.maxTurns { - h.sessions[userID] = msgs[len(msgs)-h.maxTurns:] - } -} - -// Get returns the conversation history for a user. -func (h *chatHistory) Get(userID int64) []chatMessage { - h.mu.RLock() - defer h.mu.RUnlock() - - msgs := h.sessions[userID] - if msgs == nil { - return nil - } - // Return a copy - result := make([]chatMessage, len(msgs)) - copy(result, msgs) - return result -} - -func (h *chatHistory) Replace(userID int64, msgs []chatMessage) { - h.mu.Lock() - defer h.mu.Unlock() - - if len(msgs) == 0 { - delete(h.sessions, userID) - return - } - - if len(msgs) > h.maxTurns { - msgs = msgs[len(msgs)-h.maxTurns:] - } - cloned := make([]chatMessage, len(msgs)) - copy(cloned, msgs) - h.sessions[userID] = cloned -} - -// Clear resets conversation history for a user. -func (h *chatHistory) Clear(userID int64) { - h.mu.Lock() - defer h.mu.Unlock() - delete(h.sessions, userID) -} - -// CleanOld removes sessions older than the given duration. -func (h *chatHistory) CleanOld(maxAge time.Duration) { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - for uid, msgs := range h.sessions { - if len(msgs) > 0 { - lastMsg := msgs[len(msgs)-1] - if now.Sub(lastMsg.Timestamp) > maxAge { - delete(h.sessions, uid) - } - } - } -} - -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 deleted file mode 100644 index 3e5af0b8..00000000 --- a/agent/i18n.go +++ /dev/null @@ -1,88 +0,0 @@ -package agent - -var i18nMessages = map[string]map[string]string{ - "help": { - "zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\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 /clear /help\n\n" + - "直接跟我说话就行,中英文都可以 💬", - "en": "🤖 *NOFXi — Your AI Trading Agent*\n\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 /clear /help\n\n" + - "Just talk to me in any language 💬", - }, - "status": { - "zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s", - "en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s", - }, - "no_traders": { - "zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。", - "en": "📭 No traders configured. Create one in Web UI.", - }, - "no_running_trader": { - "zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。", - "en": "⚠️ No running trader. Start one in Web UI.", - }, - "no_positions": { - "zh": "📭 当前没有持仓。", - "en": "📭 No open positions.", - }, - "positions_header": { - "zh": "📊 *当前持仓*\n\n", - "en": "📊 *Open Positions*\n\n", - }, - "total_pnl": { - "zh": "💰 *总未实现盈亏: $%.2f*", - "en": "💰 *Total Unrealized P/L: $%.2f*", - }, - "balance_header": { - "zh": "💰 *账户余额*\n\n", - "en": "💰 *Account Balances*\n\n", - }, - "traders_header": { - "zh": "🤖 *Traders*\n\n", - "en": "🤖 *Traders*\n\n", - }, - "trade_usage": { - "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", - "en": "❓ Invalid quantity: %s", - }, - "analysis_header": { - "zh": "🔍 *%s 市场分析*", - "en": "🔍 *%s Analysis*", - }, - "sentinel_off": { - "zh": "⚠️ Sentinel 未启用。", - "en": "⚠️ Sentinel not enabled.", - }, - "system_prompt": { - "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.", - }, -} - -func (a *Agent) msg(lang, key string) string { - if m, ok := i18nMessages[key]; ok { - if s, ok := m[lang]; ok { - return s - } - if s, ok := m["en"]; ok { - return s - } - } - return key -} diff --git a/agent/llm_flow_extractor.go b/agent/llm_flow_extractor.go deleted file mode 100644 index 3566bb1e..00000000 --- a/agent/llm_flow_extractor.go +++ /dev/null @@ -1,578 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) - -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 "前面那个". -- Treat this as semantic slot filling, not keyword copying. -- Users will often speak in natural language, shorthand, colloquial labels, translated labels, or mild misspellings instead of exact schema keys. -- Your job is to decide which allowed canonical field each value belongs to based on the active flow, field descriptions, current missing fields, and conversation context. -- Never require the user to say the exact internal field key. -- In task.fields, always emit the canonical field keys from Allowed field spec JSON, never aliases, paraphrases, or user wording. -- If the user clearly supplied a value for one allowed field, normalize it to that canonical key before returning JSON.` - - sections := []string{ - fmt.Sprintf("Language: %s", lang), - 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 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 filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFlowFieldSpec) llmFlowExtractionResult { - if len(specs) == 0 { - result.Fields = nil - for i := range result.Tasks { - result.Tasks[i].Fields = nil - } - return result - } - allowed := make(map[string]struct{}, len(specs)) - for _, spec := range specs { - key := strings.TrimSpace(spec.Key) - if key != "" { - allowed[key] = struct{}{} - } - } - filter := func(fields map[string]string) map[string]string { - if len(fields) == 0 { - return fields - } - clean := make(map[string]string, len(fields)) - for key, value := range fields { - if _, ok := allowed[key]; !ok { - continue - } - clean[key] = value - } - if len(clean) == 0 { - return nil - } - return clean - } - result.Fields = filter(result.Fields) - for i := range result.Tasks { - result.Tasks[i].Fields = filter(result.Tasks[i].Fields) - } - return result -} - -func 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 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) - if actionRequiresSlot(session.Name, session.Action, "target_ref") { - add(&out, "target_ref_id", slotDisplayName("target_ref", lang)+" ID", true) - add(&out, "target_ref_name", slotDisplayName("target_ref", lang), true) - } - if supportsBulkTargetSelection(session.Name, session.Action) { - add(&out, "bulk_scope", "bulk deletion scope, use all only when the user clearly requested all targets", false) - } - switch session.Name { - case "model_management": - required := map[string]bool{"provider": true} - 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, "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 == "create" || session.Action == "update_config" { - if session.Action == "create" { - add(&out, "strategy_type", "Strategy type. Use ai_trading for AI strategies, including AI500/OI/static coin-source requests; use grid_trading only for grid strategy requests.", false) - } - configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use exact product schema values, not display labels: source_type must be one of static, ai500, oi_top, oi_low; strategy_type must be ai_trading or grid_trading; selected_timeframes must be a JSON array of strings, not a JSON-encoded string." - switch explicitStrategyCreateType(session) { - case "grid_trading": - configPatchDescription += " Current strategy_type is grid_trading: use only top-level strategy_type, grid_config, publish_config, and language. Do not output ai_config or AI fields such as coin_source, indicators, risk_control, timeframes, confidence, or prompt_sections." - case "ai_trading": - configPatchDescription += " Current strategy_type is ai_trading: use top-level strategy_type, ai_config, publish_config, and language. Put coin_source, indicators, risk_control, prompt_sections, and custom_prompt inside ai_config. Do not output grid_config." - default: - configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only the config branch for that type: grid_config for grid, ai_config for AI." - } - add(&out, "config_patch", configPatchDescription, false) - } - if session.Action == "create" { - add(&out, "awaiting_final_confirmation", "Set true only after you have produced a final user-facing creation summary from the current structured config and are waiting for the user's final confirmation before executing create.", false) - } - if session.Action == "update_prompt" { - add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false) - add(&out, "custom_prompt", strategyConfigFieldDisplayName("custom_prompt", lang), false) - } - if session.Action == "update_config" { - return out - } - add(&out, "name", slotDisplayName("name", lang), true) - if session.Action == "create" { - return out - } - keys := manualStrategyEditableFieldKeys() - if strategyType := explicitStrategyCreateType(session); strategyType != "" { - keys = manualStrategyEditableFieldKeysForType(strategyType) - } - for _, key := range keys { - 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") == "" { - missing = append(missing, "strategy_name") - } - break - case "configure_exchange": - if fieldValue(session, "exchange_id") == "" { - missing = append(missing, "exchange_name") - } - break - case "configure_model": - if fieldValue(session, "model_id") == "" { - 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") == "" { - missing = append(missing, "exchange_name") - } - if fieldValue(session, "model_id") == "" { - missing = append(missing, "model_name") - } - if fieldValue(session, "strategy_id") == "" { - 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_name": - if fieldValue(session, "name") == "" { - missing = append(missing, "name") - } - case "update_prompt": - if fieldValue(session, "prompt") == "" && fieldValue(session, "custom_prompt") == "" { - missing = append(missing, "prompt") - } - case "update_config": - if fieldValue(session, "config_patch") == "" { - missing = append(missing, "config_patch") - } - case "create": - if fieldValue(session, "name") == "" { - missing = append(missing, "name") - } - default: - missing = append(missing, "update_field") - } - } - 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 key { - case "target_ref_id": - if session.TargetRef == nil { - session.TargetRef = &EntityReference{} - } - session.TargetRef.ID = value - if session.TargetRef.Source == "" { - session.TargetRef.Source = "llm_extraction" - } - continue - case "target_ref_name": - if session.TargetRef == nil { - session.TargetRef = &EntityReference{} - } - session.TargetRef.Name = value - if session.TargetRef.Source == "" { - session.TargetRef.Source = "llm_extraction" - } - continue - } - switch session.Name { - case "model_management": - if key == "provider" || key == "name" || key == "custom_model_name" || key == "api_key" || key == "custom_api_url" || key == "enabled" || key == "update_field" { - 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 "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 session.Action == "create" || session.Action == "update_config" { - switch key { - case "strategy_type": - if strategyType := parseStrategyTypeValue(value); strategyType != "" { - setStrategyCreateType(session, strategyType) - } - case strategyCreateConfigPatchField: - strategyType := explicitStrategyCreateType(*session) - if strategyType == "" { - strategyType = strategyTypeFromConfigPatchAny(value) - } - if sanitized := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(sanitized) > 0 { - raw, _ := json.Marshal(sanitized) - setField(session, strategyCreateConfigPatchField, string(raw)) - } - } - 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 deleted file mode 100644 index e9df9c20..00000000 --- a/agent/llm_flow_extractor_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package agent - -import ( - "strings" - "testing" -) - -func TestBuildActiveFlowExtractionPromptRequiresCanonicalFieldOutput(t *testing.T) { - systemPrompt, _ := buildActiveFlowExtractionPrompt( - "zh", - "skill_session", - "Active flow type: skill_session\nSkill: exchange_management\nAction: create", - "secret是abc123456", - "", - nil, - nil, - nil, - ) - - for _, want := range []string{ - "Treat this as semantic slot filling, not keyword copying.", - "always emit the canonical field keys from Allowed field spec JSON", - } { - if !strings.Contains(systemPrompt, want) { - t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt) - } - } -} diff --git a/agent/llm_skill_router.go b/agent/llm_skill_router.go deleted file mode 100644 index 2e9b943e..00000000 --- a/agent/llm_skill_router.go +++ /dev/null @@ -1,694 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "nofx/mcp" -) - -type unifiedTurnDecision struct { - TopicIntent string `json:"topic_intent,omitempty"` - BusinessAction string `json:"business_action,omitempty"` - TargetSkill string `json:"target_skill,omitempty"` - Tasks []WorkflowTask `json:"tasks,omitempty"` - TargetSnapshotID string `json:"target_snapshot_id,omitempty"` - ContextMode string `json:"context_mode,omitempty"` - ExtractedData map[string]any `json:"extracted_data,omitempty"` - ReplyToUser string `json:"reply_to_user,omitempty"` - Confidence float64 `json:"confidence,omitempty"` -} - -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 - } - - text = strings.TrimSpace(text) - if text == "" { - return "", false, nil - } - - if decision, ok, err := a.routeTurnUnifiedWithLLM(ctx, userID, lang, text); err == nil && ok { - if answer, handled, execErr := a.executeUnifiedTurnDecision(ctx, storeUserID, userID, lang, text, decision, onEvent); handled || execErr != nil { - return answer, handled, execErr - } - } - return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) -} - -func parseUnifiedTurnDecision(raw string) (unifiedTurnDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var decision unifiedTurnDecision - if err := json.Unmarshal([]byte(raw), &decision); err == nil { - return normalizeUnifiedTurnDecision(decision), nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { - return normalizeUnifiedTurnDecision(decision), nil - } - } - return unifiedTurnDecision{}, fmt.Errorf("invalid unified turn decision json") -} - -func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecision { - decision.TopicIntent = strings.TrimSpace(strings.ToLower(decision.TopicIntent)) - decision.BusinessAction = strings.TrimSpace(strings.ToLower(decision.BusinessAction)) - decision.TargetSkill = strings.TrimSpace(decision.TargetSkill) - decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID) - decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode)) - decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser) - decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks - if decision.ExtractedData == nil { - decision.ExtractedData = map[string]any{} - } - if decision.Confidence < 0 { - decision.Confidence = 0 - } - if decision.Confidence > 1 { - decision.Confidence = 1 - } - switch decision.TopicIntent { - case "continue", "continue_active": - decision.TopicIntent = "continue_active" - case "start_new", "resume_snapshot", "cancel", "instant_reply": - default: - decision.TopicIntent = "" - } - switch decision.BusinessAction { - case "direct_answer", "new_skill", "skill_tasks", "continue_skill", "planned_agent", "none": - default: - decision.BusinessAction = "" - } - switch decision.ContextMode { - case "use_current", "fresh_context", "resume_snapshot": - default: - decision.ContextMode = "use_current" - } - return decision -} - -func (d unifiedTurnDecision) reliable() bool { - if d.TopicIntent == "" || d.BusinessAction == "" { - return false - } - if d.Confidence > 0 && d.Confidence < 0.45 { - return false - } - switch d.BusinessAction { - case "direct_answer": - return strings.TrimSpace(d.ReplyToUser) != "" - case "new_skill": - if len(d.Tasks) > 0 { - return true - } - skill, _ := parseTargetSkill(d.TargetSkill) - return skill != "" - case "skill_tasks": - return len(d.Tasks) > 0 - case "continue_skill": - return d.TopicIntent == "continue_active" - case "planned_agent", "none": - return true - default: - return false - } -} - -func (a *Agent) routeTurnUnifiedWithLLM(ctx context.Context, userID int64, lang, text string) (unifiedTurnDecision, bool, error) { - systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(userID, lang, 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 unifiedTurnDecision{}, false, err - } - decision, err := parseUnifiedTurnDecision(raw) - if err != nil { - return unifiedTurnDecision{}, false, err - } - if !decision.reliable() { - return decision, false, nil - } - return decision, true, nil -} - -func (a *Agent) buildUnifiedTurnRouterPrompt(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)) - 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" - } - - activeTaskDetails := "none" - if hasActiveTask { - activeTaskDetails = buildBrainUserPrompt(lang, text, previousAssistantReply, recentConversation, currentRefs, activeTask, true) - } - - systemPrompt := prependNOFXiAdvisorPreamble(`You are the unified turn router for NOFXi. -Return JSON only. No markdown. - -You must make ONE combined decision for this user turn: -1. Topic/context decision: continue active context, start fresh/new context, resume snapshot, cancel, or direct conversational reply. -2. Business routing decision: answer directly, start/continue a management skill, or hand off to the planner. -3. Context policy: whether downstream modules may use current references, must use fresh context, or must resume a snapshot. - -topic_intent values: -- "continue_active": user is answering or continuing the active flow -- "start_new": user starts or switches to a new task/topic -- "resume_snapshot": user wants to resume one suspended snapshot -- "cancel": user cancels the current active flow -- "instant_reply": user only greets, thanks, chats, or asks a direct explanation - -business_action values: -- "direct_answer": reply_to_user is the final answer; do not change state -- "skill_tasks": start one or more management/diagnosis skill tasks; tasks is required -- "new_skill": legacy single-skill route; target_skill is required if tasks is empty -- "continue_skill": continue the active skill session -- "planned_agent": hand off to the execution planner/tools -- "none": only valid with cancel when no more action is needed - -tasks format for skill_tasks: -- id: "task_1", "task_2", ... -- skill: one available skill name -- action: one available action -- request: the self-contained user-readable subtask -- depends_on: array of task ids, empty when independent - -target_skill format for legacy new_skill: -skill_name:action, for example "trader_management:create". -Available skills: -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 - -context_mode values: -- "use_current": downstream modules may use current references and recent context -- "fresh_context": the user is switching topic; do not use old current references to fill business fields -- "resume_snapshot": restore target_snapshot_id first - -Rules: -- This router decides what context downstream LLMs will see. Be conservative with stale references. -- Treat topic_intent as the primary decision. If the user is naturally responding to the active flow, choose topic_intent="continue_active", business_action="continue_skill", context_mode="use_current"; do not hand off a continuing active flow to planned_agent. -- When an active flow has a previous assistant question, proposal, or confirmation request, reason about what the user's message refers to in that context before deciding it is a new task. -- If the user clearly switches domain/entity, set topic_intent="start_new" and context_mode="fresh_context". -- If the user says "不是交易员,是策略" or similar corrections, use fresh_context. -- If the user answers the previous assistant question, choose continue_active. -- If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task. -- If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context. -- Use skill_tasks for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy. -- If the user request contains multiple management operations, include multiple tasks and depends_on where a later task needs an earlier result. -- If the request contains exactly one management operation, include exactly one task. -- Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks. -- For model_management, "provider" means AI vendor, never an exchange. -- Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one. -- extracted_data must contain only concrete facts from the current user message. -- reply_to_user must be concise and in the user's language. -- confidence should reflect how safe it is to execute this decision without the old router fallback. - -Return JSON with this exact shape: -{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|skill_tasks|new_skill|continue_skill|planned_agent|none","target_skill":"","tasks":[{"id":"task_1","skill":"","action":"","request":"","depends_on":[]}],"target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`) - - userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\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\nManagement domain primer:\n%s\n\nActive task details:\n%s\n", - lang, - text, - defaultIfEmpty(previousAssistantReply, "(empty)"), - currentRefs, - activeFlowSummary, - defaultIfEmpty(string(snapshotJSON), "[]"), - recentConversation, - defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"), - activeTaskDetails, - ) - - return systemPrompt, userPrompt -} - -func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) { - if session, ok := a.activeStrategyCreateSession(userID); ok && strategyCreateConfirmationReply(text) { - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - } - switch decision.TopicIntent { - case "cancel": - a.clearPendingProposalSession(userID) - if a.hasAnyActiveContext(userID) { - a.clearActiveSkillSession(userID) - a.clearAnyActiveContext(userID) - return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil - } - if decision.BusinessAction == "direct_answer" && decision.ReplyToUser != "" { - emitBrainReply(onEvent, decision.ReplyToUser) - a.recordSkillInteraction(userID, text, decision.ReplyToUser) - return decision.ReplyToUser, true, nil - } - return "", false, nil - case "resume_snapshot": - a.clearPendingProposalSession(userID) - if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) { - if decision.BusinessAction == "planned_agent" { - answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "use_current", onEvent) - return answer, true, err - } - return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) - } - return "", false, nil - } - - if decision.TopicIntent == "continue_active" { - if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) { - return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent) - } - if activeSession, hasActive := a.getActiveSkillSession(userID); hasActive { - decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang) - mergeExtractedData(&activeSession, decision.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent) - } - if a.hasAnyActiveContext(userID) { - return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) - } - } - - switch decision.BusinessAction { - case "direct_answer": - if decision.ReplyToUser == "" { - return "", false, nil - } - if decision.TopicIntent == "instant_reply" && a.hasAnyActiveContext(userID) { - return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, decision.ReplyToUser); blocked { - decision.ReplyToUser = guarded - } - emitBrainReply(onEvent, decision.ReplyToUser) - a.recordSkillInteraction(userID, text, decision.ReplyToUser) - a.runPostResponseMaintenanceAsync(userID) - return decision.ReplyToUser, true, nil - case "new_skill": - if len(decision.Tasks) > 0 { - return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent) - } - skill, action := parseTargetSkill(decision.TargetSkill) - if skill == "" { - return "", false, nil - } - if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" { - if !a.suspendActiveContexts(userID, lang) { - a.clearSkillSession(userID) - a.clearWorkflowSession(userID) - a.clearExecutionState(userID) - } - a.clearActiveSkillSession(userID) - } - session := newActiveSkillSession(userID, skill, action) - session.Goal = strings.TrimSpace(text) - decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) - mergeExtractedData(&session, decision.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - case "skill_tasks": - return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent) - case "continue_skill": - activeSession, hasActive := a.getActiveSkillSession(userID) - if !hasActive { - return "", false, nil - } - decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang) - mergeExtractedData(&activeSession, decision.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent) - case "planned_agent": - if session, ok := a.activeStrategyCreateSession(userID); ok { - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - } - contextMode := decision.ContextMode - if contextMode == "resume_snapshot" { - contextMode = "use_current" - } - answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, contextMode, onEvent) - return answer, true, err - case "none": - return "", false, nil - default: - return "", false, nil - } -} - -func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) { - tasks := normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks - if len(tasks) == 0 { - return "", false, nil - } - if task, ok := strategyCreateWorkflowTask(tasks); ok { - if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" { - if !a.suspendActiveContexts(userID, lang) { - a.clearSkillSession(userID) - a.clearWorkflowSession(userID) - a.clearExecutionState(userID) - } - a.clearActiveSkillSession(userID) - } - a.clearWorkflowSession(userID) - a.clearExecutionState(userID) - session := newActiveSkillSession(userID, task.Skill, task.Action) - session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text)) - decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) - mergeExtractedData(&session, decision.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent) - } - if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" { - if !a.suspendActiveContexts(userID, lang) { - a.clearSkillSession(userID) - a.clearWorkflowSession(userID) - a.clearExecutionState(userID) - } - a.clearActiveSkillSession(userID) - } - if len(tasks) == 1 { - task := tasks[0] - session := newActiveSkillSession(userID, task.Skill, task.Action) - session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text)) - decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) - mergeExtractedData(&session, decision.ExtractedData) - return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent) - } - session := normalizeWorkflowSession(WorkflowSession{ - UserID: userID, - OriginalRequest: strings.TrimSpace(text), - Tasks: tasks, - }) - if len(session.Tasks) == 0 { - return "", false, nil - } - a.saveWorkflowSession(userID, session) - return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent) -} - -func strategyCreateWorkflowTask(tasks []WorkflowTask) (WorkflowTask, bool) { - for _, task := range tasks { - if strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" { - return task, true - } - } - return WorkflowTask{}, false -} - -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 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 - } - if _, ok := a.getActiveSkillSession(userID); ok { - return true - } - return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) -} - -func (a *Agent) clearAnyActiveContext(userID int64) bool { - cleared := false - if _, ok := a.getActiveSkillSession(userID); ok { - a.clearActiveSkillSession(userID) - cleared = true - } - 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 { - var raw string - switch skill { - case "trader_management": - if strings.HasPrefix(action, "query") { - raw = a.toolListTraders(storeUserID) - } - case "exchange_management": - if strings.HasPrefix(action, "query") { - raw = a.toolGetExchangeConfigs(storeUserID) - } - case "model_management": - if strings.HasPrefix(action, "query") { - raw = a.toolGetModelConfigs(storeUserID) - } - case "strategy_management": - if strings.HasPrefix(action, "query") { - raw = a.toolGetStrategies(storeUserID) - } - } - if strings.TrimSpace(raw) == "" { - return nil - } - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - return nil - } - return data -} - -func mustMarshalJSON(v any) string { - data, _ := json.Marshal(v) - return string(data) -} - -func applyTraderQueryFilter(lang, fallback, raw, filter string) string { - filter = strings.TrimSpace(strings.ToLower(filter)) - if filter == "" { - return fallback - } - - var payload struct { - Traders []struct { - Name string `json:"name"` - IsRunning bool `json:"is_running"` - } `json:"traders"` - } - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return fallback - } - - switch filter { - case "running_only": - names := make([]string, 0, len(payload.Traders)) - for _, trader := range payload.Traders { - if trader.IsRunning { - names = append(names, strings.TrimSpace(trader.Name)) - } - } - if lang == "zh" { - if len(names) == 0 { - return "当前没有运行中的交易员。" - } - return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、")) - } - if len(names) == 0 { - return "There are no running traders right now." - } - return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", ")) - case "stopped_only": - names := make([]string, 0, len(payload.Traders)) - for _, trader := range payload.Traders { - if !trader.IsRunning { - names = append(names, strings.TrimSpace(trader.Name)) - } - } - if lang == "zh" { - if len(names) == 0 { - return "当前没有已停止的交易员。" - } - return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、")) - } - if len(names) == 0 { - return "There are no stopped traders right now." - } - return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", ")) - default: - return fallback - } -} diff --git a/agent/market_snapshot_test.go b/agent/market_snapshot_test.go deleted file mode 100644 index 9828b862..00000000 --- a/agent/market_snapshot_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package agent - -import ( - "encoding/json" - "io" - "net/http" - "strings" - "testing" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestToolGetMarketSnapshotReturnsRealtimeAnalysisContext(t *testing.T) { - prevBaseURL := binanceFuturesAPIBaseURL - prevClient := marketDataHTTPClient - binanceFuturesAPIBaseURL = "https://example.test" - marketDataHTTPClient = &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - body := "" - switch { - case strings.HasPrefix(req.URL.Path, "/fapi/v1/ticker/24hr"): - body = `{"symbol":"BTCUSDT","lastPrice":"65000","priceChange":"1200","priceChangePercent":"1.88","highPrice":"66000","lowPrice":"63800","volume":"12345","quoteVolume":"800000000","count":98765}` - case strings.HasPrefix(req.URL.Path, "/fapi/v1/premiumIndex"): - body = `{"symbol":"BTCUSDT","markPrice":"65010","indexPrice":"64990","lastFundingRate":"0.00010000","nextFundingTime":1710000000000}` - case strings.HasPrefix(req.URL.Path, "/fapi/v1/openInterest"): - body = `{"symbol":"BTCUSDT","openInterest":"45678.9","time":1710000000000}` - case strings.HasPrefix(req.URL.Path, "/fapi/v1/klines"): - body = `[[1710000000000,"64000","65100","63900","64500","100",1710000899999],[1710000900000,"64500","65500","64400","65000","120",1710001799999]]` - default: - body = `{"error":"not found"}` - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }), - } - defer func() { - binanceFuturesAPIBaseURL = prevBaseURL - marketDataHTTPClient = prevClient - }() - - a := New(nil, nil, DefaultConfig(), nil) - raw := a.toolGetMarketSnapshot(`{"symbol":"BTC","interval":"15m","limit":2}`) - - var resp struct { - Symbol string `json:"symbol"` - Price float64 `json:"price"` - Ticker24h struct { - PriceChangePercent float64 `json:"price_change_percent"` - } `json:"ticker_24h"` - PerpMetrics struct { - FundingRate float64 `json:"funding_rate"` - OpenInterest float64 `json:"open_interest"` - } `json:"perp_metrics"` - KlineSnapshot struct { - Interval string `json:"interval"` - Limit int `json:"limit"` - PeriodChangePercent float64 `json:"period_change_percent"` - RecentKlines []map[string]any `json:"recent_klines"` - } `json:"kline_snapshot"` - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(raw), &resp); err != nil { - t.Fatalf("failed to parse tool response: %v\nraw=%s", err, raw) - } - if resp.Error != "" { - t.Fatalf("unexpected tool error: %s", resp.Error) - } - if resp.Symbol != "BTCUSDT" { - t.Fatalf("expected normalized symbol BTCUSDT, got %s", resp.Symbol) - } - if resp.Price != 65000 { - t.Fatalf("expected price 65000, got %v", resp.Price) - } - if resp.Ticker24h.PriceChangePercent != 1.88 { - t.Fatalf("expected 24h change 1.88, got %v", resp.Ticker24h.PriceChangePercent) - } - if resp.PerpMetrics.FundingRate != 0.0001 { - t.Fatalf("expected funding rate 0.0001, got %v", resp.PerpMetrics.FundingRate) - } - if resp.PerpMetrics.OpenInterest != 45678.9 { - t.Fatalf("expected open interest 45678.9, got %v", resp.PerpMetrics.OpenInterest) - } - if resp.KlineSnapshot.Interval != "15m" || resp.KlineSnapshot.Limit != 2 { - t.Fatalf("unexpected kline snapshot metadata: %+v", resp.KlineSnapshot) - } - if len(resp.KlineSnapshot.RecentKlines) != 2 { - t.Fatalf("expected 2 klines, got %d", len(resp.KlineSnapshot.RecentKlines)) - } - if resp.KlineSnapshot.PeriodChangePercent <= 0 { - t.Fatalf("expected positive period change, got %v", resp.KlineSnapshot.PeriodChangePercent) - } -} - -func TestToolGetMarketSnapshotRejectsStockSymbols(t *testing.T) { - a := New(nil, nil, DefaultConfig(), nil) - raw := a.toolGetMarketSnapshot(`{"symbol":"AAPL"}`) - if !strings.Contains(raw, "currently supports crypto symbols only") { - t.Fatalf("expected stock rejection, got: %s", raw) - } -} diff --git a/agent/memory.go b/agent/memory.go deleted file mode 100644 index 7ffe5c36..00000000 --- a/agent/memory.go +++ /dev/null @@ -1,468 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "nofx/mcp" -) - -const ( - recentConversationRounds = 6 - recentConversationMessages = recentConversationRounds * 2 - chatHistoryMaxTurns = recentConversationMessages * 2 // fallback cap when compression is unavailable - taskStateSummaryTokenLimit = 1200 - shortTermCompressThreshold = 900 - incrementalTaskStateMessages = 6 - incrementalTaskStateTokenLimit = 500 -) - -type DecisionMemory struct { - Action string `json:"action,omitempty"` - Reason string `json:"reason,omitempty"` - StillValid bool `json:"still_valid,omitempty"` - Timestamp string `json:"timestamp,omitempty"` -} - -type TaskState struct { - CurrentGoal string `json:"current_goal,omitempty"` - ActiveFlow string `json:"active_flow,omitempty"` - // OpenLoops stores only high-level unresolved issues that still matter across turns. - // Step-level pending work belongs in ExecutionState, not here. - OpenLoops []string `json:"open_loops,omitempty"` - ImportantFacts []string `json:"important_facts,omitempty"` - LastDecision *DecisionMemory `json:"last_decision,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -func TaskStateConfigKey(userID int64) string { - return fmt.Sprintf("agent_task_state_%d", userID) -} - -func (a *Agent) getTaskState(userID int64) TaskState { - if a.store == nil { - return TaskState{} - } - raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID)) - if err != nil { - a.logger.Warn("failed to load task state", "error", err, "user_id", userID) - return TaskState{} - } - raw = strings.TrimSpace(raw) - if raw == "" { - return TaskState{} - } - - var state TaskState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - a.logger.Warn("failed to parse task state", "error", err, "user_id", userID) - return TaskState{} - } - return normalizeTaskState(state) -} - -func (a *Agent) saveTaskState(userID int64, state TaskState) error { - if a.store == nil { - return fmt.Errorf("store unavailable") - } - state = normalizeTaskState(state) - if isZeroTaskState(state) { - return a.store.SetSystemConfig(TaskStateConfigKey(userID), "") - } - data, err := json.Marshal(state) - if err != nil { - return err - } - return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data)) -} - -func (a *Agent) clearTaskState(userID int64) { - if a.store == nil { - return - } - if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil { - a.logger.Warn("failed to clear task state", "error", err, "user_id", userID) - } -} - -func normalizeTaskState(state TaskState) TaskState { - state.CurrentGoal = strings.TrimSpace(state.CurrentGoal) - state.ActiveFlow = strings.TrimSpace(state.ActiveFlow) - state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops)) - state.ImportantFacts = cleanStringList(state.ImportantFacts) - if state.LastDecision != nil { - state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action) - state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason) - state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp) - if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") { - state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339) - } - if state.LastDecision.Action == "" && state.LastDecision.Reason == "" { - state.LastDecision = nil - } - } - if state.UpdatedAt == "" && !isZeroTaskState(state) { - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - } - return state -} - -func isZeroTaskState(state TaskState) bool { - return state.CurrentGoal == "" && - state.ActiveFlow == "" && - len(state.OpenLoops) == 0 && - len(state.ImportantFacts) == 0 && - state.LastDecision == nil -} - -func cleanStringList(values []string) []string { - if len(values) == 0 { - return nil - } - out := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, v := range values { - v = strings.TrimSpace(v) - if v == "" { - continue - } - key := strings.ToLower(v) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, v) - } - if len(out) == 0 { - return nil - } - return out -} - -func filterTaskStateOpenLoops(values []string) []string { - if len(values) == 0 { - return nil - } - - rejectedPrefixes := []string{ - "wait for ", - "waiting for ", - "ask for ", - "call ", - "run ", - "execute ", - "invoke ", - "use tool", - "step ", - } - rejectedContains := []string{ - "current step", - "tool call", - "api key", - "api secret", - "secret key", - "passphrase", - "model id", - "exchange id", - } - - filtered := make([]string, 0, len(values)) - for _, value := range values { - lower := strings.ToLower(strings.TrimSpace(value)) - if lower == "" { - continue - } - if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) { - continue - } - filtered = append(filtered, value) - } - if len(filtered) == 0 { - return nil - } - return filtered -} - -func matchesAnyPrefix(value string, prefixes []string) bool { - for _, prefix := range prefixes { - if strings.HasPrefix(value, prefix) { - return true - } - } - return false -} - -func matchesAnyContains(value string, patterns []string) bool { - for _, pattern := range patterns { - if strings.Contains(value, pattern) { - return true - } - } - return false -} - -func buildTaskStateContext(state TaskState) string { - state = normalizeTaskState(state) - if isZeroTaskState(state) { - return "" - } - - var sb strings.Builder - sb.WriteString("[Structured Task State - durable, non-derivable context]\n") - if state.CurrentGoal != "" { - sb.WriteString("- Current goal: ") - sb.WriteString(state.CurrentGoal) - sb.WriteString("\n") - } - if state.ActiveFlow != "" { - sb.WriteString("- Active flow: ") - sb.WriteString(state.ActiveFlow) - sb.WriteString("\n") - } - for _, loop := range state.OpenLoops { - sb.WriteString("- High-level open loop: ") - sb.WriteString(loop) - sb.WriteString("\n") - } - for _, fact := range state.ImportantFacts { - sb.WriteString("- Important fact: ") - sb.WriteString(fact) - sb.WriteString("\n") - } - if state.LastDecision != nil { - sb.WriteString("- Last decision: ") - sb.WriteString(state.LastDecision.Action) - if state.LastDecision.Reason != "" { - sb.WriteString(" | reason: ") - sb.WriteString(state.LastDecision.Reason) - } - if state.LastDecision.StillValid { - sb.WriteString(" | still valid") - } - sb.WriteString("\n") - } - return strings.TrimSpace(sb.String()) -} - -func estimateChatMessagesTokens(msgs []chatMessage) int { - total := 0 - for _, msg := range msgs { - total += len([]rune(msg.Content))/3 + 10 - } - return total -} - -func formatChatMessagesForSummary(msgs []chatMessage) string { - var sb strings.Builder - for _, msg := range msgs { - if strings.TrimSpace(msg.Content) == "" { - continue - } - role := "User" - if msg.Role == "assistant" { - role = "Assistant" - } - sb.WriteString(role) - sb.WriteString(": ") - sb.WriteString(msg.Content) - sb.WriteString("\n") - } - return strings.TrimSpace(sb.String()) -} - -func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) { - if a.aiClient == nil || a.history == nil { - return - } - - msgs := a.history.Get(userID) - if len(msgs) <= recentConversationMessages { - return - } - if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold { - return - } - - splitAt := len(msgs) - recentConversationMessages - if splitAt <= 0 { - return - } - - oldPart := msgs[:splitAt] - recentPart := msgs[splitAt:] - existingState := a.getTaskState(userID) - updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart) - if err != nil { - a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID) - return - } - if err := a.saveTaskState(userID, updatedState); err != nil { - a.log().Warn("failed to persist task state", "error", err, "user_id", userID) - return - } - a.history.Replace(userID, recentPart) -} - -func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) { - if a.aiClient == nil || a.history == nil { - return - } - - msgs := a.history.Get(userID) - if len(msgs) < 2 { - return - } - - window := msgs - if len(window) > incrementalTaskStateMessages { - window = window[len(window)-incrementalTaskStateMessages:] - } - - existingState := a.getTaskState(userID) - updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window) - if err != nil { - a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID) - return - } - if err := a.saveTaskState(userID, updatedState); err != nil { - a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID) - } -} - -func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) { - transcript := formatChatMessagesForSummary(oldPart) - if transcript == "" { - return normalizeTaskState(existing), nil - } - - existingJSON, err := json.Marshal(normalizeTaskState(existing)) - if err != nil { - return TaskState{}, err - } - - systemPrompt := `You maintain structured task state for a trading assistant. -Update the task state using the existing state plus archived dialogue. -Return JSON only. Do not return markdown. - -Rules: -- Keep only durable, non-derivable context useful for future turns. -- Do not store market prices, balances, positions, or anything tools can fetch again. -- Do not store chit-chat or repeated wording. -- current_goal: the user's active objective, if any. -- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty. -- open_loops: only high-level unresolved issues that still matter across turns. -- Do not put execution-step pending work into open_loops. -- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id". -- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding". -- important_facts: non-derivable facts worth remembering briefly. -- last_decision: keep only one current relevant decision; omit if none. -- Replace stale items instead of appending blindly. -- If a field is no longer relevant, return it empty or omit it. -- Never invent facts.` - - userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript) - - req := &mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: ctx, - MaxTokens: intPtr(taskStateSummaryTokenLimit), - } - - resp, err := a.aiClient.CallWithRequest(req) - if err != nil { - return TaskState{}, err - } - - state, err := parseTaskStateJSON(resp) - if err != nil { - return TaskState{}, err - } - state = normalizeTaskState(state) - a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart)) - return state, nil -} - -func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) { - transcript := formatChatMessagesForSummary(recentPart) - if transcript == "" { - return normalizeTaskState(existing), nil - } - - existingJSON, err := json.Marshal(normalizeTaskState(existing)) - if err != nil { - return TaskState{}, err - } - - systemPrompt := `You maintain structured task state for a trading assistant. -Update the task state incrementally using the existing state plus the latest conversation window. -Return JSON only. Do not return markdown. - -Rules: -- Capture newly confirmed facts from the latest few turns immediately. -- Preserve important existing facts that still matter; replace stale items when contradicted. -- Keep only durable, non-derivable context useful for the next turns. -- current_goal: the user's active objective right now. -- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty. -- open_loops: only high-level unresolved issues that still matter across turns. -- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs. -- Do not store execution-step pending work or tool instructions. -- Do not store market prices, balances, or anything tools can fetch again. -- Keep last_decision only if there is a current relevant decision; omit it otherwise. -- Never invent facts.` - - userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript) - - req := &mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: ctx, - MaxTokens: intPtr(incrementalTaskStateTokenLimit), - } - - resp, err := a.aiClient.CallWithRequest(req) - if err != nil { - return TaskState{}, err - } - - state, err := parseTaskStateJSON(resp) - if err != nil { - return TaskState{}, err - } - state = normalizeTaskState(state) - a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart)) - return state, nil -} - -func parseTaskStateJSON(raw string) (TaskState, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var state TaskState - if err := json.Unmarshal([]byte(raw), &state); err == nil { - return state, nil - } - - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil { - return state, nil - } - } - return TaskState{}, fmt.Errorf("invalid task state json") -} - -func intPtr(v int) *int { - return &v -} diff --git a/agent/model_create_flow_test.go b/agent/model_create_flow_test.go deleted file mode 100644 index 29bf4c34..00000000 --- a/agent/model_create_flow_test.go +++ /dev/null @@ -1,75 +0,0 @@ -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) - } - } -} - -func TestHandleModelCreateSkillUsesCollectedClaw402PrivateKey(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "agent-model-create-claw402.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - - a := New(nil, st, DefaultConfig(), slog.Default()) - session := skillSession{ - Name: "model_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{ - "provider": "claw402", - "name": "Claw402 (Base USDC)", - "api_key": "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", - "custom_model_name": "deepseek", - }, - } - - reply := a.handleModelCreateSkill("default", 42, "zh", "继续", session) - - if strings.Contains(reply, "还缺这些字段:钱包私钥") { - t.Fatalf("expected bare private key to be accepted, got: %s", reply) - } - if !strings.Contains(reply, "我先整理了一份模型配置草稿") { - t.Fatalf("expected draft summary after accepting private key, got: %s", reply) - } -} diff --git a/agent/model_provider_catalog.go b/agent/model_provider_catalog.go deleted file mode 100644 index a42eedc6..00000000 --- a/agent/model_provider_catalog.go +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index 8921b598..00000000 --- a/agent/model_provider_catalog_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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/model_wallet_fastpath.go b/agent/model_wallet_fastpath.go deleted file mode 100644 index 64b8a330..00000000 --- a/agent/model_wallet_fastpath.go +++ /dev/null @@ -1,94 +0,0 @@ -package agent - -import ( - "fmt" - "strconv" - "strings" -) - -func isModelWalletBalanceQuestion(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - // Direct wallet address questions: "我的钱包地址", "wallet address", etc. - if containsAny(lower, []string{"钱包", "wallet"}) && containsAny(lower, []string{"地址", "address"}) { - return true - } - // Balance questions with wallet context - if containsAny(lower, []string{"余额", "balance", "usdc"}) && - containsAny(lower, []string{"钱包", "wallet", "主钱包", "base", "claw402"}) { - return true - } - return false -} - -func (a *Agent) handleModelWalletBalanceQuestion(storeUserID, lang, text string) (string, bool) { - if !isModelWalletBalanceQuestion(text) || a == nil || a.store == nil { - return "", false - } - models, err := a.store.AIModel().List(storeUserID) - if err != nil { - if lang == "zh" { - return "我现在读取模型配置失败,暂时查不到 claw402 钱包余额。", true - } - return "I could not read model configs, so I cannot check the claw402 wallet balance right now.", true - } - - var matches []safeModelToolConfig - for _, model := range models { - if model == nil || strings.ToLower(strings.TrimSpace(model.Provider)) != "claw402" { - continue - } - matches = append(matches, safeModelForTool(model)) - } - if len(matches) == 0 { - if lang == "zh" { - return "当前没有找到 claw402 模型钱包配置。", true - } - return "No claw402 model wallet config was found.", true - } - - if lang == "zh" { - lines := []string{"当前 claw402 模型钱包余额:"} - for _, model := range matches { - name := defaultIfEmpty(model.Name, model.ID) - lines = append(lines, fmt.Sprintf("- %s:%s USDC", name, defaultIfEmpty(model.BalanceUSDC, "暂时无法读取"))) - if strings.TrimSpace(model.WalletAddress) != "" { - lines = append(lines, fmt.Sprintf(" 钱包地址:%s", model.WalletAddress)) - } - if balanceIsZero(model.BalanceUSDC) { - if model.Enabled { - lines = append(lines, " 这个模型配置已启用,但钱包余额为 0 USDC;这不是“未启用”,而是需要先充值 Base USDC 后才能稳定调用。") - } else { - lines = append(lines, " 钱包余额为 0 USDC;启用并充值 Base USDC 后才能稳定调用。") - } - } - } - lines = append(lines, "注意:这是 claw402/Base 模型支付钱包余额,不是 OKX/Binance 等交易所账户余额。") - return strings.Join(lines, "\n"), true - } - - lines := []string{"Current claw402 model wallet balance:"} - for _, model := range matches { - name := defaultIfEmpty(model.Name, model.ID) - lines = append(lines, fmt.Sprintf("- %s: %s USDC", name, defaultIfEmpty(model.BalanceUSDC, "unavailable"))) - if strings.TrimSpace(model.WalletAddress) != "" { - lines = append(lines, fmt.Sprintf(" Wallet address: %s", model.WalletAddress)) - } - if balanceIsZero(model.BalanceUSDC) { - lines = append(lines, " This model config may be enabled, but the wallet balance is 0 USDC; recharge Base USDC before relying on it.") - } - } - lines = append(lines, "Note: this is the claw402/Base model payment wallet balance, not an exchange account balance.") - return strings.Join(lines, "\n"), true -} - -func balanceIsZero(value string) bool { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return false - } - parsed, err := strconv.ParseFloat(trimmed, 64) - return err == nil && parsed <= 0 -} diff --git a/agent/onboard.go b/agent/onboard.go deleted file mode 100644 index 53f6af59..00000000 --- a/agent/onboard.go +++ /dev/null @@ -1,604 +0,0 @@ -package agent - -import ( - "fmt" - "strings" - "time" - - "golang.org/x/text/cases" - "golang.org/x/text/language" - "nofx/store" -) - -var titleCaser = cases.Title(language.English) - -const setupExchangeAccountName = "Default" - -// Onboard handles first-time setup through natural language. -// When there's no trader configured, the agent guides the user. - -// SetupState tracks where the user is in the setup flow. -type SetupState struct { - Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key" - Exchange string - ExchangeID string - APIKey string - APISecret string - Passphrase string - AIProvider string - AIModel string - AIModelID string - AIKey string - AIBaseURL string -} - -// needsSetup returns true if no traders are configured. -func (a *Agent) needsSetup() bool { - if a.traderManager == nil { - return true - } - return len(a.traderManager.GetAllTraders()) == 0 -} - -// 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{} - } - return &SetupState{ - Step: step, - Exchange: getConfig(a.store, userID, "exchange"), - ExchangeID: getConfig(a.store, userID, "exchange_id"), - AIProvider: getConfig(a.store, userID, "ai_provider"), - AIModel: getConfig(a.store, userID, "ai_model"), - AIModelID: getConfig(a.store, userID, "ai_model_id"), - 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, "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_base_url", s.AIBaseURL) -} - -func (a *Agent) clearSetupState(userID int64) { - 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 { - v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid)) - return v -} - -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) { - return a.handleSetupFlowForStoreUser("default", userID, text, L) -} - -func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) { - state := a.getSetupState(userID) - - lower := strings.ToLower(text) - - // Cancel setup — explicit or implicit (user asking unrelated questions) - if lower == "cancel" || lower == "取消" || lower == "/cancel" { - a.clearSetupState(userID) - return a.setupMsg(L, "cancelled"), true - } - - // If in a step that expects a key/secret, check if user is NOT sending a key - // Keys are typically long strings without spaces and Chinese characters - if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" { - trimmed := strings.TrimSpace(text) - hasChinese := false - for _, r := range trimmed { - if r >= 0x4e00 && r <= 0x9fff { - hasChinese = true - break - } - } - hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-") - tooShort := len(trimmed) < 8 - - if hasChinese || hasSpaces || tooShort { - // User is probably asking a question, not providing a key - a.clearSetupState(userID) - if L == "zh" { - return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false - } - return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false - } - } - - switch state.Step { - case "await_exchange": - return a.handleExchangeChoice(userID, text, state, L) - case "await_api_key": - state.APIKey = strings.TrimSpace(text) - state.Step = "await_api_secret" - a.saveSetupState(userID, state) - return a.setupMsg(L, "ask_secret"), true - case "await_api_secret": - state.APISecret = strings.TrimSpace(text) - // OKX/Bitget/KuCoin need passphrase - if needsPassphrase(state.Exchange) { - state.Step = "await_passphrase" - a.saveSetupState(userID, state) - return a.setupMsg(L, "ask_passphrase"), true - } - exchangeID, err := a.saveSetupExchange(storeUserID, state) - if err != nil { - a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID) - if L == "zh" { - return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 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" - a.saveSetupState(userID, state) - if L == "zh" { - return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true - } - return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true - case "await_passphrase": - state.Passphrase = strings.TrimSpace(text) - exchangeID, err := a.saveSetupExchange(storeUserID, state) - if err != nil { - a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID) - if L == "zh" { - return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 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" - a.saveSetupState(userID, state) - if L == "zh" { - return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true - } - return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true - case "await_ai_model": - return a.handleAIChoice(storeUserID, userID, text, state, L) - case "await_ai_key": - state.AIKey = strings.TrimSpace(text) - aiModelID, err := a.saveSetupAIModel(storeUserID, state) - if err != nil { - a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID) - if L == "zh" { - return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次,或稍后去 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) - } - - // Not in setup flow — only enter setup for a tiny set of explicit commands. - // Natural-language configuration requests should go to the planner first, - // including phrases like "开始配置" or "帮我配置交易所". - if isDirectSetupCommand(lower) { - state.Step = "await_exchange" - a.saveSetupState(userID, state) - return a.setupMsg(L, "ask_exchange"), true - } - - // Everything else — let normal routing handle it - return "", false -} - -func isDirectSetupCommand(text string) bool { - text = strings.ToLower(strings.TrimSpace(text)) - if text == "" { - return false - } - switch text { - case "setup", "/setup": - return true - default: - return false - } -} - -func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) { - lower := strings.ToLower(strings.TrimSpace(text)) - - exchanges := map[string]string{ - "binance": "binance", "币安": "binance", "1": "binance", - "okx": "okx", "欧易": "okx", "2": "okx", - "bybit": "bybit", "3": "bybit", - "bitget": "bitget", "4": "bitget", - "gate": "gate", "5": "gate", - "kucoin": "kucoin", "库币": "kucoin", "6": "kucoin", - "hyperliquid": "hyperliquid", "7": "hyperliquid", - } - - ex, ok := exchanges[lower] - if !ok { - return a.setupMsg(L, "invalid_exchange"), true - } - - state.Exchange = ex - state.Step = "await_api_key" - a.saveSetupState(userID, state) - - if L == "zh" { - return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key:", titleCaser.String(ex)), true - } - return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true -} - -func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) { - 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"}, - "通义": {"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": {"", "", ""}, - "跳过": {"", "", ""}, - "5": {"", "", ""}, - } - - choice, ok := models[lower] - if !ok { - return a.setupMsg(L, "invalid_ai"), true - } - - if choice.model == "" { - // Skip AI, just create trader with exchange - state.AIProvider = "" - state.AIModel = "" - state.AIModelID = "" - state.AIKey = "" - return a.finishSetup(storeUserID, userID, state, L) - } - - state.AIProvider = choice.provider - state.AIModel = choice.model - state.AIBaseURL = choice.url - state.Step = "await_ai_key" - a.saveSetupState(userID, state) - - if L == "zh" { - return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key:", choice.model), true - } - return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true -} - -func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) { - // Create exchange in store - a.logger.Info("creating trader from setup", - "exchange", state.Exchange, - "ai_model", state.AIModel, - "store_user_id", storeUserID, - ) - - // TODO: Use store to create exchange + trader config - // For now, log the config and tell user - a.clearSetupState(userID) - - result := "" - maskedKey := maskKey(state.APIKey) - if L == "zh" { - result = fmt.Sprintf("🎉 *配置完成!*\n\n"+ - "• 交易所: %s\n"+ - "• API Key: %s\n", - titleCaser.String(state.Exchange), maskedKey) - if state.AIModel != "" { - result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel) - } - result += "\n正在创建 Trader..." - } else { - result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+ - "• Exchange: %s\n"+ - "• API Key: %s\n", - titleCaser.String(state.Exchange), maskedKey) - if state.AIModel != "" { - result += fmt.Sprintf("• AI Model: %s\n", state.AIModel) - } - result += "\nCreating Trader..." - } - - // Actually create the trader via store - err := a.createTraderFromSetupForStoreUser(storeUserID, state) - if err != nil { - a.logger.Error("create trader failed", "error", err) - if L == "zh" { - result += fmt.Sprintf("\n\n⚠️ 创建失败: %v\n交易所配置已保存,下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err) - } else { - result += fmt.Sprintf("\n\n⚠️ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err) - } - } else { - if L == "zh" { - result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天" - } else { - result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me" - } - } - - return result, true -} - -func (a *Agent) createTraderFromSetup(state *SetupState) error { - return a.createTraderFromSetupForStoreUser("default", state) -} - -func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error { - if a.store == nil { - return fmt.Errorf("store not available") - } - exchangeID := state.ExchangeID - if exchangeID == "" { - var err error - exchangeID, err = a.saveSetupExchange(storeUserID, state) - if err != nil { - return fmt.Errorf("save exchange: %w", err) - } - } - - aiModelID := state.AIModelID - if state.AIModel != "" && state.AIKey != "" && aiModelID == "" { - var err error - aiModelID, err = a.saveSetupAIModel(storeUserID, state) - if err != nil { - a.logger.Error("save AI model", "error", err) - } - } - - // Reuse an existing trader if the same exchange/model pair already exists. - existingTraders, err := a.store.Trader().List(storeUserID) - if err != nil { - return fmt.Errorf("list traders: %w", err) - } - for _, existing := range existingTraders { - if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID { - a.logger.Info("reusing existing trader created via chat setup", - "trader", existing.Name, - "exchange_id", exchangeID, - "ai_model_id", aiModelID, - ) - return nil - } - } - - // Create trader config - exchangeIDShort := exchangeID - if len(exchangeIDShort) > 8 { - exchangeIDShort = exchangeIDShort[:8] - } - modelPart := aiModelID - if modelPart == "" { - modelPart = "manual" - } - trader := &store.Trader{ - ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()), - Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)), - UserID: storeUserID, - ExchangeID: exchangeID, - AIModelID: aiModelID, - IsRunning: false, - } - if err := a.store.Trader().Create(trader); err != nil { - return fmt.Errorf("save trader: %w", err) - } - - a.logger.Info("trader created via chat", - "trader", trader.Name, - "exchange", state.Exchange, - "ai", aiModelID, - ) - - return nil -} - -func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) { - if a.store == nil { - return "", fmt.Errorf("store not available") - } - - hlWallet := "" - hlUnified := false - passphrase := state.Passphrase - apiKey := state.APIKey - apiSecret := state.APISecret - - if state.Exchange == "hyperliquid" { - hlWallet = state.APISecret - apiKey = "" - apiSecret = state.APIKey - } - - exchanges, err := a.store.Exchange().List(storeUserID) - if err != nil { - return "", err - } - for _, ex := range exchanges { - if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName { - if err := a.store.Exchange().Update( - storeUserID, ex.ID, true, - apiKey, apiSecret, passphrase, - false, - hlWallet, hlUnified, false, - "", "", "", - "", "", "", 0, - ); err != nil { - return "", err - } - return ex.ID, nil - } - } - - return a.store.Exchange().Create( - storeUserID, - state.Exchange, - setupExchangeAccountName, - true, - apiKey, apiSecret, passphrase, - false, - hlWallet, hlUnified, false, - "", "", "", - "", "", "", 0, - ) -} - -func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) { - if a.store == nil { - return "", fmt.Errorf("store not available") - } - if state.AIProvider == "" { - return "", nil - } - - modelID := state.AIProvider - if err := a.store.AIModel().Update( - storeUserID, - modelID, - true, - state.AIKey, - state.AIBaseURL, - state.AIModel, - ); err != nil { - return "", err - } - - if modelID == state.AIProvider { - modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider) - } - return modelID, nil -} - -func maskKey(key string) string { - if len(key) <= 8 { - return "****" - } - return key[:4] + "****" + key[len(key)-4:] -} - -func needsPassphrase(exchange string) bool { - return exchange == "okx" || exchange == "bitget" || exchange == "kucoin" -} - -func containsAny(s string, words []string) bool { - for _, w := range words { - if strings.Contains(s, w) { - return true - } - } - return false -} - -var setupMessages = map[string]map[string]string{ - "welcome": { - "zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" + - "我发现你还没有配置交易所,让我帮你搞定吧!\n\n" + - "发送 *开始配置* 或 *setup* 开始\n" + - "发送 *取消* 随时退出", - "en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" + - "I see you haven't configured an exchange yet. Let me help!\n\n" + - "Send *setup* to begin\n" + - "Send *cancel* to exit anytime", - }, - "ask_exchange": { - "zh": "🏦 *选择你的交易所*\n\n" + - "1️⃣ Binance(币安)\n" + - "2️⃣ OKX(欧易)\n" + - "3️⃣ Bybit\n" + - "4️⃣ Bitget\n" + - "5️⃣ Gate\n" + - "6️⃣ KuCoin(库币)\n" + - "7️⃣ Hyperliquid\n\n" + - "发送数字或名称选择:", - "en": "🏦 *Choose your exchange*\n\n" + - "1️⃣ Binance\n" + - "2️⃣ OKX\n" + - "3️⃣ Bybit\n" + - "4️⃣ Bitget\n" + - "5️⃣ Gate\n" + - "6️⃣ KuCoin\n" + - "7️⃣ Hyperliquid\n\n" + - "Send number or name:", - }, - "invalid_exchange": { - "zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。", - "en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.", - }, - "ask_secret": { - "zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*:", - "en": "🔑 Got API Key.\n\nNow send your *API Secret*:", - }, - "ask_passphrase": { - "zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:", - "en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:", - }, - "ask_ai": { - "zh": "🤖 *选择 AI 模型*\n\n" + - "1️⃣ DeepSeek(推荐,便宜好用)\n" + - "2️⃣ 通义千问 (Qwen)\n" + - "3️⃣ OpenAI (GPT-4o)\n" + - "4️⃣ Claude\n" + - "5️⃣ 跳过(不配置 AI)\n\n" + - "发送数字或名称选择:", - "en": "🤖 *Choose AI model*\n\n" + - "1️⃣ DeepSeek (recommended, affordable)\n" + - "2️⃣ Qwen\n" + - "3️⃣ OpenAI (GPT-4o)\n" + - "4️⃣ Claude\n" + - "5️⃣ Skip (no AI)\n\n" + - "Send number or name:", - }, - "invalid_ai": { - "zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。", - "en": "❓ AI model not recognized. Send a number 1-5 or model name.", - }, - "cancelled": { - "zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。", - "en": "👌 Setup cancelled. Send *setup* anytime to restart.", - }, -} - -func (a *Agent) setupMsg(L, key string) string { - if m, ok := setupMessages[key]; ok { - if s, ok := m[L]; ok { - return s - } - return m["en"] - } - return key -} diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go deleted file mode 100644 index 265fa57a..00000000 --- a/agent/planner_runtime.go +++ /dev/null @@ -1,4127 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" - - "nofx/mcp" - "nofx/store" -) - -const ( - plannerMaxSteps = 8 - plannerMaxIterations = 12 - observationMaxLength = 1000 -) - -var ( - plannerCreateTimeout = 36 * time.Second - plannerReplanTimeout = 24 * time.Second - plannerReasonTimeout = 30 * time.Second - plannerFinalTimeout = 36 * time.Second - directReplyTimeout = 8 * time.Second -) - -type replannerDecision struct { - Action string `json:"action"` - Goal string `json:"goal,omitempty"` - Steps []PlanStep `json:"steps,omitempty"` - Instruction string `json:"instruction,omitempty"` - Question string `json:"question,omitempty"` -} - -type readFastPathRequest struct { - Kind string - ArgsJSON string -} - -type directReplyDecision struct { - Action string `json:"action"` - Answer string `json:"answer,omitempty"` -} - -func latestAskedQuestion(state ExecutionState) string { - if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" { - return strings.TrimSpace(state.Waiting.Question) - } - for i := len(state.Steps) - 1; i >= 0; i-- { - step := state.Steps[i] - if step.Type == planStepTypeAskUser { - if q := strings.TrimSpace(step.Instruction); q != "" { - return q - } - if q := strings.TrimSpace(step.OutputSummary); q != "" { - return q - } - } - } - if state.Status == executionStatusWaitingUser { - return strings.TrimSpace(state.FinalAnswer) - } - return "" -} - -func buildWaitingState(state ExecutionState, step PlanStep, question string) *WaitingState { - waiting := &WaitingState{ - Question: strings.TrimSpace(question), - Intent: inferWaitingIntent(state.Goal, step, question), - PendingFields: inferPendingFields(step, question), - ConfirmationTarget: inferConfirmationTarget(state.Goal, step, question), - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - return normalizeWaitingState(waiting) -} - -func inferWaitingIntent(goal string, step PlanStep, question string) string { - lowerGoal := strings.ToLower(strings.TrimSpace(goal)) - lowerQuestion := strings.ToLower(strings.TrimSpace(question)) - switch { - case step.RequiresConfirmation || strings.Contains(lowerQuestion, "需要我") || strings.Contains(lowerQuestion, "confirm") || strings.Contains(lowerQuestion, "确认"): - return "confirm_action" - case strings.Contains(lowerGoal, "交易员") || strings.Contains(lowerGoal, "trader"): - return "complete_trader_setup" - case strings.Contains(lowerGoal, "交易所") || strings.Contains(lowerGoal, "exchange"): - return "complete_exchange_config" - case strings.Contains(lowerGoal, "模型") || strings.Contains(lowerGoal, "model"): - return "complete_model_config" - default: - return "provide_missing_information" - } -} - -func inferPendingFields(step PlanStep, question string) []string { - source := strings.ToLower(strings.TrimSpace(question)) - if source == "" { - sourceBytes, _ := json.Marshal(step.ToolArgs) - source = strings.ToLower(string(sourceBytes)) - } - candidates := []struct { - key string - patterns []string - }{ - {key: "ai_model_id", patterns: []string{"ai_model_id", "model id", "模型id", "模型 id"}}, - {key: "exchange_id", patterns: []string{"exchange_id", "exchange id", "交易所id", "交易所 id"}}, - {key: "strategy_id", patterns: []string{"strategy_id", "strategy id", "策略id", "策略 id"}}, - {key: "name", patterns: []string{"trader name", "name", "名字", "名称"}}, - {key: "api_key", patterns: []string{"api key", "apikey", "api_key"}}, - {key: "secret_key", patterns: []string{"secret key", "secret_key", "密钥"}}, - {key: "passphrase", patterns: []string{"passphrase", "密码短语"}}, - } - fields := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - for _, pattern := range candidate.patterns { - if strings.Contains(source, pattern) { - fields = append(fields, candidate.key) - break - } - } - } - return cleanStringList(fields) -} - -func inferConfirmationTarget(goal string, step PlanStep, question string) string { - if step.RequiresConfirmation { - if step.ToolName != "" { - return step.ToolName - } - } - lowerGoal := strings.ToLower(strings.TrimSpace(goal)) - lowerQuestion := strings.ToLower(strings.TrimSpace(question)) - switch { - case strings.Contains(lowerGoal, "交易员") || strings.Contains(lowerQuestion, "交易员") || strings.Contains(lowerGoal, "trader"): - return "trader" - case strings.Contains(lowerGoal, "交易所") || strings.Contains(lowerQuestion, "交易所") || strings.Contains(lowerGoal, "exchange"): - return "exchange_config" - case strings.Contains(lowerGoal, "模型") || strings.Contains(lowerQuestion, "模型") || strings.Contains(lowerGoal, "model"): - return "model_config" - default: - return "" - } -} - -func isConfigOrTraderIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - keywords := []string{ - "交易员", "trader", "exchange", "交易所", "模型", "model", "api key", "apikey", - "绑定", "配置", "setup", "configure", "deepseek", "openai", "claude", "gemini", - "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter", - } - for _, kw := range keywords { - if strings.Contains(lower, kw) { - return true - } - } - return false -} - -func isStrategyIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - keywords := []string{ - "策略", "strategy", "template", "模板", "激进", "趋势跟踪", "网格策略", - "量化策略", "策略模板", "strategy studio", - } - for _, kw := range keywords { - if strings.Contains(lower, kw) { - return true - } - } - return false -} - -func isRealtimeAccountIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - keywords := []string{ - "余额", "balance", "equity", "净值", "available", "available balance", - "持仓", "position", "positions", "仓位", "unrealized pnl", "浮盈", "浮亏", - "交易历史", "trade history", "history", "closed trades", "recent trades", - "订单", "order", "orders", "成交", "pnl", "profit", "loss", - } - for _, kw := range keywords { - if strings.Contains(lower, kw) { - return true - } - } - return false -} - -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) -} - -func uniqueStrings(values []string) []string { - if len(values) == 0 { - return nil - } - out := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - return out -} - -func withPlannerStageTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if timeout <= 0 { - return context.WithCancel(ctx) - } - if deadline, ok := ctx.Deadline(); ok { - remaining := time.Until(deadline) - if remaining <= timeout { - return context.WithCancel(ctx) - } - } - return context.WithTimeout(ctx, timeout) -} - -func isPlannerTimeoutError(err error) bool { - if err == nil { - return false - } - return errors.Is(err, context.DeadlineExceeded) -} - -func plannerTimeoutMessage(lang string) string { - if lang == "zh" { - return "⏱️ 当前请求处理超时,请重试一次。若持续出现,请把问题拆小一点。" - } - return "⏱️ This request timed out. Please try again, or break it into a smaller request." -} - -func shouldResetExecutionStateForNewAttempt(text string, state ExecutionState) bool { - if state.SessionID == "" { - return false - } - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - retrySignals := []string{ - "再试", "重试", "重新", "继续", "继续创建", "我已经配置好了", "已经配置好了", "我配好了", - "我已经弄好了", "已经弄好了", "好了", "retry", "try again", "continue", "resume", - "i configured it", "i've configured it", "i already configured", "configured already", - } - for _, signal := range retrySignals { - if strings.Contains(lower, signal) { - return true - } - } - if isConfigOrTraderIntent(lower) && (state.Status == executionStatusFailed || state.Status == executionStatusCompleted) { - return true - } - if isConfigOrTraderIntent(lower) && state.Status == executionStatusWaitingUser { - return true - } - return false -} - -func ensureCurrentReferences(state *ExecutionState) { - if state.CurrentReferences == nil { - state.CurrentReferences = &CurrentReferences{} - } -} - -func preferReference(current **EntityReference, id, name, source string) { - id = strings.TrimSpace(id) - name = strings.TrimSpace(name) - source = strings.TrimSpace(source) - if id == "" && name == "" { - return - } - if *current == nil { - *current = &EntityReference{} - } - if id != "" { - (*current).ID = id - } - 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 { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return nil - } - var matched *EntityReference - for _, candidate := range candidates { - id := strings.ToLower(strings.TrimSpace(candidate.ID)) - name := strings.ToLower(strings.TrimSpace(candidate.Name)) - if id == "" && name == "" { - continue - } - if (id != "" && strings.Contains(lower, id)) || (name != "" && strings.Contains(lower, name)) { - if matched != nil { - return nil - } - copy := candidate - matched = © - } - } - return matched -} - -func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, state *ExecutionState) { - if a.store == nil || strings.TrimSpace(text) == "" { - return - } - ensureCurrentReferences(state) - - if strategies, err := a.store.Strategy().List(storeUserID); err == nil { - candidates := make([]EntityReference, 0, len(strategies)) - for _, strategy := range strategies { - 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, "user_mention") - appendReferenceHistory(state, "strategy", ref.ID, ref.Name, "user_mention") - } - } - if traders, err := a.store.Trader().List(storeUserID); err == nil { - candidates := make([]EntityReference, 0, len(traders)) - for _, trader := range traders { - 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, "user_mention") - appendReferenceHistory(state, "trader", ref.ID, ref.Name, "user_mention") - } - } - if models, err := a.store.AIModel().List(storeUserID); err == nil { - candidates := make([]EntityReference, 0, len(models)) - for _, model := range models { - name := model.Name - if name == "" { - name = model.CustomModelName - } - if name == "" { - name = model.Provider - } - candidates = append(candidates, EntityReference{ID: model.ID, Name: name}) - } - if ref := matchEntityReference(text, candidates); ref != nil { - 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 { - candidates := make([]EntityReference, 0, len(exchanges)) - for _, exchange := range exchanges { - if !store.IsVisibleExchange(exchange) { - continue - } - name := exchange.AccountName - if name == "" { - name = exchange.ExchangeType - } - candidates = append(candidates, EntityReference{ID: exchange.ID, Name: name}) - } - if ref := matchEntityReference(text, candidates); ref != nil { - preferReference(&state.CurrentReferences.Exchange, ref.ID, ref.Name, "user_mention") - appendReferenceHistory(state, "exchange", ref.ID, ref.Name, "user_mention") - } - } -} - -func updateCurrentReferencesFromToolResult(state *ExecutionState, toolName, raw string) bool { - if strings.TrimSpace(raw) == "" { - return false - } - var payload map[string]any - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return false - } - ensureCurrentReferences(state) - before, _ := json.Marshal(state.CurrentReferences) - - switch toolName { - case "manage_strategy": - if item, ok := payload["strategy"].(map[string]any); ok { - 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 { - 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 { - name := asString(item["name"]) - if name == "" { - name = asString(item["provider"]) - } - 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 { - name := asString(item["account_name"]) - if name == "" { - name = asString(item["exchange_type"]) - } - 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 { - 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") - } - } - } - state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences) - after, _ := json.Marshal(state.CurrentReferences) - return string(before) != string(after) -} - -func asString(v any) string { - s, _ := v.(string) - return strings.TrimSpace(s) -} - -func containsAnyKeyword(text string, keywords []string) bool { - for _, keyword := range keywords { - if strings.Contains(text, keyword) { - return true - } - } - return false -} - -func detectReadFastPath(text string) *readFastPathRequest { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return nil - } - - switch lower { - case "/traders": - return &readFastPathRequest{Kind: "list_traders"} - case "/strategies": - return &readFastPathRequest{Kind: "get_strategies"} - case "/models": - return &readFastPathRequest{Kind: "get_model_configs"} - case "/exchanges": - return &readFastPathRequest{Kind: "get_exchange_configs"} - case "/balance": - return &readFastPathRequest{Kind: "get_balance"} - case "/positions": - return &readFastPathRequest{Kind: "get_positions"} - case "/history", "/trades": - return &readFastPathRequest{Kind: "get_trade_history", ArgsJSON: `{"limit":10}`} - default: - 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 isEphemeralReadFastPathKind(kind string) bool { - switch kind { - case "get_balance", "get_positions", "get_trade_history": - return true - default: - return false - } -} - -func (a *Agent) executeReadFastPath(storeUserID string, _ int64, req *readFastPathRequest) string { - switch req.Kind { - case "get_balance": - return a.toolGetBalance(storeUserID) - case "get_positions": - return a.toolGetPositions(storeUserID) - case "get_trade_history": - return a.toolGetTradeHistory(req.ArgsJSON) - case "get_strategies": - return a.toolGetStrategies(storeUserID) - case "list_traders": - return a.toolListTraders(storeUserID) - case "get_model_configs": - return a.toolGetModelConfigs(storeUserID) - case "get_exchange_configs": - return a.toolGetExchangeConfigs(storeUserID) - default: - return `{"error":"unsupported fast path"}` - } -} - -func formatReadFastPathResponse(lang, kind, raw string) string { - var payload map[string]any - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return summarizeObservation(raw) - } - if errMsg, _ := payload["error"].(string); strings.TrimSpace(errMsg) != "" { - return summarizeObservation(raw) - } - - switch kind { - case "get_strategies": - items, _ := payload["strategies"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前还没有策略。" - } - return "There are no strategies yet." - } - lines := []string{"Current strategies:"} - if lang == "zh" { - lines[0] = "当前策略:" - } - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - name := asString(entry["name"]) - if name == "" { - name = asString(entry["id"]) - } - meta := make([]string, 0, 2) - if active, _ := entry["is_active"].(bool); active { - meta = append(meta, "active") - } - if isDefault, _ := entry["is_default"].(bool); isDefault { - meta = append(meta, "default") - } - if len(meta) > 0 { - lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", "))) - } else { - lines = append(lines, fmt.Sprintf("- %s", name)) - } - } - return strings.Join(lines, "\n") - case "list_traders": - items, _ := payload["traders"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前还没有交易员。" - } - return "There are no traders yet." - } - lines := []string{"Current traders:"} - if lang == "zh" { - lines[0] = "当前交易员:" - } - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - name := asString(entry["name"]) - line := fmt.Sprintf("- %s", name) - meta := cleanStringList([]string{asString(entry["exchange_type"]), asString(entry["ai_model_id"])}) - if len(meta) > 0 { - line += fmt.Sprintf(" (%s)", strings.Join(meta, ", ")) - } - lines = append(lines, line) - } - return strings.Join(lines, "\n") - case "get_model_configs": - items, _ := payload["model_configs"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前还没有模型配置。" - } - return "There are no model configs yet." - } - lines := []string{"Current model configs:"} - if lang == "zh" { - lines[0] = "当前模型配置:" - } - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - name := asString(entry["name"]) - if name == "" { - name = asString(entry["provider"]) - } - meta := make([]string, 0, 2) - if enabled, _ := entry["enabled"].(bool); enabled { - meta = append(meta, "enabled") - } - if model := asString(entry["custom_model_name"]); model != "" { - meta = append(meta, model) - } - if len(meta) > 0 { - lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", "))) - } else { - lines = append(lines, fmt.Sprintf("- %s", name)) - } - } - return strings.Join(lines, "\n") - case "get_exchange_configs": - items, _ := payload["exchange_configs"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前还没有交易所配置。" - } - return "There are no exchange configs yet." - } - lines := []string{"Current exchange configs:"} - if lang == "zh" { - lines[0] = "当前交易所配置:" - } - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - name := asString(entry["account_name"]) - if name == "" { - name = asString(entry["exchange_type"]) - } - meta := cleanStringList([]string{asString(entry["exchange_type"])}) - if enabled, _ := entry["enabled"].(bool); enabled { - meta = append(meta, "enabled") - } - if len(meta) > 0 { - lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", "))) - } else { - lines = append(lines, fmt.Sprintf("- %s", name)) - } - } - return strings.Join(lines, "\n") - case "get_balance": - items, _ := payload["balances"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前没有可用的余额数据。" - } - return "No balance data is available right now." - } - lines := []string{"Current balance overview:"} - if lang == "zh" { - lines[0] = "当前余额概览:" - } - var totalEquity float64 - var totalAvailable float64 - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - equity := toFloat(entry["total_equity"]) - available := toFloat(entry["available"]) - totalEquity += equity - totalAvailable += available - lines = append(lines, fmt.Sprintf("- %s (%s): equity %.4f, available %.4f", - asString(entry["name"]), asString(entry["exchange"]), - equity, available)) - } - if len(items) > 1 { - if lang == "zh" { - lines = append(lines, fmt.Sprintf("汇总:equity %.4f, available %.4f", totalEquity, totalAvailable)) - } else { - lines = append(lines, fmt.Sprintf("Total: equity %.4f, available %.4f", totalEquity, totalAvailable)) - } - } - return strings.Join(lines, "\n") - case "get_positions": - items, _ := payload["positions"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前没有持仓。" - } - return "There are no open positions right now." - } - lines := []string{"Current positions:"} - if lang == "zh" { - lines[0] = "当前持仓:" - } - for _, item := range items { - entry, ok := item.(map[string]any) - if !ok { - continue - } - lines = append(lines, fmt.Sprintf("- %s %s size %.4f, entry %.4f, pnl %.4f", - asString(entry["symbol"]), asString(entry["side"]), - toFloat(entry["size"]), toFloat(entry["entry_price"]), toFloat(entry["unrealized_pnl"]))) - } - return strings.Join(lines, "\n") - case "get_trade_history": - items, _ := payload["trades"].([]any) - if len(items) == 0 { - if lang == "zh" { - return "当前没有已平仓交易历史。" - } - return "There is no closed trade history yet." - } - summary, _ := payload["summary"].(map[string]any) - head := fmt.Sprintf("Recent trades: %.0f total, win rate %s, total PnL %.4f", - toFloat(summary["total_trades"]), asString(summary["win_rate"]), toFloat(summary["total_pnl"])) - if lang == "zh" { - head = fmt.Sprintf("最近交易:共 %.0f 笔,胜率 %s,总 PnL %.4f", - toFloat(summary["total_trades"]), asString(summary["win_rate"]), toFloat(summary["total_pnl"])) - } - lines := []string{head} - for idx, item := range items { - if idx >= 5 { - break - } - entry, ok := item.(map[string]any) - if !ok { - continue - } - lines = append(lines, fmt.Sprintf("- %s %s pnl %.4f (%s -> %s)", - asString(entry["symbol"]), asString(entry["side"]), toFloat(entry["pnl"]), - asString(entry["entry_time"]), asString(entry["exit_time"]))) - } - return strings.Join(lines, "\n") - default: - return summarizeObservation(raw) - } -} - -func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int64, lang, text string) (string, error) { - lock := a.flowLock(userID) - lock.Lock() - defer lock.Unlock() - if a.shouldUseAgenticTurn(userID) { - if answer, ok, err := a.runAgenticTurn(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { - return a.maybeAppendResumePrompt(userID, lang, text, answer), err - } - // Not handled — fall through to the legacy routing stack. - } - 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(storeUserID, lang, text) - } - 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) { - lock := a.flowLock(userID) - lock.Lock() - defer lock.Unlock() - if a.shouldUseAgenticTurn(userID) { - if answer, ok, err := a.runAgenticTurn(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { - return a.maybeAppendResumePrompt(userID, lang, text, answer), err - } - // Not handled — fall through to the legacy routing stack. - } - 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 - } - } 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 - } - } - 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(storeUserID, lang, text) - } - answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent) - return a.maybeAppendResumePrompt(userID, lang, text, answer), err -} - -func isInstantDirectReplyText(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - switch lower { - case "hi", "hello", "hey", "你好", "嗨", "在吗", "你好吗", "最近怎么样", "最近还好吗", "谢谢", "多谢", "谢了", "ok", "好的", "收到", "thanks", "thank you", "okay", "got it", "how are you": - return true - default: - return false - } -} - -func (a *Agent) hasActiveSkillSession(userID int64) bool { - session := a.getSkillSession(userID) - return strings.TrimSpace(session.Name) != "" -} - -func (a *Agent) hasAnyActiveContext(userID int64) bool { - if _, ok := a.getActiveSkillSession(userID); ok { - return true - } - 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 - } - switch strings.TrimSpace(state.Status) { - case executionStatusPlanning, executionStatusRunning, executionStatusWaitingUser: - return true - default: - return false - } -} - -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) { - if task, _, ok := nextRunnableWorkflowTask(workflow); ok && strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" { - a.clearWorkflowSession(userID) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text)) - answer, handled, err := a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent) - return answer, handled, err - } - answer, handled, err := a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, workflow, onEvent) - if handled || err != nil { - return answer, true, err - } - } - if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" { - if answer, ok := a.redirectModelCreateSessionToStrategyCreateIfNeeded(storeUserID, userID, lang, text, session); ok { - if onEvent != nil && strings.TrimSpace(answer) != "" { - onEvent(StreamEventTool, "hard_skill:strategy_management") - emitStreamText(onEvent, answer) - } - return answer, true, nil - } - decision, _ := a.resolveSkillSessionTurn(ctx, userID, lang, text, session) - 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": - answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent) - return answer, handled, err - default: - 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 - } - } - } - - state := a.getExecutionState(userID) - if hasActiveExecutionState(state) { - decision, extraction := a.resolveExecutionStateTurn(ctx, userID, lang, state, text) - switch decision.Intent { - case "cancel": - 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 answer, handled, err := a.redirectExecutionStateStrategyCreate(ctx, storeUserID, userID, lang, text, state, onEvent); handled || err != nil { - return answer, handled, err - } - 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 - } - } - - 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", "scan_interval_minutes", "is_cross_margin", "show_in_competition": - fields[key] = value - } - } - if len(fields) == 0 { - return nil - } - return fields -} - -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 - } - if skillName == "strategy_management" && action == "create" { - 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": - a.hydrateCreateTraderSlotReferences(storeUserID, &session) - } - a.saveSkillSession(userID, session) - a.clearExecutionState(userID) - return session, true -} - -func (a *Agent) redirectExecutionStateStrategyCreate(ctx context.Context, storeUserID string, userID int64, lang, text string, state ExecutionState, onEvent func(event, data string)) (string, bool, error) { - skillName, action := inferExecutionStateSkillBridge(state, text) - if skillName != "strategy_management" || action != "create" { - return "", false, nil - } - a.clearExecutionState(userID) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.Goal = defaultIfEmpty(strings.TrimSpace(state.Goal), strings.TrimSpace(text)) - return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) -} - -func (a *Agent) redirectModelCreateSessionToStrategyCreateIfNeeded(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - if strings.TrimSpace(session.Name) != "model_management" || strings.TrimSpace(session.Action) != "create" { - return "", false - } - strategyType := parseStrategyTypeValue(text) - if strategyType == "" && !hasExplicitCreateIntentForDomain(text, "strategy") { - return "", false - } - strategySession := skillSession{ - Name: "strategy_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{}, - } - if strategyType != "" { - setStrategyCreateType(&strategySession, strategyType) - } - a.clearSkillSession(userID) - return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, strategySession), 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"} - } - 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 kept the previous flow available. Say “continue” when you want to resume it.") -} - -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) { - if a.aiClient != nil { - return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) - } - 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(storeUserID, 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 == "" { - return "continue" - } - if isYesReply(text) || isNoReply(text) { - return "continue" - } - if isExplicitFlowAbort(text) { - return "cancel" - } - 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 a != nil && a.aiClient != nil { - if decision := a.classifySkillSessionIntentWithLLM(ctx, userID, lang, session, text); decision != "" { - return decision - } - return "continue" - } - 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) { - return "continue" - } - return "interrupt" -} - -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 - } - if isExplicitFlowAbort(text) || isYesReply(text) || isNoReply(text) { - return false - } - return true -} - -func detectRootSkillIntent(text string) string { - return "" -} - -func shouldInterruptSkillSessionBySnapshot(session skillSession, text string) bool { - currentSkill := strings.TrimSpace(session.Name) - if currentSkill == "" { - return false - } - rootSkill := detectRootSkillIntent(text) - if rootSkill == "" { - return false - } - 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 - } - } - } - mentioned := detectMentionedSkillDomain(text) - if mentioned == "" || mentioned == currentSkill { - return false - } - return looksLikeNewTopLevelIntent(text) -} - -func (a *Agent) classifySkillSessionIntentWithLLM(ctx context.Context, userID int64, lang string, session skillSession, text string) string { - if a == nil || a.aiClient == nil { - return "" - } - if !shouldUseLLMSkillSessionClassifier(session, text) { - return "" - } - currentStep, _ := currentSkillDAGStep(session) - recentConversationCtx := a.buildRecentConversationContext(userID, text) - 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() - 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 (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 { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if containsAny(lower, []string{ - "看下报错", "看看报错", "帮我看下报错", "帮我看看报错", "报错怎么回事", "错误怎么回事", - "换话题", "聊别的", "不是这个", "先说别的", "不聊这个", - }) { - return true - } - switch strings.TrimSpace(session.Name) { - case "exchange_management": - return false - case "model_management": - return false - case "strategy_management": - return false - case "trader_management": - return false - default: - return false - } -} - -func isNewSkillRootIntent(session skillSession, text string) bool { - currentSkill := strings.TrimSpace(session.Name) - currentAction := strings.TrimSpace(session.Action) - 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": - return hasExplicitCreateIntentForDomain(text, "trader") && currentAction != "create" - case "strategy_management": - return hasExplicitManagementDomainCue(text, "strategy") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" - case "model_management": - return hasExplicitManagementDomainCue(text, "model") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" - case "exchange_management": - return hasExplicitManagementDomainCue(text, "exchange") && containsAny(strings.ToLower(strings.TrimSpace(text)), []string{"创建", "新建", "create", "new"}) && currentAction != "create" - } - return false -} - -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" - } - if isExplicitFlowAbort(text) { - return "cancel" - } - 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" - } - if looksLikeNewTopLevelIntent(text) { - return "interrupt" - } - 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 == "" { - return false - } - if isCancelSkillReply(text) { - return true - } - return containsAny(lower, []string{ - "算了", "先不", "不配了", "别弄了", "不搞了", "先停", "换个话题", "换话题", "聊点别的", "聊别的", - "stop this", "drop it", "never mind", "forget it", "skip this", - }) -} - -func belongsToSkillDomain(skillName, text string) bool { - switch strings.TrimSpace(skillName) { - case "trader_management": - return hasExplicitCreateIntentForDomain(text, "trader") - case "strategy_management": - return false - case "model_management": - return false - case "exchange_management": - return false - default: - return false - } -} - -func looksLikeNewTopLevelIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.HasPrefix(lower, "/") { - return true - } - if hasExplicitCreateIntentForDomain(text, "trader") { - return true - } - if detectReadFastPath(text) != nil { - return true - } - return containsAny(lower, []string{ - "btc", "eth", "sol", "市场", "行情", "余额", "仓位", "持仓", "订单", "账户", - "price", "market", "balance", "position", "portfolio", "account", - }) -} - -func (a *Agent) tryDirectAnswer(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, bool) { - if a.aiClient == nil { - return "", false - } - - text = strings.TrimSpace(text) - if text == "" { - return "", false - } - - 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. - -Use "direct_answer" only when a concise, self-contained answer is sufficient. -Examples that often fit direct_answer: -- greetings, thanks, small talk -- concept explanations -- open-ended advice that does not require current system state -- trading education or opinion questions that can be answered from general reasoning - -Use "defer" when the message likely needs: -- a management or diagnosis skill -- 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: -- 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\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() - - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - if err != nil { - return "", false - } - - decision, err := parseDirectReplyDecision(raw) - if err != nil { - return "", false - } - if decision.Action != "direct_answer" { - return "", false - } - - answer := strings.TrimSpace(decision.Answer) - if answer == "" { - return "", false - } - - if a.history == nil { - a.history = newChatHistory(chatHistoryMaxTurns) - } - a.history.Add(userID, "user", text) - a.history.Add(userID, "assistant", answer) - a.runPostResponseMaintenanceAsync(userID) - if onEvent != nil { - emitStreamText(onEvent, answer) - } - return answer, true -} - -func parseDirectReplyDecision(raw string) (directReplyDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var decision directReplyDecision - if err := json.Unmarshal([]byte(raw), &decision); err == nil { - return normalizeDirectReplyDecision(decision), nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { - return normalizeDirectReplyDecision(decision), nil - } - } - return directReplyDecision{}, fmt.Errorf("invalid direct reply decision json") -} - -func normalizeDirectReplyDecision(decision directReplyDecision) directReplyDecision { - decision.Action = strings.TrimSpace(strings.ToLower(decision.Action)) - decision.Answer = strings.TrimSpace(decision.Answer) - 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 answer, handled, err := a.redirectExecutionStateStrategyCreate(ctx, storeUserID, userID, lang, text, state, onEvent); handled || err != nil { - return answer, handled, err - } - 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) { - return a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "", onEvent) -} - -func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID string, userID int64, lang, text string, contextMode string, onEvent func(event, data string)) (string, error) { - if session, ok := a.activeStrategyCreateSession(userID); ok { - answer, _, err := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - return answer, err - } - a.ensureHistory() - a.history.Add(userID, "user", text) - if onEvent != nil { - onEvent(StreamEventPlanning, a.planningStatusText(lang)) - } - - requestStartedAt := time.Now() - state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text, contextMode) - if err != nil { - a.logPlannerTiming("", userID, "prepare_execution_state", requestStartedAt, err) - if isPlannerTimeoutError(err) { - msg := plannerTimeoutMessage(lang) - if onEvent != nil { - onEvent(StreamEventError, msg) - emitStreamText(onEvent, msg) - } - return msg, nil - } - if hasExplicitCreateIntentForDomain(text, "strategy") { - a.logger.Warn("planner failed during strategy create; using template strategy flow instead of legacy loop", "error", err, "user_id", userID) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.Goal = strings.TrimSpace(text) - answer, _, flowErr := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - return answer, flowErr - } - a.logger.Warn("planner failed, falling back to legacy loop", "error", err, "user_id", userID) - return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent) - } - a.logPlannerTiming(state.SessionID, userID, "prepare_execution_state", requestStartedAt, nil) - - executionStartedAt := time.Now() - answer, err := a.executePlan(ctx, storeUserID, userID, lang, &state, onEvent) - a.logPlannerTiming(state.SessionID, userID, "execute_plan", executionStartedAt, err) - if err != nil { - if isPlannerTimeoutError(err) { - msg := plannerTimeoutMessage(lang) - if onEvent != nil { - onEvent(StreamEventError, msg) - emitStreamText(onEvent, msg) - } - return msg, nil - } - if answer, ok := a.tryExecutionSummaryFallbackOnAIError(lang, &state, err, onEvent); ok { - return answer, nil - } - if hasExplicitCreateIntentForDomain(state.Goal, "strategy") || hasExplicitCreateIntentForDomain(text, "strategy") { - a.logger.Warn("plan execution failed during strategy create; using template strategy flow instead of legacy loop", "error", err, "user_id", userID) - a.clearExecutionState(userID) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.Goal = defaultIfEmpty(strings.TrimSpace(state.Goal), strings.TrimSpace(text)) - answer, _, flowErr := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) - return answer, flowErr - } - a.logger.Warn("plan execution failed, falling back to legacy loop", "error", err, "user_id", userID) - return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent) - } - - if guarded, blocked := guardUnsupportedAsyncPromise(lang, answer); blocked { - answer = guarded - } - a.history.Add(userID, "assistant", answer) - a.runPostResponseMaintenanceAsync(userID) - a.logPlannerTiming(state.SessionID, userID, "run_planned_agent_total", requestStartedAt, nil) - return answer, nil -} - -func (a *Agent) runPostResponseMaintenanceAsync(userID int64) { - if a == nil || a.aiClient == nil || a.history == nil { - return - } - go func() { - defer func() { - if r := recover(); r != nil { - a.log().Warn("post-response maintenance panicked", "user_id", userID, "panic", r) - } - }() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - // Respect agent shutdown: abort early if stopCh is closed. - select { - case <-a.stopCh: - return - default: - } - a.maybeUpdateTaskStateIncrementally(ctx, userID) - a.maybeCompressHistory(ctx, userID) - }() -} - -func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text, contextMode string) (ExecutionState, error) { - existing := a.getExecutionState(userID) - if shouldResetExecutionStateForNewAttempt(text, existing) { - a.clearExecutionState(userID) - existing = ExecutionState{} - } - if existing.Status == executionStatusWaitingUser && existing.SessionID != "" { - a.refreshCurrentReferencesForUserText(storeUserID, text, &existing) - askedQuestion := latestAskedQuestion(existing) - replySummary := strings.TrimSpace(text) - if askedQuestion != "" { - replySummary = fmt.Sprintf("Answer to previous question [%s]: %s", askedQuestion, replySummary) - } - appendExecutionLog(&existing, Observation{ - Kind: "user_reply", - Summary: replySummary, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - existing.Status = executionStatusPlanning - existing.Waiting = nil - existing.FinalAnswer = "" - existing.LastError = "" - existing = a.refreshStateForDynamicRequests(storeUserID, text, existing) - existing.Steps = completedSteps(existing.Steps) - existing.CurrentStepID = "" - existing.Status = executionStatusRunning - existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(existing); err != nil { - return ExecutionState{}, err - } - return existing, nil - } - - state := newExecutionState(userID, text) - mem := a.getReferenceMemory(userID) - switch strings.TrimSpace(contextMode) { - case "fresh_context": - a.SnapshotManager(userID).Clear() - default: - if mem.CurrentReferences != nil { - state.CurrentReferences = mem.CurrentReferences - state.ReferenceHistory = mem.ReferenceHistory - } - } - a.refreshCurrentReferencesForUserText(storeUserID, text, &state) - state = a.refreshStateForDynamicRequests(storeUserID, text, state) - state.Status = executionStatusRunning - if err := a.saveExecutionState(state); err != nil { - return ExecutionState{}, err - } - return state, nil -} - -type nextStepDecision struct { - Goal string `json:"goal"` - Steps []PlanStep `json:"steps,omitempty"` - Step PlanStep `json:"step"` -} - -func (a *Agent) decideNextStep(ctx context.Context, userID int64, lang string, state ExecutionState) (nextStepDecision, error) { - toolDefs, _ := json.Marshal(plannerToolsForText(state.Goal)) - obsJSON, _ := json.Marshal(buildObservationContext(state)) - recentlyFetchedJSON, _ := json.Marshal(buildRecentlyFetchedData(state, time.Now().UTC())) - 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. - -You are operating in ReAct mode: Thought -> Action -> Observation. -Choose the immediate next action batch. Do not generate a long multi-step execution plan. - -CRITICAL — Minimal tool principle: -- Only call tools that DIRECTLY answer the user's Goal. -- Do NOT call extra tools "just in case" or "for context". If the user asks about their wallet address, do NOT also fetch market data or balances. -- If the user asks one question, call one tool (or zero if you already have the answer). - -Allowed step types: -- tool -- reason -- ask_user -- respond - -Rules: -- 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: 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. -- Use reason only when a short intermediate synthesis is necessary before the next action. -- Prefer tool or respond over reason whenever possible. -- Never emit the same reason step twice in a row. -- After a reason step, the next batch should usually be tool, ask_user, or respond. Do not stay in analysis loops. -- Never invent tools. -- If the task needs multiple independent tool reads, emit ALL of them together in one response. -- Parallelism rule: when multiple tool reads are mutually independent, do not split them across turns. Return them together in steps. -- Never mix ask_user/respond with additional steps in the same batch. -- Only emit multiple steps when every emitted step is a tool step. -- Avoid repeated tool calls. If a matching tool call already exists in Recently fetched data and age_seconds <= 60, do not call it again unless the user explicitly asks to refresh. -- For tool steps, set tool_name exactly to one available tool and provide tool_args as a JSON object. -- For ask_user or respond steps, put the user-facing question/response instruction in instruction. -- If the latest observation already answers the goal, prefer respond over another tool call. -- Never place a trade unless the user intent is explicit. -- Do NOT plan a self-introduction or capability overview unless the user explicitly asks "what can you do". Answer the user's question directly. - -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\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() - - startedAt := time.Now() - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - a.logPlannerTiming(state.SessionID, userID, "decide_next_step_llm", startedAt, err) - if err != nil { - return nextStepDecision{}, err - } - return parseNextStepDecisionJSON(raw) -} - -func parseNextStepDecisionJSON(raw string) (nextStepDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var decision nextStepDecision - if err := json.Unmarshal([]byte(raw), &decision); err == nil { - return normalizeNextStepDecision(decision), nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { - return normalizeNextStepDecision(decision), nil - } - } - return nextStepDecision{}, fmt.Errorf("invalid next step decision json") -} - -func normalizeNextStepDecision(decision nextStepDecision) nextStepDecision { - decision.Goal = strings.TrimSpace(decision.Goal) - steps := decision.Steps - if len(steps) == 0 && decision.Step.Type != "" { - steps = []PlanStep{decision.Step} - } - if len(steps) > 0 { - steps = normalizeExecutionState(ExecutionState{Steps: steps}).Steps - } - decision.Steps = steps - if len(steps) > 0 { - decision.Step = steps[0] - } - return decision -} - -func (a *Agent) refreshStateForDynamicRequests(storeUserID, userText string, state ExecutionState) ExecutionState { - kinds := snapshotKindsForIntent(userText) - if len(kinds) == 0 { - return state - } - kindsToRefresh := make(map[string]struct{}, len(kinds)) - for _, kind := range kinds { - kindsToRefresh[kind] = struct{}{} - } - - fresh := make([]Observation, 0, len(state.DynamicSnapshots)+3) - for _, obs := range state.DynamicSnapshots { - if _, ok := kindsToRefresh[obs.Kind]; ok { - continue - } - fresh = append(fresh, obs) - } - - appendSnapshot := func(kind, raw string) { - raw = strings.TrimSpace(raw) - if raw == "" { - return - } - fresh = append(fresh, Observation{ - Kind: kind, - Summary: summarizeObservation(raw), - RawJSON: raw, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - } - - for _, kind := range kinds { - switch kind { - case "current_model_configs": - appendSnapshot(kind, a.toolGetModelConfigs(storeUserID)) - case "current_exchange_configs": - appendSnapshot(kind, a.toolGetExchangeConfigs(storeUserID)) - case "current_traders": - appendSnapshot(kind, a.toolListTraders(storeUserID)) - case "current_strategies": - appendSnapshot(kind, a.toolGetStrategies(storeUserID)) - case "current_balances": - appendSnapshot(kind, a.toolGetBalance(storeUserID)) - case "current_positions": - appendSnapshot(kind, a.toolGetPositions(storeUserID)) - case "recent_trade_history": - appendSnapshot(kind, a.toolGetTradeHistory(`{"limit":10}`)) - } - } - state.DynamicSnapshots = fresh - return state -} - -func (a *Agent) buildRecentConversationContext(userID int64, currentUserText string) string { - if a.history == nil { - return "" - } - - msgs := a.history.Get(userID) - if len(msgs) == 0 { - return "" - } - - currentUserText = strings.TrimSpace(currentUserText) - if currentUserText != "" { - last := msgs[len(msgs)-1] - if last.Role == "user" && strings.TrimSpace(last.Content) == currentUserText { - msgs = msgs[:len(msgs)-1] - } - } - - if len(msgs) == 0 { - return "" - } - if len(msgs) > recentConversationMessages { - msgs = msgs[len(msgs)-recentConversationMessages:] - } - - transcript := formatChatMessagesForSummary(msgs) - if transcript == "" { - return "" - } - return transcript -} - -func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) { - toolDefs, _ := json.Marshal(plannerToolsForText(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 - // durable summaries. Prefer the current turn context plus fresh tool checks. - activeTaskCtx = "" - } - - 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: -- tool -- reason -- ask_user -- respond - -Rules: -- Use a compact memory layout when planning: Current reference summary, Current turn context, and Active task state. -- Memory priority order: - 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. -- 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 editing/query requests, prefer get_strategies/manage_strategy. -- For strategy template creation, do not call manage_strategy action=create from the planner. Strategy creation must be handled by the active strategy template flow so the selected product editor template can collect fields and require chat confirmation. -- 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. -- For high-volatility state such as balances, positions, recent trade history, or current config availability, prefer fresh tool reads over old observations. -- Keep the plan short and practical. -- End with either ask_user or respond. -- At most 8 steps. -- For tool steps, set tool_name exactly to one of the available tool names and provide tool_args as JSON object. -- For reason steps, put the reasoning task in instruction. -- 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.`) - - resumeContext := "" - if state.SessionID != "" { - if askedQuestion := latestAskedQuestion(state); askedQuestion != "" { - resumeContext = fmt.Sprintf("\n\nResume context:\n- The assistant was waiting for the user's answer to this exact question: %s\n- Interpret the new user message as the answer to that question unless the message clearly starts a new topic.", askedQuestion) - if state.Waiting != nil { - waitingJSON, _ := json.Marshal(state.Waiting) - resumeContext += fmt.Sprintf("\n- Structured waiting state JSON: %s", string(waitingJSON)) - } - } - } - - 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() - - startedAt := time.Now() - resp, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - a.logPlannerTiming(state.SessionID, userID, "create_execution_plan_llm", startedAt, err) - if err != nil { - return executionPlan{}, err - } - - plan, err := parseExecutionPlanJSON(resp) - if err != nil { - return executionPlan{}, err - } - if len(plan.Steps) == 0 { - return executionPlan{}, fmt.Errorf("empty execution plan") - } - if len(plan.Steps) > plannerMaxSteps { - plan.Steps = plan.Steps[:plannerMaxSteps] - } - for i := range plan.Steps { - if plan.Steps[i].ID == "" { - plan.Steps[i].ID = fmt.Sprintf("step_%d", i+1) - } - if plan.Steps[i].Status == "" { - plan.Steps[i].Status = planStepStatusPending - } - if plan.Steps[i].Title == "" { - plan.Steps[i].Title = strings.ReplaceAll(plan.Steps[i].ID, "_", " ") - } - } - if strings.TrimSpace(plan.Goal) == "" { - plan.Goal = strings.TrimSpace(userText) - } - return plan, nil -} - -func parseExecutionPlanJSON(raw string) (executionPlan, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var plan executionPlan - if err := json.Unmarshal([]byte(raw), &plan); err == nil { - return plan, nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &plan); err == nil { - return plan, nil - } - } - return executionPlan{}, fmt.Errorf("invalid execution plan json") -} - -func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int64, lang string, state *ExecutionState, onEvent func(event, data string)) (string, error) { - if onEvent != nil && len(state.Steps) > 0 { - onEvent(StreamEventPlan, formatPlanStatus(*state, lang)) - } - - for i := 0; i < plannerMaxIterations; i++ { - stepIndex := nextPendingStepIndex(state.Steps) - if stepIndex < 0 { - decisionStartedAt := time.Now() - decision, err := a.decideNextStep(ctx, userID, lang, *state) - a.logPlannerTiming(state.SessionID, userID, "decide_next_step", decisionStartedAt, err) - if err != nil { - return "", err - } - steps := filterFreshDuplicateToolSteps(decision.Steps, *state, time.Now().UTC()) - if len(steps) == 0 { - return "", fmt.Errorf("all next steps are duplicate fresh tool calls") - } - if hasRepeatedReasonLoop(*state, steps) { - return "", fmt.Errorf("repeated reasoning loop detected") - } - if decision.Goal != "" { - state.Goal = decision.Goal - } - base := len(completedSteps(state.Steps)) - for idx := range steps { - if steps[idx].Type == "" { - return "", fmt.Errorf("next step decision missing step type") - } - if steps[idx].ID == "" { - steps[idx].ID = fmt.Sprintf("step_%d", base+idx+1) - } - if steps[idx].Title == "" { - steps[idx].Title = strings.ReplaceAll(steps[idx].ID, "_", " ") - } - if steps[idx].Status == "" { - steps[idx].Status = planStepStatusPending - } - } - state.Steps = append(completedSteps(state.Steps), steps...) - state.Status = executionStatusRunning - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - if onEvent != nil { - onEvent(StreamEventPlan, formatPlanStatus(*state, lang)) - } - continue - } - - step := &state.Steps[stepIndex] - step.Status = planStepStatusRunning - state.Status = executionStatusRunning - state.CurrentStepID = step.ID - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if onEvent != nil { - onEvent(StreamEventStepStart, formatStepStatus(*step, stepIndex, len(state.Steps), lang)) - } - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - - switch step.Type { - case planStepTypeTool: - if answer, handled := a.redirectPlannerStrategyCreateStep(storeUserID, userID, lang, state.Goal, *step); handled { - a.clearExecutionState(userID) - if onEvent != nil && strings.TrimSpace(answer) != "" { - emitStreamText(onEvent, answer) - } - return answer, nil - } - if onEvent != nil { - onEvent(StreamEventTool, step.ToolName) - } - stepStartedAt := time.Now() - result := a.executePlanTool(ctx, storeUserID, userID, lang, *step) - a.logPlannerTiming(state.SessionID, userID, "tool:"+step.ToolName, stepStartedAt, nil) - summary := summarizeObservation(result) - referencesChanged := false - step.Status = planStepStatusCompleted - step.OutputSummary = summary - appendExecutionLog(state, Observation{ - StepID: step.ID, - Kind: "tool_result", - Summary: summary, - RawJSON: result, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - referencesChanged = updateCurrentReferencesFromToolResult(state, step.ToolName, result) - _ = referencesChanged - case planStepTypeReason: - reasonStartedAt := time.Now() - reasoning, err := a.executeReasonStep(ctx, userID, lang, state.Goal, *state, *step) - a.logPlannerTiming(state.SessionID, userID, "reason_step", reasonStartedAt, err) - if err != nil { - step.Status = planStepStatusFailed - step.Error = err.Error() - state.Status = executionStatusFailed - state.LastError = err.Error() - _ = a.saveExecutionState(*state) - return "", err - } - step.Status = planStepStatusCompleted - step.OutputSummary = reasoning - appendExecutionLog(state, Observation{ - StepID: step.ID, - Kind: "reasoning", - Summary: reasoning, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - case planStepTypeAskUser: - question := strings.TrimSpace(step.Instruction) - if question == "" { - if lang == "zh" { - question = "我还缺少一些信息,麻烦你补充一下。" - } else { - question = "I need a bit more information before I continue." - } - } - step.Status = planStepStatusCompleted - step.OutputSummary = question - state.Status = executionStatusWaitingUser - state.Waiting = buildWaitingState(*state, *step, question) - state.FinalAnswer = question - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - if onEvent != nil { - onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - emitStreamText(onEvent, question) - } - return question, nil - case planStepTypeRespond: - if finalText := deterministicCompletedPlanResponse(lang, *state, *step); finalText != "" { - step.Status = planStepStatusCompleted - step.OutputSummary = finalText - state.Status = executionStatusCompleted - state.Waiting = nil - state.FinalAnswer = finalText - state.CurrentStepID = "" - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - if onEvent != nil { - onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - emitStreamText(onEvent, finalText) - } - return finalText, nil - } - respondStartedAt := time.Now() - finalText, err := a.generateFinalPlanResponse(ctx, storeUserID, userID, lang, *state, step.Instruction) - a.logPlannerTiming(state.SessionID, userID, "respond_step", respondStartedAt, err) - if err != nil { - return "", err - } - step.Status = planStepStatusCompleted - step.OutputSummary = finalText - state.Status = executionStatusCompleted - state.Waiting = nil - state.FinalAnswer = finalText - state.CurrentStepID = "" - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - if onEvent != nil { - onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - emitStreamText(onEvent, finalText) - } - return finalText, nil - default: - return "", fmt.Errorf("unsupported step type: %s", step.Type) - } - - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if err := a.saveExecutionState(*state); err != nil { - return "", err - } - if onEvent != nil { - onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang)) - } - } - - return "", fmt.Errorf("plan execution exceeded iteration limit") -} - -func deterministicCompletedPlanResponse(lang string, state ExecutionState, respondStep PlanStep) string { - if !isCompletionOnlyRespondStep(respondStep) { - return "" - } - completed := make([]PlanStep, 0, len(state.Steps)) - for _, step := range state.Steps { - if step.ID == respondStep.ID { - continue - } - if step.Status == planStepStatusCompleted && step.Type == planStepTypeTool { - completed = append(completed, step) - continue - } - if step.Status == planStepStatusCompleted && step.Type == planStepTypeReason { - return "" - } - } - if len(completed) == 0 { - return "" - } - return formatCompletedPlanFallback(lang, completed) -} - -func isCompletionOnlyRespondStep(step PlanStep) bool { - text := strings.ToLower(strings.TrimSpace(step.Title + " " + step.Instruction)) - if text == "" { - return false - } - return containsAny(text, []string{ - "成功", - "完成", - "确认", - "created", - "updated", - "deleted", - "activated", - "duplicated", - "completed", - "confirm", - }) -} - -type fetchedToolRecord struct { - ToolName string `json:"tool_name"` - ToolArgsJSON string `json:"tool_args_json"` - FetchedAt string `json:"fetched_at"` - AgeSeconds int64 `json:"age_seconds"` -} - -func buildRecentlyFetchedData(state ExecutionState, now time.Time) []fetchedToolRecord { - state = normalizeExecutionState(state) - stepByID := make(map[string]PlanStep, len(state.Steps)) - for _, step := range state.Steps { - stepByID[step.ID] = step - } - latest := map[string]fetchedToolRecord{} - for _, obs := range state.ExecutionLog { - if obs.Kind != "tool_result" { - continue - } - step, ok := stepByID[obs.StepID] - if !ok || step.ToolName == "" { - continue - } - sig := toolCallSignature(step.ToolName, step.ToolArgs) - createdAt := parseRFC3339(obs.CreatedAt) - record := fetchedToolRecord{ - ToolName: step.ToolName, - ToolArgsJSON: toolArgsJSONString(step.ToolArgs), - FetchedAt: obs.CreatedAt, - AgeSeconds: int64(now.Sub(createdAt).Seconds()), - } - prev, exists := latest[sig] - if !exists || prev.FetchedAt < record.FetchedAt { - latest[sig] = record - } - } - out := make([]fetchedToolRecord, 0, len(latest)) - for _, record := range latest { - if record.AgeSeconds < 0 { - record.AgeSeconds = 0 - } - out = append(out, record) - } - return out -} - -func filterFreshDuplicateToolSteps(steps []PlanStep, state ExecutionState, now time.Time) []PlanStep { - if len(steps) == 0 { - return nil - } - fresh := make(map[string]struct{}) - for _, item := range buildRecentlyFetchedData(state, now) { - if item.AgeSeconds <= 60 { - fresh[item.ToolName+"|"+item.ToolArgsJSON] = struct{}{} - } - } - out := make([]PlanStep, 0, len(steps)) - for _, step := range steps { - if step.Type != planStepTypeTool { - out = append(out, step) - continue - } - sig := toolCallSignature(step.ToolName, step.ToolArgs) - if _, ok := fresh[sig]; ok { - continue - } - fresh[sig] = struct{}{} - out = append(out, step) - } - return out -} - -func hasRepeatedReasonLoop(state ExecutionState, steps []PlanStep) bool { - if len(steps) == 0 { - return false - } - last := lastCompletedStep(state.Steps) - if last == nil || last.Type != planStepTypeReason { - return false - } - for _, step := range steps { - if step.Type != planStepTypeReason { - return false - } - if stepSemanticKey(*last) != stepSemanticKey(step) { - return false - } - } - return true -} - -func lastCompletedStep(steps []PlanStep) *PlanStep { - for i := len(steps) - 1; i >= 0; i-- { - if steps[i].Status == planStepStatusCompleted { - return &steps[i] - } - } - return nil -} - -func stepSemanticKey(step PlanStep) string { - return strings.ToLower(strings.TrimSpace( - step.Type + "|" + step.ToolName + "|" + step.Title + "|" + step.Instruction, - )) -} - -func toolCallSignature(toolName string, args map[string]any) string { - return strings.TrimSpace(toolName) + "|" + toolArgsJSONString(args) -} - -func toolArgsJSONString(args map[string]any) string { - if len(args) == 0 { - return "{}" - } - data, err := json.Marshal(args) - if err != nil { - return "{}" - } - return string(data) -} - -func parseRFC3339(value string) time.Time { - t, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) - if err != nil { - return time.Time{} - } - return t -} - -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 := prependNOFXiAdvisorPreamble(`You are the replanning module for NOFXi. -Return JSON only. - -Decide what to do after a plan step completed. -Allowed actions: -- continue -- replace_remaining -- ask_user -- finish - -Rules: -- Use continue when the current remaining steps still make sense. -- Use replace_remaining when the observations materially change the remaining plan. -- Use ask_user when execution is blocked on missing user input. -- 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.`) - - 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))) - - stageCtx, cancel := withPlannerStageTimeout(ctx, plannerReplanTimeout) - defer cancel() - - startedAt := time.Now() - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - MaxTokens: intPtr(500), - }) - a.logPlannerTiming(state.SessionID, userID, "replan_after_step_llm", startedAt, err) - if err != nil { - return replannerDecision{}, err - } - return parseReplannerDecisionJSON(raw) -} - -func parseReplannerDecisionJSON(raw string) (replannerDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var decision replannerDecision - if err := json.Unmarshal([]byte(raw), &decision); err == nil { - return normalizeReplannerDecision(decision), nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { - return normalizeReplannerDecision(decision), nil - } - } - return replannerDecision{}, fmt.Errorf("invalid replanner decision json") -} - -func normalizeReplannerDecision(decision replannerDecision) replannerDecision { - decision.Action = strings.TrimSpace(decision.Action) - decision.Goal = strings.TrimSpace(decision.Goal) - decision.Instruction = strings.TrimSpace(decision.Instruction) - decision.Question = strings.TrimSpace(decision.Question) - for i := range decision.Steps { - if decision.Steps[i].ID == "" { - decision.Steps[i].ID = fmt.Sprintf("step_%d", i+1) - } - if decision.Steps[i].Status == "" { - decision.Steps[i].Status = planStepStatusPending - } - decision.Steps[i].Type = strings.TrimSpace(decision.Steps[i].Type) - decision.Steps[i].Title = strings.TrimSpace(decision.Steps[i].Title) - decision.Steps[i].ToolName = strings.TrimSpace(decision.Steps[i].ToolName) - decision.Steps[i].Instruction = strings.TrimSpace(decision.Steps[i].Instruction) - } - return decision -} - -func applyReplannerDecision(state *ExecutionState, decision replannerDecision) bool { - switch decision.Action { - case "", "continue": - return false - case "finish": - state.Steps = append(completedSteps(state.Steps), PlanStep{ - ID: fmt.Sprintf("step_finish_%d", time.Now().UTC().UnixNano()), - Type: planStepTypeRespond, - Title: "final response", - Status: planStepStatusPending, - Instruction: decision.Instruction, - }) - state.CurrentStepID = "" - if decision.Goal != "" { - state.Goal = decision.Goal - } - state.Waiting = nil - return true - case "ask_user": - question := decision.Question - if question == "" { - question = decision.Instruction - } - state.Steps = append(completedSteps(state.Steps), PlanStep{ - ID: fmt.Sprintf("step_ask_%d", time.Now().UTC().UnixNano()), - Type: planStepTypeAskUser, - Title: "need user input", - Status: planStepStatusPending, - Instruction: question, - }) - state.CurrentStepID = "" - if decision.Goal != "" { - state.Goal = decision.Goal - } - state.Waiting = buildWaitingState(*state, state.Steps[len(state.Steps)-1], question) - return true - case "replace_remaining": - if len(decision.Steps) == 0 { - return false - } - state.Steps = append(completedSteps(state.Steps), decision.Steps...) - state.CurrentStepID = "" - if decision.Goal != "" { - state.Goal = decision.Goal - } - state.Waiting = nil - return true - default: - return false - } -} - -func shouldAttemptReplan(state ExecutionState, step PlanStep, referencesChanged bool) bool { - if step.Type != planStepTypeTool { - return false - } - if toolResultIndicatesError(step.OutputSummary) || toolResultSignalsDependencyGap(step.OutputSummary) { - return true - } - if referencesChanged { - return true - } - if !hasPendingWorkAfterStep(state.Steps) { - return false - } - switch step.ToolName { - case "manage_trader", "manage_strategy", "manage_model_config", "manage_exchange_config", "execute_trade": - return toolActionMayChangePlan(step.ToolArgs) - default: - return false - } -} - -func hasPendingWorkAfterStep(steps []PlanStep) bool { - for _, step := range steps { - if step.Status == planStepStatusPending { - return true - } - } - return false -} - -func toolActionMayChangePlan(args map[string]any) bool { - action, _ := args["action"].(string) - switch strings.TrimSpace(action) { - case "create", "update", "delete", "start", "stop", "activate", "duplicate": - return true - default: - return false - } -} - -func toolResultIndicatesError(summary string) bool { - lower := strings.ToLower(strings.TrimSpace(summary)) - return strings.Contains(lower, `"error"`) || strings.Contains(lower, `"status":"error"`) || strings.Contains(lower, "failed to ") -} - -func toolResultSignalsDependencyGap(summary string) bool { - lower := strings.ToLower(strings.TrimSpace(summary)) - patterns := []string{ - "is required", "invalid ai_model_id", "invalid exchange_id", "invalid strategy_id", - "ai model is disabled", "exchange is disabled", "not found", "missing", - } - return containsAnyKeyword(lower, patterns) -} - -func completedSteps(steps []PlanStep) []PlanStep { - out := make([]PlanStep, 0, len(steps)) - for _, step := range steps { - if step.Status == planStepStatusCompleted { - out = append(out, step) - } - } - return out -} - -func (a *Agent) planningStatusText(lang string) string { - if lang == "zh" { - return "🧭 正在规划执行步骤..." - } - return "🧭 Planning the next execution steps..." -} - -func formatPlanStatus(state ExecutionState, lang string) string { - parts := make([]string, 0, len(state.Steps)) - for i, step := range state.Steps { - label := step.Title - if label == "" { - label = step.Type - } - parts = append(parts, fmt.Sprintf("%d.%s", i+1, label)) - } - if lang == "zh" { - return fmt.Sprintf("🗺️ 计划: %s", strings.Join(parts, " -> ")) - } - return fmt.Sprintf("🗺️ Plan: %s", strings.Join(parts, " -> ")) -} - -func formatStepStatus(step PlanStep, idx, total int, lang string) string { - label := step.Title - if label == "" { - label = step.Type - } - if lang == "zh" { - return fmt.Sprintf("▶️ 步骤 %d/%d: %s", idx+1, total, label) - } - return fmt.Sprintf("▶️ Step %d/%d: %s", idx+1, total, label) -} - -func formatStepCompleteStatus(step PlanStep, lang string) string { - label := step.Title - if label == "" { - label = step.Type - } - if lang == "zh" { - return fmt.Sprintf("✅ 已完成: %s", label) - } - return fmt.Sprintf("✅ Completed: %s", label) -} - -func formatReplanStatus(decision replannerDecision, lang string) string { - switch decision.Action { - case "replace_remaining": - if lang == "zh" { - return "🔄 已根据新结果更新后续步骤" - } - return "🔄 Updated the remaining steps based on new results" - case "ask_user": - if lang == "zh" { - return "📝 当前流程需要用户补充信息" - } - return "📝 This flow needs more user input" - case "finish": - if lang == "zh" { - return "🏁 已提前收敛到最终回复" - } - return "🏁 Converged early to the final response" - default: - if lang == "zh" { - return "🔄 已重新评估计划" - } - return "🔄 Re-evaluated the plan" - } -} - -func (a *Agent) executePlanTool(ctx context.Context, storeUserID string, userID int64, lang string, step PlanStep) string { - argsJSON := "{}" - if len(step.ToolArgs) > 0 { - if data, err := json.Marshal(step.ToolArgs); err == nil { - argsJSON = string(data) - } - } - return a.handleToolCall(ctx, storeUserID, userID, lang, mcp.ToolCall{ - ID: step.ID, - Type: "function", - Function: mcp.ToolCallFunction{ - Name: step.ToolName, - Arguments: argsJSON, - }, - }) -} - -func (a *Agent) redirectPlannerStrategyCreateStep(storeUserID string, userID int64, lang, text string, step PlanStep) (string, bool) { - if strings.TrimSpace(step.ToolName) != "manage_strategy" { - return "", false - } - action, _ := step.ToolArgs["action"].(string) - if strings.TrimSpace(action) != "create" { - return "", false - } - session := skillSession{ - Name: "strategy_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{}, - } - if name, _ := step.ToolArgs["name"].(string); strings.TrimSpace(name) != "" { - setField(&session, "name", name) - } - if rawConfig, ok := step.ToolArgs["config"]; ok { - if strategyType := strategyTypeFromConfigPatchAny(rawConfig); strategyType != "" { - setStrategyCreateType(&session, strategyType) - if sanitized := sanitizeStrategyCreateConfigPatchForType(rawConfig, strategyType); len(sanitized) > 0 { - raw, _ := json.Marshal(sanitized) - setField(&session, strategyCreateConfigPatchField, string(raw)) - } - } - } - if confirmed, ok := step.ToolArgs["confirmed"].(bool); ok && confirmed { - setField(&session, "awaiting_final_confirmation", "true") - } - return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true -} - -func (a *Agent) executeReasonStep(ctx context.Context, userID int64, lang, goal string, state ExecutionState, step PlanStep) (string, error) { - obsJSON, _ := json.Marshal(buildObservationContext(state)) - stageCtx, cancel := withPlannerStageTimeout(ctx, plannerReasonTimeout) - defer cancel() - - startedAt := time.Now() - resp, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage("You are the reasoning module for NOFXi. Return one short paragraph only. No markdown, no bullet list."), - mcp.NewUserMessage(fmt.Sprintf("Language: %s\nGoal: %s\nReasoning task: %s\nObservations JSON: %s\nPersistent preferences: %s\nTask state: %s", lang, goal, step.Instruction, string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))), - }, - Ctx: stageCtx, - }) - a.logPlannerTiming(state.SessionID, userID, "reason_step_llm", startedAt, err) - if err != nil { - return "", err - } - return summarizeObservation(resp), nil -} - -func (a *Agent) generateFinalPlanResponse(ctx context.Context, storeUserID string, userID int64, lang string, state ExecutionState, instruction string) (string, error) { - if instruction == "" { - instruction = "Provide the best possible final response to the user based on the finished execution." - } - // Build a compact observation summary: only step summaries, no raw JSON blobs. - obsSummary := buildCompactObservationSummary(state) - conversationCtx := a.buildRecentConversationContext(userID, state.Goal) - stageCtx, cancel := withPlannerStageTimeout(ctx, plannerFinalTimeout) - defer cancel() - startedAt := time.Now() - resp, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(finalPlanResponseSystemPrompt(lang)), - mcp.NewUserMessage(fmt.Sprintf("Goal: %s\nInstruction: %s\nRecent conversation:\n%s\nTool results:\n%s\nPreferences:\n%s", state.Goal, instruction, defaultIfEmpty(conversationCtx, "(first message)"), obsSummary, a.buildPersistentPreferencesContext(userID))), - }, - Ctx: stageCtx, - }) - a.logPlannerTiming(state.SessionID, userID, "generate_final_response_llm", startedAt, err) - return resp, err -} - -// buildCompactObservationSummary extracts only the step summaries from an -// execution state, omitting raw JSON, dynamic snapshots, and other bulk data. -// This prevents the final-response LLM from being overwhelmed with irrelevant -// data and producing verbose, off-topic replies. -func buildCompactObservationSummary(state ExecutionState) string { - state = normalizeExecutionState(state) - var parts []string - for _, step := range state.Steps { - if step.Status != planStepStatusCompleted || step.OutputSummary == "" { - continue - } - label := step.ToolName - if label == "" { - label = step.Title - } - if label == "" { - label = step.ID - } - summary := step.OutputSummary - if len(summary) > 800 { - summary = summary[:800] + "..." - } - parts = append(parts, fmt.Sprintf("[%s]: %s", label, summary)) - } - if len(parts) == 0 { - return "(no tool results)" - } - return strings.Join(parts, "\n\n") -} - -func finalPlanResponseSystemPrompt(lang string) string { - if lang == "zh" { - return `你是 NOFXi,用户的 AI 交易伙伴。像朋友聊天一样回复。 - -严格规则: -- 只回答 Goal 问的那一件事。用户问余额就只说余额,问持仓就只说持仓,问钱包就只说钱包。 -- Tool results 里有很多数据,但你只用跟 Goal 直接相关的。其余的不要提。 -- 数据为空就说"暂时查不到"加一句原因,不要展开教程。 -- 不要输出表格、分隔线、markdown 标题,除非数据本身需要对比。 -- 不要列"下一步建议"或"需要我帮你做什么",除非用户主动问。 -- 回复尽量短,能一句话说清的不要写一段话。` - } - return `You are NOFXi, the user's AI trading partner. Reply like a friend chatting. - -Strict rules: -- Answer ONLY the one thing asked in Goal. If user asks balance, only say balance. If user asks positions, only say positions. -- Tool results contain lots of data, but you only use what is directly relevant to the Goal. Do not mention the rest. -- If data is empty, say "can't fetch right now" plus one sentence why. Do not expand into tutorials. -- Do not output tables, dividers, or markdown headers unless the data itself needs comparison. -- Do not list "next step suggestions" or "want me to help you with X?" unless the user explicitly asks. -- Keep it short. If you can say it in one sentence, don't write a paragraph.` -} - -func (a *Agent) logPlannerTiming(sessionID string, userID int64, stage string, startedAt time.Time, err error) { - if stage == "" || startedAt.IsZero() { - return - } - attrs := []any{ - "session_id", sessionID, - "user_id", userID, - "stage", stage, - "elapsed_ms", time.Since(startedAt).Milliseconds(), - } - if err != nil { - attrs = append(attrs, "error", err.Error()) - } - a.log().Info("planner timing", attrs...) -} - -func nextPendingStepIndex(steps []PlanStep) int { - for i := range steps { - if steps[i].Status == "" || steps[i].Status == planStepStatusPending { - return i - } - } - return -1 -} - -func summarizeObservation(value string) string { - value = strings.TrimSpace(value) - if len(value) <= observationMaxLength { - return value - } - 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{"已完成:"} - for _, label := range labels { - lines = append(lines, "- "+label) - } - return strings.Join(lines, "\n") - } - lines := []string{"Completed:"} - for _, label := range labels { - lines = append(lines, "- "+label) - } - 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.buildSystemPromptForStoreUser(lang, storeUserID) - enrichment := a.gatherContext(storeUserID, text) - preferencesCtx := a.buildPersistentPreferencesContext(userID) - - userPrompt := text - if preferencesCtx != "" { - userPrompt = preferencesCtx + "\n\n---\n" + userPrompt - } - if enrichment != "" { - userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment - if preferencesCtx != "" { - userPrompt = preferencesCtx + "\n\n---\n" + userPrompt - } - } - - messages := []mcp.Message{mcp.NewSystemMessage(systemPrompt)} - taskStateCtx := buildTaskStateContext(a.getTaskState(userID)) - if isConfigOrTraderIntent(text) { - taskStateCtx = "" - } - if taskStateCtx != "" { - messages = append(messages, mcp.NewSystemMessage(taskStateCtx)) - } - // NOTE: We intentionally do NOT inject conversation history into the legacy - // loop. Even a single prior round causes DeepSeek to hallucinate data from - // earlier topics (e.g. outputting strategy details when asked about a wallet). - // The planner path handles multi-turn context properly; the legacy loop is - // a single-turn fallback. References like "那binance的钱包呢" still work - // because the text itself contains enough keywords for domain routing. - messages = append(messages, mcp.NewUserMessage(userPrompt)) - - // Use domain-filtered tools to reduce over-fetching; fall back to full set - // for "general" domain to preserve full functionality. - domain := plannerToolDomainForText(text) - tools := plannerToolsForText(text) - if domain == "general" { - tools = agentTools() - } - const maxToolRounds = 5 - for round := 0; round < maxToolRounds; round++ { - req := &mcp.Request{ - Messages: messages, - Tools: tools, - ToolChoice: "auto", - Ctx: ctx, - } - - resp, err := a.aiClient.CallWithRequestFull(req) - if err != nil { - if round == 0 { - 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 { - 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 { - reply := resp.Content - if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked { - reply = guarded - } - emitStreamText(onEvent, reply) - return reply, nil - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, resp.Content); blocked { - return guarded, nil - } - return resp.Content, nil - } - - assistantMsg := mcp.Message{Role: "assistant", ToolCalls: resp.ToolCalls} - if resp.Content != "" { - assistantMsg.Content = resp.Content - } - if resp.ReasoningContent != "" { - assistantMsg.ReasoningContent = resp.ReasoningContent - } - messages = append(messages, assistantMsg) - - for _, tc := range resp.ToolCalls { - if onEvent != nil { - onEvent(StreamEventTool, tc.Function.Name) - } - result := a.handleToolCall(ctx, storeUserID, userID, lang, tc) - messages = append(messages, mcp.Message{ - Role: "tool", - Content: result, - ToolCallID: tc.ID, - }) - } - } - - 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 { - if guarded, blocked := guardUnsupportedAsyncPromise(lang, finalResp); blocked { - finalResp = guarded - } - emitStreamText(onEvent, finalResp) - return finalResp, nil - } - if guarded, blocked := guardUnsupportedAsyncPromise(lang, finalResp); blocked { - return guarded, nil - } - return finalResp, nil -} diff --git a/agent/planner_tools_test.go b/agent/planner_tools_test.go deleted file mode 100644 index b8843ee0..00000000 --- a/agent/planner_tools_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package agent - -import ( - "encoding/json" - "testing" - - "nofx/mcp" -) - -// plannerToolsForText now always returns the FULL toolset (no per-domain -// trimming) so the LLM can cross-domain reason. The old "if market intent, -// hide manage_trader" filter was making cross-domain questions like "BTC -// dropped, how much am I losing?" impossible to answer because the agent -// couldn't see both market AND position tools in the same turn. -// -// We still trim the giant strategy schema for non-mutation intents because -// that one is genuinely huge and uninformative for read-only use. - -func TestPlannerToolsExposeFullSetForMarketIntent(t *testing.T) { - tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线") - names := toolNamesForTest(tools) - - // Market tools must be present. - for _, expected := range []string{"get_market_snapshot", "get_market_price", "get_kline"} { - if !containsString(names, expected) { - t.Fatalf("expected market tool %q in %v", expected, names) - } - } - // Cross-domain tools (positions, balance, trader management) must ALSO be - // present so the agent can answer "how much am I losing" follow-ups - // without losing the market context. - for _, expected := range []string{"get_positions", "get_balance", "manage_trader"} { - if !containsString(names, expected) { - t.Fatalf("expected cross-domain tool %q in market context %v", expected, names) - } - } -} - -func TestPlannerToolsExposeFullSetForExchangeIntent(t *testing.T) { - tools := plannerToolsForText("帮我添加 okx 交易所 API key") - names := toolNamesForTest(tools) - - // At least the exchange management tools must show up. - for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} { - if !containsString(names, expected) { - t.Fatalf("expected exchange tool %q in %v", expected, names) - } - } - // And the agent still has the broader surface available — adding an - // exchange often leads to "now create a trader" so trader/strategy tools - // must be reachable in the same turn. - for _, expected := range []string{"manage_trader", "get_strategies"} { - if !containsString(names, expected) { - t.Fatalf("expected adjacent tool %q in exchange context %v", expected, names) - } - } -} - -func TestPlannerToolsUseCompactManageStrategyForReadIntent(t *testing.T) { - tools := plannerToolsForText("列出我的策略") - tool := findToolForTest(tools, "manage_strategy") - if tool == nil { - t.Fatalf("expected manage_strategy in strategy tools") - } - - raw, _ := json.Marshal(tool.Function.Parameters) - if len(raw) > 900 { - t.Fatalf("expected compact strategy schema, got %d bytes", len(raw)) - } - if string(raw) == "" || !json.Valid(raw) { - t.Fatalf("expected valid strategy schema JSON") - } -} - -func TestPlannerToolsKeepFullManageStrategyForMutationIntent(t *testing.T) { - tools := plannerToolsForText("创建一个 BTC 网格策略") - tool := findToolForTest(tools, "manage_strategy") - if tool == nil { - t.Fatalf("expected manage_strategy in strategy tools") - } - - raw, _ := json.Marshal(tool.Function.Parameters) - if len(raw) < 1500 { - t.Fatalf("expected full strategy schema for mutation intent, got %d bytes", len(raw)) - } -} - -func toolNamesForTest(tools []mcp.Tool) []string { - names := make([]string, 0, len(tools)) - for _, tool := range tools { - names = append(names, tool.Function.Name) - } - return names -} - -func findToolForTest(tools []mcp.Tool, name string) *mcp.Tool { - for i := range tools { - if tools[i].Function.Name == name { - return &tools[i] - } - } - return nil -} diff --git a/agent/preferences.go b/agent/preferences.go deleted file mode 100644 index 5b834ecf..00000000 --- a/agent/preferences.go +++ /dev/null @@ -1,166 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "hash/fnv" - "strings" - "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 { - ID string `json:"id"` - Text string `json:"text"` - CreatedAt string `json:"created_at,omitempty"` -} - -func NewPersistentPreference(text string) (PersistentPreference, error) { - text = strings.TrimSpace(text) - 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{ - ID: now.Format("20060102150405.000000000"), - Text: text, - CreatedAt: now.Format(time.RFC3339), - }, nil -} - -// SessionUserIDFromKey maps a stable user key (for example a UUID string from -// auth) to the int64 session id expected by the current agent implementation. -func SessionUserIDFromKey(userKey string) int64 { - if strings.TrimSpace(userKey) == "" { - return 1 - } - h := fnv.New64a() - _, _ = h.Write([]byte(userKey)) - sum := h.Sum64() & 0x7fffffffffffffff - if sum == 0 { - return 1 - } - return int64(sum) -} - -func PreferencesConfigKey(userID int64) string { - return fmt.Sprintf("agent_preferences_%d", userID) -} - -func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference { - if a.store == nil { - return nil - } - - raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID)) - if err != nil || strings.TrimSpace(raw) == "" { - return nil - } - - var prefs []PersistentPreference - if err := json.Unmarshal([]byte(raw), &prefs); err != nil { - a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID) - return nil - } - return prefs -} - -func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error { - if a.store == nil { - return fmt.Errorf("store unavailable") - } - data, err := json.Marshal(prefs) - if err != nil { - return err - } - return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data)) -} - -func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) { - created, err := NewPersistentPreference(text) - if err != nil { - return nil, PersistentPreference{}, err - } - prefs := a.getPersistentPreferences(userID) - prefs = append([]PersistentPreference{created}, prefs...) - if len(prefs) > 20 { - prefs = prefs[:20] - } - if err := a.savePersistentPreferences(userID, prefs); err != nil { - return nil, PersistentPreference{}, err - } - return prefs, created, nil -} - -func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) { - match = strings.TrimSpace(match) - replacement = strings.TrimSpace(replacement) - if match == "" || replacement == "" { - return nil, nil, fmt.Errorf("match and replacement are required") - } - - prefs := a.getPersistentPreferences(userID) - for i := range prefs { - if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) { - prefs[i].Text = replacement - if err := a.savePersistentPreferences(userID, prefs); err != nil { - return nil, nil, err - } - return prefs, &prefs[i], nil - } - } - return prefs, nil, fmt.Errorf("preference not found") -} - -func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) { - match = strings.TrimSpace(match) - if match == "" { - return nil, nil, fmt.Errorf("match required") - } - - prefs := a.getPersistentPreferences(userID) - filtered := make([]PersistentPreference, 0, len(prefs)) - var removed *PersistentPreference - for i := range prefs { - p := prefs[i] - if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) { - cp := p - removed = &cp - continue - } - filtered = append(filtered, p) - } - if removed == nil { - return prefs, nil, fmt.Errorf("preference not found") - } - if err := a.savePersistentPreferences(userID, filtered); err != nil { - return nil, nil, err - } - return filtered, removed, nil -} - -func (a *Agent) buildPersistentPreferencesContext(userID int64) string { - prefs := a.getPersistentPreferences(userID) - if len(prefs) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n") - for _, pref := range prefs { - if strings.TrimSpace(pref.Text) == "" { - continue - } - sb.WriteString("- ") - sb.WriteString(pref.Text) - sb.WriteString("\n") - } - return strings.TrimSpace(sb.String()) -} diff --git a/agent/prompt_context.go b/agent/prompt_context.go deleted file mode 100644 index 23f7af52..00000000 --- a/agent/prompt_context.go +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 9ae3337a..00000000 --- a/agent/prompt_persona.go +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index e07f037f..00000000 --- a/agent/reference_memory.go +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index b2a8da2b..00000000 --- a/agent/scheduler.go +++ /dev/null @@ -1,127 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "log/slog" - "nofx/safe" - "strings" - "sync" - "time" -) - -type Scheduler struct { - agent *Agent - logger *slog.Logger - stopCh chan struct{} - stopOnce sync.Once -} - -func NewScheduler(a *Agent, l *slog.Logger) *Scheduler { - return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})} -} - -func (s *Scheduler) Start(ctx context.Context) { - safe.GoNamed("agent-scheduler", func() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - lastReport := time.Time{} - lastCheck := time.Time{} - for { - select { - 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 { - s.dailyReport() - lastReport = now - } - // Position risk check every 4h - if now.Sub(lastCheck) > 4*time.Hour { - s.riskCheck() - lastCheck = now - } - // Clean expired pending trades every hour. - if now.Minute() == 0 { - if s.agent.pending != nil { - s.agent.pending.CleanExpired() - } - } - } - } - }) -} - -func (s *Scheduler) Stop() { - s.stopOnce.Do(func() { - close(s.stopCh) - }) -} - -func (s *Scheduler) dailyReport() { - if s.agent.traderManager == nil { - return - } - - traders := s.agent.traderManager.GetAllTraders() - if len(traders) == 0 { - return - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02"))) - - totalPnL := 0.0 - for _, t := range traders { - info, err := t.GetAccountInfo() - 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 = "📉" - } - 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 - } - - var alerts []string - for _, t := range s.agent.traderManager.GetAllTraders() { - positions, err := t.GetPositions() - if err != nil { - continue - } - for _, p := range positions { - pnl := toFloat(p["unrealizedPnl"]) - size := toFloat(p["size"]) - if size == 0 { - continue - } - entry := toFloat(p["entryPrice"]) - if entry > 0 { - pnlPct := (pnl / (entry * size)) * 100 - if pnlPct < -5 { - alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)", - p["symbol"], p["side"], pnlPct, pnl)) - } - } - } - } - if len(alerts) > 0 { - s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n")) - } -} diff --git a/agent/sentinel.go b/agent/sentinel.go deleted file mode 100644 index 91d76385..00000000 --- a/agent/sentinel.go +++ /dev/null @@ -1,222 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "log/slog" - "math" - "net/http" - "nofx/safe" - "strconv" - "strings" - "sync" - "time" -) - -type SignalType string - -const ( - SignalPriceBreakout SignalType = "price_breakout" - SignalVolumeSpike SignalType = "volume_spike" - SignalFundingRate SignalType = "funding_rate" -) - -type Signal struct { - Type SignalType - Symbol string - Severity string - Title string - Detail string - Price float64 - Change float64 -} - -type SignalCallback func(Signal) - -type Sentinel struct { - mu sync.RWMutex - symbols []string - history map[string][]pricePt - onSignal SignalCallback - http *http.Client - logger *slog.Logger - stopCh chan struct{} - stopOnce sync.Once -} - -type pricePt struct { - Price float64 - Volume float64 - Time time.Time -} - -func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel { - return &Sentinel{ - symbols: symbols, - history: make(map[string][]pricePt), - onSignal: cb, - http: &http.Client{Timeout: 10 * time.Second}, - logger: logger, - stopCh: make(chan struct{}), - } -} - -func (s *Sentinel) Start() { - safe.GoNamed("sentinel", func() { - ticker := time.NewTicker(60 * time.Second) - defer ticker.Stop() - s.scan() - for { - select { - case <-s.stopCh: - return - case <-ticker.C: - s.scan() - } - } - }) -} - -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` 添加。" - } - 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") - } - for _, sym := range s.symbols { - if pts, ok := s.history[sym]; ok && len(pts) > 0 { - last := pts[len(pts)-1] - sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04"))) - } else { - sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym)) - } - } - return sb.String() -} - -func (s *Sentinel) scan() { - s.mu.RLock() - syms := make([]string, len(s.symbols)) - copy(syms, s.symbols) - s.mu.RUnlock() - for _, sym := range syms { - s.check(sym) - } -} - -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 - } - 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 - } - var t map[string]interface{} - 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) - chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64) - - pt := pricePt{Price: price, Volume: vol, Time: time.Now()} - s.mu.Lock() - h := s.history[symbol] - h = append(h, pt) - if len(h) > 60 { - h = h[len(h)-60:] - } - s.history[symbol] = h - s.mu.Unlock() - - 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" - } - 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)), - Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg), - 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 - } - 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), - Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, 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) - } -} diff --git a/agent/skill_catalog.go b/agent/skill_catalog.go deleted file mode 100644 index 069c4f85..00000000 --- a/agent/skill_catalog.go +++ /dev/null @@ -1,97 +0,0 @@ -package agent - -func skillCatalogPrompt(lang string) string { - if lang == "zh" { - return `## 多轮与 Skill-First 工作模式 -- 对于高频已知任务,优先按 skill 执行,不要每次从零规划 -- 如果用户仍在同一任务里,继续当前 flow,不要重新路由 -- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息 -- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置)必须单独确认 -- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议” - -## 当前重点技能 -### 1. 模型配置与诊断 -- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导 -- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查: - 1. 是否已启用模型 - 2. API Key 是否为空 - 3. custom_api_url 是否为合法 HTTPS 地址 - 4. custom_model_name 是否为空或填错 - 5. 保存后是否需要重新加载 trader -- 已知事实: - - 系统会拒绝非 HTTPS 的 custom_api_url - - 已启用模型如果缺少 API Key 或 custom_api_url,会导致 agent 不可用 - -### 2. 交易所配置与诊断 -- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API,明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段 -- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查: - 1. 系统时间是否同步 - 2. API Key / Secret 是否填反或过期 - 3. IP 白名单是否包含服务器 IP - 4. 是否启用了合约/交易权限 - 5. OKX 是否遗漏 passphrase -- 已知事实: - - OKX 除 API Key 和 Secret 外还需要 passphrase - - invalid signature / timestamp 常见根因是时间不同步或密钥不匹配 - -### 3. Trader 启动与运行诊断 -- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查: - 1. 是否存在可用且启用的模型配置 - 2. 是否存在可用且启用的交易所配置 - 3. trader 绑定的 strategy / exchange / model 是否齐全 - 4. 账户余额和权限是否满足下单要求 - 5. AI 是否一直返回 wait / hold -- 如果用户问“为什么没有开仓”,要明确区分: - - 系统没启动 - - 启动了但 AI 决策为 wait - - 有信号但下单失败 - -### 4. 交易行为异常诊断 -- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查: - 1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode) - 2. 是否为子账户杠杆限制 - 3. 是否为合约权限或 symbol 不可交易 - 4. 是否为余额不足或保证金占用过高 -- 已知事实: - - Binance 若不是 Hedge Mode,可能出现 position side mismatch 或只开单边 - - 某些子账户杠杆受限,超过限制会直接报错 - -### 5. 策略与提示词诊断 -- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议: - 1. 查看当前 strategy 配置 - 2. 区分策略模板本身和 trader 上的 custom prompt - 3. 必要时预览 prompt 或读取当前保存值后再判断 - -## 回答格式要求 -- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答 -- 配置指导类问题尽量按步骤回答 -- 如果已有工具能验证当前状态,先查再下结论 -- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”` - } - - return `## Multi-turn and Skill-First Operating Mode -- For high-frequency known tasks, prefer stable skills instead of replanning from scratch -- If the user is still in the same task, continue the active flow -- Ask only for the minimum missing fields required to proceed -- Require explicit confirmation for destructive or financially sensitive actions -- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps - -## Priority Skills -- skill_model_api_setup / skill_model_config_diagnosis -- skill_exchange_api_setup / skill_exchange_api_diagnosis -- skill_trader_start_diagnosis -- skill_order_execution_diagnosis -- skill_strategy_diagnosis - -Known facts: -- custom_api_url must be a valid HTTPS URL -- OKX requires passphrase in addition to API key and secret -- invalid signature / timestamp often means clock skew or mismatched credentials -- missing enabled model or exchange config can block trader startup -- Binance position-side issues are often caused by One-way Mode vs Hedge Mode - -Response style: -- Diagnostics: symptom -> cause -> checks -> fix -- Setup guidance: step-by-step -- Verify with tools when possible before concluding` -} diff --git a/agent/skill_dag.go b/agent/skill_dag.go deleted file mode 100644 index a0b84c15..00000000 --- a/agent/skill_dag.go +++ /dev/null @@ -1,291 +0,0 @@ -package agent - -import "strings" - -type SkillDAG struct { - SkillName string - Action string - Steps []SkillDAGStep -} - -type SkillDAGStep struct { - ID string - Kind string - RequiredFields []string - OptionalFields []string - Next []string - Terminal bool -} - -var skillDAGRegistry = buildSkillDAGRegistry() - -func buildSkillDAGRegistry() map[string]SkillDAG { - dags := []SkillDAG{ - { - SkillName: "trader_management", - Action: "create", - Steps: []SkillDAGStep{ - {ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}}, - {ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}}, - {ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}}, - {ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}}, - {ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}}, - {ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}}, - {ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true}, - {ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true}, - }, - }, - { - SkillName: "trader_management", - Action: "update_bindings", - 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", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}}, - {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", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}}, - {ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "trader_management", - Action: "stop", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}}, - {ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "trader_management", - Action: "delete", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}}, - {ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "create", - Steps: []SkillDAGStep{ - {ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}}, - {ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "update_name", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}}, - {ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "update_prompt", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}}, - {ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}}, - {ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "update_config", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_config_patch"}}, - {ID: "collect_config_patch", Kind: "collect_slot", RequiredFields: []string{"config_patch"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_patch"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "duplicate", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}}, - {ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}}, - {ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "activate", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}}, - {ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "strategy_management", - Action: "delete", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}}, - {ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "model_management", - Action: "create", - Steps: []SkillDAGStep{ - {ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}}, - {ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}}, - {ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true}, - }, - }, - { - SkillName: "model_management", - Action: "update_status", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}}, - {ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true}, - }, - }, - { - SkillName: "model_management", - Action: "update_endpoint", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}}, - {ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true}, - }, - }, - { - SkillName: "model_management", - Action: "update_name", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}}, - {ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true}, - }, - }, - { - SkillName: "model_management", - Action: "delete", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}}, - {ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - { - SkillName: "exchange_management", - Action: "create", - Steps: []SkillDAGStep{ - {ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}}, - {ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}}, - {ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true}, - }, - }, - { - SkillName: "exchange_management", - Action: "update_name", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}}, - {ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true}, - }, - }, - { - SkillName: "exchange_management", - Action: "update_status", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}}, - {ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}}, - {ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true}, - }, - }, - { - SkillName: "exchange_management", - Action: "delete", - Steps: []SkillDAGStep{ - {ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}}, - {ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}}, - {ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true}, - }, - }, - } - - registry := make(map[string]SkillDAG, len(dags)) - for _, dag := range dags { - dag = normalizeSkillDAG(dag) - if dag.SkillName == "" || dag.Action == "" { - continue - } - registry[skillDAGKey(dag.SkillName, dag.Action)] = dag - } - return registry -} - -func normalizeSkillDAG(dag SkillDAG) SkillDAG { - dag.SkillName = strings.TrimSpace(dag.SkillName) - dag.Action = strings.TrimSpace(dag.Action) - steps := make([]SkillDAGStep, 0, len(dag.Steps)) - for _, step := range dag.Steps { - step.ID = strings.TrimSpace(step.ID) - step.Kind = strings.TrimSpace(step.Kind) - step.RequiredFields = cleanStringList(step.RequiredFields) - step.OptionalFields = cleanStringList(step.OptionalFields) - step.Next = cleanStringList(step.Next) - if step.ID == "" { - continue - } - steps = append(steps, step) - } - dag.Steps = steps - return dag -} - -func skillDAGKey(skillName, action string) string { - return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action) -} - -func getSkillDAG(skillName, action string) (SkillDAG, bool) { - dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)] - return dag, ok -} - -func listSkillDAGs() []SkillDAG { - out := make([]SkillDAG, 0, len(skillDAGRegistry)) - for _, dag := range skillDAGRegistry { - out = append(out, dag) - } - return out -} diff --git a/agent/skill_dag_runtime.go b/agent/skill_dag_runtime.go deleted file mode 100644 index 8178536c..00000000 --- a/agent/skill_dag_runtime.go +++ /dev/null @@ -1,51 +0,0 @@ -package agent - -const skillDAGStepField = "_dag_step" - -func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) { - dag, ok := getSkillDAG(session.Name, session.Action) - if !ok || len(dag.Steps) == 0 { - return SkillDAGStep{}, false - } - stepID := fieldValue(session, skillDAGStepField) - if stepID == "" { - return dag.Steps[0], true - } - for _, step := range dag.Steps { - if step.ID == stepID { - return step, true - } - } - return dag.Steps[0], true -} - -func setSkillDAGStep(session *skillSession, stepID string) { - ensureSkillFields(session) - if stepID == "" { - delete(session.Fields, skillDAGStepField) - return - } - session.Fields[skillDAGStepField] = stepID -} - -func clearSkillDAGStep(session *skillSession) { - if session == nil || session.Fields == nil { - return - } - delete(session.Fields, skillDAGStepField) -} - -func advanceSkillDAGStep(session *skillSession, currentStepID string) { - dag, ok := getSkillDAG(session.Name, session.Action) - if !ok { - return - } - for _, step := range dag.Steps { - if step.ID != currentStepID || len(step.Next) == 0 { - continue - } - setSkillDAGStep(session, step.Next[0]) - return - } -} - diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go deleted file mode 100644 index 41c919c7..00000000 --- a/agent/skill_dispatcher.go +++ /dev/null @@ -1,1133 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "nofx/store" -) - -type skillSession struct { - Name string `json:"name,omitempty"` - Action string `json:"action,omitempty"` - Phase string `json:"phase,omitempty"` - TargetRef *EntityReference `json:"target_ref,omitempty"` - Fields map[string]string `json:"fields,omitempty"` - Slots *createTraderSkillSlots `json:"slots,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -type createTraderSkillSlots struct { - Name string `json:"name,omitempty"` - ExchangeID string `json:"exchange_id,omitempty"` - ExchangeName string `json:"exchange_name,omitempty"` - ModelID string `json:"model_id,omitempty"` - ModelName string `json:"model_name,omitempty"` - StrategyID string `json:"strategy_id,omitempty"` - StrategyName string `json:"strategy_name,omitempty"` - AutoStart *bool `json:"auto_start,omitempty"` -} - -type traderSkillOption struct { - ID string - Name string - Enabled bool - Hint string -} - -func skillSessionConfigKey(userID int64) string { - return fmt.Sprintf("agent_skill_session_%d", userID) -} - -func normalizeSkillSession(session skillSession) skillSession { - session.Name = strings.TrimSpace(session.Name) - session.Action = strings.TrimSpace(session.Action) - session.Phase = strings.TrimSpace(session.Phase) - session.TargetRef = normalizeEntityReference(session.TargetRef) - if len(session.Fields) > 0 { - normalized := make(map[string]string, len(session.Fields)) - for key, value := range session.Fields { - key = normalizeFieldKey(&session, key) - value = strings.TrimSpace(value) - if key == "" || value == "" { - continue - } - normalized[key] = value - } - if len(normalized) > 0 { - session.Fields = normalized - } else { - session.Fields = nil - } - } - 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) - session.Slots.ModelID = strings.TrimSpace(session.Slots.ModelID) - 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.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 - } - } - if session.Name == "" { - return skillSession{} - } - if session.UpdatedAt == "" { - session.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - } - return session -} - -func (a *Agent) getSkillSession(userID int64) skillSession { - if a.store == nil { - return skillSession{} - } - raw, err := a.store.GetSystemConfig(skillSessionConfigKey(userID)) - if err != nil || strings.TrimSpace(raw) == "" { - return skillSession{} - } - var session skillSession - if err := json.Unmarshal([]byte(raw), &session); err != nil { - return skillSession{} - } - return normalizeSkillSession(session) -} - -func (a *Agent) saveSkillSession(userID int64, session skillSession) { - if a.store == nil { - return - } - session = normalizeSkillSession(session) - if session.Name == "" { - _ = a.store.SetSystemConfig(skillSessionConfigKey(userID), "") - return - } - data, err := json.Marshal(session) - if err != nil { - return - } - _ = a.store.SetSystemConfig(skillSessionConfigKey(userID), string(data)) -} - -func (a *Agent) clearSkillSession(userID int64) { - if a.store == nil { - return - } - _ = a.store.SetSystemConfig(skillSessionConfigKey(userID), "") -} - -func isYesReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - for _, candidate := range []string{"是", "好", "好的", "确认", "确认启动", "确认创建", "要", "启动", "开始", "yes", "y", "ok", "confirm", "go ahead"} { - if lower == candidate { - return true - } - } - return false -} - -func isNoReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - for _, candidate := range []string{"不", "不用", "先不用", "取消", "不要", "no", "n", "cancel", "stop"} { - if lower == candidate { - return true - } - } - return false -} - -func isCancelSkillReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - switch lower { - case "取消", "/cancel", "cancel", "不改", "先不改", "算了", "先不用", "不用了", "不弄了", "不搞了", "换话题", "换话题了", "聊别的", "先聊别的": - return true - default: - return false - } -} - -func normalizeTraderDraftName(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - for _, prefix := range []string{"名称:", "名称:", "名字:", "名字:", "name:", "name:"} { - if strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)) { - value = strings.TrimSpace(value[len(prefix):]) - break - } - } - for _, sep := range []string{"交易所:", "交易所:", "模型:", "模型:", "策略:", "策略:", "exchange:", "model:", "strategy:"} { - if idx := strings.Index(strings.ToLower(value), strings.ToLower(sep)); idx >= 0 { - value = strings.TrimSpace(value[:idx]) - } - } - for _, sep := range []string{",", ",", "。", ";", ";", "\n"} { - if idx := strings.Index(value, sep); idx >= 0 { - value = strings.TrimSpace(value[:idx]) - } - } - return strings.Trim(value, "“”\"':: ") -} - -func choosePreferredOption(options []traderSkillOption) *traderSkillOption { - if len(options) == 1 { - copy := options[0] - return © - } - enabled := make([]traderSkillOption, 0, len(options)) - for _, option := range options { - if option.Enabled { - enabled = append(enabled, option) - } - } - if len(enabled) == 1 { - copy := enabled[0] - return © - } - return nil -} - -func formatOptionList(prefix string, options []traderSkillOption) string { - parts := make([]string, 0, len(options)) - for _, option := range options { - label := option.Name - if label == "" { - label = option.ID - } - if hint := strings.TrimSpace(option.Hint); hint != "" { - label += "(" + hint + ")" - } - if option.Enabled { - label += "(已启用)" - } else { - label += "(已禁用)" - } - parts = append(parts, label) - } - if len(parts) == 0 { - return "" - } - return prefix + strings.Join(parts, "、") -} - -func parseSkillError(raw string) string { - var payload map[string]any - if err := json.Unmarshal([]byte(raw), &payload); err == nil { - if msg, _ := payload["error"].(string); strings.TrimSpace(msg) != "" { - return strings.TrimSpace(msg) - } - } - return strings.TrimSpace(raw) -} - -func modelWalletBalanceHint(model *store.AIModel) string { - if model == nil || !agentProviderSupportsUSDCBalance(model.Provider) { - return "" - } - privateKey := strings.TrimSpace(string(model.APIKey)) - if privateKey == "" { - return "钱包未配置" - } - walletAddress, err := agentWalletAddressFromPrivateKey(privateKey) - if err != nil || strings.TrimSpace(walletAddress) == "" { - return "钱包私钥无效" - } - balance, err := agentQueryUSDCBalanceCached(walletAddress) - if err != nil { - return "钱包余额暂时无法读取" - } - if balance <= 0 { - return "钱包余额 0 USDC,需充值后才能稳定调用" - } - return fmt.Sprintf("钱包余额 %.4g USDC", balance) -} - -func (a *Agent) loadEnabledModelOptions(storeUserID string) []traderSkillOption { - if a.store == nil { - return nil - } - models, err := a.store.AIModel().List(storeUserID) - if err != nil { - return nil - } - out := make([]traderSkillOption, 0, len(models)) - for _, model := range models { - name := strings.TrimSpace(model.Name) - if name == "" { - name = strings.TrimSpace(model.ID) - } - hint := strings.Join(cleanStringList([]string{ - strings.TrimSpace(model.CustomModelName), - strings.TrimSpace(model.Provider), - modelWalletBalanceHint(model), - }), " / ") - out = append(out, traderSkillOption{ID: model.ID, Name: name, Hint: hint, Enabled: model.Enabled}) - } - return out -} - -func (a *Agent) loadExchangeOptions(storeUserID string) []traderSkillOption { - if a.store == nil { - return nil - } - exchanges, err := a.store.Exchange().List(storeUserID) - if err != nil { - return nil - } - out := make([]traderSkillOption, 0, len(exchanges)) - for _, exchange := range exchanges { - if !store.IsVisibleExchange(exchange) { - continue - } - name := strings.TrimSpace(exchange.AccountName) - if name == "" { - name = strings.TrimSpace(exchange.ExchangeType) - } - out = append(out, traderSkillOption{ID: exchange.ID, Name: name, Enabled: exchange.Enabled}) - } - return out -} - -func (a *Agent) loadStrategyOptions(storeUserID string) []traderSkillOption { - if a.store == nil { - return nil - } - strategies, err := a.store.Strategy().List(storeUserID) - if err != nil { - return nil - } - out := make([]traderSkillOption, 0, len(strategies)) - for _, strategy := range strategies { - out = append(out, traderSkillOption{ID: strategy.ID, Name: strategy.Name, Enabled: true}) - } - 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 - } - 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") - emitStreamText(onEvent, answer) - } - return answer, true - } - } - return "", false -} - -func (a *Agent) recordSkillInteraction(userID int64, userText, answer string) { - if a.history == nil { - 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) - } -} - -func (a *Agent) handleCreateTraderSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - 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 - } - } - - 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) - return formatValidationFeedback(lang, "trader", err), true - } - } - if missing := missingFieldKeysForSkillSession(session); len(missing) > 0 { - session.Phase = "collecting" - a.saveSkillSession(userID, session) - return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true - } - - if stillMissing := missingFieldKeysForSkillSession(session); len(stillMissing) > 0 { - session.Phase = "collecting" - a.saveSkillSession(userID, session) - return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true - } - - if fieldValue(session, "auto_start") == "true" { - session.Phase = "await_start_confirmation" - a.saveSkillSession(userID, session) - if lang == "zh" { - return fmt.Sprintf("准备创建交易员并立即启动。\n交易所:%s\n模型:%s\n策略:%s\n\n回复确认继续,回复先不用则只创建不启动。", - traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)), 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 - } - - session.Phase = "await_create_confirmation" - a.saveSkillSession(userID, session) - return formatTraderCreateDraftSummary(lang, session), true -} - -func (s *createTraderSkillSlots) ExchangeNameOrID() string { - if strings.TrimSpace(s.ExchangeName) != "" { - return s.ExchangeName - } - return s.ExchangeID -} - -func (s *createTraderSkillSlots) ModelNameOrID() string { - if strings.TrimSpace(s.ModelName) != "" { - return s.ModelName - } - return s.ModelID -} - -func (s *createTraderSkillSlots) StrategyNameOrID() string { - if strings.TrimSpace(s.StrategyName) != "" { - return s.StrategyName - } - 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) 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) - optionLines := make([]string, 0, 3) - if exchanges, _ := availableResources["exchanges"].([]traderSkillOption); len(exchanges) == 0 && containsString(missing, "exchange_name") { - if lang == "zh" { - prereqs = append(prereqs, "当前还没有可用交易所配置") - } else { - prereqs = append(prereqs, "there is no exchange config yet") - } - } else if containsString(missing, "exchange_name") { - if list := formatOptionList("现有交易所:", exchanges); lang == "zh" && list != "" { - optionLines = append(optionLines, list) - } else if list := formatOptionList("Available exchanges:", exchanges); lang != "zh" && list != "" { - optionLines = append(optionLines, list) - } - } - if models, _ := availableResources["models"].([]traderSkillOption); len(models) == 0 && containsString(missing, "model_name") { - if lang == "zh" { - prereqs = append(prereqs, "当前还没有可用模型配置") - } else { - prereqs = append(prereqs, "there is no model config yet") - } - } else if containsString(missing, "model_name") { - if list := formatOptionList("现有模型:", models); lang == "zh" && list != "" { - optionLines = append(optionLines, list) - } else if list := formatOptionList("Available models:", models); lang != "zh" && list != "" { - optionLines = append(optionLines, list) - } - } - if strategies, _ := availableResources["strategies"].([]traderSkillOption); len(strategies) == 0 && containsString(missing, "strategy_name") { - if lang == "zh" { - prereqs = append(prereqs, "当前还没有可用策略") - } else { - prereqs = append(prereqs, "there is no strategy yet") - } - } else if containsString(missing, "strategy_name") { - if list := formatOptionList("现有策略:", strategies); lang == "zh" && list != "" { - optionLines = append(optionLines, list) - } else if list := formatOptionList("Available strategies:", strategies); lang != "zh" && list != "" { - optionLines = append(optionLines, list) - } - } - if lang == "zh" { - reply := "新建交易员还缺这些槽位:" + missingLabels + "。" - if len(prereqs) > 0 { - reply += "\n" + strings.Join(prereqs, ";") + "。" - } - if len(optionLines) > 0 { - reply += "\n" + strings.Join(optionLines, "\n") - } - return reply - } - reply := "Creating the trader still needs these slots: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "." - if len(prereqs) > 0 { - reply += "\n" + strings.Join(prereqs, "; ") + "." - } - if len(optionLines) > 0 { - reply += "\n" + strings.Join(optionLines, "\n") - } - return reply -} - -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: fieldValue(session, "name"), - AIModelID: fieldValue(session, "model_id"), - ExchangeID: fieldValue(session, "exchange_id"), - StrategyID: fieldValue(session, "strategy_id"), - ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, - IsCrossMargin: normalizedArgs.IsCrossMargin, - ShowInCompetition: normalizedArgs.ShowInCompetition, - } - createRaw := a.toolCreateTrader(storeUserID, args) - if errMsg := parseSkillError(createRaw); errMsg != "" && strings.Contains(createRaw, `"error"`) { - 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”当前已禁用,请换一个已启用的交易所。", traderCreateExchangeNameOrID(session)) - if list := formatOptionList("可用交易所:", exchanges); list != "" { - reply += "\n" + list - } - return reply - } - 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 - } - return reply - } - if lang == "zh" { - return "创建交易员失败:" + errMsg - } - return "That create request did not go through: " + errMsg - } - var created struct { - Trader safeTraderToolConfig `json:"trader"` - } - if err := json.Unmarshal([]byte(createRaw), &created); err != nil || created.Trader.ID == "" { - a.clearSkillSession(userID) - if lang == "zh" { - return "交易员创建后返回结果异常,请稍后到列表里确认。" - } - return "The trader was created but the response could not be verified. Please check the trader list." - } - - if !startAfterCreate { - setSkillDAGStep(&session, "execute_create_only") - a.clearSkillSession(userID) - if lang == "zh" { - return fmt.Sprintf("已创建交易员“%s”。\n交易所:%s\n模型:%s\n策略:%s\n当前状态:未启动。", - 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, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) - } - - setSkillDAGStep(&session, "execute_create_and_start") - startRaw := a.toolStartTrader(storeUserID, created.Trader.ID) - if errMsg := parseSkillError(startRaw); errMsg != "" && strings.Contains(startRaw, `"error"`) { - a.clearSkillSession(userID) - if lang == "zh" { - return fmt.Sprintf("交易员“%s”已创建,但启动失败:%s", created.Trader.Name, errMsg) - } - return fmt.Sprintf("Trader %q was created, but starting it failed: %s", created.Trader.Name, errMsg) - } - - a.clearSkillSession(userID) - if lang == "zh" { - return fmt.Sprintf("已创建并启动交易员“%s”。\n交易所:%s\n模型:%s\n策略:%s", - 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, traderCreateExchangeNameOrID(session), traderCreateModelNameOrID(session), traderCreateStrategyNameOrID(session)) -} - -func (a *Agent) handleModelDiagnosisSkill(storeUserID, lang, text string) string { - raw := a.toolGetModelConfigs(storeUserID) - errMsg := parseSkillError(raw) - if errMsg != "" && strings.Contains(raw, `"error"`) { - if lang == "zh" { - return "现象:模型配置读取失败。\n更可能原因:当前存储不可用或配置列表读取失败。\n下一步:请稍后重试,或先检查后端日志。" - } - return "Symptom: failed to read model configs.\nLikely cause: the store is unavailable or loading configs failed.\nNext step: retry later or check backend logs." - } - - var payload struct { - ModelConfigs []safeModelToolConfig `json:"model_configs"` - } - _ = json.Unmarshal([]byte(raw), &payload) - - if len(payload.ModelConfigs) == 0 { - if lang == "zh" { - return "现象:当前没有任何模型配置。\n更可能原因:还没创建模型绑定。\n先检查什么:先确认你要使用哪个 provider。\n下一步:先新增并启用一个模型配置,再继续排查。" - } - return "Symptom: there are no model configs yet.\nLikely cause: no model binding has been created.\nNext step: create and enable a model config first." - } - - enabledCount := 0 - var incomplete []string - for _, model := range payload.ModelConfigs { - if model.Enabled { - enabledCount++ - } - if model.Enabled && (!model.HasAPIKey || strings.TrimSpace(model.CustomAPIURL) == "") { - incomplete = append(incomplete, model.Name) - } - } - - lines := make([]string, 0, 6) - if lang == "zh" { - lines = append(lines, "现象:这是模型配置/调用失败类问题。") - switch { - case enabledCount == 0: - lines = append(lines, "更可能原因:当前没有已启用模型。") - case len(incomplete) > 0: - lines = append(lines, "更可能原因:已启用模型里至少有一项缺少 API Key 或 custom_api_url,例如:"+strings.Join(incomplete, "、")+"。") - case containsAny(strings.ToLower(text), []string{"custom_api_url", "url", "https"}): - lines = append(lines, "更可能原因:custom_api_url 不是合法 HTTPS 地址,后端会直接拒绝保存。") - default: - lines = append(lines, "更可能原因:模型已保存,但 custom_model_name、API Key 或 provider 运行配置不匹配。") - } - lines = append(lines, "先检查什么:") - lines = append(lines, fmt.Sprintf("1. 当前共 %d 个模型配置,已启用 %d 个。", len(payload.ModelConfigs), enabledCount)) - lines = append(lines, "2. 检查目标模型是否同时具备 enabled、API Key、custom_api_url。") - lines = append(lines, "3. 如果是 OpenAI / Claude / DeepSeek 等 provider,确认 model name 填的是该 provider 实际可用的模型名。") - if excerpt := backendLogDiagnosisExcerpt(lang, text, "model"); excerpt != "" { - lines = append(lines, excerpt) - } - lines = append(lines, "下一步:如果你愿意,我下一步可以继续帮你逐项检查你当前配置里的具体模型。") - return strings.Join(lines, "\n") - } - - lines = append(lines, "Symptom: this looks like a model configuration or model runtime issue.") - switch { - case enabledCount == 0: - lines = append(lines, "Likely cause: there is no enabled model.") - case len(incomplete) > 0: - lines = append(lines, "Likely cause: at least one enabled model is missing an API key or custom_api_url, for example: "+strings.Join(incomplete, ", ")+".") - default: - lines = append(lines, "Likely cause: the model was saved, but the API key, custom_api_url, or custom_model_name does not match the provider runtime config.") - } - lines = append(lines, fmt.Sprintf("Check first: %d model configs exist, %d are enabled.", len(payload.ModelConfigs), enabledCount)) - if excerpt := backendLogDiagnosisExcerpt(lang, text, "model"); excerpt != "" { - lines = append(lines, excerpt) - } - lines = append(lines, "Next step: verify the target model has enabled=true, a non-empty API key, a valid HTTPS custom_api_url, and a correct model name.") - return strings.Join(lines, "\n") -} - -func (a *Agent) handleExchangeDiagnosisSkill(storeUserID, lang, text string) string { - exchanges := a.loadExchangeOptions(storeUserID) - lower := strings.ToLower(text) - lines := make([]string, 0, 8) - if lang == "zh" { - lines = append(lines, "现象:这是交易所 API 连接或签名类问题。") - switch { - case containsAny(lower, []string{"invalid signature", "签名"}): - lines = append(lines, "更可能原因:API Secret / passphrase 不匹配,或者系统时间不同步。") - case containsAny(lower, []string{"timestamp", "时间戳"}): - lines = append(lines, "更可能原因:服务器时间偏差过大。") - case containsAny(lower, []string{"ip not allowed", "白名单"}): - lines = append(lines, "更可能原因:API 白名单没有包含当前服务器 IP。") - case containsAny(lower, []string{"permission denied", "权限"}): - lines = append(lines, "更可能原因:交易或合约权限没有打开。") - default: - lines = append(lines, "更可能原因:密钥配置、时间同步、白名单或权限设置存在问题。") - } - lines = append(lines, "先检查什么:") - lines = append(lines, "1. 先同步系统时间,尤其是出现 invalid signature / timestamp 时。") - lines = append(lines, "2. 确认 API Key 和 Secret 没有填反、没有过期。") - if containsAny(lower, []string{"okx", "欧易"}) || containsAny(strings.ToLower(formatOptionList("", exchanges)), []string{"okx"}) { - lines = append(lines, "3. 如果是 OKX,再确认 passphrase 没漏填。") - } - lines = append(lines, "4. 检查 API 白名单是否包含当前服务器 IP。") - lines = append(lines, "5. 检查是否已经开启交易/合约权限。") - if excerpt := backendLogDiagnosisExcerpt(lang, text, "exchange"); excerpt != "" { - lines = append(lines, excerpt) - } - lines = append(lines, "下一步:如果你把具体报错原文贴给我,我可以按报错类型继续缩小范围。") - return strings.Join(lines, "\n") - } - - lines = append(lines, "Symptom: this looks like an exchange API connectivity or signature issue.") - lines = append(lines, "Check first: system time sync, API key/secret correctness, IP whitelist, trading permissions, and passphrase for OKX.") - if len(exchanges) > 0 { - lines = append(lines, "Current exchange bindings exist, so the next step is to match the exact error text to the most likely cause.") - } - if excerpt := backendLogDiagnosisExcerpt(lang, text, "exchange"); excerpt != "" { - lines = append(lines, excerpt) - } - return strings.Join(lines, "\n") -} - -func backendLogDiagnosisExcerpt(lang, text, fallbackFilter string) string { - filter := strings.TrimSpace(text) - if strings.TrimSpace(filter) == "" { - filter = fallbackFilter - } - _, entries, err := readBackendLogEntries(8, filter, true) - if err != nil || len(entries) == 0 { - if filter != fallbackFilter { - _, entries, err = readBackendLogEntries(8, fallbackFilter, true) - } - } - if err != nil || len(entries) == 0 { - return "" - } - if lang == "zh" { - return "最近命中的后端错误日志:\n- " + strings.Join(entries, "\n- ") - } - 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, "exchange_id") != "" { - options := a.loadExchangeOptions(storeUserID) - if opt := findOptionByIDOrName(options, fieldValue(*session, "exchange_id")); opt != nil { - setField(session, "exchange_id", opt.ID) - if fieldValue(*session, "exchange_name") == "" { - setField(session, "exchange_name", opt.Name) - } - } - } - if fieldValue(*session, "model_id") == "" && fieldValue(*session, "model_name") != "" { - options := a.loadEnabledModelOptions(storeUserID) - if opt := findOptionByIDOrName(options, fieldValue(*session, "model_name")); opt != nil { - 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, "model_id") != "" { - options := a.loadEnabledModelOptions(storeUserID) - if opt := findOptionByIDOrName(options, fieldValue(*session, "model_id")); opt != nil { - setField(session, "model_id", opt.ID) - if fieldValue(*session, "model_name") == "" { - setField(session, "model_name", opt.Name) - } - } - } - if fieldValue(*session, "strategy_id") == "" && fieldValue(*session, "strategy_name") != "" { - options := a.loadStrategyOptions(storeUserID) - if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_name")); opt != nil { - setField(session, "strategy_id", opt.ID) - } else if opt := findUniqueContainingOption(options, fieldValue(*session, "strategy_name")); opt != nil { - setField(session, "strategy_id", opt.ID) - } - } - if fieldValue(*session, "strategy_id") != "" { - options := a.loadStrategyOptions(storeUserID) - if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_id")); opt != nil { - setField(session, "strategy_id", opt.ID) - if fieldValue(*session, "strategy_name") == "" { - setField(session, "strategy_name", opt.Name) - } - } - } -} - -func (a *Agent) maybeResumeParentTaskAfterSuccessfulSkill(storeUserID string, userID int64, lang, skill, action, answer string) string { - 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: &EntityReference{ID: opt.ID, Name: defaultIfEmpty(opt.Name, existing.Name), Source: existing.Source}} - } - } - } - if existing != nil && strings.TrimSpace(existing.Name) != "" { - if opt := findOptionByIDOrName(options, existing.Name); opt != nil { - return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}} - } - if opt := findUniqueContainingOption(options, existing.Name); opt != nil { - return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}} - } - } - if opt := findOptionByIDOrName(options, text); opt != nil { - return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}} - } - if opt := findUniqueContainingOption(options, text); opt != nil { - return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}} - } - if len(options) > 1 { - return targetResolution{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) || strings.EqualFold(opt.Hint, 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 { - name := strings.ToLower(strings.TrimSpace(opt.Name)) - hint := strings.ToLower(strings.TrimSpace(opt.Hint)) - id := strings.ToLower(strings.TrimSpace(opt.ID)) - if (name != "" && (strings.Contains(name, query) || strings.Contains(query, name))) || - (hint != "" && (strings.Contains(hint, query) || strings.Contains(query, hint))) || - (id != "" && (strings.Contains(id, query) || strings.Contains(query, id))) { - 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_domain_context.go b/agent/skill_domain_context.go deleted file mode 100644 index 965f694b..00000000 --- a/agent/skill_domain_context.go +++ /dev/null @@ -1,209 +0,0 @@ -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), - } - if lang == "zh" { - return strings.Join([]string{ - "### 交易员配置领域约束", - "- 交易员是装配层,负责创建、换绑策略/交易所/模型,以及启动、停止、删除、查询。", - "- 编辑交易员时,默认只处理绑定关系;不要顺手改策略、模型、交易所内部配置。", - "- 交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受手动设置、充值或人为改余额。", - "- 若用户要改策略参数、模型配置或交易所凭证,应切到对应 management skill。", - "- 创建交易员时最关键的是:名称、交易所、模型、策略。", - "- 关键字段:" + strings.Join(fields, "、"), - }, "\n") - } - return strings.Join([]string{ - "### Trader Config Domain Guard", - "- Traders are the assembly layer: create, rebind strategy/exchange/model, and control lifecycle.", - "- When editing a trader, default to changing bindings only; do not silently edit the internals of the strategy, model, or exchange.", - "- Trader initial balance is auto-read from the bound exchange account equity at creation time; do not ask the user to set, top up, or manually edit trader balance.", - "- If the user wants to change strategy parameters, model config, or exchange credentials, switch to the corresponding management skill.", - "- 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), - } - if lang == "zh" { - return strings.Join([]string{ - "### 策略配置领域约束", - "- 本领域只处理策略模板。", - "- strategy_type 选项:ai_trading、grid_trading。", - "- 用户提到 AI500、OI Top、OI Low、静态币种/固定币种这类选币来源时,属于 ai_trading。", - "- 策略类型确定后,只能使用当前类型的产品编辑页模板。", - "- 策略类型未确定时,只判断类型,不要展示或混合任一分支的具体配置字段。", - "- 关键字段:" + strings.Join(fields, "、"), - }, "\n") - } - return strings.Join([]string{ - "### Strategy Config Domain Guard", - "- This domain only handles strategy templates.", - "- strategy_type options: ai_trading, grid_trading.", - "- AI500, OI Top, OI Low, and static coin-source requests imply ai_trading.", - "- Once strategy_type is known, use only that product editor template.", - "- Before strategy_type is known, only determine the type; do not show or mix concrete fields from either branch.", - "- Key fields: " + strings.Join(fields, ", "), - }, "\n") - default: - return "" - } -} - -func buildSkillDomainPrimerForSession(lang string, session skillSession) string { - if session.Name != "strategy_management" { - return buildSkillDomainPrimer(lang, session.Name) - } - strategyType := explicitStrategyCreateType(session) - if strategyType == "" { - return buildSkillDomainPrimer(lang, session.Name) - } - if lang == "zh" { - switch strategyType { - case "ai_trading": - return strings.Join([]string{ - "### AI 策略模板", - "- 只使用 ai_trading 模板:strategy_type + ai_config + publish_config。", - "- config_patch 必须使用产品 schema 原值,不要使用展示文案:strategy_type=ai_trading;source_type 只能是 static、ai500、oi_top、oi_low;没有 mixed/混合模式。", - "- 时间周期必须输出为产品枚举字符串,例如 1m、3m、5m、15m、1h;selected_timeframes 必须是字符串数组,例如 [\"1m\",\"5m\",\"15m\"],不要输出 JSON 字符串。", - "- AI500/OI Top/OI Low 选币数量范围 1~10;static_coins 最多 10 个;selected_timeframes 最多 4 个;primary_count 10~30。", - "- BTC/ETH 最大杠杆 1~20;山寨币最大杠杆 1~20;min_confidence 50~100;min_risk_reward_ratio 1~10。", - "- AI 策略创建方案不要展示或询问非 AI 模板字段:投入金额、每笔固定投入、止损、日亏损限制、最大回撤、网格字段。", - }, "\n") - case "grid_trading": - return strings.Join([]string{ - "### 网格策略模板", - "- 只使用 grid_trading 模板:strategy_type + grid_config + publish_config;config_patch 必须使用产品 schema 原值,strategy_type=grid_trading。", - "- 交易对选项:BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。", - "- grid_count 5~50;total_investment 最小 100;leverage 1~5;atr_multiplier 1~5。", - "- total_investment 是用户实际投入/保证金预算,不是杠杆后的名义仓位;最大名义仓位约等于 total_investment × leverage。用户说“投入/总投入/本金/保证金”时默认映射到 total_investment。", - "- max_drawdown_pct 5~50;stop_loss_pct 1~20;daily_loss_limit_pct 1~30;direction_bias_ratio 0.55~0.90。", - "- 没有实时行情工具结果时,不要猜当前价格或手动价格上下界;推荐 use_atr_bounds=true 的 ATR 自动边界。", - "- 如果用户让你选择/推荐剩余网格参数,价格区间默认写入 use_atr_bounds=true;不要反问用户手动价格区间,也不要编造“当前 BTC/ETH 在某价附近”。", - }, "\n") - } - } - switch strategyType { - case "ai_trading": - return strings.Join([]string{ - "### AI Strategy Template", - "- Use only ai_trading: strategy_type + ai_config + publish_config.", - "- config_patch must use product schema raw values, not display labels: strategy_type=ai_trading; source_type is only static, ai500, oi_top, or oi_low; no mixed mode.", - "- Timeframes must be product enum strings such as 1m, 3m, 5m, 15m, 1h; selected_timeframes must be a JSON string array such as [\"1m\",\"5m\",\"15m\"], not a JSON-encoded string.", - "- AI500/OI source counts 1-10; static_coins at most 10; selected_timeframes at most 4; primary_count 10-30.", - "- BTC/ETH leverage 1-20; altcoin leverage 1-20; min_confidence 50-100; min_risk_reward_ratio 1-10.", - "- Do not show or ask for non-AI-template fields in AI strategy drafts: investment amount, fixed per-trade amount, stop loss, daily loss limit, max drawdown, or grid fields.", - }, "\n") - case "grid_trading": - return strings.Join([]string{ - "### Grid Strategy Template", - "- Use only grid_trading: strategy_type + grid_config + publish_config; config_patch must use product schema raw values with strategy_type=grid_trading.", - "- Symbol options: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.", - "- grid_count 5-50; total_investment >=100; leverage 1-5; atr_multiplier 1-5.", - "- total_investment is the user's actual capital/margin budget, not leveraged notional exposure; maximum notional exposure is approximately total_investment * leverage. When the user says investment, capital, amount to put in, or margin, map it to total_investment by default.", - "- max_drawdown_pct 5-50; stop_loss_pct 1-20; daily_loss_limit_pct 1-30; direction_bias_ratio 0.55-0.90.", - "- Without fresh market data, do not guess the current price or manual upper/lower prices; recommend ATR auto bounds with use_atr_bounds=true.", - "- If the user asks you to choose/recommend the remaining grid parameters, default the price range to use_atr_bounds=true; do not ask for manual price bounds or invent statements like the current BTC/ETH price is near a value.", - }, "\n") - } - return buildSkillDomainPrimer(lang, session.Name) -} - -func buildManagementDomainPrimer(lang string) string { - if lang == "zh" { - return strings.Join([]string{ - "### 管理领域路由速记", - "- 模型/API Key/provider:model_management。", - "- 交易所账户/API 凭证:exchange_management。", - "- 交易员创建、启动、停止、绑定策略/模型/交易所:trader_management。", - "- 策略模板创建、查看、修改、删除、激活、复制:strategy_management。", - "- 这里只用于路由;具体字段和模板只在进入对应 skill 后注入。", - }, "\n") - } - return strings.Join([]string{ - "### Management Routing Cheat Sheet", - "- Model/API key/provider: model_management.", - "- Exchange account/API credentials: exchange_management.", - "- Trader create/start/stop/bind strategy/model/exchange: trader_management.", - "- Strategy template create/query/update/delete/activate/duplicate: strategy_management.", - "- This is only for routing; detailed fields/templates are injected after entering the selected skill.", - }, "\n") -} diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go deleted file mode 100644 index bed12c7b..00000000 --- a/agent/skill_execution_handlers.go +++ /dev/null @@ -1,3199 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - "nofx/mcp" - "nofx/store" -) - -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 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 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{"策略", "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 entityFieldExplicitlyMentioned(text string, keywords []string) bool { - if len(keywords) == 0 { - return false - } - return containsAny(strings.ToLower(text), keywords) -} - -type traderUpdateArgs struct { - AIModelID string - ExchangeID string - StrategyID string - ScanIntervalMinutes *int - IsCrossMargin *bool - ShowInCompetition *bool -} - -func (a traderUpdateArgs) hasAny() bool { - return a.AIModelID != "" || a.ExchangeID != "" || a.StrategyID != "" || - a.ScanIntervalMinutes != nil || a.IsCrossMargin != nil || a.ShowInCompetition != 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.ScanIntervalMinutes != nil { - base.ScanIntervalMinutes = patch.ScanIntervalMinutes - } - if patch.IsCrossMargin != nil { - base.IsCrossMargin = patch.IsCrossMargin - } - if patch.ShowInCompetition != nil { - base.ShowInCompetition = patch.ShowInCompetition - } - return base -} - -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.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)) - } -} - -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, "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 - } - 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 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 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 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 "最大持仓" - } - return "max positions" - case "min_confidence": - if lang == "zh" { - 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 最大杠杆" - } - return "BTC/ETH max leverage" - case "altcoin_max_leverage": - if lang == "zh" { - 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" - } - return "EMA" - case "enable_macd": - if lang == "zh" { - return "MACD" - } - return "MACD" - case "enable_rsi": - if lang == "zh" { - return "RSI" - } - return "RSI" - case "enable_atr": - if lang == "zh" { - return "ATR" - } - return "ATR" - case "enable_boll": - if lang == "zh" { - return "Bollinger" - } - return "Bollinger" - case "enable_all_core_indicators": - if lang == "zh" { - return "全部核心指标" - } - return "all core indicators" - case "primary_timeframe": - if lang == "zh" { - return "主周期" - } - return "primary timeframe" - case "selected_timeframes": - if lang == "zh" { - 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 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": - return fmt.Errorf("%s", strategyLockedFieldError("zh", field)) - 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 { - return fmt.Errorf("BTC/ETH 最大杠杆需要是整数") - } - cfg.RiskControl.BTCETHMaxLeverage = parsed - case "altcoin_max_leverage": - parsed, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("山寨币最大杠杆需要是整数") - } - cfg.RiskControl.AltcoinMaxLeverage = parsed - case "btceth_max_position_value_ratio": - return fmt.Errorf("%s", strategyLockedFieldError("zh", field)) - case "altcoin_max_position_value_ratio": - return fmt.Errorf("%s", strategyLockedFieldError("zh", field)) - case "max_margin_usage": - return fmt.Errorf("%s", strategyLockedFieldError("zh", field)) - case "min_position_size": - return fmt.Errorf("%s", strategyLockedFieldError("zh", field)) - 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": - cfg.Indicators.EnableMACD = value == "true" - case "enable_rsi": - cfg.Indicators.EnableRSI = value == "true" - case "enable_atr": - 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 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 lower == "grid_trading": - return "grid_trading" - case lower == "ai_trading": - return "ai_trading" - case containsAny(lower, []string{"grid", "网格"}): - return "grid_trading" - case containsAny(lower, []string{"ai500", "oi top", "oi low", "静态币", "固定币", "选币来源"}): - return "ai_trading" - case containsAny(lower, []string{"ai trading", "ai策略", "ai 策略", "ai交易", "ai 交易", "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"} - 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 "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)) - case "query_detail": - if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok { - return detail - } - return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)) - case "start", "stop", "delete": - if session.TargetRef == nil && !(session.Action == "delete" && fieldValue(session, "bulk_scope") == "all") { - if lang == "zh" { - return "请先指定要操作的交易员。" - } - return "Please specify which trader to operate on." - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "await_confirmation") - } - if session.Action == "delete" && fieldValue(session, "bulk_scope") == "all" { - return a.executeBulkTraderDelete(storeUserID, userID, lang, text, session) - } - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - var resp string - switch session.Action { - case "start": - setSkillDAGStep(&session, "execute_start") - resp = a.toolStartTrader(storeUserID, session.TargetRef.ID) - case "stop": - setSkillDAGStep(&session, "execute_stop") - resp = a.toolStopTrader(storeUserID, session.TargetRef.ID) - case "delete": - setSkillDAGStep(&session, "execute_delete") - resp = a.toolDeleteTrader(storeUserID, session.TargetRef.ID) - } - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "执行失败:" + errMsg - } - return "Action failed: " + errMsg - } - if lang == "zh" { - return fmt.Sprintf("已完成交易员操作:%s。", session.Action) - } - return fmt.Sprintf("Completed trader action: %s.", session.Action) - case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": - if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" { - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "collect_bindings") - } - 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) - } - if args.ExchangeID != "" { - setField(&session, "exchange_id", args.ExchangeID) - } - if args.StrategyID != "" { - setField(&session, "strategy_id", args.StrategyID) - } - 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 == "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" { - 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." - } - } - 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 "That change did not go through: " + errMsg - } - a.rememberReferencesFromToolResult(userID, "manage_trader", resp) - if lang == "zh" { - 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." - } - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "collect_name") - } - parsedArgs := buildTraderUpdateArgsFromSession(session) - selectedField := fieldValue(session, "update_field") - if selectedField == "" { - if !parsedArgs.hasAny() { - selectedField = detectCatalogField(text, traderFieldCatalog) - } - if selectedField != "" { - setField(&session, "update_field", selectedField) - } - } - 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, - ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes, - IsCrossMargin: normalizedArgs.IsCrossMargin, - ShowInCompetition: normalizedArgs.ShowInCompetition, - } - 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 "That change did not go through: " + errMsg - } - if lang == "zh" { - reply := "已更新交易员配置。" - if len(warnings) > 0 { - reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") - } - return reply - } - 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 - } - if selectedField != "" { - setSkillDAGStep(&session, "collect_field_value") - } else { - setSkillDAGStep(&session, "collect_name") - } - a.saveSkillSession(userID, session) - if lang == "zh" { - if selectedField != "" { - if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" { - return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang)) - } - return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang)) - } - return "你可以直接告诉我想改哪一项,比如绑定的模型、交易所、策略,或者扫描间隔、保证金模式、是否展示到竞技场。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。" - } - 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 linked model, exchange, strategy, scan interval, margin mode, or competition visibility. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow." - default: - return "" - } -} - -func (a *Agent) executeBulkTraderDelete(storeUserID string, userID int64, lang, text string, session skillSession) string { - if a == nil || a.store == nil { - if lang == "zh" { - return "我这边暂时无法读取交易员列表。" - } - return "I cannot load the trader list right now." - } - traders, err := a.store.Trader().List(storeUserID) - if err != nil { - if lang == "zh" { - return "我这边暂时没读到交易员列表:" + err.Error() - } - return "I could not load the trader list just now: " + err.Error() - } - if len(traders) == 0 { - a.clearSkillSession(userID) - if lang == "zh" { - return "当前没有可删除的交易员。" - } - return "There are no traders to delete." - } - - deletable := make([]*store.Trader, 0, len(traders)) - runningNames := make([]string, 0) - for _, trader := range traders { - if trader == nil { - continue - } - isRunning := trader.IsRunning - if a.traderManager != nil { - if memTrader, err := a.traderManager.GetTrader(trader.ID); err == nil { - if running, ok := memTrader.GetStatus()["is_running"].(bool); ok { - isRunning = running - } - } - } - if isRunning { - runningNames = append(runningNames, defaultIfEmpty(trader.Name, trader.ID)) - continue - } - deletable = append(deletable, trader) - } - - if len(deletable) == 0 { - a.clearSkillSession(userID) - if lang == "zh" { - return "当前所有交易员都还在运行中,删除前需要先停止:" + strings.Join(runningNames, "、") - } - return "All traders are still running. Stop them before deleting: " + strings.Join(runningNames, ", ") - } - - targetLabel := fmt.Sprintf("全部已停止交易员(共 %d 个)", len(deletable)) - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - - setSkillDAGStep(&session, "execute_delete") - deletedNames := make([]string, 0, len(deletable)) - failedNames := make([]string, 0) - for _, trader := range deletable { - resp := a.toolDeleteTrader(storeUserID, trader.ID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - failedNames = append(failedNames, fmt.Sprintf("%s(%s)", defaultIfEmpty(trader.Name, trader.ID), errMsg)) - continue - } - deletedNames = append(deletedNames, defaultIfEmpty(trader.Name, trader.ID)) - } - a.clearSkillSession(userID) - - if lang == "zh" { - parts := []string{fmt.Sprintf("批量删除交易员已完成:成功删除 %d 个。", len(deletedNames))} - if len(runningNames) > 0 { - parts = append(parts, "这些交易员仍在运行,已跳过,删除前需要先停止:"+strings.Join(runningNames, "、")) - } - if len(failedNames) > 0 { - parts = append(parts, "这些没删成功:"+strings.Join(failedNames, ";")) - } - if len(deletedNames) > 0 { - parts = append(parts, "已删除:"+strings.Join(deletedNames, "、")) - } - return strings.Join(parts, "\n") - } - - parts := []string{fmt.Sprintf("Bulk trader deletion finished: deleted %d trader(s).", len(deletedNames))} - if len(runningNames) > 0 { - parts = append(parts, "Skipped running traders; stop them before deleting: "+strings.Join(runningNames, ", ")) - } - if len(failedNames) > 0 { - parts = append(parts, "These did not delete successfully: "+strings.Join(failedNames, "; ")) - } - if len(deletedNames) > 0 { - parts = append(parts, "Deleted: "+strings.Join(deletedNames, ", ")) - } - return strings.Join(parts, "\n") -} - -func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { - switch session.Action { - case "query", "query_list", "create": - // These actions don't need a target — fall through. - default: - if session.TargetRef == nil { - if lang == "zh" { - return "请先指定要操作的交易所配置。" - } - return "Please specify which exchange config to operate on." - } - } - switch session.Action { - case "query_detail": - if detail, ok := a.describeExchange(storeUserID, lang, session.TargetRef); ok { - return detail - } - return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)) - case "delete": - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "await_confirmation") - } - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - setSkillDAGStep(&session, "execute_delete") - args, _ := json.Marshal(map[string]any{"action": "delete", "exchange_id": session.TargetRef.ID}) - resp := a.toolManageExchangeConfig(storeUserID, string(args)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没删成功:" + errMsg - } - return "That delete did not go through: " + errMsg - } - if lang == "zh" { - return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "delete", "已删除交易所配置。") - } - 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" { - setSkillDAGStep(&session, "collect_enabled") - } else { - setSkillDAGStep(&session, "collect_account_name") - } - } - 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 := defaultIfEmpty(patch.AccountName, fieldValue(session, "account_name")) - if accountName != "" && session.Action != "update_status" { - payload["account_name"] = accountName - } - enabledRaw := fieldValue(session, "enabled") - if patch.Enabled != nil { - enabledRaw = strconv.FormatBool(*patch.Enabled) - } - if enabledRaw != "" { - payload["enabled"] = enabledRaw == "true" - } - if value := defaultIfEmpty(patch.APIKey, fieldValue(session, "api_key")); value != "" { - payload["api_key"] = value - } - if value := defaultIfEmpty(patch.SecretKey, fieldValue(session, "secret_key")); value != "" { - payload["secret_key"] = value - } - if value := defaultIfEmpty(patch.Passphrase, fieldValue(session, "passphrase")); value != "" { - payload["passphrase"] = 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") - } - if len(payload) == 2 { - if session.Action == "update_status" { - setSkillDAGStep(&session, "collect_enabled") - } else { - if selectedField != "" { - setSkillDAGStep(&session, "collect_field_value") - } else { - setSkillDAGStep(&session, "collect_account_name") - } - } - a.saveSkillSession(userID, session) - if lang == "zh" { - if selectedField != "" { - return fmt.Sprintf("还差一步:请告诉我你想把交易所配置里的%s改成什么。", displayCatalogFieldName(selectedField, lang)) - } - return "你可以直接告诉我想改交易所配置里的哪一项,比如账户名、启用开关、API Key、Passphrase、钱包地址或 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) - resp := a.toolManageExchangeConfig(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没改成功:" + errMsg - } - return "That change did not go through: " + errMsg - } - a.rememberReferencesFromToolResult(userID, "manage_exchange_config", resp) - if lang == "zh" { - reply := "已更新交易所配置。" - if len(warnings) > 0 { - reply += "\n\n已按手动面板范围自动调整:\n- " + strings.Join(warnings, "\n- ") - } - return a.maybeResumeParentTaskAfterSuccessfulSkill(storeUserID, userID, lang, "exchange_management", "update", reply) - } - 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 "" - } -} - -func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { - switch session.Action { - case "query", "query_list", "create": - // These actions don't need a target — fall through. - default: - if session.TargetRef == nil { - if lang == "zh" { - return "请先指定要操作的模型。" - } - return "Please specify which model to operate on." - } - } - switch session.Action { - case "query_detail": - if detail, ok := a.describeModel(storeUserID, lang, session.TargetRef); ok { - return detail - } - return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)) - case "delete": - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "await_confirmation") - } - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - setSkillDAGStep(&session, "execute_delete") - raw, _ := json.Marshal(map[string]any{"action": "delete", "model_id": session.TargetRef.ID}) - resp := a.toolManageModelConfig(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没删成功:" + errMsg - } - return "That delete did not go through: " + errMsg - } - if lang == "zh" { - return "已删除模型配置。" - } - return "Deleted model config." - case "update", "update_name", "update_endpoint", "update_status": - if fieldValue(session, skillDAGStepField) == "" { - switch session.Action { - case "update_status": - setSkillDAGStep(&session, "collect_enabled") - case "update_endpoint": - setSkillDAGStep(&session, "collect_custom_api_url") - default: - setSkillDAGStep(&session, "collect_custom_model_name") - } - } - payload := map[string]any{"action": "update", "model_id": session.TargetRef.ID} - 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) - } - } - applyModelUpdatePatchToSession(&session, patch) - patch = mergeModelUpdatePatch(buildModelUpdatePatchFromSession(session), patch) - urlValue := patch.CustomAPIURL - enabledValue := "" - if patch.Enabled != nil { - enabledValue = strconv.FormatBool(*patch.Enabled) - } - apiKeyValue := patch.APIKey - modelNameValue := patch.CustomModelName - if value := defaultIfEmpty(urlValue, fieldValue(session, "custom_api_url")); value != "" { - payload["custom_api_url"] = value - } - if value := defaultIfEmpty(enabledValue, fieldValue(session, "enabled")); value != "" { - payload["enabled"] = value == "true" - } - if value := defaultIfEmpty(apiKeyValue, fieldValue(session, "api_key")); value != "" { - payload["api_key"] = value - } - if value := defaultIfEmpty(modelNameValue, fieldValue(session, "custom_model_name")); value != "" { - payload["custom_model_name"] = value - } - if session.Action == "update_name" { - delete(payload, "custom_api_url") - delete(payload, "enabled") - delete(payload, "api_key") - } - if session.Action == "update_status" { - delete(payload, "custom_api_url") - delete(payload, "custom_model_name") - delete(payload, "api_key") - } - if session.Action == "update_endpoint" { - delete(payload, "custom_model_name") - delete(payload, "enabled") - delete(payload, "api_key") - } - if len(payload) == 2 { - switch session.Action { - case "update_status": - setSkillDAGStep(&session, "collect_enabled") - case "update_endpoint": - setSkillDAGStep(&session, "collect_custom_api_url") - default: - if selectedField != "" { - setSkillDAGStep(&session, "collect_field_value") - } else { - setSkillDAGStep(&session, "collect_custom_model_name") - } - } - a.saveSkillSession(userID, session) - if lang == "zh" { - if selectedField != "" { - return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang)) - } - return "你可以直接告诉我想改哪一项,比如模型名称、接口地址,或者开关状态。" - } - 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) - resp := a.toolManageModelConfig(storeUserID, string(raw)) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - a.saveSkillSession(userID, session) - if lang == "zh" { - if strings.Contains(errMsg, "cannot enable model config before API key is configured") { - return "更新模型配置失败:这个模型还没有配置 API Key,暂时不能启用。你可以直接把 API Key 发给我,我帮你继续配置。" - } - return "这次没改成功:" + errMsg - } - a.saveSkillSession(userID, session) - 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 "已更新模型配置启用状态。" - } - return "已更新模型配置。" - } - return "Updated model config." - default: - return "" - } -} - -func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { - switch session.Action { - case "query", "query_list", "create": - // These actions don't need a target — fall through. - default: - isBulkDelete := session.Action == "delete" && fieldValue(session, "bulk_scope") == "all" - if session.TargetRef == nil && !isBulkDelete { - if lang == "zh" { - return "请先指定要操作的策略。" - } - return "Please specify which strategy to operate on." - } - } - switch session.Action { - case "query", "query_list": - return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)) - case "query_detail": - if detail, ok := a.describeStrategy(storeUserID, lang, session.TargetRef); ok { - return detail - } - return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)) - case "activate": - raw, _ := json.Marshal(map[string]any{"action": "activate", "strategy_id": session.TargetRef.ID}) - resp := a.toolManageStrategy(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没激活成功:" + errMsg - } - return "That activation did not go through: " + errMsg - } - if lang == "zh" { - return "已激活策略。" - } - return "Activated strategy." - case "duplicate": - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "collect_name") - } - newName := fieldValue(session, "name") - if newName != "" { - setField(&session, "name", newName) - } - if newName == "" { - setSkillDAGStep(&session, "collect_name") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "还差一步:请给这个新策略起个名字。" - } - 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}) - resp := a.toolManageStrategy(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没复制成功:" + errMsg - } - return "That copy did not go through: " + errMsg - } - if lang == "zh" { - return fmt.Sprintf("已复制策略,新名称为“%s”。", newName) - } - return fmt.Sprintf("Duplicated strategy as %q.", newName) - case "delete": - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "await_confirmation") - } - if fieldValue(session, "bulk_scope") == "all" { - strategies, err := a.store.Strategy().List(storeUserID) - if err != nil { - if lang == "zh" { - return "我这边暂时没读到策略列表:" + err.Error() - } - return "I could not load the strategy list just now: " + err.Error() - } - - deletable := make([]*store.Strategy, 0, len(strategies)) - skippedDefault := 0 - for _, strategy := range strategies { - if strategy == nil { - continue - } - if strategy.IsDefault { - skippedDefault++ - continue - } - deletable = append(deletable, strategy) - } - if len(deletable) == 0 { - a.clearSkillSession(userID) - if lang == "zh" { - return "当前没有可删除的自定义策略。" - } - return "There are no user-created strategies to delete." - } - - targetLabel := fmt.Sprintf("全部自定义策略(共 %d 个)", len(deletable)) - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - - setSkillDAGStep(&session, "execute_delete") - deletedNames := make([]string, 0, len(deletable)) - failedNames := make([]string, 0) - for _, strategy := range deletable { - raw, _ := json.Marshal(map[string]any{"action": "delete", "strategy_id": strategy.ID}) - resp := a.toolManageStrategy(storeUserID, string(raw)) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - failedNames = append(failedNames, fmt.Sprintf("%s(%s)", strategy.Name, errMsg)) - continue - } - deletedNames = append(deletedNames, strategy.Name) - } - a.clearSkillSession(userID) - - if lang == "zh" { - parts := []string{fmt.Sprintf("批量删除策略已完成:成功删除 %d 个。", len(deletedNames))} - if skippedDefault > 0 { - parts = append(parts, fmt.Sprintf("已跳过系统默认策略 %d 个。", skippedDefault)) - } - if len(failedNames) > 0 { - parts = append(parts, "这些没删成功:"+strings.Join(failedNames, ";")) - } - if len(deletedNames) > 0 { - parts = append(parts, "已删除:"+strings.Join(deletedNames, "、")) - } - return strings.Join(parts, "\n") - } - - parts := []string{fmt.Sprintf("Bulk strategy deletion finished: deleted %d strategy(s).", len(deletedNames))} - if skippedDefault > 0 { - parts = append(parts, fmt.Sprintf("Skipped %d default strategy(ies).", skippedDefault)) - } - if len(failedNames) > 0 { - parts = append(parts, "These did not delete successfully: "+strings.Join(failedNames, "; ")) - } - if len(deletedNames) > 0 { - parts = append(parts, "Deleted: "+strings.Join(deletedNames, ", ")) - } - return strings.Join(parts, "\n") - } - if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting { - a.saveSkillSession(userID, session) - return msg - } - if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting { - a.saveSkillSession(userID, session) - return msg - } - setSkillDAGStep(&session, "execute_delete") - raw, _ := json.Marshal(map[string]any{"action": "delete", "strategy_id": session.TargetRef.ID}) - resp := a.toolManageStrategy(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没删成功:" + errMsg - } - return "That delete did not go through: " + errMsg - } - if lang == "zh" { - return "已删除策略。" - } - return "Deleted strategy." - case "update_name", "update_config", "update_prompt": - if session.Action == "update_prompt" { - return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session) - } - if session.Action == "update_config" || fieldValue(session, strategyPendingUpdateConfigField) != "" { - return a.executeStrategyConfigUpdate(storeUserID, userID, lang, text, session) - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "collect_name") - } - newName := fieldValue(session, "name") - if newName != "" { - setField(&session, "name", newName) - } - if newName == "" { - setSkillDAGStep(&session, "collect_name") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "目前这里先支持改策略名称。你直接把新名字发给我就行。" - } - 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}) - resp := a.toolManageStrategy(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没改成功:" + errMsg - } - return "That change did not go through: " + errMsg - } - if lang == "zh" { - return fmt.Sprintf("已将策略改名为“%s”。", newName) - } - return fmt.Sprintf("Renamed strategy to %q.", newName) - case "update": - a.clearSkillSession(userID) - if lang == "zh" { - return "我需要先明确你要改策略的哪一部分:名称、提示词,还是策略参数。" - } - return "I need to know which part of the strategy to update: name, prompt, or config." - default: - return "" - } -} - -func (a *Agent) executeStrategyPromptUpdate(storeUserID string, userID int64, lang, text string, session skillSession) string { - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "collect_prompt") - } - strategy, cfg, 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() - } - - prompt := fieldValue(session, "prompt") - if prompt == "" { - prompt = fieldValue(session, "custom_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) - } - } - if prompt == "" { - setSkillDAGStep(&session, "collect_prompt") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "还差一步:请把新的提示词内容发给我,直接发正文就行。" - } - return "One more thing: send me the new prompt text." - } - - cfg.CustomPrompt = prompt - setSkillDAGStep(&session, "execute_update") - return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, "已更新策略 prompt。", "Updated strategy prompt.") -} - -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, "collect_config_patch") - } - } - - strategy, cfg, 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() - } - - if patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)); patchRaw != "" { - var patch map[string]any - if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil { - setSkillDAGStep(&session, "collect_config_patch") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "策略配置 patch 不是合法 JSON:" + err.Error() - } - return "The strategy config patch is not valid JSON: " + err.Error() - } - merged, err := store.MergeStrategyConfig(cfg, patch) - if err != nil { - setSkillDAGStep(&session, "collect_config_patch") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "策略配置 patch 无法应用:" + err.Error() - } - return "The strategy config patch could not be applied: " + err.Error() - } - beforeClamp := merged - merged.ClampLimits() - msgZH := "已更新策略配置。" - msgEN := "Updated strategy config." - setSkillDAGStep(&session, "execute_update") - if warnings := store.StrategyClampWarnings(beforeClamp, merged, lang); len(warnings) > 0 { - return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN) - } - setSkillDAGStep(&session, "execute_update") - raw, _ := json.Marshal(map[string]any{ - "action": "update", - "strategy_id": strategy.ID, - "config": patch, - "allow_clamped_update": true, - }) - resp := a.toolManageStrategy(storeUserID, string(raw)) - a.clearSkillSession(userID) - if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { - if lang == "zh" { - return "这次没改成功:" + errMsg - } - return "That change did not go through: " + errMsg - } - a.rememberReferencesFromToolResult(userID, "manage_strategy", resp) - if lang == "zh" { - return msgZH - } - return msgEN - } - - setSkillDAGStep(&session, "collect_config_patch") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "你可以直接说想怎么改策略配置,比如“选币来源改成 AI500,最低置信度 80”。我会按当前策略类型的产品模板生成 config_patch 后再更新。" - } - return "Tell me how you want to change the strategy config, for example: set coin source to ai500 and minimum confidence to 80. I will turn it into a config_patch for the current strategy type before updating." -} - -func (a *Agent) loadStrategyConfigForUpdate(storeUserID, strategyID string) (*store.Strategy, store.StrategyConfig, error) { - strategy, err := a.store.Strategy().Get(storeUserID, strategyID) - if err != nil { - return nil, store.StrategyConfig{}, err - } - cfg := store.GetDefaultStrategyConfig("zh") - if strings.TrimSpace(strategy.Config) != "" { - _ = json.Unmarshal([]byte(strategy.Config), &cfg) - } - 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 "I ran into a problem while preparing that strategy config: " + err.Error() - } - raw, _ := json.Marshal(map[string]any{ - "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 "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- ") - } - } - if lang == "zh" { - return zhMsg - } - 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 := quotedContentRE.FindStringSubmatch(text); len(matches) == 2 { - return strings.TrimSpace(matches[1]) - } - return "" -} - -func extractLabeledInt(text string, labels []string) (int, bool) { - lower := strings.ToLower(text) - for _, label := range labels { - idx := strings.Index(lower, strings.ToLower(label)) - if idx < 0 { - continue - } - segment := text[idx:] - if match := firstIntegerPattern.FindString(segment); match != "" { - if value, err := strconv.Atoi(match); err == nil { - return value, true - } - } - } - return 0, false -} - -func extractTimeframeAfterKeywords(text string, labels []string) string { - lower := strings.ToLower(text) - for _, label := range labels { - idx := strings.Index(lower, strings.ToLower(label)) - if idx < 0 { - continue - } - segment := text[idx:] - if match := timeframeTokenRE.FindString(segment); match != "" { - return strings.ToLower(match) - } - } - return "" -} - -func extractTimeframes(text string) []string { - matches := timeframeTokenRE.FindAllString(text, -1) - if len(matches) == 0 { - return nil - } - seen := make(map[string]struct{}, len(matches)) - out := make([]string, 0, len(matches)) - for _, match := range matches { - tf := strings.ToLower(strings.TrimSpace(match)) - if tf == "" { - continue - } - if _, ok := seen[tf]; ok { - continue - } - seen[tf] = struct{}{} - out = append(out, tf) - } - return out -} - -func (a *Agent) handleTraderDiagnosisSkill(storeUserID, lang, text string) string { - target := resolveDiagnosisTraderTarget(a.loadTraderOptions(storeUserID), text) - if target == nil { - raw := a.toolListTraders(storeUserID) - list := formatReadFastPathResponse(lang, "list_traders", raw) - if lang == "zh" { - return "我需要先确定要诊断哪个交易员。当前交易员:\n" + list - } - return "I need to know which trader to diagnose first. Current traders:\n" + list - } - - evidence := a.collectTraderDiagnosisEvidence(storeUserID, target.ID, target.Name) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if answer, ok := a.generateTraderDiagnosisAnswerWithLLM(ctx, lang, text, evidence); ok { - return answer - } - return formatTraderDiagnosisEvidence(lang, evidence) -} - -func resolveDiagnosisTraderTarget(options []traderSkillOption, text string) *traderSkillOption { - if opt := findOptionByIDOrName(options, text); opt != nil { - return opt - } - if opt := findUniqueContainingOption(options, text); opt != nil { - return opt - } - if len(options) == 1 { - return &options[0] - } - return nil -} - -type traderDecisionToolResponse struct { - Error string `json:"error"` - TraderID string `json:"trader_id"` - TraderName string `json:"trader_name"` - Count int `json:"count"` - Records []struct { - Success bool `json:"success"` - ErrorMessage string `json:"error_message"` - AIRequestDurationMs int `json:"ai_request_duration_ms"` - CandidateCoins []string `json:"candidate_coins"` - ExecutionLog []string `json:"execution_log"` - DecisionJSON string `json:"decision_json"` - Decisions []map[string]any `json:"decisions"` - } `json:"records"` -} - -type traderDiagnosisEvidence struct { - TraderName string - TraderConfig *store.Trader - Model *safeModelToolConfig - Exchange *safeExchangeToolConfig - Strategy *safeStrategyToolConfig - Runtime map[string]any - Account map[string]any - Positions []map[string]any - Decisions traderDecisionToolResponse - Logs struct { - Entries []any `json:"entries"` - Count int `json:"count"` - Error string `json:"error"` - } -} - -func (a *Agent) collectTraderDiagnosisEvidence(storeUserID, traderID, traderName string) traderDiagnosisEvidence { - ev := traderDiagnosisEvidence{TraderName: traderName} - if a.store != nil { - if traderCfg, err := a.resolveTraderForTool(storeUserID, traderID, traderName); err == nil { - ev.TraderConfig = traderCfg - ev.TraderName = defaultIfEmpty(traderCfg.Name, ev.TraderName) - if model, err := a.store.AIModel().Get(storeUserID, traderCfg.AIModelID); err == nil && model != nil { - safeModel := safeModelForTool(model) - ev.Model = &safeModel - } - if exchange, err := a.store.Exchange().GetByID(storeUserID, traderCfg.ExchangeID); err == nil && exchange != nil { - safeExchange := safeExchangeForTool(exchange) - ev.Exchange = &safeExchange - } - if strings.TrimSpace(traderCfg.StrategyID) != "" { - if strategy, err := a.store.Strategy().Get(storeUserID, traderCfg.StrategyID); err == nil && strategy != nil { - safeStrategy := safeStrategyForTool(strategy) - ev.Strategy = &safeStrategy - } - } - } - } - if a.traderManager != nil && ev.TraderConfig != nil { - if runtimeTrader, err := a.traderManager.GetTrader(ev.TraderConfig.ID); err == nil && runtimeTrader != nil { - ev.Runtime = runtimeTrader.GetStatus() - if account, err := runtimeTrader.GetAccountInfo(); err == nil { - ev.Account = account - } - if positions, err := runtimeTrader.GetPositions(); err == nil { - ev.Positions = positions - } - } - } - if ev.TraderConfig != nil { - decisionArgs, _ := json.Marshal(map[string]any{"trader_id": ev.TraderConfig.ID, "limit": 5}) - _ = json.Unmarshal([]byte(a.toolGetDecisions(storeUserID, string(decisionArgs))), &ev.Decisions) - logArgs, _ := json.Marshal(map[string]any{"trader_id": ev.TraderConfig.ID, "limit": 30, "errors_only": false}) - _ = json.Unmarshal([]byte(a.toolGetBackendLogs(storeUserID, string(logArgs))), &ev.Logs) - } - return ev -} - -func (a *Agent) generateTraderDiagnosisAnswerWithLLM(ctx context.Context, lang, userText string, ev traderDiagnosisEvidence) (string, bool) { - if a == nil || a.aiClient == nil || ev.TraderConfig == nil { - return "", false - } - evidenceJSON, err := json.MarshalIndent(ev, "", " ") - if err != nil { - return "", false - } - stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) - defer cancel() - systemPrompt := `You are the trader diagnosis reasoning layer for NOFXi. -You receive a complete evidence package collected by tools: trader config, bound model, bound exchange, bound strategy, account/positions, recent AI decisions, and backend logs. - -Your job: -- Reason from the evidence and produce the final user-facing diagnosis in the user's language. -- The answer must be short and useful: final cause + what the user should do. -- Prefer recent AI decisions, order validation, exchange result, runtime/account/positions over scattered backend logs. -- Do not expose evidence-package wording, tool names, raw logs, HTTP status codes, backend internals, or engineering troubleshooting unless the user explicitly asked for technical logs. -- Do not invent subscriptions, data services, websites, missing product fields, or unsupported actions. -- Never say "subscription expired" unless the evidence explicitly contains a confirmed subscription state. -- If an order is blocked because the amount is too small, explain it as account size/order minimum/system limit. Do not suggest editing position_size_usd, min_position_size, max_positions, position value ratios, or other System enforced fields. -- If the latest decision is wait/hold, explain that the trader is running and the AI chose to wait because the entry standard was not met. -- If evidence is insufficient, say what is missing and the next concrete check. - -Return plain text only. No markdown tables.` - userPrompt := fmt.Sprintf("Language: %s\nUser question: %s\n\nEvidence JSON:\n%s", lang, userText, string(evidenceJSON)) - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - if err != nil { - a.log().Warn("trader diagnosis LLM failed; using deterministic fallback", "error", err) - return "", false - } - answer := strings.TrimSpace(raw) - if answer == "" { - return "", false - } - return answer, true -} - -func formatTraderDiagnosisEvidence(lang string, ev traderDiagnosisEvidence) string { - traderName := defaultIfEmpty(ev.TraderName, "未知交易员") - if ev.TraderConfig == nil { - if lang == "zh" { - return fmt.Sprintf("我没有找到交易员“%s”,所以没法继续诊断。", traderName) - } - return fmt.Sprintf("I could not find trader %q, so I cannot diagnose it yet.", traderName) - } - latest := struct { - Success bool `json:"success"` - ErrorMessage string `json:"error_message"` - AIRequestDurationMs int `json:"ai_request_duration_ms"` - CandidateCoins []string `json:"candidate_coins"` - ExecutionLog []string `json:"execution_log"` - DecisionJSON string `json:"decision_json"` - Decisions []map[string]any `json:"decisions"` - }{} - hasDecision := len(ev.Decisions.Records) > 0 - if hasDecision { - latest = ev.Decisions.Records[0] - } - rawDecisions, _ := json.Marshal(ev.Decisions) - allEvidence := strings.ToLower(string(rawDecisions)) - latestEvidence := strings.ToLower(strings.Join(append(append([]string{}, latest.ExecutionLog...), latest.ErrorMessage, latest.DecisionJSON), "\n")) - hasAmountTooSmall := containsAny(allEvidence, []string{"opening amount too small", "below minimum", "must be ≥", "must be >=", "position value below minimum"}) - latestWait := containsAny(latestEvidence, []string{"wait succeeded", `"action":"wait"`, `"action":"hold"`}) - primarySymbol := primaryDiagnosisSymbol(latest.CandidateCoins, latest.DecisionJSON) - amount, minimum := openingAmountAndMinimum(string(rawDecisions)) - totalEquity := toFloat(ev.Account["total_equity"]) - available := toFloat(ev.Account["available_balance"]) - if available == 0 { - available = toFloat(ev.Account["available"]) - } - var maxBTCETHPositionValue float64 - if ev.Strategy != nil && ev.Strategy.Config != nil { - if risk, ok := nestedMap(ev.Strategy.Config, "ai_config", "risk_control"); ok { - maxBTCETHPositionValue = totalEquity * firstPositiveFloat(risk["btc_eth_max_position_value_ratio"], risk["btceth_max_position_value_ratio"]) - } - if maxBTCETHPositionValue == 0 { - if risk, ok := ev.Strategy.Config["risk_control"].(map[string]any); ok { - maxBTCETHPositionValue = totalEquity * firstPositiveFloat(risk["btc_eth_max_position_value_ratio"], risk["btceth_max_position_value_ratio"]) - } - } - } - - if lang == "zh" { - lines := []string{} - switch { - case !ev.TraderConfig.IsRunning: - lines = append(lines, fmt.Sprintf("%s 现在没有运行,所以不会开单。", traderName)) - lines = append(lines, "该怎么办:先启动这个交易员;启动后等它跑到下一个扫描周期,再看是否有新的 AI 决策。") - case strings.TrimSpace(ev.TraderConfig.AIModelID) == "": - lines = append(lines, fmt.Sprintf("%s 没有绑定 AI 模型,所以没法做交易决策。", traderName)) - lines = append(lines, "该怎么办:先给这个交易员绑定一个已启用、可正常调用的模型。") - case ev.Model != nil && !modelEnabled(ev.Model): - lines = append(lines, fmt.Sprintf("%s 绑定的 AI 模型目前没有启用,所以没法稳定做交易决策。", traderName)) - lines = append(lines, "该怎么办:启用当前模型,或者把交易员换到另一个可用模型。") - case strings.TrimSpace(ev.TraderConfig.ExchangeID) == "": - lines = append(lines, fmt.Sprintf("%s 没有绑定交易所账户,所以即使有信号也不能下单。", traderName)) - lines = append(lines, "该怎么办:先绑定一个可用的交易所账户。") - case ev.Exchange != nil && !exchangeEnabled(ev.Exchange): - lines = append(lines, fmt.Sprintf("%s 绑定的交易所账户目前没有启用,所以不能下单。", traderName)) - lines = append(lines, "该怎么办:启用这个交易所账户,或换成另一个可用账户。") - case hasAmountTooSmall: - summary := fmt.Sprintf("%s 不是没运行。最近它有尝试开 %s 的单,但账户资金太小,算出来的开仓金额", traderName, primarySymbol) - if amount > 0 { - summary += fmt.Sprintf("约 %.2f USDT", amount) - } - summary += ",低于系统最小下单要求" - if minimum > 0 { - summary += fmt.Sprintf(" %.2f USDT", minimum) - } - summary += ",所以这笔单被拦下了。" - lines = append(lines, summary) - if totalEquity > 0 && maxBTCETHPositionValue > 0 { - lines = append(lines, fmt.Sprintf("当前账户权益约 %.2f USDT,按策略风控算出来的单笔仓位上限约 %.2f USDT,容易达不到最小下单金额。", totalEquity, maxBTCETHPositionValue)) - } - if latestWait { - lines = append(lines, "另外,最近也有一些周期是 AI 主动选择等待,说明并不是系统完全没跑。") - } - lines = append(lines, "该怎么办:增加账户资金,或者换更适合小资金的策略/标的。AI 智能策略里的最小开仓金额是系统限制,不能手动修改。") - case latestWait: - lines = append(lines, fmt.Sprintf("%s 是运行的,最近 AI 决策也成功了;它不开单的原因是当前信号没有达到入场标准,所以主动选择等待。", traderName)) - lines = append(lines, "该怎么办:如果你想让它更容易出手,可以调整产品里真实可改的策略偏好,比如降低最低置信度或最低盈亏比;如果你更重视安全,就让它继续等待更明确的机会。") - case !hasDecision: - lines = append(lines, fmt.Sprintf("%s 目前没有读到最近 AI 决策记录,所以还不能证明它已经跑到完整决策周期。", traderName)) - lines = append(lines, "该怎么办:确认交易员已启动,并等待一个扫描周期后再查;如果仍然没有决策记录,再检查运行状态和模型调用。") - case len(latest.CandidateCoins) == 0: - lines = append(lines, fmt.Sprintf("%s 最近没有拿到可交易候选币,所以没有进入开单。", traderName)) - lines = append(lines, "该怎么办:检查策略的选币方式、指定币种或排除币设置,确认当前策略确实有可交易标的。") - case strings.TrimSpace(latest.ErrorMessage) != "": - lines = append(lines, fmt.Sprintf("%s 最近没有开单,是因为系统在决策或下单校验时返回了错误:%s", traderName, latest.ErrorMessage)) - lines = append(lines, "该怎么办:先按这条错误处理;如果它涉及交易所权限、余额、仓位模式或最小下单金额,就优先处理对应账户或策略可编辑项。") - default: - lines = append(lines, fmt.Sprintf("%s 最近没有开单,但现有记录没有显示明确的拒单原因。", traderName)) - lines = append(lines, "该怎么办:继续观察下一个扫描周期;如果连续没有开单,再重点看策略门槛、账户余额、交易所权限和模型调用是否正常。") - } - return strings.Join(lines, "\n") - } - - lines := []string{} - switch { - case !ev.TraderConfig.IsRunning: - lines = append(lines, fmt.Sprintf("%s is not running, so it will not open trades.", traderName)) - case hasAmountTooSmall: - lines = append(lines, fmt.Sprintf("%s did try to open a %s trade, but the calculated order size was below the system minimum, so it was blocked.", traderName, primarySymbol)) - case latestWait: - lines = append(lines, fmt.Sprintf("%s is running, but the latest AI decision chose to wait because the signal did not meet its entry standard.", traderName)) - case !hasDecision: - lines = append(lines, fmt.Sprintf("%s has no recent AI decision records yet, so there is not enough evidence that it completed a decision cycle.", traderName)) - case len(latest.CandidateCoins) == 0: - lines = append(lines, fmt.Sprintf("%s has no tradable candidate coins in the latest decision, so it did not open a trade.", traderName)) - case strings.TrimSpace(latest.ErrorMessage) != "": - lines = append(lines, fmt.Sprintf("%s did not open a trade because the latest decision/check returned: %s", traderName, latest.ErrorMessage)) - default: - lines = append(lines, fmt.Sprintf("%s has no clear rejection reason in the latest records yet.", traderName)) - } - lines = append(lines, "What to do: use the real editable product settings or account actions, such as adding funds, changing to a small-account-friendly symbol/strategy, or adjusting confidence/risk-reward preferences. Do not change system-enforced fields.") - return strings.Join(lines, "\n") -} - -func primaryDiagnosisSymbol(candidates []string, decisionJSON string) string { - for _, candidate := range candidates { - if trimmed := strings.TrimSpace(candidate); trimmed != "" { - return trimmed - } - } - match := regexp.MustCompile(`(?i)"symbol"\s*:\s*"([^"]+)"`).FindStringSubmatch(decisionJSON) - if len(match) >= 2 && strings.TrimSpace(match[1]) != "" { - return strings.ToUpper(strings.TrimSpace(match[1])) - } - return "当前标的" -} - -func openingAmountAndMinimum(evidence string) (float64, float64) { - amount := 0.0 - minimum := 0.0 - if match := regexp.MustCompile(`(?i)opening amount too small \((\d+(?:\.\d+)?)\s*USDT\)`).FindStringSubmatch(evidence); len(match) >= 2 { - amount, _ = strconv.ParseFloat(match[1], 64) - } - if amount == 0 { - if match := regexp.MustCompile(`(?i)"position_size_usd"\s*:\s*(\d+(?:\.\d+)?)`).FindStringSubmatch(evidence); len(match) >= 2 { - amount, _ = strconv.ParseFloat(match[1], 64) - } - } - if match := regexp.MustCompile(`(?:must be|must be ≥|>=|≥)\s*(\d+(?:\.\d+)?)\s*USDT`).FindStringSubmatch(evidence); len(match) >= 2 { - minimum, _ = strconv.ParseFloat(match[1], 64) - } - return amount, minimum -} - -func nestedMap(root map[string]any, path ...string) (map[string]any, bool) { - var current any = root - for _, key := range path { - obj, ok := current.(map[string]any) - if !ok { - return nil, false - } - current, ok = obj[key] - if !ok { - return nil, false - } - } - obj, ok := current.(map[string]any) - return obj, ok -} - -func firstPositiveFloat(values ...any) float64 { - for _, value := range values { - parsed := toFloat(value) - if parsed > 0 { - return parsed - } - } - return 0 -} - -func nonZeroPositions(positions []map[string]any) []map[string]any { - out := make([]map[string]any, 0, len(positions)) - for _, position := range positions { - if toFloat(position["size"]) != 0 { - out = append(out, position) - } - } - return out -} - -func joinAnyLines(values []any) string { - lines := make([]string, 0, len(values)) - for _, value := range values { - switch typed := value.(type) { - case string: - lines = append(lines, typed) - default: - raw, _ := json.Marshal(typed) - if len(raw) > 0 { - lines = append(lines, string(raw)) - } - } - } - return strings.Join(lines, "\n") -} - -func valueOrUnset(value string) string { - return defaultIfEmpty(strings.TrimSpace(value), "未设置") -} - -func modelName(model *safeModelToolConfig) string { - if model == nil { - return "" - } - return model.Name -} - -func modelProvider(model *safeModelToolConfig) string { - if model == nil { - return "" - } - return model.Provider -} - -func modelEnabled(model *safeModelToolConfig) bool { - return model != nil && model.Enabled -} - -func exchangeName(exchange *safeExchangeToolConfig) string { - if exchange == nil { - return "" - } - return defaultIfEmpty(exchange.AccountName, exchange.ExchangeType) -} - -func exchangeEnabled(exchange *safeExchangeToolConfig) bool { - return exchange != nil && exchange.Enabled -} - -func strategyName(strategy *safeStrategyToolConfig) string { - if strategy == nil { - return "" - } - return strategy.Name -} - -func (a *Agent) handleStrategyDiagnosisSkill(storeUserID, lang, text string) string { - raw := a.toolGetStrategies(storeUserID) - list := formatReadFastPathResponse(lang, "get_strategies", raw) - if lang == "zh" { - reply := "现象:这是策略或提示词生效问题。\n优先排查:\n1. 你改的是策略模板,还是 trader 上的 custom prompt。\n2. 策略是否真的保存成功。\n3. 运行结果不符合预期,是配置问题还是市场条件问题。\n当前策略概览:\n" + list - if excerpt := backendLogDiagnosisExcerpt(lang, text, "strategy"); excerpt != "" { - reply += "\n" + excerpt - } - return reply - } - reply := "This looks like a strategy or prompt diagnosis issue.\nCheck whether you changed the strategy template or a trader-specific prompt override.\nCurrent strategy overview:\n" + list - if excerpt := backendLogDiagnosisExcerpt(lang, text, "strategy"); excerpt != "" { - reply += "\n" + excerpt - } - return reply -} diff --git a/agent/skill_management_handlers.go b/agent/skill_management_handlers.go deleted file mode 100644 index f27cf30d..00000000 --- a/agent/skill_management_handlers.go +++ /dev/null @@ -1,2638 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "regexp" - "sort" - "strconv" - "strings" - - "nofx/store" -) - -var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`) - -func hasExplicitCreateIntentForDomain(text, domain string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" || !hasExplicitManagementDomainCue(text, domain) { - return false - } - return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"}) -} - -func extractURL(text string) string { - return strings.TrimSpace(urlPattern.FindString(text)) -} - -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 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 - } -} - -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 - } -} - -func textMeansAllTargets(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{ - "全部", "所有", "全都", "全部策略", "所有策略", "全部删除", "全部删掉", "全部删了", - "全删", "全删了", "都删", "都删了", "全清", "全清掉", - "all", "all strategies", "every strategy", - }) -} - -func supportsBulkTargetSelection(skillName, action string) bool { - switch skillName { - case "strategy_management", "trader_management": - return action == "delete" - default: - return false - } -} - -func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference { - return resolveTargetSelection(text, options, existing).Ref -} - -func hasStrictOptionMention(text string, options []traderSkillOption) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - 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 - } - } - return false -} - -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 - } - var match *traderSkillOption - if id := strings.TrimSpace(session.TargetRef.ID); id != "" { - match = findOptionByIDOrName(options, id) - } - if match == nil { - if name := strings.TrimSpace(session.TargetRef.Name); name != "" { - match = findOptionByIDOrName(options, name) - if match == nil { - match = findUniqueContainingOption(options, name) - } - } - } - if match == nil { - session.TargetRef = nil - return false - } - 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 (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - 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" { - if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok { - return detail, true - } - return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true - } - return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID)) -} - -func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - 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": - if detail, ok := a.describeExchange(storeUserID, lang, session.TargetRef); ok { - return detail, true - } - return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true - case "create": - return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true - default: - return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options) - } -} - -func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - 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": - if detail, ok := a.describeModel(storeUserID, lang, session.TargetRef); ok { - return detail, true - } - return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true - case "create": - return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true - default: - return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options) - } -} - -func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) { - if session.Name != "strategy_management" || session.Action == "" { - return "", false - } - action := session.Action - options := a.loadStrategyOptions(storeUserID) - switch action { - case "query_detail": - if detail, ok := a.describeStrategy(storeUserID, lang, session.TargetRef); ok { - return detail, true - } - return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true - case "query_list": - return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true - case "create": - return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true - default: - return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options) - } -} - -// strategyCreateDraftConfigField stores the materialized, product-normalized -// draft between turns. User-visible strategy proposals should still be rendered -// from the post-merge structured config, not from free-form LLM text. -const strategyCreateDraftConfigField = "strategy_create_draft_config" -const strategyCreateConfigPatchField = "config_patch" - -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 strategyCreateConfigFromSession(session skillSession, lang string) (store.StrategyConfig, map[string]any, []string, error) { - normalizeLegacyStrategyCreateSession(&session) - cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang) - patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)) - var patch map[string]any - if patchRaw != "" { - if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil { - return cfg, nil, nil, fmt.Errorf("策略配置 patch 不是合法 JSON:%w", err) - } - merged, err := store.MergeStrategyConfig(cfg, patch) - if err != nil { - return cfg, nil, nil, fmt.Errorf("策略配置 patch 无法应用:%w", err) - } - cfg = merged - } - applyStrategyCreateTypeDefaults(&cfg) - beforeClamp := cfg - cfg.ClampLimits() - rawCfg, _ := json.Marshal(cfg) - var configMap map[string]any - _ = json.Unmarshal(rawCfg, &configMap) - removeLockedStrategyCreateFields(configMap) - return cfg, configMap, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil -} - -func resolveStrategyCreateName(session *skillSession, text string) string { - if session == nil { - return "" - } - name := strings.TrimSpace(fieldValue(*session, "name")) - if name == "" { - if inferred := inferStandaloneStrategyName(text); inferred != "" { - name = inferred - } - } - if name != "" { - setField(session, "name", name) - } - return name -} - -func normalizeLegacyStrategyCreateSession(session *skillSession) { - if session == nil || session.Action != "create" { - return - } - strategyType := explicitStrategyCreateType(*session) - if strategyType == "" { - return - } - filterLegacyStrategyCreateFieldsForType(session, strategyType) - if patchRaw := strings.TrimSpace(fieldValue(*session, strategyCreateConfigPatchField)); patchRaw != "" { - if sanitized := sanitizeStrategyCreateConfigPatchForType(patchRaw, strategyType); len(sanitized) > 0 { - raw, _ := json.Marshal(sanitized) - setField(session, strategyCreateConfigPatchField, string(raw)) - } else { - delete(session.Fields, strategyCreateConfigPatchField) - } - } -} - -func filterLegacyStrategyCreateFieldsForType(session *skillSession, strategyType string) { - if session == nil || len(session.Fields) == 0 { - return - } - allowed := map[string]struct{}{} - for _, key := range []string{ - "name", - "description", - "is_public", - "config_visible", - "lang", - "strategy_type", - strategyCreateDraftConfigField, - strategyCreateConfigPatchField, - skillDAGStepField, - "awaiting_final_confirmation", - } { - allowed[key] = struct{}{} - } - for key := range session.Fields { - if _, ok := allowed[key]; !ok { - delete(session.Fields, key) - } - } -} - -func resetLegacyStrategyCreateSessionForType(session *skillSession, strategyType string) { - if session == nil { - return - } - keep := map[string]string{} - for _, key := range []string{"name", "description", "is_public", "config_visible", "lang"} { - if value := fieldValue(*session, key); strings.TrimSpace(value) != "" { - keep[key] = value - } - } - session.Fields = keep - setField(session, "strategy_type", strategyType) -} - -func setStrategyCreateType(session *skillSession, strategyType string) { - if session == nil || strategyType == "" { - return - } - current := explicitStrategyCreateType(*session) - if current != "" && current != strategyType { - resetLegacyStrategyCreateSessionForType(session, strategyType) - return - } - setField(session, "strategy_type", strategyType) - filterLegacyStrategyCreateFieldsForType(session, strategyType) -} - -func applyStrategyCreateTypeDefaults(cfg *store.StrategyConfig) { - if cfg == nil { - return - } - switch strings.TrimSpace(cfg.StrategyType) { - case "grid_trading": - defaultGrid := store.DefaultGridStrategyConfig() - if cfg.GridConfig == nil { - cfg.GridConfig = &defaultGrid - return - } - if strings.TrimSpace(cfg.GridConfig.Symbol) == "" { - cfg.GridConfig.Symbol = defaultGrid.Symbol - } - if cfg.GridConfig.GridCount <= 0 { - cfg.GridConfig.GridCount = defaultGrid.GridCount - } - if cfg.GridConfig.TotalInvestment <= 0 { - cfg.GridConfig.TotalInvestment = defaultGrid.TotalInvestment - } - if cfg.GridConfig.Leverage <= 0 { - cfg.GridConfig.Leverage = defaultGrid.Leverage - } - if cfg.GridConfig.ATRMultiplier <= 0 { - cfg.GridConfig.ATRMultiplier = defaultGrid.ATRMultiplier - } - if strings.TrimSpace(cfg.GridConfig.Distribution) == "" { - cfg.GridConfig.Distribution = defaultGrid.Distribution - } - if cfg.GridConfig.MaxDrawdownPct <= 0 { - cfg.GridConfig.MaxDrawdownPct = defaultGrid.MaxDrawdownPct - } - if cfg.GridConfig.StopLossPct <= 0 { - cfg.GridConfig.StopLossPct = defaultGrid.StopLossPct - } - if cfg.GridConfig.DailyLossLimitPct <= 0 { - cfg.GridConfig.DailyLossLimitPct = defaultGrid.DailyLossLimitPct - } - if cfg.GridConfig.DirectionBiasRatio <= 0 { - cfg.GridConfig.DirectionBiasRatio = defaultGrid.DirectionBiasRatio - } - if cfg.GridConfig.UpperPrice <= 0 && cfg.GridConfig.LowerPrice <= 0 { - cfg.GridConfig.UseATRBounds = true - } - case "": - cfg.StrategyType = "ai_trading" - } -} - -func removeLockedStrategyCreateFields(configMap map[string]any) { - if configMap == nil { - return - } - risk, ok := configMap["risk_control"].(map[string]any) - if ok { - removeLockedAIRiskFields(risk) - } - if aiConfig, ok := configMap["ai_config"].(map[string]any); ok { - if risk, ok := aiConfig["risk_control"].(map[string]any); ok { - removeLockedAIRiskFields(risk) - } - } -} - -func removeLockedAIRiskFields(risk map[string]any) { - delete(risk, "max_positions") - delete(risk, "btc_eth_max_position_value_ratio") - delete(risk, "btceth_max_position_value_ratio") - delete(risk, "altcoin_max_position_value_ratio") - delete(risk, "max_margin_usage") - delete(risk, "min_position_size") -} - -func strategyCreateConfirmationReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - for _, exact := range []string{ - "确认创建", "确认", "创建吧", "就按这个创建", "按这个创建", "确认应用", "就按这个应用", - "可以", "好的", "好", "没问题", "就这样", "按这个", "ok", "okay", "yes", "yep", "looks good", - } { - if lower == exact { - return true - } - } - return false -} - -func strategyCreateDefaultConfigReply(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{ - "默认", "先创建", "直接创建", "不用配置", "其他默认", "用默认", "按默认", "默认配置", - "use default", "use defaults", "default config", "create now", "create directly", - }) -} - -func explicitStrategyCreateType(session skillSession) string { - if value := strings.TrimSpace(fieldValue(session, "strategy_type")); value != "" { - return value - } - patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)) - if patchRaw == "" { - return "" - } - var patch map[string]any - if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil { - return "" - } - if value, ok := patch["strategy_type"].(string); ok { - return strings.TrimSpace(value) - } - if gridConfig, ok := patch["grid_config"]; ok && gridConfig != nil { - return "grid_trading" - } - if aiConfig, ok := patch["ai_config"]; ok && aiConfig != nil { - return "ai_trading" - } - return "" -} - -func strategyCreateConfigReady(session skillSession, cfg store.StrategyConfig, text string) (bool, string) { - strategyType := explicitStrategyCreateType(session) - if strategyType == "" { - return false, "strategy_type" - } - if missing := strategyCreateMissingTemplateFields(session, cfg); len(missing) > 0 { - return false, strings.Join(missing, ",") - } - return true, "" -} - -func strategyCreateFinalConfirmationReady(session skillSession) bool { - return strings.EqualFold(strings.TrimSpace(fieldValue(session, "awaiting_final_confirmation")), "true") -} - -func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool { - for _, key := range manualStrategyEditableFieldKeys() { - switch key { - case "name", "description", "is_public", "config_visible", "strategy_type": - continue - } - if strings.TrimSpace(fieldValue(session, key)) != "" { - return true - } - } - patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)) - if patchRaw == "" { - return false - } - var patch map[string]any - if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil { - return true - } - for key := range patch { - if strings.TrimSpace(key) != "" && strings.TrimSpace(key) != "strategy_type" { - return true - } - } - return false -} - -func strategyCreateMissingTemplateFields(session skillSession, cfg store.StrategyConfig) []string { - switch explicitStrategyCreateType(session) { - case "ai_trading": - return strategyCreateMissingAIFields(session, cfg) - case "grid_trading": - return strategyCreateMissingGridFields(session) - default: - return []string{"strategy_type"} - } -} - -func strategyCreateMissingAIFields(session skillSession, cfg store.StrategyConfig) []string { - required := []string{ - "source_type", - "primary_timeframe", - "selected_timeframes", - "btceth_max_leverage", - "altcoin_max_leverage", - "min_confidence", - "min_risk_reward_ratio", - "trading_frequency", - "entry_standards", - } - missing := make([]string, 0, len(required)+1) - for _, field := range required { - if !strategyCreateFieldExplicit(session, field) { - missing = append(missing, field) - } - } - if strings.EqualFold(strings.TrimSpace(cfg.CoinSource.SourceType), "static") && !strategyCreateFieldExplicit(session, "static_coins") { - missing = append(missing, "static_coins") - } - return missing -} - -func strategyCreateMissingGridFields(session skillSession) []string { - required := []string{ - "symbol", - "grid_count", - "total_investment", - "leverage", - "distribution", - "max_drawdown_pct", - "stop_loss_pct", - "daily_loss_limit_pct", - "use_maker_only", - } - missing := make([]string, 0, len(required)+1) - for _, field := range required { - if !strategyCreateFieldExplicit(session, field) { - missing = append(missing, field) - } - } - if !strategyCreateFieldExplicit(session, "use_atr_bounds") && (!strategyCreateFieldExplicit(session, "upper_price") || !strategyCreateFieldExplicit(session, "lower_price")) { - missing = append(missing, "use_atr_bounds 或 upper_price/lower_price") - } - return missing -} - -func strategyCreateFieldExplicit(session skillSession, field string) bool { - field = strings.TrimSpace(field) - if field == "" { - return false - } - if strings.TrimSpace(fieldValue(session, field)) != "" { - return true - } - patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)) - if patchRaw == "" { - return false - } - var patch map[string]any - if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil { - return false - } - for _, path := range strategyCreatePatchPaths(field) { - if strategyCreatePatchHasPath(patch, path...) { - return true - } - } - return false -} - -func strategyCreatePatchPaths(field string) [][]string { - switch strings.TrimSpace(field) { - case "strategy_type": - return [][]string{{"strategy_type"}} - case "source_type": - return [][]string{ - {"ai_config", "coin_source", "source_type"}, {"coin_source", "source_type"}, - {"ai_config", "coin_source", "static_coins"}, {"coin_source", "static_coins"}, - {"ai_config", "coin_source", "use_ai500"}, {"coin_source", "use_ai500"}, - {"ai_config", "coin_source", "use_oi_top"}, {"coin_source", "use_oi_top"}, - {"ai_config", "coin_source", "use_oi_low"}, {"coin_source", "use_oi_low"}, - } - case "static_coins": - return [][]string{{"ai_config", "coin_source", "static_coins"}, {"coin_source", "static_coins"}} - case "primary_timeframe": - return [][]string{{"ai_config", "indicators", "klines", "primary_timeframe"}, {"indicators", "klines", "primary_timeframe"}} - case "selected_timeframes": - return [][]string{{"ai_config", "indicators", "klines", "selected_timeframes"}, {"indicators", "klines", "selected_timeframes"}} - case "btceth_max_leverage": - return [][]string{{"ai_config", "risk_control", "btc_eth_max_leverage"}, {"risk_control", "btc_eth_max_leverage"}, {"ai_config", "risk_control", "btceth_max_leverage"}, {"risk_control", "btceth_max_leverage"}} - case "altcoin_max_leverage": - return [][]string{{"ai_config", "risk_control", "altcoin_max_leverage"}, {"risk_control", "altcoin_max_leverage"}} - case "min_confidence": - return [][]string{{"ai_config", "risk_control", "min_confidence"}, {"risk_control", "min_confidence"}} - case "min_risk_reward_ratio": - return [][]string{{"ai_config", "risk_control", "min_risk_reward_ratio"}, {"risk_control", "min_risk_reward_ratio"}} - case "trading_frequency": - return [][]string{{"ai_config", "prompt_sections", "trading_frequency"}, {"prompt_sections", "trading_frequency"}} - case "entry_standards": - return [][]string{{"ai_config", "prompt_sections", "entry_standards"}, {"prompt_sections", "entry_standards"}} - case "symbol", "grid_count", "total_investment", "leverage", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_atr_bounds", "upper_price", "lower_price": - return [][]string{{"grid_config", field}} - default: - return [][]string{{field}} - } -} - -func strategyCreatePatchHasPath(value any, path ...string) bool { - current := value - for _, part := range path { - obj, ok := current.(map[string]any) - if !ok { - return false - } - next, ok := obj[part] - if !ok { - return false - } - current = next - } - return true -} - -func formatStrategyCreateConfigNeeded(lang, missingKind string) string { - if lang == "zh" { - if missingKind == "strategy_type" { - return "先选择策略类型:grid_trading(网格策略)或 ai_trading(AI 策略)。类型确认后我会继续收集对应配置,配置好后再创建。" - } - if hints := formatStrategyMissingFieldHints(lang, missingKind); hints != "" { - return "这份策略模板还没填完整,还缺这些字段。你可以按下面选,也可以直接说“你帮我按稳健/高频/激进来推荐”:\n" + hints - } - return "这份策略模板还没填完整,还缺:" + formatStrategyMissingFieldNames(lang, missingKind) + "。你可以一句话告诉我这些字段,我会继续填模板。" - } - if missingKind == "strategy_type" { - return "Choose the strategy type first: grid_trading or ai_trading. I will collect the matching config before creating it." - } - if hints := formatStrategyMissingFieldHints(lang, missingKind); hints != "" { - return "This strategy template is not complete yet. You can choose from these options, or ask me to recommend a conservative/balanced/high-frequency setup:\n" + hints - } - return "This strategy template is not complete yet. Missing: " + formatStrategyMissingFieldNames(lang, missingKind) + ". Tell me these fields in one message and I will keep filling the template." -} - -func formatStrategyMissingFieldHints(lang, missingKind string) string { - parts := strings.Split(missingKind, ",") - lines := make([]string, 0, len(parts)) - for _, part := range parts { - field := strings.TrimSpace(part) - if field == "" { - continue - } - hint := strategyCreateFieldInlineHint(lang, field) - if hint == "" { - hint = strategyCreateFieldDisplayName(lang, field) - } - if lang == "zh" { - lines = append(lines, "- "+hint) - } else { - lines = append(lines, "- "+hint) - } - } - return strings.Join(lines, "\n") -} - -func strategyCreateFieldInlineHint(lang, field string) string { - field = strings.TrimSpace(field) - if lang != "zh" { - switch field { - case "source_type": - return "Coin source: ai500 / oi_top / oi_low / static" - case "static_coins": - return "Static coins: up to 10 symbols, e.g. BTCUSDT, ETHUSDT" - case "primary_timeframe": - return "Primary timeframe: 1m / 3m / 5m / 15m / 30m / 1h / 2h / 4h / 6h / 8h / 12h / 1d / 3d / 1w" - case "selected_timeframes": - return "Multi-timeframes: up to 4, e.g. 5m,15m,1h" - case "btceth_max_leverage", "altcoin_max_leverage": - return strategyCreateFieldDisplayName(lang, field) + ": 1-20" - case "min_confidence": - return "Minimum confidence: 50-100" - case "min_risk_reward_ratio": - return "Minimum risk/reward ratio: 1-10, step 0.5" - case "trading_frequency": - return "Trading frequency rule: free text, e.g. max 2-4 trades per day" - case "entry_standards": - return "Entry standards: free text, e.g. enter only when trend and risk/reward align" - case "symbol": - return "Symbol: BTCUSDT / ETHUSDT / SOLUSDT / BNBUSDT / XRPUSDT / DOGEUSDT" - case "grid_count": - return "Grid count: 5-50" - case "total_investment": - return "Total investment: user's capital/margin budget, minimum 100 USDT; not leveraged notional exposure" - case "leverage": - return "Grid leverage: 1-5" - case "distribution": - return "Distribution: uniform / gaussian / pyramid" - case "max_drawdown_pct": - return "Max drawdown: 5%-50%" - case "stop_loss_pct": - return "Stop loss: 1%-20%" - case "daily_loss_limit_pct": - return "Daily loss limit: 1%-30%" - case "use_maker_only": - return "Maker only: on / off" - } - return "" - } - switch field { - case "source_type": - return "选币来源:AI500 / OI Top / OI Low / 静态币种(没有混合模式)" - case "static_coins": - return "静态币种:最多 10 个,例如 BTCUSDT、ETHUSDT" - case "primary_timeframe": - return "主周期:1m / 3m / 5m / 15m / 30m / 1h / 2h / 4h / 6h / 8h / 12h / 1d / 3d / 1w" - case "selected_timeframes": - return "多周期时间框架:最多 4 个,例如 5m,15m,1h" - case "btceth_max_leverage": - return "BTC/ETH 最大杠杆:1~20 倍" - case "altcoin_max_leverage": - return "山寨币最大杠杆:1~20 倍" - case "min_confidence": - return "最低置信度:50~100,越高越谨慎" - case "min_risk_reward_ratio": - return "最小盈亏比:1~10,步进 0.5" - case "trading_frequency": - return "交易频率规则:文本,例如“每天最多 2~4 笔,避免连续追单”" - case "entry_standards": - return "开仓标准:文本,例如“趋势明确、成交量配合、风险收益合理才开仓”" - case "symbol": - return "交易对:BTCUSDT / ETHUSDT / SOLUSDT / BNBUSDT / XRPUSDT / DOGEUSDT" - case "grid_count": - return "网格数量:5~50" - case "total_investment": - return "总投入:用户实际投入/保证金预算,最低 100 USDT;不是杠杆后的名义仓位" - case "leverage": - return "杠杆:1~5 倍" - case "distribution": - return "网格分布:uniform(均匀)/ gaussian(正态)/ pyramid(金字塔)" - case "max_drawdown_pct": - return "最大回撤:5%~50%" - case "stop_loss_pct": - return "止损:1%~20%" - case "daily_loss_limit_pct": - return "日亏损限制:1%~30%" - case "use_maker_only": - return "只挂 Maker:开启 / 关闭" - case "use_atr_bounds 或 upper_price/lower_price": - return "价格边界:开启 ATR 自动边界,或手动填写上边界/下边界" - } - return "" -} - -func formatStrategyCreateFieldOptionsReply(lang, text, missingKind string) string { - if !strategyCreateAsksFieldOptions(text) { - return "" - } - field := firstStrategyMissingField(missingKind) - if field == "" { - return "" - } - if lang != "zh" { - switch field { - case "source_type": - return "Coin source options: ai500, oi_top, oi_low, or static. Pick one and I will continue filling the AI strategy template." - case "primary_timeframe", "selected_timeframes": - return "Timeframe options: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w." - } - return "For " + strategyCreateFieldDisplayName(lang, field) + ", tell me the value you want and I will keep filling the selected strategy template." - } - switch field { - case "strategy_type": - return "策略类型只有两个:\n- AI 策略:让 AI 根据行情和策略规则判断开平仓。\n- 网格策略:在价格区间内按网格低买高卖。\n你直接回复“AI 策略”或“网格策略”就行。" - case "source_type": - return "AI 策略的选币来源有 4 个:\n- AI500:从 NOFX AI500 榜单自动选币。\n- OI Top:选持仓量靠前/更活跃的币。\n- OI Low:选持仓量较低或变化较弱的币。\n- 静态币种:你指定固定币种,比如 BTCUSDT、ETHUSDT。\n没有混合模式。你选一个,我继续填模板。" - case "primary_timeframe": - return "主周期可选:1m、3m、5m、15m、30m、1h、2h、4h、6h、8h、12h、1d、3d、1w。高频一般偏 1m/3m/5m,稳健一点可以用 15m/1h。" - case "selected_timeframes": - return "多周期最多选 4 个,可选:1m、3m、5m、15m、30m、1h、2h、4h、6h、8h、12h、1d、3d、1w。常见组合比如 5m,15m,1h。" - case "btceth_max_leverage", "altcoin_max_leverage": - return strategyCreateFieldDisplayName(lang, field) + "范围是 1~20 倍。数值越高风险越大。" - case "min_confidence": - return "最低置信度范围是 50~100。数值越高越谨慎,开单会更少。" - case "min_risk_reward_ratio": - return "最小盈亏比范围是 1~10,步进 0.5。比如 1.5 表示预期收益至少是风险的 1.5 倍。" - case "trading_frequency": - return "交易频率规则是文本规则,例如“每天最多 2~4 笔,避免连续追单”。你也可以说“你帮我按高频但不过度交易来写”。" - case "entry_standards": - return "开仓标准是文本规则,例如“只在趋势明确、成交量配合、风险收益合理时开仓”。你也可以说“你帮我写一版稳健开仓标准”。" - case "symbol": - return "网格交易对可选:BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。" - case "grid_count": - return "网格数量范围是 5~50。数量越多越密,交易更频繁;数量越少,每格空间更大。" - case "total_investment": - return "网格总投入是用户实际投入/保证金预算,不是杠杆后的名义仓位;最小 100 USDT,按 100 USDT 步进。" - case "leverage": - return "网格杠杆范围是 1~5 倍。稳健一般用 1 倍。" - case "distribution": - return "网格分布可选:uniform(均匀)、gaussian(正态)、pyramid(金字塔)。" - case "max_drawdown_pct": - return "最大回撤范围是 5%~50%。" - case "stop_loss_pct": - return "止损范围是 1%~20%。" - case "daily_loss_limit_pct": - return "日亏损限制范围是 1%~30%。" - case "use_maker_only": - return "只挂 Maker 是开关项:开启会更偏向低手续费挂单,成交可能慢一些;关闭则更灵活。" - } - return strategyCreateFieldDisplayName(lang, field) + "是当前模板字段。你告诉我想怎么设置,我继续填模板。" -} - -func strategyCreateAsksFieldOptions(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - return containsAny(lower, []string{ - "有哪些", "有什么", "可选", "选项", "怎么选", "怎么填", "不知道", "不会填", - "what options", "which options", "options", "how to choose", "how should i fill", - }) -} - -func firstStrategyMissingField(missingKind string) string { - for _, part := range strings.Split(missingKind, ",") { - part = strings.TrimSpace(part) - if part != "" { - return part - } - } - return "" -} - -func formatStrategyMissingFieldNames(lang, missingKind string) string { - parts := strings.Split(missingKind, ",") - names := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if strings.Contains(part, "或") || strings.Contains(part, "/") { - names = append(names, part) - continue - } - names = append(names, strategyCreateFieldDisplayName(lang, part)) - } - if lang == "zh" { - return strings.Join(names, "、") - } - return strings.Join(names, ", ") -} - -func strategyCreateFieldDisplayName(lang, field string) string { - if lang != "zh" { - return field - } - switch strings.TrimSpace(field) { - case "source_type": - return "选币来源" - case "static_coins": - return "静态币种" - case "primary_timeframe": - return "主周期" - case "selected_timeframes": - return "多周期时间框架" - case "btceth_max_leverage": - return "BTC/ETH 最大杠杆" - case "altcoin_max_leverage": - return "山寨币最大杠杆" - case "min_confidence": - return "最低置信度" - case "min_risk_reward_ratio": - return "最小盈亏比" - case "trading_frequency": - return "交易频率规则" - case "entry_standards": - return "开仓标准" - case "symbol": - return "交易对" - case "grid_count": - return "网格数量" - case "total_investment": - return "总投入" - case "leverage": - return "杠杆" - case "distribution": - return "网格分布" - case "max_drawdown_pct": - return "最大回撤" - case "stop_loss_pct": - return "止损" - case "daily_loss_limit_pct": - return "日亏损限制" - case "use_maker_only": - return "只挂 Maker" - default: - return field - } -} - -func formatStrategyCreateDraftSummary(lang, name, strategyType 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") - } - switch strategyType { - case "grid_trading": - lines = append(lines, "这是网格策略草稿。请继续补充交易对、网格数量、总投入、杠杆、价格区间和网格风控;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。") - case "ai_trading": - lines = append(lines, "这是 AI 策略草稿。请继续补充选币来源、时间周期、风险参数和提示词方向;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。") - default: - 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") - } - switch strategyType { - case "grid_trading": - lines = append(lines, "This is a grid strategy draft. Keep refining symbol, grid count, total investment, leverage, price bounds, and grid risk settings; I will only fill fields you explicitly provide or ask me to design.") - case "ai_trading": - lines = append(lines, "This is an AI strategy draft. Keep refining coin source, timeframes, risk settings, and prompt direction; I will only fill fields you explicitly provide or ask me to design.") - default: - lines = append(lines, "You can keep refining the strategy type and matching parameters, or reply 'confirm' to create it now.") - } - return strings.Join(lines, "\n") -} - -func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cfg store.StrategyConfig) string { - name := defaultIfEmpty(fieldValue(session, "name"), "未命名策略") - if lang != "zh" { - name = defaultIfEmpty(fieldValue(session, "name"), "unnamed strategy") - } - if lang == "zh" { - lines := []string{fmt.Sprintf("我已经把“%s”的配置整理好了,确认后我再创建到策略列表。", name)} - switch cfg.StrategyType { - case "grid_trading": - grid := cfg.GridConfig - if grid == nil { - grid = &store.GridStrategyConfig{} - } - lines = append(lines, - "- 类型:网格策略", - fmt.Sprintf("- 发布到策略市场:%t", fieldValue(session, "is_public") == "true"), - fmt.Sprintf("- 发布后配置可见:%t", fieldValue(session, "config_visible") != "false"), - fmt.Sprintf("- 交易对:%s", defaultIfEmpty(grid.Symbol, "未设置")), - fmt.Sprintf("- 网格数量:%d", grid.GridCount), - fmt.Sprintf("- 总投入:%.2f USDT", grid.TotalInvestment), - fmt.Sprintf("- 杠杆:%d倍", grid.Leverage), - ) - if grid.UseATRBounds { - lines = append(lines, fmt.Sprintf("- 价格区间:ATR 动态范围(倍数 %.2f)", grid.ATRMultiplier)) - } else { - lines = append(lines, fmt.Sprintf("- 价格区间:%.2f ~ %.2f", grid.LowerPrice, grid.UpperPrice)) - } - lines = append(lines, - fmt.Sprintf("- 网格分布:%s", defaultIfEmpty(grid.Distribution, "uniform")), - fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct), - fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct), - fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct), - ) - default: - lines = append(lines, - "- 类型:AI 策略", - fmt.Sprintf("- 发布到策略市场:%t", fieldValue(session, "is_public") == "true"), - fmt.Sprintf("- 发布后配置可见:%t", fieldValue(session, "config_visible") != "false"), - fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")), - ) - lines = append(lines, formatAICoinSourceSummaryZH(cfg)...) - lines = append(lines, - fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")), - fmt.Sprintf("- K线数量:%d", cfg.Indicators.Klines.PrimaryCount), - fmt.Sprintf("- 多周期:%s", defaultIfEmpty(strings.Join(cfg.Indicators.Klines.SelectedTimeframes, ","), "未设置")), - fmt.Sprintf("- 指标:%s", formatEnabledAIIndicatorsZH(cfg)), - fmt.Sprintf("- NofxOS 量化数据:%t", cfg.Indicators.EnableQuantData), - fmt.Sprintf("- OI 排行数据:%t(%s / %d)", cfg.Indicators.EnableOIRanking, defaultIfEmpty(cfg.Indicators.OIRankingDuration, "未设置"), cfg.Indicators.OIRankingLimit), - fmt.Sprintf("- 资金流排行数据:%t(%s / %d)", cfg.Indicators.EnableNetFlowRanking, defaultIfEmpty(cfg.Indicators.NetFlowRankingDuration, "未设置"), cfg.Indicators.NetFlowRankingLimit), - fmt.Sprintf("- 涨跌幅排行数据:%t(%s / %d)", cfg.Indicators.EnablePriceRanking, defaultIfEmpty(cfg.Indicators.PriceRankingDuration, "未设置"), cfg.Indicators.PriceRankingLimit), - fmt.Sprintf("- BTC/ETH 最大杠杆:%d倍", cfg.RiskControl.BTCETHMaxLeverage), - fmt.Sprintf("- 山寨币最大杠杆:%d倍", cfg.RiskControl.AltcoinMaxLeverage), - fmt.Sprintf("- 最小置信度:%d", cfg.RiskControl.MinConfidence), - fmt.Sprintf("- 最小盈亏比:%.2f", cfg.RiskControl.MinRiskRewardRatio), - fmt.Sprintf("- 最大持仓数(System enforced):%d", cfg.RiskControl.MaxPositions), - fmt.Sprintf("- BTC/ETH 单币仓位上限(System enforced):账户权益 %.2f 倍", cfg.RiskControl.BTCETHMaxPositionValueRatio), - fmt.Sprintf("- 山寨币单币仓位上限(System enforced):账户权益 %.2f 倍", cfg.RiskControl.AltcoinMaxPositionValueRatio), - fmt.Sprintf("- 最大保证金使用率(System enforced):%.0f%%", cfg.RiskControl.MaxMarginUsage*100), - fmt.Sprintf("- 最小开仓金额(System enforced):%.2f USDT", cfg.RiskControl.MinPositionSize), - fmt.Sprintf("- 角色定义:%s", compactSummaryText(cfg.PromptSections.RoleDefinition)), - fmt.Sprintf("- 交易频率规则:%s", compactSummaryText(cfg.PromptSections.TradingFrequency)), - fmt.Sprintf("- 开仓标准:%s", compactSummaryText(cfg.PromptSections.EntryStandards)), - fmt.Sprintf("- 决策流程:%s", compactSummaryText(cfg.PromptSections.DecisionProcess)), - fmt.Sprintf("- 自定义 Prompt:%s", compactSummaryText(cfg.CustomPrompt)), - ) - } - lines = append(lines, "确认创建的话,直接回复“确认创建”。要调整也可以直接说改哪项。") - return strings.Join(lines, "\n") - } - lines := []string{fmt.Sprintf("I prepared the config for %q. Confirm and I will create it in the strategy list.", name)} - if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil { - grid := cfg.GridConfig - lines = append(lines, - "- Type: grid strategy", - fmt.Sprintf("- Symbol: %s", defaultIfEmpty(grid.Symbol, "unset")), - fmt.Sprintf("- Grid count: %d", grid.GridCount), - fmt.Sprintf("- Total investment: %.2f USDT", grid.TotalInvestment), - fmt.Sprintf("- Leverage: %dx", grid.Leverage), - ) - } else { - lines = append(lines, "- Type: AI strategy") - } - lines = append(lines, "Reply 'confirm create' to create it, or tell me what to change.") - return strings.Join(lines, "\n") -} - -func formatEnabledAIIndicatorsZH(cfg store.StrategyConfig) string { - enabled := make([]string, 0, 8) - if cfg.Indicators.EnableRawKlines { - enabled = append(enabled, "K线") - } - if cfg.Indicators.EnableVolume { - enabled = append(enabled, "成交量") - } - if cfg.Indicators.EnableOI { - enabled = append(enabled, "OI") - } - if cfg.Indicators.EnableFundingRate { - enabled = append(enabled, "资金费率") - } - if cfg.Indicators.EnableEMA { - enabled = append(enabled, "EMA") - } - if cfg.Indicators.EnableMACD { - enabled = append(enabled, "MACD") - } - if cfg.Indicators.EnableRSI { - enabled = append(enabled, "RSI") - } - if cfg.Indicators.EnableATR { - enabled = append(enabled, "ATR") - } - if cfg.Indicators.EnableBOLL { - enabled = append(enabled, "BOLL") - } - if len(enabled) == 0 { - return "无" - } - return strings.Join(enabled, ",") -} - -func formatAICoinSourceSummaryZH(cfg store.StrategyConfig) []string { - lines := make([]string, 0, 4) - sourceType := strings.ToLower(strings.TrimSpace(cfg.CoinSource.SourceType)) - switch sourceType { - case "static": - lines = append(lines, fmt.Sprintf("- 静态币种:%s", defaultIfEmpty(strings.Join(cfg.CoinSource.StaticCoins, ","), "未设置"))) - case "ai500": - lines = append(lines, fmt.Sprintf("- AI500 数量:%d", cfg.CoinSource.AI500Limit)) - case "oi_top": - lines = append(lines, fmt.Sprintf("- OI Top 数量:%d", cfg.CoinSource.OITopLimit)) - case "oi_low": - lines = append(lines, fmt.Sprintf("- OI Low 数量:%d", cfg.CoinSource.OILowLimit)) - default: - if cfg.CoinSource.UseAI500 { - lines = append(lines, fmt.Sprintf("- AI500 数量:%d", cfg.CoinSource.AI500Limit)) - } - if cfg.CoinSource.UseOITop { - lines = append(lines, fmt.Sprintf("- OI Top 数量:%d", cfg.CoinSource.OITopLimit)) - } - if cfg.CoinSource.UseOILow { - lines = append(lines, fmt.Sprintf("- OI Low 数量:%d", cfg.CoinSource.OILowLimit)) - } - } - if len(cfg.CoinSource.ExcludedCoins) > 0 { - lines = append(lines, fmt.Sprintf("- 排除币种:%s", strings.Join(cfg.CoinSource.ExcludedCoins, ","))) - } - return lines -} - -func compactSummaryText(value string) string { - value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") - if value == "" { - return "未设置" - } - const maxLen = 120 - runes := []rune(value) - if len(runes) <= maxLen { - return value - } - return string(runes[:maxLen]) + "..." -} - -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") != "false" - 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(未指定时默认 true)", 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 true 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") != "false" - testnet := fieldValue(session, "testnet") == "true" - if lang == "zh" { - lines := []string{ - fmt.Sprintf("我先整理了一份交易所配置草稿“%s”。", accountName), - fmt.Sprintf("- 交易所:%s", exType), - fmt.Sprintf("- 账户名:%s", accountName), - fmt.Sprintf("- 启用状态:%t(未指定时默认 true)", 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 true 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 - } - 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("- 全仓模式:%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), - "- Initial balance: auto-read from the bound exchange account equity at creation time", - 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 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", "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 == "" { - return false - } - return containsAny(lower, []string{ - "默认配置", "默认策略", "默认模板", "模板配置", - "default config", "default strategy", "default template", - }) -} - -func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) { - if a.store == nil { - return "", false - } - - var strategy *store.Strategy - var err error - if target != nil && strings.TrimSpace(target.ID) != "" { - strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID)) - } else if target != nil && strings.TrimSpace(target.Name) != "" { - strategies, listErr := a.store.Strategy().List(storeUserID) - if listErr != nil { - return "", false - } - for _, item := range strategies { - if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) { - strategy = item - break - } - } - } else { - strategies, listErr := a.store.Strategy().List(storeUserID) - if listErr != nil || len(strategies) != 1 { - return "", false - } - strategy = strategies[0] - } - if err != nil || strategy == nil { - return "", false - } - - var cfg store.StrategyConfig - if strings.TrimSpace(strategy.Config) != "" { - _ = json.Unmarshal([]byte(strategy.Config), &cfg) - } - - return formatStrategyDetailResponse(lang, strategy, cfg), true -} - -func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string { - name := strings.TrimSpace(strategy.Name) - if name == "" { - name = strings.TrimSpace(strategy.ID) - } - - sourceBits := make([]string, 0, 4) - if strings.TrimSpace(cfg.CoinSource.SourceType) != "" { - sourceBits = append(sourceBits, cfg.CoinSource.SourceType) - } - if cfg.CoinSource.UseAI500 { - sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit)) - } - if cfg.CoinSource.UseOITop { - sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit)) - } - if cfg.CoinSource.UseOILow { - sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit)) - } - if len(cfg.CoinSource.StaticCoins) > 0 { - sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ",")) - } - if len(cfg.CoinSource.ExcludedCoins) > 0 { - sourceBits = append(sourceBits, "excluded="+strings.Join(cfg.CoinSource.ExcludedCoins, ",")) - } - - timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...) - if len(timeframes) == 0 { - timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe}) - } - - indicatorBits := make([]string, 0, 8) - if cfg.Indicators.EnableRawKlines { - indicatorBits = append(indicatorBits, "raw_klines") - } - if cfg.Indicators.EnableVolume { - indicatorBits = append(indicatorBits, "volume") - } - if cfg.Indicators.EnableOI { - indicatorBits = append(indicatorBits, "oi") - } - if cfg.Indicators.EnableFundingRate { - indicatorBits = append(indicatorBits, "funding_rate") - } - if cfg.Indicators.EnableEMA { - indicatorBits = append(indicatorBits, "ema") - } - if cfg.Indicators.EnableMACD { - indicatorBits = append(indicatorBits, "macd") - } - if cfg.Indicators.EnableRSI { - indicatorBits = append(indicatorBits, "rsi") - } - if cfg.Indicators.EnableATR { - indicatorBits = append(indicatorBits, "atr") - } - if cfg.Indicators.EnableBOLL { - indicatorBits = append(indicatorBits, "boll") - } - sort.Strings(indicatorBits) - - promptBits := make([]string, 0, 5) - if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" { - promptBits = append(promptBits, "role_definition") - } - if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" { - promptBits = append(promptBits, "trading_frequency") - } - if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" { - promptBits = append(promptBits, "entry_standards") - } - if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" { - promptBits = append(promptBits, "decision_process") - } - - customPrompt := strings.TrimSpace(cfg.CustomPrompt) - customPromptPreview := customPrompt - if len([]rune(customPromptPreview)) > 120 { - runes := []rune(customPromptPreview) - customPromptPreview = string(runes[:120]) + "..." - } - - publishStatusZh := "未发布" - publishStatusEn := "private" - if strategy.IsPublic { - publishStatusZh = "已发布到市场" - publishStatusEn = "public" - } - configVisibleZh := "隐藏" - configVisibleEn := "hidden" - if strategy.ConfigVisible { - configVisibleZh = "可见" - configVisibleEn = "visible" - } - - if lang == "zh" { - lines := []string{ - fmt.Sprintf("策略“%s”概览:", name), - fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")), - fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")), - fmt.Sprintf("- 发布设置:%s;配置%s", publishStatusZh, configVisibleZh), - } - if strings.TrimSpace(strategy.Description) != "" { - lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description))) - } - if cfg.GridConfig != nil { - lines = append(lines, fmt.Sprintf("- 网格参数:交易对 %s;网格 %d;总投资 %.2f;杠杆 %d;分布 %s", - defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "未设置"), - cfg.GridConfig.GridCount, - cfg.GridConfig.TotalInvestment, - cfg.GridConfig.Leverage, - defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "未设置"), - )) - if cfg.GridConfig.UseATRBounds { - lines = append(lines, fmt.Sprintf("- 网格边界:ATR 自动边界,倍数 %.2f", cfg.GridConfig.ATRMultiplier)) - } else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 { - lines = append(lines, fmt.Sprintf("- 网格边界:上沿 %.4f,下沿 %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice)) - } - } - if len(sourceBits) > 0 { - lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | ")) - } - if len(timeframes) > 0 { - lines = append(lines, "- K线周期:"+strings.Join(timeframes, " / ")) - } - lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %d,BTC/ETH 最大杠杆 %d,山寨最大杠杆 %d,最低置信度 %d", - cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence)) - lines = append(lines, fmt.Sprintf("- 风控阈值:最小盈亏比 %.2f;最大保证金使用率 %.2f;最小开仓金额 %.2f", - cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize)) - if len(indicatorBits) > 0 { - lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、")) - } - if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking { - lines = append(lines, fmt.Sprintf("- NofxOS 数据:API Key=%t,量化数据=%t,OI 排行=%t,净流入排行=%t,价格排行=%t", - strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "", - cfg.Indicators.EnableQuantData, - cfg.Indicators.EnableOIRanking, - cfg.Indicators.EnableNetFlowRanking, - cfg.Indicators.EnablePriceRanking, - )) - } - if len(promptBits) > 0 { - lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、")) - } - if customPromptPreview != "" { - lines = append(lines, "- 自定义 Prompt:"+customPromptPreview) - } else { - lines = append(lines, "- 自定义 Prompt:当前为空,主要使用策略模板内置 prompt sections。") - } - lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON,或者逐段解释它的 prompt。") - return strings.Join(lines, "\n") - } - - lines := []string{ - fmt.Sprintf("Strategy %q overview:", name), - fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")), - fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")), - fmt.Sprintf("- Publish settings: %s; config %s", publishStatusEn, configVisibleEn), - } - if strings.TrimSpace(strategy.Description) != "" { - lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description))) - } - if cfg.GridConfig != nil { - lines = append(lines, fmt.Sprintf("- Grid config: symbol %s; grids %d; investment %.2f; leverage %d; distribution %s", - defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "not set"), - cfg.GridConfig.GridCount, - cfg.GridConfig.TotalInvestment, - cfg.GridConfig.Leverage, - defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "not set"), - )) - if cfg.GridConfig.UseATRBounds { - lines = append(lines, fmt.Sprintf("- Grid bounds: ATR auto bounds with multiplier %.2f", cfg.GridConfig.ATRMultiplier)) - } else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 { - lines = append(lines, fmt.Sprintf("- Grid bounds: upper %.4f, lower %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice)) - } - } - if len(sourceBits) > 0 { - lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | ")) - } - if len(timeframes) > 0 { - lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / ")) - } - lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d", - cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence)) - lines = append(lines, fmt.Sprintf("- Risk thresholds: min RR %.2f, max margin usage %.2f, min position size %.2f", - cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize)) - if len(indicatorBits) > 0 { - lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", ")) - } - if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking { - lines = append(lines, fmt.Sprintf("- NofxOS data: API key=%t, quant data=%t, OI ranking=%t, netflow ranking=%t, price ranking=%t", - strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "", - cfg.Indicators.EnableQuantData, - cfg.Indicators.EnableOIRanking, - cfg.Indicators.EnableNetFlowRanking, - cfg.Indicators.EnablePriceRanking, - )) - } - if len(promptBits) > 0 { - lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", ")) - } - if customPromptPreview != "" { - lines = append(lines, "- Custom prompt: "+customPromptPreview) - } else { - lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.") - } - lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.") - return strings.Join(lines, "\n") -} - -func (a *Agent) describeDefaultStrategyConfig(lang string) string { - if lang != "zh" { - lang = "en" - } - cfg := store.GetDefaultStrategyConfig(lang) - name := "Default Strategy Template" - description := "System default strategy configuration template" - if lang == "zh" { - name = "默认策略模板" - description = "系统默认策略配置模板" - } - return formatStrategyDetailResponse(lang, &store.Strategy{ - ID: "default_strategy_template", - Name: name, - Description: description, - }, cfg) -} - -func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) { - raw := a.toolListTraders(storeUserID) - var payload struct { - Traders []safeTraderToolConfig `json:"traders"` - } - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return "", false - } - trader := findTraderByReference(payload.Traders, target) - if trader == nil { - if len(payload.Traders) != 1 { - return "", false - } - trader = &payload.Traders[0] - } - if lang == "zh" { - status := "未运行" - if trader.IsRunning { - status = "运行中" - } - return fmt.Sprintf("交易员“%s”详情:\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f", - trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true - } - status := "stopped" - if trader.IsRunning { - status = "running" - } - return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f", - trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true -} - -func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) { - raw := a.toolGetExchangeConfigs(storeUserID) - var payload struct { - ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"` - } - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return "", false - } - exchange := findExchangeByReference(payload.ExchangeConfigs, target) - if exchange == nil { - if len(payload.ExchangeConfigs) != 1 { - return "", false - } - exchange = &payload.ExchangeConfigs[0] - } - 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)) - } - 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) { - raw := a.toolGetModelConfigs(storeUserID) - var payload struct { - ModelConfigs []safeModelToolConfig `json:"model_configs"` - } - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return "", false - } - model := findModelByReference(payload.ModelConfigs, target) - if model == nil { - if len(payload.ModelConfigs) != 1 { - return "", false - } - model = &payload.ModelConfigs[0] - } - if lang == "zh" { - lines := []string{ - fmt.Sprintf("模型配置“%s”详情:", defaultIfEmpty(model.Name, model.ID)), - fmt.Sprintf("- Provider:%s", model.Provider), - fmt.Sprintf("- 已启用:%t", model.Enabled), - fmt.Sprintf("- API Key:%t", model.HasAPIKey), - fmt.Sprintf("- URL:%s", defaultIfEmpty(model.CustomAPIURL, "未设置")), - fmt.Sprintf("- Model Name:%s", defaultIfEmpty(model.CustomModelName, "未设置")), - } - if strings.TrimSpace(model.WalletAddress) != "" { - lines = append(lines, fmt.Sprintf("- 钱包地址:%s", model.WalletAddress)) - } - if strings.TrimSpace(model.BalanceUSDC) != "" { - lines = append(lines, fmt.Sprintf("- 钱包余额:%s USDC", model.BalanceUSDC)) - } - return strings.Join(lines, "\n"), true - } - lines := []string{ - fmt.Sprintf("Model config %q details:", defaultIfEmpty(model.Name, model.ID)), - fmt.Sprintf("- Provider: %s", model.Provider), - fmt.Sprintf("- Enabled: %t", model.Enabled), - fmt.Sprintf("- API key present: %t", model.HasAPIKey), - fmt.Sprintf("- URL: %s", defaultIfEmpty(model.CustomAPIURL, "not set")), - fmt.Sprintf("- Model name: %s", defaultIfEmpty(model.CustomModelName, "not set")), - } - if strings.TrimSpace(model.WalletAddress) != "" { - lines = append(lines, fmt.Sprintf("- Wallet address: %s", model.WalletAddress)) - } - if strings.TrimSpace(model.BalanceUSDC) != "" { - lines = append(lines, fmt.Sprintf("- Wallet balance: %s USDC", model.BalanceUSDC)) - } - return strings.Join(lines, "\n"), true -} - -func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig { - if target == nil { - return nil - } - for i := range items { - if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) { - return &items[i] - } - if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) { - return &items[i] - } - } - return nil -} - -func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig { - if target == nil { - return nil - } - for i := range items { - name := defaultIfEmpty(items[i].AccountName, items[i].Name) - if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) { - return &items[i] - } - if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) { - return &items[i] - } - } - return nil -} - -func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig { - if target == nil { - return nil - } - for i := range items { - if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) { - return &items[i] - } - if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) { - return &items[i] - } - } - return nil -} - -func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption { - if a.store == nil { - return nil - } - traders, err := a.store.Trader().List(storeUserID) - if err != nil { - return nil - } - exchangeNames := map[string]string{} - if exchanges, err := a.store.Exchange().List(storeUserID); err == nil { - for _, exchange := range exchanges { - if !store.IsVisibleExchange(exchange) { - continue - } - 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 { - 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 -} - -func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { - if session.Name == "" { - session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"} - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "resolve_exchange_type") - } - if isCancelSkillReply(text) { - a.clearSkillSession(userID) - if lang == "zh" { - return "已取消当前创建交易所配置流程。" - } - return "Cancelled the current exchange creation flow." - } - 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" { - reply := "要创建交易所配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。" - if exType == "" { - reply += "\n例如:OKX、Binance、Bybit。" - } - return reply - } - return "One more thing: please tell me these details: " + formatMissingFieldList(lang, missing) + "." - } - 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) - return formatExchangeCreateDraftSummary(lang, session) - } - setSkillDAGStep(&session, "execute_create") - args := map[string]any{ - "action": "create", - "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"`) { - a.saveSkillSession(userID, session) - if lang == "zh" { - return "创建交易所配置失败:" + 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)。", accountName, exType) - } - return fmt.Sprintf("Created exchange config %s (%s).", accountName, exType) -} - -func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { - if session.Name == "" { - session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"} - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "resolve_provider") - } - if isCancelSkillReply(text) { - a.clearSkillSession(userID) - if lang == "zh" { - return "已取消当前创建模型配置流程。" - } - return "Cancelled the current model creation flow." - } - provider := fieldValue(session, "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" { - reply := "要创建模型配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。" - if provider == "" { - reply += "\n" + availableModelProvidersMessage(lang) - } else { - reply += "\n" + modelProviderDetailedGuidance(lang, provider) - } - return reply - } - 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": 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"`) { - a.saveSkillSession(userID, session) - if lang == "zh" { - return "创建模型配置失败:" + 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。", fieldValue(session, "name")) - } - return fmt.Sprintf("Created model config %s.", fieldValue(session, "name")) -} - -func inferModelCredentialFromText(provider, text string) string { - provider = strings.ToLower(strings.TrimSpace(provider)) - text = strings.TrimSpace(text) - if provider == "" || text == "" { - return "" - } - - if value := extractQuotedContent(text); value != "" { - trimmed := strings.TrimSpace(value) - if credentialLooksCompatibleWithProvider(provider, trimmed) { - return trimmed - } - } - - if credentialLooksCompatibleWithProvider(provider, text) { - return text - } - return "" -} - -func credentialLooksCompatibleWithProvider(provider, value string) bool { - provider = strings.ToLower(strings.TrimSpace(provider)) - value = strings.TrimSpace(value) - if provider == "" || value == "" { - return false - } - - switch provider { - case "claw402", "blockrun-base", "blockrun-sol": - return hexCredentialPattern.MatchString(value) - case "openai": - return openAIAPIKeyPattern.MatchString(value) - default: - return genericAPIKeyPattern.MatchString(value) || hexCredentialPattern.MatchString(value) - } -} - -func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { - if session.Name == "" { - session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"} - } - if fieldValue(session, skillDAGStepField) == "" { - setSkillDAGStep(&session, "resolve_name") - } - if isCancelSkillReply(text) { - a.clearSkillSession(userID) - if lang == "zh" { - return "已取消当前创建策略流程。" - } - return "Cancelled the current strategy creation flow." - } - name := resolveStrategyCreateName(&session, text) - if actionRequiresSlot("strategy_management", "create", "name") && name == "" { - setSkillDAGStep(&session, "resolve_name") - a.saveSkillSession(userID, session) - if lang == "zh" { - return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说:创建一个叫“趋势策略A”的策略。" - } - return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'." - } - if fieldValue(session, "strategy_type") == "" { - if strategyType := parseStrategyTypeValue(text); strategyType != "" { - setStrategyCreateType(&session, strategyType) - } - } else if strategyType := parseStrategyTypeValue(text); strategyType != "" { - setStrategyCreateType(&session, strategyType) - } - cfg, configMap, warnings, cfgErr := strategyCreateConfigFromSession(session, lang) - if cfgErr != nil { - a.saveSkillSession(userID, session) - if lang == "zh" { - return "创建策略失败:" + cfgErr.Error() - } - return "That strategy config could not be prepared: " + cfgErr.Error() - } - if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready { - setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg)) - setSkillDAGStep(&session, "collect_config") - session.Phase = "collecting" - a.saveSkillSession(userID, session) - if reply := formatStrategyCreateFieldOptionsReply(lang, text, missingKind); reply != "" { - return reply - } - return formatStrategyCreateConfigNeeded(lang, missingKind) - } - if !strategyCreateConfirmationReply(text) && !strategyCreateFinalConfirmationReady(session) { - setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg)) - setField(&session, "awaiting_final_confirmation", "true") - setSkillDAGStep(&session, "await_create_confirmation") - session.Phase = "await_create_confirmation" - a.saveSkillSession(userID, session) - return formatStrategyCreateFinalConfirmation(lang, session, cfg) - } - - setSkillDAGStep(&session, "execute_create") - args := map[string]any{ - "action": "create", - "name": name, - "lang": defaultIfEmpty(lang, "zh"), - "allow_clamped_update": true, - "confirmed": true, - } - if 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) - a.rememberReferencesFromToolResult(userID, "manage_strategy", resp) - return formatCreatedStrategyReply(lang, name, cfg, warnings) -} - -func formatCreatedStrategyReply(lang, name string, cfg store.StrategyConfig, warnings []string) string { - name = defaultIfEmpty(strings.TrimSpace(name), "未命名策略") - if lang != "zh" { - name = defaultIfEmpty(strings.TrimSpace(name), "unnamed strategy") - } - _ = warnings - if lang == "zh" { - lines := []string{fmt.Sprintf("已创建策略“%s”。实际保存配置如下:", name)} - if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil { - grid := cfg.GridConfig - lines = append(lines, - "- 类型:网格策略", - fmt.Sprintf("- 交易对:%s", defaultIfEmpty(grid.Symbol, "未设置")), - fmt.Sprintf("- 网格数量:%d", grid.GridCount), - fmt.Sprintf("- 总投入:%.2f USDT", grid.TotalInvestment), - fmt.Sprintf("- 杠杆:%d倍", grid.Leverage), - fmt.Sprintf("- 分布方式:%s", defaultIfEmpty(grid.Distribution, "未设置")), - ) - if grid.UseATRBounds { - lines = append(lines, fmt.Sprintf("- 价格范围:ATR 自动计算(倍数 %.2f)", grid.ATRMultiplier)) - } else { - lines = append(lines, fmt.Sprintf("- 价格范围:%.2f ~ %.2f USDT", grid.LowerPrice, grid.UpperPrice)) - } - lines = append(lines, - fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct), - fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct), - fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct), - fmt.Sprintf("- 只挂 Maker:%t", grid.UseMakerOnly), - ) - } else { - lines = append(lines, - "- 类型:AI 策略", - fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")), - fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")), - fmt.Sprintf("- BTC/ETH 最大杠杆:%d倍", cfg.RiskControl.BTCETHMaxLeverage), - fmt.Sprintf("- 山寨币最大杠杆:%d倍", cfg.RiskControl.AltcoinMaxLeverage), - fmt.Sprintf("- 最小置信度:%d", cfg.RiskControl.MinConfidence), - fmt.Sprintf("- 最小盈亏比:%.2f", cfg.RiskControl.MinRiskRewardRatio), - ) - } - return strings.Join(lines, "\n") - } - - lines := []string{fmt.Sprintf("Created strategy %q with this saved config:", name)} - if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil { - grid := cfg.GridConfig - lines = append(lines, - "- Type: grid strategy", - fmt.Sprintf("- Symbol: %s", defaultIfEmpty(grid.Symbol, "unset")), - fmt.Sprintf("- Grid count: %d", grid.GridCount), - fmt.Sprintf("- Total investment: %.2f USDT", grid.TotalInvestment), - fmt.Sprintf("- Leverage: %dx", grid.Leverage), - fmt.Sprintf("- Distribution: %s", defaultIfEmpty(grid.Distribution, "unset")), - ) - if grid.UseATRBounds { - lines = append(lines, fmt.Sprintf("- Price range: ATR auto bounds (multiplier %.2f)", grid.ATRMultiplier)) - } else { - lines = append(lines, fmt.Sprintf("- Price range: %.2f - %.2f USDT", grid.LowerPrice, grid.UpperPrice)) - } - } else { - lines = append(lines, - "- Type: AI strategy", - fmt.Sprintf("- Coin source: %s", defaultIfEmpty(cfg.CoinSource.SourceType, "unset")), - fmt.Sprintf("- Primary timeframe: %s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "unset")), - ) - } - return strings.Join(lines, "\n") -} - -func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) { - if session.Name == "" { - session = skillSession{Name: skillName, Action: action, Phase: "collecting"} - } - if session.Name != skillName || session.Action != action { - return "", false - } - if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) { - setField(&session, "bulk_scope", "all") - session.TargetRef = nil - } - - if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 { - currentStep, _ := currentSkillDAGStep(session) - if currentStep.ID == "resolve_target" { - if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { - session.TargetRef = resolved.Ref - } - if session.TargetRef == nil { - if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") { - setSkillDAGStep(&session, "resolve_target") - a.saveSkillSession(userID, session) - label := "可选对象:" - if lang != "zh" { - label = "Available targets:" - } - optionList := formatOptionList(label, options) - if lang == "zh" { - reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。" - if optionList != "" { - reply += "\n" + optionList - } - return reply, true - } - reply := "One more thing: tell me which one you want me to work on." - if optionList != "" { - reply += "\n" + optionList - } - return reply, true - } - } - if fieldValue(session, skillDAGStepField) == currentStep.ID { - advanceSkillDAGStep(&session, currentStep.ID) - } - } - } else { - if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil { - session.TargetRef = resolved.Ref - } - if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" { - a.saveSkillSession(userID, session) - label := formatOptionList("可选对象:", options) - if lang == "zh" { - reply := "我还需要你明确要操作的是哪一个对象。" - if label != "" { - reply += "\n" + label - } - return reply, true - } - 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 - } - return reply, true - } - } - - switch skillName { - case "trader_management": - return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true - case "exchange_management": - return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true - case "model_management": - return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true - case "strategy_management": - return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true - default: - return "", false - } -} - -func (a *Agent) askLLMAmbiguousTargetQuestion(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, allOptions, ambiguous []traderSkillOption) string { - return formatAmbiguousTargetPrompt(lang, ambiguous) -} - -func defaultIfEmpty(value, fallback string) string { - value = strings.TrimSpace(value) - if value == "" { - return strings.TrimSpace(fallback) - } - return value -} diff --git a/agent/skill_outcome.go b/agent/skill_outcome.go deleted file mode 100644 index 8922ad2d..00000000 --- a/agent/skill_outcome.go +++ /dev/null @@ -1,175 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "nofx/mcp" -) - -const ( - skillOutcomeSuccess = "success" - skillOutcomeNeedMoreInfo = "need_more_info" - skillOutcomeRecoverableError = "recoverable_error" - skillOutcomeFatalError = "fatal_error" - skillOutcomeNotHandled = "not_handled" -) - -type skillOutcome struct { - Skill string `json:"skill"` - Action string `json:"action"` - Status string `json:"status"` - GoalAchieved bool `json:"goal_achieved"` - UserMessage string `json:"user_message,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - Error string `json:"error,omitempty"` - Data map[string]any `json:"data,omitempty"` -} - -type taskReviewDecision struct { - Route string `json:"route"` - Answer string `json:"answer,omitempty"` -} - -func normalizeAtomicSkillAction(skill, action string) string { - action = strings.TrimSpace(strings.ToLower(action)) - switch skill { - case "trader_management": - switch action { - case "query", "query_list": - return "query_list" - case "query_running": - return "query_running" - case "query_detail", "query_strategy_binding", "query_exchange_binding", "query_model_binding": - return action - case "query_binding": - return "query_detail" - case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model": - return action - } - case "exchange_management": - switch action { - case "query", "query_list": - return "query_list" - case "query_detail": - return "query_detail" - case "update", "update_name", "update_status": - return action - } - case "model_management": - switch action { - case "query", "query_list": - return "query_list" - case "query_detail": - return "query_detail" - case "update", "update_name", "update_endpoint", "update_status": - return action - } - case "strategy_management": - switch action { - case "query", "query_list": - return "query_list" - case "query_detail": - return "query_detail" - case "update", "update_name", "update_config", "update_prompt": - return action - } - } - return action -} - -func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome { - outcome := skillOutcome{ - Skill: skill, - Action: action, - Status: skillOutcomeSuccess, - UserMessage: strings.TrimSpace(answer), - Data: data, - } - if activeSession.Name != "" { - outcome.Status = skillOutcomeNeedMoreInfo - outcome.GoalAchieved = false - return outcome - } - - lower := strings.ToLower(strings.TrimSpace(answer)) - switch { - case lower == "": - outcome.Status = skillOutcomeNotHandled - case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"): - outcome.Status = skillOutcomeRecoverableError - outcome.Error = strings.TrimSpace(answer) - default: - outcome.GoalAchieved = true - } - return outcome -} - -func parseTaskReviewDecision(raw string) (taskReviewDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var decision taskReviewDecision - if err := json.Unmarshal([]byte(raw), &decision); err == nil { - decision.Route = strings.TrimSpace(strings.ToLower(decision.Route)) - decision.Answer = strings.TrimSpace(decision.Answer) - return decision, nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { - decision.Route = strings.TrimSpace(strings.ToLower(decision.Route)) - decision.Answer = strings.TrimSpace(decision.Answer) - return decision, nil - } - } - return taskReviewDecision{}, fmt.Errorf("invalid task review json") -} - -func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) { - if a.aiClient == nil { - if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled { - return taskReviewDecision{Route: "replan"}, nil - } - return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil - } - - recentConversationCtx := a.buildRecentConversationContext(userID, text) - outcomeJSON, _ := json.Marshal(outcome) - systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi. -You are reviewing the JSON result returned by one structured skill execution. -Return JSON only. Do not return markdown. - -Rules: -- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully. -- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply. -- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome. -- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help. -- If you choose "complete", produce the final user-facing answer in the user's language. -- ` + cleanUserFacingReplyInstruction + ` - -Return JSON with this exact shape: -{"route":"complete|replan","answer":""}` - userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON)) - - 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 taskReviewDecision{}, err - } - return parseTaskReviewDecision(raw) -} diff --git a/agent/skill_registry.go b/agent/skill_registry.go deleted file mode 100644 index 8b231c09..00000000 --- a/agent/skill_registry.go +++ /dev/null @@ -1,721 +0,0 @@ -package agent - -import ( - "embed" - "encoding/json" - "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"` - Capabilities []string `json:"capabilities,omitempty"` - DynamicRules []string `json:"dynamic_rules,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 { - Description string `json:"description,omitempty"` - 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() - if err != nil { - panic(err) - } - return registry -} - -func loadSkillRegistry() (map[string]SkillDefinition, error) { - entries, err := embeddedSkillDefinitions.ReadDir("skills") - if err != nil { - return nil, err - } - - registry := make(map[string]SkillDefinition, len(entries)) - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue - } - raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name()) - if err != nil { - return nil, err - } - var def SkillDefinition - if err := json.Unmarshal(raw, &def); err != nil { - return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err) - } - def = normalizeSkillDefinition(def) - if def.Name == "" { - return nil, fmt.Errorf("skill definition %s has empty name", entry.Name()) - } - registry[def.Name] = def - } - return registry, nil -} - -func normalizeSkillDefinition(def SkillDefinition) SkillDefinition { - def.Name = strings.TrimSpace(def.Name) - def.Kind = strings.TrimSpace(def.Kind) - def.Domain = strings.TrimSpace(def.Domain) - def.Description = strings.TrimSpace(def.Description) - def.Intents = cleanStringList(def.Intents) - def.Capabilities = cleanStringList(def.Capabilities) - def.DynamicRules = cleanStringList(def.DynamicRules) - - if len(def.Actions) > 0 { - normalized := make(map[string]SkillActionDefinition, len(def.Actions)) - for key, action := range def.Actions { - key = strings.TrimSpace(key) - if key == "" { - continue - } - 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 - } - - if len(def.ToolMapping) > 0 { - normalized := make(map[string]string, len(def.ToolMapping)) - for key, value := range def.ToolMapping { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || value == "" { - continue - } - normalized[key] = value - } - 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 -} - -func getSkillDefinition(name string) (SkillDefinition, bool) { - def, ok := skillRegistry[strings.TrimSpace(name)] - return def, ok -} - -func listSkillNames() []string { - names := make([]string, 0, len(skillRegistry)) - for name := range skillRegistry { - names = append(names, name) - } - 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)} - if len(def.DynamicRules) > 0 { - parts = append(parts, strings.Join(def.DynamicRules, " ")) - } - switch name { - case "trader_management": - if lang == "zh" { - parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。") - } else { - parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.") - } - case "strategy_management": - if lang == "zh" { - parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。") - } else { - parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.") - } - } - 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 len(def.Capabilities) > 0 { - if lang == "zh" { - parts = append(parts, "能力: "+strings.Join(def.Capabilities, ";")) - } else { - parts = append(parts, "capabilities: "+strings.Join(def.Capabilities, "; ")) - } - } - if len(def.DynamicRules) > 0 { - if lang == "zh" { - parts = append(parts, "规则: "+strings.Join(def.DynamicRules, ";")) - } else { - parts = append(parts, "rules: "+strings.Join(def.DynamicRules, "; ")) - } - } - 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 and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.") - } - case "strategy_management": - if lang == "zh" { - parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。") - } else { - parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.") - } - } - 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 的继续操作;但如果用户要改这些对象的内部配置,应切到对应 management skill。" - } - return "When the current object is a trader, rebinding its model, exchange, or strategy remains inside trader_management; but if the user wants to change the internals of those resources, switch to the corresponding management skill." - 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。") - lines = append(lines, "- trader_management 不能让用户手动设置、充值或修改交易员余额;交易员初始余额应由系统自动读取绑定交易所净值。") - } else { - lines = append(lines, "- trader_management must not invent a profit-seeking plan; those requests belong to the planner.") - lines = append(lines, "- trader_management must not let the user set, top up, or manually edit trader balance; trader initial balance should be auto-read from the bound exchange equity.") - } - 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_runner.go b/agent/skill_runner.go deleted file mode 100644 index 38a1940a..00000000 --- a/agent/skill_runner.go +++ /dev/null @@ -1,244 +0,0 @@ -package agent - -import ( - "fmt" - "strings" -) - -type skillActionRuntime struct { - Skill SkillDefinition - Name string - Action SkillActionDefinition -} - -func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) { - def, ok := getSkillDefinition(skillName) - if !ok { - return skillActionRuntime{}, false - } - action = strings.TrimSpace(action) - if action == "" { - return skillActionRuntime{Skill: def}, true - } - actionDef, ok := def.Actions[action] - if !ok { - return skillActionRuntime{}, false - } - return skillActionRuntime{ - Skill: def, - Name: action, - Action: actionDef, - }, true -} - -func actionNeedsConfirmation(skillName, action string) bool { - runtime, ok := getSkillActionRuntime(skillName, action) - if !ok { - return false - } - return runtime.Action.NeedsConfirmation -} - -func actionRequiresSlot(skillName, action, slot string) bool { - runtime, ok := getSkillActionRuntime(skillName, action) - if !ok { - return false - } - slot = strings.TrimSpace(slot) - for _, candidate := range runtime.Action.RequiredSlots { - if candidate == slot { - return true - } - } - return false -} - -func slotDisplayName(slot, lang string) string { - slot = strings.TrimSpace(slot) - if lang != "zh" { - switch slot { - case "target_ref": - return "target" - case "name": - return "name" - case "exchange": - return "exchange" - case "model": - return "model" - case "strategy": - return "strategy" - case "exchange_type": - return "exchange type" - case "provider": - return "provider" - default: - return slot - } - } - switch slot { - case "target_ref": - return "目标对象" - case "name": - return "名称" - case "exchange": - return "交易所" - case "model": - return "模型" - case "strategy": - return "策略" - case "exchange_type": - return "交易所类型" - case "provider": - return "模型提供商" - default: - return slot - } -} - -func formatAwaitConfirmationMessage(lang, action, targetLabel string) string { - actionLabel := action - if lang == "zh" { - switch action { - case "start": - actionLabel = "启动" - case "stop": - actionLabel = "停止" - case "delete": - actionLabel = "删除" - case "activate": - actionLabel = "激活" - default: - actionLabel = action - } - return fmt.Sprintf("即将%s“%s”。这是需要确认的操作,请回复“确认”继续,回复“取消”终止。", actionLabel, targetLabel) - } - 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 "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。" - } - return "This flow is still waiting for your confirmation." -} - -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, a.formatConfirmationTargetLabel(userID, lang, session, targetLabel)), true - } - return "", false -} - -func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) { - if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" { - return "", false - } - if isYesReply(text) { - return "", false - } - return formatStillWaitingConfirmationMessage(lang), true -} diff --git a/agent/skill_semantic_gate.go b/agent/skill_semantic_gate.go deleted file mode 100644 index d110d70f..00000000 --- a/agent/skill_semantic_gate.go +++ /dev/null @@ -1,246 +0,0 @@ -package agent - -import ( - "encoding/json" - "strings" - - "nofx/store" -) - -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": - if strings.TrimSpace(action) == "create" { - add(slotDisplayName("name", 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) strategyTypeForTarget(storeUserID string, target *EntityReference) (string, bool) { - if a == nil || a.store == nil || target == nil { - return "", false - } - var strategy *store.Strategy - var err error - if id := strings.TrimSpace(target.ID); id != "" { - strategy, err = a.store.Strategy().Get(storeUserID, id) - } else if name := strings.TrimSpace(target.Name); name != "" { - strategies, listErr := a.store.Strategy().List(storeUserID) - if listErr != nil { - return "", false - } - for _, item := range strategies { - if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), name) { - strategy = item - break - } - } - } else { - return "", false - } - if err != nil || strategy == nil { - return "", false - } - cfg := store.GetDefaultStrategyConfig("zh") - if strings.TrimSpace(strategy.Config) != "" { - _ = json.Unmarshal([]byte(strategy.Config), &cfg) - } - strategyType := strings.TrimSpace(cfg.StrategyType) - if strategyType == "" { - strategyType = "ai_trading" - } - return strategyType, true -} - -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_diagnosis.json b/agent/skills/exchange_diagnosis.json deleted file mode 100644 index cebe3363..00000000 --- a/agent/skills/exchange_diagnosis.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "exchange_diagnosis", - "kind": "diagnosis", - "domain": "exchange", - "description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用、余额读取失败、下单失败或仓位模式错误等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入与执行故障。不用于创建、修改、删除或查询交易所配置这类管理操作。", - "capabilities": [ - "区分凭证缺失、签名错误、时间戳偏差、IP 白名单、权限不足、余额不足、仓位模式和 symbol 不可交易等原因", - "解释不同交易所的必填字段差异,尤其是 OKX/Bitget/KuCoin passphrase、Hyperliquid 钱包地址、Aster signer/private key、Lighter API key private key", - "把交易所原始错误翻译成新手可执行的修复步骤" - ], - "dynamic_rules": [ - "交易所连接失败优先按顺序排查:配置是否启用 -> 必填凭证是否齐全 -> API Key/Secret/Passphrase 是否填反或过期 -> 系统时间/timestamp -> IP 白名单 -> 合约/交易权限 -> 测试网/主网是否选错。", - "OKX、Bitget、KuCoin 的 passphrase/API 口令不是可选项;如果缺失,必须明确提示补齐。", - "下单失败时继续排查:账户余额/可用保证金 -> 杠杆限制 -> 仓位模式(单向/双向) -> symbol 是否支持合约交易 -> 最小下单金额/数量。", - "Hyperliquid、Aster、Lighter 这类钱包/DEX 配置错误时,不要用 CEX 的 API Key/Secret 逻辑套用;按各自 required fields 解释。", - "诊断回复不得展示完整 API Key、Secret、Passphrase 或私钥。" - ] -} diff --git a/agent/skills/exchange_management.json b/agent/skills/exchange_management.json deleted file mode 100644 index b2c12cdf..00000000 --- a/agent/skills/exchange_management.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "name": "exchange_management", - "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", "bitget", "kucoin"], - "description": "OKX、Bitget、KuCoin 专用 Passphrase/API 口令,对这些交易所启用前必须填写;Binance、Bybit、Gate、Indodax 通常不需要。" - }, - "testnet": { - "type": "bool", - "default": false, - "description": "是否使用测试网(沙盒环境),默认 false(主网)。" - }, - "enabled": { - "type": "bool", - "default": true, - "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/API 口令,不能回答“没有就留空”;缺失时应明确提示补填。", - "Hyperliquid 创建/更新时应与手动页面保持一致:至少收集 api_key + hyperliquid_wallet_addr。", - "Hyperliquid 账户启用前必须填写 hyperliquid_wallet_addr。", - "若用户使用 Hyperliquid unified account 模式,应明确记录 hyperliquid_unified_account 开关状态。", - "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": "创建新的交易所配置。根据 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。", - "如果用户选择 OKX、Bitget 或 KuCoin,不能把 passphrase 说成可选项;没有 passphrase 时应停在补字段,不要创建半成品。", - "凭证字段格式不符时,用人话告知用户正确格式,不要静默丢弃。", - "若当前父任务只是缺一个可用交易所,本动作完成后应允许父任务恢复并消费新的 exchange_id。", - "若请求只是在启用已有交易所,不应误走 create,应改走 update_status。" - ], - "success_output": "返回新 exchange_id 和创建后的交易所配置摘要(类型、账户名、是否启用)。", - "failure_output": "明确指出缺失的必填字段或非法凭证格式,禁止返回含糊的成功信息。" - }, - "update": { - "description": "更新已有交易所配置的任意可编辑字段。", - "required_slots": ["target_ref"], - "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": "删除交易所配置,不可逆操作,必须确认。", - "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_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_list": "get_exchange_configs", - "query_detail": "get_exchange_configs" - } -} diff --git a/agent/skills/model_diagnosis.json b/agent/skills/model_diagnosis.json deleted file mode 100644 index f09b5a90..00000000 --- a/agent/skills/model_diagnosis.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "model_diagnosis", - "kind": "diagnosis", - "domain": "model", - "description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用、claw402 钱包余额不足或支付失败等问题时调用。适用于用户在接入或测试大模型时遇到的配置、兼容性、支付和调用故障。不用于创建、修改、删除或查询模型配置这类管理操作。", - "capabilities": [ - "区分模型未启用、凭证缺失、endpoint/model name 配置错误、钱包余额不足、上游限流或网关异常", - "对 claw402 / blockrun-base 这类钱包付费模型,解释钱包地址、USDC 余额和支付状态", - "给出不泄露敏感凭证的下一步修复建议" - ], - "dynamic_rules": [ - "诊断模型不可用时,按顺序检查:是否存在该模型配置 -> enabled 是否为 true -> provider 是否支持 -> 凭证/API Key 或钱包私钥是否存在 -> custom_api_url 是否合法 HTTPS 或可留空 -> custom_model_name 是否有默认值或已填写 -> 钱包余额/支付状态 -> 上游限流、超时或网关错误。", - "claw402 是模型 provider,使用 Base USDC 钱包按次付费;余额为 0 USDC 时应明确说需要充值,不要说成“未配置模型”。", - "429/rate_limit_error、空响应、超时不应默认归因为余额不足;只有工具结果或错误文本指向余额/支付失败时才这么判断。", - "任何诊断回复都不得展示 API Key、钱包私钥或完整敏感凭证。" - ] -} diff --git a/agent/skills/model_management.json b/agent/skills/model_management.json deleted file mode 100644 index 5e06b36e..00000000 --- a/agent/skills/model_management.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "name": "model_management", - "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 地址硬启用。", - "claw402 是 AI 模型 provider,不是交易所、策略或交易员名称;用户说“用 claw402”时应解释为选择/绑定 claw402 模型配置。", - "claw402 使用 Base 链 EVM 钱包 + USDC 按次付费;enabled=true 只代表模型配置已启用,不代表钱包一定有余额。", - "claw402 或 blockrun-base 钱包余额为 0 USDC 时,应明确提示“钱包余额不足/需要充值”,不要说成“模型未启用”或静默改用其他模型。", - "用户明确指定某个 provider 或模型时,如果当前不可用,必须先说明不可用原因,再让用户选择修复该模型或改用其他已可用模型;不得静默替换。", - "删除操作不可逆,必须先向用户确认再执行。" - ], - "actions": { - "create": { - "description": "创建新的模型配置。", - "required_slots": ["provider"], - "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。", - "创建 claw402 后若钱包余额为 0 USDC,应提示用户充值 Base USDC 后再用于稳定调用;不要把余额不足误报为配置未启用。", - "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": "更新已有模型配置的任意可编辑字段。", - "required_slots": ["target_ref"], - "optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"], - "goal": "更新一个已有模型配置的指定字段,而不覆盖未提及字段。", - "dynamic_rules": [ - "只更新用户明确提到的字段,不要覆盖未提及的字段。", - "如果用户只是想给 trader 改用 claw402,不要在模型配置里误改显示名称;应把 claw402 作为 provider/model 选择处理。", - "更新 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": [ - "启用前必须确保 provider 对应凭证已经齐全;若 provider 有默认模型,custom_model_name 可按默认值处理。", - "启用 claw402 只校验钱包私钥等配置完整性;若钱包 0 USDC,应提示充值,但不要把它等同于 enabled=false。" - ], - "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": "删除模型配置,不可逆操作,必须确认。", - "required_slots": ["target_ref"], - "needs_confirmation": true, - "goal": "删除一个模型配置。", - "dynamic_rules": [ - "必须在确认后执行,并明确提醒删除不可逆。" - ], - "success_output": "返回删除成功结果,并明确告知该模型配置已被移除。", - "failure_output": "明确指出缺少确认、目标模型不存在,或删除失败原因。" - }, - "query_list": { - "description": "查询所有模型配置列表,包含 provider、名称、启用状态。", - "goal": "列出当前用户可见的模型配置,便于后续选择或绑定。", - "dynamic_rules": [ - "优先返回 provider、名称、启用状态,不返回 API Key 明文。", - "对于 claw402 / blockrun-base,若工具结果包含钱包地址或 USDC 余额,应用它解释支付状态;余额不足时要说“需要充值”,不要说“没配置”。" - ], - "success_output": "返回模型配置列表摘要。", - "failure_output": "若列表为空,应明确告知当前没有模型配置。" - }, - "query_detail": { - "description": "查询某个模型配置的详细信息。", - "required_slots": ["target_ref"], - "goal": "读取一个模型配置的详细信息。", - "dynamic_rules": [ - "详情返回中只能暴露 API Key/钱包私钥是否存在,不得返回明文凭证。", - "对于 claw402,应区分三种状态:配置未启用、钱包凭证缺失、钱包余额不足。" - ], - "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_list": "get_model_configs", - "query_detail": "get_model_configs" - } -} diff --git a/agent/skills/strategy_diagnosis.json b/agent/skills/strategy_diagnosis.json deleted file mode 100644 index 0e1eb8ec..00000000 --- a/agent/skills/strategy_diagnosis.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "strategy_diagnosis", - "kind": "diagnosis", - "domain": "strategy", - "description": "当用户反馈策略未生效、候选币为空、策略输出异常、提示词或配置结果与预期不一致、AI 一直 hold/wait、策略执行表现异常时调用。适用于策略内容、候选币、风控边界和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。", - "capabilities": [ - "区分策略模板配置问题、交易员绑定问题、市场数据/候选币问题、AI 决策为 hold/wait、风控拦截和交易所下单失败", - "解释 AI 策略与网格策略的字段边界、页面范围和 System enforced 字段", - "指出策略模板不能直接运行,必须由交易员绑定后执行" - ], - "dynamic_rules": [ - "策略没生效时,先区分:只是策略模板未被交易员绑定,还是交易员已绑定但运行结果不符合预期。", - "若候选币为空,检查 source_type/static_coins/AI500/OI 榜单/排除币/量化数据开关,不要直接归因为模型问题。", - "若 AI 一直 hold/wait,先检查 min_confidence、min_risk_reward_ratio、提示词是否过于保守、行情是否满足入场条件,再判断是否需要放宽策略。", - "若交易员绑定了策略但没有下单,应与 trader_diagnosis 协作区分策略无信号、风控拦截和交易所下单失败。", - "策略诊断必须区分可编辑策略字段和 System enforced 字段。AI 智能策略里的 max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 只能解释,不能建议用户修改。", - "如果不开单原因来自最小下单金额、保证金或仓位价值边界,不要建议修改 min_position_size 或 position_size_usd;应建议增加账户权益、换更适合小资金的标的、调整可编辑风险偏好或让策略在资金不足时等待。", - "策略页不存在 position_size_usd 这类固定配置项;position_size_usd 是 AI 每轮决策输出,不是策略模板字段。不要把 AI 决策里的 position_size_usd 说成可以在策略页手动修改的参数。", - "后台 402/404/EOF 类数据源错误只能作为策略分析质量的辅助影响,不能在决策记录已经显示明确风控/最小金额拒绝时作为主因。", - "策略模板本身不保存交易所、模型、扫描间隔或初始余额;这些问题应引导到 trader/model/exchange 相关诊断。" - ] -} diff --git a/agent/skills/strategy_management.json b/agent/skills/strategy_management.json deleted file mode 100644 index 0f5dfb91..00000000 --- a/agent/skills/strategy_management.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "name": "strategy_management", - "kind": "management", - "domain": "strategy", - "description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。", - "field_constraints": { - "name": { - "type": "string", - "required": true, - "max_length": 50, - "description": "策略模板名称,必填,最多 50 个字符。" - }, - "description": { - "type": "string", - "description": "策略描述,可选。" - }, - "is_public": { - "type": "bool", - "default": false, - "description": "是否发布到策略市场。" - }, - "config_visible": { - "type": "bool", - "default": true, - "description": "发布到市场后,是否允许别人查看策略配置。" - }, - "lang": { - "type": "enum", - "values": ["zh", "en"], - "default": "zh", - "description": "策略语言,zh 或 en,影响 AI 决策时使用的语言。" - }, - "strategy_type": { - "type": "enum", - "values": ["ai_trading", "grid_trading"], - "description": "策略类型:ai_trading(AI 量化)或 grid_trading(网格策略)。创建策略时必须先由用户选择或从用户话语明确识别,不能默认成 ai_trading。" - }, - "symbol": { - "type": "enum", - "values": ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"], - "description": "网格策略页面交易对下拉选项,只能从 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。用户问“交易对有哪些选项”时,直接列出这些选项。" - }, - "source_type": { - "type": "enum", - "values": ["static", "ai500", "oi_top", "oi_low"], - "description": "选币来源类型。static=用户指定静态币池,ai500=AI500榜单,oi_top=持仓量增长,oi_low=持仓量下降。" - }, - "static_coins": { - "type": "string_array", - "max_items": 10, - "description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"],source_type=static 时使用,手动页面最多 10 个。页面支持常规合约币种,也支持 xyz: 前缀资产(如 xyz:TSLA、xyz:GOLD、xyz:XYZ100)。" - }, - "excluded_coins": { - "type": "string_array", - "description": "排除币列表,所有来源均会排除这些币。" - }, - "primary_timeframe": { - "type": "string", - "values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"], - "description": "主 K 线周期,例如 5m、15m、1h。" - }, - "selected_timeframes": { - "type": "string_array", - "values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"], - "max_items": 4, - "description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"];手动页面最多选择 4 个。" - }, - "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。" - }, - "min_confidence": { - "type": "int", - "min": 50, - "max": 100, - "description": "最小开仓置信度,手动页面范围 50~100,数值越高开单越谨慎。" - }, - "min_risk_reward_ratio": { - "type": "float", - "min": 1, - "max": 10, - "description": "最小盈亏比,手动页面范围 1~10,步进 0.5;例如 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": 5, - "max": 50, - "description": "网格数量,grid_trading 类型专用,手动页面范围 5~50。" - }, - "total_investment": { - "type": "float", - "min": 100, - "description": "网格总投入金额,grid_trading 类型专用,表示用户实际投入/保证金预算,不是杠杆后的名义仓位;名义仓位约等于 total_investment × leverage。手动页面最小 100 USDT,步进 100。" - }, - "leverage": { - "type": "int", - "min": 1, - "max": 5, - "description": "网格策略杠杆倍数,手动页面当前范围 1~5。" - }, - "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": 1, - "max": 5, - "description": "ATR 边界倍数,use_atr_bounds=true 时使用,手动页面范围 1~5,步进 0.5。" - }, - "enable_direction_adjust": { - "type": "bool", - "default": false, - "description": "是否启用方向偏置调整。" - }, - "direction_bias_ratio": { - "type": "float", - "min": 0.55, - "max": 0.9, - "description": "方向偏置比例,决定多空倾向强弱;手动页面范围 0.55~0.90,通常以 55%~90% 展示。" - }, - "max_drawdown_pct": { - "type": "float", - "min": 5, - "max": 50, - "description": "网格策略最大回撤百分比,手动页面范围 5~50。" - }, - "stop_loss_pct": { - "type": "float", - "min": 1, - "max": 20, - "description": "网格策略止损百分比,手动页面范围 1~20。" - }, - "daily_loss_limit_pct": { - "type": "float", - "min": 1, - "max": 30, - "description": "网格策略每日最大亏损比例,手动页面范围 1~30,达到后当天停止新开仓。" - }, - "use_maker_only": { - "type": "bool", - "default": false, - "description": "是否优先只挂 maker 单。" - }, - "use_ai500": { - "type": "bool", - "default": false, - "description": "是否启用 AI500 榜单作为候选币来源。" - }, - "ai500_limit": { - "type": "int", - "min": 1, - "max": 10, - "description": "AI500 榜单选取数量,手动页面范围 1~10。" - }, - "use_oi_top": { - "type": "bool", - "default": false, - "description": "是否启用 OI Top 作为候选币来源。" - }, - "oi_top_limit": { - "type": "int", - "min": 1, - "max": 10, - "description": "OI Top 选取数量,手动页面范围 1~10。" - }, - "use_oi_low": { - "type": "bool", - "default": false, - "description": "是否启用 OI Low 作为候选币来源。" - }, - "oi_low_limit": { - "type": "int", - "min": 1, - "max": 10, - "description": "OI Low 选取数量,手动页面范围 1~10。" - }, - "primary_count": { - "type": "int", - "min": 10, - "max": 30, - "description": "主周期 K 线样本数量,手动页面范围 10~30。" - }, - "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": "enum", - "values": ["1h", "4h", "24h"], - "description": "OI 排行榜统计周期,页面选项为 1h、4h、24h。" - }, - "oi_ranking_limit": { - "type": "int", - "min": 5, - "max": 20, - "description": "OI 排行榜返回数量,页面选项为 5、10、15、20。" - }, - "enable_netflow_ranking": { - "type": "bool", - "default": false, - "description": "是否启用净流入排行榜。" - }, - "netflow_ranking_duration": { - "type": "enum", - "values": ["1h", "4h", "24h"], - "description": "净流入排行榜统计周期,页面选项为 1h、4h、24h。" - }, - "netflow_ranking_limit": { - "type": "int", - "min": 5, - "max": 20, - "description": "净流入排行榜返回数量,页面选项为 5、10、15、20。" - }, - "enable_price_ranking": { - "type": "bool", - "default": false, - "description": "是否启用价格波动排行榜。" - }, - "price_ranking_duration": { - "type": "enum", - "values": ["1h", "4h", "24h", "1h,4h,24h"], - "description": "价格排行榜统计周期,页面选项为 1h、4h、24h、1h,4h,24h。" - }, - "price_ranking_limit": { - "type": "int", - "min": 5, - "max": 20, - "description": "价格排行榜返回数量,页面选项为 5、10、15、20。" - } - }, - "validation_rules": [ - "本 skill 只负责策略模板创建、查看、修改、删除、激活和复制。", - "字段选项和范围来自 field_constraints;产品行为规则由 active session prompt 负责。" - ], - "actions": { - "create": { - "description": "创建策略模板。", - "required_slots": ["name"], - "optional_slots": ["strategy_type", "config_patch"], - "goal": "创建一个可供 trader 绑定使用的策略模板。", - "success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。", - "failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。" - }, - "update": { - "description": "更新策略模板的任意可编辑字段。", - "required_slots": ["target_ref"], - "optional_slots": ["name", "description", "is_public", "config_visible", "config_patch"], - "goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。", - "dynamic_rules": [ - "只更新用户明确提到的字段,不要覆盖未提及的字段。", - "杠杆超出 1~20 范围时,自动收敛并告知用户。", - "grid_trading 类型时,lower_price 必须小于 upper_price。" - ], - "success_output": "返回 strategy_id 和更新后的策略摘要。", - "failure_output": "明确指出目标策略不存在、参数非法,或仍缺哪个关键字段。" - }, - "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"], - "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"], - "optional_slots": ["config_patch"], - "goal": "修改策略模板中的一个或一组具体配置参数。", - "dynamic_rules": [ - "配置变更统一通过 config_patch 表达,字段必须来自当前策略类型的产品模板。", - "字段选项、范围和非策略字段拦截由 active session prompt 与后端 schema 负责。" - ], - "success_output": "返回 strategy_id,并明确告知已修改的配置字段及其最终值。", - "failure_output": "明确指出目标策略不存在、配置字段非法,或值仍需用户澄清。" - }, - "activate": { - "description": "将策略模板设为默认模板(激活)。", - "required_slots": ["target_ref"], - "goal": "将某个策略模板设为默认模板。", - "success_output": "返回 strategy_id,并明确告知该策略已被设为默认模板。", - "failure_output": "明确指出目标策略不存在,或激活失败原因。" - }, - "duplicate": { - "description": "复制策略模板,生成一个新的同配置模板。", - "required_slots": ["target_ref", "name"], - "goal": "复制一个现有策略模板并生成新的模板名称。", - "dynamic_rules": [ - "新名称必须单独收齐;若名称有歧义或为空,应先继续追问。" - ], - "success_output": "返回新的 strategy_id,并明确告知复制后的策略名称。", - "failure_output": "明确指出目标策略不存在,或新名称仍未收齐。" - }, - "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", - "update_name": "manage_strategy:update", - "update_prompt": "manage_strategy:update", - "update_config": "manage_strategy:update", - "activate": "manage_strategy:activate", - "duplicate": "manage_strategy:duplicate", - "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 deleted file mode 100644 index 9adfe7f9..00000000 --- a/agent/skills/trade_execution.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "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;这是系统强制项,不允许通过 Agent 修改。低于最小值时直接拒绝。", - "创建后的待确认订单默认 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 deleted file mode 100644 index 4e9c96e3..00000000 --- a/agent/skills/trader_diagnosis.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "trader_diagnosis", - "kind": "diagnosis", - "domain": "trader", - "description": "当用户反馈交易员无法启动、启动后不交易、反复报错、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。", - "capabilities": [ - "读取交易员当前状态、账户、持仓和最近决策记录", - "读取交易员绑定的策略、模型、交易所配置摘要,并把它们纳入不开单诊断证据包", - "在用户明确指定目标交易员后,读取该交易员最近的后端日志", - "把完整证据合并成适合新手理解的最终原因和下一步行动" - ], - "dynamic_rules": [ - "当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。", - "如果已经能唯一确定目标交易员,应一次性收集完整诊断证据包:交易员配置/运行状态、绑定策略、绑定模型、绑定交易所、账户权益/可用余额、当前持仓、get_decisions 最近决策记录、get_backend_logs 后台日志。不要只查其中一项就下结论。", - "面向普通用户的诊断回复只说最终原因和该怎么办,不要输出证据包清单、工具名、后台日志片段、HTTP 状态码或工程排障过程。", - "诊断结论内部必须区分:直接原因、次要影响、待确认因素。直接原因必须来自最近决策记录、交易所下单结果、风控校验或明确运行状态;后台日志里的零散错误只能作为辅助证据。", - "证据优先级固定为:最近决策记录 > 交易员运行状态/账户/持仓 > 交易所下单结果 > 后台日志。除非最近决策记录本身显示数据获取失败或 AI 决策中断,否则不要让 backend logs 盖过决策记录。", - "交易员不下单的排查顺序固定为:是否运行中 -> 是否已到扫描间隔 -> 策略候选币/行情数据是否为空 -> 最近 AI 决策是否为 hold/wait -> 风控是否拦截 -> 交易所下单是否报错 -> 余额、杠杆、仓位模式或权限是否限制。", - "判断“不下单/不开单”的主因时,最近决策记录优先级高于零散 backend error 日志;如果最近决策显示 wait succeeded,应解释为 AI 主动等待;如果最新决策 error_message 显示 opening amount too small / below minimum / must be ≥,应解释为开仓金额低于系统或交易所最小下单门槛。", - "遇到 opening amount too small、position value below minimum、must be ≥ 这类错误时,不要建议用户修改 AI 智能策略的 min_position_size 或 position_size_usd。先说明这是系统/交易所门槛或 System enforced 边界,再建议增加账户权益、换更适合小资金的交易标的、调整可编辑策略偏好,或让策略在资金不足时等待。", - "AI 智能策略里的 System enforced 字段(max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size)只能解释,不能建议用户修改;如果限制来自这些字段,行动建议必须落在产品实际可改项或用户账户/标的选择上。", - "不要只因为 backend logs 里出现 402、404、EOF、payment retry failed 就直接归因为数据服务、订阅到期或付款失败;这些内部异常不应在普通用户回答里出现,除非用户明确追问后台日志或技术细节。", - "402 不要直接翻译成“订阅到期”。在没有钱包余额、支付状态或服务侧确认前,不能说订阅过期;普通用户回答里也不要主动说 402。", - "如果最近决策记录显示 candidate_coins 非空、AI call completed、wait succeeded 或 open_* 决策已生成,则说明核心决策链路并非完全拿不到数据;此时不要把 402/404/EOF 说成不开单主因。", - "行动建议必须对应产品里真实存在且可修改的字段或操作。不要编造策略页不存在的 position_size_usd 参数,不要建议修改 System enforced 字段。", - "如果模型是 claw402 或 blockrun-base,应单独检查钱包 USDC 余额;余额不足时应说“支付余额不足/需要充值”,不要泛化成“模型没启用”。", - "如果日志显示 AI 返回 hold/wait,应解释为模型判断当前没有足够交易信号,不应误判为系统没有运行。", - "如果日志显示下单失败,应优先归因到交易所权限、API 凭证、仓位模式、余额、杠杆或 symbol 可交易性,而不是策略没有生效。", - "当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 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 deleted file mode 100644 index cdff2e11..00000000 --- a/agent/skills/trader_management.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "name": "trader_management", - "kind": "management", - "domain": "trader", - "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略。编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。", - "intents": [ - "创建交易员", - "修改交易员", - "删除交易员", - "启动交易员", - "停止交易员", - "查询交易员" - ], - "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 分钟。超出范围会自动收敛到边界值并告知用户。" - }, - "is_cross_margin": { - "type": "bool", - "default": true, - "description": "保证金模式。true = 全仓(cross margin),false = 逐仓(isolated margin)。" - }, - "show_in_competition": { - "type": "bool", - "default": true, - "description": "是否在竞技场中显示该交易员的成绩。" - }, - "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 告知用户已调整,询问是否接受。", - "交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。", - "交易员名称不能从模型 provider 自动推断;用户说“用 claw402”表示模型选择,不表示交易员名称叫 claw402。", - "用户明确指定模型、交易所或策略时,若该资源不存在、被禁用、配置不完整或钱包余额不足,必须说明具体原因并让用户确认修复或替换;不得静默换成另一个资源。", - "若用户指定 claw402 作为模型,但 claw402 钱包余额为 0 USDC,应提示先充值或确认临时改用其他可用模型;不得说成 claw402 未启用,除非 enabled 确实为 false。", - "启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。", - "若绑定的是 OKX 交易所,启用前必须已有 passphrase;若绑定的是 Hyperliquid,启用前必须已有 wallet_addr;若绑定的是 Aster,启用前必须已有 user、signer、private_key;若绑定的是 Lighter,启用前必须已有 wallet_addr 和 api_key_private_key。", - "启动(start)和停止(stop)操作属于高风险操作,必须先向用户确认再执行。", - "删除(delete)操作不可逆,必须先向用户确认再执行。" - ], - "actions": { - "create": { - "description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。", - "required_slots": ["name", "exchange", "model", "strategy"], - "optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], - "goal": "创建并初始化一个交易员。", - "dynamic_rules": [ - "若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。", - "如果用户明确指定某个模型 provider(如 claw402),应先尝试匹配该 provider 对应的模型配置;只有在说明原因并得到用户确认后,才可改用其他模型。", - "若用户没有提供交易员名称,应生成一个来自交易所/策略/方向的清晰名称,或向用户追问;不要把模型 provider、交易所类型或策略字段误用为交易员名称。", - "若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。", - "子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。", - "scan_interval_minutes 超出 3~60 时,自动收敛并告知用户。", - "不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。", - "创建完成后询问用户是否立即启动(auto_start),启动前再次确认。" - ], - "success_output": "返回 trader_id,并给出创建结果摘要(名称、绑定的交易所/模型/策略、是否已启动)。", - "failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。" - }, - "update": { - "description": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。", - "required_slots": ["target_ref"], - "optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], - "goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。", - "dynamic_rules": [ - "只更新用户明确提到的字段,不要覆盖未提及的字段。", - "换绑交易所/模型/策略时,新的资源必须已存在且已启用;若是钱包付费模型,还要解释余额不足等支付状态。", - "用户明确要求换成某个模型/交易所/策略时,不能自动选择另一个看起来可用的资源,除非用户确认。", - "如果用户要求改名,应明确告知交易员改名不在这里处理。", - "如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update;应切到对应 management skill。" - ], - "success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。", - "failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。" - }, - "update_bindings": { - "description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。", - "required_slots": ["target_ref"], - "optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"], - "goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。", - "dynamic_rules": [ - "新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。", - "当指定模型是 claw402 或 blockrun-base 且钱包余额不足时,应提示充值或让用户确认临时切换模型。", - "扫描间隔超出 3~60 时,自动收敛并告知用户。" - ], - "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": [ - "新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。", - "若用户指定的是 claw402,应优先绑定 claw402;只有在钱包余额不足、凭证缺失或配置不可用且用户确认后,才允许改绑其他模型。" - ], - "success_output": "返回 trader_id,并明确告知当前生效的 ai_model_id/模型名称。", - "failure_output": "明确指出目标交易员或模型不存在,或模型当前不可用。" - }, - "start": { - "description": "启动交易员,使其开始自动交易。高风险操作,必须确认。", - "required_slots": ["target_ref"], - "needs_confirmation": true, - "goal": "让一个已配置好的交易员进入运行状态。", - "dynamic_rules": [ - "启动前系统会自动校验绑定的交易所、模型、策略是否均可用。", - "若绑定模型为 claw402 或 blockrun-base 且钱包余额不足,应提示充值或换模型;不要把它泛化成“模型不可用”。", - "若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。" - ], - "success_output": "返回 trader_id,并明确告知交易员已开始运行。", - "failure_output": "明确指出缺少确认、依赖资源不可用,或启动未通过校验。" - }, - "stop": { - "description": "停止交易员,使其停止自动交易。高风险操作,必须确认。", - "required_slots": ["target_ref"], - "needs_confirmation": true, - "goal": "让一个运行中的交易员停止自动交易。", - "dynamic_rules": [ - "若交易员当前并未运行,也应给用户清晰说明,而不是假装停止成功。" - ], - "success_output": "返回 trader_id,并明确告知交易员已停止。", - "failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。" - }, - "delete": { - "description": "删除交易员,不可逆操作,必须确认。支持删除单个、多个或全部交易员。", - "required_slots": [], - "needs_confirmation": true, - "goal": "删除一个、多个或全部交易员及其运行入口。", - "dynamic_rules": [ - "必须在确认后执行,并明确提醒该操作不可逆。", - "删除范围可以是单个 target_ref、多个目标,或 bulk_scope=all。", - "删除前必须确认目标交易员都已停止;若存在运行中的交易员,不能删除,应要求用户先停止这些交易员。" - ], - "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", - "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", - "delete": "manage_trader:delete", - "query_list": "manage_trader:list", - "query_running": "manage_trader:list", - "query_detail": "manage_trader:list" - } -} diff --git a/agent/stock.go b/agent/stock.go deleted file mode 100644 index 250e7d8c..00000000 --- a/agent/stock.go +++ /dev/null @@ -1,444 +0,0 @@ -package agent - -import ( - "nofx/safe" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "golang.org/x/text/encoding/simplifiedchinese" - "golang.org/x/text/transform" -) - -// stockHTTPClient is a shared HTTP client for stock API requests. -// Reused across calls for connection pooling. -var stockHTTPClient = &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 5, - IdleConnTimeout: 90 * time.Second, - }, -} - -// StockQuote holds real-time stock data. -type StockQuote struct { - Name string - Code string - Market string // "A股", "港股", "美股" - Currency string // "CNY", "HKD", "USD" - Open float64 - PrevClose float64 - Price float64 - High float64 - Low float64 - Volume float64 - Turnover float64 - Date string - Time string - Change float64 - ChangePct float64 - // 盘前盘后 (美股) - ExtPrice float64 // 盘前/盘后价格 - ExtChangePct float64 // 盘前/盘后涨跌幅% - ExtChange float64 // 盘前/盘后涨跌额 - ExtTime string // 盘前/盘后时间 - IsExtHours bool // 是否在盘前盘后时段 -} - -// knownStocks maps Chinese names to stock codes. -var knownStocks = map[string]string{ - // A股 - "拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750", - "贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036", - "中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939", - "中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030", - "海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059", - "隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230", - "三六零": "sh601360", "中兴通讯": "sz000063", - // 港股 - "腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690", - "小米": "hk01810", "京东": "hk09618", "网易": "hk09999", - "百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626", - "理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868", - // 华为 is not publicly listed — removed incorrect Tencent fallback - // 美股 - "苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda", - "微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn", - "meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm", - "拼多多": "gb_pdd", "蔚来汽车": "gb_nio", -} - -// US stock ticker mapping -var usTickerMap = map[string]string{ - "AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft", - "GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx", - "TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba", - "JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc", - "COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot", -} - -func resolveStockCode(text string) (string, string) { - // Known Chinese names - for name, code := range knownStocks { - if strings.Contains(text, name) { - return code, name - } - } - - // US ticker symbols (uppercase) - upper := strings.ToUpper(text) - for ticker, code := range usTickerMap { - if strings.Contains(upper, ticker) { - return code, ticker - } - } - - // 6-digit A-share code - for _, w := range strings.Fields(text) { - w = strings.TrimSpace(w) - if len(w) == 6 { - if _, err := strconv.Atoi(w); err == nil { - prefix := "sz" - if w[0] == '6' || w[0] == '9' { prefix = "sh" } - return prefix + w, w - } - } - // 5-digit HK code - if len(w) == 5 { - if _, err := strconv.Atoi(w); err == nil { - return "hk" + w, w - } - } - } - - return "", "" -} - -// SearchResult represents a stock search result from Sina suggest API. -type SearchResult struct { - Name string // Display name - Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla) - Ticker string // Raw ticker (e.g. 300750, 00700, tsla) - Type string // Market type code: 11=A股, 31=港股, 41=美股 - Market string // "A股", "港股", "美股" -} - -// searchStock queries Sina's suggest API for dynamic stock search. -// Returns matching stocks across A-share, HK, and US markets. -func searchStock(keyword string) ([]SearchResult, error) { - // type=11 (A股), 31 (港股), 41 (美股) - u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata", - url.QueryEscape(keyword)) - - req, _ := http.NewRequest("GET", u, nil) - req.Header.Set("Referer", "https://finance.sina.com.cn") - - resp, err := stockHTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode) - } - - reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder()) - body, err := safe.ReadAllLimited(reader) - if err != nil { - return nil, err - } - - line := string(body) - // Parse: var suggestdata="item1;item2;..." - start := strings.Index(line, "\"") - end := strings.LastIndex(line, "\"") - if start == -1 || end <= start { - return nil, fmt.Errorf("invalid suggest response") - } - data := line[start+1 : end] - if data == "" { - return nil, nil // no results - } - - var results []SearchResult - items := strings.Split(data, ";") - for _, item := range items { - item = strings.TrimSpace(item) - if item == "" { - continue - } - fields := strings.Split(item, ",") - if len(fields) < 5 { - continue - } - // fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName - typeCode := fields[1] - ticker := fields[2] - sinaCode := fields[3] - displayName := fields[4] - if displayName == "" { - displayName = fields[0] - } - - var mkt, code string - switch typeCode { - case "11": // A股 - mkt = "A股" - code = sinaCode // already like sz300750, sh600519 - if code == "" { - // Build from ticker - prefix := "sz" - if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') { - prefix = "sh" - } - code = prefix + ticker - } - case "31": // 港股 - mkt = "港股" - code = "hk" + ticker - case "41": // 美股 - mkt = "美股" - code = "gb_" + ticker - default: - continue // skip funds (201), indices, etc. - } - - results = append(results, SearchResult{ - Name: displayName, - Code: code, - Ticker: ticker, - Type: typeCode, - Market: mkt, - }) - } - - return results, nil -} - -// resolveStockCodeDynamic tries local map first, then falls back to Sina search API. -func resolveStockCodeDynamic(text string) (string, string) { - // First try the static map - code, name := resolveStockCode(text) - if code != "" { - return code, name - } - - // Fall back to Sina search API - // Extract a meaningful search keyword from the text - keyword := extractStockKeyword(text) - if keyword == "" { - return "", "" - } - - results, err := searchStock(keyword) - if err != nil || len(results) == 0 { - return "", "" - } - - // Return the first (best) result - return results[0].Code, results[0].Name -} - -// extractStockKeyword extracts a likely stock name/ticker from user text. -func extractStockKeyword(text string) string { - // Remove common prefixes/suffixes that aren't stock names - text = strings.TrimSpace(text) - - // If the text itself is short enough, use it directly - // (e.g. "中远海控" or "AAPL") - if len([]rune(text)) <= 10 { - return text - } - - // Try to extract quoted terms first: 「xxx」 or "xxx" - quotePairs := [][2]string{ - {"「", "」"}, - {"\u201c", "\u201d"}, - {"\u2018", "\u2019"}, - {"\"", "\""}, - } - for _, pair := range quotePairs { - if s := strings.Index(text, pair[0]); s >= 0 { - if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 { - return text[s+len(pair[0]) : s+len(pair[0])+e] - } - } - } - - // Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX" - for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} { - if idx := strings.Index(text, prefix); idx >= 0 { - rest := strings.TrimSpace(text[idx+len(prefix):]) - // Take the first "word" (either Chinese characters or English word) - words := strings.Fields(rest) - if len(words) > 0 { - return words[0] - } - } - } - - // Last resort: use first few words - words := strings.Fields(text) - if len(words) > 0 { - return words[0] - } - - return "" -} - -func fetchStockQuote(code string) (*StockQuote, error) { - url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Referer", "https://finance.sina.com.cn") - - resp, err := stockHTTPClient.Do(req) - if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode) - } - - reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder()) - body, err := safe.ReadAllLimited(reader) - if err != nil { return nil, err } - - line := string(body) - start := strings.Index(line, "\"") - end := strings.LastIndex(line, "\"") - if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") } - - data := line[start+1 : end] - if data == "" { return nil, fmt.Errorf("empty data for %s", code) } - - if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") { - return parseAShare(code, data) - } else if strings.HasPrefix(code, "hk") { - return parseHKShare(code, data) - } else if strings.HasPrefix(code, "gb_") { - return parseUSShare(code, data) - } - - return nil, fmt.Errorf("unsupported market: %s", code) -} - -func parseAShare(code, data string) (*StockQuote, error) { - f := strings.Split(data, ",") - if len(f) < 32 { return nil, fmt.Errorf("too few fields") } - - q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"} - q.Open, _ = strconv.ParseFloat(f[1], 64) - q.PrevClose, _ = strconv.ParseFloat(f[2], 64) - q.Price, _ = strconv.ParseFloat(f[3], 64) - q.High, _ = strconv.ParseFloat(f[4], 64) - q.Low, _ = strconv.ParseFloat(f[5], 64) - q.Volume, _ = strconv.ParseFloat(f[8], 64) - q.Turnover, _ = strconv.ParseFloat(f[9], 64) - q.Date = f[30]; q.Time = f[31] - if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 } - return q, nil -} - -func parseHKShare(code, data string) (*StockQuote, error) { - f := strings.Split(data, ",") - if len(f) < 18 { return nil, fmt.Errorf("too few fields") } - - q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"} - q.PrevClose, _ = strconv.ParseFloat(f[3], 64) - q.Open, _ = strconv.ParseFloat(f[2], 64) - q.High, _ = strconv.ParseFloat(f[4], 64) - q.Low, _ = strconv.ParseFloat(f[5], 64) - q.Price, _ = strconv.ParseFloat(f[6], 64) - q.Change, _ = strconv.ParseFloat(f[7], 64) - q.ChangePct, _ = strconv.ParseFloat(f[8], 64) - q.Turnover, _ = strconv.ParseFloat(f[10], 64) - q.Volume, _ = strconv.ParseFloat(f[11], 64) - if len(f) > 17 { q.Date = f[17]; q.Time = f[17] } - return q, nil -} - -func parseUSShare(code, data string) (*StockQuote, error) { - f := strings.Split(data, ",") - if len(f) < 30 { return nil, fmt.Errorf("too few fields") } - - q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"} - q.Price, _ = strconv.ParseFloat(f[1], 64) - q.ChangePct, _ = strconv.ParseFloat(f[2], 64) - q.Change, _ = strconv.ParseFloat(f[4], 64) - q.Open, _ = strconv.ParseFloat(f[5], 64) - q.High, _ = strconv.ParseFloat(f[6], 64) - q.Low, _ = strconv.ParseFloat(f[7], 64) - // 52wk high/low - high52, _ := strconv.ParseFloat(f[8], 64) - low52, _ := strconv.ParseFloat(f[9], 64) - q.Volume, _ = strconv.ParseFloat(f[10], 64) - q.Turnover, _ = strconv.ParseFloat(f[11], 64) - if len(f) > 25 { q.Date = f[25]; q.Time = f[26] } - q.PrevClose = q.Price - q.Change - _ = high52; _ = low52 - - // 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间) - if len(f) > 24 { - extPrice, _ := strconv.ParseFloat(f[21], 64) - extPct, _ := strconv.ParseFloat(f[22], 64) - extChg, _ := strconv.ParseFloat(f[23], 64) - if extPrice > 0 { - q.ExtPrice = extPrice - q.ExtChangePct = extPct - q.ExtChange = extChg - q.ExtTime = strings.TrimSpace(f[24]) - q.IsExtHours = true - } - } - - return q, nil -} - -func formatStockQuote(q *StockQuote) string { - emoji := "🟢" - if q.ChangePct < 0 { emoji = "🔴" } - - sym := "¥" - if q.Currency == "USD" { sym = "$" } - if q.Currency == "HKD" { sym = "HK$" } - - volStr := fmt.Sprintf("%.0f", q.Volume) - if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) } - if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) } - - turnStr := fmt.Sprintf("%.0f", q.Turnover) - if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) } - - result := fmt.Sprintf(`%s *%s* (%s · %s) -💰 现价: %s%.2f (%+.2f%%) -📊 开盘: %s%.2f | 昨收: %s%.2f -📈 最高: %s%.2f | 最低: %s%.2f -📦 成交: %s | 额: %s -🕐 %s`, - emoji, q.Name, q.Code, q.Market, - sym, q.Price, q.ChangePct, - sym, q.Open, sym, q.PrevClose, - sym, q.High, sym, q.Low, - volStr, turnStr, - q.Date) - - // 盘前盘后数据 - if q.IsExtHours && q.ExtPrice > 0 { - extEmoji := "🟢" - if q.ExtChangePct < 0 { extEmoji = "🔴" } - extLabel := "🌙 盘后" - if strings.Contains(strings.ToLower(q.ExtTime), "am") { - extLabel = "🌅 盘前" - } - result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s", - extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime) - } - - return result -} diff --git a/agent/strategy_draft.go b/agent/strategy_draft.go deleted file mode 100644 index 5089ed61..00000000 --- a/agent/strategy_draft.go +++ /dev/null @@ -1,27 +0,0 @@ -package agent - -import ( - "strings" -) - -func inferStandaloneStrategyName(text string) string { - value := strings.TrimSpace(text) - if value == "" || len([]rune(value)) > 50 { - return "" - } - if strategyCreateConfirmationReply(value) || strategyCreateDefaultConfigReply(value) || isCancelSkillReply(value) { - return "" - } - if parseStrategyTypeValue(value) != "" { - return "" - } - if containsAny(strings.ToLower(value), []string{"创建", "新建", "create", "grid_trading", "ai_trading"}) { - return "" - } - return value -} - -func activeHistoryMessageAsksStrategyName(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"}) -} diff --git a/agent/strategy_field_catalog.go b/agent/strategy_field_catalog.go deleted file mode 100644 index d21589fb..00000000 --- a/agent/strategy_field_catalog.go +++ /dev/null @@ -1,224 +0,0 @@ -package agent - -func manualStrategyEditableFieldKeys() []string { - return []string{ - "name", - "description", - "is_public", - "config_visible", - "strategy_type", - "symbol", - "grid_count", - "total_investment", - "leverage", - "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", - "min_risk_reward_ratio", - "min_confidence", - "role_definition", - "trading_frequency", - "entry_standards", - "decision_process", - "custom_prompt", - } -} - -func manualStrategyEditableFieldKeysForType(strategyType string) []string { - common := []string{ - "name", - "description", - "is_public", - "config_visible", - "strategy_type", - } - switch strategyType { - case "grid_trading": - return append(common, - "symbol", - "grid_count", - "total_investment", - "leverage", - "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", - ) - case "ai_trading": - return append(common, - "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", - "min_risk_reward_ratio", - "min_confidence", - "role_definition", - "trading_frequency", - "entry_standards", - "decision_process", - "custom_prompt", - ) - default: - return manualStrategyEditableFieldKeys() - } -} - -func agentStrategyUpdatableFieldKeys() []string { - return []string{ - "name", - "description", - "is_public", - "config_visible", - "strategy_type", - "symbol", - "grid_count", - "total_investment", - "leverage", - "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", - "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 deleted file mode 100644 index f57e8f52..00000000 --- a/agent/stream_text.go +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 40f5b775..00000000 --- a/agent/tools.go +++ /dev/null @@ -1,3748 +0,0 @@ -package agent - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "nofx/kernel" - "nofx/mcp" - "nofx/safe" - "nofx/security" - "nofx/store" - "nofx/trader" - "nofx/trader/aster" - "nofx/trader/binance" - "nofx/trader/bitget" - "nofx/trader/bybit" - "nofx/trader/gate" - hyperliquidtrader "nofx/trader/hyperliquid" - "nofx/trader/indodax" - "nofx/trader/kucoin" - "nofx/trader/lighter" - "nofx/trader/okx" -) - -// cachedTools holds the static tool definitions (built once, reused per message). -var cachedTools = buildAgentTools() - -var ( - binanceFuturesAPIBaseURL = "https://fapi.binance.com" - marketDataHTTPClient = http.DefaultClient - traderInitialBalanceFetcher = defaultTraderInitialBalanceFetcher -) - -// agentTools returns the tools available to the LLM for autonomous action. -func agentTools() []mcp.Tool { return cachedTools } - -// plannerToolsForText returns the tools the LLM can call on this turn. -// -// Historically this filtered tools to a "domain" inferred from the user's -// text (asking about "market" hid trader tools, etc.). The intent was to -// keep prompts small for older models, but it made cross-domain reasoning -// structurally impossible — e.g. "BTC dropped, how much am I losing?" needs -// BOTH market AND position tools. Modern LLMs handle 22-tool surfaces fine, -// and the agent-feels-blind-and-useless symptom is worse than any prompt -// bloat. We now always expose the full toolset. -// -// `compactStrategy` still trims the giant strategy schema for non-mutation -// intents (it's a 117-line nested schema; only worth showing in full when -// the user is actually editing strategy config). -func plannerToolsForText(text string) []mcp.Tool { - compactStrategy := !looksLikeStrategyMutationIntent(text) - names := plannerToolNamesForDomain("__all__") - return toolsByName(names, compactStrategy) -} - -func plannerToolDomainForText(text string) string { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return "general" - } - if containsAny(lower, []string{"诊断", "排查", "为什么", "为啥", "失败", "报错", "异常", "停止", "没下单", "failed", "error", "diagnose", "debug", "logs", "stopped", "not trading"}) { - return "diagnosis" - } - if hasExplicitManagementDomainCue(text, "exchange") || containsAny(lower, []string{"交易所", "exchange", "apikey", "secret", "passphrase", "wallet address", "api凭证"}) { - return "exchange" - } - if hasExplicitManagementDomainCue(text, "model") || containsAny(lower, []string{"ai model", "模型", "provider", "api key", "custom_model", "custom api"}) { - return "model" - } - if hasExplicitManagementDomainCue(text, "strategy") || containsAny(lower, []string{"策略", "strategy", "选币", "止盈", "止损", "杠杆", "风控", "risk control"}) { - return "strategy" - } - if hasExplicitManagementDomainCue(text, "trader") || containsAny(lower, []string{"交易员", "trader", "启动", "停止交易员", "扫描间隔", "竞技场"}) { - return "trader" - } - if containsAny(lower, []string{"余额", "资产", "仓位", "持仓", "订单", "成交", "交易历史", "balance", "position", "positions", "trade history", "account", "钱包", "wallet"}) { - return "account" - } - if containsAny(lower, []string{"行情", "价格", "k线", "kline", "market", "price", "btc", "eth", "sol", "usdt", "股票", "stock"}) { - return "market" - } - return "general" -} - -func plannerToolNamesForDomain(domain string) []string { - // Full toolset — exposed in every turn so the LLM can cross-domain reason. - // The `__all__` sentinel is the canonical "give me everything" entry; older - // domain switches are kept for callers that explicitly request a subset. - all := []string{ - // Account / lifecycle state - "get_preferences", "manage_preferences", - "get_decisions", "get_backend_logs", - "get_exchange_configs", "manage_exchange_config", - "get_model_configs", "manage_model_config", - "get_strategies", "manage_strategy", - "manage_trader", - "get_balance", "get_positions", "get_trade_history", - "get_candidate_coins", "get_ai500_list", - "get_watchlist", "manage_watchlist", - // Trade execution - "execute_trade", - // Market data - "get_market_snapshot", "get_market_price", "get_kline", "search_stock", - } - switch domain { - case "__all__", "": - return all - case "market": - return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock", "get_ai500_list"} - case "account": - return []string{"get_balance", "get_positions", "get_trade_history", "get_exchange_configs"} - case "trader": - return []string{"get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"} - case "model": - return []string{"get_model_configs", "manage_model_config"} - case "exchange": - return []string{"get_exchange_configs", "manage_exchange_config"} - case "strategy": - return []string{"get_strategies", "manage_strategy"} - case "diagnosis": - return []string{"get_decisions", "get_backend_logs", "get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"} - default: - return all - } -} - -func toolsByName(names []string, compactStrategy bool) []mcp.Tool { - if len(names) == 0 { - return nil - } - byName := make(map[string]mcp.Tool, len(cachedTools)) - for _, tool := range cachedTools { - byName[tool.Function.Name] = tool - } - out := make([]mcp.Tool, 0, len(names)) - seen := make(map[string]bool, len(names)) - for _, name := range names { - if seen[name] { - continue - } - seen[name] = true - tool, ok := byName[name] - if !ok { - continue - } - if compactStrategy && name == "manage_strategy" { - tool = compactManageStrategyTool(tool) - } - out = append(out, tool) - } - return out -} - -func compactManageStrategyTool(tool mcp.Tool) mcp.Tool { - tool.Function.Description = "List, query, delete, activate, duplicate, create, or update strategy templates. Planning schema is compact; use action plus strategy_id/name/description/lang/is_public/config_visible, and include config only when the user explicitly provides strategy config fields." - tool.Function.Parameters = map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{"type": "string", "enum": []string{"list", "create", "update", "delete", "activate", "duplicate", "get_default_config"}}, - "strategy_id": map[string]any{"type": "string"}, - "name": map[string]any{"type": "string"}, - "description": map[string]any{"type": "string"}, - "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": "Strategy config patch. Use precise field paths/objects from the user request; omit when listing/querying/deleting/activating/duplicating."}, - }, - "required": []string{"action"}, - } - return tool -} - -func looksLikeStrategyMutationIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - return hasExplicitManagementDomainCue(text, "strategy") && - containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "修改", "更新", "编辑", "调整", "配置", "create", "new", "update", "edit", "configure"}) -} - -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) findModelByProvider(storeUserID, provider string) (*store.AIModel, error) { - models, err := a.store.AIModel().List(storeUserID) - if err != nil { - return nil, err - } - normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) - for _, model := range models { - if model == nil { - continue - } - if strings.ToLower(strings.TrimSpace(model.Provider)) == normalizedProvider { - return model, nil - } - } - return nil, 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"}, "description": "Top-level discriminator. ai_trading must use ai_config only. grid_trading must use grid_config only."}, - "language": map[string]any{"type": "string", "enum": []string{"zh", "en"}}, - "ai_config": map[string]any{ - "type": "object", - "description": "AI trading only. Do not include this for grid_trading.", - "properties": map[string]any{ - "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"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low."}, - "static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."), - "excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."), - "use_ai500": map[string]any{"type": "boolean"}, - "ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."}, - "use_oi_top": map[string]any{"type": "boolean"}, - "oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."}, - "use_oi_low": map[string]any{"type": "boolean"}, - "oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."}, - }, - }, - "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", "enum": []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}}, - "primary_count": map[string]any{"type": "number", "minimum": 10, "maximum": 30, "description": "Manual page range 10-30."}, - "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. Allowed values: 1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w. Manual page allows at most 4."), - }, - }, - "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", "enum": []string{"1h", "4h", "24h"}}, - "oi_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}}, - "enable_netflow_ranking": map[string]any{"type": "boolean"}, - "netflow_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}}, - "netflow_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}}, - "enable_price_ranking": map[string]any{"type": "boolean"}, - "price_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h", "1h,4h,24h"}}, - "price_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}}, - }, - }, - "custom_prompt": map[string]any{"type": "string"}, - "risk_control": map[string]any{ - "type": "object", - "properties": map[string]any{ - "btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20}, - "altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20}, - "min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."}, - "min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."}, - }, - }, - "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{ - "description": "Grid trading only. Do not include this for ai_trading.", - "type": "object", - "properties": map[string]any{ - "symbol": map[string]any{"type": "string", "enum": []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"}, "description": "Manual page dropdown options for grid trading symbols."}, - "grid_count": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."}, - "total_investment": map[string]any{"type": "number", "minimum": 100, "description": "User's actual capital/margin budget for the grid strategy, not leveraged notional exposure. Minimum 100 USDT."}, - "leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "Manual page range 1-5."}, - "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", "minimum": 1, "maximum": 5, "description": "Manual page range 1-5, step 0.5."}, - "distribution": map[string]any{"type": "string", "enum": []string{"uniform", "gaussian", "pyramid"}}, - "max_drawdown_pct": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."}, - "stop_loss_pct": map[string]any{"type": "number", "minimum": 1, "maximum": 20, "description": "Manual page range 1-20."}, - "daily_loss_limit_pct": map[string]any{"type": "number", "minimum": 1, "maximum": 30, "description": "Manual page range 1-30."}, - "use_maker_only": map[string]any{"type": "boolean"}, - "enable_direction_adjust": map[string]any{"type": "boolean"}, - "direction_bias_ratio": map[string]any{"type": "number", "minimum": 0.55, "maximum": 0.9, "description": "Manual page range 0.55-0.90 (shown as 55%-90%)."}, - }, - }, - "publish_config": map[string]any{ - "type": "object", - "description": "Shared publish settings for both AI and grid strategies.", - "properties": map[string]any{ - "is_public": map[string]any{"type": "boolean"}, - "config_visible": map[string]any{"type": "boolean"}, - }, - }, - }, - } -} - -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. Required for create."}, - "ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."}, - "exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."}, - "strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."}, - "scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."}, - "is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."}, - "show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."}, - } -} - -func buildAgentTools() []mcp.Tool { - return []mcp.Tool{ - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_preferences", - Description: "Get all persistent user preferences that the agent should remember long-term.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "manage_preferences", - Description: "Add, update, or delete a persistent user preference. Use this when the user asks to remember something long-term, change an existing long-term preference, or remove one.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "enum": []string{"add", "update", "delete"}, - "description": "What to do with the persistent preference.", - }, - "text": map[string]any{ - "type": "string", - "description": "The new preference text. Required for add and update.", - }, - "match": map[string]any{ - "type": "string", - "description": "How to find the existing preference to update or delete. Can be an id or distinctive text like '每天8点'.", - }, - }, - "required": []string{"action"}, - }, - }, - }, - { - 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. 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."}, - "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."}, - }, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_decisions", - Description: "Get recent AI decision records for a trader diagnosis. Use this before concluding why a trader is not opening orders: it shows candidate coins, wait/hold/open decisions, validation errors, execution logs, and AI call duration.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "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 decision records to return. Default 5, max 20."}, - }, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_exchange_configs", - Description: "Get the user's current exchange account bindings. Returns safe metadata only and whether credentials are already stored.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - 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. 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{ - "action": map[string]any{ - "type": "string", - "enum": []string{"create", "update", "delete"}, - }, - "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"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_model_configs", - Description: "Get the user's current AI model bindings. Returns safe metadata only and whether an API key is already stored.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - 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. 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{ - "action": map[string]any{ - "type": "string", - "enum": []string{"create", "update", "delete"}, - }, - "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"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_strategies", - Description: "Get the user's current strategy templates, including system default strategies available to that user.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - 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. Prefer passing precise field-level config patches in `config` instead of vague natural-language summaries. IMPORTANT: create only requires `name` — every omitted config field is automatically filled from the default template, so do NOT interrogate the user field by field. Flow: build the config from what the user said, present a one-shot summary (including which values were defaulted), and once the user agrees, call create with confirmed=true.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "enum": []string{"list", "create", "update", "delete", "activate", "duplicate", "get_default_config"}, - }, - "strategy_id": map[string]any{"type": "string"}, - "name": map[string]any{"type": "string"}, - "description": map[string]any{"type": "string"}, - "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": strategyConfigSchema(), - }, - "required": []string{"action"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "manage_trader", - Description: "List, create, update, delete, start, or stop traders. Trader edits are limited to exchange/model/strategy bindings, scan interval, margin mode, and competition visibility so they match the manual trader panel. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead. When creating, resolve bindings yourself: call get_exchange_configs / get_model_configs / get_strategies to find matching IDs instead of asking the user for IDs; only ask when a required binding is missing or genuinely ambiguous, and ask for all missing items in one question.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "enum": []string{"list", "create", "update", "delete", "start", "stop"}, - }, - "trader_id": traderConfigFieldsSchema()["trader_id"], - "name": traderConfigFieldsSchema()["name"], - "ai_model_id": traderConfigFieldsSchema()["ai_model_id"], - "exchange_id": traderConfigFieldsSchema()["exchange_id"], - "strategy_id": traderConfigFieldsSchema()["strategy_id"], - "scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"], - "is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"], - "show_in_competition": traderConfigFieldsSchema()["show_in_competition"], - }, - "required": []string{"action"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "search_stock", - Description: "Search for a stock by name, ticker symbol, or keyword. Searches across A-share (沪深), Hong Kong, and US markets. Returns a list of matching stocks with their codes. Use this when the user asks about a stock not in your known list, or when you need to find the exact code for a stock.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "keyword": map[string]any{ - "type": "string", - "description": "Search keyword: stock name (e.g. '宁德时代', '腾讯'), ticker (e.g. 'TSLA', 'AAPL'), or stock code (e.g. '300750')", - }, - }, - "required": []string{"keyword"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "execute_trade", - 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{ - "action": map[string]any{ - "type": "string", - "enum": []string{"open_long", "open_short", "close_long", "close_short"}, - "description": "Trade action: open_long (做多/buy), open_short (做空/sell), close_long (平多), close_short (平空)", - }, - "symbol": map[string]any{ - "type": "string", - "description": "Trading symbol. For crypto: BTCUSDT, ETHUSDT. For US stocks: AAPL, TSLA, NVDA (no suffix needed).", - }, - "quantity": map[string]any{ - "type": "number", - "description": "Trade quantity/amount. Required for opening positions. Use 0 to close entire position.", - }, - "leverage": map[string]any{ - "type": "number", - "description": "Leverage multiplier (e.g. 5, 10, 20). Optional, defaults to trader's current setting.", - }, - }, - "required": []string{"action", "symbol", "quantity"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_positions", - Description: "Get all current open positions across all traders. Returns symbol, side, size, entry price, mark price, and unrealized PnL.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_balance", - Description: "Get account balance and equity across all traders.", - Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_market_price", - Description: "Get the current market price for a crypto or stock symbol.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "symbol": map[string]any{ - "type": "string", - "description": "Trading symbol, e.g. BTCUSDT for crypto, AAPL for stocks", - }, - }, - "required": []string{"symbol"}, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_market_snapshot", - Description: "Get a real-time crypto market snapshot for analysis. Returns current price, 24h change, high/low, volume, funding rate, open interest, and recent K-line structure in one tool call. Prefer this when the user asks to analyze a coin, assess current行情, or wants a richer market read than a single price.", - 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 the structure snapshot, for example 5m, 15m, 1h, or 4h. Defaults to 15m.", - }, - "limit": map[string]any{ - "type": "number", - "description": "Number of recent candles to fetch for the structure snapshot. Defaults to 20 and is capped at 100.", - }, - }, - "required": []string{"symbol"}, - }, - }, - }, - { - 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{ - Name: "get_trade_history", - Description: "Get recent closed trade history with PnL. Use when user asks about past trades, performance, or trade results. Returns the most recent closed positions.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "limit": map[string]any{ - "type": "number", - "description": "Number of recent trades to return (default 10, max 50)", - }, - }, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_candidate_coins", - Description: "Get the current candidate coin list for a trader or strategy, including AI500 coin-source settings and the selected symbols.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "trader_id": map[string]any{ - "type": "string", - "description": "Optional trader id. Prefer this when asking about a running trader.", - }, - "strategy_id": map[string]any{ - "type": "string", - "description": "Optional strategy id. Use this when asking about a strategy template directly.", - }, - }, - }, - }, - }, - { - Type: "function", - Function: mcp.FunctionDef{ - Name: "get_ai500_list", - Description: "Get the AI500 index board: crypto symbols scored 0-100 by AI with their gain since entering the index, sorted by score. Use this whenever the user asks what's in AI500, wants coin recommendations, or asks you to pick promising/strong coins and hasn't named specific ones — the top entries are the well-performing candidates.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "limit": map[string]any{ - "type": "integer", - "description": "Max entries to return, default 20, max 100.", - }, - }, - }, - }, - }, - { - 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"}, - }, - }, - }, - } -} - -// handleToolCall processes a single tool call from the LLM and returns the result. -func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID int64, lang string, tc mcp.ToolCall) string { - switch tc.Function.Name { - case "get_preferences": - return a.toolGetPreferences(userID) - case "manage_preferences": - return a.toolManagePreferences(userID, tc.Function.Arguments) - case "get_backend_logs": - return a.toolGetBackendLogs(storeUserID, tc.Function.Arguments) - case "get_decisions": - return a.toolGetDecisions(storeUserID, tc.Function.Arguments) - case "get_exchange_configs": - return a.toolGetExchangeConfigs(storeUserID) - case "manage_exchange_config": - return a.toolManageExchangeConfig(storeUserID, tc.Function.Arguments) - case "get_model_configs": - return a.toolGetModelConfigs(storeUserID) - case "manage_model_config": - return a.toolManageModelConfig(storeUserID, tc.Function.Arguments) - case "get_strategies": - return a.toolGetStrategies(storeUserID) - case "manage_strategy": - return a.toolManageStrategy(storeUserID, tc.Function.Arguments) - case "manage_trader": - return a.toolManageTrader(storeUserID, tc.Function.Arguments) - case "search_stock": - return a.toolSearchStock(tc.Function.Arguments) - case "execute_trade": - return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments) - case "get_positions": - return a.toolGetPositions(storeUserID) - case "get_balance": - return a.toolGetBalance(storeUserID) - case "get_market_price": - return a.toolGetMarketPrice(tc.Function.Arguments) - case "get_market_snapshot": - return a.toolGetMarketSnapshot(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_ai500_list": - return a.toolGetAI500List(storeUserID, 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) - } -} - -type safeExchangeToolConfig struct { - ID string `json:"id"` - ExchangeType string `json:"exchange_type"` - AccountName string `json:"account_name"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - HasAPIKey bool `json:"has_api_key"` - HasSecretKey bool `json:"has_secret_key"` - HasPassphrase bool `json:"has_passphrase"` - Testnet bool `json:"testnet"` - HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr,omitempty"` - HasAsterPrivateKey bool `json:"has_aster_private_key"` - 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"` -} - -type safeModelToolConfig struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - Enabled bool `json:"enabled"` - HasAPIKey bool `json:"has_api_key"` - CustomAPIURL string `json:"custom_api_url,omitempty"` - CustomModelName string `json:"custom_model_name,omitempty"` - WalletAddress string `json:"wallet_address,omitempty"` - BalanceUSDC string `json:"balance_usdc,omitempty"` -} - -type safeTraderToolConfig struct { - ID string `json:"id"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - StrategyID string `json:"strategy_id,omitempty"` - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - IsRunning bool `json:"is_running"` - IsCrossMargin bool `json:"is_cross_margin"` - ShowInCompetition bool `json:"show_in_competition"` -} - -type safeStrategyToolConfig struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsActive bool `json:"is_active"` - IsDefault bool `json:"is_default"` - IsPublic bool `json:"is_public"` - ConfigVisible bool `json:"config_visible"` - Config map[string]any `json:"config,omitempty"` - 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"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - StrategyID string `json:"strategy_id"` - ScanIntervalMinutes *int `json:"scan_interval_minutes"` - IsCrossMargin *bool `json:"is_cross_margin"` - ShowInCompetition *bool `json:"show_in_competition"` -} - -func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig { - return safeExchangeToolConfig{ - ID: ex.ID, - ExchangeType: ex.ExchangeType, - AccountName: ex.AccountName, - Name: ex.Name, - Type: ex.Type, - Enabled: ex.Enabled, - HasAPIKey: ex.APIKey != "", - HasSecretKey: ex.SecretKey != "", - HasPassphrase: ex.Passphrase != "", - Testnet: ex.Testnet, - HyperliquidWalletAddr: ex.HyperliquidWalletAddr, - HasAsterPrivateKey: ex.AsterPrivateKey != "", - AsterUser: ex.AsterUser, - AsterSigner: ex.AsterSigner, - LighterWalletAddr: ex.LighterWalletAddr, - LighterAPIKeyIndex: ex.LighterAPIKeyIndex, - HasLighterPrivateKey: ex.LighterPrivateKey != "", - HasLighterAPIKey: ex.LighterAPIKeyPrivateKey != "", - } -} - -func defaultTraderInitialBalanceFetcher(exchangeCfg *store.Exchange, userID string) (float64, bool, error) { - if exchangeCfg == nil { - return 0, false, fmt.Errorf("exchange config not found") - } - probe, err := buildTraderExchangeProbe(exchangeCfg, userID) - if err != nil { - return 0, false, err - } - balanceInfo, err := probe.GetBalance() - if err != nil { - return 0, false, err - } - return extractTraderInitialBalance(balanceInfo) -} - -func buildTraderExchangeProbe(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) { - switch exchangeCfg.ExchangeType { - case "binance": - return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil - case "bybit": - return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil - case "okx": - return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil - case "bitget": - return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil - case "gate": - return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil - case "kucoin": - return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil - case "indodax": - return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil - case "hyperliquid": - return hyperliquidtrader.NewHyperliquidTrader( - string(exchangeCfg.APIKey), - exchangeCfg.HyperliquidWalletAddr, - exchangeCfg.Testnet, - exchangeCfg.HyperliquidUnifiedAcct, - ) - case "aster": - return aster.NewAsterTrader( - exchangeCfg.AsterUser, - exchangeCfg.AsterSigner, - string(exchangeCfg.AsterPrivateKey), - ) - case "lighter": - return lighter.NewLighterTraderV2( - exchangeCfg.LighterWalletAddr, - string(exchangeCfg.LighterAPIKeyPrivateKey), - exchangeCfg.LighterAPIKeyIndex, - false, - ) - default: - return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType) - } -} - -func extractTraderInitialBalance(balanceInfo map[string]interface{}) (float64, bool, error) { - for _, key := range []string{"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} { - raw, ok := balanceInfo[key] - if !ok { - continue - } - switch v := raw.(type) { - case float64: - return v, true, nil - case float32: - return float64(v), true, nil - case int: - return float64(v), true, nil - case int64: - return float64(v), true, nil - case int32: - return float64(v), true, nil - case string: - parsed, err := strconv.ParseFloat(v, 64) - if err == nil { - return parsed, true, nil - } - } - } - return 0, false, fmt.Errorf("initial balance not set and unable to fetch balance from exchange") -} - -func safeModelForTool(model *store.AIModel) safeModelToolConfig { - safeModel := safeModelToolConfig{ - ID: model.ID, - Name: model.Name, - Provider: model.Provider, - Enabled: model.Enabled, - HasAPIKey: model.APIKey != "", - CustomAPIURL: model.CustomAPIURL, - CustomModelName: model.CustomModelName, - } - if agentProviderSupportsUSDCBalance(model.Provider) { - privateKey := strings.TrimSpace(string(model.APIKey)) - if privateKey != "" { - if walletAddress, err := agentWalletAddressFromPrivateKey(privateKey); err == nil && strings.TrimSpace(walletAddress) != "" { - safeModel.WalletAddress = walletAddress - if balance, balanceErr := agentQueryUSDCBalanceCached(walletAddress); balanceErr == nil { - safeModel.BalanceUSDC = fmt.Sprintf("%.6f", balance) - } - } - } - } - return safeModel -} - -func modelConfigUsable(provider, modelID, apiKey, customAPIURL, customModelName string) bool { - if strings.TrimSpace(apiKey) == "" { - return false - } - resolvedURL, resolvedModel := resolveModelRuntimeConfig(provider, customAPIURL, customModelName, modelID) - return strings.TrimSpace(resolvedURL) != "" && strings.TrimSpace(resolvedModel) != "" -} - -func safeTraderForTool(trader *store.Trader, isRunning bool) safeTraderToolConfig { - return safeTraderToolConfig{ - ID: trader.ID, - Name: trader.Name, - AIModelID: trader.AIModelID, - ExchangeID: trader.ExchangeID, - StrategyID: trader.StrategyID, - InitialBalance: trader.InitialBalance, - ScanIntervalMinutes: trader.ScanIntervalMinutes, - IsRunning: isRunning, - IsCrossMargin: trader.IsCrossMargin, - ShowInCompetition: trader.ShowInCompetition, - } -} - -func safeStrategyForTool(strategy *store.Strategy) safeStrategyToolConfig { - out := safeStrategyToolConfig{ - ID: strategy.ID, - Name: strategy.Name, - Description: strategy.Description, - IsActive: strategy.IsActive, - IsDefault: strategy.IsDefault, - IsPublic: strategy.IsPublic, - ConfigVisible: strategy.ConfigVisible, - HasConfig: strings.TrimSpace(strategy.Config) != "", - } - if out.HasConfig { - var cfg map[string]any - if err := json.Unmarshal([]byte(strategy.Config), &cfg); err == nil { - out.Config = cfg - } - } - return out -} - -func (a *Agent) toolGetExchangeConfigs(storeUserID string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - exchanges, err := a.store.Exchange().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load exchange configs: %s"}`, err) - } - 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) -} - -func latestBackendLogFilePath() string { - matches, err := filepath.Glob(filepath.Join("data", "nofx_*.log")) - if err != nil || len(matches) == 0 { - return "" - } - sort.Strings(matches) - return matches[len(matches)-1] -} - -func isBackendErrorLikeLogLine(line string) bool { - lower := strings.ToLower(strings.TrimSpace(line)) - if lower == "" { - return false - } - return strings.Contains(lower, "[erro]") || - strings.Contains(lower, " panic") || - strings.Contains(lower, "🔥") || - strings.Contains(lower, "❌") || - strings.Contains(lower, " failed") || - strings.Contains(lower, " error") || - strings.Contains(lower, "invalid ") -} - -func readBackendLogEntries(limit int, contains string, errorsOnly bool) (string, []string, error) { - path := latestBackendLogFilePath() - if path == "" { - return "", nil, fmt.Errorf("backend log file not found") - } - file, err := os.Open(path) - if err != nil { - return path, nil, err - } - defer file.Close() - - filter := strings.ToLower(strings.TrimSpace(contains)) - matches := make([]string, 0, max(limit, 1)) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if errorsOnly && !isBackendErrorLikeLogLine(line) { - continue - } - if filter != "" && !strings.Contains(strings.ToLower(line), filter) { - continue - } - matches = append(matches, line) - } - if err := scanner.Err(); err != nil { - return path, nil, err - } - if limit <= 0 { - limit = 30 - } - if len(matches) > limit { - matches = matches[len(matches)-limit:] - } - 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) resolveTraderForTool(storeUserID, traderID, traderName string) (*store.Trader, error) { - traderID = strings.TrimSpace(traderID) - traderName = strings.TrimSpace(traderName) - if traderID == "" && traderName == "" { - return nil, fmt.Errorf("trader_id or trader_name is required") - } - if traderID != "" { - traderCfg, err := a.store.Trader().GetByID(traderID) - if err != nil { - return nil, fmt.Errorf("failed to load trader: %w", err) - } - if traderCfg.UserID != storeUserID { - return nil, fmt.Errorf("trader not found for current user") - } - return traderCfg, nil - } - traders, err := a.store.Trader().List(storeUserID) - if err != nil { - return nil, fmt.Errorf("failed to list traders: %w", err) - } - for _, traderCfg := range traders { - if strings.EqualFold(strings.TrimSpace(traderCfg.Name), traderName) { - return traderCfg, nil - } - } - return nil, fmt.Errorf("trader %q not found", traderName) -} - -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"` - } - if strings.TrimSpace(argsJSON) != "" { - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - 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 - } - traderCfg, err := a.resolveTraderForTool(storeUserID, args.TraderID, args.TraderName) - if err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } - path, entries, err := readBackendLogEntries(args.Limit, "", errorsOnly) - if err != nil { - return fmt.Sprintf(`{"error":"failed to read backend logs: %s"}`, err) - } - entries = filterBackendLogEntriesAny(entries, traderCfg.ID, traderCfg.Name) - 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": traderCfg.ID, - "trader_name": traderCfg.Name, - "log_file": path, - "entries": entries, - "count": len(entries), - "errors_only": errorsOnly, - }) - return string(result) -} - -func (a *Agent) toolGetDecisions(storeUserID, argsJSON string) string { - var args struct { - TraderID string `json:"trader_id"` - TraderName string `json:"trader_name"` - Limit int `json:"limit"` - } - if strings.TrimSpace(argsJSON) != "" { - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - } - if a.store == nil { - return `{"error":"store unavailable"}` - } - traderCfg, err := a.resolveTraderForTool(storeUserID, args.TraderID, args.TraderName) - if err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } - limit := args.Limit - if limit <= 0 { - limit = 5 - } - if limit > 20 { - limit = 20 - } - records, err := a.store.Decision().GetLatestRecords(traderCfg.ID, limit) - if err != nil { - return fmt.Sprintf(`{"error":"failed to get decision records: %s"}`, err) - } - items := make([]map[string]any, 0, len(records)) - for _, record := range records { - items = append(items, map[string]any{ - "id": record.ID, - "cycle_number": record.CycleNumber, - "timestamp": record.Timestamp, - "success": record.Success, - "error_message": record.ErrorMessage, - "ai_request_duration_ms": record.AIRequestDurationMs, - "candidate_coins": record.CandidateCoins, - "execution_log": record.ExecutionLog, - "decisions": record.Decisions, - "decision_json": record.DecisionJSON, - }) - } - result, _ := json.Marshal(map[string]any{ - "trader_id": traderCfg.ID, - "trader_name": traderCfg.Name, - "count": len(items), - "records": items, - }) - return string(result) -} - -func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - var args struct { - Action string `json:"action"` - ExchangeID string `json:"exchange_id"` - ExchangeType string `json:"exchange_type"` - AccountName string `json:"account_name"` - Enabled *bool `json:"enabled"` - APIKey string `json:"api_key"` - SecretKey string `json:"secret_key"` - Passphrase string `json:"passphrase"` - Testnet *bool `json:"testnet"` - HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` - HyperliquidUnifiedAccount *bool `json:"hyperliquid_unified_account"` - AsterUser string `json:"aster_user"` - AsterSigner string `json:"aster_signer"` - AsterPrivateKey string `json:"aster_private_key"` - LighterWalletAddr string `json:"lighter_wallet_addr"` - LighterPrivateKey string `json:"lighter_private_key"` - LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"` - LighterAPIKeyIndex *int `json:"lighter_api_key_index"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - action := strings.TrimSpace(args.Action) - switch action { - case "create": - missing := missingRequiredActionSlots("exchange_management", "create", map[string]string{ - "exchange_type": strings.TrimSpace(args.ExchangeType), - "account_name": strings.TrimSpace(args.AccountName), - }) - 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 := true - testnet := false - if args.Testnet != nil { - testnet = *args.Testnet - } - unified := true - if args.HyperliquidUnifiedAccount != nil { - unified = *args.HyperliquidUnifiedAccount - } - lighterIndex := 0 - 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, - exchangeType, - strings.TrimSpace(args.AccountName), - enabled, - strings.TrimSpace(args.APIKey), - strings.TrimSpace(args.SecretKey), - strings.TrimSpace(args.Passphrase), - testnet, - strings.TrimSpace(args.HyperliquidWalletAddr), - unified, - false, - strings.TrimSpace(args.AsterUser), - strings.TrimSpace(args.AsterSigner), - strings.TrimSpace(args.AsterPrivateKey), - strings.TrimSpace(args.LighterWalletAddr), - strings.TrimSpace(args.LighterPrivateKey), - strings.TrimSpace(args.LighterAPIKeyPrivateKey), - lighterIndex, - ) - if err != nil { - return fmt.Sprintf(`{"error":"failed to create exchange config: %s"}`, err) - } - created, err := a.store.Exchange().GetByID(storeUserID, id) - if err != nil { - return fmt.Sprintf(`{"error":"exchange created but failed to reload: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "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) == "" { - return `{"error":"exchange_id is required for update"}` - } - 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) - } - enabled := true - testnet := existing.Testnet - if args.Testnet != nil { - testnet = *args.Testnet - } - unified := existing.HyperliquidUnifiedAcct - if args.HyperliquidUnifiedAccount != nil { - unified = *args.HyperliquidUnifiedAccount - } - lighterIndex := existing.LighterAPIKeyIndex - if args.LighterAPIKeyIndex != nil { - lighterIndex = *args.LighterAPIKeyIndex - } - hyperWallet := existing.HyperliquidWalletAddr - if strings.TrimSpace(args.HyperliquidWalletAddr) != "" { - hyperWallet = strings.TrimSpace(args.HyperliquidWalletAddr) - } - asterUser := existing.AsterUser - if strings.TrimSpace(args.AsterUser) != "" { - asterUser = strings.TrimSpace(args.AsterUser) - } - asterSigner := existing.AsterSigner - if strings.TrimSpace(args.AsterSigner) != "" { - asterSigner = strings.TrimSpace(args.AsterSigner) - } - lighterWallet := existing.LighterWalletAddr - 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 - } - validator := exchangeConfigValidator{ - exchangeType: existing.ExchangeType, - enabled: true, - apiKey: effectiveAPIKey, - secretKey: effectiveSecretKey, - passphrase: effectivePassphrase, - hyperliquidWalletAddr: hyperWallet, - asterUser: asterUser, - asterSigner: asterSigner, - asterPrivateKey: effectiveAsterPrivateKey, - lighterWalletAddr: lighterWallet, - lighterPrivateKey: effectiveLighterPrivateKey, - lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey, - } - if err := validator.Validate(); err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } - if err := a.store.Exchange().Update( - storeUserID, - existing.ID, - enabled, - strings.TrimSpace(args.APIKey), - strings.TrimSpace(args.SecretKey), - strings.TrimSpace(args.Passphrase), - testnet, - hyperWallet, - unified, - existing.HyperliquidBuilderApproved, - asterUser, - asterSigner, - strings.TrimSpace(args.AsterPrivateKey), - lighterWallet, - strings.TrimSpace(args.LighterPrivateKey), - strings.TrimSpace(args.LighterAPIKeyPrivateKey), - lighterIndex, - ); err != nil { - 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) - } - } - updated, err := a.store.Exchange().GetByID(storeUserID, existing.ID) - if err != nil { - return fmt.Sprintf(`{"error":"exchange updated but failed to reload: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "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) == "" { - return `{"error":"exchange_id is required for delete"}` - } - if err := a.store.Exchange().Delete(storeUserID, strings.TrimSpace(args.ExchangeID)); err != nil { - return fmt.Sprintf(`{"error":"failed to delete exchange config: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "delete", - "exchange_id": strings.TrimSpace(args.ExchangeID), - }) - return string(result) - default: - return `{"error":"invalid action"}` - } -} - -func (a *Agent) toolGetModelConfigs(storeUserID string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - models, err := a.store.AIModel().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load model configs: %s"}`, err) - } - 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) -} - -func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - var args struct { - Action string `json:"action"` - ModelID string `json:"model_id"` - Provider string `json:"provider"` - Name string `json:"name"` - Enabled *bool `json:"enabled"` - APIKey string `json:"api_key"` - CustomAPIURL string `json:"custom_api_url"` - CustomModelName string `json:"custom_model_name"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - if trimmed := strings.TrimSpace(args.CustomAPIURL); trimmed != "" { - if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil { - return fmt.Sprintf(`{"error":"invalid custom_api_url: %s"}`, err) - } - } - 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 - } - // Match the manual settings page: newly created model configs should be - // enabled unless the caller explicitly asks to keep them disabled. - enabled := true - if args.Enabled != nil { - enabled = *args.Enabled - } - 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) - } - existingByProvider, err := a.findModelByProvider(storeUserID, provider) - if err != nil { - return fmt.Sprintf(`{"error":"failed to inspect existing model configs: %s"}`, err) - } - excludeID := "" - if existingByProvider != nil { - modelID = existingByProvider.ID - excludeID = existingByProvider.ID - } - if err := a.ensureUniqueModelName(storeUserID, name, excludeID); 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 - if modelID == provider { - createdID = fmt.Sprintf("%s_%s", storeUserID, provider) - } - model, err := a.store.AIModel().Get(storeUserID, createdID) - if err != nil { - model, err = a.store.AIModel().Get(storeUserID, modelID) - } - if err != nil { - return fmt.Sprintf(`{"error":"model created but failed to reload: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "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) - if modelID == "" { - return `{"error":"model_id is required for update"}` - } - existing, err := a.store.AIModel().Get(storeUserID, modelID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load model config: %s"}`, err) - } - enabled := existing.Enabled - if args.Enabled != nil { - enabled = *args.Enabled - } - customAPIURL := existing.CustomAPIURL - if strings.TrimSpace(args.CustomAPIURL) != "" { - customAPIURL = strings.TrimSpace(args.CustomAPIURL) - } - customModelName := existing.CustomModelName - if strings.TrimSpace(args.CustomModelName) != "" { - customModelName = strings.TrimSpace(args.CustomModelName) - } - apiKey := strings.TrimSpace(args.APIKey) - effectiveAPIKey := string(existing.APIKey) - if apiKey != "" { - effectiveAPIKey = apiKey - } - 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 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) - if err != nil { - return fmt.Sprintf(`{"error":"model updated but failed to reload: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "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) - if modelID == "" { - return `{"error":"model_id is required for delete"}` - } - if err := a.store.AIModel().Delete(storeUserID, modelID); err != nil { - return fmt.Sprintf(`{"error":"failed to delete model config: %s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "delete", - "model_id": modelID, - }) - return string(result) - default: - return `{"error":"invalid action"}` - } -} - -func (a *Agent) toolGetStrategies(storeUserID string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - strategies, err := a.store.Strategy().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load strategies: %s"}`, err) - } - 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{ - "strategies": safeStrategies, - "count": len(safeStrategies), - }) - return string(result) -} - -func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - var args struct { - Action string `json:"action"` - StrategyID string `json:"strategy_id"` - Name string `json:"name"` - Description string `json:"description"` - Lang string `json:"lang"` - IsPublic *bool `json:"is_public"` - ConfigVisible *bool `json:"config_visible"` - AllowClamped bool `json:"allow_clamped_update"` - Confirmed bool `json:"confirmed"` - Config map[string]any `json:"config"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - - switch strings.TrimSpace(args.Action) { - case "list": - return a.toolGetStrategies(storeUserID) - case "get_default_config": - lang := strings.TrimSpace(args.Lang) - if lang != "zh" { - lang = "en" - } - cfg := store.GetDefaultStrategyConfig(lang) - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "get_default_config", - "config": cfg, - }) - return string(payload) - case "create": - name := strings.TrimSpace(args.Name) - if name == "" { - return `{"error":"name is required for create"}` - } - if !args.Confirmed { - return `{"error":"strategy create requires explicit chat confirmation before execution. Present the strategy config summary to the user and ask them to reply 确认创建; do not claim the strategy was created.","requires_confirmation":true}` - } - if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { - return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) - } - if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } - defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang)) - var cfg any = defaultConfig - var warnings []string - if len(args.Config) > 0 { - 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) - if len(warnings) > 0 && !args.AllowClamped { - return fmt.Sprintf(`{"error":"%s"}`, formatRiskControlRefusalPrompt(merged.Language, warnings, "确认应用")) - } - cfg = merged - } - configJSON, err := json.Marshal(cfg) - if err != nil { - return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err) - } - record := &store.Strategy{ - ID: fmt.Sprintf("strategy_%d", time.Now().UnixNano()), - UserID: storeUserID, - Name: name, - Description: strings.TrimSpace(args.Description), - IsActive: false, - IsDefault: false, - IsPublic: args.IsPublic != nil && *args.IsPublic, - ConfigVisible: args.ConfigVisible == nil || *args.ConfigVisible, - Config: string(configJSON), - } - if err := a.store.Strategy().Create(record); err != nil { - return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err) - } - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "create", - "strategy": safeStrategyForTool(record), - "warnings": warnings, - }) - return string(payload) - case "update": - strategyID := strings.TrimSpace(args.StrategyID) - if strategyID == "" { - return `{"error":"strategy_id is required for update"}` - } - if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok { - return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField)) - } - existing, err := a.store.Strategy().Get(storeUserID, strategyID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err) - } - if existing.IsDefault { - return `{"error":"cannot modify system default strategy"}` - } - name := existing.Name - 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 - } - isPublic := existing.IsPublic - if args.IsPublic != nil { - isPublic = *args.IsPublic - } - configVisible := existing.ConfigVisible - if args.ConfigVisible != nil { - configVisible = *args.ConfigVisible - } - configJSON := existing.Config - var warnings []string - if len(args.Config) > 0 { - 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) - if len(warnings) > 0 && !args.AllowClamped { - return fmt.Sprintf(`{"error":"%s"}`, formatRiskControlRefusalPrompt(merged.Language, warnings, "确认应用")) - } - normalized, err := json.Marshal(merged) - if err != nil { - return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err) - } - configJSON = string(normalized) - } - record := &store.Strategy{ - ID: existing.ID, - UserID: storeUserID, - Name: name, - Description: description, - IsPublic: isPublic, - ConfigVisible: configVisible, - Config: configJSON, - } - if err := a.store.Strategy().Update(record); err != nil { - return fmt.Sprintf(`{"error":"failed to update strategy: %s"}`, err) - } - updated, err := a.store.Strategy().Get(storeUserID, existing.ID) - if err != nil { - return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err) - } - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "update", - "strategy": safeStrategyForTool(updated), - "warnings": warnings, - }) - return string(payload) - case "delete": - strategyID := strings.TrimSpace(args.StrategyID) - if strategyID == "" { - return `{"error":"strategy_id is required for delete"}` - } - if err := a.store.Strategy().Delete(storeUserID, strategyID); err != nil { - if strings.Contains(err.Error(), "cannot delete active strategy") { - strategies, listErr := a.store.Strategy().List(storeUserID) - if listErr != nil { - return fmt.Sprintf(`{"error":"failed to prepare active strategy deletion: %s"}`, listErr) - } - - var fallbackID string - for _, strategy := range strategies { - if strategy == nil || strategy.ID == strategyID { - continue - } - if strategy.IsDefault { - fallbackID = strategy.ID - break - } - if fallbackID == "" { - fallbackID = strategy.ID - } - } - if fallbackID == "" { - defaultConfig := store.GetDefaultStrategyConfig("zh") - defaultConfig.ClampLimits() - configJSON, marshalErr := json.Marshal(defaultConfig) - if marshalErr != nil { - return fmt.Sprintf(`{"error":"failed to create fallback strategy config: %s"}`, marshalErr) - } - - fallbackID = fmt.Sprintf("strategy_%d", time.Now().UnixNano()) - fallbackStrategy := &store.Strategy{ - ID: fallbackID, - UserID: storeUserID, - Name: "默认策略", - Description: "Agent-generated fallback strategy", - Config: string(configJSON), - } - if createErr := a.store.Strategy().Create(fallbackStrategy); createErr != nil { - return fmt.Sprintf(`{"error":"failed to create fallback strategy before deletion: %s"}`, createErr) - } - } - if activateErr := a.store.Strategy().SetActive(storeUserID, fallbackID); activateErr != nil { - return fmt.Sprintf(`{"error":"failed to switch active strategy before deletion: %s"}`, activateErr) - } - if retryErr := a.store.Strategy().Delete(storeUserID, strategyID); retryErr != nil { - return fmt.Sprintf(`{"error":"failed to delete strategy: %s"}`, retryErr) - } - } else { - return fmt.Sprintf(`{"error":"failed to delete strategy: %s"}`, err) - } - } - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "delete", - "strategy_id": strategyID, - }) - return string(payload) - case "activate": - strategyID := strings.TrimSpace(args.StrategyID) - if strategyID == "" { - return `{"error":"strategy_id is required for activate"}` - } - if err := a.store.Strategy().SetActive(storeUserID, strategyID); err != nil { - return fmt.Sprintf(`{"error":"failed to activate strategy: %s"}`, err) - } - updated, err := a.store.Strategy().Get(storeUserID, strategyID) - if err != nil { - return fmt.Sprintf(`{"error":"strategy activated but failed to reload: %s"}`, err) - } - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "activate", - "strategy": safeStrategyForTool(updated), - }) - return string(payload) - case "duplicate": - sourceID := strings.TrimSpace(args.StrategyID) - name := strings.TrimSpace(args.Name) - if sourceID == "" { - return `{"error":"strategy_id is required for duplicate"}` - } - 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) - } - created, err := a.store.Strategy().Get(storeUserID, newID) - if err != nil { - return fmt.Sprintf(`{"error":"strategy duplicated but failed to reload: %s"}`, err) - } - payload, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "duplicate", - "strategy": safeStrategyForTool(created), - }) - return string(payload) - default: - return `{"error":"invalid action"}` - } -} - -func (a *Agent) toolManageTrader(storeUserID, argsJSON string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - var args manageTraderArgs - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - - switch strings.TrimSpace(args.Action) { - case "list": - return a.toolListTraders(storeUserID) - case "create": - return a.toolCreateTrader(storeUserID, args) - case "update": - return a.toolUpdateTrader(storeUserID, args) - case "delete": - return a.toolDeleteTrader(storeUserID, strings.TrimSpace(args.TraderID)) - case "start": - return a.toolStartTrader(storeUserID, strings.TrimSpace(args.TraderID)) - case "stop": - return a.toolStopTrader(storeUserID, strings.TrimSpace(args.TraderID)) - default: - return `{"error":"invalid action"}` - } -} - -func (a *Agent) toolListTraders(storeUserID string) string { - traders, err := a.store.Trader().List(storeUserID) - 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 { - if running, ok := memTrader.GetStatus()["is_running"].(bool); ok { - isRunning = running - } - } - } - safeTraders = append(safeTraders, safeTraderForTool(traderCfg, isRunning)) - } - result, _ := json.Marshal(map[string]any{ - "traders": safeTraders, - "count": len(safeTraders), - }) - return string(result) -} - -func (a *Agent) validateTraderReferences(storeUserID, aiModelID, exchangeID, strategyID string) error { - return (traderBindingValidator{ - store: a.store, - storeUserID: storeUserID, - aiModelID: aiModelID, - exchangeID: exchangeID, - strategyID: strategyID, - }).Validate() -} - -func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) string { - name := strings.TrimSpace(args.Name) - 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) - } - exchangeCfg, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(args.ExchangeID)) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err) - } - scanInterval := 3 - if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 { - scanInterval = *args.ScanIntervalMinutes - if scanInterval < 3 { - scanInterval = 3 - } - } - initialBalance, found, err := traderInitialBalanceFetcher(exchangeCfg, storeUserID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to auto-read trader initial balance from exchange: %s"}`, err) - } - if !found { - return `{"error":"failed to auto-read trader initial balance from exchange"}` - } - isCrossMargin := true - if args.IsCrossMargin != nil { - isCrossMargin = *args.IsCrossMargin - } - showInCompetition := true - if args.ShowInCompetition != nil { - showInCompetition = *args.ShowInCompetition - } - btcEthLeverage := 10 - altcoinLeverage := 5 - overrideBasePrompt := false - useAI500 := false - useOITop := false - systemPromptTemplate := "default" - exchangeIDShort := strings.TrimSpace(args.ExchangeID) - if len(exchangeIDShort) > 8 { - exchangeIDShort = exchangeIDShort[:8] - } - traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, strings.TrimSpace(args.AIModelID), time.Now().Unix()) - record := &store.Trader{ - ID: traderID, - UserID: storeUserID, - Name: name, - AIModelID: strings.TrimSpace(args.AIModelID), - ExchangeID: strings.TrimSpace(args.ExchangeID), - StrategyID: strings.TrimSpace(args.StrategyID), - InitialBalance: initialBalance, - ScanIntervalMinutes: scanInterval, - IsRunning: false, - IsCrossMargin: isCrossMargin, - ShowInCompetition: showInCompetition, - BTCETHLeverage: btcEthLeverage, - AltcoinLeverage: altcoinLeverage, - TradingSymbols: "", - UseAI500: useAI500, - UseOITop: useOITop, - CustomPrompt: "", - OverrideBasePrompt: overrideBasePrompt, - SystemPromptTemplate: systemPromptTemplate, - } - if err := a.store.Trader().Create(record); err != nil { - return fmt.Sprintf(`{"error":"failed to create trader: %s"}`, err) - } - if a.traderManager != nil { - _ = a.traderManager.LoadUserTradersFromStore(a.store, storeUserID) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "create", - "trader": safeTraderForTool(record, false), - }) - return string(result) -} - -func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) string { - traderID := strings.TrimSpace(args.TraderID) - if traderID == "" { - return `{"error":"trader_id is required for update"}` - } - traders, err := a.store.Trader().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load traders: %s"}`, err) - } - var existing *store.Trader - for _, item := range traders { - if item.ID == traderID { - existing = item - break - } - } - if existing == nil { - return `{"error":"trader not found"}` - } - if trimmed := strings.TrimSpace(args.Name); trimmed != "" && !sameEntityName(trimmed, existing.Name) { - return `{"error":"trader rename is not supported here; only bindings, scan interval, margin mode, and competition visibility can be edited"}` - } - aiModelID := existing.AIModelID - if trimmed := strings.TrimSpace(args.AIModelID); trimmed != "" { - aiModelID = trimmed - } - exchangeID := existing.ExchangeID - if trimmed := strings.TrimSpace(args.ExchangeID); trimmed != "" { - exchangeID = trimmed - } - strategyID := existing.StrategyID - if trimmed := strings.TrimSpace(args.StrategyID); trimmed != "" { - strategyID = trimmed - } - if err := a.validateTraderReferences(storeUserID, aiModelID, exchangeID, strategyID); err != nil { - return fmt.Sprintf(`{"error":"%s"}`, err) - } - record := &store.Trader{ - ID: existing.ID, - UserID: storeUserID, - Name: existing.Name, - AIModelID: aiModelID, - ExchangeID: exchangeID, - StrategyID: strategyID, - InitialBalance: existing.InitialBalance, - ScanIntervalMinutes: existing.ScanIntervalMinutes, - IsRunning: existing.IsRunning, - IsCrossMargin: existing.IsCrossMargin, - ShowInCompetition: existing.ShowInCompetition, - BTCETHLeverage: existing.BTCETHLeverage, - AltcoinLeverage: existing.AltcoinLeverage, - TradingSymbols: existing.TradingSymbols, - UseAI500: existing.UseAI500, - UseOITop: existing.UseOITop, - CustomPrompt: existing.CustomPrompt, - OverrideBasePrompt: existing.OverrideBasePrompt, - SystemPromptTemplate: existing.SystemPromptTemplate, - } - if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 { - record.ScanIntervalMinutes = *args.ScanIntervalMinutes - if record.ScanIntervalMinutes < 3 { - record.ScanIntervalMinutes = 3 - } - } - if args.IsCrossMargin != nil { - record.IsCrossMargin = *args.IsCrossMargin - } - if args.ShowInCompetition != nil { - record.ShowInCompetition = *args.ShowInCompetition - } - if err := a.store.Trader().Update(record); err != nil { - return fmt.Sprintf(`{"error":"failed to update trader: %s"}`, err) - } - if a.traderManager != nil { - a.traderManager.RemoveTrader(record.ID) - _ = a.traderManager.LoadUserTradersFromStore(a.store, storeUserID) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "update", - "trader": safeTraderForTool(record, record.IsRunning), - }) - return string(result) -} - -func (a *Agent) toolDeleteTrader(storeUserID, traderID string) string { - if traderID == "" { - return `{"error":"trader_id is required for delete"}` - } - if a.traderManager != nil { - if trader, err := a.traderManager.GetTrader(traderID); err == nil { - if running, ok := trader.GetStatus()["is_running"].(bool); ok && running { - return `{"error":"trader is running; stop it before deleting"}` - } - } - } - if record, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err == nil && record != nil && record.Trader != nil && record.Trader.IsRunning { - return `{"error":"trader is running; stop it before deleting"}` - } - if traders, err := a.store.Trader().List(storeUserID); err == nil { - for _, trader := range traders { - if trader != nil && trader.ID == traderID && trader.IsRunning { - return `{"error":"trader is running; stop it before deleting"}` - } - } - } - if err := a.store.Trader().Delete(storeUserID, traderID); err != nil { - return fmt.Sprintf(`{"error":"failed to delete trader: %s"}`, err) - } - if a.traderManager != nil { - a.traderManager.RemoveTrader(traderID) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "delete", - "trader_id": traderID, - }) - return string(result) -} - -func (a *Agent) toolStartTrader(storeUserID, traderID string) string { - if traderID == "" { - return `{"error":"trader_id is required for start"}` - } - if a.traderManager == nil { - return `{"error":"trader manager unavailable"}` - } - fullCfg, err := a.store.Trader().GetFullConfig(storeUserID, traderID) - if err != nil { - return fmt.Sprintf(`{"error":"trader not found or inaccessible: %s"}`, err) - } - if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved { - return `{"error":"Hyperliquid trading authorization is incomplete; reconnect Hyperliquid wallet and complete trading authorization before starting this trader"}` - } - if existing, err := a.traderManager.GetTrader(traderID); err == nil { - if running, ok := existing.GetStatus()["is_running"].(bool); ok && running { - return `{"error":"trader is already running"}` - } - a.traderManager.RemoveTrader(traderID) - } - if err := a.traderManager.LoadUserTradersFromStore(a.store, storeUserID); err != nil { - return fmt.Sprintf(`{"error":"failed to load trader config: %s"}`, err) - } - trader, err := a.traderManager.GetTrader(traderID) - if err != nil { - if loadErr := a.traderManager.GetLoadError(traderID); loadErr != nil { - return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, loadErr) - } - return fmt.Sprintf(`{"error":"failed to get trader: %s"}`, err) - } - safe.GoNamed("agent-trader-start-"+traderID, func() { - if runErr := trader.Run(); runErr != nil { - a.logger.Error("agent tool trader runtime error", "trader_id", traderID, "error", runErr) - } - }) - _ = a.store.Trader().UpdateStatus(storeUserID, traderID, true) - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "start", - "trader_id": traderID, - "message": "Trader started", - }) - return string(result) -} - -func (a *Agent) toolStopTrader(storeUserID, traderID string) string { - if traderID == "" { - return `{"error":"trader_id is required for stop"}` - } - if a.traderManager == nil { - return `{"error":"trader manager unavailable"}` - } - if _, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err != nil { - return fmt.Sprintf(`{"error":"trader not found or inaccessible: %s"}`, err) - } - trader, err := a.traderManager.GetTrader(traderID) - if err != nil { - return fmt.Sprintf(`{"error":"trader not loaded: %s"}`, err) - } - if running, ok := trader.GetStatus()["is_running"].(bool); ok && !running { - return `{"error":"trader is already stopped"}` - } - trader.Stop() - _ = a.store.Trader().UpdateStatus(storeUserID, traderID, false) - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "stop", - "trader_id": traderID, - "message": "Trader stopped", - }) - return string(result) -} - -func (a *Agent) toolGetPreferences(userID int64) string { - prefs := a.getPersistentPreferences(userID) - result, _ := json.Marshal(map[string]any{ - "preferences": prefs, - "count": len(prefs), - }) - return string(result) -} - -func (a *Agent) toolManagePreferences(userID int64, argsJSON string) string { - var args struct { - Action string `json:"action"` - Text string `json:"text"` - Match string `json:"match"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err) - } - - switch args.Action { - case "add": - prefs, created, err := a.addPersistentPreference(userID, args.Text) - if err != nil { - return fmt.Sprintf(`{"error": "%s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "add", - "preference": created, - "preferences": prefs, - }) - return string(result) - case "update": - prefs, updated, err := a.updatePersistentPreference(userID, args.Match, args.Text) - if err != nil { - return fmt.Sprintf(`{"error": "%s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "update", - "preference": updated, - "preferences": prefs, - }) - return string(result) - case "delete": - prefs, removed, err := a.deletePersistentPreference(userID, args.Match) - if err != nil { - return fmt.Sprintf(`{"error": "%s"}`, err) - } - result, _ := json.Marshal(map[string]any{ - "status": "ok", - "action": "delete", - "preference": removed, - "preferences": prefs, - }) - return string(result) - default: - return `{"error": "invalid action"}` - } -} - -func (a *Agent) toolSearchStock(argsJSON string) string { - var args struct { - Keyword string `json:"keyword"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err) - } - - if args.Keyword == "" { - return `{"error": "keyword is required"}` - } - - results, err := searchStock(args.Keyword) - if err != nil { - return fmt.Sprintf(`{"error": "search failed: %s"}`, err) - } - - if len(results) == 0 { - return fmt.Sprintf(`{"results": [], "message": "no stocks found for '%s'"}`, args.Keyword) - } - - // Limit to top 10 results - if len(results) > 10 { - results = results[:10] - } - - // Also fetch real-time quotes for the top results (up to 3) - type enrichedResult struct { - Name string `json:"name"` - Code string `json:"code"` - Market string `json:"market"` - Quote *StockQuote `json:"quote,omitempty"` - } - - var enriched []enrichedResult - for i, r := range results { - er := enrichedResult{Name: r.Name, Code: r.Code, Market: r.Market} - if i < 3 { - q, qErr := fetchStockQuote(r.Code) - if qErr == nil && q.Price > 0 { - er.Quote = q - } - } - enriched = append(enriched, er) - } - - result, _ := json.Marshal(map[string]any{ - "keyword": args.Keyword, - "count": len(enriched), - "results": enriched, - }) - return string(result) -} - -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"` - Quantity float64 `json:"quantity"` - Leverage int `json:"leverage"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err) - } - - // Normalize symbol - sym := strings.ToUpper(args.Symbol) - // Only append USDT for crypto symbols; stock tickers (e.g. AAPL, TSLA) stay as-is - if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") { - sym += "USDT" - } - - // Validate action - validActions := map[string]bool{ - "open_long": true, "open_short": true, - "close_long": true, "close_short": true, - } - if !validActions[args.Action] { - return fmt.Sprintf(`{"error": "invalid action: %s"}`, args.Action) - } - - // For open actions, quantity must be > 0 - if (args.Action == "open_long" || args.Action == "open_short") && args.Quantity <= 0 { - return `{"error": "quantity must be > 0 for opening positions"}` - } - - // For stock symbols, check market hours and warn if closed - var marketWarning string - if isStockSymbol(sym) && a.traderManager != nil { - for _, t := range a.traderManager.GetAllTraders() { - if t.GetExchange() == "alpaca" { - ut := t.GetUnderlyingTrader() - if ut == nil { - continue - } - type marketChecker interface { - IsMarketOpen() (bool, string, error) - } - if mc, ok := ut.(marketChecker); ok { - isOpen, status, err := mc.IsMarketOpen() - if err == nil && !isOpen { - marketWarning = fmt.Sprintf("⚠️ US market is currently %s. Order will be queued for next market open.", status) - } - } - break - } - } - } - - // Create pending trade — requires user confirmation - trade := &TradeAction{ - ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()), - Action: args.Action, - Symbol: sym, - Quantity: args.Quantity, - Leverage: args.Leverage, - 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, - "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 - } - result, _ := json.Marshal(resultMap) - return string(result) -} - -func (a *Agent) toolGetPositions(storeUserID string) string { - if a.traderManager == nil { - return `{"error": "no trader manager configured"}` - } - if a.store == nil { - return `{"error": "store unavailable"}` - } - traderConfigs, err := a.store.Trader().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error": "failed to list traders: %s"}`, err) - } - - var positions []map[string]any - for _, traderCfg := range traderConfigs { - if strings.TrimSpace(traderCfg.ID) == "" { - continue - } - t, err := a.traderManager.GetTrader(traderCfg.ID) - if err != nil { - continue - } - pos, err := t.GetPositions() - if err != nil { - continue - } - for _, p := range pos { - size := toFloat(p["size"]) - if size == 0 { - continue - } - tid := traderCfg.ID - if len(tid) > 8 { - tid = tid[:8] - } - positions = append(positions, map[string]any{ - "trader": tid, - "exchange": t.GetExchange(), - "symbol": p["symbol"], - "side": p["side"], - "size": size, - "entry_price": toFloat(p["entryPrice"]), - "mark_price": toFloat(p["markPrice"]), - "unrealized_pnl": toFloat(p["unrealizedPnl"]), - "leverage": p["leverage"], - }) - } - } - - if len(positions) == 0 { - return `{"positions": [], "message": "no open positions"}` - } - - result, _ := json.Marshal(map[string]any{"positions": positions}) - return string(result) -} - -func (a *Agent) toolGetBalance(storeUserID string) string { - if a.traderManager == nil { - return `{"error": "no trader manager configured"}` - } - if a.store == nil { - return `{"error": "store unavailable"}` - } - traderConfigs, err := a.store.Trader().List(storeUserID) - if err != nil { - return fmt.Sprintf(`{"error": "failed to list traders: %s"}`, err) - } - - var balances []map[string]any - for _, traderCfg := range traderConfigs { - if strings.TrimSpace(traderCfg.ID) == "" { - continue - } - t, err := a.traderManager.GetTrader(traderCfg.ID) - if err != nil { - continue - } - info, err := t.GetAccountInfo() - if err != nil { - continue - } - tid := traderCfg.ID - if len(tid) > 8 { - tid = tid[:8] - } - balances = append(balances, map[string]any{ - "trader": tid, - "name": t.GetName(), - "exchange": t.GetExchange(), - "total_equity": toFloat(info["total_equity"]), - "available": toFloat(info["available_balance"]), - "used_margin": toFloat(info["used_margin"]), - }) - } - - result, _ := json.Marshal(map[string]any{"balances": balances}) - return string(result) -} - -func (a *Agent) toolGetMarketPrice(argsJSON string) string { - var args struct { - Symbol string `json:"symbol"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err) - } - - sym := strings.ToUpper(args.Symbol) - if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") { - sym += "USDT" - } - - if a.traderManager == nil { - return `{"error": "no trader manager configured"}` - } - - wantStock := isStockSymbol(sym) - for _, t := range a.traderManager.GetAllTraders() { - underlying := t.GetUnderlyingTrader() - if underlying == nil { - continue - } - // Route to correct exchange type (stock vs crypto) - isAlpaca := t.GetExchange() == "alpaca" - if wantStock && !isAlpaca { - continue - } - if !wantStock && isAlpaca { - continue - } - price, err := underlying.GetMarketPrice(sym) - if err == nil && price > 0 { - priceResult := map[string]any{ - "symbol": sym, - "price": price, - } - // For stocks, include market status - if wantStock && isAlpaca { - type marketChecker interface { - IsMarketOpen() (bool, string, error) - } - if mc, ok := underlying.(marketChecker); ok { - isOpen, status, mErr := mc.IsMarketOpen() - if mErr == nil { - priceResult["market_open"] = isOpen - priceResult["market_status"] = status - } - } - } - result, _ := json.Marshal(priceResult) - return string(result) - } - } - - return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym) -} - -func binanceFuturesGET(path string, out any) error { - req, err := http.NewRequest(http.MethodGet, binanceFuturesAPIBaseURL+path, nil) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - req = req.WithContext(ctx) - - resp, err := marketDataHTTPClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("source returned status %d", resp.StatusCode) - } - return json.NewDecoder(resp.Body).Decode(out) -} - -func (a *Agent) toolGetMarketSnapshot(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 isStockSymbol(symbol) { - return `{"error":"get_market_snapshot currently supports crypto symbols only"}` - } - 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 = 20 - case limit > 100: - limit = 100 - } - - var ticker24h struct { - Symbol string `json:"symbol"` - LastPrice string `json:"lastPrice"` - PriceChange string `json:"priceChange"` - PriceChangePercent string `json:"priceChangePercent"` - HighPrice string `json:"highPrice"` - LowPrice string `json:"lowPrice"` - Volume string `json:"volume"` - QuoteVolume string `json:"quoteVolume"` - Count int64 `json:"count"` - } - if err := binanceFuturesGET("/fapi/v1/ticker/24hr?symbol="+symbol, &ticker24h); err != nil { - return fmt.Sprintf(`{"error":"failed to fetch 24h ticker for %s: %s"}`, symbol, err) - } - - var premiumIndex struct { - Symbol string `json:"symbol"` - MarkPrice string `json:"markPrice"` - IndexPrice string `json:"indexPrice"` - LastFundingRate string `json:"lastFundingRate"` - NextFundingTime int64 `json:"nextFundingTime"` - Time int64 `json:"time"` - } - if err := binanceFuturesGET("/fapi/v1/premiumIndex?symbol="+symbol, &premiumIndex); err != nil { - return fmt.Sprintf(`{"error":"failed to fetch funding data for %s: %s"}`, symbol, err) - } - - var openInterest struct { - OpenInterest string `json:"openInterest"` - Symbol string `json:"symbol"` - Time int64 `json:"time"` - } - if err := binanceFuturesGET("/fapi/v1/openInterest?symbol="+symbol, &openInterest); err != nil { - return fmt.Sprintf(`{"error":"failed to fetch open interest for %s: %s"}`, symbol, err) - } - - var rawKlines [][]any - if err := binanceFuturesGET(fmt.Sprintf("/fapi/v1/klines?symbol=%s&interval=%s&limit=%d", symbol, interval, limit), &rawKlines); err != nil { - return fmt.Sprintf(`{"error":"failed to fetch kline for %s: %s"}`, symbol, err) - } - if len(rawKlines) == 0 { - return fmt.Sprintf(`{"error":"empty kline response for %s"}`, symbol) - } - - klines := make([]map[string]any, 0, len(rawKlines)) - highestHigh := 0.0 - lowestLow := 0.0 - firstClose := 0.0 - lastClose := 0.0 - totalVolume := 0.0 - for i, row := range rawKlines { - if len(row) < 7 { - continue - } - openVal := toSnapshotFloat(row[1]) - highVal := toSnapshotFloat(row[2]) - lowVal := toSnapshotFloat(row[3]) - closeVal := toSnapshotFloat(row[4]) - volumeVal := toSnapshotFloat(row[5]) - if i == 0 { - firstClose = closeVal - highestHigh = highVal - lowestLow = lowVal - } - if highVal > highestHigh { - highestHigh = highVal - } - if lowestLow == 0 || (lowVal > 0 && lowVal < lowestLow) { - lowestLow = lowVal - } - lastClose = closeVal - totalVolume += volumeVal - klines = append(klines, map[string]any{ - "open_time": row[0], - "open": openVal, - "high": highVal, - "low": lowVal, - "close": closeVal, - "volume": volumeVal, - "close_time": row[6], - }) - } - - periodChangePercent := 0.0 - if firstClose > 0 && lastClose > 0 { - periodChangePercent = ((lastClose - firstClose) / firstClose) * 100 - } - - tickerLastPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.LastPrice), 64) - tickerPriceChange, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.PriceChange), 64) - tickerPriceChangePercent, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.PriceChangePercent), 64) - tickerHighPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.HighPrice), 64) - tickerLowPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.LowPrice), 64) - tickerVolume, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.Volume), 64) - tickerQuoteVolume, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.QuoteVolume), 64) - markPrice, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.MarkPrice), 64) - indexPrice, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.IndexPrice), 64) - fundingRate, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.LastFundingRate), 64) - oiValue, _ := strconv.ParseFloat(strings.TrimSpace(openInterest.OpenInterest), 64) - - out, _ := json.Marshal(map[string]any{ - "symbol": symbol, - "price": tickerLastPrice, - "ticker_24h": map[string]any{ - "price_change": tickerPriceChange, - "price_change_percent": tickerPriceChangePercent, - "high_price": tickerHighPrice, - "low_price": tickerLowPrice, - "volume": tickerVolume, - "quote_volume": tickerQuoteVolume, - "trade_count": ticker24h.Count, - }, - "perp_metrics": map[string]any{ - "mark_price": markPrice, - "index_price": indexPrice, - "funding_rate": fundingRate, - "next_funding_time": premiumIndex.NextFundingTime, - "open_interest": oiValue, - }, - "kline_snapshot": map[string]any{ - "interval": interval, - "limit": len(klines), - "period_change_percent": periodChangePercent, - "highest_high": highestHigh, - "lowest_low": lowestLow, - "average_volume": totalVolume / float64(maxInt(len(klines), 1)), - "recent_klines": klines, - }, - }) - return string(out) -} - -func toSnapshotFloat(value any) float64 { - switch v := value.(type) { - case string: - f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) - return f - case float64: - return v - case json.Number: - f, _ := v.Float64() - return f - default: - return 0 - } -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func strategyLockedFieldError(lang, field string) string { - switch strings.TrimSpace(field) { - case "max_positions": - if lang == "zh" { - return "最大持仓数是 System enforced 字段,策略编辑页不提供普通输入控件,Agent 不能修改。" - } - return "Max positions is System enforced in the strategy editor and cannot be changed by the agent." - case "btceth_max_position_value_ratio": - if lang == "zh" { - return "BTC/ETH 单币仓位上限是 System enforced 字段,策略编辑页不提供普通输入控件,Agent 不能修改。" - } - return "BTC/ETH position value ratio is System enforced in the strategy editor and cannot be changed by the agent." - case "altcoin_max_position_value_ratio": - if lang == "zh" { - return "山寨币单币仓位上限是 System enforced 字段,策略编辑页不提供普通输入控件,Agent 不能修改。" - } - return "Altcoin position value ratio is System enforced in the strategy editor and cannot be changed by the agent." - case "max_margin_usage": - if lang == "zh" { - return "最大保证金使用率是 System enforced 字段,策略编辑页不提供普通输入控件,Agent 不能修改。" - } - return "Max margin usage is System enforced in the strategy editor and cannot be changed by the agent." - case "min_position_size": - if lang == "zh" { - return "最小开仓金额是系统固定值 12 USDT,手动面板里也是 System enforced,Agent 不能修改。" - } - return "The minimum position size is a fixed system value of 12 USDT. It is System enforced in the manual panel and cannot be changed by the agent." - default: - if lang == "zh" { - return "这个字段是系统固定项,Agent 不能修改。" - } - return "This field is system enforced and cannot be changed by the agent." - } -} - -func strategyConfigContainsLockedField(config map[string]any) (string, bool) { - if len(config) == 0 { - return "", false - } - if _, ok := config["min_position_size"]; ok { - return "min_position_size", true - } - if risk, ok := config["risk_control"].(map[string]any); ok { - for _, field := range []string{"max_positions", "btc_eth_max_position_value_ratio", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"} { - if _, ok := risk[field]; ok { - return field, true - } - } - } - if aiConfig, ok := config["ai_config"].(map[string]any); ok { - if risk, ok := aiConfig["risk_control"].(map[string]any); ok { - for _, field := range []string{"max_positions", "btc_eth_max_position_value_ratio", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"} { - if _, ok := risk[field]; ok { - return field, true - } - } - } - } - return "", false -} - -func validKlineInterval(interval string) bool { - switch strings.TrimSpace(strings.ToLower(interval)) { - case "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1mo": - 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"}` - } - - var args struct { - Limit int `json:"limit"` - } - if argsJSON != "" { - _ = json.Unmarshal([]byte(argsJSON), &args) - } - if args.Limit <= 0 { - args.Limit = 10 - } - if args.Limit > 50 { - args.Limit = 50 - } - - if a.traderManager == nil { - return `{"error": "no trader manager configured"}` - } - - var trades []map[string]any - var totalPnL float64 - var wins, losses int - - for id, t := range a.traderManager.GetAllTraders() { - positions, err := a.store.Position().GetClosedPositions(id, args.Limit) - if err != nil { - continue - } - tid := id - if len(tid) > 8 { - tid = tid[:8] - } - for _, pos := range positions { - pnl := pos.RealizedPnL - totalPnL += pnl - if pnl >= 0 { - wins++ - } else { - losses++ - } - - entryTime := "" - if pos.EntryTime > 0 { - entryTime = time.Unix(pos.EntryTime/1000, 0).Format("2006-01-02 15:04") - } - exitTime := "" - if pos.ExitTime > 0 { - exitTime = time.Unix(pos.ExitTime/1000, 0).Format("2006-01-02 15:04") - } - - trades = append(trades, map[string]any{ - "trader": t.GetName(), - "trader_id": tid, - "symbol": pos.Symbol, - "side": pos.Side, - "entry_price": pos.EntryPrice, - "exit_price": pos.ExitPrice, - "quantity": pos.Quantity, - "leverage": pos.Leverage, - "pnl": pnl, - "entry_time": entryTime, - "exit_time": exitTime, - }) - } - } - - if len(trades) == 0 { - return `{"trades": [], "message": "no closed trades found"}` - } - - // Sort trades by exit time (most recent first) for consistent ordering across traders - sort.Slice(trades, func(i, j int) bool { - ti, _ := trades[i]["exit_time"].(string) - tj, _ := trades[j]["exit_time"].(string) - return ti > tj // reverse chronological - }) - - // Only return up to the limit - if len(trades) > args.Limit { - trades = trades[:args.Limit] - } - - winRate := 0.0 - total := wins + losses - if total > 0 { - winRate = float64(wins) / float64(total) * 100 - } - - result, _ := json.Marshal(map[string]any{ - "trades": trades, - "summary": map[string]any{ - "total_trades": total, - "wins": wins, - "losses": losses, - "win_rate": fmt.Sprintf("%.1f%%", winRate), - "total_pnl": totalPnL, - }, - }) - return string(result) -} - -func (a *Agent) toolGetCandidateCoins(storeUserID string, userID int64, argsJSON string) string { - if a.store == nil { - return `{"error":"store unavailable"}` - } - - var args struct { - TraderID string `json:"trader_id"` - StrategyID string `json:"strategy_id"` - } - if strings.TrimSpace(argsJSON) != "" { - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) - } - } - - traderID := strings.TrimSpace(args.TraderID) - strategyID := strings.TrimSpace(args.StrategyID) - state := a.getExecutionState(userID) - if traderID == "" && state.CurrentReferences != nil && state.CurrentReferences.Trader != nil { - traderID = strings.TrimSpace(state.CurrentReferences.Trader.ID) - } - if strategyID == "" && state.CurrentReferences != nil && state.CurrentReferences.Strategy != nil { - strategyID = strings.TrimSpace(state.CurrentReferences.Strategy.ID) - } - - if traderID != "" { - return a.toolGetCandidateCoinsForTrader(storeUserID, traderID) - } - if strategyID != "" { - return a.toolGetCandidateCoinsForStrategy(storeUserID, strategyID) - } - return `{"error":"trader_id or strategy_id is required"}` -} - -func (a *Agent) toolGetCandidateCoinsForTrader(storeUserID, traderID string) string { - if a.traderManager == nil { - return `{"error":"no trader manager configured"}` - } - record, err := a.store.Trader().GetFullConfig(storeUserID, traderID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err) - } - memTrader, err := a.traderManager.GetTrader(traderID) - if err != nil { - return fmt.Sprintf(`{"error":"trader is not loaded in memory: %s"}`, err) - } - - coins, coinErr := memTrader.GetCandidateCoins() - cfg := memTrader.GetStrategyConfig() - status := memTrader.GetStatus() - isRunning, _ := status["is_running"].(bool) - payload := map[string]any{ - "trader": safeTraderForTool(record.Trader, isRunning), - "coin_source": candidateCoinSourceSummary(cfg), - "candidate_count": len(coins), - "candidate_symbols": candidateCoinSymbols(coins), - "candidates": candidateCoinDetails(coins), - } - if coinErr != nil { - payload["error"] = coinErr.Error() - } - result, _ := json.Marshal(payload) - return string(result) -} - -func (a *Agent) toolGetCandidateCoinsForStrategy(storeUserID, strategyID string) string { - record, err := a.store.Strategy().Get(storeUserID, strategyID) - if err != nil { - return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err) - } - cfg, err := record.ParseConfig() - if err != nil { - return fmt.Sprintf(`{"error":"failed to parse strategy config: %s"}`, err) - } - - engine := kernel.NewStrategyEngine(cfg) - coins, coinErr := engine.GetCandidateCoins() - payload := map[string]any{ - "strategy": safeStrategyForTool(record), - "coin_source": candidateCoinSourceSummary(cfg), - "candidate_count": len(coins), - "candidate_symbols": candidateCoinSymbols(coins), - "candidates": candidateCoinDetails(coins), - } - if coinErr != nil { - payload["error"] = coinErr.Error() - } - result, _ := json.Marshal(payload) - return string(result) -} - -func candidateCoinSourceSummary(cfg *store.StrategyConfig) map[string]any { - if cfg == nil { - return nil - } - return map[string]any{ - "source_type": cfg.CoinSource.SourceType, - "use_ai500": cfg.CoinSource.UseAI500, - "ai500_limit": cfg.CoinSource.AI500Limit, - "use_oi_top": cfg.CoinSource.UseOITop, - "oi_top_limit": cfg.CoinSource.OITopLimit, - "use_oi_low": cfg.CoinSource.UseOILow, - "oi_low_limit": cfg.CoinSource.OILowLimit, - "use_hyper_all": cfg.CoinSource.UseHyperAll, - "use_hyper_main": cfg.CoinSource.UseHyperMain, - "hyper_main_limit": cfg.CoinSource.HyperMainLimit, - "static_coins": cfg.CoinSource.StaticCoins, - "excluded_coins": cfg.CoinSource.ExcludedCoins, - } -} - -func candidateCoinSymbols(coins []kernel.CandidateCoin) []string { - out := make([]string, 0, len(coins)) - for _, coin := range coins { - out = append(out, coin.Symbol) - } - return out -} - -func candidateCoinDetails(coins []kernel.CandidateCoin) []map[string]any { - out := make([]map[string]any, 0, len(coins)) - for _, coin := range coins { - out = append(out, map[string]any{ - "symbol": coin.Symbol, - "sources": coin.Sources, - }) - } - 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. -var knownCryptoSymbols = map[string]bool{ - "BTC": true, "ETH": true, "SOL": true, "BNB": true, "XRP": true, - "DOGE": true, "ADA": true, "AVAX": true, "DOT": true, "LINK": true, - "PEPE": true, "SHIB": true, "ARB": true, "OP": true, "SUI": true, - "APT": true, "SEI": true, "TIA": true, "JUP": true, "WIF": true, - "NEAR": true, "ATOM": true, "FTM": true, "MATIC": true, "INJ": true, - "RENDER": true, "FET": true, "TAO": true, "WLD": true, "USDT": true, - "USDC": true, "BUSD": true, "DAI": true, "UNI": true, "AAVE": true, - "LDO": true, "MKR": true, "CRV": true, "PENDLE": true, "ENA": true, - "ONDO": true, "TRUMP": true, "TON": true, "TRX": true, "LTC": true, - "BCH": true, "ETC": true, "FIL": true, "ICP": true, "HBAR": true, - "VET": true, "ALGO": true, "SAND": true, "MANA": true, "AXS": true, - "GMT": true, "APE": true, "GALA": true, "IMX": true, "BLUR": true, - "STRK": true, "ZK": true, "W": true, "IO": true, "ZRO": true, - "BONK": true, "FLOKI": true, "ORDI": true, "STX": true, "RUNE": true, -} - -// isStockSymbol heuristically determines if a symbol is a stock ticker (not crypto). -// Stock tickers are 1-5 uppercase letters without numeric suffixes like "USDT". -// Known crypto base symbols (BTC, ETH, SOL etc.) are excluded. -func isStockSymbol(sym string) bool { - sym = strings.ToUpper(sym) - - // Check known crypto base symbols first (critical: "BTC", "ETH" etc. are NOT stocks) - if knownCryptoSymbols[sym] { - return false - } - - // If it already has a crypto quote suffix, it's crypto - cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"} - for _, suffix := range cryptoSuffixes { - if strings.HasSuffix(sym, suffix) && len(sym) > len(suffix) { - return false - } - } - // Pure uppercase letters, 1-5 chars = likely a stock ticker - if len(sym) >= 1 && len(sym) <= 5 { - allLetters := true - for _, c := range sym { - if c < 'A' || c > 'Z' { - allLetters = false - break - } - } - if allLetters { - return true - } - } - return false -} diff --git a/agent/trade.go b/agent/trade.go deleted file mode 100644 index a626f0ff..00000000 --- a/agent/trade.go +++ /dev/null @@ -1,538 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "math" - "nofx/market" - "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"` - 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. -type pendingTrades struct { - mu sync.RWMutex - trades map[string]*TradeAction // id -> trade -} - -func newPendingTrades() *pendingTrades { - return &pendingTrades{trades: make(map[string]*TradeAction)} -} - -func (p *pendingTrades) Add(t *TradeAction) { - p.mu.Lock() - defer p.mu.Unlock() - p.trades[t.ID] = t -} - -func (p *pendingTrades) Get(id string) *TradeAction { - p.mu.RLock() - defer p.mu.RUnlock() - return p.trades[id] -} - -func (p *pendingTrades) Remove(id string) { - p.mu.Lock() - defer p.mu.Unlock() - delete(p.trades, id) -} - -// CleanExpired removes trades older than 5 minutes. -func (p *pendingTrades) CleanExpired() { - p.mu.Lock() - defer p.mu.Unlock() - cutoff := time.Now().Add(-5 * time.Minute).Unix() - for id, t := range p.trades { - if t.CreatedAt < cutoff { - delete(p.trades, id) - } - } -} - -// parseTradeCommand parses natural language trade commands. -// Returns nil if the message is not a trade command. -func parseTradeCommand(text string) *TradeAction { - upper := strings.ToUpper(strings.TrimSpace(text)) - - // Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1" - // Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH" - - var action, symbol string - var quantity float64 - var leverage int - - words := strings.Fields(upper) - if len(words) < 2 { - return nil - } - - switch words[0] { - case "做多", "LONG", "BUY": - action = "open_long" - case "做空", "SHORT", "SELL": - action = "open_short" - case "平多": - action = "close_long" - case "平空": - action = "close_short" - case "CLOSE": - if len(words) >= 3 { - switch words[1] { - case "LONG": - action = "close_long" - words = append(words[:1], words[2:]...) // remove "LONG" - case "SHORT": - action = "close_short" - words = append(words[:1], words[2:]...) // remove "SHORT" - } - } - if action == "" { - return nil - } - default: - return nil - } - - // Parse symbol - if len(words) < 2 { - return nil - } - symbol = words[1] - // Only append USDT for crypto symbols, not stock tickers - if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") { - symbol += "USDT" - } - - // Parse quantity (optional) - if len(words) >= 3 { - fmt.Sscanf(words[2], "%f", &quantity) - } - - // Parse leverage (optional, "x10" or "10x") - if len(words) >= 4 { - lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X") - fmt.Sscanf(lev, "%d", &leverage) - } - - if action == "" || symbol == "" { - return nil - } - - return &TradeAction{ - ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()), - Action: action, - Symbol: symbol, - Quantity: quantity, - Leverage: leverage, - Status: "pending", - CreatedAt: time.Now().Unix(), - } -} - -// executeTrade performs the actual trade execution via TraderManager. -func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error { - if a.traderManager == nil { - return fmt.Errorf("no trader manager available") - } - - wantStock, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade) - if err != nil { - return err - } - if err := validateTradeAction(trade, wantStock, selectedTrader, underlyingTrader); err != nil { - return err - } - - switch trade.Action { - case "open_long": - if trade.Quantity <= 0 { - return fmt.Errorf("quantity must be > 0") - } - _, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage) - return err - case "open_short": - if trade.Quantity <= 0 { - return fmt.Errorf("quantity must be > 0") - } - _, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage) - return err - case "close_long": - _, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity) - return err - case "close_short": - _, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity) - return err - default: - return fmt.Errorf("unknown action: %s", trade.Action) - } -} - -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 isMajorTradeSymbol(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") -} - -// isMajorTradeSymbol mirrors trader/auto_trader_risk.isMajorAsset for the -// chat-execute path. BTC/ETH crypto perps and Hyperliquid XYZ assets -// (US stocks, commodities, forex) get the higher BTC/ETH risk tier — their -// per-position caps should not be clamped to the 1x altcoin tier. -func isMajorTradeSymbol(symbol string) bool { - if isBTCETHSymbol(symbol) { - return true - } - return market.IsXyzDexAsset(symbol) -} - -// formatTradeConfirmation creates a confirmation message for a pending trade. -func formatTradeConfirmation(trade *TradeAction, lang string) string { - actionNames := map[string]string{ - "open_long": "做多 (Long)", - "open_short": "做空 (Short)", - "close_long": "平多 (Close Long)", - "close_short": "平空 (Close Short)", - } - - symbol := trade.Symbol - if strings.HasSuffix(symbol, "USDT") { - symbol = strings.TrimSuffix(symbol, "USDT") - } - actionName := actionNames[trade.Action] - if actionName == "" { - actionName = trade.Action - } - - if lang == "zh" { - msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+ - "操作: %s\n"+ - "品种: %s\n", actionName, symbol) - if trade.Quantity > 0 { - msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity) - } - 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 - } - - msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+ - "Action: %s\n"+ - "Symbol: %s\n", actionName, symbol) - if trade.Quantity > 0 { - msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity) - } - 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 -} - -// handleTradeConfirmation processes a trade confirmation message. -func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) { - upper := strings.ToUpper(strings.TrimSpace(text)) - - var tradeID string - 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] - } - } - - if tradeID == "" { - return "", false - } - - if a.pending == nil { - return "", false - } - - trade := a.pending.Get(tradeID) - if trade == nil { - if lang == "zh" { - return "❌ 交易已过期或不存在。", true - } - 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" - - a.logger.Info("executing trade", - slog.String("id", trade.ID), - slog.String("action", trade.Action), - slog.String("symbol", trade.Symbol), - slog.Float64("quantity", trade.Quantity), - ) - - err := a.executeTrade(ctx, trade) - if err != nil { - trade.Status = "failed" - trade.Error = err.Error() - if lang == "zh" { - return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true - } - return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true - } - - trade.Status = "executed" - symbol := trade.Symbol - if strings.HasSuffix(symbol, "USDT") { - symbol = strings.TrimSuffix(symbol, "USDT") - } - actionEmoji := "📈" - if strings.Contains(trade.Action, "short") { - actionEmoji = "📉" - } - if strings.Contains(trade.Action, "close") { - actionEmoji = "✅" - } - - qtyStr := "" - if trade.Quantity > 0 { - qtyStr = fmt.Sprintf(" %.4f", trade.Quantity) - } - - if lang == "zh" { - return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true - } - return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true -} - -// marshals trade action to JSON for embedding in responses -func marshalTradeAction(trade *TradeAction) string { - b, _ := json.Marshal(trade) - return string(b) -} diff --git a/agent/trader_scope_test.go b/agent/trader_scope_test.go deleted file mode 100644 index 90e083ff..00000000 --- a/agent/trader_scope_test.go +++ /dev/null @@ -1,2027 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "log/slog" - "path/filepath" - "strings" - "testing" - "time" - - "nofx/mcp" - "nofx/store" -) - -type staticAIClient struct { - response string - lastRequest *mcp.Request -} - -func (c *staticAIClient) SetAPIKey(apiKey string, customURL string, customModel string) {} -func (c *staticAIClient) SetTimeout(timeout time.Duration) {} -func (c *staticAIClient) CallWithMessages(systemPrompt, userPrompt string) (string, error) { - return c.response, nil -} -func (c *staticAIClient) CallWithRequest(req *mcp.Request) (string, error) { - c.lastRequest = req - return c.response, nil -} -func (c *staticAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) { - c.lastRequest = req - if onChunk != nil { - onChunk(c.response) - } - return c.response, nil -} -func (c *staticAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) { - c.lastRequest = req - return &mcp.LLMResponse{Content: c.response}, nil -} - -func TestClassifyWorkflowTaskTreatsTraderEditAsManualPanelUpdate(t *testing.T) { - task, ok := classifyWorkflowTask("帮我把交易员小爱换策略") - if !ok { - t.Fatal("expected trader binding edit to classify") - } - if task.Skill != "trader_management" || task.Action != "update_bindings" { - t.Fatalf("unexpected task: %+v", task) - } - - task, ok = classifyWorkflowTask("帮我把交易员小爱扫描间隔改成10分钟") - if !ok { - t.Fatal("expected trader manual-panel edit to classify") - } - if task.Skill != "trader_management" || task.Action != "update_bindings" { - t.Fatalf("unexpected trader update task: %+v", task) - } -} - -func TestGetDecisionsToolReturnsRecentTraderDecisionEvidence(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "decision-evidence.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - traderCfg := &store.Trader{ - ID: "trader-claw402", - UserID: "default", - Name: "claw402", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 6.21, - ScanIntervalMinutes: 3, - IsRunning: true, - } - if err := st.Trader().Create(traderCfg); err != nil { - t.Fatalf("seed trader: %v", err) - } - if err := st.Decision().LogDecision(&store.DecisionRecord{ - TraderID: traderCfg.ID, - CycleNumber: 150, - Timestamp: time.Now().Add(-3 * time.Minute), - Success: true, - AIRequestDurationMs: 12095, - CandidateCoins: []string{"BTCUSDT"}, - ExecutionLog: []string{"AI call duration: 12095 ms", "✓ BTCUSDT wait succeeded"}, - Decisions: []store.DecisionAction{{ - Symbol: "BTCUSDT", - Action: "wait", - Success: true, - }}, - }); err != nil { - t.Fatalf("seed wait decision: %v", err) - } - if err := st.Decision().LogDecision(&store.DecisionRecord{ - TraderID: traderCfg.ID, - CycleNumber: 151, - Timestamp: time.Now(), - Success: false, - ErrorMessage: "Failed to get AI decision: failed to parse AI response: decision validation failed: decision #1 validation failed: BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT", - AIRequestDurationMs: 25878, - CandidateCoins: []string{"BTCUSDT"}, - ExecutionLog: []string{"AI call duration: 25878 ms"}, - DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`, - }); err != nil { - t.Fatalf("seed rejected decision: %v", err) - } - - raw := a.toolGetDecisions("default", `{"trader_name":"claw402","limit":2}`) - for _, want := range []string{"claw402", "BTCUSDT", "wait", "wait succeeded", "opening amount too small", "must be ≥60.00 USDT"} { - if !strings.Contains(raw, want) { - t.Fatalf("expected decision evidence %q in tool response, got: %s", want, raw) - } - } -} - -func TestTraderDiagnosisReadsDecisionsInsteadOfAskingUserForScreenshot(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-decisions.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - traderCfg := &store.Trader{ - ID: "trader-claw402", - UserID: "default", - Name: "claw402", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 6.21, - ScanIntervalMinutes: 3, - IsRunning: true, - } - if err := st.Trader().Create(traderCfg); err != nil { - t.Fatalf("seed trader: %v", err) - } - if err := st.Decision().LogDecision(&store.DecisionRecord{ - TraderID: traderCfg.ID, - CycleNumber: 1, - Timestamp: time.Now(), - Success: true, - AIRequestDurationMs: 13249, - CandidateCoins: []string{"BTCUSDT"}, - ExecutionLog: []string{"AI call duration: 13249 ms", "✓ BTCUSDT wait succeeded"}, - Decisions: []store.DecisionAction{{ - Symbol: "BTCUSDT", - Action: "wait", - Success: true, - }}, - }); err != nil { - t.Fatalf("seed decision: %v", err) - } - - reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢") - for _, want := range []string{"claw402 是运行的", "主动选择等待", "入场标准", "该怎么办"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected diagnosis to include %q, got: %s", want, reply) - } - } - for _, unexpected := range []string{"截图", "自己点", "不能直接帮你查", "诊断证据包", "AI 调用耗时", "status 402", "404", "EOF", "订阅"} { - if strings.Contains(reply, unexpected) { - t.Fatalf("diagnosis should not ask user to self-serve %q, got: %s", unexpected, reply) - } - } -} - -func TestTraderDiagnosisAmountTooSmallUsesUserFacingCauseAndAction(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-amount-too-small.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - traderCfg := &store.Trader{ - ID: "trader-claw402", - UserID: "default", - Name: "claw402", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 6.21, - ScanIntervalMinutes: 3, - IsRunning: true, - } - if err := st.Trader().Create(traderCfg); err != nil { - t.Fatalf("seed trader: %v", err) - } - if err := st.Decision().LogDecision(&store.DecisionRecord{ - TraderID: traderCfg.ID, - CycleNumber: 2, - Timestamp: time.Now(), - Success: false, - ErrorMessage: "Failed to get AI decision: failed to parse AI response: decision validation failed: decision #1 validation failed: BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT", - AIRequestDurationMs: 25878, - CandidateCoins: []string{"BTCUSDT"}, - ExecutionLog: []string{"AI call duration: 25878 ms"}, - DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`, - }); err != nil { - t.Fatalf("seed decision: %v", err) - } - - reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢") - for _, want := range []string{"不是没运行", "账户资金太小", "开仓金额约 28.00 USDT", "最小下单要求 60.00 USDT", "增加账户资金", "不能手动修改"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected diagnosis to include %q, got: %s", want, reply) - } - } - for _, unexpected := range []string{"诊断证据包", "辅助异常", "status 402", "404", "EOF", "订阅", "数据服务", "position_size_usd", "AI 调用耗时"} { - if strings.Contains(reply, unexpected) { - t.Fatalf("diagnosis should stay user-facing and avoid %q, got: %s", unexpected, reply) - } - } -} - -func TestTraderDiagnosisUsesLLMToReasonOverCollectedEvidence(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-llm.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - llm := &staticAIClient{response: "claw402 的最终原因是账户资金太小,最近想开 BTCUSDT 空单但金额低于最小下单要求。该怎么办:增加账户资金,或换更适合小资金的策略/标的。"} - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(llm) - traderCfg := &store.Trader{ - ID: "trader-claw402", - UserID: "default", - Name: "claw402", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 6.21, - ScanIntervalMinutes: 3, - IsRunning: true, - } - if err := st.Trader().Create(traderCfg); err != nil { - t.Fatalf("seed trader: %v", err) - } - if err := st.Decision().LogDecision(&store.DecisionRecord{ - TraderID: traderCfg.ID, - CycleNumber: 3, - Timestamp: time.Now(), - Success: false, - ErrorMessage: "BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT", - CandidateCoins: []string{"BTCUSDT"}, - DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`, - }); err != nil { - t.Fatalf("seed decision: %v", err) - } - - reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢") - if reply != llm.response { - t.Fatalf("expected LLM diagnosis response, got: %s", reply) - } - if llm.lastRequest == nil || len(llm.lastRequest.Messages) < 2 { - t.Fatalf("expected LLM request to be captured") - } - prompt := llm.lastRequest.Messages[1].Content - for _, want := range []string{"Evidence JSON", "claw402", "BTCUSDT", "opening amount too small", "decision_json"} { - if !strings.Contains(prompt, want) { - t.Fatalf("expected LLM evidence prompt to include %q, got: %s", want, prompt) - } - } -} - -func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) { - primer := buildSkillDomainPrimer("zh", "trader_management") - for _, want := range []string{ - "交易员是装配层", - "默认只处理绑定关系", - "应切到对应 management skill", - } { - if !strings.Contains(primer, want) { - t.Fatalf("expected primer to contain %q, got: %s", want, primer) - } - } -} - -func TestStrategyDomainPrimerKeepsSourceCountsWithinEditorBounds(t *testing.T) { - primer := buildSkillDomainPrimerForSession("zh", skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "strategy_type": "ai_trading", - }, - }) - for _, want := range []string{ - "AI500/OI Top/OI Low 选币数量范围 1~10", - "没有 mixed/混合模式", - "BTC/ETH 最大杠杆 1~20", - "min_confidence 50~100", - } { - if !strings.Contains(primer, want) { - t.Fatalf("expected primer to contain %q, got: %s", want, primer) - } - } -} - -func TestStrategyConfigSchemaOnlyExposesEditorCoinSourceFields(t *testing.T) { - schema := strategyConfigSchema() - properties := schema["properties"].(map[string]any) - aiConfig := properties["ai_config"].(map[string]any) - aiProperties := aiConfig["properties"].(map[string]any) - coinSource := aiProperties["coin_source"].(map[string]any) - coinProperties := coinSource["properties"].(map[string]any) - for _, unexpected := range []string{"use_hyper_all", "use_hyper_main", "hyper_main_limit"} { - if _, ok := coinProperties[unexpected]; ok { - t.Fatalf("strategy config schema should not expose non-editor coin source field %s", unexpected) - } - } - ai500 := coinProperties["ai500_limit"].(map[string]any) - if ai500["maximum"] != 10 { - t.Fatalf("expected AI500 maximum 10, got %+v", ai500) - } -} - -func TestLoadEnabledModelOptionsUseConfigNameAsPrimaryLabel(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-model-options.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - - options := a.loadEnabledModelOptions("default") - if len(options) != 1 { - t.Fatalf("expected one model option, got %d", len(options)) - } - if options[0].Name != "DeepSeek AI" { - t.Fatalf("expected primary option label to stay on config name, got %q", options[0].Name) - } - if !strings.Contains(options[0].Hint, "deepseek-chat") || !strings.Contains(options[0].Hint, "deepseek") { - t.Fatalf("expected hint to retain runtime model/provider context, got %q", options[0].Hint) - } -} - -func TestHydrateCreateTraderSlotReferencesNormalizesModelIDFromVisibleName(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-model-id-normalize.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - - session := skillSession{ - Name: "trader_management", - Action: "create", - Fields: map[string]string{ - "model_id": "DeepSeek AI", - }, - } - a.hydrateCreateTraderSlotReferences("default", &session) - if got := fieldValue(session, "model_id"); got != "default_deepseek" { - t.Fatalf("expected visible model name in model_id slot to normalize to actual id, got %q", got) - } - if got := fieldValue(session, "model_name"); got != "DeepSeek AI" { - t.Fatalf("expected normalized model name to be preserved, got %q", got) - } -} - -func TestHydrateCreateTraderSlotReferencesNormalizesExchangeIDFromVisibleName(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-exchange-id-normalize.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - exchangeID, err := st.Exchange().Create("default", "okx", "小偶", true, "api-test", "secret-test", "pass", false, "", false, false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed exchange: %v", err) - } - - session := skillSession{ - Name: "trader_management", - Action: "create", - Fields: map[string]string{ - "exchange_id": "小偶", - }, - } - a.hydrateCreateTraderSlotReferences("default", &session) - if got := fieldValue(session, "exchange_id"); got != exchangeID { - t.Fatalf("expected visible exchange name in exchange_id slot to normalize to actual id, got %q", got) - } - if got := fieldValue(session, "exchange_name"); got != "小偶" { - t.Fatalf("expected normalized exchange name to be preserved, got %q", got) - } -} - -func TestToolDeleteTraderRejectsRunningTrader(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "delete-running-trader.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.Trader().Create(&store.Trader{ - ID: "trader-running", - UserID: "default", - Name: "运行中", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 100, - ScanIntervalMinutes: 3, - IsRunning: true, - }); err != nil { - t.Fatalf("seed trader: %v", err) - } - - resp := a.toolDeleteTrader("default", "trader-running") - if !strings.Contains(resp, "stop it before deleting") { - t.Fatalf("expected running trader delete to be rejected, got: %s", resp) - } - traders, err := st.Trader().List("default") - if err != nil { - t.Fatalf("list traders: %v", err) - } - if len(traders) != 1 { - t.Fatalf("expected running trader to remain, got %d traders", len(traders)) - } -} - -func TestBulkTraderDeleteDeletesOnlyStoppedTraders(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - for _, trader := range []*store.Trader{ - {ID: "trader-stopped", UserID: "default", Name: "已停止", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: false}, - {ID: "trader-running", UserID: "default", Name: "运行中", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: true}, - } { - if err := st.Trader().Create(trader); err != nil { - t.Fatalf("seed trader %s: %v", trader.ID, err) - } - } - - session := skillSession{ - Name: "trader_management", - Action: "delete", - Phase: "await_confirmation", - Fields: map[string]string{ - "bulk_scope": "all", - skillDAGStepField: "await_confirmation", - }, - } - resp := a.executeBulkTraderDelete("default", 99, "zh", "确认", session) - if !strings.Contains(resp, "成功删除 1 个") || !strings.Contains(resp, "运行中") { - t.Fatalf("expected stopped trader deleted and running trader skipped, got: %s", resp) - } - traders, err := st.Trader().List("default") - if err != nil { - t.Fatalf("list traders: %v", err) - } - if len(traders) != 1 || traders[0].ID != "trader-running" { - t.Fatalf("expected only running trader to remain, got: %+v", traders) - } -} - -func TestBulkTraderDeleteRequiresConfirmationBeforeDeleting(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders-confirmation.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.Trader().Create(&store.Trader{ - ID: "trader-stopped", - UserID: "default", - Name: "已停止", - AIModelID: "model-1", - ExchangeID: "exchange-1", - InitialBalance: 100, - ScanIntervalMinutes: 3, - IsRunning: false, - }); err != nil { - t.Fatalf("seed trader: %v", err) - } - - session := skillSession{ - Name: "trader_management", - Action: "delete", - Fields: map[string]string{ - "bulk_scope": "all", - }, - } - resp := a.executeBulkTraderDelete("default", 99, "zh", "全部删除", session) - if !strings.Contains(resp, "请回复“确认”继续") { - t.Fatalf("expected confirmation prompt, got: %s", resp) - } - traders, err := st.Trader().List("default") - if err != nil { - t.Fatalf("list traders: %v", err) - } - if len(traders) != 1 { - t.Fatalf("expected trader to remain before confirmation, got %d traders", len(traders)) - } -} - -func TestResolveTargetSelectionMatchesUniqueNameInUserText(t *testing.T) { - options := []traderSkillOption{ - {ID: "exchange-a", Name: "okx"}, - {ID: "exchange-b", Name: "为:小易"}, - {ID: "exchange-c", Name: "小偶"}, - } - resolved := resolveTargetSelection("先把 为:小易 删掉,其他 5 个先保留", options, nil) - if resolved.Ref == nil { - t.Fatal("expected target ref to resolve from user text") - } - if resolved.Ref.ID != "exchange-b" || resolved.Ref.Name != "为:小易" { - t.Fatalf("unexpected resolved target: %+v", resolved.Ref) - } -} - -func TestStrategyUpdateUsesExplicitTargetOverCurrentReference(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-explicit-target-over-current.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - userID := int64(99) - - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - for _, strategy := range []*store.Strategy{ - {ID: "strategy-short", UserID: "default", Name: "BTC趋势做空", ConfigVisible: true, Config: string(rawCfg)}, - {ID: "strategy-long", UserID: "default", Name: "AI500 做多策略", ConfigVisible: true, Config: string(rawCfg)}, - } { - if err := st.Strategy().Create(strategy); err != nil { - t.Fatalf("seed strategy %s: %v", strategy.ID, err) - } - } - a.saveReferenceMemory(userID, &CurrentReferences{ - Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"}, - }, nil) - - patch := map[string]any{ - "coin_source": map[string]any{ - "source_type": "ai500", - "use_ai500": true, - "ai500_limit": 5, - }, - "custom_prompt": "AI500 强做多策略:只寻找强趋势多头机会。", - } - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "update_config", - Phase: "collecting", - Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)}, - } - - reply, handled := a.handleSimpleEntitySkill( - "default", - userID, - "zh", - "我想基于AI500 做多策略来调整成更强的做多逻辑", - session, - "strategy_management", - "update_config", - a.loadStrategyOptions("default"), - ) - if !handled { - t.Fatalf("expected handler to handle request") - } - if !strings.Contains(reply, "已更新策略配置") { - t.Fatalf("expected strategy update reply, got: %s", reply) - } - - shortStrategy, err := st.Strategy().Get("default", "strategy-short") - if err != nil { - t.Fatalf("load short strategy: %v", err) - } - longStrategy, err := st.Strategy().Get("default", "strategy-long") - if err != nil { - t.Fatalf("load long strategy: %v", err) - } - if strings.Contains(shortStrategy.Config, "强做多") { - t.Fatalf("current reference strategy was incorrectly updated: %s", shortStrategy.Config) - } - if !strings.Contains(longStrategy.Config, "强做多") { - t.Fatalf("explicitly named strategy was not updated: %s", longStrategy.Config) - } -} - -func TestStrategyUpdateDoesNotInferTargetFromCurrentReference(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-no-current-reference-fallback.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - userID := int64(100) - - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-short", - UserID: "default", - Name: "BTC趋势做空", - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - a.saveReferenceMemory(userID, &CurrentReferences{ - Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"}, - }, nil) - - patch := map[string]any{"custom_prompt": "不应被写入"} - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "update_config", - Phase: "collecting", - Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)}, - } - - reply, handled := a.handleSimpleEntitySkill( - "default", - userID, - "zh", - "帮我把策略改强一点", - session, - "strategy_management", - "update_config", - a.loadStrategyOptions("default"), - ) - if !handled { - t.Fatalf("expected handler to ask for target") - } - if !strings.Contains(reply, "确定目标对象") && !strings.Contains(reply, "明确要操作的是哪一个对象") { - t.Fatalf("expected target clarification, got: %s", reply) - } - strategy, err := st.Strategy().Get("default", "strategy-short") - if err != nil { - t.Fatalf("load strategy: %v", err) - } - if strings.Contains(strategy.Config, "不应被写入") { - t.Fatalf("strategy was incorrectly updated through current reference fallback: %s", strategy.Config) - } -} - -func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-custom", - UserID: "default", - Name: "自定义策略", - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - - session := skillSession{ - Name: "strategy_management", - Action: "delete", - Fields: map[string]string{ - "bulk_scope": "all", - }, - } - resp := a.executeStrategyManagementAction("default", 99, "zh", "全部删除", session) - if !strings.Contains(resp, "请回复“确认”继续") { - t.Fatalf("expected confirmation prompt, got: %s", resp) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - found := false - for _, strategy := range strategies { - if strategy.ID == "strategy-custom" { - found = true - } - } - if !found { - t.Fatal("expected strategy to remain before confirmation") - } -} - -func TestEnsureLiveTargetReferenceFallsBackFromStaleIDToName(t *testing.T) { - session := skillSession{ - TargetRef: &EntityReference{ - ID: "stale-id", - Name: "小易", - }, - } - options := []traderSkillOption{ - {ID: "exchange-a", Name: "okx"}, - {ID: "exchange-b", Name: "为:小易"}, - } - if !ensureLiveTargetReference(&session, options) { - t.Fatal("expected stale id with matching name to resolve") - } - if session.TargetRef == nil || session.TargetRef.ID != "exchange-b" || session.TargetRef.Name != "为:小易" { - t.Fatalf("unexpected target ref after live check: %+v", session.TargetRef) - } -} - -func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "trader-create-missing-prompt.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil { - t.Fatalf("seed model: %v", err) - } - exchangeID, err := st.Exchange().Create("default", "okx", "OKX 主账户", true, "api-test", "secret-test", "pass", false, "", false, false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed exchange: %v", err) - } - _ = exchangeID - cfg := store.GetDefaultStrategyConfig("zh") - rawCfg, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal strategy config: %v", err) - } - if err := st.Strategy().Create(&store.Strategy{ - ID: "strategy-ai500", - UserID: "default", - Name: "AI500稳重策略", - Description: "test", - IsPublic: false, - ConfigVisible: true, - Config: string(rawCfg), - }); err != nil { - t.Fatalf("seed strategy: %v", err) - } - - session := skillSession{ - Name: "trader_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{}, - } - prompt := a.buildTraderCreateMissingPrompt("default", "zh", session, a.buildTraderCreateConversationResources("default", session)) - for _, want := range []string{"名称", "交易所", "模型", "策略"} { - if !strings.Contains(prompt, want) { - t.Fatalf("expected missing prompt to include %q, got: %s", want, prompt) - } - } - for _, want := range []string{"现有交易所", "现有模型", "现有策略"} { - if !strings.Contains(prompt, want) { - t.Fatalf("expected missing prompt to include options line %q, got: %s", want, prompt) - } - } -} - -func TestTraderCreateRequiresResolvedResourceIDs(t *testing.T) { - session := skillSession{ - Name: "trader_management", - Action: "create", - Fields: map[string]string{ - "name": "凯茵", - "exchange_name": "Binance", - "model_name": "deepseek", - "strategy_name": "BTC趋势做空", - }, - } - - missing := missingFieldKeysForSkillSession(session) - for _, want := range []string{"exchange_name", "model_name", "strategy_name"} { - if !containsString(missing, want) { - t.Fatalf("expected unresolved %s to remain missing, got %v", want, missing) - } - } - - active := ActiveSkillSession{ - SkillName: "trader_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "凯茵", - "exchange_name": "Binance", - "model_name": "deepseek", - "strategy_name": "BTC趋势做空", - }, - } - activeMissing := missingRequiredFields(active) - for _, want := range []string{"exchange", "model", "strategy"} { - if !containsString(activeMissing, want) { - t.Fatalf("expected unresolved active slot %s to remain missing, got %v", want, activeMissing) - } - } -} - -func TestStrategyCreateUsesConfigPatch(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-config-patch.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - patch := map[string]any{ - "strategy_type": "ai_trading", - "coin_source": map[string]any{ - "source_type": "static", - "static_coins": []any{"BTCUSDT"}, - "use_ai500": false, - "use_oi_low": true, - "oi_low_limit": 1, - }, - "risk_control": map[string]any{ - "max_positions": 1, - "btc_eth_max_leverage": 5, - "altcoin_max_leverage": 5, - "min_confidence": 80, - "min_risk_reward_ratio": 3, - }, - "indicators": map[string]any{ - "klines": map[string]any{ - "primary_timeframe": "5m", - "selected_timeframes": []any{"5m", "15m"}, - }, - }, - "prompt_sections": map[string]any{ - "trading_frequency": "每天最多 2-4 笔,避免过度交易。", - "entry_standards": "只在 BTC 下跌趋势确认时考虑做空,禁止把做多作为主方向。", - }, - "custom_prompt": "BTC 趋势做空策略:仅关注 BTCUSDT,趋势向下且反弹受阻时才考虑开空。", - } - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "BTC趋势做空", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session) - if !strings.Contains(reply, "已创建策略") { - t.Fatalf("expected created reply, got: %s", reply) - } - - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - var created *store.Strategy - for _, strategy := range strategies { - if strategy.Name == "BTC趋势做空" { - created = strategy - break - } - } - if created == nil { - t.Fatalf("expected strategy to be created") - } - - var cfg store.StrategyConfig - if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "BTCUSDT" { - t.Fatalf("expected BTC static coin source, got %+v", cfg.CoinSource) - } - if cfg.CoinSource.UseAI500 { - t.Fatalf("expected AI500 disabled for explicit BTC strategy") - } - if cfg.CoinSource.UseOILow { - t.Fatalf("expected OI low disabled when source_type is static, got %+v", cfg.CoinSource) - } - if cfg.RiskControl.MaxPositions != 3 || cfg.RiskControl.MinConfidence != 80 { - t.Fatalf("expected risk patch to apply, got %+v", cfg.RiskControl) - } - if !strings.Contains(cfg.CustomPrompt, "BTC 趋势做空") || !strings.Contains(cfg.PromptSections.EntryStandards, "做空") { - t.Fatalf("expected prompt patch to apply, got custom=%q entry=%q", cfg.CustomPrompt, cfg.PromptSections.EntryStandards) - } -} - -func TestAIStrategySystemEnforcedFieldsAreDisplayedButNotEditable(t *testing.T) { - cfg := store.GetDefaultStrategyConfig("zh") - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "我的AI策略", - }, - } - reply := formatStrategyCreateFinalConfirmation("zh", session, cfg) - for _, want := range []string{"最大持仓数(System enforced)", "BTC/ETH 单币仓位上限(System enforced)", "最大保证金使用率(System enforced)", "最小开仓金额(System enforced)"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected final summary to display %q, got: %s", want, reply) - } - } - - resp := applyStrategyConfigPatch(&cfg, "max_margin_usage", "0.5") - if resp == nil || !strings.Contains(resp.Error(), "System enforced") { - t.Fatalf("expected system enforced edit to be rejected, got: %v", resp) - } -} - -func TestStrategyCreateNaturalLanguageDoesNotBypassTemplateType(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-draft-two-turn.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - active := ActiveSkillSession{ - SessionID: "as_test", - UserID: 1, - SkillName: "strategy_management", - ActionName: "create", - Goal: "真的去创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍", - CollectedFields: map[string]any{ - "name": "BTCETH_15m_趋势", - }, - LocalHistory: []chatMessage{ - {Role: "user", Content: "真的去创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍"}, - {Role: "assistant", Content: "现在只差一个名称。"}, - {Role: "user", Content: "BTCETH_15m_趋势"}, - }, - } - session := activeToLegacySkillSession(active) - reply := a.handleStrategyCreateSkill("default", 1, "zh", "BTCETH_15m_趋势", session) - if !strings.Contains(reply, "先选择策略类型") { - t.Fatalf("expected strategy type question instead of legacy natural-language parsing, got: %s", reply) - } - - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - if len(strategies) != 0 { - t.Fatalf("expected no strategy before template is complete, got %d", len(strategies)) - } -} - -func TestStrategyCreateAsksTypeBeforeUsingDefaultTemplateType(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-ask-type.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "我的策略", - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "我的策略", session) - if !strings.Contains(reply, "先选择策略类型") || strings.Contains(reply, "交易所") { - t.Fatalf("expected strategy type question without exchange binding, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "我的策略" { - t.Fatalf("strategy should not be created before type is confirmed") - } - } -} - -func TestStrategyCreateConfirmationStillRequiresType(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-confirm-no-type.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "我的策略", - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session) - if !strings.Contains(reply, "先选择策略类型") { - t.Fatalf("expected type question before create, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "我的策略" { - t.Fatalf("strategy should not be created before type is known") - } - } -} - -func TestStrategyCreateStandaloneNameCanContainStrategyWord(t *testing.T) { - active := ActiveSkillSession{ - SessionID: "as_test", - UserID: 1, - SkillName: "strategy_management", - ActionName: "create", - Goal: "创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍", - CollectedFields: map[string]any{}, - LocalHistory: []chatMessage{ - {Role: "user", Content: "创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍"}, - {Role: "assistant", Content: "现在只差一个名称。"}, - {Role: "user", Content: "趋势策略A"}, - }, - } - - session := activeToLegacySkillSession(active) - if got := fieldValue(session, "name"); got != "趋势策略A" { - t.Fatalf("expected standalone strategy name to be preserved, got %q", got) - } -} - -func TestStrategyCreateProposesGridDefaultsBeforeCreate(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-draft.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "我的网格策略", - "strategy_type": "grid_trading", - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "grid_trading", session) - if !strings.Contains(reply, "还缺") || !strings.Contains(reply, "交易对") || !strings.Contains(reply, "网格数量") { - t.Fatalf("expected grid template missing-fields prompt, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "我的网格策略" { - t.Fatalf("strategy should not be created before grid config is ready") - } - } -} - -func TestStrategyCreateSwitchingTypeDropsPreviousTemplateFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-switch-type.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - aiPatch := map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{"source_type": "ai500"}, - "risk_control": map[string]any{ - "min_confidence": 80, - "min_risk_reward_ratio": 3, - }, - }, - } - rawPatch, _ := json.Marshal(aiPatch) - session := skillSession{ - Name: "strategy_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{ - "name": "我的网格大大", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "算了选网格策略吧", session) - if !strings.Contains(reply, "还缺") || !strings.Contains(reply, "交易对") { - t.Fatalf("expected grid missing fields after type switch, got: %s", reply) - } - if strings.Contains(reply, "AI500") || strings.Contains(reply, "置信度") { - t.Fatalf("type switch should not reuse AI fields or default BTC summary, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "我的网格大大" { - t.Fatalf("strategy should not be created after switching type with missing grid fields") - } - } -} - -func TestActiveStrategyCreateFilterIsolatesTemplateOnTypeSwitch(t *testing.T) { - session := ActiveSkillSession{ - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "我的网格大大", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{"source_type": "ai500"}, - }, - }, - }, - } - filtered := filterExtractedDataForActiveSession(session, map[string]any{ - "strategy_type": "grid_trading", - strategyCreateConfigPatchField: map[string]any{ - "strategy_type": "grid_trading", - "grid_config": map[string]any{"symbol": "ETHUSDT"}, - "ai_config": map[string]any{"coin_source": map[string]any{"source_type": "ai500"}}, - }, - }, "zh") - mergeExtractedData(&session, filtered) - if got := session.CollectedFields["strategy_type"]; got != "grid_trading" { - t.Fatalf("expected switched strategy type, got %+v", session.CollectedFields) - } - if _, ok := session.CollectedFields["source_type"]; ok { - t.Fatalf("expected AI-only flat fields to be dropped, got %+v", session.CollectedFields) - } - patch := session.CollectedFields[strategyCreateConfigPatchField].(map[string]any) - if _, ok := patch["ai_config"]; ok { - t.Fatalf("expected ai_config to be removed from grid patch, got %+v", patch) - } - if _, ok := patch["grid_config"]; !ok { - t.Fatalf("expected grid_config to remain, got %+v", patch) - } -} - -func TestStrategyCreateConfirmationFillsMissingGridDefaults(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-confirm-defaults.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "餐巾纸", - "strategy_type": "grid_trading", - "symbol": "BTCUSDT", - "awaiting_final_confirmation": "true", - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "好的,就这样", session) - if !strings.Contains(reply, "还缺") || strings.Contains(reply, "已创建策略") { - t.Fatalf("expected missing grid fields instead of default create, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "餐巾纸" { - t.Fatalf("strategy should not be created before grid template is complete") - } - } -} - -func TestStrategyCreateGridDraftSummaryDoesNotMentionAIFields(t *testing.T) { - reply := formatStrategyCreateDraftSummary("zh", "我的网格策略", "grid_trading", nil, nil) - for _, unexpected := range []string{"选币来源", "最大持仓", "置信度", "盈亏比", "多周期"} { - if strings.Contains(reply, unexpected) { - t.Fatalf("grid draft summary should not mention AI-only field %q: %s", unexpected, reply) - } - } - for _, expected := range []string{"网格策略", "交易对", "网格数量", "总投入", "杠杆", "价格区间"} { - if !strings.Contains(reply, expected) { - t.Fatalf("grid draft summary should mention %q, got: %s", expected, reply) - } - } -} - -func TestAllowedStrategyCreateFieldsUseConfigPatchOnly(t *testing.T) { - gridSession := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "strategy_type": "grid_trading", - }, - } - gridSpecs := allowedFieldSpecsForSkillSession(gridSession, "zh") - gridKeys := make(map[string]bool, len(gridSpecs)) - for _, spec := range gridSpecs { - gridKeys[spec.Key] = true - } - for _, expected := range []string{"strategy_type", "name", strategyCreateConfigPatchField, "awaiting_final_confirmation"} { - if !gridKeys[expected] { - t.Fatalf("expected grid field %q in specs", expected) - } - } - for _, unexpected := range []string{"symbol", "grid_count", "total_investment", "source_type", "selected_timeframes", "min_confidence", "min_risk_reward_ratio"} { - if gridKeys[unexpected] { - t.Fatalf("strategy create specs should not expose template field %q outside config_patch", unexpected) - } - } -} - -func TestStrategyCreateReadyConfigRequiresFinalConfirmation(t *testing.T) { - patch := map[string]any{ - "strategy_type": "grid_trading", - "grid_config": map[string]any{ - "symbol": "BTCUSDT", - "grid_count": 20, - "total_investment": 200, - "leverage": 2, - "use_atr_bounds": true, - "atr_multiplier": 2, - "distribution": "uniform", - "max_drawdown_pct": 15, - "stop_loss_pct": 8, - "daily_loss_limit_pct": 6, - "use_maker_only": true, - "enable_direction_adjust": false, - }, - } - rawPatch, _ := json.Marshal(patch) - session := ActiveSkillSession{ - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "小白策略", - "strategy_type": "grid_trading", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session) - if !blocked { - t.Fatalf("expected ready strategy create config to require final confirmation") - } - if !strings.Contains(reply, "确认后我再创建") || !strings.Contains(reply, "BTCUSDT") || !strings.Contains(reply, "20") { - t.Fatalf("expected final confirmation summary, got: %s", reply) - } - - session.CollectedFields["awaiting_final_confirmation"] = true - if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); !blocked { - t.Fatalf("same-turn awaiting flag without prior assistant confirmation should still be blocked") - } - session.LocalHistory = append(session.LocalHistory, chatMessage{Role: "assistant", Content: reply}) - if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); blocked { - t.Fatalf("already-confirmable session should not be blocked") - } -} - -func TestStrategyCreateConfirmationForcesSynchronousExecutionRoute(t *testing.T) { - patch := map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{ - "source_type": "ai500", - "use_ai500": true, - "ai500_limit": 5, - }, - "indicators": map[string]any{ - "klines": map[string]any{ - "primary_timeframe": "1m", - "selected_timeframes": []any{"1m", "5m"}, - }, - }, - "risk_control": map[string]any{ - "btc_eth_max_leverage": 3, - "altcoin_max_leverage": 2, - "min_confidence": 70, - "min_risk_reward_ratio": 1.5, - }, - "prompt_sections": map[string]any{ - "trading_frequency": "高频交易但避免过度交易。", - "entry_standards": "只在短周期趋势明确且风险收益合理时开仓。", - }, - }, - } - rawPatch, _ := json.Marshal(patch) - session := ActiveSkillSession{ - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "AI500高频交易", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: string(rawPatch), - }, - LocalHistory: []chatMessage{ - {Role: "assistant", Content: "请确认是否按以上设置创建?如果没问题,我就执行创建。"}, - }, - } - for _, confirmation := range []string{"确认创建", "可以", "好的", "没问题", "ok"} { - t.Run(confirmation, func(t *testing.T) { - sessionCopy := session - sessionCopy.CollectedFields = map[string]any{ - "name": "AI500高频交易", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: string(rawPatch), - } - decision := activeSessionStepDecision{ - Route: "ask_user", - Reply: "好的,正在为你创建“AI500高频交易”策略……", - } - - if !maybeForceStrategyCreateExecutionOnConfirmation("zh", confirmation, &sessionCopy, &decision) { - t.Fatalf("expected confirmation %q to force execute route", confirmation) - } - if decision.Route != "execute_skill" || decision.Reply != "" { - t.Fatalf("expected synchronous execute route with empty reply, got %+v", decision) - } - if !activeFieldBool(sessionCopy.CollectedFields["awaiting_final_confirmation"]) { - t.Fatalf("expected awaiting_final_confirmation to be set before execution") - } - }) - } -} - -func TestStrategyCreateConfirmationForcesExecutionWithoutPriorPromptPhrase(t *testing.T) { - patch := map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{ - "source_type": "ai500", - "use_ai500": true, - "ai500_limit": 5, - }, - "indicators": map[string]any{ - "klines": map[string]any{ - "primary_timeframe": "3m", - "selected_timeframes": []any{"3m", "5m", "15m"}, - }, - }, - "risk_control": map[string]any{ - "btc_eth_max_leverage": 3, - "altcoin_max_leverage": 2, - "min_confidence": 75, - "min_risk_reward_ratio": 1.5, - }, - "prompt_sections": map[string]any{ - "trading_frequency": "高频但避免过度交易。", - "entry_standards": "趋势明确、成交量配合、风险收益合理才开仓。", - }, - }, - } - rawPatch, _ := json.Marshal(patch) - session := ActiveSkillSession{ - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "高频稳健AI500", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: string(rawPatch), - }, - LocalHistory: []chatMessage{ - {Role: "assistant", Content: "这是我建议的一版配置。"}, - }, - } - decision := activeSessionStepDecision{ - Route: "ask_user", - Reply: "好的,马上为你创建“高频稳健AI500”策略。", - } - if !maybeForceStrategyCreateExecutionOnConfirmation("zh", "确认创建", &session, &decision) { - t.Fatalf("expected ready strategy confirmation to force execute even without prior prompt phrase") - } - if decision.Route != "execute_skill" || decision.Reply != "" { - t.Fatalf("expected execute route, got %+v", decision) - } -} - -func TestUnifiedPlannedAgentCannotStealActiveStrategyCreateConfirmation(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-planner-steal.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"好的,马上为你创建策略。","extracted_data":{}}`}) - - patch := map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{ - "source_type": "ai500", - "use_ai500": true, - "ai500_limit": 5, - }, - "indicators": map[string]any{ - "klines": map[string]any{ - "primary_timeframe": "5m", - "selected_timeframes": []any{"1m", "5m", "15m"}, - }, - }, - "risk_control": map[string]any{ - "btc_eth_max_leverage": 3, - "altcoin_max_leverage": 2, - "min_confidence": 80, - "min_risk_reward_ratio": 1.5, - }, - "prompt_sections": map[string]any{ - "trading_frequency": "每天最多 5-8 笔,避免连续亏损后追单。", - "entry_standards": "趋势确认、成交量放大、资金费率正常才开仓。", - }, - }, - } - rawPatch, _ := json.Marshal(patch) - userID := int64(42) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.CollectedFields = map[string]any{ - "name": "AI500高频", - "strategy_type": "ai_trading", - "awaiting_final_confirmation": true, - strategyCreateConfigPatchField: string(rawPatch), - } - a.saveActiveSkillSession(session) - - decision := unifiedTurnDecision{ - TopicIntent: "continue_active", - BusinessAction: "planned_agent", - } - reply, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "确认", decision, nil) - if err != nil { - t.Fatalf("execute unified turn: %v", err) - } - if !handled { - t.Fatalf("expected turn to be handled") - } - if strings.Contains(reply, "马上") || strings.Contains(reply, "稍后") || strings.Contains(reply, "正在") { - t.Fatalf("expected planner promise to be bypassed, got: %s", reply) - } - if !strings.Contains(reply, "已创建策略") { - t.Fatalf("expected real strategy creation result, got: %s", reply) - } -} - -func TestStrategyCreateRepairPromiseIsNotReturnedOnConfirmation(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-repair-promise.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"好的,马上为你创建AI500高频稳健策略。","extracted_data":{}}`}) - - userID := int64(42) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.CollectedFields = map[string]any{ - "name": "AI500高频", - "strategy_type": "ai_trading", - } - session.LocalHistory = []chatMessage{ - {Role: "assistant", Content: "如果你确认没问题,告诉我“确认创建”,我就帮你直接创建。"}, - } - a.saveActiveSkillSession(session) - - reply, handled, err := a.driveActiveSession(context.Background(), "default", userID, "zh", "确认创建", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected confirmation turn to be handled") - } - if strings.Contains(reply, "马上") || strings.Contains(reply, "正在") || strings.Contains(reply, "稍后") { - t.Fatalf("repair promise should not be returned on confirmation, got: %s", reply) - } -} - -func TestModelCreateSessionRedirectsStrategyTypeChoiceToStrategyCreate(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-type-choice-not-model-provider.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - const userID int64 = 42 - a.saveSkillSession(userID, skillSession{ - Name: "model_management", - Action: "create", - Phase: "collecting", - Fields: map[string]string{}, - }) - - reply, ok := a.redirectModelCreateSessionToStrategyCreateIfNeeded("default", userID, "zh", "1.AI交易策略", a.getSkillSession(userID)) - if !ok { - t.Fatalf("expected strategy type choice to redirect away from model create") - } - if strings.Contains(reply, "模型提供商") || strings.Contains(reply, "provider") { - t.Fatalf("strategy type choice must not ask for model provider, got: %s", reply) - } - session := a.getSkillSession(userID) - if session.Name != "strategy_management" || session.Action != "create" { - t.Fatalf("expected active session to be strategy create, got %+v", session) - } - if got := fieldValue(session, "strategy_type"); got != "ai_trading" { - t.Fatalf("expected ai strategy type to be captured, got %q in %+v", got, session) - } -} - -func TestStrategyCreateAskUserReplyIsNotOverriddenByTemplateMissingFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-llm-ask-reply.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"我会按 AI 策略模板继续填。你如果想稳健,我建议先用 OI Low、15m 主周期、最低置信度 70。确认这个方向吗?"}`}) - - session := ActiveSkillSession{ - UserID: 42, - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "AI高频", - "strategy_type": "ai_trading", - }, - } - reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "1h", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected active session to be handled") - } - if strings.Contains(reply, "这份策略模板还没填完整") || strings.Contains(reply, "还缺这些字段") { - t.Fatalf("LLM ask_user reply should not be overridden by hard template missing list, got: %s", reply) - } - if !strings.Contains(reply, "OI Low") || !strings.Contains(reply, "70") { - t.Fatalf("expected LLM reply to pass through, got: %s", reply) - } -} - -func TestStrategyCreateAIReplyRejectsNonTemplateInvestmentQuestion(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-ai-non-template-question.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"你打算投入多少资金来运行这个策略?比如100U、500U?这样我可以帮你设置止损和仓位。"}`}) - - session := ActiveSkillSession{ - UserID: 42, - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "AI500稳健", - "strategy_type": "ai_trading", - }, - } - reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "全部你定吧,稳健就行", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected active session to be handled") - } - for _, blocked := range []string{"投入多少", "100U", "500U", "止损", "仓位"} { - if strings.Contains(reply, blocked) { - t.Fatalf("AI strategy reply should not ask non-template field %q, got: %s", blocked, reply) - } - } -} - -func TestStrategyCreateOptionsQuestionExplainsCurrentMissingField(t *testing.T) { - session := ActiveSkillSession{ - UserID: 42, - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "AI500高频交易", - "strategy_type": "ai_trading", - }, - } - reply, blocked := strategyCreateTemplateMissingReply("zh", "有哪些选择吗", session) - if !blocked { - t.Fatalf("expected options question to be handled") - } - for _, want := range []string{"AI500", "OI Top", "OI Low", "静态币种"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected source options to include %q, got: %s", want, reply) - } - } - if strings.Contains(reply, "还缺") || strings.Contains(reply, "BTC/ETH 最大杠杆") { - t.Fatalf("options question should not repeat the full missing-field list, got: %s", reply) - } -} - -func TestStrategyCreateMissingFieldsIncludeInlineOptions(t *testing.T) { - reply := formatStrategyCreateConfigNeeded("zh", "source_type,primary_timeframe,btceth_max_leverage,min_confidence,trading_frequency") - for _, want := range []string{"AI500", "OI Top", "OI Low", "静态币种", "1m", "1h", "1~20", "50~100", "每天最多"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected missing-field prompt to include option/range %q, got: %s", want, reply) - } - } - if !strings.Contains(reply, "你帮我按稳健/高频/激进来推荐") { - t.Fatalf("expected prompt to offer recommendation shortcut, got: %s", reply) - } -} - -func TestStrategyCreateConfigPatchReplyUsesStructuredMissingFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-recommendation-structured-missing.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"我建议按高频但稳健来填:主周期 3m,多周期 3m/5m/15m,BTC/ETH 3倍,山寨币 2倍。确认的话我会继续整理完整模板。","extracted_data":{"config_patch":{"strategy_type":"ai_trading","ai_config":{"indicators":{"klines":{"primary_timeframe":"3m","selected_timeframes":["3m","5m","15m"]}}}}}}`}) - - session := ActiveSkillSession{ - UserID: 42, - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "AI500高频交易", - "strategy_type": "ai_trading", - strategyCreateConfigPatchField: map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{"source_type": "ai500", "use_ai500": true, "ai500_limit": 5}, - "risk_control": map[string]any{ - "min_confidence": 70, - }, - }, - }, - }, - } - reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "继续", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected recommendation request to be handled") - } - if !strings.Contains(reply, "这份策略模板还没填完整") { - t.Fatalf("expected structured missing-field prompt after partial config_patch, got: %s", reply) - } - if strings.Contains(reply, "我建议按高频但稳健来填") { - t.Fatalf("LLM free-form recommendation should not be used as the current plan, got: %s", reply) - } - if !strings.Contains(reply, "BTC/ETH 最大杠杆") || !strings.Contains(reply, "开仓标准") { - t.Fatalf("expected deterministic missing template fields, got: %s", reply) - } -} - -func TestStrategyCreateFirstStageConfigProgressUsesStructuredMissingFields(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-first-stage-structured-missing.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"收到,AI500 和最低置信度 80 是你指定的;其他我建议按高频稳健来定:主周期 3m,多周期 3m/5m/15m,BTC/ETH 3倍,山寨币 2倍,最小盈亏比 2。","extracted_data":{}}`}) - - session := ActiveSkillSession{ - UserID: 42, - SkillName: "strategy_management", - ActionName: "create", - CollectedFields: map[string]any{ - "name": "高频稳健AI500", - "strategy_type": "ai_trading", - strategyCreateConfigProgressThisTurnField: true, - strategyCreateConfigPatchField: map[string]any{ - "strategy_type": "ai_trading", - "ai_config": map[string]any{ - "coin_source": map[string]any{"source_type": "ai500", "use_ai500": true}, - "risk_control": map[string]any{ - "min_confidence": 80, - }, - }, - }, - }, - } - reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "选币选AI500,最新置信度80,其他你定,能高频交易稳定就行", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected active session to be handled") - } - if !strings.Contains(reply, "这份策略模板还没填完整") { - t.Fatalf("expected structured missing-field prompt after first-stage config progress, got: %s", reply) - } - if strings.Contains(reply, "其他我建议按高频稳健来定") { - t.Fatalf("LLM free-form recommendation should not be used as the current plan, got: %s", reply) - } - if !strings.Contains(reply, "主周期") || !strings.Contains(reply, "BTC/ETH 最大杠杆") { - t.Fatalf("expected deterministic missing template fields, got: %s", reply) - } -} - -func TestStrategyCreateConfirmationUsesModelRepairForPriorStyleProposal(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-create-style-repair.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - a.SetAIClient(&staticAIClient{response: `{"route":"execute_skill","extracted_data":{"config_patch":{"strategy_type":"ai_trading","ai_config":{"coin_source":{"source_type":"ai500","use_ai500":true,"ai500_limit":3},"indicators":{"klines":{"primary_timeframe":"1m","primary_count":20,"selected_timeframes":["1m","5m","15m"],"enable_multi_timeframe":true,"enable_raw_klines":true},"enable_volume":true,"enable_oi":true,"enable_funding_rate":true,"enable_quant_data":true},"risk_control":{"btc_eth_max_leverage":5,"altcoin_max_leverage":5,"min_confidence":75,"min_risk_reward_ratio":3},"prompt_sections":{"trading_frequency":"高频但不过度交易:目标每小时 1-3 笔;单笔持仓通常 10-30 分钟。","entry_standards":"只在短周期趋势、成交量/OI、资金费率或排行信号形成共振时入场。"}}}}}`}) - - userID := int64(42) - session := newActiveSkillSession(userID, "strategy_management", "create") - session.CollectedFields = map[string]any{ - "name": "AI500极致稳定高频", - "strategy_type": "ai_trading", - } - session.LocalHistory = []chatMessage{ - {Role: "assistant", Content: "我建议主周期改成1分钟,多周期改成1分钟、5分钟、15分钟,交易频率按高频但稳定来写。"}, - } - - reply, handled, err := a.driveActiveSession(context.Background(), "default", userID, "zh", "好的可以,确认创建", session, nil) - if err != nil { - t.Fatalf("drive active session: %v", err) - } - if !handled { - t.Fatalf("expected confirmation to be handled") - } - if !strings.Contains(reply, "已创建策略") { - t.Fatalf("expected real strategy creation after model repair, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - if len(strategies) != 1 { - t.Fatalf("expected one created strategy, got %d", len(strategies)) - } - var cfg store.StrategyConfig - if err := json.Unmarshal([]byte(strategies[0].Config), &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - if cfg.CoinSource.SourceType != "ai500" || cfg.Indicators.Klines.PrimaryTimeframe != "1m" { - t.Fatalf("expected model-repaired AI500 1m strategy, got %+v", cfg) - } -} - -func TestStrategyCreateCreatesGridAfterConfigPatch(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-ready.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - patch := map[string]any{ - "strategy_type": "grid_trading", - "grid_config": map[string]any{ - "symbol": "ETHUSDT", - "grid_count": 12, - "total_investment": 1000, - "leverage": 3, - "use_atr_bounds": true, - "atr_multiplier": 2, - "distribution": "gaussian", - "max_drawdown_pct": 15, - "stop_loss_pct": 5, - "daily_loss_limit_pct": 10, - "use_maker_only": true, - }, - } - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "我的网格策略", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session) - if !strings.Contains(reply, "已创建策略") { - t.Fatalf("expected create reply, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - var created *store.Strategy - for _, strategy := range strategies { - if strategy.Name == "我的网格策略" { - created = strategy - break - } - } - if created == nil { - t.Fatalf("expected grid strategy to be created") - } - var cfg store.StrategyConfig - if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - if cfg.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "ETHUSDT" { - t.Fatalf("expected grid config to persist, got %+v", cfg) - } -} - -func TestManageStrategyToolCreateRequiresConfirmation(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-tool-create-confirmation.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - resp := a.toolManageStrategy("default", `{"action":"create","name":"未确认网格","lang":"zh","config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`) - if !strings.Contains(resp, "requires_confirmation") { - t.Fatalf("expected tool create to require confirmation, got: %s", resp) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - for _, strategy := range strategies { - if strategy.Name == "未确认网格" { - t.Fatalf("unconfirmed tool call should not create strategy") - } - } - - resp = a.toolManageStrategy("default", `{"action":"create","name":"已确认网格","lang":"zh","confirmed":true,"allow_clamped_update":true,"config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected confirmed create to succeed, got: %s", resp) - } -} - -func TestStrategyCreateGridPatchInfersStrategyType(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-infers-type.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - patch := map[string]any{ - "grid_config": map[string]any{ - "symbol": "BTCUSDT", - "grid_count": 20, - "total_investment": 200, - "leverage": 2, - "use_atr_bounds": true, - "atr_multiplier": 2, - "distribution": "uniform", - "max_drawdown_pct": 15, - "stop_loss_pct": 5, - "daily_loss_limit_pct": 10, - "use_maker_only": true, - }, - } - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "小白网格", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session) - if !strings.Contains(reply, "已创建策略") { - t.Fatalf("expected create reply, got: %s", reply) - } - strategies, err := st.Strategy().List("default") - if err != nil { - t.Fatalf("list strategies: %v", err) - } - var cfg store.StrategyConfig - for _, strategy := range strategies { - if strategy.Name == "小白网格" { - if err := json.Unmarshal([]byte(strategy.Config), &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - break - } - } - if cfg.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "BTCUSDT" { - t.Fatalf("expected grid patch to infer grid_trading, got %+v", cfg) - } -} - -func TestStrategyCreateGridPatchKeepsBackendGridDefaults(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-defaults.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), slog.Default()) - - patch := map[string]any{ - "strategy_type": "grid_trading", - "grid_config": map[string]any{ - "symbol": "ETHUSDT", - "grid_count": 20, - "total_investment": 500, - "leverage": 3, - }, - } - rawPatch, _ := json.Marshal(patch) - session := skillSession{ - Name: "strategy_management", - Action: "create", - Fields: map[string]string{ - "name": "餐巾纸", - strategyCreateConfigPatchField: string(rawPatch), - }, - } - - reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session) - if !strings.Contains(reply, "还缺") || strings.Contains(reply, "已创建策略") { - t.Fatalf("expected incomplete grid patch to ask for missing fields, got: %s", reply) - } -} - -func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) { - result := llmFlowExtractionResult{ - Intent: "continue", - Tasks: []llmFlowExtractionTask{{ - Skill: "exchange_management", - Action: "create", - Fields: map[string]string{ - "secret": "wrong-key", - "secret_key": "canonical-secret", - "api_key": "api", - }, - }}, - } - filtered := filterLLMFlowExtractionFields(result, []llmFlowFieldSpec{ - {Key: "secret_key"}, - {Key: "api_key"}, - }) - fields := filtered.Tasks[0].Fields - if _, ok := fields["secret"]; ok { - t.Fatalf("expected invented field key to be filtered, got: %+v", fields) - } - if fields["secret_key"] != "canonical-secret" || fields["api_key"] != "api" { - t.Fatalf("expected canonical fields to remain, got: %+v", fields) - } -} - -func TestExchangeCreateAllowedFieldSpecsUseCanonicalSecretKey(t *testing.T) { - specs := allowedFieldSpecsForSkillSession(skillSession{Name: "exchange_management", Action: "create"}, "zh") - foundSecretKey := false - for _, spec := range specs { - if spec.Key == "secret" { - t.Fatal("exchange create schema should not expose non-canonical secret key") - } - if spec.Key == "secret_key" { - foundSecretKey = true - } - } - if !foundSecretKey { - t.Fatal("expected exchange create schema to include canonical secret_key") - } -} - -func TestActiveSessionExtractedDataFiltersToAllowedSchema(t *testing.T) { - session := ActiveSkillSession{ - SkillName: "exchange_management", - ActionName: "create", - CollectedFields: map[string]any{ - "exchange_type": "okx", - }, - } - filtered := filterExtractedDataForActiveSession(session, map[string]any{ - "account_name": "呢呢", - "api_key": "api", - "secret": "wrong-key", - "secret_key": "canonical-secret", - "passphrase": "pass", - }, "zh") - if _, ok := filtered["secret"]; ok { - t.Fatalf("expected central brain alias key to be filtered, got: %+v", filtered) - } - for _, key := range []string{"account_name", "api_key", "secret_key", "passphrase"} { - if _, ok := filtered[key]; !ok { - t.Fatalf("expected canonical key %q to remain, got: %+v", key, filtered) - } - } -} - -func TestBrainUserPromptIncludesActiveAllowedFieldSchema(t *testing.T) { - prompt := buildBrainUserPrompt( - "zh", - "密钥是abc123456", - "要创建交易所配置,还缺这些字段:Secret。", - "", - "", - ActiveSkillSession{SkillName: "exchange_management", ActionName: "create"}, - true, - ) - if !strings.Contains(prompt, "allowed_field_spec_json") || !strings.Contains(prompt, `"secret_key"`) { - t.Fatalf("expected brain prompt to expose canonical field schema, got:\n%s", prompt) - } -} diff --git a/agent/unified_turn_router_test.go b/agent/unified_turn_router_test.go deleted file mode 100644 index 9d3a86ec..00000000 --- a/agent/unified_turn_router_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package agent - -import ( - "context" - "path/filepath" - "strings" - "testing" - - "nofx/store" -) - -func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) { - raw := `{ - "topic_intent": "start_new", - "business_action": "new_skill", - "target_skill": "strategy_management:update_config", - "context_mode": "fresh_context", - "extracted_data": {"name": "BTC趋势"}, - "confidence": 0.82 - }` - - decision, err := parseUnifiedTurnDecision(raw) - if err != nil { - t.Fatalf("parse unified decision: %v", err) - } - if decision.TopicIntent != "start_new" { - t.Fatalf("expected normalized topic intent, got %q", decision.TopicIntent) - } - if decision.BusinessAction != "new_skill" { - t.Fatalf("expected business action new_skill, got %q", decision.BusinessAction) - } - if decision.ContextMode != "fresh_context" { - t.Fatalf("expected fresh_context, got %q", decision.ContextMode) - } - if !decision.reliable() { - t.Fatalf("expected decision to be reliable: %+v", decision) - } -} - -func TestParseUnifiedTurnDecisionAcceptsSkillTaskList(t *testing.T) { - raw := `{ - "topic_intent": "start_new", - "business_action": "skill_tasks", - "context_mode": "fresh_context", - "tasks": [ - {"id":"task_1","skill":"strategy_management","action":"create","request":"创建高频交易策略","depends_on":[]}, - {"id":"task_2","skill":"trader_management","action":"configure_strategy","request":"绑定到交易员","depends_on":["task_1"]} - ], - "confidence": 0.86 - }` - - decision, err := parseUnifiedTurnDecision(raw) - if err != nil { - t.Fatalf("parse unified decision: %v", err) - } - if decision.BusinessAction != "skill_tasks" { - t.Fatalf("expected skill_tasks, got %q", decision.BusinessAction) - } - if len(decision.Tasks) != 2 { - t.Fatalf("expected 2 tasks, got %+v", decision.Tasks) - } - if decision.Tasks[0].Skill != "strategy_management" || decision.Tasks[0].Action != "create" { - t.Fatalf("unexpected first task: %+v", decision.Tasks[0]) - } - if !decision.reliable() { - t.Fatalf("expected task-list decision to be reliable: %+v", decision) - } -} - -func TestUnifiedTurnDecisionNewSkillCanUseSingleTask(t *testing.T) { - decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{ - TopicIntent: "start_new", - BusinessAction: "new_skill", - ContextMode: "fresh_context", - Tasks: []WorkflowTask{{ - Skill: "strategy_management", - Action: "create", - Request: "创建高频交易策略", - }}, - Confidence: 0.9, - }) - if !decision.reliable() { - t.Fatalf("expected new_skill with task list to be reliable: %+v", decision) - } -} - -func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) { - lowConfidence := unifiedTurnDecision{ - TopicIntent: "start_new", - BusinessAction: "planned_agent", - ContextMode: "fresh_context", - Confidence: 0.2, - } - lowConfidence = normalizeUnifiedTurnDecision(lowConfidence) - if lowConfidence.reliable() { - t.Fatalf("expected low confidence decision to fall back") - } - - emptyDirect := unifiedTurnDecision{ - TopicIntent: "instant_reply", - BusinessAction: "direct_answer", - ContextMode: "use_current", - Confidence: 0.9, - } - emptyDirect = normalizeUnifiedTurnDecision(emptyDirect) - if emptyDirect.reliable() { - t.Fatalf("expected direct_answer without reply_to_user to fall back") - } -} - -func TestExecuteUnifiedTurnDecisionDirectAnswerRecordsHistory(t *testing.T) { - a := New(nil, nil, DefaultConfig(), nil) - userID := int64(101) - decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{ - TopicIntent: "instant_reply", - BusinessAction: "direct_answer", - ContextMode: "use_current", - ReplyToUser: "你好,我在。", - Confidence: 0.9, - }) - - answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "你好", decision, nil) - if err != nil { - t.Fatalf("execute unified decision: %v", err) - } - if !handled { - t.Fatal("expected direct answer to be handled") - } - if answer != "你好,我在。" { - t.Fatalf("unexpected answer: %q", answer) - } - - history := a.history.Get(userID) - if len(history) != 2 { - t.Fatalf("expected user and assistant history entries, got %d", len(history)) - } - if history[0].Role != "user" || history[0].Content != "你好" { - t.Fatalf("unexpected user history entry: %+v", history[0]) - } - if history[1].Role != "assistant" || history[1].Content != "你好,我在。" { - t.Fatalf("unexpected assistant history entry: %+v", history[1]) - } -} - -func TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "continue-active-router.db") - st, err := store.New(dbPath) - if err != nil { - t.Fatalf("create store: %v", err) - } - a := New(nil, st, DefaultConfig(), nil) - userID := int64(102) - - session := newActiveSkillSession(userID, "strategy_management", "create") - session.Goal = "创建网格策略" - session.CollectedFields["name"] = "我的网格策略" - session.CollectedFields["strategy_type"] = "grid_trading" - setActiveSessionPendingHint(&session, "现在还需要确认网格交易对、网格数量、总投入、杠杆和价格区间。") - a.saveActiveSkillSession(session) - - decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{ - TopicIntent: "continue_active", - BusinessAction: "planned_agent", - ContextMode: "use_current", - Confidence: 0.9, - }) - answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "那你帮我创吧", decision, nil) - if err != nil { - t.Fatalf("execute unified decision: %v", err) - } - if !handled { - t.Fatal("expected active session continuation to be handled") - } - if !strings.Contains(answer, "还缺") || !strings.Contains(answer, "交易对") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") { - t.Fatalf("expected strategy session to continue without planner/trader handoff, got: %s", answer) - } - if _, ok := a.getActiveSkillSession(userID); !ok { - t.Fatalf("expected strategy active session to remain pending") - } -} - -func TestGuardUnexecutedActiveTaskCompletionBlocksCreationClaim(t *testing.T) { - session := ActiveSkillSession{ - SkillName: "strategy_management", - ActionName: "create", - } - reply, blocked := guardUnexecutedActiveTaskCompletion("zh", session, "已经创建好了。策略现在就在你的策略列表里。") - if !blocked { - t.Fatalf("expected unexecuted active create completion claim to be blocked") - } - if !strings.Contains(reply, "还没有真正创建") { - t.Fatalf("expected honest not-created reply, got: %s", reply) - } - - _, blocked = guardUnexecutedActiveTaskCompletion("zh", session, "我建议先用 BTCUSDT 做新手网格策略。") - if blocked { - t.Fatalf("non-completion proposal should not be blocked") - } -} - -func TestGuardUnsupportedAsyncPromiseBlocksFakeDiagnosisProgress(t *testing.T) { - reply, blocked := guardUnsupportedAsyncPromise("zh", "诊断还在进行中,请再稍等一下。我马上分析完“小小”的历史交易记录,找到亏损原因后会立刻告诉您。") - if !blocked { - t.Fatal("expected fake async diagnosis progress to be blocked") - } - for _, want := range []string{"没有后台异步任务", "当前回复"} { - if !strings.Contains(reply, want) { - t.Fatalf("expected guarded reply to contain %q, got: %s", want, reply) - } - } - - _, blocked = guardUnsupportedAsyncPromise("zh", "我需要策略名称和历史记录范围,才能开始诊断。") - if blocked { - t.Fatal("missing-info diagnosis reply should not be blocked") - } - - _, blocked = guardUnsupportedAsyncPromise("zh", "好的,参数已确认,正在为您创建“餐巾纸”网格策略。") - if !blocked { - t.Fatal("expected fake async strategy create progress to be blocked") - } -} - -func TestFinishTaskGuardBlocksFakeCreateProgressPromise(t *testing.T) { - reply, blocked := guardUnsupportedAsyncPromise("zh", "策略正在创建中,请稍等一会儿。创建成功后我会立刻告诉您。") - if !blocked { - t.Fatal("expected fake create progress promise to be blocked") - } - if !strings.Contains(reply, "没有后台异步任务") || !strings.Contains(reply, "实际执行") { - t.Fatalf("expected honest execution correction, got: %s", reply) - } -} - -func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) { - a := New(nil, nil, DefaultConfig(), nil) - systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略") - for _, want := range []string{ - "context_mode values", - "fresh_context", - "downstream modules", - "tasks format", - "skill_tasks", - "topic_intent as the primary decision", - } { - if !strings.Contains(systemPrompt, want) { - t.Fatalf("expected system prompt to contain %q", want) - } - } - if !strings.Contains(userPrompt, "不是交易员,是策略") { - t.Fatalf("expected user prompt to contain current user message") - } -} diff --git a/agent/user_facing_prompt.go b/agent/user_facing_prompt.go deleted file mode 100644 index 8af3a506..00000000 --- a/agent/user_facing_prompt.go +++ /dev/null @@ -1,3 +0,0 @@ -package agent - -const cleanUserFacingReplyInstruction = "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation." diff --git a/agent/user_facing_prompt_test.go b/agent/user_facing_prompt_test.go deleted file mode 100644 index 273503af..00000000 --- a/agent/user_facing_prompt_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package agent - -import "testing" - -func TestCleanUserFacingReplyInstruction(t *testing.T) { - if cleanUserFacingReplyInstruction == "" { - t.Fatal("expected clean user-facing reply instruction to be defined") - } - if got, want := cleanUserFacingReplyInstruction, "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."; got != want { - t.Fatalf("unexpected instruction\nwant: %q\ngot: %q", want, got) - } -} diff --git a/agent/web.go b/agent/web.go deleted file mode 100644 index e1571226..00000000 --- a/agent/web.go +++ /dev/null @@ -1,373 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "nofx/safe" - "regexp" - "time" -) - -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 { - return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID) -} - -func storeUserIDFromContext(ctx context.Context) string { - if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" { - return v - } - 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}$`) - -// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w). -var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`) - -// binanceClient is a shared HTTP client for proxying Binance API requests. -// Reused across requests to benefit from connection pooling. -var binanceClient = &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 20, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, -} - -// WebHandler provides HTTP endpoints for the NOFXi agent. -type WebHandler struct { - agent *Agent - logger *slog.Logger -} - -func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler { - return &WebHandler{agent: agent, logger: logger} -} - -// HandleHealth handles GET /api/agent/health. -func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) { - writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)}) -} - -// HandleChat handles POST /api/agent/chat. -func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(rw, "method not allowed", 405) - return - } - var req struct { - Message string `json:"message"` - UserID int64 `json:"user_id"` - UserKey string `json:"user_key"` - Lang string `json:"lang"` - } - // Limit request body to 64KB to prevent abuse - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeJSON(rw, 400, map[string]string{"error": "invalid request"}) - return - } - if req.Message == "" { - writeJSON(rw, 400, map[string]string{"error": "message required"}) - return - } - if req.UserID == 0 { - req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context())) - } - msg := req.Message - if req.Lang != "" { - msg = "[lang:" + req.Lang + "] " + msg - } - - ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second) - defer cancel() - - 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": "I ran into a problem while handling that message. Please try again."}) - return - } - writeJSON(rw, 200, map[string]string{"response": resp}) -} - -// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat. -// Sends server-sent events with types including planning, plan, step_start, -// step_complete, replan, tool, delta, done, error. -func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(rw, "method not allowed", 405) - return - } - var req struct { - Message string `json:"message"` - UserID int64 `json:"user_id"` - UserKey string `json:"user_key"` - Lang string `json:"lang"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeJSON(rw, 400, map[string]string{"error": "invalid request"}) - return - } - if req.Message == "" { - writeJSON(rw, 400, map[string]string{"error": "message required"}) - return - } - if req.UserID == 0 { - req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context())) - } - msg := req.Message - if req.Lang != "" { - msg = "[lang:" + req.Lang + "] " + msg - } - - // Set SSE headers - rw.Header().Set("Content-Type", "text/event-stream") - rw.Header().Set("Cache-Control", "no-cache") - rw.Header().Set("Connection", "keep-alive") - rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering - rw.WriteHeader(200) - - flusher, ok := rw.(http.Flusher) - if !ok { - writeSSE(rw, nil, "error", "streaming not supported") - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) - defer cancel() - - resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) { - if ctx.Err() != nil { - return - } - writeSSE(rw, flusher, event, data) - }) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { - w.logger.Info("agent stream cancelled", "user_id", req.UserID, "error", err) - return - } - w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID) - writeSSE(rw, flusher, "error", "I ran into a problem while handling that message. Please try again.") - return - } - if ctx.Err() != nil { - return - } - // Send final done event with complete response - writeSSE(rw, flusher, "done", resp) -} - -// writeSSE writes a single SSE event. -func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) { - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data)) - if flusher != nil { - flusher.Flush() - } -} - -// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix). -func sseEscape(s string) string { - // SSE spec: multi-line data uses multiple "data:" lines - // But we use JSON encoding to avoid this complexity - b, _ := json.Marshal(s) - return string(b) -} - -// HandleKlines proxies kline data from Binance. -func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) { - symbol := r.URL.Query().Get("symbol") - if symbol == "" { - symbol = "BTCUSDT" - } - interval := r.URL.Query().Get("interval") - if interval == "" { - interval = "1h" - } - - if !validSymbolRe.MatchString(symbol) { - writeJSON(rw, 400, map[string]string{"error": "invalid symbol"}) - return - } - if !validIntervalRe.MatchString(interval) { - writeJSON(rw, 400, map[string]string{"error": "invalid interval"}) - return - } - - proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval)) -} - -// HandleTicker proxies ticker data from Binance. -func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) { - symbol := r.URL.Query().Get("symbol") - if symbol == "" { - symbol = "BTCUSDT" - } - - if !validSymbolRe.MatchString(symbol) { - writeJSON(rw, 400, map[string]string{"error": "invalid symbol"}) - return - } - - proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)) -} - -// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT -// Batch endpoint: fetches multiple tickers concurrently, returns array. -func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) { - symbolsParam := r.URL.Query().Get("symbols") - if symbolsParam == "" { - symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT" - } - - // Validate symbols - var symbols []string - for _, s := range splitComma(symbolsParam) { - if validSymbolRe.MatchString(s) { - symbols = append(symbols, s) - } - } - if len(symbols) == 0 { - writeJSON(rw, 400, map[string]string{"error": "no valid symbols"}) - return - } - if len(symbols) > 20 { - writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"}) - return - } - - // Fetch all tickers concurrently with context propagation - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - type result struct { - idx int - data json.RawMessage - } - results := make(chan result, len(symbols)) - for i, sym := range symbols { - idx, s := i, sym - safe.GoNamed("ticker-fetch-"+s, func() { - req, err := http.NewRequestWithContext(ctx, "GET", - fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil) - if err != nil { - results <- result{idx: idx} - return - } - resp, err := binanceClient.Do(req) - if err != nil { - results <- result{idx: idx} - return - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - results <- result{idx: idx} - return - } - body, err := safe.ReadAllLimited(resp.Body, 16*1024) - if err != nil { - results <- result{idx: idx} - return - } - results <- result{idx: idx, data: body} - }) - } - - // Collect results in order - ordered := make([]json.RawMessage, len(symbols)) - for range symbols { - r := <-results - if r.data != nil { - ordered[r.idx] = r.data - } - } - - // Filter out nil entries and write response - out := make([]json.RawMessage, 0, len(ordered)) - for _, d := range ordered { - if d != nil { - out = append(out, d) - } - } - rw.Header().Set("Content-Type", "application/json") - json.NewEncoder(rw).Encode(out) -} - -// commaRe is pre-compiled for splitComma — avoids recompiling on every call. -var commaRe = regexp.MustCompile(`\s*,\s*`) - -// splitComma splits a comma-separated string, trims whitespace, skips empty. -func splitComma(s string) []string { - var parts []string - for _, p := range commaRe.Split(s, -1) { - if p != "" { - parts = append(parts, p) - } - } - return parts -} - -func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) { - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - writeJSON(rw, 500, map[string]string{"error": "failed to create request"}) - return - } - resp, err := binanceClient.Do(req) - if err != nil { - // Distinguish client cancellation from upstream failures - if ctx.Err() != nil { - return // Client disconnected, no point writing response - } - writeJSON(rw, 502, map[string]string{"error": "upstream request failed"}) - return - } - defer resp.Body.Close() - - // Forward upstream error status codes instead of silently proxying bad data - if resp.StatusCode != http.StatusOK { - writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)}) - return - } - - rw.Header().Set("Content-Type", "application/json") - // CORS is handled by the gin middleware — no need to set it here - // Limit response body to 2MB to prevent memory exhaustion - io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024)) -} - -func writeJSON(w http.ResponseWriter, status int, v interface{}) { - w.Header().Set("Content-Type", "application/json") - // CORS is handled by the gin middleware — no need to set it here - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} diff --git a/agent/workflow.go b/agent/workflow.go deleted file mode 100644 index 2f7ec355..00000000 --- a/agent/workflow.go +++ /dev/null @@ -1,959 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "nofx/mcp" -) - -const ( - workflowTaskPending = "pending" - workflowTaskRunning = "running" - workflowTaskCompleted = "completed" - workflowTaskFailed = "failed" -) - -type WorkflowTask struct { - ID string `json:"id,omitempty"` - Skill string `json:"skill,omitempty"` - Action string `json:"action,omitempty"` - Request string `json:"request,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - Status string `json:"status,omitempty"` - Error string `json:"error,omitempty"` -} - -type WorkflowSession struct { - UserID int64 `json:"user_id"` - OriginalRequest string `json:"original_request,omitempty"` - Tasks []WorkflowTask `json:"tasks,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -type workflowDecomposition struct { - Tasks []WorkflowTask `json:"tasks"` -} - -func workflowSessionConfigKey(userID int64) string { - return fmt.Sprintf("agent_workflow_session_%d", userID) -} - -func normalizeWorkflowSession(session WorkflowSession) WorkflowSession { - session.OriginalRequest = strings.TrimSpace(session.OriginalRequest) - normalized := make([]WorkflowTask, 0, len(session.Tasks)) - for i, task := range session.Tasks { - task.ID = strings.TrimSpace(task.ID) - if task.ID == "" { - task.ID = fmt.Sprintf("task_%d", i+1) - } - task.Skill = strings.TrimSpace(task.Skill) - task.Action = normalizeAtomicSkillAction(task.Skill, task.Action) - task.Request = strings.TrimSpace(task.Request) - task.DependsOn = cleanStringList(task.DependsOn) - task.Status = strings.TrimSpace(task.Status) - if task.Status == "" { - task.Status = workflowTaskPending - } - task.Error = strings.TrimSpace(task.Error) - if task.Skill == "" || task.Action == "" || task.Request == "" { - continue - } - normalized = append(normalized, task) - } - session.Tasks = normalized - if len(session.Tasks) == 0 { - return WorkflowSession{} - } - if session.UpdatedAt == "" { - session.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - } - return session -} - -func (a *Agent) getWorkflowSession(userID int64) WorkflowSession { - if a.store == nil { - return WorkflowSession{} - } - raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID)) - if err != nil || strings.TrimSpace(raw) == "" { - return WorkflowSession{} - } - var session WorkflowSession - if err := json.Unmarshal([]byte(raw), &session); err != nil { - return WorkflowSession{} - } - return normalizeWorkflowSession(session) -} - -func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) { - if a.store == nil { - return - } - session = normalizeWorkflowSession(session) - if len(session.Tasks) == 0 { - _ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "") - return - } - session.UserID = userID - session.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - data, err := json.Marshal(session) - if err != nil { - return - } - _ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data)) -} - -func (a *Agent) clearWorkflowSession(userID int64) { - if a.store == nil { - return - } - _ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "") -} - -func hasActiveWorkflowSession(session WorkflowSession) bool { - if len(session.Tasks) == 0 { - return false - } - for _, task := range session.Tasks { - if task.Status == workflowTaskPending || task.Status == workflowTaskRunning { - return true - } - } - return false -} - -func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) { - for i, task := range session.Tasks { - if task.Status != workflowTaskPending && task.Status != workflowTaskRunning { - continue - } - depsReady := true - for _, dep := range task.DependsOn { - ok := false - for _, candidate := range session.Tasks { - if candidate.ID == dep && candidate.Status == workflowTaskCompleted { - ok = true - break - } - } - if !ok { - depsReady = false - break - } - } - if depsReady { - return task, i, true - } - } - return WorkflowTask{}, -1, false -} - -func supportedWorkflowSkill(skill, action string) bool { - skill = strings.TrimSpace(skill) - action = normalizeAtomicSkillAction(skill, action) - if skill == "" || action == "" { - return false - } - 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": - if action == "query_running" { - return true - } - } - return false -} - -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) - return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil - } - - if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" { - decision, _ := 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 - } - 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, "") - a.saveWorkflowSession(userID, session) - if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil { - if final != "" && answer != "" { - return answer + "\n\n" + final, true, err - } - if answer != "" { - return answer, true, err - } - return final, true, err - } - } - 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 { - summary := a.generateWorkflowSummary(ctx, userID, lang, session) - a.clearWorkflowSession(userID) - if summary == "" { - if lang == "zh" { - summary = "已完成当前任务流。" - } else { - summary = "Completed the current workflow." - } - } - if onEvent != nil { - onEvent(StreamEventPlan, summary) - emitStreamText(onEvent, summary) - } - return summary, true, nil - } - - session.Tasks[index].Status = workflowTaskRunning - a.saveWorkflowSession(userID, session) - taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"} - a.saveSkillSession(userID, taskSession) - - if onEvent != nil { - onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session)) - onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action) - } - - 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) - session = markCurrentWorkflowTask(session, workflowTaskCompleted, "") - a.saveWorkflowSession(userID, session) - if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil { - if answer != "" && more != "" { - return answer + "\n\n" + more, true, err - } - if answer != "" { - return answer, true, err - } - return more, true, err - } - } - return answer, true, nil -} - -func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession { - for i := range session.Tasks { - if session.Tasks[i].Status == workflowTaskRunning { - session.Tasks[i].Status = status - session.Tasks[i].Error = strings.TrimSpace(errMsg) - return session - } - } - return session -} - -func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string { - parts := make([]string, 0, len(session.Tasks)) - for _, task := range session.Tasks { - label := task.Request - if label == "" { - label = task.Skill + ":" + task.Action - } - switch task.Status { - case workflowTaskCompleted: - label = "✓ " + label - case workflowTaskRunning: - label = "→ " + label - default: - label = "· " + label - } - parts = append(parts, label) - } - if lang == "zh" { - return "任务流:" + strings.Join(parts, " | ") - } - return "Workflow: " + strings.Join(parts, " | ") -} - -func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string { - completed := make([]string, 0, len(session.Tasks)) - for _, task := range session.Tasks { - if task.Status == workflowTaskCompleted { - completed = append(completed, task.Request) - } - } - if len(completed) == 0 { - return "" - } - if a.aiClient == nil { - if lang == "zh" { - return "已完成这些任务:" + strings.Join(completed, ";") - } - return "Completed these tasks: " + strings.Join(completed, "; ") - } - stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) - defer cancel() - systemPrompt := `You are summarizing a finished workflow for NOFXi. -Return one short user-facing summary in the user's language. -Do not mention internal DAG, scheduler, or JSON. -` + cleanUserFacingReplyInstruction - userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- ")) - raw, err := a.aiClient.CallWithRequest(&mcp.Request{ - Messages: []mcp.Message{ - mcp.NewSystemMessage(systemPrompt), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - if err != nil { - if lang == "zh" { - return "已完成这些任务:" + strings.Join(completed, ";") - } - return "Completed these tasks: " + strings.Join(completed, "; ") - } - return strings.TrimSpace(raw) -} - -func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) { - if !looksLikeMultiTaskIntent(text) { - return workflowDecomposition{}, nil - } - if a.aiClient != nil { - if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 { - return dec, nil - } - } - return a.decomposeWorkflowIntentFallback(text), nil -} - -func looksLikeMultiTaskIntent(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - connectors := []string{",", ",", "然后", "再", "并且", "并", "同时", "and", "then"} - count := 0 - for _, c := range connectors { - if strings.Contains(lower, c) { - count++ - } - } - 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 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) -Rules: -- Prefer atomic actions such as create, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail. -- If one request contains create plus follow-up edits in the same skill, split them into multiple tasks. -- If later tasks need an entity created earlier, make the dependency explicit in depends_on. -- Keep each request user-readable and self-contained enough for a single skill handler to execute. -- 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), - mcp.NewUserMessage(userPrompt), - }, - Ctx: stageCtx, - }) - if err != nil { - return workflowDecomposition{}, err - } - return parseWorkflowDecomposition(raw) -} - -func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - var out workflowDecomposition - if err := json.Unmarshal([]byte(raw), &out); err == nil { - out = normalizeWorkflowDecomposition(out) - return out, nil - } - start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start >= 0 && end > start { - if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil { - out = normalizeWorkflowDecomposition(out) - return out, nil - } - } - return workflowDecomposition{}, fmt.Errorf("invalid workflow json") -} - -func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition { - normalized := make([]WorkflowTask, 0, len(out.Tasks)) - for i, task := range out.Tasks { - task.ID = strings.TrimSpace(task.ID) - if task.ID == "" { - task.ID = fmt.Sprintf("task_%d", i+1) - } - task.Skill = strings.TrimSpace(task.Skill) - task.Action = normalizeAtomicSkillAction(task.Skill, task.Action) - task.Request = strings.TrimSpace(task.Request) - task.DependsOn = cleanStringList(task.DependsOn) - if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" { - continue - } - task.Status = workflowTaskPending - normalized = append(normalized, task) - } - out.Tasks = normalized - return out -} - -func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition { - segments := splitWorkflowSegments(text) - tasks := make([]WorkflowTask, 0, len(segments)) - nextID := 1 - for _, segment := range segments { - prevSkill := "" - if len(tasks) > 0 { - 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++ - } - } - 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 { - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text}) - } - if hasStart { - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) - } - 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 { - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text}) - } - if hasStart { - tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text}) - } - 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" - 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 "} - for _, sep := range separators { - next := make([]string, 0, len(parts)) - for _, part := range parts { - split := strings.Split(part, sep) - for _, candidate := range split { - candidate = strings.TrimSpace(candidate) - if candidate != "" { - next = append(next, candidate) - } - } - } - parts = next - } - return parts -} - -func classifyWorkflowTask(text string) (WorkflowTask, bool) { - segment := strings.TrimSpace(text) - if segment == "" { - return WorkflowTask{}, false - } - lower := strings.ToLower(segment) - switch { - case hasExplicitCreateIntentForDomain(segment, "trader"): - return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true - 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_bindings" - 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 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 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 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) { - return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true - } - } - return WorkflowTask{}, false -} diff --git a/api/agent_preferences.go b/api/agent_preferences.go deleted file mode 100644 index f73c3e5f..00000000 --- a/api/agent_preferences.go +++ /dev/null @@ -1,110 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "strings" - - "nofx/agent" - - "github.com/gin-gonic/gin" -) - -type agentPreferencePayload struct { - Text string `json:"text"` -} - -func (s *Server) handleGetAgentPreferences(c *gin.Context) { - uid := agent.SessionUserIDFromKey(c.GetString("user_id")) - raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid)) - if err != nil || strings.TrimSpace(raw) == "" { - c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}}) - return - } - - var prefs []agent.PersistentPreference - if err := json.Unmarshal([]byte(raw), &prefs); err != nil { - c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}}) - return - } - - c.JSON(http.StatusOK, gin.H{"preferences": prefs}) -} - -func (s *Server) handleCreateAgentPreference(c *gin.Context) { - uid := agent.SessionUserIDFromKey(c.GetString("user_id")) - - var req agentPreferencePayload - if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" { - 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 { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - prefs := s.loadAgentPreferences(uid) - prefs = append([]agent.PersistentPreference{created}, prefs...) - if len(prefs) > 20 { - prefs = prefs[:20] - } - - if err := s.saveAgentPreferences(uid, prefs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"}) - return - } - - c.JSON(http.StatusOK, gin.H{"preferences": prefs}) -} - -func (s *Server) handleDeleteAgentPreference(c *gin.Context) { - uid := agent.SessionUserIDFromKey(c.GetString("user_id")) - id := strings.TrimSpace(c.Param("id")) - if id == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "id required"}) - return - } - - prefs := s.loadAgentPreferences(uid) - filtered := prefs[:0] - for _, pref := range prefs { - if pref.ID != id { - filtered = append(filtered, pref) - } - } - - if err := s.saveAgentPreferences(uid, filtered); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"}) - return - } - - c.JSON(http.StatusOK, gin.H{"preferences": filtered}) -} - -func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference { - raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID)) - if err != nil || strings.TrimSpace(raw) == "" { - return []agent.PersistentPreference{} - } - - var prefs []agent.PersistentPreference - if err := json.Unmarshal([]byte(raw), &prefs); err != nil { - return []agent.PersistentPreference{} - } - return prefs -} - -func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error { - data, err := json.Marshal(prefs) - if err != nil { - return err - } - return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data)) -} diff --git a/api/agent_routes.go b/api/agent_routes.go deleted file mode 100644 index b15f6114..00000000 --- a/api/agent_routes.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "nofx/agent" - - "github.com/gin-gonic/gin" -) - -// RegisterAgentHandler registers NOFXi agent API routes on the main router. -// Chat endpoint requires authentication; market data endpoints are public. -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) { - 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: true, - 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) { - 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: true, - CanViewSensitiveSecrets: false, - }) - req := c.Request.WithContext(ctx) - h.HandleChatStream(c.Writer, req) - }) - // Public endpoints — read-only market data - s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth)) - s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines)) - s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker)) - s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers)) -} diff --git a/api/handler_ai500.go b/api/handler_ai500.go deleted file mode 100644 index 6a320d2e..00000000 --- a/api/handler_ai500.go +++ /dev/null @@ -1,43 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "nofx/agent" -) - -// handleAI500List serves the AI500 index board for the agent UI panel. -// Data is fetched through the user's claw402 wallet when one is configured -// (falling back to the direct nofxos client) and served from a 5-minute -// cache, so panel polling never hammers the upstream. -func (s *Server) handleAI500List(c *gin.Context) { - userID := c.GetString("user_id") - - limit := 0 - if raw := c.Query("limit"); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil || parsed < 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a non-negative integer"}) - return - } - limit = parsed - } - - walletKey := agent.Claw402WalletKeyForStoreUser(s.store, userID) - entries, err := agent.AI500Board(walletKey, limit) - if err != nil { - SafeInternalError(c, "Get AI500 list", err) - return - } - - // Flat body, matching /api/symbols: the web httpClient wraps the raw - // response body as `data`, so a nested success/data envelope here would - // hide the coins from the panel. - c.JSON(http.StatusOK, gin.H{ - "coins": entries, - "count": len(entries), - }) -} diff --git a/api/handler_exchange.go b/api/handler_exchange.go index 50208559..84b23744 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -37,6 +37,7 @@ type SafeExchangeConfig struct { HasPassphrase bool `json:"has_passphrase"` Testnet bool `json:"testnet,omitempty"` HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive) + HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"` HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"` HasAsterPrivateKey bool `json:"has_aster_private_key"` AsterUser string `json:"asterUser"` // Aster username (not sensitive) @@ -59,6 +60,7 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig { HasPassphrase: exchange.Passphrase != "", Testnet: exchange.Testnet, HyperliquidWalletAddr: exchange.HyperliquidWalletAddr, + HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct, HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved, HasAsterPrivateKey: exchange.AsterPrivateKey != "", AsterUser: exchange.AsterUser, @@ -80,7 +82,7 @@ type ExchangeConfigUpdate struct { Passphrase string `json:"passphrase"` // OKX specific Testnet bool `json:"testnet"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` - HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode + HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"` AsterUser string `json:"aster_user"` AsterSigner string `json:"aster_signer"` @@ -105,7 +107,7 @@ type CreateExchangeRequest struct { Passphrase string `json:"passphrase"` Testnet bool `json:"testnet"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` - HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral + HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"` AsterUser string `json:"aster_user"` AsterSigner string `json:"aster_signer"` @@ -147,6 +149,19 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { c.JSON(http.StatusOK, safeExchanges) } +func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool { + if requested != nil { + return *requested + } + if strings.EqualFold(exchangeType, "hyperliquid") { + if len(fallback) > 0 { + return fallback[0] + } + return true + } + return false +} + // handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config) func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { userID := c.GetString("user_id") @@ -255,6 +270,11 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { if exchangeData.HyperliquidBuilderApproved != nil { effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved } + effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount( + existing.ExchangeType, + exchangeData.HyperliquidUnifiedAcct, + existing.HyperliquidUnifiedAcct, + ) if missing := store.MissingRequiredExchangeCredentialFields( existing.ExchangeType, @@ -281,7 +301,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { tradersToReload[t.ID] = true } - err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) + err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) if err != nil { SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err) return @@ -387,10 +407,11 @@ func (s *Server) handleCreateExchange(c *gin.Context) { } // Exchange configs only persist once complete; persisted configs are always enabled. + effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct) id, err := s.store.Exchange().Create( userID, req.ExchangeType, req.AccountName, true, req.APIKey, req.SecretKey, req.Passphrase, req.Testnet, - req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct, req.HyperliquidBuilderApproved, + req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved, req.AsterUser, req.AsterSigner, req.AsterPrivateKey, req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex, ) diff --git a/api/handler_exchange_test.go b/api/handler_exchange_test.go index 4c4da538..aa1fcf0a 100644 --- a/api/handler_exchange_test.go +++ b/api/handler_exchange_test.go @@ -18,6 +18,7 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T APIKey: crypto.EncryptedString("api-test-123"), SecretKey: crypto.EncryptedString("secret-test-123"), Passphrase: crypto.EncryptedString("passphrase-test-123"), + HyperliquidUnifiedAcct: true, AsterPrivateKey: crypto.EncryptedString("aster-private-key"), LighterPrivateKey: crypto.EncryptedString("lighter-private-key"), LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"), @@ -42,4 +43,24 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T if !safe.HasLighterAPIKey { t.Fatalf("expected has_lighter_api_key_private_key to be true") } + if !safe.HyperliquidUnifiedAcct { + t.Fatalf("expected hyperliquid unified account to be exposed") + } +} + +func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) { + if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) { + t.Fatalf("expected new hyperliquid accounts to default unified account on") + } + if effectiveHyperliquidUnifiedAccount("binance", nil) { + t.Fatalf("expected non-hyperliquid accounts to default unified account off") + } + fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false) + if fallbackFalse { + t.Fatalf("expected omitted update field to preserve existing false value") + } + requestedTrue := true + if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) { + t.Fatalf("expected explicit true to override existing false value") + } } diff --git a/api/handler_trader.go b/api/handler_trader.go index e363092b..552f27b9 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -434,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // Set scan interval default value scanIntervalMinutes := req.ScanIntervalMinutes - if scanIntervalMinutes < 3 { - scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3 + if scanIntervalMinutes <= 0 { + scanIntervalMinutes = 15 + } else if scanIntervalMinutes < 3 { + scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum. } // Query exchange actual balance, override user input diff --git a/api/handler_user.go b/api/handler_user.go index a312616a..bd4c57f8 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -225,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { name, description string } type strategyLocale struct { - trend, megaCap, breakout strategyI18n + defaultStrategy strategyI18n } locales := map[string]strategyLocale{ "zh": { - trend: strategyI18n{"美股趋势策略", "开箱即用的 Hyperliquid 美股 USDC 策略。只扫描流动性更好的美股合约,低杠杆、低频率,适合直接创建 Agent 后运行。"}, - megaCap: strategyI18n{"美股大盘稳健策略", "开箱即用的 Hyperliquid 美股 USDC 策略。固定关注 AAPL、MSFT、GOOGL、AMZN、META 等大盘股,强调趋势确认和回撤控制。"}, - breakout: strategyI18n{"美股突破策略", "开箱即用的 Hyperliquid 美股 USDC 策略。扫描 24h 强势美股,等待突破确认后再开仓,避免频繁追涨。"}, + defaultStrategy: strategyI18n{"NOFX Claw402 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"}, }, "en": { - trend: strategyI18n{"US Stock Trend Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans liquid US stock perps with low leverage and low trade frequency, suitable for one-click Agent deployment."}, - megaCap: strategyI18n{"US Mega-Cap Steady Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Fixed universe: AAPL, MSFT, GOOGL, AMZN and META, with trend confirmation and drawdown control."}, - breakout: strategyI18n{"US Stock Breakout Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans 24h strong US stocks and waits for breakout confirmation before entering, avoiding impulsive chasing."}, + defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."}, }, "id": { - trend: strategyI18n{"Strategi Tren Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai perp saham AS likuid dengan leverage rendah dan frekuensi rendah."}, - megaCap: strategyI18n{"Strategi Stabil Mega-Cap AS", "Strategi saham AS USDC Hyperliquid siap jalan. Universe tetap: AAPL, MSFT, GOOGL, AMZN, META, dengan konfirmasi tren."}, - breakout: strategyI18n{"Strategi Breakout Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai saham AS kuat 24 jam dan menunggu konfirmasi breakout."}, + defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."}, }, } locale, ok := locales[lang] @@ -256,76 +250,42 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { applyConfig func(*store.StrategyConfig) } - setStockRank := func(c *store.StrategyConfig, direction string, limit int) { - c.CoinSource.SourceType = "hyper_rank" + setClaw402Strategy := func(c *store.StrategyConfig) { + c.CoinSource.SourceType = "vergex_signal" c.CoinSource.StaticCoins = nil c.CoinSource.UseAI500 = false c.CoinSource.UseOITop = false c.CoinSource.UseOILow = false c.CoinSource.UseHyperAll = false c.CoinSource.UseHyperMain = false - c.CoinSource.HyperRankCategory = "stock" - c.CoinSource.HyperRankDirection = direction - c.CoinSource.HyperRankLimit = limit - } - setStaticStocks := func(c *store.StrategyConfig, symbols []string) { - c.CoinSource.SourceType = "static" - c.CoinSource.StaticCoins = symbols - c.CoinSource.UseAI500 = false - c.CoinSource.UseOITop = false - c.CoinSource.UseOILow = false - c.CoinSource.UseHyperAll = false - c.CoinSource.UseHyperMain = false - } - setStableRisk := func(c *store.StrategyConfig) { + c.CoinSource.HyperRankCategory = "all" + c.CoinSource.VergexLimit = 10 + c.CoinSource.VergexMarketType = "all" + c.CoinSource.VergexChain = "hyperliquid" c.RiskControl.MaxPositions = 2 - c.RiskControl.BTCETHMaxLeverage = 3 - c.RiskControl.AltcoinMaxLeverage = 3 - c.RiskControl.BTCETHMaxPositionValueRatio = 2.0 - c.RiskControl.AltcoinMaxPositionValueRatio = 0.6 - c.RiskControl.MaxMarginUsage = 0.45 + c.RiskControl.BTCETHMaxLeverage = 10 + c.RiskControl.AltcoinMaxLeverage = 10 + c.RiskControl.BTCETHMaxPositionValueRatio = 1.0 + c.RiskControl.AltcoinMaxPositionValueRatio = 1.0 + c.RiskControl.MaxMarginUsage = 0.35 c.RiskControl.MinConfidence = 78 c.RiskControl.MinRiskRewardRatio = 3.0 c.Indicators.Klines.PrimaryTimeframe = "15m" - c.Indicators.Klines.LongerTimeframe = "4h" - c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"} - c.Indicators.EnableEMA = true - c.Indicators.EnableMACD = true - c.Indicators.EnableRSI = true - c.Indicators.EnableATR = true - c.Indicators.EnableVolume = true + c.Indicators.Klines.PrimaryCount = 30 + c.Indicators.Klines.LongerTimeframe = "" + c.Indicators.Klines.LongerCount = 0 + c.Indicators.Klines.EnableMultiTimeframe = false + c.Indicators.Klines.SelectedTimeframes = []string{"15m"} + c.Indicators.EnableRawKlines = true } definitions := []strategyDef{ { - name: locale.trend.name, - description: locale.trend.description, + name: locale.defaultStrategy.name, + description: locale.defaultStrategy.description, isActive: true, applyConfig: func(c *store.StrategyConfig) { - setStockRank(c, "volume", 5) - setStableRisk(c) - }, - }, - { - name: locale.megaCap.name, - description: locale.megaCap.description, - isActive: false, - applyConfig: func(c *store.StrategyConfig) { - setStaticStocks(c, []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"}) - setStableRisk(c) - c.RiskControl.MaxPositions = 2 - c.RiskControl.MinConfidence = 80 - }, - }, - { - name: locale.breakout.name, - description: locale.breakout.description, - isActive: false, - applyConfig: func(c *store.StrategyConfig) { - setStockRank(c, "gainers", 5) - setStableRisk(c) - c.RiskControl.MinConfidence = 82 - c.RiskControl.MinRiskRewardRatio = 3.5 + setClaw402Strategy(c) }, }, } @@ -359,8 +319,11 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { legacyDefaultNames := []string{ "均衡策略", "稳健策略", "积极策略", + "美股趋势策略", "美股稳健策略", "美股突破策略", "Balanced Strategy", "Conservative Strategy", "Aggressive Strategy", + "US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy", "Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif", + "Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS", } return s.store.Transaction(func(tx *gorm.DB) error { diff --git a/api/handler_user_default_strategy_test.go b/api/handler_user_default_strategy_test.go index 8d2eecc0..baeb0c80 100644 --- a/api/handler_user_default_strategy_test.go +++ b/api/handler_user_default_strategy_test.go @@ -7,7 +7,7 @@ import ( "nofx/store" ) -func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) { +func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) { st, err := store.New(t.TempDir() + "/nofx.db") if err != nil { t.Fatalf("store.New failed: %v", err) @@ -24,8 +24,8 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) { if err != nil { t.Fatalf("List strategies failed: %v", err) } - if len(strategies) != 3 { - t.Fatalf("expected 3 default strategies, got %d", len(strategies)) + if len(strategies) != 1 { + t.Fatalf("expected 1 default strategy, got %d", len(strategies)) } byName := map[string]*store.Strategy{} @@ -43,40 +43,22 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) { t.Fatalf("expected exactly one active strategy, got %d", activeCount) } - trend := byName["美股趋势策略"] - if trend == nil || !trend.IsActive { - t.Fatalf("美股趋势策略 should exist and be active") + defaultStrategy := byName["NOFX Claw402 自动策略"] + if defaultStrategy == nil || !defaultStrategy.IsActive { + t.Fatalf("NOFX Claw402 自动策略 should exist and be active") } - trendCfg, err := trend.ParseConfig() + trendCfg, err := defaultStrategy.ParseConfig() if err != nil { - t.Fatalf("trend ParseConfig failed: %v", err) + t.Fatalf("default ParseConfig failed: %v", err) } - if trendCfg.CoinSource.SourceType != "hyper_rank" || trendCfg.CoinSource.HyperRankCategory != "stock" || trendCfg.CoinSource.HyperRankDirection != "volume" { - t.Fatalf("trend strategy should use Hyperliquid stock volume ranking, got %+v", trendCfg.CoinSource) + if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" { + t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource) } if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 || trendCfg.RiskControl.MaxMarginUsage > 0.45 { - t.Fatalf("trend strategy should be low-risk Hyperliquid native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl) + t.Fatalf("default strategy should be low-risk Claw402/Vergex native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl) } - - megaCap := byName["美股大盘稳健策略"] - if megaCap == nil { - t.Fatalf("美股大盘稳健策略 should exist") - } - megaCfg, err := megaCap.ParseConfig() - if err != nil { - t.Fatalf("megaCap ParseConfig failed: %v", err) - } - if megaCfg.CoinSource.SourceType != "static" { - t.Fatalf("mega-cap strategy should use static stock symbols, got %+v", megaCfg.CoinSource) - } - wantSymbols := []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"} - if len(megaCfg.CoinSource.StaticCoins) != len(wantSymbols) { - t.Fatalf("unexpected static stock list: %+v", megaCfg.CoinSource.StaticCoins) - } - for i, want := range wantSymbols { - if megaCfg.CoinSource.StaticCoins[i] != want { - t.Fatalf("static stock %d: want %s got %s", i, want, megaCfg.CoinSource.StaticCoins[i]) - } + if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 { + t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl) } } @@ -140,10 +122,8 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust if byName["均衡策略"] != 0 { t.Fatalf("legacy preset should be removed, got names=%+v", byName) } - for _, name := range []string{"美股趋势策略", "美股大盘稳健策略", "美股突破策略"} { - if byName[name] != 1 { - t.Fatalf("expected exactly one %s, got names=%+v", name, byName) - } + if byName["NOFX Claw402 自动策略"] != 1 { + t.Fatalf("expected exactly one NOFX Claw402 自动策略, got names=%+v", byName) } if len(activeNames) != 1 || activeNames[0] != "aa" { t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames) diff --git a/api/handler_vergex.go b/api/handler_vergex.go new file mode 100644 index 00000000..665f0690 --- /dev/null +++ b/api/handler_vergex.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "nofx/logger" + "nofx/provider/vergex" + "strings" + + "github.com/gin-gonic/gin" +) + +func (s *Server) handleVergexSignalRanking(c *gin.Context) { + client, ok := s.newVergexClientForRequest(c) + if !ok { + return + } + data, err := client.GetSignalRanking(context.Background(), vergex.Query{ + Chain: strings.TrimSpace(c.Query("chain")), + LiqBand: strings.TrimSpace(c.Query("liqBand")), + }) + if err != nil { + logger.Warnf("Vergex signal-ranking failed: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems) + marketType := strings.TrimSpace(c.Query("marketType")) + items := vergex.FilterSignalRankingItems(data.Items, marketType, limit) + c.JSON(http.StatusOK, gin.H{ + "items": items, + "raw": data.Raw, + }) +} + +func (s *Server) handleVergexSignalLab(c *gin.Context) { + client, ok := s.newVergexClientForRequest(c) + if !ok { + return + } + body, err := client.GetSignalLab(context.Background(), vergex.Query{ + MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType), + Symbol: strings.TrimSpace(c.Query("symbol")), + Chain: strings.TrimSpace(c.Query("chain")), + LiqBand: strings.TrimSpace(c.Query("liqBand")), + }) + if err != nil { + logger.Warnf("Vergex signal-lab failed: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", body) +} + +func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) { + client, ok := s.newVergexClientForRequest(c) + if !ok { + return + } + body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{ + MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType), + Symbol: strings.TrimSpace(c.Query("symbol")), + Chain: strings.TrimSpace(c.Query("chain")), + LiqBand: strings.TrimSpace(c.Query("liqBand")), + }) + if err != nil { + logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", body) +} + +func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return nil, false + } + walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return nil, false + } + if walletKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"}) + return nil, false + } + client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return nil, false + } + return client, true +} + +func parsePositiveInt(raw string, fallback int) int { + if raw == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 { + return fallback + } + return n +} + +func withDefault(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/api/server.go b/api/server.go index bb54906c..cf608d5d 100644 --- a/api/server.go +++ b/api/server.go @@ -202,9 +202,6 @@ func (s *Server) setupRoutes() { s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding) s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet) - s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences) - s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference) - s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference) // User account management s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", @@ -214,8 +211,9 @@ func (s *Server) setupRoutes() { // Server IP query (requires authentication, for whitelist configuration) s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP) - // AI500 index board (cached; routed through claw402 when configured) - s.route(protected, "GET", "/ai500", "AI500 index board (?limit=20)", s.handleAI500List) + s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking) + s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab) + s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap) // AI trader management s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status", @@ -226,7 +224,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en `:id = trader_id from GET /api/my-traders`, s.handleGetTraderConfig) s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader", - `Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} + `Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`, s.handleCreateTrader) s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration", @@ -337,10 +335,10 @@ CRITICAL: Always use the "id" field for strategy_id.`, IMPORTANT: For most use cases just POST {"name":""} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes). StrategyConfig fields: - coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) - coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static" - coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10) - coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection + coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended) + coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10) + coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type + coin_source.vergex_chain: "hyperliquid" indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h" indicators.klines.primary_count: number of candles (20-100) indicators.klines.enable_multi_timeframe: true for trend/swing analysis diff --git a/api/strategy.go b/api/strategy.go index ad784b18..1555ca62 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -636,6 +637,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { symbols = append(symbols, c.Symbol) } quantDataMap := engine.FetchQuantDataBatch(symbols) + vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols) // Fetch OI ranking data (market-wide position changes) oiRankingData := engine.FetchOIRankingData() @@ -666,6 +668,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { PromptVariant: req.PromptVariant, MarketDataMap: marketDataMap, QuantDataMap: quantDataMap, + VergexDataMap: vergexDataMap, OIRankingData: oiRankingData, NetFlowRankingData: netFlowRankingData, PriceRankingData: priceRankingData, diff --git a/kernel/engine.go b/kernel/engine.go index cfed0a23..c994ca90 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -10,11 +10,13 @@ import ( "nofx/market" "nofx/provider/hyperliquid" "nofx/provider/nofxos" + "nofx/provider/vergex" "nofx/security" "nofx/store" "os" "sort" "strings" + "sync" "time" ) @@ -104,6 +106,7 @@ type Context struct { MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` QuantDataMap map[string]*QuantData `json:"-"` + VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"` OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers @@ -183,8 +186,10 @@ type OIDeltaData struct { // StrategyEngine strategy execution engine type StrategyEngine struct { - config *store.StrategyConfig - nofxosClient *nofxos.Client + config *store.StrategyConfig + nofxosClient *nofxos.Client + vergexClient *vergex.Client + vergexRankingCache map[string]*vergex.SignalRankItem } // NewStrategyEngine creates strategy execution engine. @@ -217,11 +222,25 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) } else { logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err) } + + vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{}) + if err == nil { + logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL) + } else { + logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err) + } + return &StrategyEngine{ + config: config, + nofxosClient: client, + vergexClient: vergexClient, + vergexRankingCache: make(map[string]*vergex.SignalRankItem), + } } return &StrategyEngine{ - config: config, - nofxosClient: client, + config: config, + nofxosClient: client, + vergexRankingCache: make(map[string]*vergex.SignalRankItem), } } @@ -230,7 +249,7 @@ func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool { return false } source := e.config.CoinSource - if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.UseHyperAll || source.UseHyperMain { + if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain { return true } for _, symbol := range source.StaticCoins { @@ -392,6 +411,20 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { } return e.filterExcludedCoins(coins), nil + case "vergex_signal": + coins, err := e.getVergexSignalCoins( + coinSource.VergexLimit, + coinSource.VergexMarketType, + coinSource.VergexChain, + coinSource.VergexLiqBand, + coinSource.HyperRankCategory, + coinSource.StaticCoins, + ) + if err != nil { + return nil, err + } + return e.filterExcludedCoins(coins), nil + case "mixed": if coinSource.UseAI500 { poolCoins, err := e.getAI500Coins(coinSource.AI500Limit) @@ -694,6 +727,126 @@ func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int return candidates, nil } +func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) { + if e.vergexClient == nil { + return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet") + } + if marketType == "" { + marketType = vergex.DefaultMarketType + } + chain = vergex.QueryChain(chain) + if limit <= 0 { + limit = 5 + } + if limit > store.MaxCandidateCoins { + limit = store.MaxCandidateCoins + } + category = strings.ToLower(strings.TrimSpace(category)) + + ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{ + Chain: chain, + LiqBand: liqBand, + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err) + } + + rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins) + if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" { + fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{ + LiqBand: liqBand, + }) + if fallbackErr == nil { + fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins) + if len(fallbackItems) > 0 { + logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain) + ranking = fallbackRanking + rankedItems = fallbackItems + } + } else { + logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr) + } + } + e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems)) + for _, item := range rankedItems { + itemCopy := item + if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" { + e.vergexRankingCache[symbol] = &itemCopy + } + } + + if len(selectedSymbols) > 0 { + candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit)) + seen := make(map[string]bool) + for _, raw := range selectedSymbols { + symbol := vergex.TradableSymbolForMarket(marketType, raw) + if symbol == "" || seen[symbol] { + continue + } + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"vergex_signal"}, + }) + seen[symbol] = true + if len(candidates) >= limit { + break + } + } + if len(candidates) == 0 { + return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType) + } + logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType) + return candidates, nil + } + + items := make([]vergex.SignalRankItem, 0, limit) + for _, item := range rankedItems { + if category != "" && category != "all" && item.Category != category { + continue + } + items = append(items, item) + if len(items) >= limit { + break + } + } + if len(items) == 0 { + if category != "" && category != "all" { + return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category) + } + return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType) + } + + candidates := make([]CandidateCoin, 0, len(items)) + for _, item := range items { + itemCopy := item + symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol) + if symbol == "" { + continue + } + e.vergexRankingCache[symbol] = &itemCopy + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"vergex_signal"}, + }) + } + logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit) + return candidates, nil +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func withDefaultText(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + // ============================================================================ // External & Quant Data // ============================================================================ @@ -879,6 +1032,282 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant return result } +func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis { + result := make(map[string]*vergex.MarketAnalysis) + if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" { + return result + } + if e.vergexClient == nil { + logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured") + return result + } + if ctx == nil { + ctx = context.Background() + } + + source := e.config.CoinSource + marketType := source.VergexMarketType + if marketType == "" { + marketType = vergex.DefaultMarketType + } + chain := source.VergexChain + chain = vergex.QueryChain(chain) + + seen := make(map[string]bool) + limited := make([]string, 0, store.MaxCandidateCoins) + for _, symbol := range symbols { + symbol = vergexDetailSymbolForLookup(marketType, symbol) + if symbol == "" { + continue + } + if seen[symbol] { + continue + } + seen[symbol] = true + limited = append(limited, symbol) + if len(limited) >= store.MaxCandidateCoins+store.MaxPositions { + break + } + } + + type vergexAnalysisResult struct { + symbol string + analysis *vergex.MarketAnalysis + } + + resultCh := make(chan vergexAnalysisResult, len(limited)) + var wg sync.WaitGroup + sem := make(chan struct{}, vergexDetailSymbolConcurrency) + for _, symbol := range limited { + symbol := symbol + querySymbol := vergex.QuerySymbol(symbol) + if querySymbol == "" { + continue + } + itemMarketType := marketType + itemCategory := "" + var ranking *vergex.SignalRankItem + if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil { + ranking = cached + if cached.MarketType != "" { + itemMarketType = cached.MarketType + } + itemCategory = cached.Category + } + + analysis := &vergex.MarketAnalysis{ + Symbol: symbol, + QuerySymbol: querySymbol, + MarketType: itemMarketType, + Ranking: ranking, + } + query := vergex.Query{ + MarketType: itemMarketType, + Symbol: symbol, + Chain: chain, + LiqBand: source.VergexLiqBand, + Category: itemCategory, + } + + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + analysis.SignalLabError = ctx.Err().Error() + analysis.HeatmapError = ctx.Err().Error() + resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis} + return + } + e.populateVergexDetailData(ctx, analysis, query) + if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 || + analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil { + resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis} + } + }() + } + + wg.Wait() + close(resultCh) + for item := range resultCh { + result[item.symbol] = item.analysis + } + + logger.Infof("📊 Vergex detail data ready for %d symbols", len(result)) + return result +} + +func vergexDetailSymbolForLookup(marketType, symbol string) string { + return vergex.TradableSymbolForMarket(marketType, symbol) +} + +const ( + vergexDetailRequestTimeout = 45 * time.Second + vergexDetailSymbolConcurrency = 2 +) + +func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) { + type endpointResult struct { + name string + body json.RawMessage + err error + } + + run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) { + requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout) + defer cancel() + body, err := fetch(requestCtx, query) + out <- endpointResult{name: name, body: body, err: err} + } + + out := make(chan endpointResult, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + run("signal-lab", e.fetchVergexSignalLabWithFallback, out) + }() + go func() { + defer wg.Done() + run("heatmap", e.fetchVergexHeatmapWithFallback, out) + }() + wg.Wait() + close(out) + + for item := range out { + switch item.name { + case "signal-lab": + if item.err != nil { + logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err) + analysis.SignalLabError = item.err.Error() + } else { + analysis.SignalLab = item.body + } + case "heatmap": + if item.err != nil { + logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err) + analysis.HeatmapError = item.err.Error() + } else { + analysis.Heatmap = item.body + } + } + } +} + +func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) { + var lastErr error + for idx, candidate := range vergexDetailQueryCandidates(query) { + body, err := e.vergexClient.GetSignalLab(ctx, candidate) + if err == nil { + if idx > 0 { + logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default")) + } + return body, nil + } + lastErr = err + if !isRetryableVergexDetailError(err) { + break + } + } + return nil, lastErr +} + +func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) { + var lastErr error + for idx, candidate := range vergexDetailQueryCandidates(query) { + body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate) + if err == nil { + if idx > 0 { + logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default")) + } + return body, nil + } + lastErr = err + if !isRetryableVergexDetailError(err) { + break + } + } + return nil, lastErr +} + +func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query { + marketTypes := vergexDetailMarketTypeCandidates(query) + chains := uniqueValues(query.Chain, "mainnet", "") + + candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains)) + for _, marketType := range marketTypes { + for _, chain := range chains { + candidate := query + candidate.MarketType = marketType + candidate.Chain = chain + candidates = append(candidates, candidate) + } + } + return candidates +} + +func vergexDetailMarketTypeCandidates(query vergex.Query) []string { + if isVergexAllMarketType(query.MarketType) { + if market.IsXyzDexAsset(query.Symbol) { + return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp") + } + return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp") + } + values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"} + return uniqueNonEmpty(values...) +} + +func isVergexAllMarketType(marketType string) bool { + switch strings.ToLower(strings.TrimSpace(marketType)) { + case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex": + return true + default: + return false + } +} + +func isRetryableVergexDetailError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "invalid markettype") || + strings.Contains(msg, "invalid_request") || + strings.Contains(msg, "invalid chain") || + strings.Contains(msg, "market not found") || + strings.Contains(msg, "not_found") +} + +func uniqueNonEmpty(values ...string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func uniqueValues(values ...string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + // FetchOIRankingData fetches market-wide OI ranking data func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData { indicators := e.config.Indicators diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go index 125949bd..2237ceb6 100644 --- a/kernel/engine_analysis.go +++ b/kernel/engine_analysis.go @@ -85,6 +85,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S } } pruneCandidateCoinsWithoutMarketData(ctx) + enrichVergexDataWithStrategy(ctx, engine) // Ensure OITopDataMap is initialized if ctx.OITopDataMap == nil { @@ -142,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S return decision, nil } +func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) { + if ctx == nil || engine == nil || ctx.VergexDataMap != nil { + return + } + if engine.GetConfig().CoinSource.SourceType != "vergex_signal" { + return + } + symbolSet := make(map[string]bool) + symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions)) + for _, coin := range ctx.CandidateCoins { + if !symbolSet[coin.Symbol] { + symbolSet[coin.Symbol] = true + symbols = append(symbols, coin.Symbol) + } + } + for _, pos := range ctx.Positions { + if !symbolSet[pos.Symbol] { + symbolSet[pos.Symbol] = true + symbols = append(symbols, pos.Symbol) + } + } + ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols) +} + // ============================================================================ // Market Data Fetching // ============================================================================ diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go index c4ceb682..75a84ad4 100644 --- a/kernel/engine_prompt.go +++ b/kernel/engine_prompt.go @@ -4,6 +4,7 @@ import ( "fmt" "nofx/market" "nofx/provider/nofxos" + "nofx/provider/vergex" "nofx/store" "strings" "time" @@ -18,20 +19,15 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string var sb strings.Builder riskControl := e.config.RiskControl promptSections := e.config.PromptSections - lang := e.GetLanguage() - zh := lang == LangChinese + // System prompts are intentionally English-only. UI copy can be localized, + // but the model contract should stay language-stable for an international + // open-source project and for reproducible trading behavior. + lang := LangEnglish + zh := false singleSymbol, primarySymbol := e.singleSymbolInfo() - // XYZ-only override: when the strategy trades a single Hyperliquid XYZ - // asset (US stocks, commodities, forex), force the entire prompt to - // English regardless of the strategy's stored language. Mixing Chinese - // reasoning with US-equity analysis confuses the LLM (its US-stock - // training is overwhelmingly English) and the user prompt sections - // ended up looking incoherent because some sections respect the - // language flag while legacy stored sections were always English. - if singleSymbol && market.IsXyzDexAsset(primarySymbol) { - zh = false - lang = LangEnglish + if e.usesVergexSignalPrompt() { + return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol) } // 0. Data Dictionary & Schema (ensure AI understands all fields) @@ -41,8 +37,9 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string // 1. Role definition (editable; falls back to a generic intro in the // correct language so we don't mix EN headings with ZH custom text). - if promptSections.RoleDefinition != "" { - sb.WriteString(promptSections.RoleDefinition) + roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition) + if roleDefinition != "" { + sb.WriteString(roleDefinition) sb.WriteString("\n\n") } else if zh { sb.WriteString("# 你是一名专业的 Hyperliquid USDC 多资产交易 AI\n\n") @@ -73,26 +70,28 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh) // 4. Trading frequency (editable) - if promptSections.TradingFrequency != "" { - sb.WriteString(promptSections.TradingFrequency) + tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency) + if tradingFrequency != "" { + sb.WriteString(tradingFrequency) sb.WriteString("\n\n") } else if zh { sb.WriteString("# ⏱️ 交易频率提醒\n\n") sb.WriteString("- 优秀交易员: 每日 2-4 单 ≈ 每小时 0.1-0.2 单\n") sb.WriteString("- 每小时 > 2 单 = 过度交易\n") - sb.WriteString("- 单笔持仓时长 ≥ 30-60 分钟\n") - sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 30 分钟就平仓 → 太冲动。\n\n") + sb.WriteString("- 单笔持仓时长 ≥ 45-90 分钟\n") + sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 45 分钟就平仓 → 太冲动。\n\n") } else { sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n") sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n") sb.WriteString("- >2 trades/hour = overtrading\n") - sb.WriteString("- Single position hold time ≥ 30-60 minutes\n") - sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 30 minutes → too impulsive.\n\n") + sb.WriteString("- Single position hold time ≥ 45-90 minutes\n") + sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n") } // 5. Entry standards (editable) - if promptSections.EntryStandards != "" { - sb.WriteString(promptSections.EntryStandards) + entryStandards := englishOnlyPromptSection(promptSections.EntryStandards) + if entryStandards != "" { + sb.WriteString(entryStandards) if zh { sb.WriteString("\n\n你拥有以下指标数据:\n") } else { @@ -117,8 +116,9 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string } // 6. Decision process (editable) - if promptSections.DecisionProcess != "" { - sb.WriteString(promptSections.DecisionProcess) + decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess) + if decisionProcess != "" { + sb.WriteString(decisionProcess) sb.WriteString("\n\n") } else if zh { sb.WriteString("# 📋 决策流程\n\n") @@ -146,7 +146,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string // incoherent mixed-language final prompt that confused the LLM. // 2. It guarantees a stock-specific, US-equity-tuned briefing // regardless of when the strategy was first created. - customPrompt := e.config.CustomPrompt + customPrompt := englishOnlyPromptSection(e.config.CustomPrompt) if singleSymbol && market.IsXyzDexAsset(primarySymbol) { customPrompt = buildXYZStockCustomPrompt(primarySymbol) } @@ -169,43 +169,259 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string return sb.String() } -// buildXYZStockCustomPrompt returns the canonical English long-only stock +func (e *StrategyEngine) usesVergexSignalPrompt() bool { + if e == nil || e.config == nil { + return false + } + coinSource := e.config.CoinSource + sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType)) + return sourceType == "vergex_signal" || + sourceType == "claw402" || + sourceType == "claw402_vergex" || + coinSource.VergexMarketType != "" || + coinSource.VergexChain != "" || + coinSource.VergexLimit > 0 +} + +func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string { + var sb strings.Builder + riskControl := e.config.RiskControl + + writeVergexSchemaPrompt(&sb, zh) + sb.WriteString("\n\n---\n\n") + + if zh { + sb.WriteString("# 你是 NOFX Claw402 自动交易员\n\n") + sb.WriteString("你的任务是交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。只允许交易本轮候选标的和已有持仓,不要自行发明代码或切换到榜单外标的。\n\n") + sb.WriteString("# 决策数据优先级\n\n") + sb.WriteString("1. Claw402.ai Signal Ranking: 决定本轮候选池、排名、方向和类别。\n") + sb.WriteString("2. Claw402.ai Signal Lab: 用于确认趋势、动量、事件或模型信号;这是开仓前的核心确认数据。\n") + sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: 用于识别清算密集区、成本区、止损位置和止盈目标。\n") + sb.WriteString("4. 原始 OHLCV K 线: 用于验证入场时机、趋势结构、波动和风险回报。\n\n") + sb.WriteString("# 交易原则\n\n") + sb.WriteString("- 先管理已有持仓,再考虑新开仓。\n") + sb.WriteString("- 开仓需要 Signal Lab、热力图和 K 线方向大体一致;任一关键数据缺失或互相冲突时,默认等待。\n") + sb.WriteString("- 不要把 Claw402 排名当作唯一买入理由;排名只是候选池,开仓必须经过详情数据和 K 线确认。\n") + sb.WriteString("- 本轮 Candidate Coins 中的标的都是允许交易的候选;如果某个标的详情缺失,只能降低置信度或等待,不能说它不属于可交易范围。\n") + sb.WriteString("- 如果 Signal Lab 或热力图没有出现在该标的的 Vergex Claw402 Signals 里,必须在 reasoning 中说明缺失;如果已经出现,则不能声称该标的缺少该数据。\n") + sb.WriteString("- 防止频繁开平仓:非止损或强止盈情况下,开仓后至少持有 45 分钟;小亏小赚的噪音区优先持有到 90 分钟;平仓后同一标的 90 分钟内不重新进场;每小时最多 1 次新开仓。\n") + sb.WriteString("- 止损必须放在无效点之外;止盈优先放在热力图阻力/清算区域或满足风险回报的位置。\n\n") + } else { + sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n") + sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n") + sb.WriteString("# Decision Data Priority\n\n") + sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n") + sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n") + sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n") + sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n") + sb.WriteString("# Trading Rules\n\n") + sb.WriteString("- Manage existing positions before opening new ones.\n") + sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n") + sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n") + sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n") + sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n") + sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n") + sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n") + } + + writeModeVariant(&sb, variant, zh) + + altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio + if altcoinPosValueRatio <= 0 { + altcoinPosValueRatio = 1.0 + } + writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh) + writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh) + + customPrompt := englishOnlyPromptSection(e.config.CustomPrompt) + if customPrompt != "" { + sb.WriteString("# User Preference\n\n") + sb.WriteString(customPrompt) + sb.WriteString("\n\n") + } + + return sb.String() +} + +func englishOnlyPromptSection(section string) string { + trimmed := strings.TrimSpace(section) + if trimmed == "" { + return "" + } + if detectLanguage(trimmed) == LangChinese { + return "" + } + return trimmed +} + +func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) { + if zh { + sb.WriteString("# Claw402.ai TradeFi 数据说明\n\n") + sb.WriteString("- Equity: 账户总权益,包含浮动盈亏,单位 USDT。\n") + sb.WriteString("- Balance: 可用余额,用于判断还能否开新仓,单位 USDT。\n") + sb.WriteString("- Margin: 当前保证金使用率,越高风险越大。\n") + sb.WriteString("- Position: 当前持仓,包含方向、进场价、杠杆、未实现盈亏、强平价。\n") + sb.WriteString("- Claw402 Ranking: 本轮可交易候选池、排名、方向和类别。\n") + sb.WriteString("- Signal Lab: Claw402 对单个标的的深度信号,用于确认趋势和质量。\n") + sb.WriteString("- Cost/Liquidation Heatmap: 成本区与清算密集区,用于止损、止盈和拥挤风险判断。\n") + sb.WriteString("- Raw OHLCV Kline: 原始 K 线,用于确认趋势结构、入场位置和风险回报。\n") + } else { + sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n") + sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n") + sb.WriteString("- Balance: available balance for new positions, in USDT.\n") + sb.WriteString("- Margin: current margin usage; higher means more risk.\n") + sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n") + sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n") + sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n") + sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n") + sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n") + } +} + +func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) { + maxPositionValue := accountEquity * tradeFiPositionValueRatio + if zh { + sb.WriteString("# 风控硬约束\n\n") + sb.WriteString("## 后端强制\n") + sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个 Claw402 候选标的\n", riskControl.MaxPositions)) + sb.WriteString(fmt.Sprintf("- 单仓最大名义价值: %.0f USDT (= 权益 %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio)) + sb.WriteString(fmt.Sprintf("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100)) + sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize)) + sb.WriteString("## AI 建议\n") + sb.WriteString(fmt.Sprintf("- 交易杠杆: Claw402 候选标的最高 %dx\n", riskControl.AltcoinMaxLeverage)) + sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f\n", riskControl.MinRiskRewardRatio)) + sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才能开仓\n\n", riskControl.MinConfidence)) + sb.WriteString("# 仓位大小\n\n") + sb.WriteString("根据置信度和单仓最大名义价值填写 `position_size_usd`:\n") + sb.WriteString("- 高置信 (≥85): 使用上限的 80-100%\n") + sb.WriteString("- 中置信 (70-84): 使用上限的 50-80%\n") + sb.WriteString("- 低置信 (60-69): 使用上限的 30-50%\n") + sb.WriteString("- 不要直接把 available_balance 当作 position_size_usd。\n\n") + } else { + sb.WriteString("# Hard Risk Constraints\n\n") + sb.WriteString("## Backend enforced\n") + sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions)) + sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio)) + sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100)) + sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize)) + sb.WriteString("## AI guided\n") + sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage)) + sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio)) + sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence)) + sb.WriteString("# Position Sizing\n\n") + sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n") + sb.WriteString("- Do not scale position_size_usd down by confidence.\n") + sb.WriteString("- Do not open small probe positions.\n") + sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n") + sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n") + } +} + +func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) { + exampleSymbol := "xyz:NVDA" + secondSymbol := "xyz:AAPL" + if singleSymbol && strings.TrimSpace(primarySymbol) != "" { + exampleSymbol = primarySymbol + secondSymbol = primarySymbol + } + positionSize := accountEquity * tradeFiPositionValueRatio + leverage := riskControl.AltcoinMaxLeverage + if leverage <= 0 { + leverage = 1 + } + + sb.WriteString("# Output Format (Strictly Follow)\n\n") + if zh { + sb.WriteString("必须使用 XML 标签 分隔简明分析和决策 JSON。\n\n") + sb.WriteString("方向必须由数据决定:上涨结构确认时可以 `open_long`,下跌结构确认时可以 `open_short`;不要默认只做多或只做空。\n\n") + } else { + sb.WriteString("Use XML tags and to separate concise analysis from the decision JSON.\n\n") + sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n") + } + sb.WriteString("\n") + if zh { + sb.WriteString("简明说明: Claw402 排名、Signal Lab、热力图、K 线是否一致;如果缺数据或冲突,说明为什么等待。\n") + } else { + sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n") + } + sb.WriteString("\n\n") + sb.WriteString("\n") + sb.WriteString("```json\n[\n") + if singleSymbol { + sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize)) + } else { + sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize)) + sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize)) + } + sb.WriteString("]\n```\n") + sb.WriteString("\n\n") + + if zh { + sb.WriteString("## 字段要求\n\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") + sb.WriteString(fmt.Sprintf("- `confidence`: 0-100,开仓建议 ≥ %d\n", riskControl.MinConfidence)) + sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") + sb.WriteString("- 所有数值必须是算好的数字,不能写公式。\n") + if singleSymbol { + sb.WriteString(fmt.Sprintf("- 本策略只交易 `%s`,JSON 的 symbol 必须完全等于它。\n", exampleSymbol)) + } else { + sb.WriteString("- JSON 的 symbol 必须完全来自本轮候选标的或已有持仓;`xyz:` 标的保留前缀,core crypto 标的不要添加 `xyz:` 或 `USDT` 后缀。\n") + } + sb.WriteString("\n") + } else { + sb.WriteString("## Field Requirements\n\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") + sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence)) + sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") + sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n") + if singleSymbol { + sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol)) + } else { + sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n") + } + sb.WriteString("\n") + } +} + +// buildXYZStockCustomPrompt returns the canonical English directional stock // briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on -// the XYZ board. This is the quick-trade flow's stance: when the user clicks -// the lightning button they want exposure NOW, not a watch-and-wait analyst. -// We bias the AI toward opening a probing long on every flat cycle, with -// risk guardrails to keep the size sane. Symbol is inlined for LLM grounding -// so it never confuses the trading instrument. +// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the +// trading instrument. func buildXYZStockCustomPrompt(symbol string) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol)) - sb.WriteString("Core stance: LONG-ONLY, ACTIVE. Do not short. Do not sit on the sidelines.\n\n") + sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n") - sb.WriteString("## ⚡ Flat-Account Rule (HIGHEST PRIORITY)\n") - sb.WriteString("If `Current Positions` is None / empty AND no open exposure on this symbol, you MUST open a long position THIS cycle.\n") - sb.WriteString("- The user is paying for the quick-trade flow specifically to be in the market — your default is OPEN, not WAIT.\n") - sb.WriteString("- `wait` / `hold` are only acceptable when you ALREADY have an open position (then it's a hold decision).\n") - sb.WriteString("- If you cannot find a clear breakout setup, take a probing entry at market with reduced size (40-60% of max position value) and a tight 2% stop. Probing > waiting.\n") - sb.WriteString("- Set `confidence` >= 75 so backend risk control accepts the order. Justify the 75 in reasoning even if the technical picture is mixed — the strategic decision to be exposed IS the edge for this flow.\n\n") + sb.WriteString("## Flat-Account Rule\n") + sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n") + sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n") + sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n") + sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n") + sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n") - sb.WriteString("## Preferred Entry Conditions (use to size up, not to gate the entry)\n") - sb.WriteString("Size up toward the 80-100% bracket when you see ANY of:\n") + sb.WriteString("## Long Entry Conditions\n") sb.WriteString("- Break of the prior session/intraday high on rising volume.\n") sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n") sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n") sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n") + sb.WriteString("## Short Entry Conditions\n") + sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n") + sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n") + sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n") + sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n") + sb.WriteString("## Risk Guardrails (non-negotiable)\n") sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n") sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n") sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n") sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n") - sb.WriteString("- Once long, do NOT short the same cycle. Manage the open position first.\n\n") + sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n") - sb.WriteString("## Position Management (when already long)\n") + sb.WriteString("## Position Management\n") sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n") sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n") - sb.WriteString("- Holding past 30 minutes is fine; flipping in/out every cycle is not.\n\n") + sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n") sb.WriteString("## Discipline\n") sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol)) @@ -220,7 +436,7 @@ func buildXYZStockCustomPrompt(symbol string) string { // to put the actual trading symbol into the JSON example. func (e *StrategyEngine) singleSymbolInfo() (bool, string) { coinSource := e.config.CoinSource - if coinSource.SourceType == "static" && len(coinSource.StaticCoins) == 1 { + if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 { return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0])) } return false, "" @@ -637,6 +853,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { sb.WriteString(e.formatQuantData(quantData)) } } + if ctx.VergexDataMap != nil { + if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex { + sb.WriteString(e.formatVergexData(vergexData)) + } + } sb.WriteString("\n") } sb.WriteString("\n") @@ -663,7 +884,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { } sb.WriteString("---\n\n") - sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n") + sb.WriteString("Now please analyze briefly and output the decision JSON.\n") return sb.String() } @@ -702,6 +923,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co sb.WriteString(e.formatQuantData(quantData)) } } + if ctx.VergexDataMap != nil { + if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex { + sb.WriteString(e.formatVergexData(vergexData)) + } + } sb.WriteString("\n") } @@ -760,11 +986,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { return " (Hyperliquid All)" case "hyper_main": return " (Hyperliquid Top20)" + case "vergex_signal": + return " (Vergex Signal)" + } + if strings.HasPrefix(sources[0], "hyper_rank") { + return " (Hyperliquid Dynamic Rank)" } } return "" } +func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string { + if data == nil { + return "" + } + var sb strings.Builder + sb.WriteString("\nVergex Claw402 Signals:\n") + sb.WriteString(vergex.FormatAnalysisForAI(data)) + return sb.String() +} + // ============================================================================ // Market Data Formatting // ============================================================================ diff --git a/kernel/engine_prompt_test.go b/kernel/engine_prompt_test.go new file mode 100644 index 00000000..25a6a338 --- /dev/null +++ b/kernel/engine_prompt_test.go @@ -0,0 +1,124 @@ +package kernel + +import ( + "strings" + "testing" + + "nofx/store" +) + +func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) { + cfg := store.GetDefaultStrategyConfig("zh") + cfg.CoinSource.SourceType = "vergex_signal" + cfg.CoinSource.VergexLimit = 5 + cfg.PromptSections.RoleDefinition = "# 你是一个专业的 Hyperliquid USDC 多资产交易AI" + cfg.CustomPrompt = "只做多,不做空。" + + engine := NewStrategyEngine(&cfg) + prompt := engine.BuildSystemPrompt(30, "balanced") + + if !strings.Contains(prompt, "NOFX Claw402 auto-trader") { + t.Fatalf("prompt did not use the Claw402/Vergex TradeFi role:\n%s", prompt) + } + if !strings.Contains(prompt, "Claw402.ai Signal Ranking") || !strings.Contains(prompt, "Signal Lab") || !strings.Contains(prompt, "Cost/Liquidation Heatmap") { + t.Fatalf("prompt is missing Claw402/Vergex detail data guidance:\n%s", prompt) + } + if !strings.Contains(prompt, "open_short") { + t.Fatalf("prompt should explicitly allow short entries:\n%s", prompt) + } + if !strings.Contains(prompt, "Direction must be data-driven") { + t.Fatalf("prompt should explain that direction is data-driven, not long-only:\n%s", prompt) + } + if !strings.Contains(prompt, "every open position must use exactly 10x") { + t.Fatalf("prompt should force 10x leverage for Claw402 opens:\n%s", prompt) + } + if !strings.Contains(prompt, "use the full max notional per position") { + t.Fatalf("prompt should force full-size Claw402 opens:\n%s", prompt) + } + if containsCJK(prompt) { + t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt) + } + legacyPhrases := []string{ + "Hyperliquid USDC 多资产交易AI", + "只做多", + "山寨币", + "BTC/ETH", + "LONG-ONLY", + "Do not short", + "MUST open a long", + } + for _, phrase := range legacyPhrases { + if strings.Contains(prompt, phrase) { + t.Fatalf("prompt still contains legacy phrase %q:\n%s", phrase, prompt) + } + } +} + +func TestBuildSystemPromptFallsBackToEnglishWhenConfiguredLanguageIsChinese(t *testing.T) { + cfg := store.GetDefaultStrategyConfig("zh") + cfg.CoinSource.SourceType = "static" + cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"} + cfg.CoinSource.VergexLimit = 0 + cfg.CoinSource.VergexMarketType = "" + cfg.CoinSource.VergexChain = "" + cfg.PromptSections.RoleDefinition = "# 你是中文系统提示词" + cfg.PromptSections.TradingFrequency = "# 高频交易\n每分钟交易。" + cfg.PromptSections.EntryStandards = "# 入场\n随便开仓。" + cfg.PromptSections.DecisionProcess = "# 决策\n直接输出。" + cfg.CustomPrompt = "中文偏好不应进入系统提示词。" + + engine := NewStrategyEngine(&cfg) + prompt := engine.BuildSystemPrompt(30, "balanced") + + required := []string{ + "Data Dictionary & Trading Rules", + "You are a professional Hyperliquid USDC multi-asset trading AI", + "Trading Frequency Awareness", + "Entry Standards", + "Decision Process", + } + for _, phrase := range required { + if !strings.Contains(prompt, phrase) { + t.Fatalf("English fallback prompt missing %q:\n%s", phrase, prompt) + } + } + if containsCJK(prompt) { + t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt) + } +} + +func TestBuildSystemPromptDoesNotForceLongOnlyForSingleXYZ(t *testing.T) { + prompt := buildXYZStockCustomPrompt("XYZ:INTC") + + required := []string{ + "DIRECTIONAL, SIGNAL-DRIVEN", + "You may open long or short", + "open_short", + } + for _, phrase := range required { + if !strings.Contains(prompt, phrase) { + t.Fatalf("single XYZ prompt missing %q:\n%s", phrase, prompt) + } + } + + forbidden := []string{ + "LONG-ONLY", + "Do not short", + "MUST open a long", + "Probing > waiting", + } + for _, phrase := range forbidden { + if strings.Contains(prompt, phrase) { + t.Fatalf("single XYZ prompt still contains forced-long phrase %q:\n%s", phrase, prompt) + } + } +} + +func containsCJK(text string) bool { + for _, r := range text { + if r >= 0x4E00 && r <= 0x9FFF { + return true + } + } + return false +} diff --git a/kernel/engine_vergex_test.go b/kernel/engine_vergex_test.go new file mode 100644 index 00000000..90002fbb --- /dev/null +++ b/kernel/engine_vergex_test.go @@ -0,0 +1,107 @@ +package kernel + +import ( + "testing" + + "nofx/provider/vergex" +) + +func TestVergexDetailQueryCandidatesUseHIP3MarketAndMainnetChain(t *testing.T) { + candidates := vergexDetailQueryCandidates(vergex.Query{ + MarketType: vergex.DefaultMarketType, + Symbol: "xyz:INTC", + Chain: vergex.DefaultChain, + Category: "stock", + }) + + if len(candidates) == 0 { + t.Fatal("expected detail query candidates") + } + if candidates[0].MarketType != "hip3_perp" || candidates[0].Chain != "mainnet" { + t.Fatalf("first candidate = %+v, want hip3_perp/mainnet", candidates[0]) + } + + if !hasVergexDetailCandidate(candidates, "hip3_perp", "") { + t.Fatalf("expected hip3_perp/default-chain fallback in %+v", candidates) + } + if hasVergexDetailCandidate(candidates, "stock", "mainnet") { + t.Fatalf("did not expect stock marketType fallback for Vergex detail endpoint: %+v", candidates) + } +} + +func TestVergexDetailSymbolForLookupKeepsCoreCryptoBaseSymbols(t *testing.T) { + cases := []struct { + name string + marketType string + symbol string + want string + }{ + { + name: "core crypto from all board", + marketType: "all", + symbol: "AAVE", + want: "AAVE", + }, + { + name: "core crypto with usdt suffix", + marketType: "all", + symbol: "HYPEUSDT", + want: "HYPE", + }, + { + name: "xyz stock keeps xyz prefix", + marketType: "all", + symbol: "xyz:INTC", + want: "xyz:INTC", + }, + { + name: "hip3 symbol gains xyz prefix", + marketType: vergex.DefaultMarketType, + symbol: "SNDK", + want: "xyz:SNDK", + }, + { + name: "core market strips suffix", + marketType: "core_perp", + symbol: "LITUSDT", + want: "LIT", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := vergexDetailSymbolForLookup(tc.marketType, tc.symbol); got != tc.want { + t.Fatalf("vergexDetailSymbolForLookup(%q, %q) = %q, want %q", tc.marketType, tc.symbol, got, tc.want) + } + }) + } +} + +func TestVergexDetailQueryCandidatesPreferMarketTypeBySymbolWhenSourceIsAll(t *testing.T) { + cryptoCandidates := vergexDetailQueryCandidates(vergex.Query{ + MarketType: "all", + Symbol: "AAVE", + Chain: "mainnet", + }) + if len(cryptoCandidates) == 0 || cryptoCandidates[0].MarketType != "core_perp" { + t.Fatalf("crypto candidates should prefer core_perp first: %+v", cryptoCandidates) + } + + xyzCandidates := vergexDetailQueryCandidates(vergex.Query{ + MarketType: "all", + Symbol: "xyz:SNDK", + Chain: "mainnet", + }) + if len(xyzCandidates) == 0 || xyzCandidates[0].MarketType != vergex.DefaultMarketType { + t.Fatalf("xyz candidates should prefer hip3_perp first: %+v", xyzCandidates) + } +} + +func hasVergexDetailCandidate(candidates []vergex.Query, marketType, chain string) bool { + for _, candidate := range candidates { + if candidate.MarketType == marketType && candidate.Chain == chain { + return true + } + } + return false +} diff --git a/main.go b/main.go index 3d7045af..0ef3a3b5 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,6 @@ package main import ( - "log/slog" - nofxiagent "nofx/agent" "nofx/api" "nofx/auth" "nofx/config" @@ -12,7 +10,6 @@ import ( _ "nofx/mcp/payment" _ "nofx/mcp/provider" "nofx/store" - "nofx/telegram" "nofx/telemetry" "os" "os/signal" @@ -141,27 +138,12 @@ func main() { // Start API server server := api.NewServer(traderManager, st, cryptoService, cfg.APIServerPort) - // Create hot-reload channel for Telegram bot; wire it to the API server - // so that POST /api/telegram can trigger a bot restart when the token changes. - 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 Telegram bot (if TELEGRAM_BOT_TOKEN is configured) - go telegram.Start(cfg, st, telegramReloadCh) - // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 9da53b61..a566b810 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -417,7 +417,7 @@ func ensureHyperliquidNativeStrategy(traderName, exchangeType string, cfg *store } source := strings.ToLower(strings.TrimSpace(cfg.CoinSource.SourceType)) - if source == "hyper_rank" || source == "static" || source == "hyper_all" || source == "hyper_main" { + if source == "hyper_rank" || source == "vergex_signal" || source == "static" || source == "hyper_all" || source == "hyper_main" { return } @@ -645,16 +645,19 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg // Load strategy config (must have strategy) var strategyConfig *store.StrategyConfig + strategyConfigRaw := "" if traderCfg.StrategyID != "" { strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID) if err != nil { return fmt.Errorf("failed to load strategy %s for trader %s: %w", traderCfg.StrategyID, traderCfg.Name, err) } + strategyConfigRaw = strategy.Config // Parse JSON config strategyConfig, err = strategy.ParseConfig() if err != nil { return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err) } + strategyConfig.ClampLimits() logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name) ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig) } else { @@ -669,6 +672,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg traderConfig := trader.AutoTraderConfig{ ID: traderCfg.ID, Name: traderCfg.Name, + StrategyID: traderCfg.StrategyID, AIModel: aiModelCfg.Provider, Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account) @@ -686,6 +690,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg IsCrossMargin: traderCfg.IsCrossMargin, ShowInCompetition: traderCfg.ShowInCompetition, StrategyConfig: strategyConfig, + StrategyConfigRaw: strategyConfigRaw, } logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v", diff --git a/manager/trader_manager_test.go b/manager/trader_manager_test.go index 15ea6a01..b6054375 100644 --- a/manager/trader_manager_test.go +++ b/manager/trader_manager_test.go @@ -265,7 +265,7 @@ func TestEnsureHyperliquidNativeStrategy(t *testing.T) { }) t.Run("native sources are kept as-is", func(t *testing.T) { - nativeSources := []string{"hyper_rank", "static", "hyper_all", "hyper_main", " Hyper_Rank "} + nativeSources := []string{"hyper_rank", "vergex_signal", "static", "hyper_all", "hyper_main", " Hyper_Rank "} for _, src := range nativeSources { cfg := &store.StrategyConfig{ CoinSource: store.CoinSourceConfig{SourceType: src}, diff --git a/mcp/payment/x402.go b/mcp/payment/x402.go index 4f2e460f..6e539c41 100644 --- a/mcp/payment/x402.go +++ b/mcp/payment/x402.go @@ -56,6 +56,74 @@ func x402Sleep(ctx context.Context, d time.Duration) error { } } +func doInitialX402Request( + ctx context.Context, + httpClient *http.Client, + buildReqFn func() (*http.Request, error), + providerTag string, + logger mcp.Logger, +) (*http.Response, error) { + var lastBody []byte + var lastErr error + var lastStatus int + + for attempt := 1; attempt <= X402MaxPaymentRetries; attempt++ { + req, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req = req.WithContext(ctx) + + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + if attempt < X402MaxPaymentRetries { + wait := X402RetryBaseWait * time.Duration(attempt) + logger.Warnf("⚠️ [%s] Initial request failed: %v, retrying in %v (%d/%d)...", + providerTag, err, wait, attempt+1, X402MaxPaymentRetries) + if err := x402Sleep(ctx, wait); err != nil { + return nil, err + } + continue + } + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPaymentRequired { + return resp, nil + } + + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read response: %w", readErr) + } + lastBody = body + lastStatus = resp.StatusCode + + if isRetryableInitialX402Status(resp.StatusCode) && attempt < X402MaxPaymentRetries { + wait := X402RetryBaseWait * time.Duration(attempt) + logger.Warnf("⚠️ [%s] Initial server error (status %d), retrying in %v (%d/%d)...", + providerTag, resp.StatusCode, wait, attempt+1, X402MaxPaymentRetries) + if err := x402Sleep(ctx, wait); err != nil { + return nil, err + } + continue + } + + return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body)) + } + + if lastErr != nil { + return nil, fmt.Errorf("failed to send request after %d retries: %w", X402MaxPaymentRetries, lastErr) + } + return nil, fmt.Errorf("%s API error after %d retries (status %d): %s", providerTag, X402MaxPaymentRetries, lastStatus, string(lastBody)) +} + +func isRetryableInitialX402Status(status int) bool { + return status == http.StatusTooManyRequests || status >= 500 +} + // ── Shared x402 types ──────────────────────────────────────────────────────── // X402v2PaymentRequired is the structure of the Payment-Required header (x402 v2). @@ -145,15 +213,10 @@ func DoX402Request( if ctx == nil { ctx = context.Background() } - req, err := buildReqFn() - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req = req.WithContext(ctx) - resp, err := httpClient.Do(req) + resp, err := doInitialX402Request(ctx, httpClient, buildReqFn, providerTag, logger) if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) + return nil, err } defer resp.Body.Close() @@ -290,26 +353,14 @@ func DoX402RequestStream( if ctx == nil { ctx = context.Background() } - // Initial request also inherits ctx so stage timeouts cancel the 402 handshake. - req, err := buildReqFn() + resp, err := doInitialX402Request(ctx, httpClient, buildReqFn, providerTag, logger) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req = req.WithContext(ctx) - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) + return nil, err } // Non-402 initial response if resp.StatusCode != http.StatusPaymentRequired { - if resp.StatusCode == http.StatusOK { - return resp, nil - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body)) + return resp, nil } // 402 — extract payment header and sign diff --git a/mcp/payment/x402_test.go b/mcp/payment/x402_test.go new file mode 100644 index 00000000..48135512 --- /dev/null +++ b/mcp/payment/x402_test.go @@ -0,0 +1,52 @@ +package payment + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "nofx/mcp" +) + +func TestDoX402RequestStreamRetriesInitialServerError(t *testing.T) { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := atomic.AddInt32(&calls, 1) + if call == 1 { + http.Error(w, "temporary upstream failure", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: ok\n\n")) + })) + defer server.Close() + + resp, err := DoX402RequestStream( + context.Background(), + server.Client(), + func() (*http.Request, error) { + return http.NewRequest(http.MethodPost, server.URL, nil) + }, + func(string) (string, error) { return "unused", nil }, + "test-claw402", + mcp.NewNoopLogger(), + ) + if err != nil { + t.Fatalf("DoX402RequestStream returned error: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + if got := string(body); got != "data: ok\n\n" { + t.Fatalf("body = %q, want SSE body", got) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("calls = %d, want 2", got) + } +} diff --git a/provider/vergex/client.go b/provider/vergex/client.go new file mode 100644 index 00000000..176d8418 --- /dev/null +++ b/provider/vergex/client.go @@ -0,0 +1,960 @@ +package vergex + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "nofx/mcp" + "nofx/mcp/payment" + "nofx/provider/hyperliquid" + "os" + "sort" + "strings" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + DefaultBaseURL = "https://claw402.ai" + DefaultChain = "mainnet" + DefaultMarketType = "hip3_perp" + MaxSignalRankingItems = 30 + SignalRankingPath = "/api/v1/vergex/signal-ranking" + SignalLabPath = "/api/v1/vergex/signal-lab" + CostLiquidationHeatmapPath = "/api/v1/vergex/cost-liquidation-heatmap" +) + +type Client struct { + baseURL string + privateKey *ecdsa.PrivateKey + httpClient *http.Client + logger mcp.Logger +} + +type Query struct { + MarketType string + Symbol string + Chain string + LiqBand string + Category string +} + +type SignalRankingData struct { + Raw json.RawMessage `json:"raw"` + Items []SignalRankItem `json:"items"` +} + +type SignalRankItem struct { + Rank int `json:"rank,omitempty"` + Symbol string `json:"symbol"` + MarketType string `json:"market_type,omitempty"` + Bias string `json:"bias,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Score float64 `json:"score,omitempty"` + Category string `json:"category,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type MarketAnalysis struct { + Symbol string `json:"symbol"` + QuerySymbol string `json:"query_symbol"` + MarketType string `json:"market_type"` + Ranking *SignalRankItem `json:"ranking,omitempty"` + SignalLab json.RawMessage `json:"signal_lab,omitempty"` + SignalLabError string `json:"signal_lab_error,omitempty"` + Heatmap json.RawMessage `json:"heatmap,omitempty"` + HeatmapError string `json:"heatmap_error,omitempty"` +} + +func NewClient(baseURL, privateKeyHex string, logger mcp.Logger) (*Client, error) { + if baseURL == "" { + baseURL = DefaultBaseURL + } + baseURL = strings.TrimRight(baseURL, "/") + if privateKeyHex == "" { + privateKeyHex = os.Getenv("CLAW402_WALLET_KEY") + } + if privateKeyHex == "" { + return nil, fmt.Errorf("claw402 wallet private key not set") + } + if logger == nil { + logger = mcp.NewNoopLogger() + } + + hexKey := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x") + pk, err := crypto.HexToECDSA(hexKey) + if err != nil { + return nil, fmt.Errorf("invalid claw402 private key: %w", err) + } + + return &Client{ + baseURL: baseURL, + privateKey: pk, + httpClient: &http.Client{Timeout: 30 * time.Second}, + logger: logger, + }, nil +} + +func (c *Client) GetSignalRanking(ctx context.Context, q Query) (*SignalRankingData, error) { + params := url.Values{} + addQueryDefaults(params, q, false) + body, err := c.doGET(ctx, SignalRankingPath, params) + if err != nil { + return nil, err + } + return ParseSignalRanking(body) +} + +func (c *Client) GetSignalLab(ctx context.Context, q Query) (json.RawMessage, error) { + if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" { + return nil, fmt.Errorf("marketType and symbol are required") + } + params := url.Values{} + addQueryDefaults(params, q, true) + return c.doGET(ctx, SignalLabPath, params) +} + +func (c *Client) GetCostLiquidationHeatmap(ctx context.Context, q Query) (json.RawMessage, error) { + if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" { + return nil, fmt.Errorf("marketType and symbol are required") + } + params := url.Values{} + addQueryDefaults(params, q, true) + return c.doGET(ctx, CostLiquidationHeatmapPath, params) +} + +func addQueryDefaults(params url.Values, q Query, includeMarket bool) { + if includeMarket { + if q.MarketType != "" { + params.Set("marketType", q.MarketType) + } + if q.Symbol != "" { + params.Set("symbol", MarketSymbol(q.MarketType, q.Symbol)) + } + } + if q.Chain != "" { + params.Set("chain", QueryChain(q.Chain)) + } + if q.LiqBand != "" { + params.Set("liqBand", q.LiqBand) + } +} + +func (c *Client) doGET(ctx context.Context, path string, params url.Values) ([]byte, error) { + if c == nil { + return nil, fmt.Errorf("vergex client is nil") + } + if ctx == nil { + ctx = context.Background() + } + fullURL := c.baseURL + path + if encoded := params.Encode(); encoded != "" { + fullURL += "?" + encoded + } + + buildReq := func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Client-ID", "nofx") + return req, nil + } + + body, err := payment.DoX402Request( + ctx, + c.httpClient, + buildReq, + payment.MakeClaw402SignFunc(c.privateKey), + "claw402-vergex", + c.logger, + ) + if err != nil { + return nil, fmt.Errorf("vergex request failed (%s): %w", path, err) + } + return body, nil +} + +func ParseSignalRanking(body []byte) (*SignalRankingData, error) { + raw := json.RawMessage(append([]byte(nil), body...)) + var decoded any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("failed to parse vergex signal-ranking response: %w", err) + } + + rows := findObjectArray(decoded) + items := make([]SignalRankItem, 0, len(rows)) + for idx, row := range rows { + obj, ok := row.(map[string]any) + if !ok { + continue + } + item, ok := parseRankItem(obj, idx+1) + if ok { + items = append(items, item) + } + } + + return &SignalRankingData{Raw: raw, Items: items}, nil +} + +func FilterTradFiItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem { + if marketType == "" { + marketType = DefaultMarketType + } + return filterSignalRankingItems(items, marketType, limit, false) +} + +func FilterSignalRankingItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem { + return filterSignalRankingItems(items, marketType, limit, true) +} + +func filterSignalRankingItems(items []SignalRankItem, marketType string, limit int, allowAll bool) []SignalRankItem { + requestedMarketType := marketType + normalizedMarketType := normalizeMarketType(marketType) + includeAll := allowAll && isAllMarketType(marketType) + if limit <= 0 { + limit = 5 + } + if limit > MaxSignalRankingItems { + limit = MaxSignalRankingItems + } + + out := make([]SignalRankItem, 0, limit) + seen := make(map[string]bool) + for _, item := range items { + base := QuerySymbol(item.Symbol) + if base == "" { + continue + } + itemMarket := normalizeMarketType(item.MarketType) + isXYZ := hyperliquid.IsXYZAsset(item.Symbol) || hyperliquid.IsXYZAsset(base) + if !includeAll { + if itemMarket != "" && normalizedMarketType != "" && itemMarket != normalizedMarketType && !isTradeFiMarketType(itemMarket) && !isXYZ { + continue + } + if itemMarket == "" && !isXYZ { + continue + } + } + item.MarketType = coalesce(item.MarketType, inferRankingMarketType(item.Symbol, base, requestedMarketType)) + tradeSymbol := TradableSymbolForMarket(item.MarketType, item.Symbol) + if tradeSymbol == "" || seen[tradeSymbol] { + continue + } + item.Symbol = base + item.Category = rankingCategory(item.MarketType, base) + out = append(out, item) + seen[tradeSymbol] = true + if len(out) >= limit { + break + } + } + return out +} + +func TradableSymbol(symbol string) string { + return TradableSymbolForMarket(DefaultMarketType, symbol) +} + +func TradableSymbolForMarket(marketType, symbol string) string { + base := QuerySymbol(symbol) + if base == "" { + return "" + } + if isCoreMarketType(marketType) { + return base + } + if isAllMarketType(marketType) && !hyperliquid.IsXYZAsset(symbol) && !hyperliquid.IsXYZAsset(base) { + return base + } + return hyperliquid.FormatCoinForAPI("xyz:" + base) +} + +func MarketSymbol(marketType, symbol string) string { + symbol = strings.TrimSpace(symbol) + if symbol == "" { + return "" + } + if strings.Contains(symbol, "/") { + parts := strings.Split(symbol, "/") + symbol = parts[len(parts)-1] + } + if strings.HasPrefix(strings.ToLower(symbol), "xyz:") { + return "xyz:" + hyperliquid.NormalizeCoinBase(strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:")) + } + base := QuerySymbol(symbol) + if base == "" { + return "" + } + if normalizeMarketType(marketType) == "hip3perp" { + return "xyz:" + base + } + return base +} + +func QuerySymbol(symbol string) string { + symbol = strings.TrimSpace(symbol) + if symbol == "" { + return "" + } + symbol = strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:") + if strings.Contains(symbol, "/") { + parts := strings.Split(symbol, "/") + symbol = parts[len(parts)-1] + } + return hyperliquid.NormalizeCoinBase(symbol) +} + +func QueryChain(chain string) string { + raw := strings.TrimSpace(chain) + normalized := strings.ToLower(raw) + switch normalized { + case "", "hyperliquid", "hl": + return DefaultChain + default: + return raw + } +} + +func FormatAnalysisForAI(analysis *MarketAnalysis) string { + if analysis == nil { + return "" + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("### %s (Vergex %s/%s)\n", analysis.Symbol, analysis.MarketType, analysis.QuerySymbol)) + if analysis.Ranking != nil { + sb.WriteString(fmt.Sprintf("Ranking: rank=%d bias=%s confidence=%.2f score=%.4f category=%s\n", + analysis.Ranking.Rank, + emptyDash(analysis.Ranking.Bias), + analysis.Ranking.Confidence, + analysis.Ranking.Score, + emptyDash(analysis.Ranking.Category))) + } + if len(analysis.SignalLab) > 0 { + sb.WriteString("#### Signal Lab\n") + sb.WriteString(FormatSignalLabMarkdown(analysis.SignalLab)) + sb.WriteString("\n") + } else if analysis.SignalLabError != "" { + sb.WriteString("Signal Lab: unavailable (") + sb.WriteString(truncateText(analysis.SignalLabError, 360)) + sb.WriteString(")\n") + } + if len(analysis.Heatmap) > 0 { + sb.WriteString("#### Cost/Liquidation Heatmap\n") + sb.WriteString(FormatHeatmapMarkdown(analysis.Heatmap)) + sb.WriteString("\n") + } else if analysis.HeatmapError != "" { + sb.WriteString("Cost/Liquidation Heatmap: unavailable (") + sb.WriteString(truncateText(analysis.HeatmapError, 360)) + sb.WriteString(")\n") + } + return sb.String() +} + +func FormatSignalLabMarkdown(raw json.RawMessage) string { + data, ok := decodeVergexDataObject(raw) + if !ok { + return fallbackJSONBlock(raw, 2200) + } + + var sb strings.Builder + writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "bias", "confidence", "compositeZ", "score"}) + + dimensions := objectArray(data, "dimensions") + if len(dimensions) == 0 { + return withFallbackIfEmpty(sb.String(), raw) + } + + sb.WriteString("| Family | Signal | Direction | Strength | Percentile | Detail |\n") + sb.WriteString("| --- | --- | --- | --- | ---: | --- |\n") + limit := minInt(len(dimensions), 8) + for _, row := range dimensions[:limit] { + sb.WriteString("| ") + sb.WriteString(markdownCell(firstString(row, "family"))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(firstString(row, "label", "key"))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(firstString(row, "direction"))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(firstString(row, "strength"))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(formatOptionalFloat(row, "percentile"))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(truncateText(firstString(row, "detail", "what"), 220))) + sb.WriteString(" |\n") + } + if len(dimensions) > limit { + sb.WriteString(fmt.Sprintf("- Additional dimensions omitted: %d\n", len(dimensions)-limit)) + } + return withFallbackIfEmpty(sb.String(), raw) +} + +func FormatHeatmapMarkdown(raw json.RawMessage) string { + data, ok := decodeVergexDataObject(raw) + if !ok { + return fallbackJSONBlock(raw, 2600) + } + + bins := objectArray(data, "bins") + if len(bins) == 0 { + var sb strings.Builder + writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"}) + return withFallbackIfEmpty(sb.String(), raw) + } + + zones := make([]heatmapZone, 0, len(bins)) + var totalLongCost, totalShortCost, totalLongLiq, totalShortLiq float64 + for _, bin := range bins { + zone := heatmapZone{ + Start: firstFloat(bin, "bucketStartPrice", "start", "startPrice"), + End: firstFloat(bin, "bucketEndPrice", "end", "endPrice"), + PX: firstFloat(bin, "px", "price"), + LongCost: firstFloat(bin, "longCost"), + ShortCost: firstFloat(bin, "shortCost"), + LongLiq: firstFloat(bin, "longLiq", "longLiquidation"), + ShortLiq: firstFloat(bin, "shortLiq", "shortLiquidation"), + } + totalLongCost += zone.LongCost + totalShortCost += zone.ShortCost + totalLongLiq += zone.LongLiq + totalShortLiq += zone.ShortLiq + zone.Score = maxFloat(zone.LongCost, zone.ShortCost, zone.LongLiq, zone.ShortLiq) + if zone.Score > 0 { + zones = append(zones, zone) + } + } + sortHeatmapZones(zones) + + var sb strings.Builder + writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"}) + sb.WriteString(fmt.Sprintf("- Total cost: long %s / short %s\n", formatUSDAmount(totalLongCost), formatUSDAmount(totalShortCost))) + sb.WriteString(fmt.Sprintf("- Total liquidation: long %s / short %s\n", formatUSDAmount(totalLongLiq), formatUSDAmount(totalShortLiq))) + sb.WriteString("| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |\n") + sb.WriteString("| --- | ---: | ---: | ---: | ---: | --- |\n") + limit := minInt(len(zones), 10) + for _, zone := range zones[:limit] { + sb.WriteString("| ") + sb.WriteString(markdownCell(formatPriceZone(zone))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(formatUSDAmount(zone.LongCost))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(formatUSDAmount(zone.ShortCost))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(formatUSDAmount(zone.LongLiq))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(formatUSDAmount(zone.ShortLiq))) + sb.WriteString(" | ") + sb.WriteString(markdownCell(zone.MainCluster())) + sb.WriteString(" |\n") + } + if len(zones) > limit { + sb.WriteString(fmt.Sprintf("- Additional heatmap bins omitted: %d\n", len(zones)-limit)) + } + return withFallbackIfEmpty(sb.String(), raw) +} + +type heatmapZone struct { + Start float64 + End float64 + PX float64 + LongCost float64 + ShortCost float64 + LongLiq float64 + ShortLiq float64 + Score float64 +} + +func (z heatmapZone) MainCluster() string { + maxVal := maxFloat(z.LongCost, z.ShortCost, z.LongLiq, z.ShortLiq) + switch maxVal { + case z.LongCost: + return "long cost" + case z.ShortCost: + return "short cost" + case z.LongLiq: + return "long liquidation" + case z.ShortLiq: + return "short liquidation" + default: + return "-" + } +} + +func sortHeatmapZones(zones []heatmapZone) { + sort.SliceStable(zones, func(i, j int) bool { + return zones[i].Score > zones[j].Score + }) +} + +func decodeVergexDataObject(raw json.RawMessage) (map[string]any, bool) { + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, false + } + obj, ok := decoded.(map[string]any) + if !ok { + return nil, false + } + if data, ok := lookupNormalized(obj, "data"); ok { + if dataObj, ok := data.(map[string]any); ok { + return dataObj, true + } + } + return obj, true +} + +func writeScalarSummary(sb *strings.Builder, obj map[string]any, keys []string) { + wrote := false + for _, key := range keys { + value, ok := lookupNormalized(obj, key) + if !ok { + continue + } + text := formatScalarValue(value) + if text == "" { + continue + } + sb.WriteString(fmt.Sprintf("- %s: %s\n", titleKey(key), text)) + wrote = true + } + if wrote { + sb.WriteString("\n") + } +} + +func objectArray(obj map[string]any, key string) []map[string]any { + val, ok := lookupNormalized(obj, key) + if !ok { + return nil + } + rows, ok := val.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + if rowObj, ok := row.(map[string]any); ok { + out = append(out, rowObj) + } + } + return out +} + +func formatOptionalFloat(obj map[string]any, key string) string { + val, ok := lookupNormalized(obj, key) + if !ok { + return "-" + } + num, ok := anyFloat(val) + if !ok { + return formatScalarValue(val) + } + return trimFloat(num, 1) +} + +func anyFloat(val any) (float64, bool) { + switch t := val.(type) { + case float64: + return t, true + case float32: + return float64(t), true + case int: + return float64(t), true + case int64: + return float64(t), true + case json.Number: + f, err := t.Float64() + return f, err == nil + case string: + var f float64 + if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil { + return f, true + } + } + return 0, false +} + +func formatScalarValue(val any) string { + switch t := val.(type) { + case string: + return strings.TrimSpace(t) + case bool: + return fmt.Sprintf("%t", t) + case float64: + return trimFloat(t, 4) + case json.Number: + f, err := t.Float64() + if err == nil { + return trimFloat(f, 4) + } + return t.String() + default: + if f, ok := anyFloat(val); ok { + return trimFloat(f, 4) + } + return "" + } +} + +func formatPriceZone(z heatmapZone) string { + if z.Start != 0 || z.End != 0 { + return fmt.Sprintf("%s-%s", trimFloat(z.Start, 4), trimFloat(z.End, 4)) + } + if z.PX != 0 { + return trimFloat(z.PX, 4) + } + return "-" +} + +func formatUSDAmount(v float64) string { + abs := math.Abs(v) + sign := "" + if v < 0 { + sign = "-" + } + switch { + case abs >= 1_000_000_000: + return fmt.Sprintf("%s$%.2fB", sign, abs/1_000_000_000) + case abs >= 1_000_000: + return fmt.Sprintf("%s$%.2fM", sign, abs/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("%s$%.2fK", sign, abs/1_000) + default: + return fmt.Sprintf("%s$%.2f", sign, abs) + } +} + +func trimFloat(v float64, precision int) string { + text := fmt.Sprintf("%.*f", precision, v) + text = strings.TrimRight(text, "0") + text = strings.TrimRight(text, ".") + if text == "-0" { + return "0" + } + return text +} + +func markdownCell(text string) string { + text = strings.ReplaceAll(strings.TrimSpace(text), "\n", " ") + text = strings.ReplaceAll(text, "|", "\\|") + if text == "" { + return "-" + } + return text +} + +func titleKey(key string) string { + switch key { + case "marketType": + return "Market type" + case "liqBand": + return "Liquidation band" + case "currentPrice": + return "Current price" + case "binStep": + return "Bin step" + case "compositeZ": + return "Composite Z" + default: + if key == "" { + return "" + } + return strings.ToUpper(key[:1]) + key[1:] + } +} + +func withFallbackIfEmpty(text string, raw json.RawMessage) string { + if strings.TrimSpace(text) == "" { + return fallbackJSONBlock(raw, 2200) + } + return text +} + +func fallbackJSONBlock(raw json.RawMessage, maxBytes int) string { + return "```json\n" + CompactJSON(raw, maxBytes) + "\n```\n" +} + +func maxFloat(values ...float64) float64 { + max := 0.0 + for _, value := range values { + if value > max { + max = value + } + } + return max +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func CompactJSON(raw json.RawMessage, maxBytes int) string { + if len(raw) == 0 { + return "{}" + } + var buf any + if err := json.Unmarshal(raw, &buf); err == nil { + if compact, err := json.Marshal(buf); err == nil { + raw = compact + } + } + text := string(raw) + if maxBytes > 0 && len(text) > maxBytes { + return text[:maxBytes] + "..." + } + return text +} + +func truncateText(text string, maxBytes int) string { + text = strings.TrimSpace(text) + if maxBytes <= 0 || len(text) <= maxBytes { + return text + } + return text[:maxBytes] + "..." +} + +func parseRankItem(obj map[string]any, fallbackRank int) (SignalRankItem, bool) { + symbol := firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name") + if symbol == "" { + symbol = nestedMarketString(obj, "symbol", "ticker", "base", "coin", "asset", "name") + } + if symbol == "" { + return SignalRankItem{}, false + } + raw, _ := json.Marshal(obj) + rank := firstInt(obj, "rank", "ranking", "position") + if rank <= 0 { + rank = fallbackRank + } + score := firstFloat(obj, "compositeZ", "composite_z", "score", "rank_score", "z", "value") + confidence := firstFloat(obj, "confidence", "conf", "signalConfidence", "signal_confidence") + marketType := firstString(obj, "marketType", "market_type", "venue") + if marketType == "" { + marketType = nestedMarketString(obj, "marketType", "market_type", "venue", "type") + } + item := SignalRankItem{ + Rank: rank, + Symbol: QuerySymbol(symbol), + MarketType: marketType, + Bias: firstString(obj, "bias", "direction", "side", "signal"), + Confidence: confidence, + Score: score, + Raw: raw, + } + if item.Symbol != "" { + item.Category = hyperliquid.XYZCategory(item.Symbol) + } + return item, item.Symbol != "" +} + +func nestedMarketString(obj map[string]any, keys ...string) string { + val, ok := lookupNormalized(obj, "market") + if !ok { + return "" + } + nested, ok := val.(map[string]any) + if !ok { + return "" + } + return firstString(nested, keys...) +} + +func findObjectArray(v any) []any { + switch t := v.(type) { + case []any: + if arrayLooksLikeRows(t) { + return t + } + for _, item := range t { + if rows := findObjectArray(item); len(rows) > 0 { + return rows + } + } + case map[string]any: + for _, key := range []string{"data", "items", "results", "ranking", "rankings", "rows", "markets", "signals"} { + if val, ok := lookupNormalized(t, key); ok { + if rows := findObjectArray(val); len(rows) > 0 { + return rows + } + } + } + for _, val := range t { + if rows := findObjectArray(val); len(rows) > 0 { + return rows + } + } + } + return nil +} + +func arrayLooksLikeRows(rows []any) bool { + for _, row := range rows { + obj, ok := row.(map[string]any) + if !ok { + continue + } + if firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name") != "" { + return true + } + } + return false +} + +func firstString(obj map[string]any, keys ...string) string { + for _, key := range keys { + val, ok := lookupNormalized(obj, key) + if !ok { + continue + } + switch t := val.(type) { + case string: + if strings.TrimSpace(t) != "" { + return strings.TrimSpace(t) + } + case fmt.Stringer: + if strings.TrimSpace(t.String()) != "" { + return strings.TrimSpace(t.String()) + } + } + } + return "" +} + +func firstFloat(obj map[string]any, keys ...string) float64 { + for _, key := range keys { + val, ok := lookupNormalized(obj, key) + if !ok { + continue + } + switch t := val.(type) { + case float64: + return t + case int: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + case string: + var f float64 + if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil { + return f + } + } + } + return 0 +} + +func firstInt(obj map[string]any, keys ...string) int { + for _, key := range keys { + val, ok := lookupNormalized(obj, key) + if !ok { + continue + } + switch t := val.(type) { + case float64: + return int(t) + case int: + return t + case json.Number: + i, _ := t.Int64() + return int(i) + case string: + var i int + if _, err := fmt.Sscanf(strings.TrimSpace(t), "%d", &i); err == nil { + return i + } + } + } + return 0 +} + +func lookupNormalized(obj map[string]any, key string) (any, bool) { + want := normalizeKey(key) + for k, v := range obj { + if normalizeKey(k) == want { + return v, true + } + } + return nil, false +} + +func normalizeKey(key string) string { + replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "") + return replacer.Replace(strings.ToLower(strings.TrimSpace(key))) +} + +func normalizeMarketType(marketType string) string { + replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", "/", "") + return replacer.Replace(strings.ToLower(strings.TrimSpace(marketType))) +} + +func isTradeFiMarketType(marketType string) bool { + switch normalizeMarketType(marketType) { + case "hip3perp", "hip3", "xyz", "xyzperp", "tradefi", "tradfi", + "stock", "stocks", "equity", "equities", "usequity", "usequities", "usstock", "usstocks", + "commodity", "commodities", "forex", "fx", "index", "indices", "preipo": + return true + default: + return false + } +} + +func isAllMarketType(marketType string) bool { + switch normalizeMarketType(marketType) { + case "", "all", "any", "ranking", "signalranking", "claw402", "vergex": + return true + default: + return false + } +} + +func isCoreMarketType(marketType string) bool { + switch normalizeMarketType(marketType) { + case "coreperp", "core", "crypto", "cryptoperp": + return true + default: + return false + } +} + +func inferRankingMarketType(symbol, base, fallback string) string { + if !isAllMarketType(fallback) && strings.TrimSpace(fallback) != "" { + return fallback + } + if hyperliquid.IsXYZAsset(symbol) || hyperliquid.IsXYZAsset(base) { + return DefaultMarketType + } + return "core_perp" +} + +func rankingCategory(marketType, base string) string { + if isCoreMarketType(marketType) { + return "crypto" + } + return hyperliquid.XYZCategory(base) +} + +func coalesce(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func emptyDash(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} diff --git a/provider/vergex/client_test.go b/provider/vergex/client_test.go new file mode 100644 index 00000000..9fa364c1 --- /dev/null +++ b/provider/vergex/client_test.go @@ -0,0 +1,220 @@ +package vergex + +import ( + "fmt" + "net/url" + "strings" + "testing" +) + +func TestParseSignalRankingAndFilterTradFiItems(t *testing.T) { + body := []byte(`{ + "data": { + "rankings": [ + {"marketType":"hip3-perp","symbol":"AAPL","bias":"long","confidence":0.88,"compositeZ":1.75}, + {"marketType":"stock","symbol":"NVDA","bias":"long","confidence":0.81,"compositeZ":1.25}, + {"market_type":"core_perp","symbol":"BTC","bias":"short","score":0.91} + ] + } + }`) + + ranking, err := ParseSignalRanking(body) + if err != nil { + t.Fatalf("ParseSignalRanking returned error: %v", err) + } + if len(ranking.Items) != 3 { + t.Fatalf("items len = %d, want 3", len(ranking.Items)) + } + if ranking.Items[0].Symbol != "AAPL" || ranking.Items[0].MarketType != "hip3-perp" || ranking.Items[0].Bias != "long" { + t.Fatalf("unexpected first item: %+v", ranking.Items[0]) + } + + items := FilterTradFiItems(ranking.Items, "hip3_perp", 5) + if len(items) != 2 { + t.Fatalf("filtered len = %d, want 2", len(items)) + } + if got := TradableSymbol(items[0].Symbol); got != "xyz:AAPL" { + t.Fatalf("TradableSymbol = %q, want xyz:AAPL", got) + } + if got := TradableSymbol(items[1].Symbol); got != "xyz:NVDA" { + t.Fatalf("TradableSymbol = %q, want xyz:NVDA", got) + } +} + +func TestFilterTradFiItemsAllowsFullClaw402Board(t *testing.T) { + items := make([]SignalRankItem, 0, 35) + for i := 1; i <= 35; i++ { + items = append(items, SignalRankItem{ + Rank: i, + Symbol: fmt.Sprintf("xyz:STK%02d", i), + MarketType: "hip3_perp", + Bias: "bullish", + }) + } + + filtered := FilterTradFiItems(items, "hip3_perp", 30) + if len(filtered) != 30 { + t.Fatalf("filtered len = %d, want 30", len(filtered)) + } + if filtered[0].Symbol != "STK01" || filtered[29].Symbol != "STK30" { + t.Fatalf("unexpected filtered bounds: first=%q last=%q", filtered[0].Symbol, filtered[29].Symbol) + } + + capped := FilterTradFiItems(items, "hip3_perp", 35) + if len(capped) != MaxSignalRankingItems { + t.Fatalf("capped len = %d, want %d", len(capped), MaxSignalRankingItems) + } +} + +func TestParseSignalRankingReadsNestedMarketShape(t *testing.T) { + body := []byte(`{ + "data": { + "items": [ + {"market":{"marketType":"hip3_perp","symbol":"xyz:NBIS"},"symbol":"xyz:NBIS","bias":"bullish","compositeZ":1.05,"rank":5}, + {"market":{"marketType":"hip3_perp","symbol":"xyz:DRAM"},"symbol":"xyz:DRAM","bias":"bullish","compositeZ":0.47,"rank":10}, + {"market":{"marketType":"core_perp","symbol":"BTC"},"symbol":"BTC","bias":"bearish","compositeZ":-0.08,"rank":12} + ] + } + }`) + + ranking, err := ParseSignalRanking(body) + if err != nil { + t.Fatalf("ParseSignalRanking returned error: %v", err) + } + allItems := FilterSignalRankingItems(ranking.Items, "all", 30) + if len(allItems) != 3 { + t.Fatalf("all filtered len = %d, want 3: %+v", len(allItems), allItems) + } + if allItems[2].Symbol != "BTC" || allItems[2].MarketType != "core_perp" || allItems[2].Category != "crypto" { + t.Fatalf("unexpected crypto item: %+v", allItems[2]) + } + items := FilterTradFiItems(ranking.Items, "hip3_perp", 30) + if len(items) != 2 { + t.Fatalf("filtered len = %d, want 2: %+v", len(items), items) + } + if items[0].Symbol != "NBIS" || items[0].MarketType != "hip3_perp" { + t.Fatalf("unexpected first item: %+v", items[0]) + } + if items[1].Symbol != "DRAM" || items[1].MarketType != "hip3_perp" { + t.Fatalf("unexpected second item: %+v", items[1]) + } +} + +func TestMarketSymbolPreservesHIP3XYZPrefix(t *testing.T) { + if got := MarketSymbol("hip3_perp", "INTC"); got != "xyz:INTC" { + t.Fatalf("MarketSymbol hip3_perp/INTC = %q, want xyz:INTC", got) + } + if got := MarketSymbol("hip3_perp", "xyz:skhx"); got != "xyz:SKHX" { + t.Fatalf("MarketSymbol hip3_perp/xyz:skhx = %q, want xyz:SKHX", got) + } + if got := MarketSymbol("core_perp", "BTC"); got != "BTC" { + t.Fatalf("MarketSymbol core_perp/BTC = %q, want BTC", got) + } + if got := TradableSymbolForMarket("core_perp", "BTC"); got != "BTC" { + t.Fatalf("TradableSymbolForMarket core_perp/BTC = %q, want BTC", got) + } + if got := TradableSymbolForMarket("hip3_perp", "INTC"); got != "xyz:INTC" { + t.Fatalf("TradableSymbolForMarket hip3_perp/INTC = %q, want xyz:INTC", got) + } +} + +func TestAddQueryDefaultsUsesClaw402GatewayParams(t *testing.T) { + params := url.Values{} + addQueryDefaults(params, Query{ + MarketType: "hip3_perp", + Symbol: "INTC", + Chain: "hyperliquid", + LiqBand: "15", + }, true) + + if got := params.Get("marketType"); got != "hip3_perp" { + t.Fatalf("marketType = %q", got) + } + if got := params.Get("symbol"); got != "xyz:INTC" { + t.Fatalf("symbol = %q, want xyz:INTC", got) + } + if got := params.Get("chain"); got != "mainnet" { + t.Fatalf("chain = %q, want mainnet", got) + } + if got := params.Get("liqBand"); got != "15" { + t.Fatalf("liqBand = %q, want 15", got) + } +} + +func TestQueryChainMapsHyperliquidToVergexMainnet(t *testing.T) { + if got := QueryChain("hyperliquid"); got != "mainnet" { + t.Fatalf("QueryChain hyperliquid = %q, want mainnet", got) + } +} + +func TestFormatAnalysisForAIIncludesDetailErrors(t *testing.T) { + text := FormatAnalysisForAI(&MarketAnalysis{ + Symbol: "xyz:NVDA", + QuerySymbol: "NVDA", + MarketType: "stock", + SignalLabError: "upstream returned status 502", + HeatmapError: "market not found", + }) + + if !containsAll(text, "Signal Lab: unavailable", "upstream returned status 502", "Cost/Liquidation Heatmap: unavailable", "market not found") { + t.Fatalf("formatted analysis did not include detail errors:\n%s", text) + } +} + +func TestFormatAnalysisForAIFormatsVergexDetailsAsMarkdown(t *testing.T) { + text := FormatAnalysisForAI(&MarketAnalysis{ + Symbol: "xyz:DRAM", + QuerySymbol: "DRAM", + MarketType: "hip3_perp", + SignalLab: []byte(`{ + "data": { + "band": "15", + "bias": "bullish", + "compositeZ": 1.41, + "confidence": "Medium", + "dimensions": [ + { + "family": "I Cost & Positioning", + "label": "Capital-gains overhang", + "direction": "bullish", + "strength": "medium", + "percentile": 80, + "detail": "price is above aggregate cost" + } + ] + } + }`), + Heatmap: []byte(`{ + "data": { + "binStep": 3.2, + "bins": [ + {"bucketStartPrice": 100, "bucketEndPrice": 103.2, "longCost": 1200000, "shortCost": 1000, "longLiq": 5000, "shortLiq": 700000}, + {"bucketStartPrice": 103.2, "bucketEndPrice": 106.4, "longCost": 1000, "shortCost": 2000, "longLiq": 900000, "shortLiq": 4000} + ] + } + }`), + }) + + if !containsAll(text, + "#### Signal Lab", + "| Family | Signal | Direction | Strength | Percentile | Detail |", + "Capital-gains overhang", + "#### Cost/Liquidation Heatmap", + "| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |", + "$1.20M", + ) { + t.Fatalf("formatted analysis is not markdown enough:\n%s", text) + } + if strings.Contains(text, "Signal Lab: {") || strings.Contains(text, "Cost/Liquidation Heatmap: {") { + t.Fatalf("formatted analysis still includes raw inline JSON:\n%s", text) + } +} + +func containsAll(text string, needles ...string) bool { + for _, needle := range needles { + if !strings.Contains(text, needle) { + return false + } + } + return true +} diff --git a/store/ai_charge.go b/store/ai_charge.go index 340009e8..6ec28d62 100644 --- a/store/ai_charge.go +++ b/store/ai_charge.go @@ -157,7 +157,7 @@ func IsClaw402Config(aiModel string) bool { // EstimateRunway estimates how many days the given USDC balance will last func EstimateRunway(usdcBalance float64, modelName string, scanIntervalMinutes int) (dailyCost float64, runwayDays float64) { if scanIntervalMinutes <= 0 { - scanIntervalMinutes = 3 + scanIntervalMinutes = 15 } callsPerDay := float64(24*60) / float64(scanIntervalMinutes) pricePerCall := GetModelPrice(modelName) diff --git a/store/position.go b/store/position.go index 92463db9..e0adbe7d 100644 --- a/store/position.go +++ b/store/position.go @@ -116,8 +116,8 @@ type TraderPosition struct { Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"` CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"` Source string `gorm:"column:source;default:system" json:"source"` - CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC - UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC + UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC } // TableName returns the table name @@ -198,14 +198,14 @@ func (s *PositionStore) Create(pos *TraderPosition) error { func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error { nowMs := time.Now().UTC().UnixMilli() return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ - "exit_price": exitPrice, + "exit_price": exitPrice, "exit_order_id": exitOrderID, - "exit_time": nowMs, - "realized_pnl": realizedPnL, - "fee": fee, - "status": "CLOSED", - "close_reason": closeReason, - "updated_at": nowMs, + "exit_time": nowMs, + "realized_pnl": realizedPnL, + "fee": fee, + "status": "CLOSED", + "close_reason": closeReason, + "updated_at": nowMs, }).Error } @@ -311,15 +311,15 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde } return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ - "quantity": quantity, - "exit_price": exitPrice, - "exit_order_id": exitOrderID, - "exit_time": exitTimeMs, - "realized_pnl": totalRealizedPnL, - "fee": totalFee, - "status": "CLOSED", - "close_reason": closeReason, - "updated_at": time.Now().UTC().UnixMilli(), + "quantity": quantity, + "exit_price": exitPrice, + "exit_order_id": exitOrderID, + "exit_time": exitTimeMs, + "realized_pnl": totalRealizedPnL, + "fee": totalFee, + "status": "CLOSED", + "close_reason": closeReason, + "updated_at": time.Now().UTC().UnixMilli(), }).Error } @@ -350,7 +350,7 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er // GetOpenPositionBySymbol gets open position for specified symbol and direction func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) { var pos TraderPosition - err := s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, symbol, side, "OPEN"). + err := s.db.Where("trader_id = ? AND symbol = ? AND UPPER(side) = UPPER(?) AND status = ?", traderID, symbol, side, "OPEN"). Order("entry_time DESC"). First(&pos).Error @@ -365,7 +365,7 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) ( // Try without USDT suffix for backward compatibility if strings.HasSuffix(symbol, "USDT") { baseSymbol := strings.TrimSuffix(symbol, "USDT") - err = s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, baseSymbol, side, "OPEN"). + err = s.db.Where("trader_id = ? AND symbol = ? AND UPPER(side) = UPPER(?) AND status = ?", traderID, baseSymbol, side, "OPEN"). Order("entry_time DESC"). First(&pos).Error if err == nil { diff --git a/store/position_test.go b/store/position_test.go new file mode 100644 index 00000000..b9419400 --- /dev/null +++ b/store/position_test.go @@ -0,0 +1,44 @@ +package store + +import ( + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestGetOpenPositionBySymbolMatchesSideCaseInsensitively(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open in-memory sqlite: %v", err) + } + + positions := NewPositionStore(db) + if err := positions.InitTables(); err != nil { + t.Fatalf("init position table: %v", err) + } + + entryTime := time.Now().Add(-5 * time.Minute).UnixMilli() + if err := positions.Create(&TraderPosition{ + TraderID: "trader-1", + Symbol: "AAVEUSDT", + Side: "LONG", + Quantity: 0.27, + EntryPrice: 88.519, + EntryTime: entryTime, + }); err != nil { + t.Fatalf("create position: %v", err) + } + + got, err := positions.GetOpenPositionBySymbol("trader-1", "AAVEUSDT", "long") + if err != nil { + t.Fatalf("get open position: %v", err) + } + if got == nil { + t.Fatal("expected open position") + } + if got.EntryTime != entryTime { + t.Fatalf("entry time mismatch: got %d want %d", got.EntryTime, entryTime) + } +} diff --git a/store/strategy.go b/store/strategy.go index 68aa8b9f..e273538a 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -46,6 +46,9 @@ func (c *StrategyConfig) ClampLimits() { if c.CoinSource.OILowLimit > MaxCandidateCoins { c.CoinSource.OILowLimit = MaxCandidateCoins } + if c.CoinSource.VergexLimit > MaxCandidateCoins { + c.CoinSource.VergexLimit = MaxCandidateCoins + } // Clamp static coins if len(c.CoinSource.StaticCoins) > MaxCandidateCoins { @@ -136,6 +139,8 @@ func (c *StrategyConfig) ClampLimits() { // must use the exact frontend/backend enum values. func (c *StrategyConfig) NormalizeProductSchema() { c.StrategyType = normalizeStrategyType(c.StrategyType) + c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins) + c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins) c.CoinSource.SourceType = normalizeCoinSourceType(c.CoinSource.SourceType) if c.CoinSource.SourceType == "" { c.CoinSource.SourceType = inferCoinSourceType(c.CoinSource) @@ -205,26 +210,53 @@ func (c *StrategyConfig) NormalizeProductSchema() { if c.CoinSource.HyperRankLimit <= 0 { c.CoinSource.HyperRankLimit = 5 } - default: - c.CoinSource.SourceType = "hyper_rank" + case "vergex_signal": c.CoinSource.UseAI500 = false c.CoinSource.UseOITop = false c.CoinSource.UseOILow = false c.CoinSource.UseHyperAll = false c.CoinSource.UseHyperMain = false - if c.CoinSource.HyperRankCategory == "" { - c.CoinSource.HyperRankCategory = "stock" + minLimit := 10 + if len(c.CoinSource.StaticCoins) > 0 { + minLimit = len(c.CoinSource.StaticCoins) + if minLimit > MaxCandidateCoins { + minLimit = MaxCandidateCoins + } } - if c.CoinSource.HyperRankDirection == "" { - c.CoinSource.HyperRankDirection = "gainers" + if c.CoinSource.VergexLimit < minLimit { + c.CoinSource.VergexLimit = minLimit } - if c.CoinSource.HyperRankLimit <= 0 { - c.CoinSource.HyperRankLimit = 5 + if c.CoinSource.VergexMarketType == "" { + c.CoinSource.VergexMarketType = "all" + } + if c.CoinSource.VergexChain == "" { + c.CoinSource.VergexChain = "hyperliquid" + } + default: + c.CoinSource.SourceType = "vergex_signal" + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + minLimit := 10 + if len(c.CoinSource.StaticCoins) > 0 { + minLimit = len(c.CoinSource.StaticCoins) + if minLimit > MaxCandidateCoins { + minLimit = MaxCandidateCoins + } + } + if c.CoinSource.VergexLimit < minLimit { + c.CoinSource.VergexLimit = minLimit + } + if c.CoinSource.VergexMarketType == "" { + c.CoinSource.VergexMarketType = "all" + } + if c.CoinSource.VergexChain == "" { + c.CoinSource.VergexChain = "hyperliquid" } } - c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins) - c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins) c.Indicators.Klines.PrimaryTimeframe = normalizeTimeframe(c.Indicators.Klines.PrimaryTimeframe) c.Indicators.Klines.LongerTimeframe = normalizeTimeframe(c.Indicators.Klines.LongerTimeframe) c.Indicators.Klines.SelectedTimeframes = normalizeTimeframes(c.Indicators.Klines.SelectedTimeframes) @@ -257,8 +289,10 @@ func normalizeCoinSourceType(value string) string { return "oi_top" case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"): return "oi_low" - case strings.Contains(compact, "hyperrank") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "动态榜单") || strings.Contains(value, "涨幅榜"): + case strings.Contains(compact, "hyperrank"): return "hyper_rank" + case strings.Contains(compact, "vergex") || strings.Contains(compact, "claw402") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "动态榜单") || strings.Contains(value, "涨幅榜") || strings.Contains(value, "信号榜"): + return "vergex_signal" case strings.Contains(compact, "hyperall"): return "hyper_all" case strings.Contains(compact, "hypermain"): @@ -284,10 +318,12 @@ func inferCoinSourceType(source CoinSourceConfig) string { return "hyper_all" case source.UseHyperMain: return "hyper_main" + case source.VergexLimit > 0 || source.VergexMarketType != "" || source.VergexChain != "" || source.VergexLiqBand != "": + return "vergex_signal" case source.HyperRankCategory != "" || source.HyperRankDirection != "" || source.HyperRankLimit > 0: return "hyper_rank" default: - return "hyper_rank" + return "vergex_signal" } } @@ -783,6 +819,14 @@ type CoinSourceConfig struct { HyperRankDirection string `json:"hyper_rank_direction,omitempty"` // Hyperliquid dynamic ranking maximum count. Defaults to 5 and is hard capped at 10 for AI context safety. HyperRankLimit int `json:"hyper_rank_limit,omitempty"` + // Vergex signal-ranking maximum count. Defaults to 5 and is hard capped at 10. + VergexLimit int `json:"vergex_limit,omitempty"` + // Vergex market type for detail endpoints, e.g. hip3_perp for Hyperliquid TradeFi perps. + VergexMarketType string `json:"vergex_market_type,omitempty"` + // Vergex chain query parameter. Defaults to hyperliquid. + VergexChain string `json:"vergex_chain,omitempty"` + // Vergex liquidation band query parameter. + VergexLiqBand string `json:"vergex_liq_band,omitempty"` // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig } @@ -916,28 +960,29 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { config := StrategyConfig{ Language: normalizedLang, CoinSource: CoinSourceConfig{ - SourceType: "hyper_rank", - UseAI500: false, - AI500Limit: 3, - UseOITop: false, - OITopLimit: 3, - UseOILow: false, - OILowLimit: 3, - UseHyperAll: false, - UseHyperMain: false, - HyperMainLimit: 30, - HyperRankCategory: "stock", - HyperRankDirection: "gainers", - HyperRankLimit: 5, + SourceType: "vergex_signal", + UseAI500: false, + AI500Limit: 3, + UseOITop: false, + OITopLimit: 3, + UseOILow: false, + OILowLimit: 3, + UseHyperAll: false, + UseHyperMain: false, + HyperMainLimit: 30, + HyperRankCategory: "all", + VergexLimit: 10, + VergexMarketType: "all", + VergexChain: "hyperliquid", }, Indicators: IndicatorConfig{ Klines: KlineConfig{ - PrimaryTimeframe: "5m", - PrimaryCount: 20, - LongerTimeframe: "4h", - LongerCount: 10, - EnableMultiTimeframe: true, - SelectedTimeframes: []string{"5m", "15m", "1h"}, + PrimaryTimeframe: "15m", + PrimaryCount: 30, + LongerTimeframe: "", + LongerCount: 0, + EnableMultiTimeframe: false, + SelectedTimeframes: []string{"15m"}, }, EnableRawKlines: true, // Required - raw OHLCV data for AI analysis EnableEMA: false, @@ -945,9 +990,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { EnableRSI: false, EnableATR: false, EnableBOLL: false, - EnableVolume: true, - EnableOI: true, - EnableFundingRate: true, + EnableVolume: false, + EnableOI: false, + EnableFundingRate: false, EMAPeriods: []int{20, 50}, RSIPeriods: []int{7, 14}, ATRPeriods: []int{14}, @@ -969,57 +1014,57 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { PriceRankingLimit: 10, }, RiskControl: RiskControlConfig{ - MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) - BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided) - AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided) - BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED) - AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED) - MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED) - MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED) - MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) - MinConfidence: 75, // Min 75% confidence (AI guided) + MaxPositions: 2, // Max 2 instruments simultaneously (CODE ENFORCED) + BTCETHMaxLeverage: 10, // BTC/ETH exchange leverage (AI guided) + AltcoinMaxLeverage: 10, // TradeFi exchange leverage (AI guided) + BTCETHMaxPositionValueRatio: 1.0, // Claw402 default: same cap across assets + AltcoinMaxPositionValueRatio: 1.0, // Claw402 default: same cap across assets + MaxMarginUsage: 0.35, // Max 35% margin usage (CODE ENFORCED) + MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED) + MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) + MinConfidence: 78, // Min 78% confidence (AI guided) }, } if lang == "zh" { config.PromptSections = PromptSectionsConfig{ - RoleDefinition: `# 你是一个专业的 Hyperliquid USDC 多资产交易AI + RoleDefinition: `# 你是 NOFX Claw402 自动交易员 -你的任务是根据提供的市场数据做出交易决策。你可以分析并交易 Hyperliquid 上线的 USDC 永续合约,包括美股、大宗商品和加密资产。你是一个经验丰富的量化交易员,擅长跨资产技术分析和风险管理。`, - TradingFrequency: `# ⏱️ 交易频率意识 +你只交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。候选池来自 Claw402.ai/Vergex,开仓前必须结合 Signal Lab、成本/清算热力图和原始 K 线判断。`, + TradingFrequency: `# 交易频率 -- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔 -- 每小时超过2笔 = 过度交易 -- 单笔持仓时间 ≥ 30-60分钟 -如果你发现自己每个周期都在交易 → 标准太低;如果持仓不到30分钟就平仓 → 太冲动。`, - EntryStandards: `# 🎯 入场标准(严格) +- 优先等待高质量机会,不需要每轮都交易。 +- 先管理已有持仓,再考虑新开仓。 +- 同一轮不要频繁开平同一标的。`, + EntryStandards: `# 入场标准 -只在多个信号共振时入场。自由使用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、或平仓后立即重新开仓等低质量行为。`, - DecisionProcess: `# 📋 决策流程 +只有 Claw402 Signal Lab、成本/清算热力图和原始 K 线大体一致时才开仓。Claw402 排名只是候选池,不是单独买入理由。任一关键数据缺失或冲突时,默认等待。`, + DecisionProcess: `# 决策流程 -1. 检查持仓 → 是否止盈/止损 -2. 扫描候选币种 + 多时间框架 → 是否存在强信号 -3. 先写思维链,再输出结构化JSON`, +1. 检查已有持仓,先决定止盈、止损或继续持有。 +2. 从 Claw402 榜单取本轮候选,并对每个候选读取 Claw402 Ranking、Signal Lab、Cost/Liquidation Heatmap。 +3. 用原始 K 线确认入场位置、止损和止盈。 +4. 输出简洁 reasoning 和严格 JSON。`, } } else { config.PromptSections = PromptSectionsConfig{ - RoleDefinition: `# You are a professional Hyperliquid USDC multi-asset trading AI + RoleDefinition: `# You are the NOFX Claw402 auto-trader -Your task is to make trading decisions based on the provided market data. You can analyze and trade Hyperliquid-listed USDC perpetual markets, including US equities, commodities and crypto assets. You are an experienced quantitative trader skilled in cross-asset technical analysis and risk management.`, - TradingFrequency: `# ⏱️ Trading Frequency Awareness +Trade Hyperliquid Claw402-ranked instruments only. The candidate pool comes from Claw402.ai/Vergex; before opening a position, combine Signal Lab, cost/liquidation heatmap and raw candles.`, + TradingFrequency: `# Trading Frequency -- Excellent trader: 2-4 trades per day ≈ 0.1-0.2 trades per hour -- >2 trades per hour = overtrading -- Single position holding time ≥ 30-60 minutes -If you find yourself trading every cycle → standards are too low; if closing positions in <30 minutes → too impulsive.`, - EntryStandards: `# 🎯 Entry Standards (Strict) +- Wait for quality; you do not need to trade every cycle. +- Manage existing positions before opening new ones. +- Do not churn in and out of the same symbol in one cycle.`, + EntryStandards: `# Entry Standards -Only enter positions when multiple signals resonate. Freely use any effective analysis methods, avoid low-quality behaviors such as single indicators, contradictory signals, sideways oscillation, or immediately restarting after closing positions.`, - DecisionProcess: `# 📋 Decision Process +Open only when Claw402 Signal Lab, cost/liquidation heatmap and raw candles broadly agree. Ranking defines the candidate pool, not a standalone entry reason. Wait when key data is missing or contradictory.`, + DecisionProcess: `# Decision Process -1. Check positions → whether to take profit/stop loss -2. Scan candidate coins + multi-timeframe → whether strong signals exist -3. Write chain of thought first, then output structured JSON`, +1. Check current positions first: take profit, stop loss or hold. +2. Pull this cycle's Claw402 board and read Claw402 Ranking, Signal Lab and Cost/Liquidation Heatmap for each candidate. +3. Use raw candles to confirm entry, stop and target. +4. Output concise reasoning and strict JSON.`, } } @@ -1461,6 +1506,8 @@ func (c *StrategyConfig) getEffectiveCoinCount() int { count = c.CoinSource.OILowLimit case "hyper_rank": count = c.CoinSource.HyperRankLimit + case "vergex_signal": + count = c.CoinSource.VergexLimit case "hyper_main": count = c.CoinSource.HyperMainLimit case "hyper_all": diff --git a/store/strategy_hyperliquid_defaults_test.go b/store/strategy_hyperliquid_defaults_test.go index 108ebdcb..67ad86ff 100644 --- a/store/strategy_hyperliquid_defaults_test.go +++ b/store/strategy_hyperliquid_defaults_test.go @@ -2,41 +2,41 @@ package store import "testing" -func TestDefaultHyperliquidStrategyDoesNotEnableNofxOSData(t *testing.T) { +func TestDefaultVergexStrategyDoesNotEnableNofxOSData(t *testing.T) { cfg := GetDefaultStrategyConfig("zh") - assertHyperliquidStockRankDefault(t, cfg) + assertVergexSignalDefault(t, cfg) ind := cfg.Indicators if ind.NofxOSAPIKey != "" { - t.Fatalf("default should not include a NofxOS API key for Hyperliquid strategies") + t.Fatalf("default should not include a NofxOS API key for Claw402/Vergex strategies") } if ind.EnableQuantData || ind.EnableQuantOI || ind.EnableQuantNetflow || ind.EnableOIRanking || ind.EnableNetFlowRanking || ind.EnablePriceRanking { - t.Fatalf("default Hyperliquid strategy must not enable NofxOS datasets: %+v", ind) + t.Fatalf("default Claw402/Vergex strategy must not enable NofxOS datasets: %+v", ind) } if !ind.EnableRawKlines { t.Fatalf("raw Hyperliquid klines must stay enabled") } } -func TestHyperliquidRankDefaultSurvivesClampAndNormalize(t *testing.T) { +func TestVergexSignalDefaultSurvivesClampAndNormalize(t *testing.T) { cfg := GetDefaultStrategyConfig("zh") cfg.CoinSource.UseAI500 = true cfg.ClampLimits() - assertHyperliquidStockRankDefault(t, cfg) + assertVergexSignalDefault(t, cfg) if cfg.CoinSource.UseAI500 { - t.Fatalf("Hyperliquid rank strategy must clear stale AI500 flag: %+v", cfg.CoinSource) + t.Fatalf("Claw402/Vergex signal strategy must clear stale AI500 flag: %+v", cfg.CoinSource) } } -func TestEmptyCoinSourceInfersHyperliquidRankNotAI500(t *testing.T) { +func TestEmptyCoinSourceInfersVergexSignalNotAI500(t *testing.T) { cfg := GetDefaultStrategyConfig("zh") cfg.CoinSource = CoinSourceConfig{} cfg.NormalizeProductSchema() - assertHyperliquidStockRankDefault(t, cfg) + assertVergexSignalDefault(t, cfg) } -func assertHyperliquidStockRankDefault(t *testing.T, cfg StrategyConfig) { +func assertVergexSignalDefault(t *testing.T, cfg StrategyConfig) { t.Helper() - if cfg.CoinSource.SourceType != "hyper_rank" || cfg.CoinSource.HyperRankCategory != "stock" || cfg.CoinSource.HyperRankDirection != "gainers" || cfg.CoinSource.HyperRankLimit != 5 { - t.Fatalf("coin source = %+v, want Hyperliquid dynamic stock gainers top 5", cfg.CoinSource) + if cfg.CoinSource.SourceType != "vergex_signal" || cfg.CoinSource.VergexLimit != 10 || cfg.CoinSource.VergexMarketType != "all" || cfg.CoinSource.VergexChain != "hyperliquid" { + t.Fatalf("coin source = %+v, want Claw402/Vergex all-market signal top 10", cfg.CoinSource) } } diff --git a/store/strategy_schema_test.go b/store/strategy_schema_test.go index cc330eae..1d3d52d2 100644 --- a/store/strategy_schema_test.go +++ b/store/strategy_schema_test.go @@ -122,3 +122,62 @@ func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) { } } } + +func TestStrategyConfigNormalizeProductSchemaForVergexSignal(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + cfg.CoinSource = CoinSourceConfig{ + SourceType: "Claw402 Vergex 信号榜", + } + + cfg.NormalizeProductSchema() + + if cfg.CoinSource.SourceType != "vergex_signal" { + t.Fatalf("source_type = %q, want vergex_signal", cfg.CoinSource.SourceType) + } + if cfg.CoinSource.VergexLimit != 10 { + t.Fatalf("vergex_limit = %d, want 10", cfg.CoinSource.VergexLimit) + } + if cfg.CoinSource.VergexMarketType != "all" { + t.Fatalf("vergex_market_type = %q, want all", cfg.CoinSource.VergexMarketType) + } + if cfg.CoinSource.VergexChain != "hyperliquid" { + t.Fatalf("vergex_chain = %q, want hyperliquid", cfg.CoinSource.VergexChain) + } +} + +func TestStrategyConfigNormalizeProductSchemaForVergexSignalLimits(t *testing.T) { + t.Run("dynamic board keeps the one built-in strategy candidate depth", func(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + cfg.CoinSource = CoinSourceConfig{ + SourceType: "vergex_signal", + VergexLimit: 1, + StaticCoins: nil, + VergexChain: "hyperliquid", + VergexLiqBand: "", + } + + cfg.NormalizeProductSchema() + + if cfg.CoinSource.VergexLimit != 10 { + t.Fatalf("vergex_limit = %d, want 10", cfg.CoinSource.VergexLimit) + } + }) + + t.Run("manual picks keep selected count", func(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + cfg.CoinSource = CoinSourceConfig{ + SourceType: "vergex_signal", + VergexLimit: 1, + StaticCoins: []string{"xyz:nvda", "XYZ:AAPL"}, + } + + cfg.NormalizeProductSchema() + + if cfg.CoinSource.VergexLimit != 2 { + t.Fatalf("vergex_limit = %d, want 2", cfg.CoinSource.VergexLimit) + } + if got := cfg.CoinSource.StaticCoins; len(got) != 2 || got[0] != "XYZ:NVDA" || got[1] != "XYZ:AAPL" { + t.Fatalf("static_coins = %+v, want normalized xyz symbols", got) + } + }) +} diff --git a/store/trader.go b/store/trader.go index 9a4bd780..0cc5e3da 100644 --- a/store/trader.go +++ b/store/trader.go @@ -26,7 +26,7 @@ type Trader struct { ExchangeID string `gorm:"column:exchange_id;not null" json:"exchange_id"` StrategyID string `gorm:"column:strategy_id;default:''" json:"strategy_id"` InitialBalance float64 `gorm:"column:initial_balance;not null" json:"initial_balance"` - ScanIntervalMinutes int `gorm:"column:scan_interval_minutes;default:3" json:"scan_interval_minutes"` + ScanIntervalMinutes int `gorm:"column:scan_interval_minutes;default:15" json:"scan_interval_minutes"` IsRunning bool `gorm:"column:is_running;default:false" json:"is_running"` IsCrossMargin bool `gorm:"column:is_cross_margin;default:true" json:"is_cross_margin"` ShowInCompetition bool `gorm:"column:show_in_competition;default:true" json:"show_in_competition"` diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 2667849f..6c29fdd8 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -52,9 +52,10 @@ func (at *AutoTrader) logErrorf(format string, args ...interface{}) { // AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions) type AutoTraderConfig struct { // Trader identification - ID string // Trader unique identifier (for log directory, etc.) - Name string // Trader display name - AIModel string // AI model: "qwen" or "deepseek" + ID string // Trader unique identifier (for log directory, etc.) + Name string // Trader display name + StrategyID string // Associated strategy ID used to refresh live strategy config + AIModel string // AI model: "qwen" or "deepseek" // Trading platform selection Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter" @@ -121,7 +122,7 @@ type AutoTraderConfig struct { Claw402WalletKey string // Scan configuration - ScanInterval time.Duration // Scan interval (recommended 3 minutes) + ScanInterval time.Duration // Scan interval (recommended 15 minutes) // Account configuration InitialBalance float64 // Initial balance (for P&L calculation, must be set manually) @@ -138,7 +139,8 @@ type AutoTraderConfig struct { ShowInCompetition bool // Whether to show in competition page // Strategy configuration (use complete strategy config) - StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.) + StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.) + StrategyConfigRaw string // Raw strategy config JSON from DB, used to detect live edits } // AutoTrader automatic trader @@ -396,6 +398,38 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au }, nil } +func (at *AutoTrader) reloadStrategyConfigIfChanged() error { + if at == nil || at.store == nil || at.config.StrategyID == "" { + return nil + } + + strategy, err := at.store.Strategy().Get(at.userID, at.config.StrategyID) + if err != nil { + return fmt.Errorf("failed to load strategy %s: %w", at.config.StrategyID, err) + } + + if at.strategyEngine != nil && strategy.Config == at.config.StrategyConfigRaw { + return nil + } + + strategyConfig, err := strategy.ParseConfig() + if err != nil { + return fmt.Errorf("failed to parse strategy %s: %w", strategy.Name, err) + } + strategyConfig.ClampLimits() + + claw402Key := at.config.Claw402WalletKey + if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" { + claw402Key = at.config.CustomAPIKey + } + + at.config.StrategyConfig = strategyConfig + at.config.StrategyConfigRaw = strategy.Config + at.strategyEngine = kernel.NewStrategyEngine(strategyConfig, claw402Key) + at.logInfof("🔄 Strategy config refreshed from DB: %s", strategy.Name) + return nil +} + // Run runs the automatic trading main loop func (at *AutoTrader) Run() error { at.isRunningMutex.Lock() diff --git a/trader/auto_trader_full_size_test.go b/trader/auto_trader_full_size_test.go new file mode 100644 index 00000000..7ee67610 --- /dev/null +++ b/trader/auto_trader_full_size_test.go @@ -0,0 +1,54 @@ +package trader + +import ( + "nofx/kernel" + "nofx/store" + "testing" +) + +func TestApplyAutopilotFullSizeOpenForClaw402(t *testing.T) { + cfg := store.GetDefaultStrategyConfig("en") + cfg.CoinSource.SourceType = "vergex_signal" + cfg.RiskControl.BTCETHMaxLeverage = 10 + cfg.RiskControl.AltcoinMaxLeverage = 10 + cfg.RiskControl.BTCETHMaxPositionValueRatio = 1 + cfg.RiskControl.AltcoinMaxPositionValueRatio = 1 + + at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}} + decision := &kernel.Decision{ + Symbol: "xyz:INTC", + Action: "open_long", + Leverage: 3, + PositionSizeUSD: 12, + } + + at.applyAutopilotFullSizeOpen(decision, 29.8) + + if decision.Leverage != 10 { + t.Fatalf("expected leverage to be forced to 10x, got %dx", decision.Leverage) + } + if decision.PositionSizeUSD != 29.8 { + t.Fatalf("expected position size to use full notional 29.8, got %.2f", decision.PositionSizeUSD) + } +} + +func TestApplyAutopilotFullSizeOpenSkipsNonClaw402Strategies(t *testing.T) { + cfg := store.GetDefaultStrategyConfig("en") + cfg.CoinSource.SourceType = "static" + cfg.RiskControl.BTCETHMaxLeverage = 10 + cfg.RiskControl.AltcoinMaxLeverage = 10 + + at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}} + decision := &kernel.Decision{ + Symbol: "BTCUSDT", + Action: "open_long", + Leverage: 3, + PositionSizeUSD: 12, + } + + at.applyAutopilotFullSizeOpen(decision, 29.8) + + if decision.Leverage != 3 || decision.PositionSizeUSD != 12 { + t.Fatalf("non-Claw402 strategies should not be rewritten, got leverage=%d size=%.2f", decision.Leverage, decision.PositionSizeUSD) + } +} diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index 3be8154b..a483d11c 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -30,6 +30,10 @@ func (at *AutoTrader) runCycle() error { return nil } + if err := at.reloadStrategyConfigIfChanged(); err != nil { + at.logWarnf("⚠️ Strategy refresh failed, using current in-memory config: %v", err) + } + // Check USDC balance periodically for claw402 users (every 10 cycles) if at.callCount%10 == 0 && store.IsClaw402Config(at.config.AIModel) { at.checkClaw402Balance() @@ -250,7 +254,9 @@ func (at *AutoTrader) runCycle() error { } } - // Execute decisions and record results + // Execute decisions and record results. Trade throttle is applied here, + // immediately before order placement, so AI churn cannot become live orders. + opensAllowedThisCycle := 0 for _, d := range sortedDecisions { // Check if trader is stopped before each decision (allow immediate stop during execution) at.isRunningMutex.RLock() @@ -275,6 +281,17 @@ func (at *AutoTrader) runCycle() error { Success: false, } + if reason := at.tradeThrottleReason(d, ctx, opensAllowedThisCycle); reason != "" { + at.logWarnf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason) + actionRecord.Error = reason + record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason)) + record.Decisions = append(record.Decisions, actionRecord) + continue + } + if isOpenAction(d.Action) { + opensAllowedThisCycle++ + } + if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil { at.logErrorf("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err) actionRecord.Error = err.Error() @@ -740,7 +757,7 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision { func (at *AutoTrader) checkClaw402Balance() { scanMinutes := int(at.config.ScanInterval.Minutes()) if scanMinutes <= 0 { - scanMinutes = 3 + scanMinutes = 15 } dailyCost, _ := store.EstimateRunway(1.0, at.config.CustomModelName, scanMinutes) logger.Infof("💰 [%s] Estimated daily AI cost: ~$%.2f (model: %s, interval: %dm)", diff --git a/trader/auto_trader_orders.go b/trader/auto_trader_orders.go index 1635201b..e1a5e14e 100644 --- a/trader/auto_trader_orders.go +++ b/trader/auto_trader_orders.go @@ -90,6 +90,8 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio equity = availableBalance // Fallback to available balance } + at.applyAutopilotFullSizeOpen(decision, equity) + // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { @@ -204,6 +206,8 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti equity = availableBalance // Fallback to available balance } + at.applyAutopilotFullSizeOpen(decision, equity) + // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { diff --git a/trader/auto_trader_risk.go b/trader/auto_trader_risk.go index d73f81ad..17a3f6f7 100644 --- a/trader/auto_trader_risk.go +++ b/trader/auto_trader_risk.go @@ -2,8 +2,10 @@ package trader import ( "fmt" + "nofx/kernel" "nofx/logger" "nofx/market" + "nofx/store" "strings" "time" ) @@ -237,6 +239,46 @@ func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity return positionSizeUSD, false } +func (at *AutoTrader) applyAutopilotFullSizeOpen(decision *kernel.Decision, equity float64) { + if at == nil || decision == nil || at.config.StrategyConfig == nil || equity <= 0 { + return + } + + cfg := at.config.StrategyConfig + if cfg.CoinSource.SourceType != "vergex_signal" { + return + } + + riskControl := cfg.RiskControl + leverage := riskControl.AltcoinMaxLeverage + positionValueRatio := riskControl.AltcoinMaxPositionValueRatio + if isMajorAsset(decision.Symbol) { + leverage = riskControl.BTCETHMaxLeverage + positionValueRatio = riskControl.BTCETHMaxPositionValueRatio + } + if leverage < store.MinLeverage { + leverage = store.MinLeverage + } + if leverage > store.MaxAltLeverage { + leverage = store.MaxAltLeverage + } + if positionValueRatio <= 0 { + positionValueRatio = 1.0 + } + + fullPositionSize := equity * positionValueRatio + if fullPositionSize <= 0 { + return + } + + if decision.Leverage != leverage || decision.PositionSizeUSD != fullPositionSize { + logger.Infof(" 📏 [AUTOPILOT] Full-size open enforced for %s: leverage %dx → %dx, notional %.2f → %.2f USDT", + decision.Symbol, decision.Leverage, leverage, decision.PositionSizeUSD, fullPositionSize) + } + decision.Leverage = leverage + decision.PositionSizeUSD = fullPositionSize +} + // enforceMinPositionSize checks minimum position size (CODE ENFORCED) func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error { if at.config.StrategyConfig == nil { diff --git a/trader/auto_trader_throttle.go b/trader/auto_trader_throttle.go new file mode 100644 index 00000000..5771f230 --- /dev/null +++ b/trader/auto_trader_throttle.go @@ -0,0 +1,278 @@ +package trader + +import ( + "fmt" + "nofx/kernel" + "nofx/market" + "nofx/store" + "strings" + "time" +) + +const ( + autopilotMinHoldDuration = 45 * time.Minute + autopilotNoiseCloseHoldDuration = 90 * time.Minute + autopilotReentryCooldown = 90 * time.Minute + autopilotMaxOpensPerHour = 1 + autopilotMaxOpensPerCycle = 1 + earlyCloseStopLossBypassPct = -2.5 + earlyCloseTakeProfitBypassPct = 5.0 + noiseCloseLossFloorPct = -1.0 + noiseCloseProfitCeilingPct = 2.0 +) + +func isOpenAction(action string) bool { + switch strings.ToLower(strings.TrimSpace(action)) { + case "open_long", "open_short": + return true + default: + return false + } +} + +func isCloseAction(action string) bool { + switch strings.ToLower(strings.TrimSpace(action)) { + case "close_long", "close_short": + return true + default: + return false + } +} + +func closeActionSide(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case "close_long": + return "long" + case "close_short": + return "short" + default: + return "" + } +} + +func openActionSide(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case "open_long": + return "long" + case "open_short": + return "short" + default: + return "" + } +} + +func normalizedDecisionSymbol(symbol string) string { + return market.Normalize(strings.TrimSpace(symbol)) +} + +func (at *AutoTrader) tradeThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string { + if ctx == nil { + return "" + } + + switch { + case isOpenAction(decision.Action): + return at.openThrottleReason(decision, ctx, opensQueuedThisCycle) + case isCloseAction(decision.Action): + return at.closeThrottleReason(decision, ctx) + default: + return "" + } +} + +func (at *AutoTrader) openThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string { + symbol := normalizedDecisionSymbol(decision.Symbol) + if symbol == "" { + return "" + } + + if opensQueuedThisCycle >= autopilotMaxOpensPerCycle { + return fmt.Sprintf("trade throttle: only %d new position may be opened per cycle", autopilotMaxOpensPerCycle) + } + + if pos := findAnyContextPosition(ctx, symbol); pos != nil { + return fmt.Sprintf("trade throttle: %s already has an open %s position; manage or close it before opening another side", symbol, pos.Side) + } + + openCount, err := at.countRecentOpenOrders(time.Now().Add(-1 * time.Hour)) + if err != nil { + at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err) + } else if openCount >= autopilotMaxOpensPerHour { + return fmt.Sprintf("trade throttle: %d open order already executed in the last hour; max is %d", openCount, autopilotMaxOpensPerHour) + } + + if order := at.findRecentCloseOrder(symbol, time.Now().Add(-autopilotReentryCooldown)); order != nil { + age := time.Since(time.UnixMilli(order.CreatedAt)) + remaining := autopilotReentryCooldown - age + if remaining < 0 { + remaining = 0 + } + return fmt.Sprintf("trade throttle: %s was closed %s ago; wait %s before re-entry", symbol, roundDuration(age), roundDuration(remaining)) + } + + return "" +} + +func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.Context) string { + symbol := normalizedDecisionSymbol(decision.Symbol) + side := closeActionSide(decision.Action) + if symbol == "" || side == "" { + return "" + } + + pos := findContextPosition(ctx, symbol, side) + pnlPct := 0.0 + entryTime := int64(0) + if pos != nil { + pnlPct = pos.UnrealizedPnLPct + entryTime = pos.UpdateTime + } + + if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-autopilotNoiseCloseHoldDuration)); order != nil && order.CreatedAt > entryTime { + entryTime = order.CreatedAt + } + if entryTime <= 0 { + return "" + } + + heldFor := time.Since(time.UnixMilli(entryTime)) + if heldFor < 0 { + heldFor = 0 + } + if heldFor >= autopilotMinHoldDuration { + if heldFor >= autopilotNoiseCloseHoldDuration || + pnlPct <= noiseCloseLossFloorPct || + pnlPct >= noiseCloseProfitCeilingPct { + return "" + } + + remaining := autopilotNoiseCloseHoldDuration - heldFor + return fmt.Sprintf( + "trade throttle: %s %s has been held for %s with PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close", + symbol, + side, + roundDuration(heldFor), + pnlPct, + noiseCloseLossFloorPct, + noiseCloseProfitCeilingPct, + roundDuration(remaining), + ) + } + + // Do not block true risk exits or unusually strong take-profit exits. + if pnlPct <= earlyCloseStopLossBypassPct || pnlPct >= earlyCloseTakeProfitBypassPct { + return "" + } + + remaining := autopilotMinHoldDuration - heldFor + return fmt.Sprintf( + "trade throttle: %s %s has only been held for %s with PnL %.2f%%; min AI-managed hold is %s unless loss <= %.1f%% or profit >= %.1f%%", + symbol, + side, + roundDuration(heldFor), + pnlPct, + roundDuration(autopilotMinHoldDuration), + earlyCloseStopLossBypassPct, + earlyCloseTakeProfitBypassPct, + ) + fmt.Sprintf("; wait about %s", roundDuration(remaining)) +} + +func findContextPosition(ctx *kernel.Context, symbol string, side string) *kernel.PositionInfo { + if ctx == nil { + return nil + } + for i := range ctx.Positions { + pos := &ctx.Positions[i] + if normalizedDecisionSymbol(pos.Symbol) == symbol && strings.EqualFold(pos.Side, side) { + return pos + } + } + return nil +} + +func findAnyContextPosition(ctx *kernel.Context, symbol string) *kernel.PositionInfo { + if ctx == nil { + return nil + } + for i := range ctx.Positions { + pos := &ctx.Positions[i] + if normalizedDecisionSymbol(pos.Symbol) == symbol { + return pos + } + } + return nil +} + +func (at *AutoTrader) recentOrders(limit int) ([]*store.TraderOrder, error) { + if at == nil || at.store == nil { + return nil, nil + } + return at.store.Order().GetTraderOrders(at.id, limit) +} + +func (at *AutoTrader) countRecentOpenOrders(since time.Time) (int, error) { + orders, err := at.recentOrders(100) + if err != nil { + return 0, err + } + sinceMs := since.UTC().UnixMilli() + count := 0 + for _, order := range orders { + if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) { + continue + } + if isOpenAction(order.OrderAction) { + count++ + } + } + return count, nil +} + +func (at *AutoTrader) findRecentCloseOrder(symbol string, since time.Time) *store.TraderOrder { + orders, err := at.recentOrders(100) + if err != nil { + at.logWarnf("⚠️ Trade throttle could not read recent close orders: %v", err) + return nil + } + sinceMs := since.UTC().UnixMilli() + for _, order := range orders { + if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) { + continue + } + if normalizedDecisionSymbol(order.Symbol) == symbol && isCloseAction(order.OrderAction) { + return order + } + } + return nil +} + +func (at *AutoTrader) findRecentOpenOrder(symbol string, side string, since time.Time) *store.TraderOrder { + orders, err := at.recentOrders(100) + if err != nil { + at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err) + return nil + } + sinceMs := since.UTC().UnixMilli() + for _, order := range orders { + if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) { + continue + } + if normalizedDecisionSymbol(order.Symbol) == symbol && + strings.EqualFold(openActionSide(order.OrderAction), side) { + return order + } + } + return nil +} + +func isCanceledOrder(order *store.TraderOrder) bool { + status := strings.ToUpper(strings.TrimSpace(order.Status)) + return status == "CANCELED" || status == "CANCELLED" || status == "REJECTED" || status == "EXPIRED" +} + +func roundDuration(d time.Duration) string { + if d < time.Minute { + return "0m" + } + return d.Round(time.Minute).String() +} diff --git a/trader/auto_trader_throttle_test.go b/trader/auto_trader_throttle_test.go new file mode 100644 index 00000000..2c7717dd --- /dev/null +++ b/trader/auto_trader_throttle_test.go @@ -0,0 +1,81 @@ +package trader + +import ( + "nofx/kernel" + "strings" + "testing" + "time" +) + +func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context { + return &kernel.Context{ + Positions: []kernel.PositionInfo{ + { + Symbol: symbol, + Side: side, + UnrealizedPnLPct: pnlPct, + UpdateTime: time.Now().Add(-heldFor).UnixMilli(), + }, + }, + } +} + +func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) { + at := &AutoTrader{} + ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -0.3) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if !strings.Contains(reason, "min AI-managed hold") { + t.Fatalf("expected early close to be blocked by min hold, got %q", reason) + } +} + +func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) { + at := &AutoTrader{} + ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if reason != "" { + t.Fatalf("expected hard stop close to pass, got %q", reason) + } +} + +func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) { + at := &AutoTrader{} + ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if !strings.Contains(reason, "noise band") { + t.Fatalf("expected flat close to be blocked inside noise window, got %q", reason) + } +} + +func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) { + at := &AutoTrader{} + ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if reason != "" { + t.Fatalf("expected confirmed loss after min hold to pass, got %q", reason) + } +} + +func TestTradeThrottleBlocksSecondOpenInCycle(t *testing.T) { + at := &AutoTrader{} + ctx := &kernel.Context{} + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1) + if !strings.Contains(reason, "only 1 new position") { + t.Fatalf("expected second open in cycle to be blocked, got %q", reason) + } +} + +func TestTradeThrottleBlocksOpeningAgainstExistingPosition(t *testing.T) { + at := &AutoTrader{} + ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 1.0) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 0) + if !strings.Contains(reason, "already has an open") { + t.Fatalf("expected opposite open to be blocked when position exists, got %q", reason) + } +} diff --git a/web/src/components/agent/AI500Panel.tsx b/web/src/components/agent/AI500Panel.tsx deleted file mode 100644 index aef087ce..00000000 --- a/web/src/components/agent/AI500Panel.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useEffect, useState } from 'react' -import { Sparkles } from 'lucide-react' -import { api } from '../../lib/api' -import type { AI500Coin } from '../../lib/api/data' - -interface AI500PanelProps { - language: string - disabled?: boolean - onAnalyzeSymbol: (coin: AI500Coin) => void -} - -const REFRESH_INTERVAL_MS = 5 * 60 * 1000 // matches the backend cache TTL - -function formatGain(value?: number) { - const n = Number(value || 0) - if (!Number.isFinite(n) || n === 0) return '—' - return `${n > 0 ? '+' : ''}${n.toFixed(2)}%` -} - -function scoreColor(score: number) { - if (score >= 80) return '#0ECB81' - if (score >= 60) return '#F0B90B' - return '#848E9C' -} - -export function AI500Panel({ language, disabled, onAnalyzeSymbol }: AI500PanelProps) { - const [coins, setCoins] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - let cancelled = false - const load = () => { - api - .getAI500List(20) - .then((res) => { - if (cancelled) return - setCoins(res.coins || []) - setError('') - }) - .catch((err) => { - if (cancelled) return - setError(err?.message || 'Failed to load AI500') - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - } - load() - const timer = setInterval(load, REFRESH_INTERVAL_MS) - return () => { - cancelled = true - clearInterval(timer) - } - }, []) - - if (loading) { - return ( -
- {language === 'zh' ? '正在加载 AI500 榜单…' : 'Loading AI500 board…'} -
- ) - } - - if (error) { - return ( -
- {language === 'zh' ? 'AI500 榜单加载失败:' : 'Failed to load AI500: '} - {error} -
- ) - } - - if (coins.length === 0) { - return ( -
- {language === 'zh' ? '当前没有符合条件的 AI500 标的。' : 'No AI500 constituents right now.'} -
- ) - } - - return ( -
-
- - {language === 'zh' - ? 'AI 评分精选 · 点击标的让 Agent 分析' - : 'AI-scored picks · click to ask the agent'} -
-
- {coins.map((coin, idx) => { - const display = coin.pair.replace(/USDT$/i, '') - const gain = Number(coin.increase_percent || 0) - return ( - - ) - })} -
-
- ) -} diff --git a/web/src/components/agent/AgentStepPanel.tsx b/web/src/components/agent/AgentStepPanel.tsx deleted file mode 100644 index fd0c3715..00000000 --- a/web/src/components/agent/AgentStepPanel.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type { AgentStep } from '../../types/agent' -import { useLanguage } from '../../contexts/LanguageContext' - -interface AgentStepPanelProps { - steps?: AgentStep[] - visible?: boolean -} - -const statusStyles: Record = { - planning: { dot: '#7c3aed', text: '#c4b5fd' }, - pending: { dot: 'rgba(255,255,255,0.18)', text: '#818198' }, - running: { dot: '#F0B90B', text: '#f6d67a' }, - completed: { dot: '#00e5a0', text: '#9cf5d5' }, - replanned: { dot: '#38bdf8', text: '#9bdcf7' }, -} - -// Map raw backend tool names to friendly user-facing labels. -// Backend emits `step.label` like `tool:get_positions` and we render that as -// "📊 Checking your positions" instead of hiding it from the user. -const toolLabels: Record = { - // Read-only state - get_positions: { zh: '📊 检查持仓', en: '📊 Checking positions', id: '📊 Memeriksa posisi' }, - get_balance: { zh: '💰 查余额', en: '💰 Reading balance', id: '💰 Membaca saldo' }, - get_trade_history: { zh: '📜 查交易历史', en: '📜 Reading trade history', id: '📜 Membaca riwayat' }, - get_decisions: { zh: '🤖 查 AI 决策记录', en: '🤖 Reading AI decisions', id: '🤖 Membaca keputusan AI' }, - get_strategies: { zh: '📋 查策略列表', en: '📋 Listing strategies', id: '📋 Daftar strategi' }, - get_candidate_coins: { zh: '🎯 查标的池', en: '🎯 Reading candidate pool', id: '🎯 Kandidat' }, - get_exchange_configs: { zh: '🔌 查交易所配置', en: '🔌 Reading exchanges', id: '🔌 Bursa' }, - get_model_configs: { zh: '🧠 查 AI 模型', en: '🧠 Reading AI models', id: '🧠 Model AI' }, - get_preferences: { zh: '⚙️ 查偏好', en: '⚙️ Reading preferences', id: '⚙️ Preferensi' }, - get_backend_logs: { zh: '🪵 查后台日志', en: '🪵 Reading logs', id: '🪵 Membaca log' }, - get_watchlist: { zh: '👁 查关注列表', en: '👁 Reading watchlist', id: '👁 Membaca watchlist' }, - - // Market data - search_stock: { zh: '🔍 搜索股票', en: '🔍 Searching stocks', id: '🔍 Mencari saham' }, - get_market_price: { zh: '📈 查实时价格', en: '📈 Fetching price', id: '📈 Mengambil harga' }, - get_market_snapshot: { zh: '📈 查市场快照', en: '📈 Reading market snapshot', id: '📈 Snapshot pasar' }, - get_kline: { zh: '📈 查 K 线', en: '📈 Reading candlesticks', id: '📈 Membaca candlestick' }, - - // Mutating - manage_trader: { zh: '🤖 管理 Trader', en: '🤖 Managing trader', id: '🤖 Mengelola trader' }, - manage_strategy: { zh: '📋 管理策略', en: '📋 Managing strategy', id: '📋 Mengelola strategi' }, - manage_exchange_config: { zh: '🔌 管理交易所', en: '🔌 Managing exchange', id: '🔌 Mengelola bursa' }, - manage_model_config: { zh: '🧠 管理 AI 模型', en: '🧠 Managing AI model', id: '🧠 Mengelola model' }, - manage_preferences: { zh: '⚙️ 更新偏好', en: '⚙️ Updating preferences', id: '⚙️ Memperbarui preferensi' }, - manage_watchlist: { zh: '👁 更新关注列表', en: '👁 Updating watchlist', id: '👁 Memperbarui watchlist' }, - execute_trade: { zh: '⚡ 准备下单', en: '⚡ Preparing trade', id: '⚡ Menyiapkan order' }, -} - -function friendlyStepLabel(rawLabel: string, lang: 'zh' | 'en' | 'id'): string { - const trimmed = rawLabel.trim() - if (trimmed.toLowerCase().startsWith('tool:')) { - const toolName = trimmed.slice(5).trim().toLowerCase() - const entry = toolLabels[toolName] - if (entry) return entry[lang] - // Unknown tool — surface a generic but still informative label - const generic = { - zh: `🔧 调用 ${toolName}`, - en: `🔧 Calling ${toolName}`, - id: `🔧 Memanggil ${toolName}`, - } - return generic[lang] - } - return rawLabel -} - -export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) { - const { language } = useLanguage() - const lang = (language === 'zh' || language === 'id' ? language : 'en') as - | 'zh' - | 'en' - | 'id' - - if (!visible || !steps || steps.length === 0) { - return null - } - - // Drop only the internal-routing chatter (central_brain); keep tool steps — - // they are exactly what the user wants to see ("agent is actually doing something"). - const visibleSteps = steps.filter((step) => { - const detail = (step.detail || '').trim().toLowerCase() - return detail !== 'central_brain' - }) - - if (visibleSteps.length === 0) { - return null - } - - const liveRunHeading = lang === 'zh' ? 'AGENT 实时动作' : lang === 'id' ? 'AKSI AGENT' : 'LIVE RUN' - - return ( -
-
- {liveRunHeading} -
-
- {visibleSteps.map((step) => { - const style = statusStyles[step.status] - const label = friendlyStepLabel(step.label, lang) - return ( -
- -
-
- {label} -
- {step.detail && step.detail.trim().toLowerCase() !== 'central_brain' && ( -
- {step.detail} -
- )} -
-
- ) - })} -
-
- ) -} diff --git a/web/src/components/agent/ChatInput.tsx b/web/src/components/agent/ChatInput.tsx deleted file mode 100644 index dfc83993..00000000 --- a/web/src/components/agent/ChatInput.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { - useRef, - useState, - useCallback, - useEffect, - useImperativeHandle, - forwardRef, -} from 'react' -import { ArrowUp, Square } from 'lucide-react' - -export interface ChatInputHandle { - focus: () => void - clear: () => void - getValue: () => string -} - -interface ChatInputProps { - language: string - loading: boolean - value: string - onChange: (value: string) => void - onSend: (text: string) => void - onStop: () => void -} - -export const ChatInput = forwardRef( - function ChatInput( - { language, loading, value, onChange, onSend, onStop }, - ref - ) { - const [composing, setComposing] = useState(false) - const inputRef = useRef(null) - - useImperativeHandle( - ref, - () => ({ - focus: () => inputRef.current?.focus(), - clear: () => { - onChange('') - if (inputRef.current) inputRef.current.style.height = 'auto' - }, - getValue: () => value, - }), - [onChange, value] - ) - - const resizeInput = useCallback(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = Math.min(el.scrollHeight, 150) + 'px' - }, []) - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - onChange(e.target.value) - }, - [onChange] - ) - - const handleSend = () => { - const msg = value.trim() - if (!msg || loading) return - onChange('') - if (inputRef.current) inputRef.current.style.height = 'auto' - onSend(msg) - inputRef.current?.focus() - } - - useEffect(() => { - resizeInput() - }, [resizeInput, value]) - - // Keyboard shortcut: Cmd+K to focus - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault() - inputRef.current?.focus() - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, []) - - return ( -
-
-