diff --git a/agent/agent.go b/agent/agent.go index 0b1d1e21..12b50136 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -619,16 +619,15 @@ func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string { - **你不知道用户持有什么股票/币种,除非工具返回了数据** - 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓" -## 行为准则 -- 把用户当交易小白,而不是开发者或量化工程师。 -- 先说结论,再说原因和下一步。 -- 语言要简单、清楚、直接,少用术语。 -- 如果必须用术语,立刻用大白话解释。 -- 简洁、专业、有观点。不说废话。 -- 用户问什么答什么,不要推销配置。 -- 有实时数据时给具体价位,没有时给策略框架和思路。 +## 行为准则(最高优先级) +- **用户问什么就只答什么** — 问余额只说余额,问持仓只说持仓,问价格只说价格。不要把 System Context 里的其他数据也一起输出。 +- **System Context 是参考资料,不是输出模板** — 里面有很多实时数据,但你只用跟用户问题直接相关的那部分。 +- **回复要短** — 能一句话说清就不要写一段。不要用表格、分隔线、标题,除非数据需要对比。 +- **不要主动推销** — 不要列"下一步建议"、"需要我帮你做什么",除非用户主动问。数据为空就一句话说明原因。 +- **不要重复自我介绍** — 除非用户首次问"你是谁/你能做什么"。 +- 把用户当交易小白,语言简单直接。 +- 先说结论,再说原因。 - **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。绝不编造。 -- 用交易相关的 emoji 让回复更直观。 - 用中文回复。 当前时间: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05")) @@ -708,16 +707,15 @@ You can call these tools to take action: - **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 -- Treat the user like a trading beginner, not a developer. -- Lead with the conclusion first, then explain the reason and next step. -- Use plain language and keep jargon to a minimum. -- If you must use a technical term, explain it in simple words immediately. -- Concise, professional, opinionated. No fluff. -- Answer what's asked. Don't push setup. -- With real-time data: give specific levels. Without: give strategy frameworks. +## Behavior (HIGHEST PRIORITY) +- **Answer ONLY what the user asked** — if they ask balance, only say balance. If they ask positions, only say positions. Do not dump other System Context data. +- **System Context is reference material, not output template** — it has lots of real-time data, but only use what is directly relevant to the user's question. +- **Keep it short** — if you can say it in one sentence, don't write a paragraph. No tables, dividers, or headers unless data needs comparison. +- **Don't upsell** — don't list "next step suggestions" or "want me to help?" unless the user explicitly asks. If data is empty, one sentence explaining why. +- **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. +- Lead with the conclusion, then the reason. - **Honesty is rule #1** — uncertain = say uncertain, no data = say no data. -- Use trading emojis. Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05")) } diff --git a/agent/model_wallet_fastpath.go b/agent/model_wallet_fastpath.go index 6670defe..64b8a330 100644 --- a/agent/model_wallet_fastpath.go +++ b/agent/model_wallet_fastpath.go @@ -8,11 +8,19 @@ import ( func isModelWalletBalanceQuestion(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" || !strings.Contains(lower, "claw402") { + if lower == "" { return false } - return containsAny(lower, []string{"余额", "balance", "usdc"}) && - containsAny(lower, []string{"钱包", "wallet", "主钱包", "base"}) + // 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) { diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index cd005e17..ba390004 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -2712,6 +2712,11 @@ 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 @@ -2740,6 +2745,7 @@ Rules: - 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}]}` @@ -3715,19 +3721,19 @@ func (a *Agent) executeReasonStep(ctx context.Context, userID int64, lang, goal } func (a *Agent) generateFinalPlanResponse(ctx context.Context, storeUserID string, userID int64, lang string, state ExecutionState, instruction string) (string, error) { - obsJSON, _ := json.Marshal(buildObservationContext(state)) 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.NewSystemMessage("You are responding after a completed execution plan. Use the observations as the source of truth. Be concise and actionable."), - mcp.NewSystemMessage(cleanUserFacingReplyInstruction), - mcp.NewUserMessage(fmt.Sprintf("Goal: %s\nResponse instruction: %s\nObservations JSON: %s\nPersistent preferences: %s\nTask state: %s", state.Goal, instruction, string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))), + 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, }) @@ -3735,19 +3741,57 @@ func (a *Agent) generateFinalPlanResponse(ctx context.Context, storeUserID strin 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 的执行结果回复模块。 -只根据 Observations JSON 和已完成步骤回答用户。 -不要引入未观察到的策略、交易员、模型或交易所信息。 -不要承诺稍后通知;如果工具已经执行,直接说结果;如果工具失败,直接说失败原因和下一步。 -用中文,简洁清楚。` + return `你是 NOFXi,用户的 AI 交易伙伴。像朋友聊天一样回复。 + +严格规则: +- 只回答 Goal 问的那一件事。用户问余额就只说余额,问持仓就只说持仓,问钱包就只说钱包。 +- Tool results 里有很多数据,但你只用跟 Goal 直接相关的。其余的不要提。 +- 数据为空就说"暂时查不到"加一句原因,不要展开教程。 +- 不要输出表格、分隔线、markdown 标题,除非数据本身需要对比。 +- 不要列"下一步建议"或"需要我帮你做什么",除非用户主动问。 +- 回复尽量短,能一句话说清的不要写一段话。` } - return `You are NOFXi's execution-result response module. -Answer only from Observations JSON and completed steps. -Do not introduce unobserved strategy, trader, model, or exchange details. -Do not promise later notification; if a tool executed, state the result; if it failed, state the reason and next step. -Be concise and clear.` + 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) { @@ -3935,7 +3979,12 @@ func (a *Agent) thinkAndActLegacyWithStore(ctx context.Context, storeUserID stri } messages = append(messages, mcp.NewUserMessage(userPrompt)) - tools := agentTools() + // Use domain-filtered tools to reduce over-fetching; fall back to full set + // for "general" domain to preserve full functionality. + tools := plannerToolsForText(text) + if plannerToolDomainForText(text) == "general" { + tools = agentTools() + } const maxToolRounds = 5 for round := 0; round < maxToolRounds; round++ { diff --git a/agent/tools.go b/agent/tools.go index 981287db..f96f9369 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -70,7 +70,7 @@ func plannerToolDomainForText(text string) string { if hasExplicitManagementDomainCue(text, "trader") || containsAny(lower, []string{"交易员", "trader", "启动", "停止交易员", "扫描间隔", "竞技场"}) { return "trader" } - if containsAny(lower, []string{"余额", "资产", "仓位", "持仓", "订单", "成交", "交易历史", "balance", "position", "positions", "trade history", "account"}) { + 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"}) { @@ -84,7 +84,7 @@ func plannerToolNamesForDomain(domain string) []string { case "market": return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock"} case "account": - return []string{"get_balance", "get_positions", "get_trade_history"} + 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":