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