diff --git a/agent/config_validation.go b/agent/config_validation.go index 6c38481c..7c443caa 100644 --- a/agent/config_validation.go +++ b/agent/config_validation.go @@ -92,20 +92,21 @@ func (v exchangeConfigValidator) Validate() error { if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) { return fmt.Errorf("Secret format looks invalid") } - if exchangeType == "okx" && v.enabled && strings.TrimSpace(v.passphrase) == "" { - return fmt.Errorf("OKX requires passphrase before enabling this exchange config") - } - if exchangeType == "hyperliquid" && v.enabled && strings.TrimSpace(v.hyperliquidWalletAddr) == "" { - return fmt.Errorf("Hyperliquid requires wallet address before enabling this exchange config") - } - if exchangeType == "aster" && v.enabled { - if strings.TrimSpace(v.asterUser) == "" || strings.TrimSpace(v.asterSigner) == "" || strings.TrimSpace(v.asterPrivateKey) == "" { - return fmt.Errorf("Aster requires user, signer, and private key before enabling this exchange config") - } - } - if exchangeType == "lighter" && v.enabled { - if strings.TrimSpace(v.lighterWalletAddr) == "" || strings.TrimSpace(v.lighterAPIKeyPrivateKey) == "" { - return fmt.Errorf("Lighter requires wallet address and API key private key before enabling this exchange config") + if v.enabled { + missing := store.MissingRequiredExchangeCredentialFields( + exchangeType, + v.apiKey, + v.secretKey, + v.passphrase, + v.hyperliquidWalletAddr, + v.asterUser, + v.asterSigner, + v.asterPrivateKey, + v.lighterWalletAddr, + v.lighterAPIKeyPrivateKey, + ) + if len(missing) > 0 { + return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", ")) } } return nil diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go index 638dd9bc..a9b41485 100644 --- a/agent/config_visibility_test.go +++ b/agent/config_visibility_test.go @@ -112,30 +112,25 @@ func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testin } } -func TestToolManageExchangeConfigUpdateAutoEnablesWhenConfigBecomesComplete(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "exchange-update-auto-enable.db") +func TestToolManageExchangeConfigCreateRejectsIncompleteDraft(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-create-incomplete.db") st, err := store.New(dbPath) if err != nil { t.Fatalf("create store: %v", err) } a := New(nil, st, DefaultConfig(), slog.Default()) - exchangeID, err := st.Exchange().Create("default", "okx", "OKX Main", false, "api-test-123456", "secret-test-123456", "", false, "", false, "", "", "", "", "", "", 0) - if err != nil { - t.Fatalf("seed incomplete exchange: %v", err) + resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"okx","account_name":"OKX Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`) + if !strings.Contains(resp, `"error"`) || !strings.Contains(resp, "passphrase") { + t.Fatalf("expected incomplete create to be rejected with missing passphrase, got: %s", resp) } - resp := a.toolManageExchangeConfig("default", `{"action":"update","exchange_id":"`+exchangeID+`","passphrase":"passphrase-123456"}`) - if strings.Contains(resp, `"error"`) { - t.Fatalf("expected update to succeed, got: %s", resp) - } - - updated, err := st.Exchange().GetByID("default", exchangeID) + exchanges, err := st.Exchange().List("default") if err != nil { - t.Fatalf("reload exchange: %v", err) + t.Fatalf("list exchanges: %v", err) } - if !updated.Enabled { - t.Fatalf("expected completed exchange config to auto-enable after update") + if len(exchanges) != 0 { + t.Fatalf("expected incomplete exchange not to be persisted, got %#v", exchanges) } } @@ -272,6 +267,38 @@ func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) { } } +func TestLoadExchangeOptionsHidesInvisibleExchangeRows(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "exchange-options-visible.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("create store: %v", err) + } + a := New(nil, st, DefaultConfig(), slog.Default()) + + if err := store.DB().Create(&store.Exchange{ + ID: "hidden-exchange", + UserID: "default", + ExchangeType: "okx", + AccountName: "123413", + Name: "OKX Futures", + Type: "cex", + Enabled: false, + }).Error; err != nil { + t.Fatalf("seed legacy hidden exchange: %v", err) + } + if _, err := st.Exchange().Create("default", "okx", "我的主力OKX账户", true, "api-test", "secret-test", "pass-test", false, "", false, "", "", "", "", "", "", 0); err != nil { + t.Fatalf("create visible exchange: %v", err) + } + + options := a.loadExchangeOptions("default") + if len(options) != 1 { + t.Fatalf("expected only the visible exchange option, got %+v", options) + } + if options[0].Name != "我的主力OKX账户" { + t.Fatalf("expected visible exchange name, got %+v", options) + } +} + func TestDescribeExchangeIncludesTypeSpecificVisibleFields(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "exchange-detail.db") st, err := store.New(dbPath) diff --git a/agent/llm_skill_router.go b/agent/llm_skill_router.go index e6563003..829ae300 100644 --- a/agent/llm_skill_router.go +++ b/agent/llm_skill_router.go @@ -16,6 +16,17 @@ type llmSkillRouteDecision struct { Confidence float64 `json:"confidence,omitempty"` } +type unifiedTurnDecision struct { + TopicIntent string `json:"topic_intent,omitempty"` + BusinessAction string `json:"business_action,omitempty"` + TargetSkill string `json:"target_skill,omitempty"` + TargetSnapshotID string `json:"target_snapshot_id,omitempty"` + ContextMode string `json:"context_mode,omitempty"` + ExtractedData map[string]any `json:"extracted_data,omitempty"` + ReplyToUser string `json:"reply_to_user,omitempty"` + Confidence float64 `json:"confidence,omitempty"` +} + func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { if a.aiClient == nil { return "", false, nil @@ -26,6 +37,12 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI return "", false, nil } + if decision, ok, err := a.routeTurnUnifiedWithLLM(ctx, userID, lang, text); err == nil && ok { + if answer, handled, execErr := a.executeUnifiedTurnDecision(ctx, storeUserID, userID, lang, text, decision, onEvent); handled || execErr != nil { + return answer, handled, execErr + } + } + decision, ok, err := a.routeTurnWithLLM(ctx, userID, lang, text) if err != nil || !ok { return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) @@ -72,6 +89,290 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) } +func parseUnifiedTurnDecision(raw string) (unifiedTurnDecision, error) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var decision unifiedTurnDecision + if err := json.Unmarshal([]byte(raw), &decision); err == nil { + return normalizeUnifiedTurnDecision(decision), nil + } + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start >= 0 && end > start { + if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil { + return normalizeUnifiedTurnDecision(decision), nil + } + } + return unifiedTurnDecision{}, fmt.Errorf("invalid unified turn decision json") +} + +func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecision { + decision.TopicIntent = strings.TrimSpace(strings.ToLower(decision.TopicIntent)) + decision.BusinessAction = strings.TrimSpace(strings.ToLower(decision.BusinessAction)) + decision.TargetSkill = strings.TrimSpace(decision.TargetSkill) + decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID) + decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode)) + decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser) + if decision.ExtractedData == nil { + decision.ExtractedData = map[string]any{} + } + if decision.Confidence < 0 { + decision.Confidence = 0 + } + if decision.Confidence > 1 { + decision.Confidence = 1 + } + switch decision.TopicIntent { + case "continue", "continue_active": + decision.TopicIntent = "continue_active" + case "start_new", "resume_snapshot", "cancel", "instant_reply": + default: + decision.TopicIntent = "" + } + switch decision.BusinessAction { + case "direct_answer", "new_skill", "continue_skill", "planned_agent", "none": + default: + decision.BusinessAction = "" + } + switch decision.ContextMode { + case "use_current", "fresh_context", "resume_snapshot": + default: + decision.ContextMode = "use_current" + } + return decision +} + +func (d unifiedTurnDecision) reliable() bool { + if d.TopicIntent == "" || d.BusinessAction == "" { + return false + } + if d.Confidence > 0 && d.Confidence < 0.45 { + return false + } + switch d.BusinessAction { + case "direct_answer": + return strings.TrimSpace(d.ReplyToUser) != "" + case "new_skill": + skill, _ := parseTargetSkill(d.TargetSkill) + return skill != "" + case "continue_skill": + return d.TopicIntent == "continue_active" + case "planned_agent", "none": + return true + default: + return false + } +} + +func (a *Agent) routeTurnUnifiedWithLLM(ctx context.Context, userID int64, lang, text string) (unifiedTurnDecision, bool, error) { + systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(userID, lang, text) + stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout) + defer cancel() + + raw, err := a.aiClient.CallWithRequest(&mcp.Request{ + Messages: []mcp.Message{ + mcp.NewSystemMessage(systemPrompt), + mcp.NewUserMessage(userPrompt), + }, + Ctx: stageCtx, + }) + if err != nil { + return unifiedTurnDecision{}, false, err + } + decision, err := parseUnifiedTurnDecision(raw) + if err != nil { + return unifiedTurnDecision{}, false, err + } + if !decision.reliable() { + return decision, false, nil + } + return decision, true, nil +} + +func (a *Agent) buildUnifiedTurnRouterPrompt(userID int64, lang, text string) (string, string) { + activeSkill := a.getSkillSession(userID) + activeTask, hasActiveTask := a.getActiveSkillSession(userID) + activeWorkflow := a.getWorkflowSession(userID) + activeExec := a.getExecutionState(userID) + pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID) + previousAssistantReply := a.currentPendingHintText(userID) + snapshots := a.SnapshotManager(userID).List() + snapshotJSON, _ := json.Marshal(snapshots) + currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) + recentConversation := a.buildRecentConversationContext(userID, text) + if strings.TrimSpace(recentConversation) == "" { + recentConversation = "(empty)" + } + activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal) + if strings.TrimSpace(activeFlowSummary) == "" { + activeFlowSummary = "none" + } + + activeTaskDetails := "none" + if hasActiveTask { + activeTaskDetails = buildBrainUserPrompt(lang, text, previousAssistantReply, recentConversation, currentRefs, activeTask, true) + } + + systemPrompt := prependNOFXiAdvisorPreamble(`You are the unified turn router for NOFXi. +Return JSON only. No markdown. + +You must make ONE combined decision for this user turn: +1. Topic/context decision: continue active context, start fresh/new context, resume snapshot, cancel, or direct conversational reply. +2. Business routing decision: answer directly, start/continue a management skill, or hand off to the planner. +3. Context policy: whether downstream modules may use current references, must use fresh context, or must resume a snapshot. + +topic_intent values: +- "continue_active": user is answering or continuing the active flow +- "start_new": user starts or switches to a new task/topic +- "resume_snapshot": user wants to resume one suspended snapshot +- "cancel": user cancels the current active flow +- "instant_reply": user only greets, thanks, chats, or asks a direct explanation + +business_action values: +- "direct_answer": reply_to_user is the final answer; do not change state +- "new_skill": start a management/diagnosis skill; target_skill is required +- "continue_skill": continue the active skill session +- "planned_agent": hand off to the execution planner/tools +- "none": only valid with cancel when no more action is needed + +target_skill format for new_skill: +skill_name:action, for example "trader_management:create". +Available skills: +trader_management, exchange_management, model_management, strategy_management, +trader_diagnosis, exchange_diagnosis, model_diagnosis, strategy_diagnosis + +Available actions: +create, update, update_name, update_bindings, configure_strategy, configure_exchange, configure_model, +update_status, update_endpoint, update_config, update_prompt, delete, start, stop, activate, duplicate, +query_list, query_detail, query_running + +context_mode values: +- "use_current": downstream modules may use current references and recent context +- "fresh_context": the user is switching topic; do not use old current references to fill business fields +- "resume_snapshot": restore target_snapshot_id first + +Rules: +- This router decides what context downstream LLMs will see. Be conservative with stale references. +- If the user clearly switches domain/entity, set topic_intent="start_new" and context_mode="fresh_context". +- If the user says "不是交易员,是策略" or similar corrections, use fresh_context. +- If the user answers the previous assistant question, choose continue_active. +- If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task. +- If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context. +- Use new_skill for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy. +- Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks. +- For model_management, "provider" means AI vendor, never an exchange. +- Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one. +- extracted_data must contain only concrete facts from the current user message. +- reply_to_user must be concise and in the user's language. +- confidence should reflect how safe it is to execute this decision without the old router fallback. + +Return JSON with this exact shape: +{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|new_skill|continue_skill|planned_agent|none","target_skill":"","target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`) + + userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n\nManagement domain primer:\n%s\n\nActive task details:\n%s\n", + lang, + text, + defaultIfEmpty(previousAssistantReply, "(empty)"), + currentRefs, + activeFlowSummary, + defaultIfEmpty(string(snapshotJSON), "[]"), + recentConversation, + defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"), + activeTaskDetails, + ) + + return systemPrompt, userPrompt +} + +func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) { + switch decision.TopicIntent { + case "cancel": + a.clearPendingProposalSession(userID) + if a.hasAnyActiveContext(userID) { + a.clearActiveSkillSession(userID) + a.clearAnyActiveContext(userID) + return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil + } + if decision.BusinessAction == "direct_answer" && decision.ReplyToUser != "" { + emitBrainReply(onEvent, decision.ReplyToUser) + a.recordSkillInteraction(userID, text, decision.ReplyToUser) + return decision.ReplyToUser, true, nil + } + return "", false, nil + case "resume_snapshot": + a.clearPendingProposalSession(userID) + if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) { + if decision.BusinessAction == "planned_agent" { + answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "use_current", onEvent) + return answer, true, err + } + return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent) + } + return "", false, nil + } + + if decision.TopicIntent == "continue_active" { + if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) { + return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent) + } + } + + switch decision.BusinessAction { + case "direct_answer": + if decision.ReplyToUser == "" { + return "", false, nil + } + if decision.TopicIntent == "instant_reply" && a.hasAnyActiveContext(userID) { + return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil + } + emitBrainReply(onEvent, decision.ReplyToUser) + a.recordSkillInteraction(userID, text, decision.ReplyToUser) + a.runPostResponseMaintenanceAsync(userID) + return decision.ReplyToUser, true, nil + case "new_skill": + skill, action := parseTargetSkill(decision.TargetSkill) + if skill == "" { + return "", false, nil + } + if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" { + if !a.suspendActiveContexts(userID, lang) { + a.clearSkillSession(userID) + a.clearWorkflowSession(userID) + a.clearExecutionState(userID) + } + a.clearActiveSkillSession(userID) + } + session := newActiveSkillSession(userID, skill, action) + session.Goal = strings.TrimSpace(text) + decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang) + mergeExtractedData(&session, decision.ExtractedData) + return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent) + case "continue_skill": + activeSession, hasActive := a.getActiveSkillSession(userID) + if !hasActive { + return "", false, nil + } + decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang) + mergeExtractedData(&activeSession, decision.ExtractedData) + return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent) + case "planned_agent": + contextMode := decision.ContextMode + if contextMode == "resume_snapshot" { + contextMode = "use_current" + } + answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, contextMode, onEvent) + return answer, true, err + case "none": + return "", false, nil + default: + return "", false, nil + } +} + func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) { raw = strings.TrimSpace(raw) raw = strings.TrimPrefix(raw, "```json") diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 9a68b9b5..198d6ec4 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -10,6 +10,7 @@ import ( "time" "nofx/mcp" + "nofx/store" ) const ( @@ -412,6 +413,9 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st if exchanges, err := a.store.Exchange().List(storeUserID); err == nil { candidates := make([]EntityReference, 0, len(exchanges)) for _, exchange := range exchanges { + if !store.IsVisibleExchange(exchange) { + continue + } name := exchange.AccountName if name == "" { name = exchange.ExchangeType diff --git a/agent/skill_dispatcher.go b/agent/skill_dispatcher.go index 4534fa18..a28eb802 100644 --- a/agent/skill_dispatcher.go +++ b/agent/skill_dispatcher.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "nofx/store" ) type skillSession struct { @@ -298,6 +300,9 @@ func (a *Agent) loadExchangeOptions(storeUserID string) []traderSkillOption { } out := make([]traderSkillOption, 0, len(exchanges)) for _, exchange := range exchanges { + if !store.IsVisibleExchange(exchange) { + continue + } name := strings.TrimSpace(exchange.AccountName) if name == "" { name = strings.TrimSpace(exchange.ExchangeType) diff --git a/agent/skill_management_handlers.go b/agent/skill_management_handlers.go index 4f32cb8d..1947ca75 100644 --- a/agent/skill_management_handlers.go +++ b/agent/skill_management_handlers.go @@ -1295,6 +1295,9 @@ func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption { exchangeNames := map[string]string{} if exchanges, err := a.store.Exchange().List(storeUserID); err == nil { for _, exchange := range exchanges { + if !store.IsVisibleExchange(exchange) { + continue + } name := strings.TrimSpace(exchange.AccountName) if name == "" { name = strings.TrimSpace(exchange.ExchangeType) diff --git a/agent/tools.go b/agent/tools.go index cdc3be09..c1705385 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -1254,8 +1254,6 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { missing := missingRequiredActionSlots("exchange_management", "create", map[string]string{ "exchange_type": strings.TrimSpace(args.ExchangeType), "account_name": strings.TrimSpace(args.AccountName), - "api_key": strings.TrimSpace(args.APIKey), - "secret_key": strings.TrimSpace(args.SecretKey), }) if len(missing) > 0 { return fmt.Sprintf(`{"error":"missing required fields for create: %s"}`, strings.Join(missing, ", ")) @@ -1264,12 +1262,7 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if exchangeType == "" { return `{"error":"exchange_type is required for create"}` } - // Match the manual settings page: newly created model configs should be - // enabled unless the caller explicitly asks to keep them disabled. enabled := true - if args.Enabled != nil { - enabled = *args.Enabled - } testnet := false if args.Testnet != nil { testnet = *args.Testnet @@ -1363,10 +1356,7 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if err != nil { return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err) } - enabled := existing.Enabled - if args.Enabled != nil { - enabled = *args.Enabled - } + enabled := true testnet := existing.Testnet if args.Testnet != nil { testnet = *args.Testnet @@ -1433,12 +1423,6 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { lighterPrivateKey: effectiveLighterPrivateKey, lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey, } - if args.Enabled == nil { - if err := validator.Validate(); err == nil { - enabled = true - } - } - validator.enabled = enabled if err := validator.Validate(); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } diff --git a/agent/unified_turn_router_test.go b/agent/unified_turn_router_test.go new file mode 100644 index 00000000..79edc1d3 --- /dev/null +++ b/agent/unified_turn_router_test.go @@ -0,0 +1,110 @@ +package agent + +import ( + "context" + "strings" + "testing" +) + +func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) { + raw := `{ + "topic_intent": "start_new", + "business_action": "new_skill", + "target_skill": "strategy_management:update_config", + "context_mode": "fresh_context", + "extracted_data": {"name": "BTC趋势"}, + "confidence": 0.82 + }` + + decision, err := parseUnifiedTurnDecision(raw) + if err != nil { + t.Fatalf("parse unified decision: %v", err) + } + if decision.TopicIntent != "start_new" { + t.Fatalf("expected normalized topic intent, got %q", decision.TopicIntent) + } + if decision.BusinessAction != "new_skill" { + t.Fatalf("expected business action new_skill, got %q", decision.BusinessAction) + } + if decision.ContextMode != "fresh_context" { + t.Fatalf("expected fresh_context, got %q", decision.ContextMode) + } + if !decision.reliable() { + t.Fatalf("expected decision to be reliable: %+v", decision) + } +} + +func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) { + lowConfidence := unifiedTurnDecision{ + TopicIntent: "start_new", + BusinessAction: "planned_agent", + ContextMode: "fresh_context", + Confidence: 0.2, + } + lowConfidence = normalizeUnifiedTurnDecision(lowConfidence) + if lowConfidence.reliable() { + t.Fatalf("expected low confidence decision to fall back") + } + + emptyDirect := unifiedTurnDecision{ + TopicIntent: "instant_reply", + BusinessAction: "direct_answer", + ContextMode: "use_current", + Confidence: 0.9, + } + emptyDirect = normalizeUnifiedTurnDecision(emptyDirect) + if emptyDirect.reliable() { + t.Fatalf("expected direct_answer without reply_to_user to fall back") + } +} + +func TestExecuteUnifiedTurnDecisionDirectAnswerRecordsHistory(t *testing.T) { + a := New(nil, nil, DefaultConfig(), nil) + userID := int64(101) + decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{ + TopicIntent: "instant_reply", + BusinessAction: "direct_answer", + ContextMode: "use_current", + ReplyToUser: "你好,我在。", + Confidence: 0.9, + }) + + answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "你好", decision, nil) + if err != nil { + t.Fatalf("execute unified decision: %v", err) + } + if !handled { + t.Fatal("expected direct answer to be handled") + } + if answer != "你好,我在。" { + t.Fatalf("unexpected answer: %q", answer) + } + + history := a.history.Get(userID) + if len(history) != 2 { + t.Fatalf("expected user and assistant history entries, got %d", len(history)) + } + if history[0].Role != "user" || history[0].Content != "你好" { + t.Fatalf("unexpected user history entry: %+v", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "你好,我在。" { + t.Fatalf("unexpected assistant history entry: %+v", history[1]) + } +} + +func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) { + a := New(nil, nil, DefaultConfig(), nil) + systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略") + for _, want := range []string{ + "context_mode values", + "fresh_context", + "downstream modules", + } { + if !strings.Contains(systemPrompt, want) { + t.Fatalf("expected system prompt to contain %q", want) + } + } + if !strings.Contains(userPrompt, "不是交易员,是策略") { + t.Fatalf("expected user prompt to contain current user message") + } +} diff --git a/api/exchange_account_state.go b/api/exchange_account_state.go index f3079496..91179907 100644 --- a/api/exchange_account_state.go +++ b/api/exchange_account_state.go @@ -319,29 +319,23 @@ func accountAssetForExchange(exchangeType string) string { } func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) { - switch exchangeCfg.ExchangeType { - case "binance", "bybit", "gate", "indodax": - if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" { - return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true + missingFields := store.MissingRequiredExchangeCredentialFields( + exchangeCfg.ExchangeType, + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + string(exchangeCfg.Passphrase), + exchangeCfg.HyperliquidWalletAddr, + exchangeCfg.AsterUser, + exchangeCfg.AsterSigner, + string(exchangeCfg.AsterPrivateKey), + exchangeCfg.LighterWalletAddr, + string(exchangeCfg.LighterAPIKeyPrivateKey), + ) + if len(missingFields) > 0 { + if len(missingFields) == 1 && missingFields[0] == "exchange_type" { + return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true } - case "okx", "bitget", "kucoin": - if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" { - return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true - } - case "hyperliquid": - if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" { - return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true - } - case "aster": - if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" { - return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true - } - case "lighter": - if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" { - return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true - } - default: - return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true } return "", "", "", false diff --git a/api/handler_exchange.go b/api/handler_exchange.go index deb6102b..217aacaa 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "nofx/config" "nofx/crypto" @@ -37,9 +38,9 @@ type SafeExchangeConfig struct { Testnet bool `json:"testnet,omitempty"` HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive) HasAsterPrivateKey bool `json:"has_aster_private_key"` - AsterUser string `json:"asterUser"` // Aster username (not sensitive) - AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive) - LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive) + AsterUser string `json:"asterUser"` // Aster username (not sensitive) + AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive) + LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive) HasLighterPrivateKey bool `json:"has_lighter_private_key"` HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"` } @@ -199,13 +200,73 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // Update each exchange's configuration and track traders that need reload tradersToReload := make(map[string]bool) for exchangeID, exchangeData := range req.Exchanges { + existing, err := s.store.Exchange().GetByID(userID, exchangeID) + if err != nil { + SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err) + return + } + effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey) + if effectiveAPIKey == "" { + effectiveAPIKey = strings.TrimSpace(string(existing.APIKey)) + } + effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey) + if effectiveSecretKey == "" { + effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey)) + } + effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase) + if effectivePassphrase == "" { + effectivePassphrase = strings.TrimSpace(string(existing.Passphrase)) + } + effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey) + if effectiveAsterPrivateKey == "" { + effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey)) + } + effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey) + if effectiveLighterAPIKeyPrivateKey == "" { + effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey)) + } + effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr) + if effectiveHyperliquidWalletAddr == "" { + effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr) + } + effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser) + if effectiveAsterUser == "" { + effectiveAsterUser = strings.TrimSpace(existing.AsterUser) + } + effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner) + if effectiveAsterSigner == "" { + effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner) + } + effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr) + if effectiveLighterWalletAddr == "" { + effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr) + } + if missing := store.MissingRequiredExchangeCredentialFields( + existing.ExchangeType, + effectiveAPIKey, + effectiveSecretKey, + effectivePassphrase, + effectiveHyperliquidWalletAddr, + effectiveAsterUser, + effectiveAsterSigner, + effectiveAsterPrivateKey, + effectiveLighterWalletAddr, + effectiveLighterAPIKeyPrivateKey, + ); len(missing) > 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")), + "missing_fields": missing, + }) + return + } + // Find traders using this exchange BEFORE updating traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID) for _, t := range traders { tradersToReload[t.ID] = true } - err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) + err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) if err != nil { SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err) return @@ -291,10 +352,28 @@ func (s *Server) handleCreateExchange(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) return } + if missing := store.MissingRequiredExchangeCredentialFields( + req.ExchangeType, + req.APIKey, + req.SecretKey, + req.Passphrase, + req.HyperliquidWalletAddr, + req.AsterUser, + req.AsterSigner, + req.AsterPrivateKey, + req.LighterWalletAddr, + req.LighterAPIKeyPrivateKey, + ); len(missing) > 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")), + "missing_fields": missing, + }) + return + } - // Create new exchange account + // Exchange configs only persist once complete; persisted configs are always enabled. id, err := s.store.Exchange().Create( - userID, req.ExchangeType, req.AccountName, req.Enabled, + userID, req.ExchangeType, req.AccountName, true, req.APIKey, req.SecretKey, req.Passphrase, req.Testnet, req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct, req.AsterUser, req.AsterSigner, req.AsterPrivateKey, diff --git a/store/exchange.go b/store/exchange.go index e4acf69d..3819a376 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -4,6 +4,7 @@ import ( "fmt" "nofx/crypto" "nofx/logger" + "strings" "time" "github.com/google/uuid" @@ -57,6 +58,9 @@ func (s *ExchangeStore) initTables() error { // Still run data migrations s.migrateToMultiAccount() s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default") + if err := s.cleanupIncompleteExchangeConfigs(); err != nil { + logger.Warnf("Exchange cleanup migration warning: %v", err) + } return nil } } @@ -72,10 +76,48 @@ func (s *ExchangeStore) initTables() error { // Fix empty account_name for existing records s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default") + if err := s.cleanupIncompleteExchangeConfigs(); err != nil { + logger.Warnf("Exchange cleanup migration warning: %v", err) + } return nil } +func (s *ExchangeStore) cleanupIncompleteExchangeConfigs() error { + var exchanges []Exchange + if err := s.db.Find(&exchanges).Error; err != nil { + return err + } + for _, exchange := range exchanges { + missing := MissingRequiredExchangeCredentialFields( + exchange.ExchangeType, + string(exchange.APIKey), + string(exchange.SecretKey), + string(exchange.Passphrase), + exchange.HyperliquidWalletAddr, + exchange.AsterUser, + exchange.AsterSigner, + string(exchange.AsterPrivateKey), + exchange.LighterWalletAddr, + string(exchange.LighterAPIKeyPrivateKey), + ) + if len(missing) > 0 { + if err := s.db.Delete(&Exchange{}, "id = ? AND user_id = ?", exchange.ID, exchange.UserID).Error; err != nil { + return err + } + logger.Infof("🧹 Removed incomplete exchange config during migration: id=%s user=%s missing=%s", exchange.ID, exchange.UserID, strings.Join(missing, ",")) + continue + } + if !exchange.Enabled { + if err := s.db.Model(&Exchange{}).Where("id = ? AND user_id = ?", exchange.ID, exchange.UserID).Update("enabled", true).Error; err != nil { + return err + } + logger.Infof("🧹 Enabled complete exchange config during migration: id=%s user=%s", exchange.ID, exchange.UserID) + } + } + return nil +} + // migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID) func (s *ExchangeStore) migrateToMultiAccount() error { // Check if migration is needed by looking for old-style IDs (non-UUID) @@ -188,6 +230,10 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) { + if missing := MissingRequiredExchangeCredentialFields(exchangeType, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterApiKeyPrivateKey); len(missing) > 0 { + return "", fmt.Errorf("missing required exchange fields: %s", strings.Join(missing, ", ")) + } + id := uuid.New().String() name, typ := getExchangeNameAndType(exchangeType) @@ -205,7 +251,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled UserID: userID, Name: name, Type: typ, - Enabled: enabled, + Enabled: true, APIKey: crypto.EncryptedString(apiKey), SecretKey: crypto.EncryptedString(secretKey), Passphrase: crypto.EncryptedString(passphrase), @@ -232,10 +278,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error { - logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) + logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s", userID, id) updates := map[string]interface{}{ - "enabled": enabled, + "enabled": true, "testnet": testnet, "hyperliquid_wallet_addr": hyperliquidWalletAddr, "hyperliquid_unified_account": hyperliquidUnifiedAcct, diff --git a/store/visibility.go b/store/visibility.go index e37d6c43..ed6d981f 100644 --- a/store/visibility.go +++ b/store/visibility.go @@ -2,6 +2,55 @@ package store import "strings" +func MissingRequiredExchangeCredentialFields(exchangeType, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) []string { + switch strings.ToLower(strings.TrimSpace(exchangeType)) { + case "binance", "bybit", "gate", "indodax": + return missingNamedFields( + namedField{"api_key", apiKey}, + namedField{"secret_key", secretKey}, + ) + case "okx", "bitget", "kucoin": + return missingNamedFields( + namedField{"api_key", apiKey}, + namedField{"secret_key", secretKey}, + namedField{"passphrase", passphrase}, + ) + case "hyperliquid": + return missingNamedFields( + namedField{"api_key", apiKey}, + namedField{"hyperliquid_wallet_addr", hyperliquidWalletAddr}, + ) + case "aster": + return missingNamedFields( + namedField{"aster_user", asterUser}, + namedField{"aster_signer", asterSigner}, + namedField{"aster_private_key", asterPrivateKey}, + ) + case "lighter": + return missingNamedFields( + namedField{"lighter_wallet_addr", lighterWalletAddr}, + namedField{"lighter_api_key_private_key", lighterAPIKeyPrivateKey}, + ) + default: + return []string{"exchange_type"} + } +} + +type namedField struct { + name string + value string +} + +func missingNamedFields(fields ...namedField) []string { + missing := make([]string, 0, len(fields)) + for _, field := range fields { + if strings.TrimSpace(field.value) == "" { + missing = append(missing, field.name) + } + } + return missing +} + func IsVisibleAIModel(model *AIModel) bool { if model == nil { return false @@ -45,4 +94,3 @@ func IsVisibleStrategy(strategy *Strategy) bool { } return strings.TrimSpace(strategy.Name) != "" } -