mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Unify agent routing and tighten exchange config
This commit is contained in:
@@ -92,20 +92,21 @@ func (v exchangeConfigValidator) Validate() error {
|
|||||||
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
|
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
|
||||||
return fmt.Errorf("Secret format looks invalid")
|
return fmt.Errorf("Secret format looks invalid")
|
||||||
}
|
}
|
||||||
if exchangeType == "okx" && v.enabled && strings.TrimSpace(v.passphrase) == "" {
|
if v.enabled {
|
||||||
return fmt.Errorf("OKX requires passphrase before enabling this exchange config")
|
missing := store.MissingRequiredExchangeCredentialFields(
|
||||||
}
|
exchangeType,
|
||||||
if exchangeType == "hyperliquid" && v.enabled && strings.TrimSpace(v.hyperliquidWalletAddr) == "" {
|
v.apiKey,
|
||||||
return fmt.Errorf("Hyperliquid requires wallet address before enabling this exchange config")
|
v.secretKey,
|
||||||
}
|
v.passphrase,
|
||||||
if exchangeType == "aster" && v.enabled {
|
v.hyperliquidWalletAddr,
|
||||||
if strings.TrimSpace(v.asterUser) == "" || strings.TrimSpace(v.asterSigner) == "" || strings.TrimSpace(v.asterPrivateKey) == "" {
|
v.asterUser,
|
||||||
return fmt.Errorf("Aster requires user, signer, and private key before enabling this exchange config")
|
v.asterSigner,
|
||||||
}
|
v.asterPrivateKey,
|
||||||
}
|
v.lighterWalletAddr,
|
||||||
if exchangeType == "lighter" && v.enabled {
|
v.lighterAPIKeyPrivateKey,
|
||||||
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 len(missing) > 0 {
|
||||||
|
return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -112,30 +112,25 @@ func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolManageExchangeConfigUpdateAutoEnablesWhenConfigBecomesComplete(t *testing.T) {
|
func TestToolManageExchangeConfigCreateRejectsIncompleteDraft(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "exchange-update-auto-enable.db")
|
dbPath := filepath.Join(t.TempDir(), "exchange-create-incomplete.db")
|
||||||
st, err := store.New(dbPath)
|
st, err := store.New(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create store: %v", err)
|
t.Fatalf("create store: %v", err)
|
||||||
}
|
}
|
||||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
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)
|
resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"okx","account_name":"OKX Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`)
|
||||||
if err != nil {
|
if !strings.Contains(resp, `"error"`) || !strings.Contains(resp, "passphrase") {
|
||||||
t.Fatalf("seed incomplete exchange: %v", err)
|
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"}`)
|
exchanges, err := st.Exchange().List("default")
|
||||||
if strings.Contains(resp, `"error"`) {
|
|
||||||
t.Fatalf("expected update to succeed, got: %s", resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := st.Exchange().GetByID("default", exchangeID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload exchange: %v", err)
|
t.Fatalf("list exchanges: %v", err)
|
||||||
}
|
}
|
||||||
if !updated.Enabled {
|
if len(exchanges) != 0 {
|
||||||
t.Fatalf("expected completed exchange config to auto-enable after update")
|
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) {
|
func TestDescribeExchangeIncludesTypeSpecificVisibleFields(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "exchange-detail.db")
|
dbPath := filepath.Join(t.TempDir(), "exchange-detail.db")
|
||||||
st, err := store.New(dbPath)
|
st, err := store.New(dbPath)
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ type llmSkillRouteDecision struct {
|
|||||||
Confidence float64 `json:"confidence,omitempty"`
|
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) {
|
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 {
|
if a.aiClient == nil {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
@@ -26,6 +37,12 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
|
|||||||
return "", false, nil
|
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)
|
decision, ok, err := a.routeTurnWithLLM(ctx, userID, lang, text)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
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)
|
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) {
|
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
raw = strings.TrimPrefix(raw, "```json")
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
|
"nofx/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -412,6 +413,9 @@ func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, st
|
|||||||
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
||||||
candidates := make([]EntityReference, 0, len(exchanges))
|
candidates := make([]EntityReference, 0, len(exchanges))
|
||||||
for _, exchange := range exchanges {
|
for _, exchange := range exchanges {
|
||||||
|
if !store.IsVisibleExchange(exchange) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := exchange.AccountName
|
name := exchange.AccountName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = exchange.ExchangeType
|
name = exchange.ExchangeType
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"nofx/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type skillSession struct {
|
type skillSession struct {
|
||||||
@@ -298,6 +300,9 @@ func (a *Agent) loadExchangeOptions(storeUserID string) []traderSkillOption {
|
|||||||
}
|
}
|
||||||
out := make([]traderSkillOption, 0, len(exchanges))
|
out := make([]traderSkillOption, 0, len(exchanges))
|
||||||
for _, exchange := range exchanges {
|
for _, exchange := range exchanges {
|
||||||
|
if !store.IsVisibleExchange(exchange) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := strings.TrimSpace(exchange.AccountName)
|
name := strings.TrimSpace(exchange.AccountName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = strings.TrimSpace(exchange.ExchangeType)
|
name = strings.TrimSpace(exchange.ExchangeType)
|
||||||
|
|||||||
@@ -1295,6 +1295,9 @@ func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
|
|||||||
exchangeNames := map[string]string{}
|
exchangeNames := map[string]string{}
|
||||||
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
||||||
for _, exchange := range exchanges {
|
for _, exchange := range exchanges {
|
||||||
|
if !store.IsVisibleExchange(exchange) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := strings.TrimSpace(exchange.AccountName)
|
name := strings.TrimSpace(exchange.AccountName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = strings.TrimSpace(exchange.ExchangeType)
|
name = strings.TrimSpace(exchange.ExchangeType)
|
||||||
|
|||||||
@@ -1254,8 +1254,6 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
|
|||||||
missing := missingRequiredActionSlots("exchange_management", "create", map[string]string{
|
missing := missingRequiredActionSlots("exchange_management", "create", map[string]string{
|
||||||
"exchange_type": strings.TrimSpace(args.ExchangeType),
|
"exchange_type": strings.TrimSpace(args.ExchangeType),
|
||||||
"account_name": strings.TrimSpace(args.AccountName),
|
"account_name": strings.TrimSpace(args.AccountName),
|
||||||
"api_key": strings.TrimSpace(args.APIKey),
|
|
||||||
"secret_key": strings.TrimSpace(args.SecretKey),
|
|
||||||
})
|
})
|
||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return fmt.Sprintf(`{"error":"missing required fields for create: %s"}`, strings.Join(missing, ", "))
|
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 == "" {
|
if exchangeType == "" {
|
||||||
return `{"error":"exchange_type is required for create"}`
|
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
|
enabled := true
|
||||||
if args.Enabled != nil {
|
|
||||||
enabled = *args.Enabled
|
|
||||||
}
|
|
||||||
testnet := false
|
testnet := false
|
||||||
if args.Testnet != nil {
|
if args.Testnet != nil {
|
||||||
testnet = *args.Testnet
|
testnet = *args.Testnet
|
||||||
@@ -1363,10 +1356,7 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err)
|
return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err)
|
||||||
}
|
}
|
||||||
enabled := existing.Enabled
|
enabled := true
|
||||||
if args.Enabled != nil {
|
|
||||||
enabled = *args.Enabled
|
|
||||||
}
|
|
||||||
testnet := existing.Testnet
|
testnet := existing.Testnet
|
||||||
if args.Testnet != nil {
|
if args.Testnet != nil {
|
||||||
testnet = *args.Testnet
|
testnet = *args.Testnet
|
||||||
@@ -1433,12 +1423,6 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
|
|||||||
lighterPrivateKey: effectiveLighterPrivateKey,
|
lighterPrivateKey: effectiveLighterPrivateKey,
|
||||||
lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey,
|
lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey,
|
||||||
}
|
}
|
||||||
if args.Enabled == nil {
|
|
||||||
if err := validator.Validate(); err == nil {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validator.enabled = enabled
|
|
||||||
if err := validator.Validate(); err != nil {
|
if err := validator.Validate(); err != nil {
|
||||||
return fmt.Sprintf(`{"error":"%s"}`, err)
|
return fmt.Sprintf(`{"error":"%s"}`, err)
|
||||||
}
|
}
|
||||||
|
|||||||
110
agent/unified_turn_router_test.go
Normal file
110
agent/unified_turn_router_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,29 +319,23 @@ func accountAssetForExchange(exchangeType string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||||
switch exchangeCfg.ExchangeType {
|
missingFields := store.MissingRequiredExchangeCredentialFields(
|
||||||
case "binance", "bybit", "gate", "indodax":
|
exchangeCfg.ExchangeType,
|
||||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
string(exchangeCfg.APIKey),
|
||||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
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":
|
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
|
||||||
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 "", "", "", false
|
return "", "", "", false
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"nofx/config"
|
"nofx/config"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
@@ -37,9 +38,9 @@ type SafeExchangeConfig struct {
|
|||||||
Testnet bool `json:"testnet,omitempty"`
|
Testnet bool `json:"testnet,omitempty"`
|
||||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
||||||
HasLighterAPIKey bool `json:"has_lighter_api_key_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
|
// Update each exchange's configuration and track traders that need reload
|
||||||
tradersToReload := make(map[string]bool)
|
tradersToReload := make(map[string]bool)
|
||||||
for exchangeID, exchangeData := range req.Exchanges {
|
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
|
// Find traders using this exchange BEFORE updating
|
||||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||||
for _, t := range traders {
|
for _, t := range traders {
|
||||||
tradersToReload[t.ID] = true
|
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 {
|
if err != nil {
|
||||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||||
return
|
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)})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||||
return
|
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(
|
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.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -57,6 +58,9 @@ func (s *ExchangeStore) initTables() error {
|
|||||||
// Still run data migrations
|
// Still run data migrations
|
||||||
s.migrateToMultiAccount()
|
s.migrateToMultiAccount()
|
||||||
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,10 +76,48 @@ func (s *ExchangeStore) initTables() error {
|
|||||||
|
|
||||||
// Fix empty account_name for existing records
|
// Fix empty account_name for existing records
|
||||||
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
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
|
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)
|
// migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID)
|
||||||
func (s *ExchangeStore) migrateToMultiAccount() error {
|
func (s *ExchangeStore) migrateToMultiAccount() error {
|
||||||
// Check if migration is needed by looking for old-style IDs (non-UUID)
|
// 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,
|
asterUser, asterSigner, asterPrivateKey,
|
||||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
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()
|
id := uuid.New().String()
|
||||||
name, typ := getExchangeNameAndType(exchangeType)
|
name, typ := getExchangeNameAndType(exchangeType)
|
||||||
|
|
||||||
@@ -205,7 +251,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: typ,
|
Type: typ,
|
||||||
Enabled: enabled,
|
Enabled: true,
|
||||||
APIKey: crypto.EncryptedString(apiKey),
|
APIKey: crypto.EncryptedString(apiKey),
|
||||||
SecretKey: crypto.EncryptedString(secretKey),
|
SecretKey: crypto.EncryptedString(secretKey),
|
||||||
Passphrase: crypto.EncryptedString(passphrase),
|
Passphrase: crypto.EncryptedString(passphrase),
|
||||||
@@ -232,10 +278,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
|
|||||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||||
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
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{}{
|
updates := map[string]interface{}{
|
||||||
"enabled": enabled,
|
"enabled": true,
|
||||||
"testnet": testnet,
|
"testnet": testnet,
|
||||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||||
|
|||||||
@@ -2,6 +2,55 @@ package store
|
|||||||
|
|
||||||
import "strings"
|
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 {
|
func IsVisibleAIModel(model *AIModel) bool {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return false
|
return false
|
||||||
@@ -45,4 +94,3 @@ func IsVisibleStrategy(strategy *Strategy) bool {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(strategy.Name) != ""
|
return strings.TrimSpace(strategy.Name) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user