From 4cadf6f44274e716da248430116c0c4fb52cbcfa Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sat, 25 Apr 2026 11:48:37 +0800 Subject: [PATCH] fix(agent,claw402): harden agent runtime and strip max_tokens for thinking models - Fix Stop() race condition using sync.Once - Add ensureHistory() to prevent nil panic in planner/dispatcher - Add bounds check on trader ID slicing - Log saveExecutionState and clearSetupState errors instead of discarding - Remove always-true modelID condition in onboard setup - Add Chinese setup keywords and expand model name aliases - Strip max_tokens from claw402 requests to avoid thinking-model budget exhaustion - Hide Agent nav tab (Beta) pending merge to main - Sync tests with code changes Co-Authored-By: Claude Opus 4.6 --- agent/agent.go | 35 +++++++++++---- agent/config_tools_test.go | 7 +-- agent/onboard.go | 10 ++--- agent/onboard_test.go | 5 ++- agent/planner_runtime.go | 28 ++++++++---- agent/skill_dispatcher.go | 4 +- agent/skill_execution_handlers.go | 57 ++++++++++++++++++++++++- mcp/payment/claw402.go | 20 ++++++++- web/src/components/common/HeaderBar.tsx | 22 +++++++++- 9 files changed, 153 insertions(+), 35 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 93f6351c..8eb51e95 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -14,6 +14,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "nofx/manager" @@ -34,6 +35,7 @@ type Agent struct { history *chatHistory pending *pendingTrades stopCh chan struct{} // signals background goroutines to stop + stopOnce sync.Once NotifyFunc func(userID int64, text string) error } @@ -62,6 +64,12 @@ func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.L 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 @@ -121,7 +129,19 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str apiKey := string(model.APIKey) customAPIURL := strings.TrimSpace(model.CustomAPIURL) modelName := strings.TrimSpace(model.CustomModelName) - customAPIURL, modelName = resolveModelRuntimeConfig(model.Provider, customAPIURL, modelName, model.ID) + 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) + return client, modelName, true + } + + customAPIURL, modelName = resolveModelRuntimeConfig(provider, customAPIURL, modelName, model.ID) if apiKey == "" || customAPIURL == "" { a.log().Warn( "enabled AI model is incomplete", @@ -201,12 +221,7 @@ func (a *Agent) Start() { 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) - } + a.stopOnce.Do(func() { close(a.stopCh) }) if a.sentinel != nil { a.sentinel.Stop() } @@ -689,7 +704,11 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) { if pnl < 0 { e = "🔴" } - sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, id[:8])) + tid := id + if len(tid) > 8 { + tid = tid[:8] + } + sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid)) } } if !hasAny { diff --git a/agent/config_tools_test.go b/agent/config_tools_test.go index 4cf717d7..7d6d89ef 100644 --- a/agent/config_tools_test.go +++ b/agent/config_tools_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - "nofx/mcp" "nofx/store" ) @@ -380,7 +379,9 @@ func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) { t.Fatalf("unexpected model name: %s", modelName) } - if _, ok := client.(*mcp.Client); !ok { - t.Fatalf("expected *mcp.Client, got %T", client) + // After the provider registry refactor, registered providers (like openai) + // return their own AIClient implementation, not *mcp.Client. + if client == nil { + t.Fatal("expected non-nil AI client from provider registry") } } diff --git a/agent/onboard.go b/agent/onboard.go index 17b6d597..d047dd82 100644 --- a/agent/onboard.go +++ b/agent/onboard.go @@ -87,7 +87,9 @@ func (a *Agent) saveSetupState(userID int64, s *SetupState) { func (a *Agent) clearSetupState(userID int64) { for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} { - a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), "") + if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil { + a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err) + } } } @@ -224,7 +226,7 @@ func isDirectSetupCommand(text string) bool { return false } switch text { - case "setup", "/setup": + case "setup", "/setup", "开始配置", "配置", "开始设置": return true default: return false @@ -500,9 +502,7 @@ func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, return "", err } - if modelID == state.AIProvider { - modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider) - } + modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider) return modelID, nil } diff --git a/agent/onboard_test.go b/agent/onboard_test.go index 0529650c..117055c3 100644 --- a/agent/onboard_test.go +++ b/agent/onboard_test.go @@ -9,12 +9,13 @@ func TestIsDirectSetupCommand(t *testing.T) { }{ {text: "setup", want: true}, {text: "/setup", want: true}, - {text: "开始配置", want: false}, + {text: "开始配置", want: true}, + {text: "配置", want: true}, + {text: "开始设置", want: true}, {text: "/开始配置", want: false}, {text: "创建全新的配置,杠杆你定", want: false}, {text: "帮我配置一个 deepseek 模型", want: false}, {text: "绑定交易所 okx", want: false}, - {text: "配置", want: false}, } for _, tc := range cases { diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 5db56a64..ea319181 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -468,9 +468,7 @@ func (a *Agent) tryReadFastPath(storeUserID string, userID int64, lang, text str if req == nil { return "", false } - if a.history == nil { - a.history = newChatHistory(100) - } + a.ensureHistory() a.history.Add(userID, "user", text) raw := a.executeReadFastPath(storeUserID, userID, req) @@ -758,6 +756,10 @@ func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int6 if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok { return answer, nil } + // Check setup flow before falling back to noAI — handles "开始配置", "setup", etc. + if reply, handled := a.handleSetupFlowForStoreUser(storeUserID, userID, text, lang); handled { + return reply, nil + } if a.aiClient == nil { return a.noAIFallback(lang, text) } @@ -787,6 +789,13 @@ func (a *Agent) thinkAndActStream(ctx context.Context, storeUserID string, userI if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok { return answer, nil } + // Check setup flow before falling back to noAI — handles "开始配置", "setup", etc. + if reply, handled := a.handleSetupFlowForStoreUser(storeUserID, userID, text, lang); handled { + if onEvent != nil { + onEvent(StreamEventDelta, reply) + } + return reply, nil + } if a.aiClient == nil { return a.noAIFallback(lang, text) } @@ -1256,9 +1265,7 @@ Return JSON with this exact shape: return "", false } - if a.history == nil { - a.history = newChatHistory(100) - } + a.ensureHistory() a.history.Add(userID, "user", text) a.history.Add(userID, "assistant", answer) a.maybeUpdateTaskStateIncrementally(ctx, userID) @@ -1297,6 +1304,7 @@ func normalizeDirectReplyDecision(decision directReplyDecision) directReplyDecis } func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) { + a.ensureHistory() a.history.Add(userID, "user", text) if onEvent != nil { onEvent(StreamEventPlanning, a.planningStatusText(lang)) @@ -1797,7 +1805,9 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6 CreatedAt: time.Now().UTC().Format(time.RFC3339), }) referencesChanged = updateCurrentReferencesFromToolResult(state, step.ToolName, result) - _ = referencesChanged + if referencesChanged { + a.log().Info("tool step updated references", "tool", step.ToolName, "session", state.SessionID) + } case planStepTypeReason: reasonStartedAt := time.Now() reasoning, err := a.executeReasonStep(ctx, userID, lang, state.Goal, *state, *step) @@ -1807,7 +1817,9 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6 step.Error = err.Error() state.Status = executionStatusFailed state.LastError = err.Error() - _ = a.saveExecutionState(*state) + if saveErr := a.saveExecutionState(*state); saveErr != nil { + a.log().Warn("failed to save execution state after reason step error", "error", saveErr) + } return "", err } step.Status = planStepStatusCompleted diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go index 96c2a71f..db3ee326 100644 --- a/agent/skill_dispatcher.go +++ b/agent/skill_dispatcher.go @@ -661,9 +661,7 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int } func (a *Agent) recordSkillInteraction(userID int64, userText, answer string) { - if a.history == nil { - a.history = newChatHistory(100) - } + a.ensureHistory() a.history.Add(userID, "user", userText) a.history.Add(userID, "assistant", answer) } diff --git a/agent/skill_execution_handlers.go b/agent/skill_execution_handlers.go index 98db45cd..6b6930fa 100644 --- a/agent/skill_execution_handlers.go +++ b/agent/skill_execution_handlers.go @@ -677,8 +677,8 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l if apiKey := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); apiKey != "" { setField(&session, "api_key", apiKey) } - if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成"}); modelName != "" { - setField(&session, "custom_model_name", modelName) + if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成", "改为", "修改为", "换成", "换到", "切换为", "切换到", "change to", "switch to"}); modelName != "" { + setField(&session, "custom_model_name", normalizeModelName(modelName)) } if value := fieldValue(session, "custom_api_url"); value != "" { payload["custom_api_url"] = value @@ -749,6 +749,59 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l } } +// normalizeModelName maps common user-friendly model aliases to the canonical +// names used by claw402 and other providers (e.g. "claude opus4.6" → "claude-opus"). +func normalizeModelName(name string) string { + lower := strings.ToLower(strings.TrimSpace(name)) + aliases := map[string]string{ + // Claude + "claude opus": "claude-opus", + "claude opus4.6": "claude-opus", + "claude opus 4.6": "claude-opus", + "claude-opus-4-6": "claude-opus", + "claude sonnet": "claude-sonnet", + "claude sonnet4.6": "claude-sonnet", + "claude sonnet 4.6": "claude-sonnet", + "claude haiku": "claude-haiku", + // GPT + "gpt5.4": "gpt-5.4", + "gpt 5.4": "gpt-5.4", + "gpt5.4pro": "gpt-5.4-pro", + "gpt 5.4pro": "gpt-5.4-pro", + "gpt 5.4 pro": "gpt-5.4-pro", + "gpt5 mini": "gpt-5-mini", + "gpt 5 mini": "gpt-5-mini", + "gpt5.3": "gpt-5.3", + "gpt 5.3": "gpt-5.3", + // DeepSeek + "deepseek reasoner": "deepseek-reasoner", + "deepseek chat": "deepseek-chat", + // Qwen (通义千问) + "qwen max": "qwen-max", + "qwen plus": "qwen-plus", + "qwen turbo": "qwen-turbo", + "qwen flash": "qwen-flash", + "通义千问": "qwen-max", + // Gemini + "gemini 3.1 pro": "gemini-3.1-pro", + "gemini 3.1pro": "gemini-3.1-pro", + // Kimi + "kimi k2.5": "kimi-k2.5", + // GLM (智谱清言) + "glm5": "glm-5", + "glm 5": "glm-5", + "glm5 turbo": "glm-5-turbo", + "glm 5 turbo": "glm-5-turbo", + "glm5-turbo": "glm-5-turbo", + "智谱清言": "glm-5", + } + if canonical, ok := aliases[lower]; ok { + return canonical + } + // Replace spaces with hyphens as a general fallback + return strings.ReplaceAll(strings.TrimSpace(name), " ", "-") +} + func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string { switch session.Action { case "query", "query_list": diff --git a/mcp/payment/claw402.go b/mcp/payment/claw402.go index 871cc765..38edbb6b 100644 --- a/mcp/payment/claw402.go +++ b/mcp/payment/claw402.go @@ -225,18 +225,34 @@ func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) { // ── Format overrides for Anthropic endpoints ───────────────────────────────── +// stripMaxTokens removes per-call max_tokens caps from a body destined for +// claw402. The gateway already enforces a per-route default/floor/cap +// (see providers/*.yaml token_default_max_out / token_min_max_out / +// token_max_out_cap). Sending a small max_tokens here on a thinking model +// (Kimi K2.5, DeepSeek R1/V4) caused reasoning tokens to consume the entire +// budget and left `delta.content` empty, surfacing as "no content received". +// upto settles on real usage, so removing the cap costs nothing extra. +func stripMaxTokens(body map[string]any) map[string]any { + if body == nil { + return body + } + delete(body, "max_tokens") + delete(body, "max_completion_tokens") + return body +} + func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { if c.claudeProxy != nil { return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt) } - return c.Client.BuildMCPRequestBody(systemPrompt, userPrompt) + return stripMaxTokens(c.Client.BuildMCPRequestBody(systemPrompt, userPrompt)) } func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any { if c.claudeProxy != nil { return c.claudeProxy.BuildRequestBodyFromRequest(req) } - return c.Client.BuildRequestBodyFromRequest(req) + return stripMaxTokens(c.Client.BuildRequestBodyFromRequest(req)) } func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) { diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx index 7d719d41..cb4badad 100644 --- a/web/src/components/common/HeaderBar.tsx +++ b/web/src/components/common/HeaderBar.tsx @@ -108,12 +108,16 @@ export default function HeaderBar({ path: string label: string requiresAuth: boolean + badge?: string + hidden?: boolean }[] = [ { page: 'agent', path: ROUTES.agent, label: 'Agent', requiresAuth: false, + badge: 'Beta', + hidden: true, }, { page: 'data', @@ -182,7 +186,7 @@ export default function HeaderBar({ navigateInApp(tab.path) } - return navTabs.map((tab) => ( + return navTabs.filter((tab) => !tab.hidden).map((tab) => ( )) })()} @@ -436,12 +445,16 @@ export default function HeaderBar({ path: string label: string requiresAuth: boolean + badge?: string + hidden?: boolean }[] = [ { page: 'agent', path: ROUTES.agent, label: 'Agent', requiresAuth: false, + badge: 'Beta', + hidden: true, }, { page: 'data', @@ -510,7 +523,7 @@ export default function HeaderBar({ setMobileMenuOpen(false) } - return navTabs.map((tab, i) => ( + return navTabs.filter((tab) => !tab.hidden).map((tab, i) => ( )} {tab.label} + {tab.badge && ( + + {tab.badge} + + )} {tab.requiresAuth && !isLoggedIn && ( LOGIN_REQ