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"
"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 {
+4 -3
View File
@@ -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")
}
}
+4 -4
View File
@@ -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)
}
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: "开始配置", 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 {
+20 -8
View File
@@ -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
+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) {
if a.history == nil {
a.history = newChatHistory(100)
}
a.ensureHistory()
a.history.Add(userID, "user", userText)
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 != "" {
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":
+18 -2
View File
@@ -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) {
+20 -2
View File
@@ -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) => (
<button
key={tab.page}
onClick={() => handleNavClick(tab)}
@@ -193,6 +197,11 @@ export default function HeaderBar({
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
)}
{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>
))
})()}
@@ -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) => (
<motion.button
key={tab.page}
initial={{ x: -20, opacity: 0 }}
@@ -527,6 +540,11 @@ export default function HeaderBar({
/>
)}
{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 && (
<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