fix(agent): reduce verbose responses — focus answers on user's question only

Root cause: when planner fails (402 payment), legacy loop dumps all system
context to LLM which outputs everything. Also final response prompt was too
weak — LLM treated all observations as required output.

Changes:
- Strengthen system prompt: "answer ONLY what user asked", no tables/tutorials
  unless requested, no self-intro repeats, no "next step" suggestions
- Add compact observation summary for final response (step summaries only,
  no raw JSON blobs)
- Domain-filtered tool selection in legacy loop to prevent over-fetching
- Fix domain routing: "钱包/wallet" → account domain (not model), with
  exchange configs included for wallet context
- Widen wallet fast-path: no longer requires "claw402" keyword
- Anti-repetition instructions in planner step selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-05-11 21:12:48 +08:00
parent 9f25bf49bf
commit bf289e8eb3
4 changed files with 93 additions and 38 deletions

View File

@@ -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"))
}

View File

@@ -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) {

View File

@@ -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++ {

View File

@@ -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":