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 <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-04-25 11:48:37 +08:00
parent 5dbe32d884
commit 4cadf6f442
9 changed files with 153 additions and 35 deletions
+27 -8
View File
@@ -14,6 +14,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"nofx/manager" "nofx/manager"
@@ -34,6 +35,7 @@ type Agent struct {
history *chatHistory history *chatHistory
pending *pendingTrades pending *pendingTrades
stopCh chan struct{} // signals background goroutines to stop stopCh chan struct{} // signals background goroutines to stop
stopOnce sync.Once
NotifyFunc func(userID int64, text string) error 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) 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 { func (a *Agent) log() *slog.Logger {
if a != nil && a.logger != nil { if a != nil && a.logger != nil {
return a.logger return a.logger
@@ -121,7 +129,19 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str
apiKey := string(model.APIKey) apiKey := string(model.APIKey)
customAPIURL := strings.TrimSpace(model.CustomAPIURL) customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName) 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 == "" { if apiKey == "" || customAPIURL == "" {
a.log().Warn( a.log().Warn(
"enabled AI model is incomplete", "enabled AI model is incomplete",
@@ -201,12 +221,7 @@ func (a *Agent) Start() {
func (a *Agent) Stop() { func (a *Agent) Stop() {
// Signal all background goroutines (e.g. chat-history-cleanup) to exit. // Signal all background goroutines (e.g. chat-history-cleanup) to exit.
select { a.stopOnce.Do(func() { close(a.stopCh) })
case <-a.stopCh:
// Already closed
default:
close(a.stopCh)
}
if a.sentinel != nil { if a.sentinel != nil {
a.sentinel.Stop() a.sentinel.Stop()
} }
@@ -689,7 +704,11 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) {
if pnl < 0 { if pnl < 0 {
e = "🔴" 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 { if !hasAny {
+4 -3
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"testing" "testing"
"nofx/mcp"
"nofx/store" "nofx/store"
) )
@@ -380,7 +379,9 @@ func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
t.Fatalf("unexpected model name: %s", modelName) t.Fatalf("unexpected model name: %s", modelName)
} }
if _, ok := client.(*mcp.Client); !ok { // After the provider registry refactor, registered providers (like openai)
t.Fatalf("expected *mcp.Client, got %T", client) // return their own AIClient implementation, not *mcp.Client.
if client == nil {
t.Fatal("expected non-nil AI client from provider registry")
} }
} }
+5 -5
View File
@@ -87,7 +87,9 @@ func (a *Agent) saveSetupState(userID int64, s *SetupState) {
func (a *Agent) clearSetupState(userID int64) { 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"} { 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 return false
} }
switch text { switch text {
case "setup", "/setup": case "setup", "/setup", "开始配置", "配置", "开始设置":
return true return true
default: default:
return false return false
@@ -500,9 +502,7 @@ func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string,
return "", err 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 return modelID, nil
} }
+3 -2
View File
@@ -9,12 +9,13 @@ func TestIsDirectSetupCommand(t *testing.T) {
}{ }{
{text: "setup", want: true}, {text: "setup", want: true},
{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: "创建全新的配置,杠杆你定", want: false}, {text: "创建全新的配置,杠杆你定", want: false},
{text: "帮我配置一个 deepseek 模型", want: false}, {text: "帮我配置一个 deepseek 模型", want: false},
{text: "绑定交易所 okx", want: false}, {text: "绑定交易所 okx", want: false},
{text: "配置", want: false},
} }
for _, tc := range cases { for _, tc := range cases {
+20 -8
View File
@@ -468,9 +468,7 @@ func (a *Agent) tryReadFastPath(storeUserID string, userID int64, lang, text str
if req == nil { if req == nil {
return "", false return "", false
} }
if a.history == nil { a.ensureHistory()
a.history = newChatHistory(100)
}
a.history.Add(userID, "user", text) a.history.Add(userID, "user", text)
raw := a.executeReadFastPath(storeUserID, userID, req) 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 { if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok {
return answer, nil 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 { if a.aiClient == nil {
return a.noAIFallback(lang, text) 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 { if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok {
return answer, nil 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 { if a.aiClient == nil {
return a.noAIFallback(lang, text) return a.noAIFallback(lang, text)
} }
@@ -1256,9 +1265,7 @@ Return JSON with this exact shape:
return "", false return "", false
} }
if a.history == nil { a.ensureHistory()
a.history = newChatHistory(100)
}
a.history.Add(userID, "user", text) a.history.Add(userID, "user", text)
a.history.Add(userID, "assistant", answer) a.history.Add(userID, "assistant", answer)
a.maybeUpdateTaskStateIncrementally(ctx, userID) 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) { 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) a.history.Add(userID, "user", text)
if onEvent != nil { if onEvent != nil {
onEvent(StreamEventPlanning, a.planningStatusText(lang)) 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), CreatedAt: time.Now().UTC().Format(time.RFC3339),
}) })
referencesChanged = updateCurrentReferencesFromToolResult(state, step.ToolName, result) referencesChanged = updateCurrentReferencesFromToolResult(state, step.ToolName, result)
_ = referencesChanged if referencesChanged {
a.log().Info("tool step updated references", "tool", step.ToolName, "session", state.SessionID)
}
case planStepTypeReason: case planStepTypeReason:
reasonStartedAt := time.Now() reasonStartedAt := time.Now()
reasoning, err := a.executeReasonStep(ctx, userID, lang, state.Goal, *state, *step) 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() step.Error = err.Error()
state.Status = executionStatusFailed state.Status = executionStatusFailed
state.LastError = err.Error() 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 return "", err
} }
step.Status = planStepStatusCompleted step.Status = planStepStatusCompleted
+1 -3
View File
@@ -661,9 +661,7 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int
} }
func (a *Agent) recordSkillInteraction(userID int64, userText, answer string) { func (a *Agent) recordSkillInteraction(userID int64, userText, answer string) {
if a.history == nil { a.ensureHistory()
a.history = newChatHistory(100)
}
a.history.Add(userID, "user", userText) a.history.Add(userID, "user", userText)
a.history.Add(userID, "assistant", answer) a.history.Add(userID, "assistant", answer)
} }
+55 -2
View File
@@ -677,8 +677,8 @@ func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, l
if apiKey := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); apiKey != "" { if apiKey := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); apiKey != "" {
setField(&session, "api_key", apiKey) setField(&session, "api_key", apiKey)
} }
if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成"}); modelName != "" { if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成", "改为", "修改为", "换成", "换到", "切换为", "切换到", "change to", "switch to"}); modelName != "" {
setField(&session, "custom_model_name", modelName) setField(&session, "custom_model_name", normalizeModelName(modelName))
} }
if value := fieldValue(session, "custom_api_url"); value != "" { if value := fieldValue(session, "custom_api_url"); value != "" {
payload["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 { func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
switch session.Action { switch session.Action {
case "query", "query_list": case "query", "query_list":
+18 -2
View File
@@ -225,18 +225,34 @@ func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
// ── Format overrides for Anthropic endpoints ───────────────────────────────── // ── 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 { func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
if c.claudeProxy != nil { if c.claudeProxy != nil {
return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt) 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 { func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
if c.claudeProxy != nil { if c.claudeProxy != nil {
return c.claudeProxy.BuildRequestBodyFromRequest(req) return c.claudeProxy.BuildRequestBodyFromRequest(req)
} }
return c.Client.BuildRequestBodyFromRequest(req) return stripMaxTokens(c.Client.BuildRequestBodyFromRequest(req))
} }
func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) { func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) {
+20 -2
View File
@@ -108,12 +108,16 @@ export default function HeaderBar({
path: string path: string
label: string label: string
requiresAuth: boolean requiresAuth: boolean
badge?: string
hidden?: boolean
}[] = [ }[] = [
{ {
page: 'agent', page: 'agent',
path: ROUTES.agent, path: ROUTES.agent,
label: 'Agent', label: 'Agent',
requiresAuth: false, requiresAuth: false,
badge: 'Beta',
hidden: true,
}, },
{ {
page: 'data', page: 'data',
@@ -182,7 +186,7 @@ export default function HeaderBar({
navigateInApp(tab.path) navigateInApp(tab.path)
} }
return navTabs.map((tab) => ( return navTabs.filter((tab) => !tab.hidden).map((tab) => (
<button <button
key={tab.page} key={tab.page}
onClick={() => handleNavClick(tab)} onClick={() => handleNavClick(tab)}
@@ -193,6 +197,11 @@ export default function HeaderBar({
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" /> <span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
)} )}
{tab.label} {tab.label}
{tab.badge && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-top relative -top-1">
{tab.badge}
</span>
)}
</button> </button>
)) ))
})()} })()}
@@ -436,12 +445,16 @@ export default function HeaderBar({
path: string path: string
label: string label: string
requiresAuth: boolean requiresAuth: boolean
badge?: string
hidden?: boolean
}[] = [ }[] = [
{ {
page: 'agent', page: 'agent',
path: ROUTES.agent, path: ROUTES.agent,
label: 'Agent', label: 'Agent',
requiresAuth: false, requiresAuth: false,
badge: 'Beta',
hidden: true,
}, },
{ {
page: 'data', page: 'data',
@@ -510,7 +523,7 @@ export default function HeaderBar({
setMobileMenuOpen(false) setMobileMenuOpen(false)
} }
return navTabs.map((tab, i) => ( return navTabs.filter((tab) => !tab.hidden).map((tab, i) => (
<motion.button <motion.button
key={tab.page} key={tab.page}
initial={{ x: -20, opacity: 0 }} initial={{ x: -20, opacity: 0 }}
@@ -527,6 +540,11 @@ export default function HeaderBar({
/> />
)} )}
{tab.label} {tab.label}
{tab.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-middle relative -top-1">
{tab.badge}
</span>
)}
{tab.requiresAuth && !isLoggedIn && ( {tab.requiresAuth && !isLoggedIn && (
<span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1"> <span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1">
LOGIN_REQ LOGIN_REQ