feat(agent): add NOFXi agent chat workflow (#1495)

- Add NOFXi agent backend: central brain, planner runtime, skill routing,
  memory/state handling, config validation, and action execution
- Add agent chat page with SSE streaming, step/status panels, and
  user preferences
- Extend trader/model/exchange/strategy APIs and store for agent-driven
  configuration
- Add stopCh guard in async maintenance goroutine to prevent leak on Stop()
- Add timeout context for trader diagnosis LLM calls
- Add TargetRef nil guards in all execute*Action handlers
- Add ensureHistory() for nil-safe history access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-05-11 16:52:38 +08:00
120 changed files with 23558 additions and 7004 deletions

4
.gitignore vendored
View File

@@ -44,6 +44,7 @@ decision_logs/
nofx_test
# Node.js
web/node_modules
web/node_modules/
node_modules/
web/dist/
@@ -52,6 +53,9 @@ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
# 本地 Agent QA seed个人调试用不纳入版本控制
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
# VS code
.vscode

272
agent/active_session.go Normal file
View File

@@ -0,0 +1,272 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// ActiveSkillSession is the minimal session for the central brain architecture.
// It replaces the old skillSession + ExecutionState combo for management skill flows.
type ActiveSkillSession struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
SkillName string `json:"skill_name"`
ActionName string `json:"action_name"`
LegacyPhase string `json:"legacy_phase,omitempty"`
Goal string `json:"goal,omitempty"`
PendingHint *PendingHint `json:"pending_hint,omitempty"`
CollectedFields map[string]any `json:"collected_fields,omitempty"`
LocalHistory []chatMessage `json:"local_history,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type PendingHint struct {
Prompt string `json:"prompt,omitempty"`
HintType string `json:"hint_type,omitempty"`
}
type PendingProposalSession struct {
UserID int64 `json:"user_id"`
SourceUserText string `json:"source_user_text,omitempty"`
ProposalText string `json:"proposal_text,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func activeSkillSessionKey(userID int64) string {
return fmt.Sprintf("agent_active_skill_session_%d", userID)
}
func pendingProposalSessionKey(userID int64) string {
return fmt.Sprintf("agent_pending_proposal_session_%d", userID)
}
func (a *Agent) getActiveSkillSession(userID int64) (ActiveSkillSession, bool) {
if a.store == nil {
return ActiveSkillSession{}, false
}
raw, err := a.store.GetSystemConfig(activeSkillSessionKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return ActiveSkillSession{}, false
}
var s ActiveSkillSession
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return ActiveSkillSession{}, false
}
if s.SessionID == "" || s.SkillName == "" {
return ActiveSkillSession{}, false
}
s.PendingHint = normalizePendingHint(s.PendingHint)
return s, true
}
func (a *Agent) saveActiveSkillSession(s ActiveSkillSession) {
if a.store == nil {
return
}
s.PendingHint = normalizePendingHint(s.PendingHint)
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, _ := json.Marshal(s)
_ = a.store.SetSystemConfig(activeSkillSessionKey(s.UserID), string(data))
}
func (a *Agent) clearActiveSkillSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(activeSkillSessionKey(userID), "")
}
func (a *Agent) getPendingProposalSession(userID int64) (PendingProposalSession, bool) {
if a.store == nil {
return PendingProposalSession{}, false
}
raw, err := a.store.GetSystemConfig(pendingProposalSessionKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return PendingProposalSession{}, false
}
var s PendingProposalSession
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return PendingProposalSession{}, false
}
if s.UserID == 0 || strings.TrimSpace(s.ProposalText) == "" {
return PendingProposalSession{}, false
}
return s, true
}
func (a *Agent) savePendingProposalSession(s PendingProposalSession) {
if a.store == nil {
return
}
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, _ := json.Marshal(s)
_ = a.store.SetSystemConfig(pendingProposalSessionKey(s.UserID), string(data))
}
func (a *Agent) clearPendingProposalSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(pendingProposalSessionKey(userID), "")
}
func newActiveSkillSession(userID int64, skill, action string) ActiveSkillSession {
return ActiveSkillSession{
SessionID: fmt.Sprintf("as_%d", time.Now().UnixNano()),
UserID: userID,
SkillName: skill,
ActionName: action,
LegacyPhase: "collecting",
Goal: "",
PendingHint: nil,
CollectedFields: map[string]any{},
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func normalizePendingHint(hint *PendingHint) *PendingHint {
if hint == nil {
return nil
}
prompt := strings.TrimSpace(hint.Prompt)
if prompt == "" {
return nil
}
out := &PendingHint{
Prompt: prompt,
HintType: strings.TrimSpace(hint.HintType),
}
return out
}
func pendingHintFromAssistantReply(reply string) *PendingHint {
reply = strings.TrimSpace(reply)
if reply == "" {
return nil
}
hintType := ""
switch {
case strings.Contains(reply, "请选择") || strings.Contains(strings.ToLower(reply), "choose"):
hintType = "choice"
case strings.Contains(reply, "确认") || strings.Contains(strings.ToLower(reply), "confirm"):
hintType = "confirmation"
case strings.HasSuffix(reply, "?") || strings.HasSuffix(reply, ""):
hintType = "question"
}
if hintType == "" {
return nil
}
return &PendingHint{Prompt: reply, HintType: hintType}
}
func setActiveSessionPendingHint(session *ActiveSkillSession, reply string) {
if session == nil {
return
}
session.PendingHint = pendingHintFromAssistantReply(reply)
}
func clearActiveSessionPendingHint(session *ActiveSkillSession) {
if session == nil {
return
}
session.PendingHint = nil
}
func (a *Agent) currentPendingHintText(userID int64) string {
if active, ok := a.getActiveSkillSession(userID); ok && active.PendingHint != nil && strings.TrimSpace(active.PendingHint.Prompt) != "" {
return strings.TrimSpace(active.PendingHint.Prompt)
}
if state := a.getExecutionState(userID); state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
return strings.TrimSpace(state.Waiting.Question)
}
if proposal, ok := a.getPendingProposalSession(userID); ok && strings.TrimSpace(proposal.ProposalText) != "" {
return strings.TrimSpace(proposal.ProposalText)
}
return strings.TrimSpace(a.getLastAssistantReply(userID))
}
func activeSessionHasField(s ActiveSkillSession, slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return false
}
if len(s.CollectedFields) == 0 {
return false
}
switch slot {
case "target_ref":
if value, ok := s.CollectedFields["bulk_scope"]; ok && strings.EqualFold(strings.TrimSpace(fmt.Sprint(value)), "all") {
return true
}
for _, key := range []string{"target_ref", "target_ref_id", "target_ref_name"} {
if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
case "exchange":
value, ok := s.CollectedFields["exchange_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
case "model":
for _, key := range []string{"model_id", "ai_model_id"} {
if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
case "strategy":
value, ok := s.CollectedFields["strategy_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
default:
value, ok := s.CollectedFields[slot]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
}
}
// missingRequiredFields returns required slots not yet collected, reading from skill registry.
func missingRequiredFields(s ActiveSkillSession) []string {
def, ok := getSkillDefinition(s.SkillName)
if !ok {
return nil
}
actionDef, ok := def.Actions[s.ActionName]
if !ok {
return nil
}
var missing []string
for _, slot := range actionDef.RequiredSlots {
if !activeSessionHasField(s, slot) {
missing = append(missing, slot)
}
}
return missing
}
// fieldConstraintSummary returns a compact description of missing fields for the LLM prompt.
func fieldConstraintSummary(s ActiveSkillSession) string {
def, ok := getSkillDefinition(s.SkillName)
if !ok {
return ""
}
missing := missingRequiredFields(s)
if len(missing) == 0 {
return ""
}
lines := make([]string, 0, len(missing))
for _, key := range missing {
constraint, ok := def.FieldConstraints[key]
if !ok {
lines = append(lines, fmt.Sprintf("- %s (required)", key))
continue
}
desc := constraint.Description
if len(constraint.Values) > 0 {
desc += fmt.Sprintf(" [options: %s]", strings.Join(constraint.Values, ", "))
}
lines = append(lines, fmt.Sprintf("- %s: %s", key, desc))
}
return strings.Join(lines, "\n")
}

View File

@@ -11,16 +11,20 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
gethcrypto "github.com/ethereum/go-ethereum/crypto"
"nofx/manager"
"nofx/market"
"nofx/mcp"
"nofx/store"
"nofx/wallet"
)
type Agent struct {
@@ -35,23 +39,35 @@ type Agent struct {
history *chatHistory
pending *pendingTrades
stopCh chan struct{} // signals background goroutines to stop
stopOnce sync.Once
setupStates sync.Map
flowLocks sync.Map
NotifyFunc func(userID int64, text string) error
}
type Config struct {
Language string `json:"language"`
WatchSymbols []string `json:"watch_symbols"`
EnableBriefs bool `json:"enable_briefs"`
EnableNews bool `json:"enable_news"`
EnableSentinel bool `json:"enable_sentinel"`
BriefTimes []int `json:"brief_times"`
Language string `json:"language"`
WatchSymbols []string `json:"watch_symbols"`
EnableBriefs bool `json:"enable_briefs"`
EnableNews bool `json:"enable_news"`
EnableSentinel bool `json:"enable_sentinel"`
AllowTradeExecution bool `json:"allow_trade_execution"`
BriefTimes []int `json:"brief_times"`
}
var (
agentWalletAddressFromPrivateKey = walletAddressFromPrivateKey
agentQueryUSDCBalanceCached = wallet.QueryUSDCBalanceCached
)
func DefaultConfig() *Config {
return &Config{
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
Language: "zh",
WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
EnableBriefs: true,
EnableNews: true,
EnableSentinel: true,
AllowTradeExecution: true,
BriefTimes: []int{8, 20},
}
}
@@ -59,7 +75,7 @@ func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.L
if cfg == nil {
cfg = DefaultConfig()
}
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})}
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(chatHistoryMaxTurns), pending: newPendingTrades(), stopCh: make(chan struct{})}
}
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
@@ -77,6 +93,14 @@ func (a *Agent) log() *slog.Logger {
return slog.Default()
}
func (a *Agent) flowLock(userID int64) *sync.Mutex {
if a == nil {
return &sync.Mutex{}
}
lock, _ := a.flowLocks.LoadOrStore(userID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func (a *Agent) EnsureAIClient() {
a.ensureAIClientForStoreUser("default")
}
@@ -108,57 +132,182 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str
if storeUserID == "" {
storeUserID = "default"
}
model, err := a.store.AIModel().GetDefault(storeUserID)
if err != nil || model == nil {
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err)
return nil, "", false
candidateUserIDs := []string{storeUserID}
if storeUserID != "default" {
candidateUserIDs = append(candidateUserIDs, "default")
}
a.log().Info(
"agent selected AI model config",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"enabled", model.Enabled,
"has_api_key", len(model.APIKey) > 0,
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
"custom_model_name", strings.TrimSpace(model.CustomModelName),
)
apiKey := string(model.APIKey)
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
provider := strings.ToLower(strings.TrimSpace(model.Provider))
// Use the provider registry for providers like claw402 that have their own
// client implementation (x402 payment, custom auth, etc.).
if client := mcp.NewAIClientByProvider(provider); client != nil {
if modelName == "" {
modelName = model.ID
for _, candidateUserID := range candidateUserIDs {
models, err := a.store.AIModel().List(candidateUserID)
if err != nil {
a.log().Warn("failed to list AI models for store user", "store_user_id", candidateUserID, "error", err)
continue
}
candidates := rankAgentModelCandidates(models)
for _, candidate := range candidates {
model := candidate.model
if model == nil || !model.Enabled || !agentModelHasUsableAPIKey(model) {
continue
}
a.log().Info(
"agent evaluating AI model config",
"store_user_id", candidateUserID,
"model_id", model.ID,
"provider", model.Provider,
"enabled", model.Enabled,
"has_api_key", len(model.APIKey) > 0,
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
"custom_model_name", strings.TrimSpace(model.CustomModelName),
"prefer_model_with_balance", candidate.preferModelWithBalance,
"wallet_balance_usdc", candidate.balanceUSDC,
)
apiKey := strings.TrimSpace(string(model.APIKey))
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
customAPIURL, modelName = resolveModelRuntimeConfig(model.Provider, customAPIURL, modelName, model.ID)
if apiKey == "" || customAPIURL == "" {
a.log().Warn(
"skipping incomplete enabled AI model",
"store_user_id", candidateUserID,
"model_id", model.ID,
"provider", model.Provider,
"has_api_key", apiKey != "",
"has_custom_api_url", customAPIURL != "",
)
continue
}
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
client.SetAPIKey(apiKey, customAPIURL, modelName)
a.log().Info("agent AI client selected", "store_user_id", candidateUserID, "model_id", model.ID, "model", modelName)
return client, modelName, true
}
client.SetAPIKey(apiKey, customAPIURL, modelName)
return client, modelName, true
}
customAPIURL, modelName = resolveModelRuntimeConfig(provider, customAPIURL, modelName, model.ID)
if apiKey == "" || customAPIURL == "" {
a.log().Warn(
"enabled AI model is incomplete",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"has_api_key", apiKey != "",
"has_custom_api_url", customAPIURL != "",
)
return nil, "", false
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID)
return nil, "", false
}
type agentModelCandidate struct {
model *store.AIModel
preferModelWithBalance bool
balanceUSDC float64
}
func rankAgentModelCandidates(models []*store.AIModel) []agentModelCandidate {
candidates := make([]agentModelCandidate, 0, len(models))
for _, model := range models {
if model == nil {
continue
}
candidate := agentModelCandidate{model: model}
if balance, ok := agentModelUSDCBalance(model); ok && balance > 0 {
candidate.preferModelWithBalance = true
candidate.balanceUSDC = balance
}
candidates = append(candidates, candidate)
}
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
name := modelName
client.SetAPIKey(apiKey, customAPIURL, name)
return client, name, true
sort.SliceStable(candidates, func(i, j int) bool {
left := candidates[i]
right := candidates[j]
if left.preferModelWithBalance != right.preferModelWithBalance {
return left.preferModelWithBalance
}
if left.balanceUSDC != right.balanceUSDC {
return left.balanceUSDC > right.balanceUSDC
}
leftUpdatedAt := time.Time{}
rightUpdatedAt := time.Time{}
if left.model != nil {
leftUpdatedAt = left.model.UpdatedAt
}
if right.model != nil {
rightUpdatedAt = right.model.UpdatedAt
}
if !leftUpdatedAt.Equal(rightUpdatedAt) {
return leftUpdatedAt.After(rightUpdatedAt)
}
leftID := ""
rightID := ""
if left.model != nil {
leftID = left.model.ID
}
if right.model != nil {
rightID = right.model.ID
}
return leftID < rightID
})
return candidates
}
func agentModelUSDCBalance(model *store.AIModel) (float64, bool) {
if model == nil || !agentProviderSupportsUSDCBalance(model.Provider) {
return 0, false
}
privateKey := strings.TrimSpace(string(model.APIKey))
if privateKey == "" {
return 0, false
}
walletAddress, err := agentWalletAddressFromPrivateKey(privateKey)
if err != nil || strings.TrimSpace(walletAddress) == "" {
return 0, false
}
balance, err := agentQueryUSDCBalanceCached(walletAddress)
if err != nil || balance <= 0 {
return 0, false
}
return balance, true
}
func agentProviderSupportsUSDCBalance(provider string) bool {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "claw402", "blockrun-base":
return true
default:
return false
}
}
func agentModelHasUsableAPIKey(model *store.AIModel) bool {
if model == nil {
return false
}
if strings.TrimSpace(string(model.APIKey)) != "" {
return true
}
envKeyByProvider := map[string]string{
"deepseek": "DEEPSEEK_API_KEY",
"openai": "OPENAI_API_KEY",
"claude": "ANTHROPIC_API_KEY",
"gemini": "GEMINI_API_KEY",
"grok": "XAI_API_KEY",
"kimi": "MOONSHOT_API_KEY",
"minimax": "MINIMAX_API_KEY",
"qwen": "DASHSCOPE_API_KEY",
}
envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))]
return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != ""
}
func walletAddressFromPrivateKey(privateKey string) (string, error) {
key := strings.TrimSpace(privateKey)
if !strings.HasPrefix(key, "0x") {
return "", fmt.Errorf("private key must start with 0x")
}
if len(key) != 66 {
return "", fmt.Errorf("private key must be 66 characters")
}
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
if err != nil {
return "", err
}
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
}
func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) {
@@ -180,6 +329,7 @@ func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallback
"grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"},
"kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"},
"minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"},
"claw402": {url: "https://claw402.ai", model: "deepseek"},
}
if customAPIURL == "" {
@@ -221,7 +371,12 @@ func (a *Agent) Start() {
func (a *Agent) Stop() {
// Signal all background goroutines (e.g. chat-history-cleanup) to exit.
a.stopOnce.Do(func() { close(a.stopCh) })
select {
case <-a.stopCh:
// Already closed
default:
close(a.stopCh)
}
if a.sentinel != nil {
a.sentinel.Stop()
}
@@ -263,9 +418,7 @@ func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID strin
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
a.clearConversationState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
@@ -274,6 +427,9 @@ func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID strin
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return reply, nil
}
if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled {
return reply, nil
}
// Everything else goes through the planner and tool system.
return a.thinkAndAct(ctx, storeUserID, userID, lang, text)
@@ -309,9 +465,7 @@ func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
a.clearConversationState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
@@ -319,13 +473,37 @@ func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
if onEvent != nil {
onEvent(StreamEventDelta, reply)
emitStreamText(onEvent, reply)
}
return reply, nil
}
if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled {
if onEvent != nil {
emitStreamText(onEvent, reply)
}
return reply, nil
}
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
}
func (a *Agent) clearConversationState(userID int64) {
if a == nil {
return
}
if a.history != nil {
a.history.Clear(userID)
}
a.clearTaskState(userID)
a.clearSkillSession(userID)
a.clearActiveSkillSession(userID)
a.clearPendingProposalSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
a.clearReferenceMemory(userID)
a.SnapshotManager(userID).Clear()
a.clearSetupState(userID)
}
// StreamEvent types sent via SSE to the frontend.
const (
StreamEventPlanning = "planning"
@@ -341,8 +519,12 @@ const (
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
func (a *Agent) buildSystemPrompt(lang string) string {
return a.buildSystemPromptForStoreUser(lang, "default")
}
func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string {
// Gather live system state
traderInfo := a.getTradersSummary()
traderInfo := a.getTradersSummaryForStoreUser(storeUserID)
watchlist := ""
if a.sentinel != nil {
watchlist = a.sentinel.FormatWatchlist(lang)
@@ -382,19 +564,24 @@ func (a *Agent) buildSystemPrompt(lang string) string {
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易加密货币或美股。美股open_long=买入close_long=卖出。调用后创建待确认订单,用户回复"确认 trade_xxx"。
- **execute_trade** — 下单交易(加密货币或美股)。常见写法:"做多 BTC 0.01 x10"、"做空 ETH 0.1"、"平多 BTC"、"平空 ETH";英文也支持 "long BTC 0.01 x10"、"short ETH 0.1"、"close long BTC"、"close short ETH"。美股open_long=买入close_long=卖出。调用后创建待确认订单,不会立刻成交。若触发大额风控,用户必须回复"确认大额 trade_xxx";待确认订单 5 分钟后自动失效
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
- **get_balance** — 查看账户余额
- **get_market_price** — 获取实时价格(加密货币或股票代码)
- **get_kline** — 获取最近 K 线 / 蜡烛图数据(适合“看 15 分钟 K 线”“最近 50 根 1 小时 K 线”)
- **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置
- **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
- **get_watchlist / manage_watchlist** — 查看、添加、移除运行时监控币对,适合“把 BTC 加入监控”“别再监控 SOL”这类请求
### 配置、策略与交易员管理规则
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
- **策略模板创建成功后应立即出现在策略列表/策略页**
- **策略模板不能直接启动或运行;只有交易员有运行态。**
- 如果用户说“启动策略 / 运行策略”,要明确说明:应先把策略绑定到交易员,再启动交易员
- 用户没问运行/部署/创建交易员时,不要主动延伸到交易员、模型或交易所绑定
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时优先使用 manage_exchange_config
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时优先使用 manage_model_config
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
@@ -406,9 +593,10 @@ func (a *Agent) buildSystemPrompt(lang string) string {
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 下单前先尊重风控:数量过大、仓位太小、杠杆过高、超过权益上限时,不要假装能下单,要直接用人话解释原因
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
- 提醒用户确认命令格式;普通订单用“确认 trade_xxx”大额订单用“确认大额 trade_xxx”
### 数据真实性规则(极其重要!)
- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓
@@ -419,6 +607,10 @@ func (a *Agent) buildSystemPrompt(lang string) string {
- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓"
## 行为准则
- 把用户当交易小白,而不是开发者或量化工程师。
- 先说结论,再说原因和下一步。
- 语言要简单、清楚、直接,少用术语。
- 如果必须用术语,立刻用大白话解释。
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
@@ -461,10 +653,11 @@ func (a *Agent) buildSystemPrompt(lang string) string {
## Tools
You can call these tools to take action:
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation.
- **execute_trade** — Place a trade order (crypto or US stocks). Common phrasings include "long BTC 0.01 x10", "short ETH 0.1", "close long BTC", and "close short ETH". For stocks: open_long=buy, close_long=sell. This creates a pending trade first; it does not execute immediately. Large orders require "confirm large trade_xxx", and pending trades expire after 5 minutes.
- **get_positions** — View all current open positions (crypto + stocks)
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
- **get_kline** — Get recent candlestick / kline data for a crypto symbol
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
@@ -473,10 +666,14 @@ You can call these tools to take action:
### Configuration, Strategy, and Trader Rules
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
- **After creation, a strategy template should immediately appear in the strategy list/page**
- **A strategy template cannot be started or run directly; only traders have runtime state**
- If the user says "start the strategy" or "run this strategy", explain that the strategy must be attached to a trader first, then the trader can be started
- Do not proactively bring up traders, models, or exchange bindings unless the user asks to run, deploy, or create a trader
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
- When the user wants to bind or edit an AI model, prefer manage_model_config
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
- When the user wants to add, remove, or inspect monitored coins, prefer get_watchlist / manage_watchlist
- If required fields are missing, ask a focused follow-up question first, then call the tool
- **Do not claim the system lacks these capabilities when the tools exist**
- For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full
@@ -485,9 +682,10 @@ You can call these tools to take action:
### Trade Safety Rules
- Only call execute_trade when user explicitly requests a trade
- Respect risk guardrails before placing a trade: if the quantity is too large, the notional is too small, leverage is too high, or the order exceeds equity limits, explain the reason plainly instead of pretending it can be placed
- Analysis and advice don't need tools — just reply directly
- Show trade details clearly: symbol, direction, quantity, leverage
- Remind user of the confirmation command format
- Remind user of the confirmation command format; normal orders use "confirm trade_xxx", large orders use "confirm large trade_xxx"
### Data Truthfulness Rules (CRITICAL!)
- **Position data MUST come from get_positions tool only** — NEVER fabricate positions
@@ -498,6 +696,10 @@ You can call these tools to take action:
- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding"
## Behavior
- Treat the user like a trading beginner, not a developer.
- Lead with the conclusion first, then explain the reason and next step.
- Use plain language and keep jargon to a minimum.
- If you must use a technical term, explain it in simple words immediately.
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- With real-time data: give specific levels. Without: give strategy frameworks.
@@ -508,7 +710,7 @@ Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-
}
// gatherContext collects real-time market data relevant to the user's message.
func (a *Agent) gatherContext(text string) string {
func (a *Agent) gatherContext(storeUserID, text string) string {
var parts []string
upper := strings.ToUpper(text)
@@ -573,8 +775,16 @@ func (a *Agent) gatherContext(text string) string {
}
// Trader positions
if a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
if a.traderManager != nil && a.store != nil {
traderConfigs, _ := a.store.Trader().List(storeUserID)
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
positions, err := t.GetPositions()
if err != nil {
continue
@@ -594,27 +804,51 @@ func (a *Agent) gatherContext(text string) string {
}
func (a *Agent) getTradersSummary() string {
return a.getTradersSummaryForStoreUser("default")
}
func (a *Agent) getTradersSummaryForStoreUser(storeUserID string) string {
if a.traderManager == nil {
return "Traders: none configured"
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
if a.store == nil {
return "Traders: none configured"
}
if strings.TrimSpace(storeUserID) == "" {
storeUserID = "default"
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil || len(traderConfigs) == 0 {
return "Traders: none configured"
}
var lines []string
for id, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
isRunning := traderCfg.IsRunning
exchange := traderCfg.ExchangeID
if err == nil && t != nil {
s := t.GetStatus()
if running, ok := s["is_running"].(bool); ok {
isRunning = running
}
exchange = t.GetExchange()
}
status := "stopped"
if running {
if isRunning {
status = "running"
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", traderCfg.Name, tid, status, exchange))
}
if len(lines) == 0 {
return "Traders: none configured"
}
return "Traders:\n" + strings.Join(lines, "\n")
}
@@ -642,7 +876,7 @@ func (a *Agent) handleStatus(L string) string {
}
// noAIFallback — when no AI is available, still try to be useful.
func (a *Agent) noAIFallback(lang, text string) (string, error) {
func (a *Agent) noAIFallback(storeUserID, lang, text string) (string, error) {
upper := strings.ToUpper(text)
// Try to provide market data directly
@@ -657,16 +891,16 @@ func (a *Agent) noAIFallback(lang, text string) (string, error) {
// Check if asking about positions/balance
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
return a.queryPositionsDirect(lang)
return a.queryPositionsDirect(storeUserID, lang)
}
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
return a.queryBalancesDirect(lang)
return a.queryBalancesDirect(storeUserID, lang)
}
if lang == "zh" {
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用\n• 加密货币实时行情试试「BTC」\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用\n• 加密货币实时行情试试「BTC」\n• `/status` 查看系统状态\n• `/clear` 清空当前对话记忆\n\n发送 *开始配置* 配置 AI 模型。", nil
}
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` to check system status\n• `/clear` to clear the current conversation memory\n\nSend *setup* to configure AI.", nil
}
func (a *Agent) aiServiceFailure(lang string, err error) (string, error) {
@@ -676,19 +910,89 @@ func (a *Agent) aiServiceFailure(lang string, err error) (string, error) {
}
a.logger.Error("AI service call failed", "error", reason)
if lang == "zh" {
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n%s", reason, aiServiceFailureGuidance("zh", reason)), nil
}
return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), nil
return fmt.Sprintf("The AI service call failed: %s\n\n%s", reason, aiServiceFailureGuidance(lang, reason)), nil
}
func (a *Agent) queryPositionsDirect(L string) (string, error) {
func aiServiceFailureGuidance(lang, reason string) string {
lower := strings.ToLower(strings.TrimSpace(reason))
looksLikeHTMLGateway := strings.Contains(lower, "invalid character '<'") ||
strings.Contains(lower, "unexpected character '<'") ||
strings.Contains(lower, "<html") ||
strings.Contains(lower, "<!doctype html")
looksLikeUpstreamEmptyOutput := strings.Contains(lower, "upstream_empty_output") ||
(strings.Contains(lower, "empty output") && strings.Contains(lower, "rate_limit_error"))
looksLikeRateLimit := strings.Contains(lower, "status 429") ||
strings.Contains(lower, "rate limit") ||
strings.Contains(lower, "rate_limit_error")
looksLikeBannedAccount := strings.Contains(lower, "user_is_banned") ||
strings.Contains(lower, "account is banned") ||
strings.Contains(lower, "account banned")
looksLikeAuthFailure := strings.Contains(lower, "status 401") ||
strings.Contains(lower, "authentication_failed") ||
strings.Contains(lower, "authentication_error") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "invalid api key")
if lang == "zh" {
if looksLikeHTMLGateway {
return "这不是“未配置模型”。这次更像是上游返回了 HTML 页面或网关/反代错误页,而不是标准 JSON 响应。更可能原因是模型服务地址配错、网关拦截、支付/鉴权页返回、或上游服务临时异常。请优先检查当前启用模型的 custom_api_url、反向代理/网关状态,以及对应 provider 的服务状态。"
}
if looksLikeBannedAccount {
return "这不是“未配置模型”。当前启用模型已经连到了上游,但上游明确拒绝登录,原因是账号被禁用/封禁USER_IS_BANNED。请检查当前启用模型配置对应的账号或 API Key换一个可用账号/API Key或切换到另一个已启用模型后再试。"
}
if looksLikeAuthFailure {
return "这不是“未配置模型”。当前启用模型已经连到了上游,但鉴权失败了。请检查当前启用模型的 API Key、钱包凭证、provider 账号状态和 custom_api_url 是否匹配;修复凭证或切换到另一个可用模型后再试。"
}
if looksLikeUpstreamEmptyOutput {
return "这不是“未配置模型”。这次更像是上游模型没有返回有效内容,当前 provider 把它包装成了 429 / rate_limit_error。更可能原因是上游临时限流、服务拥塞、模型空响应或 provider 网关没有拿到有效结果;不应优先归因成“余额不足”。请先重试一次;如果持续出现,再检查当前启用模型的 provider 状态、限流配额、网关日志,或先切换到另一个可用模型。"
}
if looksLikeRateLimit {
return "这不是“未配置模型”。这次更像是当前模型 provider 触发了限流或网关节流。更可能原因是并发过高、调用频率超限、provider 临时拥塞,或上游配额限制。请先稍后重试;如果持续出现,再检查当前启用模型的 provider 配额、限流策略和网关状态。"
}
return "这不是“未配置模型”。更可能是模型服务余额不足、接口报错、鉴权失败或超时。请检查当前启用模型的 API 状态后再试。"
}
if looksLikeHTMLGateway {
return "This is not a missing-model issue. It looks more like the upstream returned an HTML page or gateway/proxy error page instead of the expected JSON response. The likely causes are a wrong model endpoint URL, gateway interception, a payment/auth page being returned, or a temporary upstream outage. Check the active model's custom_api_url, proxy/gateway status, and the provider service health first."
}
if looksLikeBannedAccount {
return "This is not a missing-model issue. The active model reached the upstream provider, but login was rejected because the account is banned (USER_IS_BANNED). Check the active model account/API key, replace it with a usable credential, or switch to another enabled model."
}
if looksLikeAuthFailure {
return "This is not a missing-model issue. The active model reached the upstream provider, but authentication failed. Check the active model API key, wallet credential, provider account status, and custom_api_url, or switch to another enabled model."
}
if looksLikeUpstreamEmptyOutput {
return "This is not a missing-model issue. The upstream model appears to have returned no usable output, and the provider wrapped it as a 429 / rate_limit_error. The more likely causes are temporary throttling, upstream congestion, an empty model response, or a gateway that did not receive a valid result. Do not treat this as an insufficient-balance issue first. Retry once, then check the active provider status, rate limits, gateway logs, or switch to another model."
}
if looksLikeRateLimit {
return "This is not a missing-model issue. The active model provider more likely hit rate limiting or gateway throttling. Check the provider quota, rate-limit policy, and gateway status, then retry."
}
return "This is not a missing-model issue. The active model provider more likely returned an API error, authentication failure, timeout, or insufficient-balance response. Please check the active model API and try again."
}
func (a *Agent) queryPositionsDirect(storeUserID, L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
if a.store == nil {
return a.msg(L, "no_traders"), nil
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("📊 *Positions*\n\n")
hasAny := false
for id, t := range a.traderManager.GetAllTraders() {
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
positions, err := t.GetPositions()
if err != nil {
continue
@@ -704,7 +1008,7 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) {
if pnl < 0 {
e = "🔴"
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
@@ -717,18 +1021,32 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) {
return sb.String(), nil
}
func (a *Agent) queryBalancesDirect(L string) (string, error) {
func (a *Agent) queryBalancesDirect(storeUserID, L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
if a.store == nil {
return a.msg(L, "no_traders"), nil
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("💰 *Balance*\n\n")
for id, t := range a.traderManager.GetAllTraders() {
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}

View File

@@ -0,0 +1,53 @@
package agent
import (
"log/slog"
"path/filepath"
"testing"
"nofx/store"
)
func TestLoadAIClientFromStoreUserPrefersModelWithBalance(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-selection.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", true, "sk-test", "", "gpt-5.2"); err != nil {
t.Fatalf("create openai model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "wallet_claw402", "Claw402", true, "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", "", "glm-5"); err != nil {
t.Fatalf("create claw402 model: %v", err)
}
restoreWalletAddress := agentWalletAddressFromPrivateKey
restoreBalanceQuery := agentQueryUSDCBalanceCached
t.Cleanup(func() {
agentWalletAddressFromPrivateKey = restoreWalletAddress
agentQueryUSDCBalanceCached = restoreBalanceQuery
})
agentWalletAddressFromPrivateKey = func(privateKey string) (string, error) {
if privateKey == "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca" {
return "0xabc", nil
}
return "", nil
}
agentQueryUSDCBalanceCached = func(address string) (float64, error) {
if address == "0xabc" {
return 12.5, nil
}
return 0, nil
}
a := New(nil, st, DefaultConfig(), slog.Default())
_, modelName, ok := a.loadAIClientFromStoreUser("default")
if !ok {
t.Fatalf("expected model selection to succeed")
}
if modelName != "glm-5" {
t.Fatalf("expected model with wallet balance to be selected, got %q", modelName)
}
}

View File

@@ -0,0 +1,128 @@
package agent
import (
"errors"
"log/slog"
"strings"
"testing"
)
func TestAIServiceFailureHighlightsHTMLGatewayResponse(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New("fail to parse AI server response: failed to parse response: invalid character '<' looking for beginning of value"))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游返回了 HTML 页面或网关/反代错误页",
"custom_api_url",
"不是“未配置模型”",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错或超时") {
t.Fatalf("html parse error should not use the generic balance/timeout-only guidance: %s", msg)
}
}
func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","param":null,"type":"rate_limit_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游模型没有返回有效内容",
"不应优先归因成“余额不足”",
"切换到另一个可用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错、鉴权失败或超时") {
t.Fatalf("upstream empty output should not use the generic balance/auth/timeout guidance: %s", msg)
}
}
func TestAIServiceFailureHighlightsBannedAccountAuthFailure(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 401): {"error":{"code":"authentication_failed","message":"login failed: USER_IS_BANNED","param":null,"type":"authentication_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"账号被禁用/封禁",
"USER_IS_BANNED",
"换一个可用账号/API Key",
"切换到另一个已启用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
for _, unexpected := range []string{"余额不足", "超时"} {
if strings.Contains(msg, unexpected) {
t.Fatalf("banned account auth failure should not mention %q: %s", unexpected, msg)
}
}
}
func TestCompletedPlanFallbackDoesNotExposeFinalSummaryFailure(t *testing.T) {
msg := formatCompletedPlanFallback("zh", []PlanStep{
{
Type: planStepTypeTool,
Status: planStepStatusCompleted,
Title: "创建名为 eeg 的策略",
},
})
if msg == "" {
t.Fatalf("expected fallback message")
}
for _, bad := range []string{"失败", "AI", "稍后"} {
if strings.Contains(msg, bad) {
t.Fatalf("fallback should not expose final summary failure %q: %s", bad, msg)
}
}
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
t.Fatalf("fallback should summarize completed work, got: %s", msg)
}
}
func TestDeterministicCompletedPlanResponseSkipsLLMForSimpleConfirmation(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{
ID: "create_strategy",
Type: planStepTypeTool,
Status: planStepStatusCompleted,
Title: "创建名为 eeg 的策略",
},
{
ID: "respond",
Type: planStepTypeRespond,
Status: planStepStatusRunning,
Title: "策略创建成功",
Instruction: "确认策略创建成功",
},
},
}
msg := deterministicCompletedPlanResponse("zh", state, state.Steps[1])
if msg == "" {
t.Fatalf("expected deterministic response")
}
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
t.Fatalf("unexpected deterministic response: %s", msg)
}
}

View File

@@ -0,0 +1,87 @@
package agent
import "strings"
func (a *Agent) executeAtomicSkillTask(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (string, bool) {
return a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent)
}
func (a *Agent) executeAtomicSkillTaskWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (string, bool) {
skill := strings.TrimSpace(session.Name)
action := normalizeAtomicSkillAction(skill, session.Action)
session.Name = skill
session.Action = action
if strings.TrimSpace(session.Phase) == "" {
session.Phase = "collecting"
}
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
var (
answer string
handled bool
)
switch skill {
case "trader_management":
if action == "create" {
answer, handled = a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
} else {
answer, handled = a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
}
}
case "exchange_management":
answer, handled = a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
case "model_management":
answer, handled = a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
case "strategy_management":
answer, handled = a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
case "model_diagnosis":
answer, handled = a.handleModelDiagnosisSkill(storeUserID, lang, text), true
case "exchange_diagnosis":
answer, handled = a.handleExchangeDiagnosisSkill(storeUserID, lang, text), true
case "trader_diagnosis":
answer, handled = a.handleTraderDiagnosisSkill(storeUserID, lang, text), true
case "strategy_diagnosis":
answer, handled = a.handleStrategyDiagnosisSkill(storeUserID, lang, text), true
default:
return "", false
}
if handled && onEvent != nil {
label := "atomic_skill:" + skill
if action != "" {
label += ":" + action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, handled
}
func (a *Agent) executeAtomicSkillTaskOutcome(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (skillOutcome, bool) {
return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent)
}
func (a *Agent) executeAtomicSkillTaskOutcomeWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (skillOutcome, bool) {
answer, handled := a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, session, onEvent)
if !handled {
return skillOutcome{}, false
}
skill := strings.TrimSpace(session.Name)
action := normalizeAtomicSkillAction(skill, session.Action)
switch skill {
case "model_diagnosis", "exchange_diagnosis", "trader_diagnosis", "strategy_diagnosis":
return skillOutcome{
Skill: skill,
Action: defaultIfEmpty(action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: answer,
}, true
default:
return inferSkillOutcome(skill, action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, skill, action, a)), true
}
}

View File

@@ -1,127 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account",
"04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
path, entries, err := readBackendLogEntries(10, "model", true)
if err != nil {
t.Fatalf("readBackendLogEntries() error = %v", err)
}
if !strings.Contains(path, "nofx_2099-01-01.log") {
t.Fatalf("unexpected log path: %s", path)
}
if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") {
t.Fatalf("unexpected filtered entries: %#v", entries)
}
}
func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed",
"04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
a := newTestAgentWithStore(t)
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-owned",
UserID: "user-1",
Name: "Owned Trader",
AIModelID: "model-1",
ExchangeID: "exchange-1",
StrategyID: "strategy-1",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create owned trader: %v", err)
}
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-other",
UserID: "user-2",
Name: "Other Trader",
AIModelID: "model-2",
ExchangeID: "exchange-2",
StrategyID: "strategy-2",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create other trader: %v", err)
}
resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`)
var okResult struct {
TraderID string `json:"trader_id"`
Entries []string `json:"entries"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(resp), &okResult); err != nil {
t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp)
}
if okResult.TraderID != "trader-owned" || okResult.Count != 1 {
t.Fatalf("unexpected owned response: %+v", okResult)
}
if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") {
t.Fatalf("unexpected owned entries: %#v", okResult.Entries)
}
resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`)
var denied struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resp), &denied); err != nil {
t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp)
}
if denied.Error != "trader not found for current user" {
t.Fatalf("unexpected denied response: %+v", denied)
}
}

View File

@@ -30,7 +30,11 @@ func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
}
}
func (b *Brain) Stop() { b.stopOnce.Do(func() { close(b.stopCh) }) }
func (b *Brain) Stop() {
b.stopOnce.Do(func() {
close(b.stopCh)
})
}
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
@@ -54,22 +58,26 @@ func (b *Brain) HandleSignal(sig Signal) {
emoji := map[string]string{"info": "", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
if e == "" { e = "📊" }
if e == "" {
e = "📊"
}
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
}
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
seenOrder := make([]string, 0, 1024)
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
cleanTick := 0
for {
select {
case <-b.stopCh: return
case <-b.stopCh:
return
case <-ticker.C:
b.scanNews(seen)
b.scanNews(seen, &seenOrder)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
@@ -79,16 +87,20 @@ func (b *Brain) StartNewsScan(interval time.Duration) {
})
}
func (b *Brain) scanNews(seen map[string]bool) {
func (b *Brain) scanNews(seen map[string]bool, seenOrder *[]string) {
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
if err != nil { return }
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.logger.Debug("news API non-200", "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
if err != nil { return }
if err != nil {
return
}
var result struct {
Data []struct {
@@ -100,39 +112,65 @@ func (b *Brain) scanNews(seen map[string]bool) {
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil { return }
if err := json.Unmarshal(body, &result); err != nil {
return
}
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
for _, d := range result.Data {
if seen[d.URL] { continue }
if seen[d.URL] {
continue
}
seen[d.URL] = true
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
*seenOrder = append(*seenOrder, d.URL)
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute {
continue
}
lower := strings.ToLower(d.Title + " " + d.Body)
bc, brc := 0, 0
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
for _, w := range bullish {
if strings.Contains(lower, w) {
bc++
}
}
for _, w := range bearish {
if strings.Contains(lower, w) {
brc++
}
}
if bc == 0 && brc == 0 { continue }
if bc == 0 && brc == 0 {
continue
}
emoji := "📰"
sentiment := "NEUTRAL"
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
if bc > brc {
emoji = "🟢"
sentiment = "BULLISH"
}
if brc > bc {
emoji = "🔴"
sentiment = "BEARISH"
}
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
emoji, d.Title, d.Source, sentiment))
}
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
// Evict the oldest half when seen grows large so recent URLs stay deduped deterministically.
if len(seen) > 1000 {
i, half := 0, len(seen)/2
for k := range seen {
if i >= half { break }
delete(seen, k)
i++
half := len(seen) / 2
for i := 0; i < half && i < len(*seenOrder); i++ {
delete(seen, (*seenOrder)[i])
}
if half < len(*seenOrder) {
*seenOrder = append((*seenOrder)[:0], (*seenOrder)[half:]...)
} else {
*seenOrder = (*seenOrder)[:0]
}
}
}
@@ -144,7 +182,8 @@ func (b *Brain) StartMarketBriefs(hours []int) {
sent := make(map[string]bool)
for {
select {
case <-b.stopCh: return
case <-b.stopCh:
return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
@@ -160,21 +199,35 @@ func (b *Brain) StartMarketBriefs(hours []int) {
func (b *Brain) sendBrief(hour int) {
title := "☀️ *早间市场简报*"
if hour >= 18 { title = "🌙 *晚间市场简报*" }
if hour >= 18 {
title = "🌙 *晚间市场简报*"
}
// Fetch BTC/ETH prices for the brief
var btcPrice, ethPrice, btcChg, ethChg string
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
if err != nil { continue }
if err != nil {
continue
}
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
if readErr != nil || !statusOK { continue }
if readErr != nil || !statusOK {
continue
}
var t map[string]string
if err := json.Unmarshal(body, &t); err != nil { continue }
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
if err := json.Unmarshal(body, &t); err != nil {
continue
}
if sym == "BTCUSDT" {
btcPrice = t["lastPrice"]
btcChg = t["priceChangePercent"]
}
if sym == "ETHUSDT" {
ethPrice = t["lastPrice"]
ethChg = t["priceChangePercent"]
}
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",

1489
agent/central_brain.go Normal file

File diff suppressed because it is too large Load Diff

116
agent/clear_memory_test.go Normal file
View File

@@ -0,0 +1,116 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestClearRemovesActiveAndPendingConversationState(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-clear.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
userID := int64(42)
a.history.Add(userID, "assistant", "之前的回复")
_ = a.saveTaskState(userID, TaskState{CurrentGoal: "配置模型"})
a.saveActiveSkillSession(ActiveSkillSession{
SessionID: "as_test",
UserID: userID,
SkillName: "model_management",
ActionName: "create",
PendingHint: &PendingHint{
Prompt: "请选择 provider",
HintType: "question",
},
})
a.savePendingProposalSession(PendingProposalSession{
UserID: userID,
SourceUserText: "帮我配置模型",
ProposalText: "推荐 claw402你要继续吗",
})
a.saveSetupState(userID, &SetupState{
Step: "await_ai_model",
AIProvider: "claw402",
})
if err := st.SetSystemConfig(skillSessionConfigKey(userID), `{"name":"model_management","action":"create"}`); err != nil {
t.Fatalf("seed skill session: %v", err)
}
a.saveWorkflowSession(userID, WorkflowSession{
Tasks: []WorkflowTask{{
ID: "task_1",
Skill: "model_management",
Action: "create",
Request: "帮我配置模型",
Status: workflowTaskPending,
}},
})
if err := st.SetSystemConfig(ExecutionStateConfigKey(userID), `{"user_id":42,"session_id":"exec_1"}`); err != nil {
t.Fatalf("seed execution state: %v", err)
}
a.saveReferenceMemory(userID, &CurrentReferences{
Model: &EntityReference{ID: "m1", Name: "claw402", Source: "context"},
}, nil)
a.SnapshotManager(userID).Save(SuspendedTask{ResumeHint: "旧任务"})
reply, err := a.HandleMessage(context.Background(), userID, "/clear")
if err != nil {
t.Fatalf("clear returned error: %v", err)
}
if reply == "" {
t.Fatalf("expected clear reply")
}
if got := a.history.Get(userID); len(got) != 0 {
t.Fatalf("history not cleared: %+v", got)
}
if got := a.buildRecentConversationContext(userID, "你好"); got != "" {
t.Fatalf("recent conversation context not cleared: %q", got)
}
if got := a.currentPendingHintText(userID); got != "" {
t.Fatalf("pending hint not cleared: %q", got)
}
if got := a.buildCurrentTurnContext(userID, "zh", "你好"); got != "" {
if strings.Contains(got, "Previous assistant reply:") || strings.Contains(got, "Recent conversation:") {
t.Fatalf("current turn context still contains prior chat memory: %q", got)
}
}
if got := a.buildActiveTaskStateContext(userID, "zh"); got != "" {
t.Fatalf("active task state context not cleared: %q", got)
}
if state := a.getTaskState(userID); state.CurrentGoal != "" || state.ActiveFlow != "" {
t.Fatalf("task state not cleared: %+v", state)
}
if _, ok := a.getActiveSkillSession(userID); ok {
t.Fatalf("active skill session not cleared")
}
if _, ok := a.getPendingProposalSession(userID); ok {
t.Fatalf("pending proposal session not cleared")
}
if session := a.getSkillSession(userID); session.Name != "" {
t.Fatalf("legacy skill session not cleared: %+v", session)
}
if session := a.getWorkflowSession(userID); len(session.Tasks) != 0 {
t.Fatalf("workflow session not cleared: %+v", session)
}
if state := a.getExecutionState(userID); state.SessionID != "" {
t.Fatalf("execution state not cleared: %+v", state)
}
if memory := a.getReferenceMemory(userID); memory.CurrentReferences != nil || len(memory.ReferenceHistory) != 0 {
t.Fatalf("reference memory not cleared: %+v", memory)
}
if stack := a.SnapshotManager(userID).List(); len(stack) != 0 {
t.Fatalf("snapshots not cleared: %+v", stack)
}
if setup := a.getSetupState(userID); setup.Step != "" || setup.AIProvider != "" {
t.Fatalf("setup state not cleared: %+v", setup)
}
}

View File

@@ -1,387 +0,0 @@
package agent
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func newTestAgentWithStore(t *testing.T) *Agent {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("create test store: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return &Agent{store: st}
}
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true,
"testnet":true
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
}
updateResp := a.toolManageExchangeConfig("user-1", `{
"action":"update",
"exchange_id":"`+created.Exchange.ID+`",
"account_name":"Renamed",
"enabled":false
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
}
deleteResp := a.toolManageExchangeConfig("user-1", `{
"action":"delete",
"exchange_id":"`+created.Exchange.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
t.Fatalf("unexpected model payload: %+v", created.Model)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":false,
"custom_model_name":"gpt-5"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
}
deleteResp := a.toolManageModelConfig("user-1", `{
"action":"delete",
"model_id":"`+created.Model.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":false,
"custom_model_name":"gpt-4o"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":true
}`)
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
}
}
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
incompleteCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_model_name":"gpt-4o"
}`)
var incomplete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
}
completeCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_model_name":"deepseek-chat"
}`)
var complete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
}
model, err := a.store.AIModel().GetDefault("user-1")
if err != nil {
t.Fatalf("GetDefault() error = %v", err)
}
if model.ID != complete.Model.ID {
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
}
}
func TestToolManageTraderLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create trader response: %+v", created)
}
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
t.Fatalf("unexpected created trader: %+v", created.Trader)
}
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
var listed struct {
Count int `json:"count"`
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
}
if listed.Count != 1 || len(listed.Traders) != 1 {
t.Fatalf("unexpected trader list: %+v", listed)
}
updateResp := a.toolManageTrader("user-1", `{
"action":"update",
"trader_id":"`+created.Trader.ID+`",
"name":"Renamed Trader",
"scan_interval_minutes":8
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
}
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
}
deleteResp := a.toolManageTrader("user-1", `{
"action":"delete",
"trader_id":"`+created.Trader.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete trader response: %+v", deleted)
}
}
func TestToolManageStrategyLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
}
listResp := a.toolGetStrategies("user-1")
if !strings.Contains(listResp, "激进") {
t.Fatalf("expected created strategy in list, got %s", listResp)
}
updateResp := a.toolManageStrategy("user-1", `{
"action":"update",
"strategy_id":"`+created.Strategy.ID+`",
"description":"更新后的描述"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Strategy.Description != "更新后的描述" {
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
}
activateResp := a.toolManageStrategy("user-1", `{
"action":"activate",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(activateResp, `"action":"activate"`) {
t.Fatalf("unexpected activate response: %s", activateResp)
}
deleteResp := a.toolManageStrategy("user-1", `{
"action":"delete",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(deleteResp, `"action":"delete"`) {
t.Fatalf("unexpected delete response: %s", deleteResp)
}
}
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
a := newTestAgentWithStore(t)
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
t.Fatalf("seed model: %v", err)
}
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
if !ok {
t.Fatal("expected AI client to load from user-specific model")
}
if client == nil {
t.Fatal("expected non-nil AI client")
}
if modelName != "gpt-5-mini" {
t.Fatalf("unexpected model name: %s", modelName)
}
// After the provider registry refactor, registered providers (like openai)
// return their own AIClient implementation, not *mcp.Client.
if client == nil {
t.Fatal("expected non-nil AI client from provider registry")
}
}

466
agent/config_validation.go Normal file
View File

@@ -0,0 +1,466 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"nofx/security"
"nofx/store"
)
type ConfigValidationResult struct {
Warnings []string
}
type ConfigValidator interface {
Validate() error
}
var (
openAIAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9\-_]{4,}$`)
genericAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_\-]{8,}$`)
hexCredentialPattern = regexp.MustCompile(`^(0x)?[A-Fa-f0-9]{16,}$`)
supportedModelProvider = map[string]struct{}{
"openai": {}, "deepseek": {}, "claude": {}, "gemini": {}, "qwen": {}, "kimi": {}, "grok": {}, "minimax": {}, "claw402": {}, "blockrun-base": {}, "blockrun-sol": {},
}
)
const (
manualTraderScanIntervalMin = 3
manualTraderScanIntervalMax = 60
manualTraderInitialBalance = 100.0
manualLighterAPIKeyIndexMin = 0
manualLighterAPIKeyIndexMax = 255
)
type modelConfigValidator struct {
provider string
enabled bool
apiKey string
customAPIURL string
customModelName string
modelID string
}
func (v modelConfigValidator) Validate() error {
provider := strings.ToLower(strings.TrimSpace(v.provider))
if provider == "" {
return fmt.Errorf("provider is required")
}
if _, ok := supportedModelProvider[provider]; !ok {
return fmt.Errorf("unsupported provider: %s", provider)
}
if trimmed := strings.TrimSpace(v.customAPIURL); trimmed != "" {
if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil {
return fmt.Errorf("invalid custom_api_url: %w", err)
}
}
if v.enabled && !modelConfigUsable(provider, v.modelID, strings.TrimSpace(v.apiKey), strings.TrimSpace(v.customAPIURL), strings.TrimSpace(v.customModelName)) {
return fmt.Errorf("cannot enable model config before a usable API key, URL, and model are configured")
}
if provider == "openai" && strings.TrimSpace(v.apiKey) != "" && !openAIAPIKeyPattern.MatchString(strings.TrimSpace(v.apiKey)) {
return fmt.Errorf("OpenAI API Key format looks invalid")
}
return nil
}
type exchangeConfigValidator struct {
exchangeType string
enabled bool
apiKey string
secretKey string
passphrase string
hyperliquidWalletAddr string
asterUser string
asterSigner string
asterPrivateKey string
lighterWalletAddr string
lighterPrivateKey string
lighterAPIKeyPrivateKey string
}
func (v exchangeConfigValidator) Validate() error {
exchangeType := strings.ToLower(strings.TrimSpace(v.exchangeType))
if exchangeType == "" {
return fmt.Errorf("exchange_type is required")
}
if trimmed := strings.TrimSpace(v.apiKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) {
return fmt.Errorf("API Key format looks invalid")
}
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
return fmt.Errorf("Secret format looks invalid")
}
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
}
type traderBindingValidator struct {
store *store.Store
storeUserID string
aiModelID string
exchangeID string
strategyID string
}
func (v traderBindingValidator) Validate() error {
if v.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(v.aiModelID) == "" {
return fmt.Errorf("ai_model_id is required")
}
if strings.TrimSpace(v.exchangeID) == "" {
return fmt.Errorf("exchange_id is required")
}
model, err := v.store.AIModel().Get(v.storeUserID, strings.TrimSpace(v.aiModelID))
if err != nil {
return fmt.Errorf("invalid ai_model_id: %w", err)
}
if !model.Enabled {
return fmt.Errorf("ai model is disabled")
}
if !modelConfigUsable(model.Provider, model.ID, strings.TrimSpace(string(model.APIKey)), strings.TrimSpace(model.CustomAPIURL), strings.TrimSpace(model.CustomModelName)) {
return fmt.Errorf("ai model config is incomplete")
}
exchange, err := v.store.Exchange().GetByID(v.storeUserID, strings.TrimSpace(v.exchangeID))
if err != nil {
return fmt.Errorf("invalid exchange_id: %w", err)
}
if !exchange.Enabled {
return fmt.Errorf("exchange is disabled")
}
if err := (exchangeConfigValidator{
exchangeType: exchange.ExchangeType,
enabled: exchange.Enabled,
apiKey: strings.TrimSpace(string(exchange.APIKey)),
secretKey: strings.TrimSpace(string(exchange.SecretKey)),
passphrase: strings.TrimSpace(string(exchange.Passphrase)),
hyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
asterUser: exchange.AsterUser,
asterSigner: exchange.AsterSigner,
asterPrivateKey: strings.TrimSpace(string(exchange.AsterPrivateKey)),
lighterWalletAddr: exchange.LighterWalletAddr,
lighterPrivateKey: strings.TrimSpace(string(exchange.LighterPrivateKey)),
lighterAPIKeyPrivateKey: strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)),
}).Validate(); err != nil {
return fmt.Errorf("exchange config is incomplete: %w", err)
}
if trimmed := strings.TrimSpace(v.strategyID); trimmed != "" {
if _, err := v.store.Strategy().Get(v.storeUserID, trimmed); err != nil {
return fmt.Errorf("invalid strategy_id: %w", err)
}
}
return nil
}
func (a *Agent) validateModelDraft(storeUserID, modelID, provider string, enabled bool, apiKey, customAPIURL, customModelName string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(provider) == "" && strings.TrimSpace(modelID) != "" {
model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(modelID))
if err != nil {
return err
}
provider = model.Provider
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(model.APIKey))
}
if strings.TrimSpace(customAPIURL) == "" {
customAPIURL = strings.TrimSpace(model.CustomAPIURL)
}
if strings.TrimSpace(customModelName) == "" {
customModelName = strings.TrimSpace(model.CustomModelName)
}
}
return (modelConfigValidator{
provider: provider,
enabled: enabled,
apiKey: apiKey,
customAPIURL: customAPIURL,
customModelName: customModelName,
modelID: modelID,
}).Validate()
}
func (a *Agent) validateExchangeDraft(storeUserID, exchangeID, exchangeType string, enabled bool, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(exchangeType) == "" && strings.TrimSpace(exchangeID) != "" {
exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID))
if err != nil {
return err
}
exchangeType = exchange.ExchangeType
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(exchange.APIKey))
}
if strings.TrimSpace(secretKey) == "" {
secretKey = strings.TrimSpace(string(exchange.SecretKey))
}
if strings.TrimSpace(passphrase) == "" {
passphrase = strings.TrimSpace(string(exchange.Passphrase))
}
if strings.TrimSpace(hyperliquidWalletAddr) == "" {
hyperliquidWalletAddr = strings.TrimSpace(exchange.HyperliquidWalletAddr)
}
if strings.TrimSpace(asterUser) == "" {
asterUser = strings.TrimSpace(exchange.AsterUser)
}
if strings.TrimSpace(asterSigner) == "" {
asterSigner = strings.TrimSpace(exchange.AsterSigner)
}
if strings.TrimSpace(asterPrivateKey) == "" {
asterPrivateKey = strings.TrimSpace(string(exchange.AsterPrivateKey))
}
if strings.TrimSpace(lighterWalletAddr) == "" {
lighterWalletAddr = strings.TrimSpace(exchange.LighterWalletAddr)
}
if strings.TrimSpace(lighterAPIKeyPrivateKey) == "" {
lighterAPIKeyPrivateKey = strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey))
}
}
return (exchangeConfigValidator{
exchangeType: exchangeType,
enabled: enabled,
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
hyperliquidWalletAddr: hyperliquidWalletAddr,
asterUser: asterUser,
asterSigner: asterSigner,
asterPrivateKey: asterPrivateKey,
lighterWalletAddr: lighterWalletAddr,
lighterAPIKeyPrivateKey: lighterAPIKeyPrivateKey,
}).Validate()
}
func (a *Agent) validateTraderDraft(storeUserID, aiModelID, exchangeID, strategyID string) error {
return (traderBindingValidator{
store: a.store,
storeUserID: storeUserID,
aiModelID: aiModelID,
exchangeID: exchangeID,
strategyID: strategyID,
}).Validate()
}
func formatValidationFeedback(lang, domain string, err error) string {
if err == nil {
return ""
}
raw := strings.TrimSpace(err.Error())
lower := strings.ToLower(raw)
if lang == "zh" {
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。OpenAI 的 API Key 通常以 `sk-` 开头,请直接发完整 Key我继续帮你补进当前草稿。"
case strings.Contains(lower, "api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。请直接发完整的 API Key不要附带多余说明文字。"
case strings.Contains(lower, "secret format looks invalid"):
return "这份配置还有问题Secret 格式不对。请直接发完整的 Secret 值,不要和 API Key 填反。"
case strings.Contains(lower, "okx requires passphrase"):
return "这份配置还有问题OKX 账户缺少 Passphrase启用前需要补齐。你直接把 Passphrase 发我就行。"
case strings.Contains(lower, "hyperliquid requires wallet address"):
return "这份配置还有问题Hyperliquid 账户缺少钱包地址,启用前需要补齐。"
case strings.Contains(lower, "aster requires user, signer, and private key"):
return "这份配置还有问题Aster 账户还缺 user、signer 和 private key启用前需要补齐。"
case strings.Contains(lower, "lighter requires wallet address and api key private key"):
return "这份配置还有问题Lighter 账户还缺钱包地址和 API key private key启用前需要补齐。"
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "这份配置还有问题:要先把 API Key、接口地址和模型名称配完整才能启用。你可以继续把缺的字段发给我。"
case strings.Contains(lower, "unsupported provider"):
return "这份配置还有问题provider 不在支持范围内。请从 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax 里选一个。"
case strings.Contains(lower, "invalid custom_api_url"):
return "这份配置还有问题:接口地址格式不对。请给我完整的 URL或直接说使用默认地址。"
case strings.Contains(lower, "ai model is disabled"):
return "这份配置还有问题:绑定的模型当前是禁用状态。请换一个已启用模型,或先启用这个模型。"
case strings.Contains(lower, "exchange is disabled"):
return "这份配置还有问题:绑定的交易所当前已禁用。请换一个已启用交易所,或先启用这个交易所。"
case strings.Contains(lower, "ai model config is incomplete"):
return "这份配置还有问题:绑定的模型配置还没补完整,暂时不能使用。"
case strings.Contains(lower, "invalid ai_model_id"):
return "这份配置还有问题:模型引用无效。请明确告诉我你要绑定哪个模型。"
case strings.Contains(lower, "invalid exchange_id"):
return "这份配置还有问题:交易所引用无效。请明确告诉我你要绑定哪个交易所。"
case strings.Contains(lower, "invalid strategy_id"):
return "这份配置还有问题:策略引用无效。请明确告诉我你要绑定哪个策略。"
case strings.Contains(lower, "provider is required"):
return "这份配置还缺 provider。请先告诉我你要用哪个模型提供商。"
case strings.Contains(lower, "exchange_type is required"):
return "这份配置还缺交易所类型。请先告诉我你要接哪个交易所。"
}
switch domain {
case "model":
return "这份模型草稿还有问题:" + raw
case "exchange":
return "这份交易所草稿还有问题:" + raw
case "trader":
return "这份交易员草稿还有问题:" + raw
case "strategy":
return "这份策略草稿还有问题:" + raw
default:
return "这份配置还有问题:" + raw
}
}
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. OpenAI keys usually start with `sk-`. Send the full key and I'll keep filling the draft."
case strings.Contains(lower, "api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. Send the full API key directly."
case strings.Contains(lower, "secret format looks invalid"):
return "This draft still has an issue: the secret format looks wrong. Send the full secret value directly."
case strings.Contains(lower, "okx requires passphrase"):
return "This draft still has an issue: an OKX config needs a passphrase before it can be enabled. Send the passphrase and I'll keep going."
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "This draft still has an issue: the API key, endpoint URL, and model name must be completed before the config can be enabled."
}
switch domain {
case "model":
return "This model draft still has an issue: " + raw
case "exchange":
return "This exchange draft still has an issue: " + raw
case "trader":
return "This trader draft still has an issue: " + raw
case "strategy":
return "This strategy draft still has an issue: " + raw
default:
return "This draft still has an issue: " + raw
}
}
func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (traderUpdateArgs, []string) {
warnings := make([]string, 0, 2)
if args.ScanIntervalMinutes != nil {
requested := *args.ScanIntervalMinutes
normalized := requested
if normalized < manualTraderScanIntervalMin {
normalized = manualTraderScanIntervalMin
}
if normalized > manualTraderScanIntervalMax {
normalized = manualTraderScanIntervalMax
}
if normalized != requested {
args.ScanIntervalMinutes = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("扫描间隔手动可配置范围是 %d 到 %d 分钟,已从 %d 调整为 %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("scan interval is limited to %d-%d minutes in the manual config, adjusted from %d to %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
}
}
}
return args, warnings
}
func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,我已经先按风控范围收敛:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I normalized them first:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func formatRiskControlRefusalPrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,本次不会按你给的原值直接保存:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前安全范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I did not save the original request as-is:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func marshalStringList(values []string) string {
if len(values) == 0 {
return ""
}
raw, err := json.Marshal(values)
if err != nil {
return ""
}
return string(raw)
}
func unmarshalStringList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func normalizeExchangePatchToManualLimits(lang string, patch exchangeUpdatePatch) (exchangeUpdatePatch, []string) {
warnings := make([]string, 0, 1)
if patch.LighterAPIKeyIndex != nil {
requested := *patch.LighterAPIKeyIndex
normalized := requested
if normalized < manualLighterAPIKeyIndexMin {
normalized = manualLighterAPIKeyIndexMin
}
if normalized > manualLighterAPIKeyIndexMax {
normalized = manualLighterAPIKeyIndexMax
}
if normalized != requested {
patch.LighterAPIKeyIndex = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("Lighter API Key Index 手动面板范围是 %d 到 %d已从 %d 调整为 %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("lighter API key index is limited to %d-%d in the manual editor, adjusted from %d to %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
}
}
}
return patch, warnings
}

View File

@@ -0,0 +1,692 @@
package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestToolManageModelConfigCreateRequiresCredential(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"deepseek"}`)
if !strings.Contains(resp, `"error":"api_key is required for create"`) {
t.Fatalf("expected missing api_key error, got: %s", resp)
}
}
func TestToolManageModelConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"qwen","api_key":"sk-test-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
model, err := st.AIModel().Get("default", "default_qwen")
if err != nil {
t.Fatalf("load created model: %v", err)
}
if !model.Enabled {
t.Fatalf("expected agent-created model to default to enabled so it matches manual creation")
}
}
func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-upsert.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_qwen", "qwen1", false, "sk-old-qwen-123456", "", "qwen3-max"); err != nil {
t.Fatalf("seed existing qwen model: %v", err)
}
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"Qwen","api_key":"sk-new-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to reuse existing qwen config instead of failing, got: %s", resp)
}
models, err := st.AIModel().List("default")
if err != nil {
t.Fatalf("list models: %v", err)
}
qwenCount := 0
for _, model := range models {
if model != nil && model.Provider == "qwen" {
qwenCount++
if model.ID != "default_qwen" {
t.Fatalf("expected existing qwen record to be reused, got model id %q", model.ID)
}
if model.Name != "Qwen" {
t.Fatalf("expected reused qwen record to be renamed, got %q", model.Name)
}
if !model.Enabled {
t.Fatalf("expected reused qwen record to be enabled after agent create")
}
}
}
if qwenCount != 1 {
t.Fatalf("expected exactly one qwen record after reuse, got %d", qwenCount)
}
}
func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"binance","account_name":"Binance Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
exchanges, err := st.Exchange().List("default")
if err != nil {
t.Fatalf("list exchanges: %v", err)
}
if len(exchanges) != 1 || exchanges[0] == nil {
t.Fatalf("expected one created exchange, got %#v", exchanges)
}
if !exchanges[0].Enabled {
t.Fatalf("expected agent-created exchange to default to enabled so it matches manual creation")
}
}
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())
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)
}
exchanges, err := st.Exchange().List("default")
if err != nil {
t.Fatalf("list exchanges: %v", err)
}
if len(exchanges) != 0 {
t.Fatalf("expected incomplete exchange not to be persisted, got %#v", exchanges)
}
}
func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility-list.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", false, "", "", ""); err != nil {
t.Fatalf("seed incomplete model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", false, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed configured model: %v", err)
}
resp := a.toolGetModelConfigs("default")
if strings.Contains(resp, `"id":"default_openai"`) {
t.Fatalf("incomplete model should be hidden from tool query: %s", resp)
}
if !strings.Contains(resp, `"id":"default_deepseek"`) {
t.Fatalf("configured model should remain visible: %s", resp)
}
}
func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-risk-guard.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-risk-guard",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-risk-guard","config":{"risk_control":{"btc_eth_max_leverage":100,"altcoin_max_leverage":100}}}`)
if !strings.Contains(resp, `不会按你给的原值直接保存`) {
t.Fatalf("expected out-of-range leverage update to be rejected before save, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.BTCETHMaxLeverage != 5 || parsed.RiskControl.AltcoinMaxLeverage != 5 {
t.Fatalf("expected stored leverage to remain unchanged at safe defaults, got btc_eth=%d alt=%d", parsed.RiskControl.BTCETHMaxLeverage, parsed.RiskControl.AltcoinMaxLeverage)
}
}
func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-fixed-min-position.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-fixed-min-position",
UserID: "default",
Name: "固定最小开仓策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-fixed-min-position","config":{"risk_control":{"min_position_size":20}}}`)
if !strings.Contains(resp, "固定值 12 USDT") {
t.Fatalf("expected fixed min position size rejection, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.MinPositionSize != 12 {
t.Fatalf("expected stored min position size to remain fixed at 12, got %v", parsed.RiskControl.MinPositionSize)
}
}
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.exchangeSkillOptionSummary("zh")
for _, expected := range []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected option %q in summary, got: %s", expected, summary)
}
}
for _, hidden := range []string{"Alpaca", "Forex", "Metals"} {
if strings.Contains(summary, hidden) {
t.Fatalf("did not expect hidden manual-page option %q in summary: %s", hidden, summary)
}
}
}
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)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
hyperID, err := st.Exchange().Create("default", "hyperliquid", "Dex Pro", true, "hyper-api-key", "", "", true, "0xabc", true, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed hyperliquid exchange: %v", err)
}
detail, ok := a.describeExchange("default", "zh", &EntityReference{ID: hyperID})
if !ok {
t.Fatal("expected describeExchange to resolve hyperliquid config")
}
for _, expected := range []string{"交易所配置“Dex Pro”详情", "交易所hyperliquid", "账户名Dex Pro", "API Keytrue", "Hyperliquid 钱包地址0xabc"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected hyperliquid detail to contain %q, got: %s", expected, detail)
}
}
lighterID, err := st.Exchange().Create("default", "lighter", "Lighter Main", false, "", "", "", false, "", true, "", "", "", "wallet-1", "", "lighter-secret", 7)
if err != nil {
t.Fatalf("seed lighter exchange: %v", err)
}
detail, ok = a.describeExchange("default", "zh", &EntityReference{ID: lighterID})
if !ok {
t.Fatal("expected describeExchange to resolve lighter config")
}
for _, expected := range []string{"交易所lighter", "Lighter 钱包地址wallet-1", "Lighter API Key 私钥true", "Lighter API Key Index7"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected lighter detail to contain %q, got: %s", expected, detail)
}
}
}
func TestSkillVisibleFieldSummaryForExchangeUsesReadableNames(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "exchange_management", "update")
for _, expected := range []string{"交易所类型", "账户名", "API Key", "Secret", "Passphrase", "Hyperliquid 钱包地址", "Aster User", "Lighter API Key 私钥", "Lighter API Key Index"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "hyperliquid_wallet_addr") || strings.Contains(summary, "lighter_api_key_private_key") {
t.Fatalf("field summary should use readable labels instead of raw keys: %s", summary)
}
}
func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "strategy_management", "update_config")
for _, expected := range []string{"发布到市场", "配置可见", "交易对", "杠杆", "主周期", "多周期时间框架", "NofxOS API key", "角色定义", "自定义 Prompt"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "最小开仓金额") {
t.Fatalf("strategy field summary should not expose fixed min position size editing: %s", summary)
}
}
func TestStrategyVisibleFieldSummaryUsesTargetStrategyType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-type-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "ETHUSDT",
GridCount: 12,
TotalInvestment: 1000,
Leverage: 3,
UseATRBounds: true,
ATRMultiplier: 2,
Distribution: "gaussian",
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-grid-fields",
UserID: "default",
Name: "我的第一个网格策略",
Description: "",
IsPublic: false,
ConfigVisible: true,
Config: string(raw),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "update_config",
TargetRef: &EntityReference{
ID: strategy.ID,
Name: strategy.Name,
},
}
resources := a.buildActiveSessionResources("default", session)
if got := resources["target_strategy_type"]; got != "grid_trading" {
t.Fatalf("expected grid strategy type in resources, got: %#v", got)
}
fields, ok := resources["target_editable_fields"].([]string)
if !ok {
t.Fatalf("expected editable field list in resources, got: %#v", resources["target_editable_fields"])
}
joined := strings.Join(fields, ",")
if !strings.Contains(joined, "symbol") || strings.Contains(joined, "source_type") {
t.Fatalf("expected grid-only editable fields in resources, got: %s", joined)
}
}
func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update")
for _, expected := range []string{"交易所", "模型", "策略", "扫描间隔", "全仓模式", "竞技场显示"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary)
}
}
for _, unexpected := range []string{"名称", "初始资金", "初始余额", "杠杆", "交易对", "Prompt", "AI500", "OI Top"} {
if strings.Contains(summary, unexpected) {
t.Fatalf("trader field summary should stay within manual panel fields, got: %s", summary)
}
}
}
func TestToolUpdateTraderRejectsRenameOutsideManualPanel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-update-reject-rename.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-rename",
UserID: "default",
Name: "Rename Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
if err := st.Trader().Create(&store.Trader{
ID: "trader-rename",
UserID: "default",
Name: "原交易员",
AIModelID: "default_deepseek",
ExchangeID: exchangeID,
StrategyID: "strategy-trader-rename",
InitialBalance: 1000,
ScanIntervalMinutes: 5,
IsCrossMargin: true,
ShowInCompetition: true,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
resp := a.toolManageTrader("default", `{"action":"update","trader_id":"trader-rename","name":"新名字"}`)
if !strings.Contains(resp, "trader rename is not supported here") {
t.Fatalf("expected rename rejection, got: %s", resp)
}
}
func TestToolCreateTraderResponseHidesLegacyTraderTuningFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-create-response-shape.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-shape",
UserID: "default",
Name: "Shape Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
return 88.5, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"形状测试","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-trader-shape"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
for _, blocked := range []string{"btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "system_prompt_template"} {
if strings.Contains(resp, blocked) {
t.Fatalf("expected trader create response to hide legacy tuning field %q, got: %s", blocked, resp)
}
}
}
func TestToolCreateTraderAutoReadsInitialBalanceFromExchange(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-auto-balance.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-auto-balance",
UserID: "default",
Name: "Auto Balance Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
if exchangeCfg == nil || exchangeCfg.ID != exchangeID {
t.Fatalf("unexpected exchange config passed to balance fetcher: %#v", exchangeCfg)
}
if userID != "default" {
t.Fatalf("unexpected user id %q", userID)
}
return 4321.25, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"奶茶","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-auto-balance","initial_balance":999}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected one trader, got %d", len(traders))
}
if traders[0].InitialBalance != 4321.25 {
t.Fatalf("expected initial balance to be auto-read from exchange, got %.2f", traders[0].InitialBalance)
}
}
func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 12,
TotalInvestment: 1500,
Leverage: 4,
UpperPrice: 120000,
LowerPrice: 90000,
UseATRBounds: false,
ATRMultiplier: 2,
Distribution: "gaussian",
MaxDrawdownPct: 15,
StopLossPct: 5,
DailyLossLimitPct: 10,
UseMakerOnly: true,
EnableDirectionAdjust: true,
DirectionBiasRatio: 0.7,
}
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-detail-1",
UserID: "default",
Name: "Grid Alpha",
Description: "grid strategy for regression",
IsPublic: true,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
strategy.ConfigVisible = false
if err := st.Strategy().Update(strategy); err != nil {
t.Fatalf("update strategy visibility: %v", err)
}
detail, ok := a.describeStrategy("default", "zh", &EntityReference{ID: strategy.ID})
if !ok {
t.Fatal("expected describeStrategy to resolve seeded strategy")
}
for _, expected := range []string{
"策略“Grid Alpha”概览",
"发布设置:已发布到市场;配置隐藏",
"网格参数:交易对 BTCUSDT网格 12总投资 1500.00;杠杆 4分布 gaussian",
"网格边界:上沿 120000.0000,下沿 90000.0000",
} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
}
}
for _, unexpected := range []string{
"标的来源:",
"NofxOS 数据:",
} {
if strings.Contains(detail, unexpected) {
t.Fatalf("expected grid strategy detail not to contain AI field %q, got: %s", unexpected, detail)
}
}
}

View File

@@ -0,0 +1,111 @@
package agent
type entityFieldMeta struct {
Key string
Keywords []string
ValueType string
ManualEditable bool
AgentUpdatable bool
}
var traderFieldCatalog = []entityFieldMeta{
{Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
}
var modelFieldCatalog = []entityFieldMeta{
{Key: "provider", Keywords: []string{"provider", "模型提供商", "模型厂商", "vendor"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true},
{Key: "name", Keywords: []string{"名称", "名字", "name"}, ValueType: "name", ManualEditable: true, AgentUpdatable: true},
{Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true},
{Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "custom_api_url", Keywords: []string{"url", "endpoint", "地址", "接口"}, ValueType: "url", ManualEditable: true, AgentUpdatable: true},
{Key: "custom_model_name", Keywords: []string{"model name", "模型名称", "模型名"}, ValueType: "model_name", ManualEditable: true, AgentUpdatable: true},
}
var exchangeFieldCatalog = []entityFieldMeta{
{Key: "exchange_type", Keywords: []string{"交易所类型", "交易所", "exchange type", "exchange"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true},
{Key: "account_name", Keywords: []string{"账户名", "account name"}, ValueType: "account_name", ManualEditable: true, AgentUpdatable: true},
{Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true},
{Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "secret_key", Keywords: []string{"secret key", "secret", "secret_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "passphrase", Keywords: []string{"passphrase", "密码短语"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "testnet", Keywords: []string{"testnet", "测试网"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "hyperliquid_wallet_addr", Keywords: []string{"hyperliquid wallet", "hyperliquid钱包", "主钱包地址", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_user", Keywords: []string{"aster user", "aster用户", "用户地址", "user"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_signer", Keywords: []string{"aster signer", "signer"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_private_key", Keywords: []string{"aster private key", "aster私钥", "private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_wallet_addr", Keywords: []string{"lighter wallet", "lighter钱包", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_api_key_private_key", Keywords: []string{"lighter api key private key", "lighter api key", "api key private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_api_key_index", Keywords: []string{"lighter api key index", "lighter索引", "api key index"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
}
func fieldKeysByCapability(catalog []entityFieldMeta, include func(entityFieldMeta) bool) []string {
keys := make([]string, 0, len(catalog))
for _, field := range catalog {
if include(field) {
keys = append(keys, field.Key)
}
}
return keys
}
func keywordsForField(catalog []entityFieldMeta, field string) []string {
for _, item := range catalog {
if item.Key == field {
return item.Keywords
}
}
return nil
}
func manualTraderEditableFieldKeys() []string {
return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentTraderUpdatableFieldKeys() []string {
return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func manualModelEditableFieldKeys() []string {
return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentModelUpdatableFieldKeys() []string {
return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func manualExchangeEditableFieldKeys() []string {
return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentExchangeUpdatableFieldKeys() []string {
return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func traderFieldKeywords(field string) []string {
return keywordsForField(traderFieldCatalog, field)
}
func modelFieldKeywords(field string) []string {
return keywordsForField(modelFieldCatalog, field)
}
func exchangeFieldKeywords(field string) []string {
return keywordsForField(exchangeFieldCatalog, field)
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
@@ -30,22 +32,38 @@ const (
)
type ExecutionState struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type SuspendedTask struct {
SnapshotID string `json:"snapshot_id,omitempty"`
IntentID string `json:"intent_id,omitempty"`
ParentIntentID string `json:"parent_intent_id,omitempty"`
Kind string `json:"kind,omitempty"`
ResumeHint string `json:"resume_hint,omitempty"`
ResumeOnSuccess bool `json:"resume_on_success,omitempty"`
ResumeTriggers []string `json:"resume_triggers,omitempty"`
SkillSession *skillSession `json:"skill_session,omitempty"`
WorkflowSession *WorkflowSession `json:"workflow_session,omitempty"`
ExecutionState *ExecutionState `json:"execution_state,omitempty"`
LocalHistory []chatMessage `json:"local_history,omitempty"`
SuspendedAt string `json:"suspended_at,omitempty"`
}
type PlanStep struct {
@@ -78,8 +96,18 @@ type WaitingState struct {
}
type EntityReference struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type ReferenceRecord struct {
Kind string `json:"kind,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type CurrentReferences struct {
@@ -89,6 +117,20 @@ type CurrentReferences struct {
Exchange *EntityReference `json:"exchange,omitempty"`
}
type SnapshotSummary struct {
SnapshotID string `json:"snapshot_id,omitempty"`
IntentID string `json:"intent_id,omitempty"`
ParentIntentID string `json:"parent_intent_id,omitempty"`
Kind string `json:"kind,omitempty"`
ResumeHint string `json:"resume_hint,omitempty"`
SuspendedAt string `json:"suspended_at,omitempty"`
}
type SnapshotManager struct {
agent *Agent
userID int64
}
type executionPlan struct {
Goal string `json:"goal"`
Steps []PlanStep `json:"steps"`
@@ -103,6 +145,82 @@ func ExecutionStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_execution_state_%d", userID)
}
func taskStackConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_stack_%d", userID)
}
func (a *Agent) SnapshotManager(userID int64) SnapshotManager {
return SnapshotManager{agent: a, userID: userID}
}
func (m SnapshotManager) Save(task SuspendedTask) {
if m.agent == nil {
return
}
m.agent.pushTaskStack(m.userID, task)
}
func (m SnapshotManager) Load() (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
return m.agent.popTaskStack(m.userID)
}
func (m SnapshotManager) Peek() (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
return m.agent.peekTaskStack(m.userID)
}
func (m SnapshotManager) List() []SnapshotSummary {
if m.agent == nil {
return nil
}
stack := m.agent.getTaskStack(m.userID)
out := make([]SnapshotSummary, 0, len(stack))
for _, item := range stack {
out = append(out, SnapshotSummary{
SnapshotID: strings.TrimSpace(item.SnapshotID),
IntentID: strings.TrimSpace(item.IntentID),
ParentIntentID: strings.TrimSpace(item.ParentIntentID),
Kind: strings.TrimSpace(item.Kind),
ResumeHint: strings.TrimSpace(item.ResumeHint),
SuspendedAt: strings.TrimSpace(item.SuspendedAt),
})
}
return out
}
func (m SnapshotManager) Stack() []SuspendedTask {
if m.agent == nil {
return nil
}
return m.agent.getTaskStack(m.userID)
}
func (m SnapshotManager) RemoveAt(index int) (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
stack := m.agent.getTaskStack(m.userID)
if index < 0 || index >= len(stack) {
return SuspendedTask{}, false
}
task := stack[index]
stack = append(stack[:index], stack[index+1:]...)
m.agent.saveTaskStack(m.userID, stack)
return task, true
}
func (m SnapshotManager) Clear() {
if m.agent == nil {
return
}
m.agent.clearTaskStack(m.userID)
}
func (a *Agent) getExecutionState(userID int64) ExecutionState {
if a.store == nil {
return ExecutionState{}
@@ -133,6 +251,9 @@ func (a *Agent) saveExecutionState(state ExecutionState) error {
if state.SessionID == "" {
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
}
if state.UserID != 0 && (state.CurrentReferences != nil || len(state.ReferenceHistory) > 0) {
a.saveReferenceMemory(state.UserID, state.CurrentReferences, state.ReferenceHistory)
}
data, err := json.Marshal(state)
if err != nil {
return err
@@ -149,6 +270,80 @@ func (a *Agent) clearExecutionState(userID int64) {
}
}
func (a *Agent) getTaskStack(userID int64) []SuspendedTask {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(taskStackConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task stack", "error", err, "user_id", userID)
return nil
}
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var stack []SuspendedTask
if err := json.Unmarshal([]byte(raw), &stack); err != nil {
a.logger.Warn("failed to parse task stack", "error", err, "user_id", userID)
return nil
}
return normalizeTaskStack(stack)
}
func (a *Agent) saveTaskStack(userID int64, stack []SuspendedTask) {
if a.store == nil {
return
}
stack = normalizeTaskStack(stack)
if len(stack) == 0 {
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), "")
return
}
data, err := json.Marshal(stack)
if err != nil {
return
}
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), string(data))
}
func (a *Agent) peekTaskStack(userID int64) (SuspendedTask, bool) {
stack := a.getTaskStack(userID)
if len(stack) == 0 {
return SuspendedTask{}, false
}
return stack[len(stack)-1], true
}
func (a *Agent) pushTaskStack(userID int64, task SuspendedTask) {
task = normalizeSuspendedTask(task)
if task.Kind == "" {
return
}
stack := a.getTaskStack(userID)
stack = append(stack, task)
stack = normalizeTaskStack(stack)
a.saveTaskStack(userID, stack)
}
func (a *Agent) popTaskStack(userID int64) (SuspendedTask, bool) {
stack := a.getTaskStack(userID)
if len(stack) == 0 {
return SuspendedTask{}, false
}
task := stack[len(stack)-1]
stack = stack[:len(stack)-1]
a.saveTaskStack(userID, stack)
return task, true
}
func (a *Agent) clearTaskStack(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), "")
}
func newExecutionState(userID int64, goal string) ExecutionState {
now := time.Now().UTC().Format(time.RFC3339)
return normalizeExecutionState(ExecutionState{
@@ -168,6 +363,7 @@ func normalizeExecutionState(state ExecutionState) ExecutionState {
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
state.LastError = strings.TrimSpace(state.LastError)
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
state.ReferenceHistory = normalizeReferenceHistory(state.ReferenceHistory)
state.Waiting = normalizeWaitingState(state.Waiting)
if state.Status == "" && state.SessionID != "" {
state.Status = executionStatusPlanning
@@ -201,6 +397,88 @@ func normalizeExecutionState(state ExecutionState) ExecutionState {
return state
}
func normalizeSuspendedTask(task SuspendedTask) SuspendedTask {
task.SnapshotID = strings.TrimSpace(task.SnapshotID)
task.IntentID = strings.TrimSpace(task.IntentID)
task.ParentIntentID = strings.TrimSpace(task.ParentIntentID)
task.Kind = strings.TrimSpace(task.Kind)
task.ResumeHint = strings.TrimSpace(task.ResumeHint)
task.ResumeTriggers = cleanStringList(task.ResumeTriggers)
task.SuspendedAt = strings.TrimSpace(task.SuspendedAt)
if task.SkillSession != nil {
session := normalizeSkillSession(*task.SkillSession)
if session.Name == "" {
task.SkillSession = nil
} else {
task.SkillSession = &session
}
}
if task.WorkflowSession != nil {
session := normalizeWorkflowSession(*task.WorkflowSession)
if len(session.Tasks) == 0 {
task.WorkflowSession = nil
} else {
task.WorkflowSession = &session
}
}
if task.ExecutionState != nil {
state := normalizeExecutionState(*task.ExecutionState)
if strings.TrimSpace(state.SessionID) == "" {
task.ExecutionState = nil
} else {
task.ExecutionState = &state
}
}
if task.Kind == "" {
switch {
case task.SkillSession != nil:
task.Kind = "skill_session"
case task.WorkflowSession != nil:
task.Kind = "workflow_session"
case task.ExecutionState != nil:
task.Kind = "execution_state"
}
}
if task.Kind == "" {
return SuspendedTask{}
}
if task.SnapshotID == "" {
task.SnapshotID = "snap_" + uuid.NewString()
}
if task.IntentID == "" {
task.IntentID = "intent_" + uuid.NewString()
}
if task.SuspendedAt == "" {
task.SuspendedAt = time.Now().UTC().Format(time.RFC3339)
}
return task
}
func normalizeTaskStack(stack []SuspendedTask) []SuspendedTask {
if len(stack) == 0 {
return nil
}
now := time.Now().UTC()
out := make([]SuspendedTask, 0, len(stack))
for _, item := range stack {
item = normalizeSuspendedTask(item)
if item.Kind == "" {
continue
}
if t, err := time.Parse(time.RFC3339, item.SuspendedAt); err == nil && now.Sub(t) > 24*time.Hour {
continue
}
out = append(out, item)
}
if len(out) == 0 {
return nil
}
if len(out) > 5 {
out = out[len(out)-5:]
}
return out
}
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
if waiting == nil {
return nil
@@ -224,9 +502,14 @@ func normalizeEntityReference(ref *EntityReference) *EntityReference {
}
ref.ID = strings.TrimSpace(ref.ID)
ref.Name = strings.TrimSpace(ref.Name)
ref.Source = strings.TrimSpace(ref.Source)
ref.UpdatedAt = strings.TrimSpace(ref.UpdatedAt)
if ref.ID == "" && ref.Name == "" {
return nil
}
if ref.UpdatedAt == "" {
ref.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return ref
}
@@ -244,6 +527,34 @@ func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
return refs
}
func normalizeReferenceHistory(history []ReferenceRecord) []ReferenceRecord {
if len(history) == 0 {
return nil
}
out := make([]ReferenceRecord, 0, len(history))
for _, item := range history {
item.Kind = strings.TrimSpace(item.Kind)
item.ID = strings.TrimSpace(item.ID)
item.Name = strings.TrimSpace(item.Name)
item.Source = strings.TrimSpace(item.Source)
item.CreatedAt = strings.TrimSpace(item.CreatedAt)
if item.Kind == "" || (item.ID == "" && item.Name == "") {
continue
}
if item.CreatedAt == "" {
item.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, item)
}
if len(out) == 0 {
return nil
}
if len(out) > 12 {
out = out[len(out)-12:]
}
return out
}
func normalizeObservationList(values []Observation) []Observation {
if len(values) == 0 {
return nil
@@ -332,8 +643,8 @@ func buildObservationContext(state ExecutionState) map[string]any {
state = normalizeExecutionState(state)
return map[string]any{
"current_references": state.CurrentReferences,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
}
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"strings"
"sync"
"time"
)
@@ -101,3 +102,16 @@ func (h *chatHistory) CleanOld(maxAge time.Duration) {
}
}
}
func (a *Agent) getLastAssistantReply(userID int64) string {
if a == nil || a.history == nil {
return ""
}
msgs := a.history.Get(userID)
for i := len(msgs) - 1; i >= 0; i-- {
if strings.EqualFold(strings.TrimSpace(msgs[i].Role), "assistant") {
return strings.TrimSpace(msgs[i].Content)
}
}
return ""
}

View File

@@ -3,20 +3,22 @@ package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*交易:* 做多 BTC 0.01 x10 · 做空 ETH 0.1 · 平多 BTC · 平空 ETH\n" +
" 也支持 /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*查询:* /positions /balance /pnl /traders\n" +
"*分析:* /analyze BTC\n" +
"*监控:* /watch BTC · /unwatch BTC\n" +
"*策略:* /strategy\n" +
"*系统:* /status /help\n\n" +
"*系统:* /status /clear /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
"*Trade:* long BTC 0.01 x10 · short ETH 0.1 · close long BTC · close short ETH\n" +
" Also supports /buy /sell /long /short + symbol qty leverage\n" +
"*Query:* /positions /balance /pnl /traders\n" +
"*Analyze:* /analyze BTC\n" +
"*Monitor:* /watch BTC · /unwatch BTC\n" +
"*Strategy:* /strategy\n" +
"*System:* /status /help\n\n" +
"*System:* /status /clear /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
@@ -52,8 +54,8 @@ var i18nMessages = map[string]map[string]string{
"en": "🤖 *Traders*\n\n",
},
"trade_usage": {
"zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
"en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
"zh": "手动下单示例:`做多 BTC 0.01 x10`、`做空 ETH 0.1`、`平多 BTC`、`平空 ETH`。也支持 `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`。下单后需要确认;大额订单要用“确认大额 trade_xxx”。",
"en": "Manual trade examples: `long BTC 0.01 x10`, `short ETH 0.1`, `close long BTC`, `close short ETH`. Also supports `/buy BTC 0.01` or `/sell ETH 0.5 3x`. Orders require confirmation; large orders use `confirm large trade_xxx`.",
},
"invalid_qty": {
"zh": "❓ 无效数量: %s",
@@ -68,8 +70,8 @@ var i18nMessages = map[string]map[string]string{
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"zh": "你是 NOFXi一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
"zh": "你是 NOFXi一个专业的 AI 交易 Agent。把用户当交易小白,用简单清楚的大白话回复,先说结论,再说下一步。使用少量交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Treat the user like a trading beginner, use plain language, lead with the conclusion, then the next step. Use a small amount of trading emojis.",
},
}

578
agent/llm_flow_extractor.go Normal file
View File

@@ -0,0 +1,578 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
type llmFlowExtractionTask struct {
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
type llmFlowExtractionResult struct {
Intent string `json:"intent,omitempty"`
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
InlineSubIntent string `json:"inline_sub_intent,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
Tasks []llmFlowExtractionTask `json:"tasks,omitempty"`
Reason string `json:"reason,omitempty"`
}
type llmFlowFieldSpec struct {
Key string `json:"key"`
Description string `json:"description"`
Required bool `json:"required,omitempty"`
}
func buildActiveFlowExtractionPrompt(lang, flowLabel, flowContext string, text string, recentConversationCtx string, currentRefs any, suspendedSnapshots any, extraSections []string) (string, string) {
systemPrompt := `You extract structured continuation input for an active NOFXi flow.
Return JSON only. No markdown.
You must decide one of:
- "continue": the user is continuing the current flow and may have supplied fields
- "switch": the user is switching away to another task
- "cancel": the user is cancelling the current flow
- "instant_reply": the user is only chatting / greeting and no task fields should be written
Rules:
- Prefer "continue" only when the message clearly contributes to the current flow.
- Set target_snapshot_id only when the user is clearly referring to one suspended snapshot from Suspended snapshots JSON.
- For greetings, thanks, and casual chat, use "instant_reply".
- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个".
- Treat this as semantic slot filling, not keyword copying.
- Users will often speak in natural language, shorthand, colloquial labels, translated labels, or mild misspellings instead of exact schema keys.
- Your job is to decide which allowed canonical field each value belongs to based on the active flow, field descriptions, current missing fields, and conversation context.
- Never require the user to say the exact internal field key.
- In task.fields, always emit the canonical field keys from Allowed field spec JSON, never aliases, paraphrases, or user wording.
- If the user clearly supplied a value for one allowed field, normalize it to that canonical key before returning JSON.`
sections := []string{
fmt.Sprintf("Language: %s", lang),
fmt.Sprintf("Active flow label: %s", flowLabel),
flowContext,
fmt.Sprintf("Current references JSON: %s", mustMarshalJSON(currentRefs)),
fmt.Sprintf("Suspended snapshots JSON: %s", mustMarshalJSON(suspendedSnapshots)),
}
sections = append(sections, extraSections...)
sections = append(sections, fmt.Sprintf("User message: %s", text), fmt.Sprintf("Recent conversation:\n%s", recentConversationCtx))
return systemPrompt, strings.Join(sections, "\n")
}
func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult {
out, ok := parseRawFlowExtractionEnvelope(raw)
if !ok {
return llmFlowExtractionResult{}
}
switch out.Intent {
case "continue", "switch", "cancel", "instant_reply":
return out
default:
return llmFlowExtractionResult{}
}
}
func parseRawFlowExtractionEnvelope(raw string) (llmFlowExtractionResult, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out llmFlowExtractionResult
if err := json.Unmarshal([]byte(raw), &out); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil {
return llmFlowExtractionResult{}, false
}
}
out.Intent = strings.TrimSpace(strings.ToLower(out.Intent))
out.TargetSnapshotID = strings.TrimSpace(out.TargetSnapshotID)
out.Reason = strings.TrimSpace(out.Reason)
if len(out.Fields) > 0 {
clean := make(map[string]string, len(out.Fields))
for key, value := range out.Fields {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
clean[key] = value
}
out.Fields = clean
}
cleanTasks := make([]llmFlowExtractionTask, 0, len(out.Tasks))
for _, task := range out.Tasks {
task.Skill = strings.TrimSpace(task.Skill)
task.Action = strings.TrimSpace(task.Action)
if len(task.Fields) > 0 {
clean := make(map[string]string, len(task.Fields))
for key, value := range task.Fields {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
clean[key] = value
}
task.Fields = clean
}
cleanTasks = append(cleanTasks, task)
}
out.Tasks = cleanTasks
return out, out.Intent != ""
}
func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFlowFieldSpec) llmFlowExtractionResult {
if len(specs) == 0 {
result.Fields = nil
for i := range result.Tasks {
result.Tasks[i].Fields = nil
}
return result
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
filter := func(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
clean := make(map[string]string, len(fields))
for key, value := range fields {
if _, ok := allowed[key]; !ok {
continue
}
clean[key] = value
}
if len(clean) == 0 {
return nil
}
return clean
}
result.Fields = filter(result.Fields)
for i := range result.Tasks {
result.Tasks[i].Fields = filter(result.Tasks[i].Fields)
}
return result
}
func formatConversationMissingFields(lang string, missingFields []string) string {
if len(missingFields) == 0 {
if lang == "zh" {
return "当前没有缺失槽位。"
}
return "There are currently no missing slots."
}
display := make([]string, 0, len(missingFields))
for _, field := range missingFields {
display = append(display, slotDisplayName(field, lang))
}
if lang == "zh" {
return "当前仍缺这些槽位:" + strings.Join(display, "、")
}
return "Current missing slots: " + strings.Join(display, ", ")
}
func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) {
currentStep, _ := currentSkillDAGStep(session)
fieldSpecs := allowedFieldSpecsForSkillSession(session, lang)
currentValues := currentFieldValuesForSkillSession(session)
missing := missingFieldKeysForSkillSession(session)
summary := fmt.Sprintf("Active flow type: skill_session\nSkill: %s\nAction: %s\nCurrent DAG step: %s", session.Name, session.Action, currentStep.ID)
return summary, fieldSpecs, currentValues, missing
}
func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFlowFieldSpec {
add := func(out *[]llmFlowFieldSpec, key, description string, required bool) {
*out = append(*out, llmFlowFieldSpec{Key: key, Description: description, Required: required})
}
out := make([]llmFlowFieldSpec, 0, 24)
if actionRequiresSlot(session.Name, session.Action, "target_ref") {
add(&out, "target_ref_id", slotDisplayName("target_ref", lang)+" ID", true)
add(&out, "target_ref_name", slotDisplayName("target_ref", lang), true)
}
if supportsBulkTargetSelection(session.Name, session.Action) {
add(&out, "bulk_scope", "bulk deletion scope, use all only when the user clearly requested all targets", false)
}
switch session.Name {
case "model_management":
required := map[string]bool{"provider": true}
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "provider", slotDisplayName("provider", lang), required["provider"])
add(&out, "name", displayCatalogFieldName("name", lang), required["name"])
add(&out, "custom_model_name", displayCatalogFieldName("custom_model_name", lang), required["custom_model_name"])
add(&out, "api_key", displayCatalogFieldName("api_key", lang), required["api_key"])
add(&out, "custom_api_url", displayCatalogFieldName("custom_api_url", lang), false)
add(&out, "enabled", displayCatalogFieldName("enabled", lang), false)
case "exchange_management":
required := map[string]bool{"exchange_type": true, "account_name": true}
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "exchange_type", slotDisplayName("exchange_type", lang), required["exchange_type"])
add(&out, "account_name", displayCatalogFieldName("account_name", lang), required["account_name"])
add(&out, "api_key", displayCatalogFieldName("api_key", lang), false)
add(&out, "secret_key", displayCatalogFieldName("secret_key", lang), false)
add(&out, "passphrase", displayCatalogFieldName("passphrase", lang), false)
add(&out, "testnet", displayCatalogFieldName("testnet", lang), false)
add(&out, "enabled", displayCatalogFieldName("enabled", lang), false)
add(&out, "hyperliquid_wallet_addr", displayCatalogFieldName("hyperliquid_wallet_addr", lang), false)
add(&out, "aster_user", displayCatalogFieldName("aster_user", lang), false)
add(&out, "aster_signer", displayCatalogFieldName("aster_signer", lang), false)
add(&out, "aster_private_key", displayCatalogFieldName("aster_private_key", lang), false)
add(&out, "lighter_wallet_addr", displayCatalogFieldName("lighter_wallet_addr", lang), false)
add(&out, "lighter_api_key_private_key", displayCatalogFieldName("lighter_api_key_private_key", lang), false)
add(&out, "lighter_api_key_index", displayCatalogFieldName("lighter_api_key_index", lang), false)
case "trader_management":
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "name", slotDisplayName("name", lang), true)
add(&out, "exchange_id", slotDisplayName("exchange", lang)+" ID", false)
add(&out, "exchange_name", slotDisplayName("exchange", lang), true)
add(&out, "model_id", slotDisplayName("model", lang)+" ID", false)
add(&out, "model_name", slotDisplayName("model", lang), true)
add(&out, "strategy_id", slotDisplayName("strategy", lang)+" ID", false)
add(&out, "strategy_name", slotDisplayName("strategy", lang), true)
add(&out, "auto_start", "auto_start", false)
add(&out, "scan_interval_minutes", displayCatalogFieldName("scan_interval_minutes", lang), false)
add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false)
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
case "strategy_management":
if session.Action == "create" || session.Action == "update_config" {
if session.Action == "create" {
add(&out, "strategy_type", "Strategy type. Use ai_trading for AI strategies, including AI500/OI/static coin-source requests; use grid_trading only for grid strategy requests.", false)
}
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use exact product schema values, not display labels: source_type must be one of static, ai500, oi_top, oi_low; strategy_type must be ai_trading or grid_trading; selected_timeframes must be a JSON array of strings, not a JSON-encoded string."
switch explicitStrategyCreateType(session) {
case "grid_trading":
configPatchDescription += " Current strategy_type is grid_trading: use only top-level strategy_type, grid_config, publish_config, and language. Do not output ai_config or AI fields such as coin_source, indicators, risk_control, timeframes, confidence, or prompt_sections."
case "ai_trading":
configPatchDescription += " Current strategy_type is ai_trading: use top-level strategy_type, ai_config, publish_config, and language. Put coin_source, indicators, risk_control, prompt_sections, and custom_prompt inside ai_config. Do not output grid_config."
default:
configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only the config branch for that type: grid_config for grid, ai_config for AI."
}
add(&out, "config_patch", configPatchDescription, false)
}
if session.Action == "create" {
add(&out, "awaiting_final_confirmation", "Set true only after you have produced a final user-facing creation summary from the current structured config and are waiting for the user's final confirmation before executing create.", false)
}
if session.Action == "update_prompt" {
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
add(&out, "custom_prompt", strategyConfigFieldDisplayName("custom_prompt", lang), false)
}
if session.Action == "update_config" {
return out
}
add(&out, "name", slotDisplayName("name", lang), true)
if session.Action == "create" {
return out
}
keys := manualStrategyEditableFieldKeys()
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
keys = manualStrategyEditableFieldKeysForType(strategyType)
}
for _, key := range keys {
add(&out, key, strategyConfigFieldDisplayName(key, lang), false)
}
}
return out
}
func currentFieldValuesForSkillSession(session skillSession) map[string]string {
values := map[string]string{}
for key, value := range session.Fields {
if trimmed := strings.TrimSpace(value); trimmed != "" {
values[key] = trimmed
}
}
if session.TargetRef != nil {
if session.TargetRef.ID != "" {
values["target_ref_id"] = session.TargetRef.ID
}
if session.TargetRef.Name != "" {
values["target_ref_name"] = session.TargetRef.Name
}
}
for _, key := range []string{"name", "exchange_id", "exchange_name", "model_id", "model_name", "strategy_id", "strategy_name", "auto_start"} {
if value := fieldValue(session, key); value != "" {
values[key] = value
}
}
return values
}
func missingFieldKeysForSkillSession(session skillSession) []string {
missing := make([]string, 0, 8)
switch session.Name {
case "model_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if strings.HasPrefix(session.Action, "update") {
if session.Action == "update_status" {
if fieldValue(session, "enabled") == "" {
missing = append(missing, "enabled")
}
} else if session.Action == "update_endpoint" {
if fieldValue(session, "custom_api_url") == "" {
missing = append(missing, "custom_api_url")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
for _, key := range []string{"provider"} {
if fieldValue(session, key) == "" {
missing = append(missing, key)
}
}
if fieldValue(session, "api_key") == "" {
missing = append(missing, "api_key")
}
}
case "exchange_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if strings.HasPrefix(session.Action, "update") {
if session.Action == "update_status" {
if fieldValue(session, "enabled") == "" {
missing = append(missing, "enabled")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
for _, key := range []string{"exchange_type", "account_name", "api_key", "secret_key"} {
if fieldValue(session, key) == "" {
missing = append(missing, key)
}
}
}
case "trader_management":
if strings.HasPrefix(session.Action, "update") || strings.HasPrefix(session.Action, "configure_") {
if session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" {
switch session.Action {
case "configure_strategy":
if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name")
}
break
case "configure_exchange":
if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name")
}
break
case "configure_model":
if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name")
}
break
}
if len(missing) > 0 {
break
}
if fieldValue(session, "model_id") == "" && fieldValue(session, "exchange_id") == "" && fieldValue(session, "strategy_id") == "" &&
fieldValue(session, "model_name") == "" && fieldValue(session, "exchange_name") == "" && fieldValue(session, "strategy_name") == "" {
missing = append(missing, "update_field")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name")
}
if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name")
}
if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name")
}
}
case "strategy_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
switch session.Action {
case "update_name":
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
case "update_prompt":
if fieldValue(session, "prompt") == "" && fieldValue(session, "custom_prompt") == "" {
missing = append(missing, "prompt")
}
case "update_config":
if fieldValue(session, "config_patch") == "" {
missing = append(missing, "config_patch")
}
case "create":
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
default:
missing = append(missing, "update_field")
}
}
sort.Strings(missing)
return missing
}
func providerExplicitlyMentionedInText(provider, text string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
lower := strings.ToLower(strings.TrimSpace(text))
if provider == "" || lower == "" {
return false
}
spec, _ := modelProviderSpecByID(provider)
candidates := []string{provider, strings.ToLower(strings.TrimSpace(spec.DisplayName))}
switch provider {
case "blockrun-base":
candidates = append(candidates, "blockrun", "blockrun base", "base wallet")
case "blockrun-sol":
candidates = append(candidates, "blockrun", "blockrun sol", "solana wallet")
case "claw402":
candidates = append(candidates, "claw 402")
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate != "" && strings.Contains(lower, candidate) {
return true
}
}
return false
}
func sanitizeLLMExtractionForSkillSession(text string, session skillSession, result llmFlowExtractionResult) llmFlowExtractionResult {
if session.Name != "model_management" || len(result.Tasks) == 0 {
return result
}
task := result.Tasks[0]
if task.Fields == nil {
return result
}
if provider := strings.TrimSpace(task.Fields["provider"]); provider != "" && !providerExplicitlyMentionedInText(provider, text) {
delete(task.Fields, "provider")
result.Tasks[0] = task
}
return result
}
func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *skillSession, result llmFlowExtractionResult, lang string, text string) {
if session == nil {
return
}
result = sanitizeLLMExtractionForSkillSession(text, *session, result)
if sub := strings.TrimSpace(result.InlineSubIntent); sub == "create_sub_resource" || sub == "edit_sub_resource" {
setField(session, "inline_sub_intent", sub)
}
if len(result.Tasks) == 0 {
return
}
task := result.Tasks[0]
if task.Skill != "" && task.Skill != session.Name {
return
}
if task.Action != "" && session.Action != "" && task.Action != session.Action {
return
}
for key, value := range task.Fields {
value = strings.TrimSpace(value)
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
}
switch session.Name {
case "model_management":
if key == "provider" || key == "name" || key == "custom_model_name" || key == "api_key" || key == "custom_api_url" || key == "enabled" || key == "update_field" {
setField(session, key, value)
}
case "exchange_management":
switch key {
case "exchange_type", "account_name", "api_key", "secret_key", "passphrase", "testnet", "enabled", "update_field":
setField(session, key, value)
}
case "trader_management":
switch key {
case "update_field":
setField(session, key, value)
case "name", "exchange_id", "exchange_name", "model_id", "ai_model_id", "model_name", "strategy_id", "strategy_name", "auto_start":
setField(session, key, value)
case "scan_interval_minutes", "is_cross_margin", "show_in_competition":
setField(session, key, value)
}
case "strategy_management":
if key == "name" {
setField(session, "name", value)
continue
}
if session.Action == "create" || session.Action == "update_config" {
switch key {
case "strategy_type":
if strategyType := parseStrategyTypeValue(value); strategyType != "" {
setStrategyCreateType(session, strategyType)
}
case strategyCreateConfigPatchField:
strategyType := explicitStrategyCreateType(*session)
if strategyType == "" {
strategyType = strategyTypeFromConfigPatchAny(value)
}
if sanitized := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(sanitized) > 0 {
raw, _ := json.Marshal(sanitized)
setField(session, strategyCreateConfigPatchField, string(raw))
}
}
continue
}
cfg := unmarshalStrategyCreateDraft(fieldValue(*session, strategyCreateDraftConfigField), lang)
if err := applyStrategyConfigPatch(&cfg, key, value); err == nil {
setField(session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
}
}
}
}

View File

@@ -0,0 +1,28 @@
package agent
import (
"strings"
"testing"
)
func TestBuildActiveFlowExtractionPromptRequiresCanonicalFieldOutput(t *testing.T) {
systemPrompt, _ := buildActiveFlowExtractionPrompt(
"zh",
"skill_session",
"Active flow type: skill_session\nSkill: exchange_management\nAction: create",
"secret是abc123456",
"",
nil,
nil,
nil,
)
for _, want := range []string{
"Treat this as semantic slot filling, not keyword copying.",
"always emit the canonical field keys from Allowed field spec JSON",
} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt)
}
}
}

View File

@@ -9,14 +9,19 @@ import (
"nofx/mcp"
)
type llmSkillRouteDecision struct {
Route string `json:"route"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
type unifiedTurnDecision struct {
TopicIntent string `json:"topic_intent,omitempty"`
BusinessAction string `json:"business_action,omitempty"`
TargetSkill string `json:"target_skill,omitempty"`
Tasks []WorkflowTask `json:"tasks,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) tryLLMSkillRoute(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 {
return "", false, nil
}
@@ -26,65 +31,101 @@ func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID
return "", false, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
executionState := normalizeExecutionState(a.getExecutionState(userID))
executionJSON, _ := json.Marshal(executionState)
systemPrompt := `You are the lightweight skill router for NOFXi.
Decide whether the user's message should go to a structured skill or continue to the planner.
Return JSON only. Do not return markdown.
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
}
}
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
Use route "skill" only when the user intent is clear enough to send directly to one structured skill.
Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests.
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)
Available skills:
- trader_management
- exchange_management
- model_management
- strategy_management
- trader_diagnosis
- exchange_diagnosis
- model_diagnosis
- strategy_diagnosis
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")
}
For management skills, choose one atomic action from:
- query_list
- query_detail
- query_running
- create
- update_name
- update_bindings
- update_status
- update_endpoint
- update_config
- update_prompt
- delete
- start
- stop
- activate
- duplicate
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)
decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
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", "skill_tasks", "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
}
Set filter only when it is clearly implied by the user. Use values like:
- running_only
- stopped_only
- enabled_only
- disabled_only
- active_only
- default_only
Rules:
- Prefer route "planner" when uncertain.
- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis.
- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities.
- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running".
- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail".
- Do not use route "skill" for casual chat.
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
Return JSON with this exact shape:
{"route":"skill|planner","skill":"","action":"","filter":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
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":
if len(d.Tasks) > 0 {
return true
}
skill, _ := parseTargetSkill(d.TargetSkill)
return skill != ""
case "skill_tasks":
return len(d.Tasks) > 0
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()
@@ -96,160 +137,469 @@ Return JSON with this exact shape:
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
- "skill_tasks": start one or more management/diagnosis skill tasks; tasks is required
- "new_skill": legacy single-skill route; target_skill is required if tasks is empty
- "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
tasks format for skill_tasks:
- id: "task_1", "task_2", ...
- skill: one available skill name
- action: one available action
- request: the self-contained user-readable subtask
- depends_on: array of task ids, empty when independent
target_skill format for legacy 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.
- Treat topic_intent as the primary decision. If the user is naturally responding to the active flow, choose topic_intent="continue_active", business_action="continue_skill", context_mode="use_current"; do not hand off a continuing active flow to planned_agent.
- When an active flow has a previous assistant question, proposal, or confirmation request, reason about what the user's message refers to in that context before deciding it is a new task.
- 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 skill_tasks for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy.
- If the user request contains multiple management operations, include multiple tasks and depends_on where a later task needs an earlier result.
- If the request contains exactly one management operation, include exactly one task.
- 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|skill_tasks|new_skill|continue_skill|planned_agent|none","target_skill":"","tasks":[{"id":"task_1","skill":"","action":"","request":"","depends_on":[]}],"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) {
if session, ok := a.activeStrategyCreateSession(userID); ok && strategyCreateConfirmationReply(text) {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
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
}
decision, err := parseLLMSkillRouteDecision(raw)
if err != nil || decision.Route != "skill" {
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)
}
if activeSession, hasActive := a.getActiveSkillSession(userID); hasActive {
decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang)
mergeExtractedData(&activeSession, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
}
if a.hasAnyActiveContext(userID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
}
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
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
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, decision.ReplyToUser); blocked {
decision.ReplyToUser = guarded
}
emitBrainReply(onEvent, decision.ReplyToUser)
a.recordSkillInteraction(userID, text, decision.ReplyToUser)
a.runPostResponseMaintenanceAsync(userID)
return decision.ReplyToUser, true, nil
case "new_skill":
if len(decision.Tasks) > 0 {
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent)
}
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 "skill_tasks":
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, 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":
if session, ok := a.activeStrategyCreateSession(userID); ok {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
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 (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) {
tasks := normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
if len(tasks) == 0 {
return "", false, nil
}
if task, ok := strategyCreateWorkflowTask(tasks); ok {
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)
}
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
session := newActiveSkillSession(userID, task.Skill, task.Action)
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
}
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)
}
if len(tasks) == 1 {
task := tasks[0]
session := newActiveSkillSession(userID, task.Skill, task.Action)
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
}
session := normalizeWorkflowSession(WorkflowSession{
UserID: userID,
OriginalRequest: strings.TrimSpace(text),
Tasks: tasks,
})
if len(session.Tasks) == 0 {
return "", false, nil
}
a.saveWorkflowSession(userID, session)
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func strategyCreateWorkflowTask(tasks []WorkflowTask) (WorkflowTask, bool) {
for _, task := range tasks {
if strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" {
return task, true
}
}
return WorkflowTask{}, false
}
func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string {
lines := make([]string, 0, 8)
if hasActiveTask {
lines = append(lines, fmt.Sprintf("Active task session: %s / %s / phase=%s", activeTask.SkillName, activeTask.ActionName, defaultIfEmpty(activeTask.LegacyPhase, "collecting")))
if strings.TrimSpace(activeTask.Goal) != "" {
lines = append(lines, "Active task goal: "+strings.TrimSpace(activeTask.Goal))
}
if activeTask.PendingHint != nil && strings.TrimSpace(activeTask.PendingHint.Prompt) != "" {
lines = append(lines, "Active task pending hint: "+strings.TrimSpace(activeTask.PendingHint.Prompt))
}
if len(activeTask.CollectedFields) > 0 {
fieldsJSON, _ := json.Marshal(activeTask.CollectedFields)
lines = append(lines, "Active task collected_fields: "+string(fieldsJSON))
}
}
if strings.TrimSpace(skill.Name) != "" {
lines = append(lines, fmt.Sprintf("Active skill session: %s / %s / phase=%s", skill.Name, skill.Action, defaultIfEmpty(skill.Phase, "collecting")))
if routing := buildSkillActionRoutingSummary(lang, skill); routing != "" {
lines = append(lines, routing)
}
}
if hasActiveWorkflowSession(workflow) {
lines = append(lines, fmt.Sprintf("Active workflow: original_request=%s pending_tasks=%d", workflow.OriginalRequest, countPendingWorkflowTasks(workflow)))
}
if hasActiveExecutionState(state) {
lines = append(lines, fmt.Sprintf("Active execution state: status=%s goal=%s", state.Status, state.Goal))
if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
lines = append(lines, "Waiting question: "+strings.TrimSpace(state.Waiting.Question))
}
}
if hasPendingProposal {
lines = append(lines, "Pending assistant proposal awaiting user response.")
if strings.TrimSpace(pendingProposal.SourceUserText) != "" {
lines = append(lines, "Proposal source request: "+strings.TrimSpace(pendingProposal.SourceUserText))
}
lines = append(lines, "Proposal text: "+strings.TrimSpace(pendingProposal.ProposalText))
}
return strings.Join(lines, "\n")
}
func (a *Agent) handlePendingProposalResponse(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
proposal, ok := a.getPendingProposalSession(userID)
if !ok {
return "", false, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("The user is replying to the assistant's previous proposal.\n\nOriginal user request:\n%s\n\nPrevious assistant proposal:\n%s\n\nCurrent user reply:\n%s", proposal.SourceUserText, proposal.ProposalText, text), onEvent)
if err == nil && strings.TrimSpace(answer) != "" {
a.clearPendingProposalSession(userID)
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_skill_route"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
onEvent(StreamEventDelta, answer)
}
return answer, true, nil
return answer, true, err
}
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision llmSkillRouteDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeLLMSkillRouteDecision(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 normalizeLLMSkillRouteDecision(decision), nil
func countPendingWorkflowTasks(session WorkflowSession) int {
count := 0
for _, task := range session.Tasks {
switch task.Status {
case workflowTaskPending, workflowTaskRunning:
count++
}
}
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
return count
}
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string {
if refs == nil {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
return decision
}
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action}
switch decision.Skill {
case "trader_management":
if decision.Action == "create" {
answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
lines := make([]string, 0, 4)
appendLine := func(kind string, ref *EntityReference) {
if ref == nil {
return
}
name := strings.TrimSpace(defaultIfEmpty(ref.Name, ref.ID))
if name == "" {
return
}
source := formatReferenceSourceLabel(lang, ref.Source)
if lang == "zh" {
line := fmt.Sprintf("- 当前%s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf("(来源: %s", source)
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
return
}
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && decision.Action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
line := fmt.Sprintf("- Current %s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf(" (source: %s)", source)
}
if !handled {
return skillOutcome{}, false
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "exchange_management":
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_management":
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "strategy_management":
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text),
}, true
case "exchange_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text),
}, true
case "trader_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text),
}, true
case "strategy_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text),
}, true
default:
return skillOutcome{}, false
lines = append(lines, line)
}
appendLine("strategy", refs.Strategy)
appendLine("trader", refs.Trader)
appendLine("model", refs.Model)
appendLine("exchange", refs.Exchange)
if len(lines) == 0 {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
return strings.Join(lines, "\n")
}
func formatReferenceSourceLabel(lang, source string) string {
source = strings.TrimSpace(source)
if source == "" {
return ""
}
if lang == "zh" {
switch source {
case "user_mention":
return "用户提及"
case "tool_output":
return "工具结果"
case "inferred_from_context":
return "上下文推断"
default:
return source
}
}
switch source {
case "user_mention":
return "user mention"
case "tool_output":
return "tool output"
case "inferred_from_context":
return "context inference"
default:
return source
}
}
func hasAnyActiveContext(a *Agent, userID int64) bool {
if a == nil {
return false
}
if _, ok := a.getActiveSkillSession(userID); ok {
return true
}
return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID))
}
func (a *Agent) clearAnyActiveContext(userID int64) bool {
cleared := false
if _, ok := a.getActiveSkillSession(userID); ok {
a.clearActiveSkillSession(userID)
cleared = true
}
if a.hasActiveSkillSession(userID) {
a.clearSkillSession(userID)
cleared = true
}
if hasActiveWorkflowSession(a.getWorkflowSession(userID)) {
a.clearWorkflowSession(userID)
cleared = true
}
if hasActiveExecutionState(a.getExecutionState(userID)) {
a.clearExecutionState(userID)
cleared = true
}
if cleared {
a.SnapshotManager(userID).Clear()
}
return cleared
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {

View File

@@ -0,0 +1,107 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestToolGetMarketSnapshotReturnsRealtimeAnalysisContext(t *testing.T) {
prevBaseURL := binanceFuturesAPIBaseURL
prevClient := marketDataHTTPClient
binanceFuturesAPIBaseURL = "https://example.test"
marketDataHTTPClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := ""
switch {
case strings.HasPrefix(req.URL.Path, "/fapi/v1/ticker/24hr"):
body = `{"symbol":"BTCUSDT","lastPrice":"65000","priceChange":"1200","priceChangePercent":"1.88","highPrice":"66000","lowPrice":"63800","volume":"12345","quoteVolume":"800000000","count":98765}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/premiumIndex"):
body = `{"symbol":"BTCUSDT","markPrice":"65010","indexPrice":"64990","lastFundingRate":"0.00010000","nextFundingTime":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/openInterest"):
body = `{"symbol":"BTCUSDT","openInterest":"45678.9","time":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/klines"):
body = `[[1710000000000,"64000","65100","63900","64500","100",1710000899999],[1710000900000,"64500","65500","64400","65000","120",1710001799999]]`
default:
body = `{"error":"not found"}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}),
}
defer func() {
binanceFuturesAPIBaseURL = prevBaseURL
marketDataHTTPClient = prevClient
}()
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"BTC","interval":"15m","limit":2}`)
var resp struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Ticker24h struct {
PriceChangePercent float64 `json:"price_change_percent"`
} `json:"ticker_24h"`
PerpMetrics struct {
FundingRate float64 `json:"funding_rate"`
OpenInterest float64 `json:"open_interest"`
} `json:"perp_metrics"`
KlineSnapshot struct {
Interval string `json:"interval"`
Limit int `json:"limit"`
PeriodChangePercent float64 `json:"period_change_percent"`
RecentKlines []map[string]any `json:"recent_klines"`
} `json:"kline_snapshot"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("failed to parse tool response: %v\nraw=%s", err, raw)
}
if resp.Error != "" {
t.Fatalf("unexpected tool error: %s", resp.Error)
}
if resp.Symbol != "BTCUSDT" {
t.Fatalf("expected normalized symbol BTCUSDT, got %s", resp.Symbol)
}
if resp.Price != 65000 {
t.Fatalf("expected price 65000, got %v", resp.Price)
}
if resp.Ticker24h.PriceChangePercent != 1.88 {
t.Fatalf("expected 24h change 1.88, got %v", resp.Ticker24h.PriceChangePercent)
}
if resp.PerpMetrics.FundingRate != 0.0001 {
t.Fatalf("expected funding rate 0.0001, got %v", resp.PerpMetrics.FundingRate)
}
if resp.PerpMetrics.OpenInterest != 45678.9 {
t.Fatalf("expected open interest 45678.9, got %v", resp.PerpMetrics.OpenInterest)
}
if resp.KlineSnapshot.Interval != "15m" || resp.KlineSnapshot.Limit != 2 {
t.Fatalf("unexpected kline snapshot metadata: %+v", resp.KlineSnapshot)
}
if len(resp.KlineSnapshot.RecentKlines) != 2 {
t.Fatalf("expected 2 klines, got %d", len(resp.KlineSnapshot.RecentKlines))
}
if resp.KlineSnapshot.PeriodChangePercent <= 0 {
t.Fatalf("expected positive period change, got %v", resp.KlineSnapshot.PeriodChangePercent)
}
}
func TestToolGetMarketSnapshotRejectsStockSymbols(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"AAPL"}`)
if !strings.Contains(raw, "currently supports crypto symbols only") {
t.Fatalf("expected stock rejection, got: %s", raw)
}
}

View File

@@ -11,8 +11,9 @@ import (
)
const (
recentConversationRounds = 3
recentConversationRounds = 6
recentConversationMessages = recentConversationRounds * 2
chatHistoryMaxTurns = recentConversationMessages * 2 // fallback cap when compression is unavailable
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6

View File

@@ -1,132 +0,0 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"nofx/mcp"
"nofx/store"
)
type fakeAIClient struct {
callCount int
}
func (f *fakeAIClient) SetAPIKey(string, string, string) {}
func (f *fakeAIClient) SetTimeout(time.Duration) {}
func (f *fakeAIClient) CallWithMessages(string, string) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) {
f.callCount++
return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil
}
func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return nil, nil
}
func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(42)
payload := strings.Repeat("BTC ETH market context ", 20)
for i := 0; i < 6; i++ {
a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload)
a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload)
}
a.maybeCompressHistory(context.Background(), userID)
msgs := a.history.Get(userID)
if len(msgs) != recentConversationMessages {
t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs))
}
if fakeClient.callCount != 1 {
t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected persisted task state goal, got %#v", state)
}
if state.LastDecision == nil || state.LastDecision.Action != "paused setup" {
t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision)
}
if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" {
t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops)
}
if strings.Contains(msgs[0].Content, "#0") {
t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content)
}
if !strings.Contains(msgs[0].Content, "#3") {
t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content)
}
if !strings.Contains(msgs[len(msgs)-1].Content, "#5") {
t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content)
}
}
func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) {
state := normalizeTaskState(TaskState{
OpenLoops: []string{
"wait for API secret",
"call get_exchange_configs",
"finish trader setup after external configuration is ready",
},
})
if len(state.OpenLoops) != 1 {
t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops)
}
if state.OpenLoops[0] != "finish trader setup after external configuration is ready" {
t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops)
}
}
func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(7)
a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的错误是运行时出现的")
a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误")
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
if fakeClient.callCount != 1 {
t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected incrementally persisted task state, got %#v", state)
}
}

View File

@@ -0,0 +1,75 @@
package agent
import (
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestHandleModelCreateSkillAsksProviderFirstWithClaw402Recommendation(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-create.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
reply := a.handleModelCreateSkill("default", 42, "zh", "请帮我创建一个模型", skillSession{})
for _, want := range []string{
"还缺这些字段:模型提供商",
"可选模型 provider",
"推荐 `claw402`",
"并列可选",
"按次付费",
"Base USDC 钱包支付",
"直接创建 Base 钱包",
"直接扫码充值/支付",
} {
if !strings.Contains(reply, want) {
t.Fatalf("expected reply to contain %q, got: %s", want, reply)
}
}
for _, unexpected := range []string{
"还缺这些字段模型提供商、API Key",
"还缺这些字段:模型提供商、钱包私钥",
"还缺这些字段模型提供商、wallet private key",
} {
if strings.Contains(reply, unexpected) {
t.Fatalf("provider-first reply should not ask for credentials yet: %s", reply)
}
}
}
func TestHandleModelCreateSkillUsesCollectedClaw402PrivateKey(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-create-claw402.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "model_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{
"provider": "claw402",
"name": "Claw402 (Base USDC)",
"api_key": "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca",
"custom_model_name": "deepseek",
},
}
reply := a.handleModelCreateSkill("default", 42, "zh", "继续", session)
if strings.Contains(reply, "还缺这些字段:钱包私钥") {
t.Fatalf("expected bare private key to be accepted, got: %s", reply)
}
if !strings.Contains(reply, "我先整理了一份模型配置草稿") {
t.Fatalf("expected draft summary after accepting private key, got: %s", reply)
}
}

View File

@@ -0,0 +1,242 @@
package agent
import (
"fmt"
"strings"
)
type modelProviderSpec struct {
ID string
DisplayName string
DefaultModel string
CredentialLabelZH string
CredentialLabelEN string
SupportsCustomAPIURL bool
SupportsCustomModel bool
UsesWalletCredential bool
Recommended bool
RecommendedModelHints []string
}
func supportedModelProviders() []modelProviderSpec {
return []modelProviderSpec{
{ID: "deepseek", DisplayName: "DeepSeek", DefaultModel: "deepseek-chat", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "qwen", DisplayName: "Qwen", DefaultModel: "qwen3-max", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "openai", DisplayName: "OpenAI", DefaultModel: "gpt-5.1", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "claude", DisplayName: "Claude", DefaultModel: "claude-opus-4-6", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "gemini", DisplayName: "Google Gemini", DefaultModel: "gemini-3-pro-preview", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "grok", DisplayName: "Grok (xAI)", DefaultModel: "grok-3-latest", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "kimi", DisplayName: "Kimi (Moonshot)", DefaultModel: "moonshot-v1-auto", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "minimax", DisplayName: "MiniMax", DefaultModel: "MiniMax-M2.5", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{
ID: "claw402",
DisplayName: "Claw402 (Base USDC)",
DefaultModel: "deepseek",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: true,
UsesWalletCredential: true,
Recommended: true,
RecommendedModelHints: []string{"deepseek", "glm-5", "gpt-5.4", "claude-opus", "qwen-max", "grok-4.1"},
},
{
ID: "blockrun-base",
DisplayName: "BlockRun (Base Wallet)",
DefaultModel: "auto",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: false,
UsesWalletCredential: true,
},
{
ID: "blockrun-sol",
DisplayName: "BlockRun (Solana Wallet)",
DefaultModel: "auto",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: false,
UsesWalletCredential: true,
},
}
}
func modelProviderSpecByID(provider string) (modelProviderSpec, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
for _, spec := range supportedModelProviders() {
if spec.ID == provider {
return spec, true
}
}
return modelProviderSpec{}, false
}
func supportedModelProviderIDs() []string {
specs := supportedModelProviders()
out := make([]string, 0, len(specs))
for _, spec := range specs {
out = append(out, spec.ID)
}
return out
}
func defaultModelNameForProvider(provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
return strings.TrimSpace(spec.DefaultModel)
}
func defaultModelConfigName(provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
provider = strings.TrimSpace(provider)
if provider == "" {
return ""
}
return provider + " AI"
}
return spec.DisplayName
}
func modelProviderSupportsCustomAPIURL(provider string) bool {
spec, ok := modelProviderSpecByID(provider)
return ok && spec.SupportsCustomAPIURL
}
func modelProviderSupportsCustomModel(provider string) bool {
spec, ok := modelProviderSpecByID(provider)
return ok && spec.SupportsCustomModel
}
func modelProviderCredentialLabel(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
if lang == "zh" {
return "API Key"
}
return "API key"
}
if lang == "zh" {
return spec.CredentialLabelZH
}
return spec.CredentialLabelEN
}
func modelProviderSummaryList(lang string) string {
parts := make([]string, 0, len(supportedModelProviders()))
for _, spec := range supportedModelProviders() {
if lang == "zh" {
item := fmt.Sprintf("%s默认 %s", spec.ID, spec.DefaultModel)
if spec.Recommended {
item += " [推荐]"
}
parts = append(parts, item)
continue
}
item := fmt.Sprintf("%s (default %s)", spec.ID, spec.DefaultModel)
if spec.Recommended {
item += " [recommended]"
}
parts = append(parts, item)
}
if lang == "zh" {
return strings.Join(parts, "、")
}
return strings.Join(parts, ", ")
}
func modelProviderChoicePrompt(lang string) string {
if lang == "zh" {
return "可选模型 provider" + modelProviderSummaryList(lang) + "。这些 provider 是并列可选的:你可以直接选 `claw402`、DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax 这类 API Key provider或者选 `blockrun-base` / `blockrun-sol` 这类钱包 provider。我们优先推荐 `claw402`,因为它按次付费、用 Base USDC 钱包支付、默认配置更省事。对于第一次使用的新手,也可以直接去产品配置页的模型配置里选择 `claw402`:那里支持直接创建 Base 钱包,并且可以直接扫码充值/支付。请先告诉我你想用哪个 provider。"
}
return "Available model providers: " + modelProviderSummaryList(lang) + ". These providers are peer options: you can choose `claw402`, an API-key provider such as DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax, or a wallet-based provider such as `blockrun-base` / `blockrun-sol`. We recommend `claw402` first because it is pay-per-use, uses Base USDC wallet payment, and has the simplest default setup. If this is your first time, you can also open the product's model config page, choose `claw402`, create a Base wallet there directly, and pay by scanning the QR/deposit flow. Tell me which provider you want first."
}
func modelProviderDetailedGuidance(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
if lang == "zh" {
lines := []string{
fmt.Sprintf("你现在选的是 %s。", spec.DisplayName),
fmt.Sprintf("- 默认模型名:%s", spec.DefaultModel),
fmt.Sprintf("- 凭证类型:%s", spec.CredentialLabelZH),
}
if spec.SupportsCustomModel {
lines = append(lines, "- `custom_model_name` 可选;留空时默认用上面的默认模型。")
} else {
lines = append(lines, "- 这个 provider 不需要单独填写 `custom_model_name`。")
}
if spec.SupportsCustomAPIURL {
lines = append(lines, "- `custom_api_url` 可选;留空时使用官方默认地址。")
} else {
lines = append(lines, "- 这个 provider 不需要 `custom_api_url`。")
}
if len(spec.RecommendedModelHints) > 0 {
lines = append(lines, "- 常见可选模型:"+strings.Join(spec.RecommendedModelHints, "、"))
}
if provider == "claw402" {
lines = append(lines, "- 这是我们优先推荐的 provider按次付费、Base USDC 钱包支付,对新手最省事。")
lines = append(lines, "- 如果你是第一次用,也可以直接去配置页的模型配置里选择 `claw402`,那里支持直接创建 Base 钱包,并可直接扫码充值/支付。")
}
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("You selected %s.", spec.DisplayName),
fmt.Sprintf("- Default model: %s", spec.DefaultModel),
fmt.Sprintf("- Credential type: %s", spec.CredentialLabelEN),
}
if spec.SupportsCustomModel {
lines = append(lines, "- `custom_model_name` is optional; if omitted, the default model will be used.")
} else {
lines = append(lines, "- This provider does not need a separate `custom_model_name`.")
}
if spec.SupportsCustomAPIURL {
lines = append(lines, "- `custom_api_url` is optional; if omitted, the official default endpoint will be used.")
} else {
lines = append(lines, "- This provider does not need `custom_api_url`.")
}
if len(spec.RecommendedModelHints) > 0 {
lines = append(lines, "- Common model choices: "+strings.Join(spec.RecommendedModelHints, ", "))
}
if provider == "claw402" {
lines = append(lines, "- This is our recommended provider: pay-per-use, Base USDC wallet payment, and the easiest setup for first-time users.")
lines = append(lines, "- If this is your first time, you can also open the model config page, choose `claw402`, create a Base wallet there directly, and pay through the QR/deposit flow.")
}
return strings.Join(lines, "\n")
}
func modelProviderCredentialGuidance(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
provider = strings.TrimSpace(spec.ID)
if lang == "zh" {
switch provider {
case "claw402":
return "claw402 这里要填的是 Base 链 EVM 钱包私钥。\n- 如果你是第一次用,最省事的方式是直接去配置页的模型配置里选择 `claw402`。\n- 那里可以一键快速创建钱包,界面会直接展示新钱包私钥,并且提供 Base USDC 充值入口。\n- 创建后请立刻备份私钥;系统会用它完成 claw402 支付和模型调用。\n- 如果你已经有 MetaMask、Rabby、Coinbase Wallet 这类 Base/EVM 钱包,也可以从钱包里导出现有私钥再发我。"
case "blockrun-base":
return "blockrun-base 这里要填的是 Base 链 EVM 钱包私钥。你可以从现有 EVM 钱包导出私钥后发我。"
case "blockrun-sol":
return "blockrun-sol 这里要填的是 Solana 钱包私钥。你可以从现有 Solana 钱包导出私钥后发我。"
default:
return fmt.Sprintf("%s 这里要填的是 %s。你把完整值发我就行我会继续当前模型草稿。", spec.DisplayName, spec.CredentialLabelZH)
}
}
switch provider {
case "claw402":
return "For claw402, this field expects a Base-chain EVM wallet private key.\n- If this is your first time, the easiest path is to open the model config page and choose `claw402`.\n- That flow can quickly create a wallet for you, show the new private key, and provide a Base USDC deposit path.\n- Back up the key immediately after creation; the system uses it for claw402 payments and model access.\n- If you already use MetaMask, Rabby, or Coinbase Wallet, you can also export an existing Base/EVM wallet private key and send it to me."
case "blockrun-base":
return "For blockrun-base, this field expects a Base-chain EVM wallet private key. You can export it from an existing EVM wallet and send it to me."
case "blockrun-sol":
return "For blockrun-sol, this field expects a Solana wallet private key. You can export it from an existing Solana wallet and send it to me."
default:
return fmt.Sprintf("For %s, this field expects your %s. Send me the full value and I'll continue the current model draft.", spec.DisplayName, spec.CredentialLabelEN)
}
}

View File

@@ -0,0 +1,57 @@
package agent
import (
"strings"
"testing"
)
func TestModelProviderChoicePromptIncludesRecommendationWithoutAutoSelection(t *testing.T) {
msg := modelProviderChoicePrompt("zh")
for _, want := range []string{
"可选模型 provider",
"claw402",
"DeepSeek",
"OpenAI",
"并列可选",
"blockrun-base",
"直接创建 Base 钱包",
"直接扫码充值/支付",
"请先告诉我你想用哪个 provider",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected prompt to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "把私钥发给我") {
t.Fatalf("provider choice prompt should not jump ahead to credential collection: %s", msg)
}
}
func TestModelProviderCredentialGuidanceForClaw402MentionsConfigPageWalletFlow(t *testing.T) {
msg := modelProviderCredentialGuidance("zh", "claw402")
for _, want := range []string{
"Base 链 EVM 钱包私钥",
"配置页的模型配置里选择 `claw402`",
"快速创建钱包",
"充值入口",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected guidance to contain %q, got: %s", want, msg)
}
}
}
func TestModelProviderDetailedGuidanceForClaw402MentionsBeginnerFlow(t *testing.T) {
msg := modelProviderDetailedGuidance("zh", "claw402")
for _, want := range []string{
"优先推荐",
"按次付费",
"Base USDC 钱包支付",
"直接创建 Base 钱包",
"直接扫码充值/支付",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected detailed guidance to contain %q, got: %s", want, msg)
}
}
}

View File

@@ -0,0 +1,86 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
func isModelWalletBalanceQuestion(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !strings.Contains(lower, "claw402") {
return false
}
return containsAny(lower, []string{"余额", "balance", "usdc"}) &&
containsAny(lower, []string{"钱包", "wallet", "主钱包", "base"})
}
func (a *Agent) handleModelWalletBalanceQuestion(storeUserID, lang, text string) (string, bool) {
if !isModelWalletBalanceQuestion(text) || a == nil || a.store == nil {
return "", false
}
models, err := a.store.AIModel().List(storeUserID)
if err != nil {
if lang == "zh" {
return "我现在读取模型配置失败,暂时查不到 claw402 钱包余额。", true
}
return "I could not read model configs, so I cannot check the claw402 wallet balance right now.", true
}
var matches []safeModelToolConfig
for _, model := range models {
if model == nil || strings.ToLower(strings.TrimSpace(model.Provider)) != "claw402" {
continue
}
matches = append(matches, safeModelForTool(model))
}
if len(matches) == 0 {
if lang == "zh" {
return "当前没有找到 claw402 模型钱包配置。", true
}
return "No claw402 model wallet config was found.", true
}
if lang == "zh" {
lines := []string{"当前 claw402 模型钱包余额:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s%s USDC", name, defaultIfEmpty(model.BalanceUSDC, "暂时无法读取")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" 钱包地址:%s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
if model.Enabled {
lines = append(lines, " 这个模型配置已启用,但钱包余额为 0 USDC这不是“未启用”而是需要先充值 Base USDC 后才能稳定调用。")
} else {
lines = append(lines, " 钱包余额为 0 USDC启用并充值 Base USDC 后才能稳定调用。")
}
}
}
lines = append(lines, "注意:这是 claw402/Base 模型支付钱包余额,不是 OKX/Binance 等交易所账户余额。")
return strings.Join(lines, "\n"), true
}
lines := []string{"Current claw402 model wallet balance:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s: %s USDC", name, defaultIfEmpty(model.BalanceUSDC, "unavailable")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" Wallet address: %s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
lines = append(lines, " This model config may be enabled, but the wallet balance is 0 USDC; recharge Base USDC before relying on it.")
}
}
lines = append(lines, "Note: this is the claw402/Base model payment wallet balance, not an exchange account balance.")
return strings.Join(lines, "\n"), true
}
func balanceIsZero(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
parsed, err := strconv.ParseFloat(trimmed, 64)
return err == nil && parsed <= 0
}

View File

@@ -11,6 +11,7 @@ import (
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
@@ -41,6 +42,11 @@ func (a *Agent) needsSetup() bool {
// getSetupState loads the current setup state from user preferences.
func (a *Agent) getSetupState(userID int64) *SetupState {
if cached, ok := a.setupStates.Load(userID); ok {
if state, ok := cached.(*SetupState); ok && state != nil {
return cloneSetupState(state)
}
}
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
if step == "" {
return &SetupState{}
@@ -49,48 +55,30 @@ func (a *Agent) getSetupState(userID int64) *SetupState {
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
APIKey: getConfig(a.store, userID, "api_key"),
APISecret: getConfig(a.store, userID, "api_secret"),
Passphrase: getConfig(a.store, userID, "passphrase"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
AIKey: getConfig(a.store, userID, "ai_key"),
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
}
}
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.setupStates.Store(userID, cloneSetupState(s))
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
// Store only a masked marker for secrets — full values stay in memory only.
// This prevents plaintext credentials from lingering in the config store
// if the setup flow is interrupted before clearSetupState runs.
if s.APIKey != "" {
setConfig(a.store, userID, "api_key", "****")
}
if s.APISecret != "" {
setConfig(a.store, userID, "api_secret", "****")
}
if s.Passphrase != "" {
setConfig(a.store, userID, "passphrase", "****")
}
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
if s.AIKey != "" {
setConfig(a.store, userID, "ai_key", "****")
}
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} {
if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil {
a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err)
}
a.setupStates.Delete(userID)
for _, k := range []string{"step", "exchange", "exchange_id", "ai_provider", "ai_model", "ai_model_id", "ai_base_url"} {
a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), "")
}
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), "")
}
func getConfig(st *store.Store, uid int64, key string) string {
@@ -102,6 +90,14 @@ func setConfig(st *store.Store, uid int64, key, val string) {
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
}
func cloneSetupState(s *SetupState) *SetupState {
if s == nil {
return &SetupState{}
}
copy := *s
return &copy
}
// handleSetupFlow processes the setup conversation.
// Returns (response, handled). If handled=false, continue to normal routing.
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
@@ -165,7 +161,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
@@ -182,7 +178,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
@@ -201,7 +197,7 @@ func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, te
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true
return fmt.Sprintf("⚠️ I could not save the AI model settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.AIModelID = aiModelID
return a.finishSetup(storeUserID, userID, state, L)
@@ -226,7 +222,7 @@ func isDirectSetupCommand(text string) bool {
return false
}
switch text {
case "setup", "/setup", "开始配置", "配置", "开始设置":
case "setup", "/setup":
return true
default:
return false
@@ -265,19 +261,19 @@ func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, st
lower := strings.ToLower(strings.TrimSpace(text))
models := map[string]struct{ provider, model, url string }{
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
@@ -502,7 +498,9 @@ func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string,
return "", err
}
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
if modelID == state.AIProvider {
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
}
return modelID, nil
}

View File

@@ -1,26 +0,0 @@
package agent
import "testing"
func TestIsDirectSetupCommand(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "setup", want: true},
{text: "/setup", want: true},
{text: "开始配置", want: true},
{text: "配置", want: true},
{text: "开始设置", want: true},
{text: "/开始配置", want: false},
{text: "创建全新的配置,杠杆你定", want: false},
{text: "帮我配置一个 deepseek 模型", want: false},
{text: "绑定交易所 okx", want: false},
}
for _, tc := range cases {
if got := isDirectSetupCommand(tc.text); got != tc.want {
t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,807 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestIsConfigOrTraderIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "帮我创建一个交易员", want: true},
{text: "我已经配置好了 OKX 和 DeepSeek", want: true},
{text: "List my traders", want: true},
{text: "BTC 接下来怎么看", want: false},
}
for _, tc := range cases {
if got := isConfigOrTraderIntent(tc.text); got != tc.want {
t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestIsRealtimeAccountIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "现在余额多少", want: true},
{text: "我的仓位还在吗", want: true},
{text: "show recent trade history", want: true},
{text: "帮我创建交易员", want: false},
}
for _, tc := range cases {
if got := isRealtimeAccountIntent(tc.text); got != tc.want {
t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestDetectReadFastPath(t *testing.T) {
cases := []struct {
text string
want string
}{
{text: "/traders", want: "list_traders"},
{text: "/strategies", want: "get_strategies"},
{text: "/models", want: "get_model_configs"},
{text: "/exchanges", want: "get_exchange_configs"},
{text: "/balance", want: "get_balance"},
{text: "/positions", want: "get_positions"},
{text: "/history", want: "get_trade_history"},
{text: "/trades", want: "get_trade_history"},
{text: "列出我当前的策略", want: ""},
{text: "查看当前交易员", want: ""},
{text: "现在余额多少", want: ""},
{text: "我的仓位还在吗", want: ""},
{text: "我现在有哪些账户", want: ""},
{text: "我的余额", want: ""},
{text: "根据我的余额帮我分析我应该买什么", want: ""},
{text: "我的策略是AI100但是No candidate coins available, cycle skipped", want: ""},
{text: "帮我创建一个 trader", want: ""},
}
for _, tc := range cases {
req := detectReadFastPath(tc.text)
got := ""
if req != nil {
got = req.Kind
}
if got != tc.want {
t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want)
}
}
}
func TestShouldResetExecutionStateForNewAttempt(t *testing.T) {
state := ExecutionState{
SessionID: "sess_1",
Status: executionStatusWaitingUser,
}
if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) {
t.Fatalf("expected retry-style config request to reset execution state")
}
if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) {
t.Fatalf("did not expect generic market query to reset execution state")
}
}
func TestLatestAskedQuestion(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"},
},
}
got := latestAskedQuestion(state)
want := "需要我用正确的参数重试创建交易员 lky 吗?"
if got != want {
t.Fatalf("latestAskedQuestion() = %q, want %q", got, want)
}
}
func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Waiting: &WaitingState{
Question: "请确认是否继续创建交易员 lky",
Intent: "confirm_action",
},
Steps: []PlanStep{
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"},
},
}
if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" {
t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got)
}
}
func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"Main",
"enabled":true
}`)
state := ExecutionState{
SessionID: "sess_1",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_model_configs", Summary: "stale"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state)
if len(refreshed.DynamicSnapshots) < 3 {
t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots)
}
var foundModel, foundExchange, foundTraders bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_model_configs":
foundModel = strings.Contains(obs.RawJSON, "openai")
case "current_exchange_configs":
foundExchange = strings.Contains(obs.RawJSON, "okx")
case "current_traders":
foundTraders = strings.Contains(obs.RawJSON, `"traders"`)
}
}
if !foundModel || !foundExchange || !foundTraders {
t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots)
}
}
func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
state := ExecutionState{
SessionID: "sess_2",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_balances", Summary: "stale balances"},
{Kind: "current_positions", Summary: "stale positions"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state)
var keptBalances, keptPositions, foundHistory bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_balances":
keptBalances = strings.Contains(obs.Summary, "stale balances")
case "current_positions":
keptPositions = strings.Contains(obs.Summary, "stale positions")
case "recent_trade_history":
foundHistory = obs.RawJSON != ""
}
}
if !keptBalances || !keptPositions || foundHistory {
t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots)
}
}
func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp)
}
}
func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_legacy",
UserID: 1,
Observations: []Observation{
{Kind: "tool_result", Summary: "legacy tool result"},
},
})
if len(state.Observations) != 0 {
t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations)
}
if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" {
t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog)
}
}
func TestBuildWaitingStateForTraderConfirmation(t *testing.T) {
state := ExecutionState{Goal: "创建交易员 lky"}
step := PlanStep{
ID: "step_ask_1",
Type: planStepTypeAskUser,
Instruction: "需要我用正确的参数重试创建交易员 lky 吗?",
RequiresConfirmation: true,
}
waiting := buildWaitingState(state, step, step.Instruction)
if waiting == nil {
t.Fatal("expected waiting state")
}
if waiting.Intent != "confirm_action" {
t.Fatalf("unexpected waiting intent: %+v", waiting)
}
if waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", waiting)
}
}
func TestNormalizeWaitingStateCleansFields(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_waiting",
UserID: 1,
Waiting: &WaitingState{
Question: " 请提供 strategy_id ",
Intent: " complete_trader_setup ",
PendingFields: []string{" strategy_id ", "strategy_id"},
ConfirmationTarget: " trader ",
},
})
if state.Waiting == nil {
t.Fatal("expected normalized waiting state")
}
if state.Waiting.Question != "请提供 strategy_id" {
t.Fatalf("unexpected normalized question: %+v", state.Waiting)
}
if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" {
t.Fatalf("unexpected pending fields: %+v", state.Waiting)
}
if state.Waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", state.Waiting)
}
}
func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
state := newExecutionState(1, "帮我改一下激进这个策略")
a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state)
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) {
state := newExecutionState(1, "创建策略")
changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{
"status":"ok",
"action":"create",
"strategy":{"id":"strategy_1","name":"激进"}
}`)
if !changed {
t.Fatalf("expected reference update to report changed")
}
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.ID != "strategy_1" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestShouldAttemptReplan(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending},
},
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "manage_trader",
ToolArgs: map[string]any{"action": "create"},
OutputSummary: `{"status":"ok","action":"create"}`,
}, false) {
t.Fatalf("expected create trader step to trigger replan")
}
if shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"balances":[]}`,
}, false) {
t.Fatalf("did not expect read-only balance step to trigger replan")
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"error":"ai_model_id is required"}`,
}, false) {
t.Fatalf("expected dependency/error result to trigger replan")
}
}
type failingAIClient struct{}
func (f *failingAIClient) SetAPIKey(string, string, string) {}
func (f *failingAIClient) SetTimeout(_ time.Duration) {}
func (f *failingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) {
return "", errors.New("API returned error (status 402): insufficient balance")
}
func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("API returned error (status 402): insufficient balance")
}
type capturePlannerAIClient struct {
systemPrompt string
userPrompt string
}
func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {}
func (c *capturePlannerAIClient) SetTimeout(time.Duration) {}
func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.systemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.userPrompt = req.Messages[1].Content
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type blockingAIClient struct{}
func (b *blockingAIClient) SetAPIKey(string, string, string) {}
func (b *blockingAIClient) SetTimeout(time.Duration) {}
func (b *blockingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) {
<-req.Ctx.Done()
return "", req.Ctx.Err()
}
func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type directReplyAIClient struct {
lastSystemPrompt string
lastUserPrompt string
routerPrompt string
skillRouterPrompt string
plannerPrompt string
}
func (d *directReplyAIClient) SetAPIKey(string, string, string) {}
func (d *directReplyAIClient) SetTimeout(time.Duration) {}
func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
d.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
d.lastUserPrompt = req.Messages[1].Content
}
if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") {
d.routerPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "你好") {
return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil
}
return `{"action":"defer","answer":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") {
d.skillRouterPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") {
return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil
}
return `{"route":"planner","skill":"","action":"","filter":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") {
d.plannerPrompt = d.lastSystemPrompt
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) {
a := &Agent{
aiClient: &failingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil)
if err != nil {
t.Fatalf("thinkAndActLegacy() error = %v", err)
}
if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") {
t.Fatalf("expected provider failure message, got fallback: %q", resp)
}
if !strings.Contains(resp, "AI 服务调用失败") {
t.Fatalf("expected provider failure message, got %q", resp)
}
}
func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) {
client := &directReplyAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "你好,我在") {
t.Fatalf("expected direct reply response, got %q", resp)
}
if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") {
t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt)
}
}
func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) {
a := newTestAgentWithStore(t)
a.aiClient = &directReplyAIClient{}
resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp)
}
}
func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil {
t.Fatalf("update trader status: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") {
t.Fatalf("expected routed running-trader answer, got %q", resp)
}
if client.skillRouterPrompt == "" {
t.Fatal("expected lightweight skill router prompt to be used")
}
if client.plannerPrompt != "" {
t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt)
}
}
func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
a.logger = slog.Default()
userID := int64(90)
state := newExecutionState(userID, "继续完成当前任务")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请确认是否继续",
Intent: "confirm_action",
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if strings.Contains(resp, "你好,我在") {
t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp)
}
if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") {
t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt)
}
}
func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
a.history = newChatHistory(10)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"lang":"zh"
}`)
userID := int64(91)
state := newExecutionState(userID, "创建交易员")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请告诉我交易员名称",
PendingFields: []string{"name"},
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if got := a.getExecutionState(userID); got.SessionID != "" {
t.Fatalf("expected execution state to be cleared, got %+v", got)
}
}
func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(42)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
a.history.Add(userID, "user", "好的那就按当前trader来")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的那就按当前trader来", newExecutionState(userID, "好的那就按当前trader来"))
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n")
toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:")
if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx {
t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt)
}
recentBlock := client.userPrompt[recentIdx:toolsIdx]
if strings.Contains(recentBlock, "好的那就按当前trader来") {
t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock)
}
if !strings.Contains(client.systemPrompt, "Memory priority order:") {
t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") {
t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") {
t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt)
}
}
func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(99)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
}
func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(100)
a.history.Add(userID, "user", "配置页怎么只有三个交易所")
a.history.Add(userID, "assistant", "目前你看到的是三个交易所。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") {
t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") {
t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt)
}
}
func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) {
oldTimeout := plannerCreateTimeout
plannerCreateTimeout = 10 * time.Millisecond
defer func() { plannerCreateTimeout = oldTimeout }()
a := &Agent{
aiClient: &blockingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil)
if err != nil {
t.Fatalf("runPlannedAgent() error = %v", err)
}
if !strings.Contains(resp, "处理超时") {
t.Fatalf("expected timeout message, got %q", resp)
}
}
func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) {
a := &Agent{
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
pending: newPendingTrades(),
}
resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing")
if err != nil {
t.Fatalf("handleMessageForStoreUser() error = %v", err)
}
if !strings.Contains(resp, "交易已过期或不存在") {
t.Fatalf("expected direct trade confirmation handling, got %q", resp)
}
}
func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) {
url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected deepseek default url: %q", url)
}
if model != "deepseek-chat" {
t.Fatalf("unexpected deepseek default model: %q", model)
}
url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected resolved url: %q", url)
}
if model != "deepseek1" {
t.Fatalf("expected existing custom model name to win, got %q", model)
}
}

View File

@@ -0,0 +1,84 @@
package agent
import (
"encoding/json"
"testing"
"nofx/mcp"
)
func TestPlannerToolsForMarketIntentAreTrimmed(t *testing.T) {
tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线")
names := toolNamesForTest(tools)
for _, expected := range []string{"get_market_snapshot", "get_market_price", "get_kline"} {
if !containsString(names, expected) {
t.Fatalf("expected market tool %q in %v", expected, names)
}
}
for _, unexpected := range []string{"manage_strategy", "manage_trader", "manage_exchange_config", "manage_model_config"} {
if containsString(names, unexpected) {
t.Fatalf("did not expect management tool %q in market tools %v", unexpected, names)
}
}
}
func TestPlannerToolsForExchangeIntentAreTrimmed(t *testing.T) {
tools := plannerToolsForText("帮我添加 okx 交易所 API key")
names := toolNamesForTest(tools)
if len(names) != 2 {
t.Fatalf("expected two exchange tools, got %v", names)
}
for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} {
if !containsString(names, expected) {
t.Fatalf("expected exchange tool %q in %v", expected, names)
}
}
}
func TestPlannerToolsUseCompactManageStrategyForReadIntent(t *testing.T) {
tools := plannerToolsForText("列出我的策略")
tool := findToolForTest(tools, "manage_strategy")
if tool == nil {
t.Fatalf("expected manage_strategy in strategy tools")
}
raw, _ := json.Marshal(tool.Function.Parameters)
if len(raw) > 900 {
t.Fatalf("expected compact strategy schema, got %d bytes", len(raw))
}
if string(raw) == "" || !json.Valid(raw) {
t.Fatalf("expected valid strategy schema JSON")
}
}
func TestPlannerToolsKeepFullManageStrategyForMutationIntent(t *testing.T) {
tools := plannerToolsForText("创建一个 BTC 网格策略")
tool := findToolForTest(tools, "manage_strategy")
if tool == nil {
t.Fatalf("expected manage_strategy in strategy tools")
}
raw, _ := json.Marshal(tool.Function.Parameters)
if len(raw) < 1500 {
t.Fatalf("expected full strategy schema for mutation intent, got %d bytes", len(raw))
}
}
func toolNamesForTest(tools []mcp.Tool) []string {
names := make([]string, 0, len(tools))
for _, tool := range tools {
names = append(names, tool.Function.Name)
}
return names
}
func findToolForTest(tools []mcp.Tool, name string) *mcp.Tool {
for i := range tools {
if tools[i].Function.Name == name {
return &tools[i]
}
}
return nil
}

View File

@@ -8,6 +8,8 @@ import (
"time"
)
const maxPersistentPreferenceLength = 500
// PersistentPreference is a durable user instruction shown in the UI and
// injected into the agent context for future conversations.
type PersistentPreference struct {
@@ -21,6 +23,9 @@ func NewPersistentPreference(text string) (PersistentPreference, error) {
if text == "" {
return PersistentPreference{}, fmt.Errorf("text required")
}
if len([]rune(text)) > maxPersistentPreferenceLength {
return PersistentPreference{}, fmt.Errorf("text too long (max %d characters)", maxPersistentPreferenceLength)
}
now := time.Now().UTC()
return PersistentPreference{

View File

@@ -1,31 +0,0 @@
package agent
import (
"strings"
"testing"
)
func TestNewPersistentPreference(t *testing.T) {
pref, err := NewPersistentPreference(" Always answer in Chinese. ")
if err != nil {
t.Fatalf("expected preference to be created, got error: %v", err)
}
if pref.ID == "" {
t.Fatal("expected non-empty preference id")
}
if pref.Text != "Always answer in Chinese." {
t.Fatalf("expected trimmed text, got %q", pref.Text)
}
if pref.CreatedAt == "" {
t.Fatal("expected created_at to be set")
}
if strings.Contains(pref.ID, "Always") {
t.Fatalf("expected generated id, got %q", pref.ID)
}
}
func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) {
if _, err := NewPersistentPreference(" "); err == nil {
t.Fatal("expected empty text to be rejected")
}
}

74
agent/prompt_context.go Normal file
View File

@@ -0,0 +1,74 @@
package agent
import (
"fmt"
"strings"
)
func (a *Agent) buildCurrentTurnContext(userID int64, lang, currentUserText string) string {
var parts []string
previousAssistantReply := strings.TrimSpace(a.currentPendingHintText(userID))
if previousAssistantReply != "" {
parts = append(parts, "Previous assistant reply:\n"+previousAssistantReply)
}
recentConversation := strings.TrimSpace(a.buildRecentConversationContext(userID, currentUserText))
if recentConversation != "" {
parts = append(parts, "Recent conversation:\n"+recentConversation)
}
currentRefs := strings.TrimSpace(buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)))
if currentRefs != "" {
parts = append(parts, "Current references:\n"+currentRefs)
}
return strings.Join(parts, "\n\n")
}
func (a *Agent) buildActiveTaskStateContext(userID int64, lang string) string {
activeSkill := a.getSkillSession(userID)
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
activeWorkflow := a.getWorkflowSession(userID)
activeExec := normalizeExecutionState(a.getExecutionState(userID))
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
lines := []string{}
if hasActiveTask || strings.TrimSpace(activeSkill.Name) != "" || hasActiveWorkflowSession(activeWorkflow) || hasActiveExecutionState(activeExec) || hasPendingProposal {
summary := strings.TrimSpace(buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal))
if summary != "" {
lines = append(lines, summary)
}
}
taskState := normalizeTaskState(a.getTaskState(userID))
if taskState.CurrentGoal != "" {
lines = append(lines, "Durable goal: "+taskState.CurrentGoal)
}
if taskState.ActiveFlow != "" {
lines = append(lines, "Durable active flow: "+taskState.ActiveFlow)
}
if len(taskState.OpenLoops) > 0 {
limit := len(taskState.OpenLoops)
if limit > 3 {
limit = 3
}
for _, loop := range taskState.OpenLoops[:limit] {
lines = append(lines, "Open loop: "+loop)
}
}
if hasActiveExecutionState(activeExec) {
lines = append(lines, fmt.Sprintf("Execution status: %s", activeExec.Status))
if strings.TrimSpace(activeExec.Goal) != "" {
lines = append(lines, "Execution goal: "+strings.TrimSpace(activeExec.Goal))
}
if activeExec.Waiting != nil && strings.TrimSpace(activeExec.Waiting.Question) != "" {
lines = append(lines, "Waiting question: "+strings.TrimSpace(activeExec.Waiting.Question))
}
if strings.TrimSpace(activeExec.CurrentStepID) != "" {
lines = append(lines, "Current step id: "+strings.TrimSpace(activeExec.CurrentStepID))
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}

25
agent/prompt_persona.go Normal file
View File

@@ -0,0 +1,25 @@
package agent
import "strings"
const nofxiAdvisorSystemPreamble = `You are NOFXi, the core intelligence hub of the NOFX platform.
You understand NOFX's underlying logic, feature boundaries, and quantitative operating model.
Your first duty is not blind execution. You act as the user's senior quantitative advisor so every NOFX configuration is correct, safe, and logically consistent.
When the user runs into a problem, combine the current state with NOFX platform constraints, proactively diagnose what is wrong, and provide concrete next steps.
User-facing response style rules:
- Treat the user like a trading beginner, not a developer.
- Prefer simple, plain language over technical jargon.
- Lead with the conclusion first, then one or two concrete next steps.
- Keep sentences short and easy to scan.
- If you must use a technical term, explain it in everyday words immediately.
- Do not expose internal architecture, tool names, JSON fields, or implementation details unless the user explicitly asks for them.
- When asking follow-up questions, make them specific, friendly, and easy to answer.`
func prependNOFXiAdvisorPreamble(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return nofxiAdvisorSystemPreamble
}
return nofxiAdvisorSystemPreamble + "\n\n" + body
}

101
agent/reference_memory.go Normal file
View File

@@ -0,0 +1,101 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type ReferenceMemory struct {
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func referenceMemoryConfigKey(userID int64) string {
return fmt.Sprintf("agent_reference_memory_%d", userID)
}
func (a *Agent) getReferenceMemory(userID int64) ReferenceMemory {
if a == nil || a.store == nil {
return ReferenceMemory{}
}
raw, err := a.store.GetSystemConfig(referenceMemoryConfigKey(userID))
if err != nil {
return ReferenceMemory{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ReferenceMemory{}
}
var memory ReferenceMemory
if err := json.Unmarshal([]byte(raw), &memory); err != nil {
return ReferenceMemory{}
}
memory.CurrentReferences = normalizeCurrentReferences(memory.CurrentReferences)
memory.ReferenceHistory = normalizeReferenceHistory(memory.ReferenceHistory)
return memory
}
func (a *Agent) saveReferenceMemory(userID int64, refs *CurrentReferences, history []ReferenceRecord) {
if a == nil || a.store == nil {
return
}
memory := ReferenceMemory{
CurrentReferences: normalizeCurrentReferences(refs),
ReferenceHistory: normalizeReferenceHistory(history),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
if memory.CurrentReferences == nil && len(memory.ReferenceHistory) == 0 {
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "")
return
}
data, err := json.Marshal(memory)
if err != nil {
return
}
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), string(data))
}
func (a *Agent) clearReferenceMemory(userID int64) {
if a == nil || a.store == nil {
return
}
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "")
}
func (a *Agent) semanticCurrentReferences(userID int64) *CurrentReferences {
state := a.getExecutionState(userID)
if refs := normalizeCurrentReferences(state.CurrentReferences); refs != nil {
return refs
}
return a.getReferenceMemory(userID).CurrentReferences
}
func (a *Agent) semanticReferenceHistory(userID int64) []ReferenceRecord {
state := a.getExecutionState(userID)
if history := normalizeReferenceHistory(state.ReferenceHistory); len(history) > 0 {
return history
}
return a.getReferenceMemory(userID).ReferenceHistory
}
func (a *Agent) rememberReferencesFromToolResult(userID int64, toolName, raw string) {
if a == nil {
return
}
memory := a.getReferenceMemory(userID)
state := ExecutionState{
UserID: userID,
CurrentReferences: memory.CurrentReferences,
ReferenceHistory: memory.ReferenceHistory,
}
if !updateCurrentReferencesFromToolResult(&state, toolName, raw) {
return
}
a.saveReferenceMemory(userID, state.CurrentReferences, state.ReferenceHistory)
execState := a.getExecutionState(userID)
execState.CurrentReferences = state.CurrentReferences
a.saveExecutionState(execState)
}

View File

@@ -29,8 +29,10 @@ func (s *Scheduler) Start(ctx context.Context) {
lastCheck := time.Time{}
for {
select {
case <-ctx.Done(): return
case <-s.stopCh: return
case <-ctx.Done():
return
case <-s.stopCh:
return
case now := <-ticker.C:
// Daily report at 21:00
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
@@ -53,13 +55,21 @@ func (s *Scheduler) Start(ctx context.Context) {
})
}
func (s *Scheduler) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Scheduler) Stop() {
s.stopOnce.Do(func() {
close(s.stopCh)
})
}
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil { return }
if s.agent.traderManager == nil {
return
}
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 { return }
if len(traders) == 0 {
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
@@ -67,30 +77,40 @@ func (s *Scheduler) dailyReport() {
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
if err != nil { continue }
if err != nil {
continue
}
equity := toFloat(info["total_equity"])
pnl := toFloat(info["unrealized_pnl"])
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
totalPnL += pnl
}
e := "📈"
if totalPnL < 0 { e = "📉" }
if totalPnL < 0 {
e = "📉"
}
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
s.agent.notifyAll(sb.String())
}
func (s *Scheduler) riskCheck() {
if s.agent.traderManager == nil { return }
if s.agent.traderManager == nil {
return
}
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
if err != nil {
continue
}
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 { continue }
if size == 0 {
continue
}
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100

View File

@@ -77,20 +77,51 @@ func (s *Sentinel) Start() {
})
}
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) Symbols() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, len(s.symbols))
copy(out, s.symbols)
return out
}
func (s *Sentinel) AddSymbol(sym string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, x := range s.symbols {
if x == sym {
return
}
}
s.symbols = append(s.symbols, sym)
}
func (s *Sentinel) RemoveSymbol(sym string) {
s.mu.Lock()
defer s.mu.Unlock()
for i, x := range s.symbols {
if x == sym {
s.symbols = append(s.symbols[:i], s.symbols[i+1:]...)
return
}
}
}
func (s *Sentinel) FormatWatchlist(L string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.symbols) == 0 {
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
if L == "zh" {
return "📭 监控列表为空。用 `/watch BTC` 添加。"
}
return "📭 Watchlist empty. Use `/watch BTC` to add."
}
var sb strings.Builder
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
if L == "zh" {
sb.WriteString("👁️ *监控列表*\n\n")
} else {
sb.WriteString("👁️ *Watchlist*\n\n")
}
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
@@ -114,16 +145,22 @@ func (s *Sentinel) scan() {
func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil { return }
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
if err != nil { return }
if err != nil {
return
}
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil { return }
if err := json.Unmarshal(body, &t); err != nil {
return
}
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
@@ -133,41 +170,53 @@ func (s *Sentinel) check(symbol string) {
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 { h = h[len(h)-60:] }
if len(h) > 60 {
h = h[len(h)-60:]
}
s.history[symbol] = h
s.mu.Unlock()
if len(h) < 5 { return }
if len(h) < 5 {
return
}
// Price breakout (>3% in 5 min)
old := h[len(h)-5]
pct := ((price - old.Price) / old.Price) * 100
if math.Abs(pct) >= 3.0 {
sev := "warning"
if math.Abs(pct) >= 6.0 { sev = "critical" }
if math.Abs(pct) >= 6.0 {
sev = "critical"
}
dir := "📈 拉升"
if pct < 0 { dir = "📉 下跌" }
if pct < 0 {
dir = "📉 下跌"
}
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
Price: price, Change: pct})
Price: price, Change: pct})
}
// Volume spike (>3x avg)
if len(h) >= 10 {
var avg float64
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
for i := 0; i < len(h)-1; i++ {
avg += h[i].Volume
}
avg /= float64(len(h) - 1)
if avg > 0 && vol > avg*3 {
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
Price: price, Change: chg})
Price: price, Change: chg})
}
}
}
func (s *Sentinel) emit(sig Signal) {
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
if s.onSignal != nil { s.onSignal(sig) }
if s.onSignal != nil {
s.onSignal(sig)
}
}

View File

@@ -1,35 +0,0 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) {
got := skillCatalogPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_model_config_diagnosis",
"skill_exchange_api_diagnosis",
"skill_trader_start_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got)
}
}
}
func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
got := a.buildSystemPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_exchange_api_setup",
"skill_order_execution_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("buildSystemPrompt(zh) missing %q", want)
}
}
}

View File

@@ -35,15 +35,6 @@ func buildSkillDAGRegistry() map[string]SkillDAG {
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",
@@ -53,6 +44,33 @@ func buildSkillDAGRegistry() map[string]SkillDAG {
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_strategy",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_exchange",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"exchange_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "exchange_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_model",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "ai_model_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "start",
@@ -111,12 +129,9 @@ func buildSkillDAGRegistry() map[string]SkillDAG {
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_config_patch"}},
{ID: "collect_config_patch", Kind: "collect_slot", RequiredFields: []string{"config_patch"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_patch"}, Terminal: true},
},
},
{
@@ -274,4 +289,3 @@ func listSkillDAGs() []SkillDAG {
}
return out
}

View File

@@ -1,27 +0,0 @@
package agent
import "testing"
func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", step.ID)
}
}
func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
setSkillDAGStep(&session, "resolve_config_field")
advanceSkillDAGStep(&session, "resolve_config_field")
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_config_value" {
t.Fatalf("expected resolve_config_value, got %s", step.ID)
}
}

View File

@@ -1,67 +0,0 @@
package agent
import "testing"
func TestGetSkillDAGForStructuredActions(t *testing.T) {
tests := []struct {
skill string
action string
}{
{skill: "trader_management", action: "create"},
{skill: "trader_management", action: "update_bindings"},
{skill: "strategy_management", action: "update_config"},
{skill: "strategy_management", action: "update_prompt"},
{skill: "model_management", action: "update_status"},
{skill: "exchange_management", action: "update_name"},
}
for _, tt := range tests {
dag, ok := getSkillDAG(tt.skill, tt.action)
if !ok {
t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action)
}
if dag.SkillName != tt.skill || dag.Action != tt.action {
t.Fatalf("unexpected dag identity: %+v", dag)
}
if len(dag.Steps) == 0 {
t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action)
}
}
}
func TestStructuredDAGsHaveTerminalStep(t *testing.T) {
for _, dag := range listSkillDAGs() {
hasTerminal := false
for _, step := range dag.Steps {
if step.Terminal {
hasTerminal = true
break
}
}
if !hasTerminal {
t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action)
}
}
}
func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) {
dag, ok := getSkillDAG("strategy_management", "update_config")
if !ok {
t.Fatal("missing strategy update_config dag")
}
if len(dag.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(dag.Steps))
}
if dag.Steps[0].ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID)
}
if dag.Steps[1].ID != "resolve_config_field" {
t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID)
}
if dag.Steps[2].ID != "resolve_config_value" {
t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID)
}
if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal {
t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,828 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
if strings.Contains(modelResp, `"error"`) {
t.Fatalf("failed to create model: %s", modelResp)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
strategyResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略",
"lang":"zh"
}`)
if strings.Contains(strategyResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", strategyResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") {
t.Fatalf("expected missing-field prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号")
if err != nil {
t.Fatalf("thinkAndAct() second turn error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") {
t.Fatalf("expected trader creation confirmation, got %q", resp)
}
listResp := a.toolListTraders("user-1")
if !strings.Contains(listResp, "波段一号") {
t.Fatalf("expected created trader in list, got %s", listResp)
}
}
func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp)
}
}
}
func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") {
t.Fatalf("expected trader creation flow prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() interrupt error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if a.hasActiveSkillSession(13) {
t.Fatal("expected skill session to be cleared after interruption")
}
}
func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"保守策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") {
t.Fatalf("expected start confirmation prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用")
if err != nil {
t.Fatalf("thinkAndAct() confirmation error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") {
t.Fatalf("expected create-without-start response, got %q", resp)
}
}
func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "模型配置") {
t.Fatalf("expected model diagnosis response, got %q", resp)
}
}
func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") {
t.Fatalf("expected exchange diagnosis response, got %q", resp)
}
}
func TestExchangeManagementCreateAndQuerySkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建交易所配置") {
t.Fatalf("expected exchange create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") {
t.Fatalf("expected exchange query response, got %q", resp)
}
}
func TestModelManagementCreateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected model create response, got %q", resp)
}
}
func TestStrategyManagementCreateAndActivateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B")
if err != nil {
t.Fatalf("thinkAndAct() activate error = %v", err)
}
if !strings.Contains(resp, "已激活策略") {
t.Fatalf("expected strategy activate response, got %q", resp)
}
}
func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的")
if err != nil {
t.Fatalf("thinkAndAct() detail query error = %v", err)
}
for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
}
func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
_ = a.toolManageTrader("user-1", `{
"action":"create",
"name":"测试交易员",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"strategy_id":""
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") {
t.Fatalf("expected trader query response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易")
if err != nil {
t.Fatalf("thinkAndAct() diagnosis error = %v", err)
}
if !strings.Contains(resp, "交易员运行诊断") {
t.Fatalf("expected trader diagnosis response, got %q", resp)
}
}
func TestExchangeManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
var created struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户")
if err != nil {
t.Fatalf("rename exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange update response, got %q", resp)
}
raw := a.toolGetExchangeConfigs("user-1")
if !strings.Contains(raw, "备用账户") {
t.Fatalf("expected renamed exchange in list, got %s", raw)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置")
if err != nil {
t.Fatalf("disable exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange status update response, got %q", resp)
}
raw = a.toolGetExchangeConfigs("user-1")
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") {
t.Fatalf("expected exchange to be disabled, got %s", raw)
}
}
func TestModelManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner")
if err != nil {
t.Fatalf("rename model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta")
if err != nil {
t.Fatalf("update model endpoint error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model endpoint update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置")
if err != nil {
t.Fatalf("disable model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model status update response, got %q", resp)
}
raw := a.toolGetModelConfigs("user-1")
if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") {
t.Fatalf("expected updated model fields, got %s", raw)
}
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) {
t.Fatalf("expected model to be disabled, got %s", raw)
}
}
func TestStrategyManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略")
if err != nil {
t.Fatalf("create strategy error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt把提示词改成“优先观察BTC和ETH信号不一致时不要开仓”")
if err != nil {
t.Fatalf("update strategy prompt error = %v", err)
}
if !strings.Contains(resp, "已更新策略 prompt") {
t.Fatalf("expected strategy prompt update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数把最大持仓改成2最低置信度改成80主周期改成15m并使用15m 1h 4h")
if err != nil {
t.Fatalf("update strategy config error = %v", err)
}
if !strings.Contains(resp, "已更新策略参数") {
t.Fatalf("expected strategy config update response, got %q", resp)
}
listRaw := a.toolGetStrategies("user-1")
if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) {
t.Fatalf("expected updated strategy config, got %s", listRaw)
}
}
func TestTraderManagementAtomicBindingUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
modelOpenAI := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var openAI struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil {
t.Fatalf("unmarshal openai model: %v", err)
}
modelDeepSeek := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var deepSeek struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil {
t.Fatalf("unmarshal deepseek model: %v", err)
}
exchangeBinance := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Binance 主账户",
"enabled":true
}`)
var binance struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil {
t.Fatalf("unmarshal binance exchange: %v", err)
}
exchangeOKX := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"OKX 主账户",
"enabled":true
}`)
var okx struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil {
t.Fatalf("unmarshal okx exchange: %v", err)
}
strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`)
var stA struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyA), &stA); err != nil {
t.Fatalf("unmarshal strategy A: %v", err)
}
strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`)
var stB struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyB), &stB); err != nil {
t.Fatalf("unmarshal strategy B: %v", err)
}
createTrader := a.toolManageTrader("user-1", `{
"action":"create",
"name":"实盘一号",
"ai_model_id":"`+openAI.Model.ID+`",
"exchange_id":"`+binance.Exchange.ID+`",
"strategy_id":"`+stA.Strategy.ID+`"
}`)
var trader struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createTrader), &trader); err != nil {
t.Fatalf("unmarshal trader: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B")
if err != nil {
t.Fatalf("update trader bindings error = %v", err)
}
if !strings.Contains(resp, "已更新交易员绑定") {
t.Fatalf("expected trader binding update response, got %q", resp)
}
listRaw := a.toolListTraders("user-1")
if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) {
t.Fatalf("expected trader bindings to change, got %s", listRaw)
}
}
func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) {
a := newTestAgentWithStore(t)
for _, name := range []string{"趋势策略A", "趋势策略B"} {
resp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"`+name+`",
"lang":"zh"
}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("failed to create strategy %q: %s", name, resp)
}
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete start error = %v", err)
}
if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") {
t.Fatalf("expected bulk delete confirmation, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err)
}
if !strings.Contains(resp, "成功删除 2 个") {
t.Fatalf("expected bulk delete success summary, got %q", resp)
}
listResp := a.toolGetStrategies("user-1")
if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") {
t.Fatalf("expected created strategies to be deleted, got %s", listResp)
}
}
func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
enabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(enabledExchange, `"error"`) {
t.Fatalf("failed to create enabled exchange: %s", enabledExchange)
}
anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"lky",
"enabled":true
}`)
if strings.Contains(anotherEnabledExchange, `"error"`) {
t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange)
}
disabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"new",
"enabled":false
}`)
if strings.Contains(disabledExchange, `"error"`) {
t.Fatalf("failed to create disabled exchange: %s", disabledExchange)
}
_ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader")
if err != nil {
t.Fatalf("create trader start error = %v", err)
}
if !strings.Contains(resp, "new已禁用") {
t.Fatalf("expected disabled exchange to be labelled, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test交易所用new、策略用激进")
if err != nil {
t.Fatalf("disabled exchange selection error = %v", err)
}
if !strings.Contains(resp, "当前已禁用") {
t.Fatalf("expected disabled exchange warning, got %q", resp)
}
}
func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下")
if err != nil {
t.Fatalf("enter exchange update flow error = %v", err)
}
if !strings.Contains(resp, "请告诉我你要改什么") {
t.Fatalf("expected exchange update prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改")
if err != nil {
t.Fatalf("cancel exchange flow error = %v", err)
}
if !strings.Contains(resp, "已取消当前流程") {
t.Fatalf("expected flow cancellation, got %q", resp)
}
}
func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) {
session := skillSession{Name: "exchange_management", Action: "update"}
a := &Agent{}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got)
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" {
t.Fatalf("expected topic shift to cancel current skill flow, got %q", got)
}
}
type skillSessionClassifierAIClient struct {
lastSystemPrompt string
lastUserPrompt string
response string
}
func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {}
func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {}
func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.lastUserPrompt = req.Messages[1].Content
}
return c.response, nil
}
func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{aiClient: client}
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Fields: map[string]string{
skillDAGStepField: "resolve_config_value",
"config_field": "min_confidence",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" {
t.Fatalf("expected numeric slot fill to continue, got %q", got)
}
if client.lastSystemPrompt != "" {
t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "exchange_management",
Action: "update",
Fields: map[string]string{
skillDAGStepField: "collect_account_name",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected ambiguous deflection to interrupt, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "model_management",
Action: "create",
Fields: map[string]string{
skillDAGStepField: "collect_optional_fields",
"provider": "openai",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" {
t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置")
if err != nil {
t.Fatalf("thinkAndAct() default config error = %v", err)
}
if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") {
t.Fatalf("expected default strategy config response, got %q", resp)
}
}
func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略A",
"lang":"zh"
}`)
if strings.Contains(createResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", createResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70核心指标都全选")
if err != nil {
t.Fatalf("thinkAndAct() multi-field update error = %v", err)
}
if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") {
t.Fatalf("expected multi-field update confirmation, got %q", resp)
}
strategiesRaw := a.toolGetStrategies("user-1")
if !strings.Contains(strategiesRaw, `"min_confidence":70`) ||
!strings.Contains(strategiesRaw, `"enable_ema":true`) ||
!strings.Contains(strategiesRaw, `"enable_macd":true`) ||
!strings.Contains(strategiesRaw, `"enable_rsi":true`) ||
!strings.Contains(strategiesRaw, `"enable_atr":true`) ||
!strings.Contains(strategiesRaw, `"enable_boll":true`) {
t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw)
}
}

View File

@@ -0,0 +1,209 @@
package agent
import "strings"
func buildSkillDomainPrimer(lang, skillName string) string {
skillName = strings.TrimSpace(skillName)
if skillName == "" {
return ""
}
switch skillName {
case "model_management":
fields := []string{
fieldKnowledgeDisplayName("provider", lang),
displayCatalogFieldName("name", lang),
displayCatalogFieldName("api_key", lang),
displayCatalogFieldName("custom_api_url", lang),
displayCatalogFieldName("custom_model_name", lang),
displayCatalogFieldName("enabled", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 模型配置领域约束",
"- 当前领域是 AI 模型配置,不是交易所配置。",
"- provider 指模型厂商,不是交易所类型。",
"- 关键字段:" + strings.Join(fields, "、"),
"- 候选 provider" + modelProviderSummaryList(lang),
"- 推荐 providerclaw402。claw402 是 NOFXi 官方推荐方案,按次付费,使用 Base 链 EVM 钱包 + USDC 支付。",
"- 如果用户不确定选哪个 provider可以优先推荐 claw402 并说明其优势,但绝不能替用户自动选中 claw402必须先展示完整 provider 选项并让用户自己选择。",
"- 如果 provider 还没选定,下一步必须先让用户从完整 provider 列表里选一个,不能先收集 API Key、钱包私钥或其他凭证。",
"- 普通 provideropenai/deepseek/claude 等)通常要填 API Keycustom_model_name 和 custom_api_url 可以留空走默认值。",
"- claw402 需要钱包私钥custom_model_name 留空时默认 deepseek。",
"- blockrun-base / blockrun-sol 走钱包私钥模式,不需要 custom_api_urlcustom_model_name 默认 auto。",
}, "\n")
}
return strings.Join([]string{
"### Model Config Domain Guard",
"- The current domain is AI model configuration, not exchange configuration.",
"- provider means the model vendor, not an exchange venue.",
"- Key fields: " + strings.Join(fields, ", "),
"- Supported providers: " + modelProviderSummaryList(lang),
"- Recommended provider: claw402. claw402 is the NOFXi recommended pay-per-use option that uses a Base chain wallet + USDC.",
"- If the user is unsure which provider to pick, you may recommend claw402 and explain its advantages, but you must not auto-select claw402 for them. Show the full provider options first and let the user choose.",
"- If provider is still missing, the next step must be to ask the user to choose one from the full provider list. Do not ask for an API key, wallet private key, or other credentials before the provider is chosen.",
"- Standard providers (openai/deepseek/claude etc.) usually require an API key; `custom_model_name` and `custom_api_url` can be omitted to use defaults.",
"- claw402 uses a wallet private key and defaults to `deepseek` if `custom_model_name` is omitted.",
"- blockrun-base / blockrun-sol use wallet private keys, do not need `custom_api_url`, and default to `auto`.",
}, "\n")
case "exchange_management":
fields := []string{
slotDisplayName("exchange_type", lang),
displayCatalogFieldName("account_name", lang),
displayCatalogFieldName("api_key", lang),
displayCatalogFieldName("secret_key", lang),
displayCatalogFieldName("passphrase", lang),
displayCatalogFieldName("enabled", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 交易所配置领域约束",
"- 当前领域是交易所账户配置,不是 AI 模型配置。",
"- exchange_type 指交易所类型provider 这个词不应用来代指交易所。",
"- 关键字段:" + strings.Join(fields, "、"),
"- 支持的交易所类型:" + strings.Join(enumOptionValues("exchange_management", "exchange_type"), "、"),
}, "\n")
}
return strings.Join([]string{
"### Exchange Config Domain Guard",
"- The current domain is exchange account configuration, not AI model configuration.",
"- exchange_type means the trading venue. Do not use provider to mean an exchange.",
"- Key fields: " + strings.Join(fields, ", "),
"- Supported exchange types: " + strings.Join(enumOptionValues("exchange_management", "exchange_type"), ", "),
}, "\n")
case "trader_management":
fields := []string{
slotDisplayName("name", lang),
slotDisplayName("exchange", lang),
slotDisplayName("model", lang),
slotDisplayName("strategy", lang),
displayCatalogFieldName("scan_interval_minutes", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 交易员配置领域约束",
"- 交易员是装配层,负责创建、换绑策略/交易所/模型,以及启动、停止、删除、查询。",
"- 编辑交易员时,默认只处理绑定关系;不要顺手改策略、模型、交易所内部配置。",
"- 交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受手动设置、充值或人为改余额。",
"- 若用户要改策略参数、模型配置或交易所凭证,应切到对应 management skill。",
"- 创建交易员时最关键的是:名称、交易所、模型、策略。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Trader Config Domain Guard",
"- Traders are the assembly layer: create, rebind strategy/exchange/model, and control lifecycle.",
"- When editing a trader, default to changing bindings only; do not silently edit the internals of the strategy, model, or exchange.",
"- Trader initial balance is auto-read from the bound exchange account equity at creation time; do not ask the user to set, top up, or manually edit trader balance.",
"- If the user wants to change strategy parameters, model config, or exchange credentials, switch to the corresponding management skill.",
"- The key create fields are name, exchange, model, and strategy.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")
case "strategy_management":
fields := []string{
slotDisplayName("name", lang),
displayCatalogFieldName("strategy_type", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 策略配置领域约束",
"- 本领域只处理策略模板。",
"- strategy_type 选项ai_trading、grid_trading。",
"- 用户提到 AI500、OI Top、OI Low、静态币种/固定币种这类选币来源时,属于 ai_trading。",
"- 策略类型确定后,只能使用当前类型的产品编辑页模板。",
"- 策略类型未确定时,只判断类型,不要展示或混合任一分支的具体配置字段。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Strategy Config Domain Guard",
"- This domain only handles strategy templates.",
"- strategy_type options: ai_trading, grid_trading.",
"- AI500, OI Top, OI Low, and static coin-source requests imply ai_trading.",
"- Once strategy_type is known, use only that product editor template.",
"- Before strategy_type is known, only determine the type; do not show or mix concrete fields from either branch.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")
default:
return ""
}
}
func buildSkillDomainPrimerForSession(lang string, session skillSession) string {
if session.Name != "strategy_management" {
return buildSkillDomainPrimer(lang, session.Name)
}
strategyType := explicitStrategyCreateType(session)
if strategyType == "" {
return buildSkillDomainPrimer(lang, session.Name)
}
if lang == "zh" {
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI 策略模板",
"- 只使用 ai_trading 模板strategy_type + ai_config + publish_config。",
"- config_patch 必须使用产品 schema 原值不要使用展示文案strategy_type=ai_tradingsource_type 只能是 static、ai500、oi_top、oi_low没有 mixed/混合模式。",
"- 时间周期必须输出为产品枚举字符串,例如 1m、3m、5m、15m、1hselected_timeframes 必须是字符串数组,例如 [\"1m\",\"5m\",\"15m\"],不要输出 JSON 字符串。",
"- AI500/OI Top/OI Low 选币数量范围 110static_coins 最多 10 个selected_timeframes 最多 4 个primary_count 1030。",
"- BTC/ETH 最大杠杆 120山寨币最大杠杆 120min_confidence 50100min_risk_reward_ratio 110。",
"- AI 策略创建方案不要展示或询问非 AI 模板字段:投入金额、每笔固定投入、止损、日亏损限制、最大回撤、网格字段。",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### 网格策略模板",
"- 只使用 grid_trading 模板strategy_type + grid_config + publish_configconfig_patch 必须使用产品 schema 原值strategy_type=grid_trading。",
"- 交易对选项BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。",
"- grid_count 550total_investment 最小 100leverage 15atr_multiplier 15。",
"- total_investment 是用户实际投入/保证金预算,不是杠杆后的名义仓位;最大名义仓位约等于 total_investment × leverage。用户说“投入/总投入/本金/保证金”时默认映射到 total_investment。",
"- max_drawdown_pct 550stop_loss_pct 120daily_loss_limit_pct 130direction_bias_ratio 0.550.90。",
"- 没有实时行情工具结果时,不要猜当前价格或手动价格上下界;推荐 use_atr_bounds=true 的 ATR 自动边界。",
"- 如果用户让你选择/推荐剩余网格参数,价格区间默认写入 use_atr_bounds=true不要反问用户手动价格区间也不要编造“当前 BTC/ETH 在某价附近”。",
}, "\n")
}
}
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI Strategy Template",
"- Use only ai_trading: strategy_type + ai_config + publish_config.",
"- config_patch must use product schema raw values, not display labels: strategy_type=ai_trading; source_type is only static, ai500, oi_top, or oi_low; no mixed mode.",
"- Timeframes must be product enum strings such as 1m, 3m, 5m, 15m, 1h; selected_timeframes must be a JSON string array such as [\"1m\",\"5m\",\"15m\"], not a JSON-encoded string.",
"- AI500/OI source counts 1-10; static_coins at most 10; selected_timeframes at most 4; primary_count 10-30.",
"- BTC/ETH leverage 1-20; altcoin leverage 1-20; min_confidence 50-100; min_risk_reward_ratio 1-10.",
"- Do not show or ask for non-AI-template fields in AI strategy drafts: investment amount, fixed per-trade amount, stop loss, daily loss limit, max drawdown, or grid fields.",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### Grid Strategy Template",
"- Use only grid_trading: strategy_type + grid_config + publish_config; config_patch must use product schema raw values with strategy_type=grid_trading.",
"- Symbol options: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.",
"- grid_count 5-50; total_investment >=100; leverage 1-5; atr_multiplier 1-5.",
"- total_investment is the user's actual capital/margin budget, not leveraged notional exposure; maximum notional exposure is approximately total_investment * leverage. When the user says investment, capital, amount to put in, or margin, map it to total_investment by default.",
"- max_drawdown_pct 5-50; stop_loss_pct 1-20; daily_loss_limit_pct 1-30; direction_bias_ratio 0.55-0.90.",
"- Without fresh market data, do not guess the current price or manual upper/lower prices; recommend ATR auto bounds with use_atr_bounds=true.",
"- If the user asks you to choose/recommend the remaining grid parameters, default the price range to use_atr_bounds=true; do not ask for manual price bounds or invent statements like the current BTC/ETH price is near a value.",
}, "\n")
}
return buildSkillDomainPrimer(lang, session.Name)
}
func buildManagementDomainPrimer(lang string) string {
if lang == "zh" {
return strings.Join([]string{
"### 管理领域路由速记",
"- 模型/API Key/providermodel_management。",
"- 交易所账户/API 凭证exchange_management。",
"- 交易员创建、启动、停止、绑定策略/模型/交易所trader_management。",
"- 策略模板创建、查看、修改、删除、激活、复制strategy_management。",
"- 这里只用于路由;具体字段和模板只在进入对应 skill 后注入。",
}, "\n")
}
return strings.Join([]string{
"### Management Routing Cheat Sheet",
"- Model/API key/provider: model_management.",
"- Exchange account/API credentials: exchange_management.",
"- Trader create/start/stop/bind strategy/model/exchange: trader_management.",
"- Strategy template create/query/update/delete/activate/duplicate: strategy_management.",
"- This is only for routing; detailed fields/templates are injected after entering the selected skill.",
}, "\n")
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -42,11 +42,11 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list"
case "query_running":
return "query_running"
case "query_detail":
case "query_detail", "query_strategy_binding", "query_exchange_binding", "query_model_binding":
return action
case "query_binding":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_bindings":
case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return action
}
case "exchange_management":
@@ -55,9 +55,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_status":
case "update", "update_name", "update_status":
return action
}
case "model_management":
@@ -66,9 +64,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_endpoint", "update_status":
case "update", "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
@@ -77,9 +73,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_config", "update_prompt":
case "update", "update_name", "update_config", "update_prompt":
return action
}
}
@@ -158,6 +152,7 @@ Rules:
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
- If you choose "complete", produce the final user-facing answer in the user's language.
- ` + cleanUserFacingReplyInstruction + `
Return JSON with this exact shape:
{"route":"complete|replan","answer":""}`

View File

@@ -6,19 +6,40 @@ import (
"fmt"
"sort"
"strings"
"sync"
)
//go:embed skills/*.json
var embeddedSkillDefinitions embed.FS
type SkillDefinition struct {
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
DynamicRules []string `json:"dynamic_rules,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
FieldConstraints map[string]SkillFieldConstraint `json:"field_constraints,omitempty"`
ValidationRules []string `json:"validation_rules,omitempty"`
PerExchangeRequiredFields map[string][]string `json:"per_exchange_required_fields,omitempty"`
}
type SkillFieldConstraint struct {
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Values []string `json:"values,omitempty"`
Aliases map[string]string `json:"aliases,omitempty"`
Description string `json:"description,omitempty"`
RequiredFor []string `json:"required_for,omitempty"`
Default any `json:"default,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
MaxLength int `json:"max_length,omitempty"`
MustBeHTTPS bool `json:"must_be_https,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
type SkillActionDefinition struct {
@@ -26,9 +47,14 @@ type SkillActionDefinition struct {
RequiredSlots []string `json:"required_slots,omitempty"`
OptionalSlots []string `json:"optional_slots,omitempty"`
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
Goal string `json:"goal,omitempty"`
DynamicRules []string `json:"dynamic_rules,omitempty"`
SuccessOutput string `json:"success_output,omitempty"`
FailureOutput string `json:"failure_output,omitempty"`
}
var skillRegistry = mustLoadSkillRegistry()
var skillContextCache sync.Map
func mustLoadSkillRegistry() map[string]SkillDefinition {
registry, err := loadSkillRegistry()
@@ -72,6 +98,8 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
def.Capabilities = cleanStringList(def.Capabilities)
def.DynamicRules = cleanStringList(def.DynamicRules)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
@@ -83,6 +111,10 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
action.Description = strings.TrimSpace(action.Description)
action.RequiredSlots = cleanStringList(action.RequiredSlots)
action.OptionalSlots = cleanStringList(action.OptionalSlots)
action.Goal = strings.TrimSpace(action.Goal)
action.DynamicRules = cleanStringList(action.DynamicRules)
action.SuccessOutput = strings.TrimSpace(action.SuccessOutput)
action.FailureOutput = strings.TrimSpace(action.FailureOutput)
normalized[key] = action
}
def.Actions = normalized
@@ -101,6 +133,46 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.ToolMapping = normalized
}
if len(def.FieldConstraints) > 0 {
normalized := make(map[string]SkillFieldConstraint, len(def.FieldConstraints))
for key, constraint := range def.FieldConstraints {
key = strings.TrimSpace(key)
if key == "" {
continue
}
constraint.Type = strings.TrimSpace(constraint.Type)
constraint.Values = cleanStringList(constraint.Values)
constraint.RequiredFor = cleanStringList(constraint.RequiredFor)
constraint.Description = strings.TrimSpace(constraint.Description)
if len(constraint.Aliases) > 0 {
aliases := make(map[string]string, len(constraint.Aliases))
for alias, value := range constraint.Aliases {
alias = strings.TrimSpace(alias)
value = strings.TrimSpace(value)
if alias == "" || value == "" {
continue
}
aliases[alias] = value
}
constraint.Aliases = aliases
}
normalized[key] = constraint
}
def.FieldConstraints = normalized
}
def.ValidationRules = cleanStringList(def.ValidationRules)
if len(def.PerExchangeRequiredFields) > 0 {
normalized := make(map[string][]string, len(def.PerExchangeRequiredFields))
for key, fields := range def.PerExchangeRequiredFields {
key = strings.TrimSpace(key)
if key == "" {
continue
}
normalized[key] = cleanStringList(fields)
}
def.PerExchangeRequiredFields = normalized
}
return def
}
@@ -117,3 +189,533 @@ func listSkillNames() []string {
sort.Strings(names)
return names
}
func buildSkillRoutingSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
def, ok := getSkillDefinition(name)
if !ok {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.DynamicRules) > 0 {
parts = append(parts, strings.Join(def.DynamicRules, " "))
}
switch name {
case "trader_management":
if lang == "zh" {
parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。")
} else {
parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.")
}
case "strategy_management":
if lang == "zh" {
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
} else {
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
}
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
}
return strings.Join(lines, "\n")
}
func buildSkillDefinitionSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
def, ok := getSkillDefinition(name)
if !ok {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.Capabilities) > 0 {
if lang == "zh" {
parts = append(parts, "能力: "+strings.Join(def.Capabilities, ""))
} else {
parts = append(parts, "capabilities: "+strings.Join(def.Capabilities, "; "))
}
}
if len(def.DynamicRules) > 0 {
if lang == "zh" {
parts = append(parts, "规则: "+strings.Join(def.DynamicRules, ""))
} else {
parts = append(parts, "rules: "+strings.Join(def.DynamicRules, "; "))
}
}
if action, ok := def.Actions["create"]; ok && len(action.RequiredSlots) > 0 {
if lang == "zh" {
parts = append(parts, "创建必填: "+formatRequiredSlotList(lang, action.RequiredSlots))
} else {
parts = append(parts, "create requires: "+formatRequiredSlotList(lang, action.RequiredSlots))
}
}
switch name {
case "trader_management":
if lang == "zh" {
parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。")
} else {
parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.")
}
case "strategy_management":
if lang == "zh" {
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
} else {
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
}
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
}
return strings.Join(lines, "\n")
}
func defaultManagementSkillNames() []string {
return []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
}
}
func buildSkillDependencySummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" {
return ""
}
switch session.Name {
case "trader_management":
if session.Action == "create" {
if lang == "zh" {
return "trader_management:create 必须收齐 4 个核心槽位:交易员名称、交易所、模型、策略。后 3 个依赖项都允许两种补法:直接选用户已有可用资源,或在当前主流程里立即新建/启用后再回流继续创建交易员。若用户是在启用、修复或新建这些依赖资源,这仍然是在继续创建交易员主流程,不是新开平级任务。"
}
return "trader_management:create requires 4 core slots: trader name, exchange, model, and strategy. The last 3 dependencies can be satisfied in two ways: choose an existing usable resource, or create/enable one inline and then resume trader creation. If the user is enabling, fixing, or creating one of those dependencies, that is still continuation of the trader creation flow, not a new peer task."
}
if lang == "zh" {
return "当当前对象是交易员时,换绑模型、交易所、策略都属于 trader_management 的继续操作;但如果用户要改这些对象的内部配置,应切到对应 management skill。"
}
return "When the current object is a trader, rebinding its model, exchange, or strategy remains inside trader_management; but if the user wants to change the internals of those resources, switch to the corresponding management skill."
default:
return ""
}
}
func buildSkillActionContractSummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" {
return ""
}
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, ok := def.Actions[session.Action]
if !ok {
return ""
}
required := defaultIfEmpty(formatRequiredSlotList(lang, action.RequiredSlots), "无")
goal := strings.TrimSpace(action.Goal)
if goal == "" {
goal = strings.TrimSpace(action.Description)
}
lines := []string{
fmt.Sprintf("### Active Skill Contract: %s:%s", session.Name, session.Action),
}
if lang == "zh" {
lines = append(lines, "- 目标:"+defaultIfEmpty(goal, "按该动作的业务规则完成当前请求。"))
lines = append(lines, "- 必填输入:"+required)
if len(action.DynamicRules) > 0 {
lines = append(lines, "- 动态逻辑规则:")
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if action.SuccessOutput != "" || action.FailureOutput != "" {
lines = append(lines, "- 预期输出:"+strings.TrimSpace(strings.Join(cleanStringList([]string{
ifThenElse(action.SuccessOutput != "", "成功:"+action.SuccessOutput, ""),
ifThenElse(action.FailureOutput != "", "失败:"+action.FailureOutput, ""),
}), "")))
}
} else {
lines = append(lines, "- Goal: "+defaultIfEmpty(goal, "Complete the current request under this action's business rules."))
lines = append(lines, "- Required input: "+required)
if len(action.DynamicRules) > 0 {
lines = append(lines, "- Dynamic rules:")
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if action.SuccessOutput != "" || action.FailureOutput != "" {
lines = append(lines, "- Expected output: "+strings.TrimSpace(strings.Join(cleanStringList([]string{
ifThenElse(action.SuccessOutput != "", "success: "+action.SuccessOutput, ""),
ifThenElse(action.FailureOutput != "", "failure: "+action.FailureOutput, ""),
}), "; ")))
}
}
return strings.Join(lines, "\n")
}
func ifThenElse[T any](cond bool, a, b T) T {
if cond {
return a
}
return b
}
func buildSkillForbiddenSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
switch name {
case "trader_management":
if lang == "zh" {
lines = append(lines, "- trader_management 不能直接设计赚钱/不亏钱方案;那类目标应交给 planner。")
lines = append(lines, "- trader_management 不能让用户手动设置、充值或修改交易员余额;交易员初始余额应由系统自动读取绑定交易所净值。")
} else {
lines = append(lines, "- trader_management must not invent a profit-seeking plan; those requests belong to the planner.")
lines = append(lines, "- trader_management must not let the user set, top up, or manually edit trader balance; trader initial balance should be auto-read from the bound exchange equity.")
}
case "exchange_management":
if lang == "zh" {
lines = append(lines, "- exchange_management 只负责保存和修改交易所配置,不负责行情查询、交易执行或诊断 API 报错。")
} else {
lines = append(lines, "- exchange_management only saves and updates exchange configs; it does not do market reads, trading, or API diagnosis.")
}
case "model_management":
if lang == "zh" {
lines = append(lines, "- model_management 只负责保存和修改模型配置,不负责测试连接、诊断上游错误或生成策略方案。")
} else {
lines = append(lines, "- model_management only saves and updates model configs; it does not test connectivity, diagnose upstream failures, or design strategies.")
}
case "strategy_management":
if lang == "zh" {
lines = append(lines, "- strategy_management 只负责模板管理;策略模板不能直接启动运行,运行态属于 trader。")
} else {
lines = append(lines, "- strategy_management only manages templates; strategy templates do not run directly and runtime belongs to traders.")
}
}
}
return strings.Join(lines, "\n")
}
func buildManagementSkillContext(lang string, session *skillSession) string {
key := fmt.Sprintf("full|%s|", lang)
if session != nil {
key = fmt.Sprintf("full|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
}
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 3)
if summary := buildSkillDefinitionSummary(lang, defaultManagementSkillNames()); summary != "" {
parts = append(parts, "Management skill summary:\n"+summary)
}
if forbidden := buildSkillForbiddenSummary(lang, defaultManagementSkillNames()); forbidden != "" {
parts = append(parts, "Management skill negative constraints:\n"+forbidden)
}
if session != nil {
if dependency := buildSkillDependencySummary(lang, *session); dependency != "" {
parts = append(parts, "Active skill dependency summary:\n"+dependency)
}
if contract := buildSkillActionContractSummary(lang, *session); contract != "" {
parts = append(parts, contract)
}
}
return strings.Join(parts, "\n\n")
})
}
func buildManagementSkillRoutingContext(lang string) string {
return buildManagementSkillRoutingContextWithSession(lang, nil)
}
func buildSkillActionRoutingSummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" {
return ""
}
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, ok := def.Actions[session.Action]
if !ok {
return ""
}
lines := []string{
fmt.Sprintf("### Active skill routing hints: %s:%s", session.Name, session.Action),
}
if goal := strings.TrimSpace(action.Goal); goal != "" {
if lang == "zh" {
lines = append(lines, "- 当前动作目标:"+goal)
} else {
lines = append(lines, "- Current action goal: "+goal)
}
}
if dependency := buildSkillDependencySummary(lang, session); dependency != "" {
if lang == "zh" {
lines = append(lines, "- 当前 flow 依赖提示:"+dependency)
} else {
lines = append(lines, "- Flow dependency hint: "+dependency)
}
}
if len(action.DynamicRules) > 0 {
if lang == "zh" {
lines = append(lines, "- 当前动作动态规则:")
} else {
lines = append(lines, "- Current action dynamic rules:")
}
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
return strings.Join(lines, "\n")
}
func buildManagementSkillRoutingContextWithSession(lang string, session *skillSession) string {
key := fmt.Sprintf("routing|%s|", lang)
if session != nil {
key = fmt.Sprintf("routing|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
}
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 1)
if summary := buildSkillRoutingSummary(lang, defaultManagementSkillNames()); summary != "" {
parts = append(parts, "Management skill summary:\n"+summary)
}
if session != nil {
if summary := buildSkillActionRoutingSummary(lang, *session); summary != "" {
parts = append(parts, summary)
}
}
return strings.Join(parts, "\n\n")
})
}
func buildCurrentSkillExecutionContext(lang string, session skillSession) string {
key := fmt.Sprintf("current|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 3)
if dependency := buildSkillDependencySummary(lang, session); dependency != "" {
parts = append(parts, "Active skill dependency summary:\n"+dependency)
}
if contract := buildSkillActionContractSummary(lang, session); contract != "" {
parts = append(parts, contract)
}
if knowledge := buildSkillFieldKnowledgeSummary(lang, session); knowledge != "" {
parts = append(parts, knowledge)
}
return strings.Join(parts, "\n\n")
})
}
func buildSkillFieldKnowledgeSummary(lang string, session skillSession) string {
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, hasAction := def.Actions[session.Action]
relevant := orderedSkillFieldKeys(def, action, hasAction)
lines := make([]string, 0, len(relevant)+6)
title := "### Active Field Knowledge"
if lang == "zh" {
title = "### 当前字段知识"
}
lines = append(lines, title)
for _, field := range relevant {
constraint, ok := def.FieldConstraints[field]
if !ok {
continue
}
lines = append(lines, formatFieldKnowledgeLine(lang, field, constraint))
}
if len(def.PerExchangeRequiredFields) > 0 {
if lang == "zh" {
lines = append(lines, "- 按交易所类型的必填字段:")
} else {
lines = append(lines, "- Required fields by exchange type:")
}
keys := make([]string, 0, len(def.PerExchangeRequiredFields))
for key := range def.PerExchangeRequiredFields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fields := make([]string, 0, len(def.PerExchangeRequiredFields[key]))
for _, field := range def.PerExchangeRequiredFields[key] {
fields = append(fields, fieldKnowledgeDisplayName(field, lang))
}
lines = append(lines, fmt.Sprintf(" - %s: %s", key, strings.Join(fields, "、")))
}
}
if len(def.ValidationRules) > 0 {
if lang == "zh" {
lines = append(lines, "- 关键校验规则:")
} else {
lines = append(lines, "- Key validation rules:")
}
for i, rule := range def.ValidationRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if len(lines) == 1 {
return ""
}
return strings.Join(lines, "\n")
}
func orderedSkillFieldKeys(def SkillDefinition, action SkillActionDefinition, hasAction bool) []string {
keys := make([]string, 0, len(def.FieldConstraints))
seen := map[string]struct{}{}
add := func(field string) {
field = strings.TrimSpace(field)
if field == "" {
return
}
if _, ok := def.FieldConstraints[field]; !ok {
return
}
if _, ok := seen[field]; ok {
return
}
seen[field] = struct{}{}
keys = append(keys, field)
}
if hasAction {
for _, field := range action.RequiredSlots {
add(field)
}
for _, field := range action.OptionalSlots {
add(field)
}
}
if len(keys) == 0 {
for field := range def.FieldConstraints {
add(field)
}
}
return keys
}
func formatFieldKnowledgeLine(lang, field string, constraint SkillFieldConstraint) string {
parts := make([]string, 0, 8)
if constraint.Description != "" {
parts = append(parts, constraint.Description)
}
if constraint.Type != "" {
if lang == "zh" {
parts = append(parts, "类型="+constraint.Type)
} else {
parts = append(parts, "type="+constraint.Type)
}
}
if constraint.Required {
if lang == "zh" {
parts = append(parts, "当前全局必填")
} else {
parts = append(parts, "globally required")
}
}
if len(constraint.Values) > 0 {
label := "可选值="
if lang != "zh" {
label = "values="
}
parts = append(parts, label+strings.Join(constraint.Values, "/"))
}
if len(constraint.RequiredFor) > 0 {
label := "仅这些类型必填="
if lang != "zh" {
label = "required_for="
}
parts = append(parts, label+strings.Join(constraint.RequiredFor, "/"))
}
if len(constraint.Aliases) > 0 {
aliasPairs := make([]string, 0, len(constraint.Aliases))
keys := make([]string, 0, len(constraint.Aliases))
for alias := range constraint.Aliases {
keys = append(keys, alias)
}
sort.Strings(keys)
for _, alias := range keys {
aliasPairs = append(aliasPairs, alias+"->"+constraint.Aliases[alias])
}
label := "别名="
if lang != "zh" {
label = "aliases="
}
parts = append(parts, label+strings.Join(aliasPairs, ", "))
}
if constraint.MustBeHTTPS {
if lang == "zh" {
parts = append(parts, "必须是 HTTPS")
} else {
parts = append(parts, "must be HTTPS")
}
}
if constraint.Min != nil || constraint.Max != nil {
rangeText := ""
switch {
case constraint.Min != nil && constraint.Max != nil:
rangeText = fmt.Sprintf("%.0f~%.0f", *constraint.Min, *constraint.Max)
case constraint.Min != nil:
rangeText = fmt.Sprintf(">=%.0f", *constraint.Min)
case constraint.Max != nil:
rangeText = fmt.Sprintf("<=%.0f", *constraint.Max)
}
if rangeText != "" {
label := "范围="
if lang != "zh" {
label = "range="
}
parts = append(parts, label+rangeText)
}
}
return fmt.Sprintf("- %s: %s", fieldKnowledgeDisplayName(field, lang), strings.Join(cleanStringList(parts), ""))
}
func fieldKnowledgeDisplayName(field, lang string) string {
if lang == "zh" {
switch field {
case "exchange_type":
return "交易所类型"
case "account_name":
return "账户名"
case "provider":
return "模型提供商"
case "custom_model_name":
return "模型名称"
case "custom_api_url":
return "接口地址"
}
}
return displayCatalogFieldName(field, lang)
}
func formatRequiredSlotList(lang string, slots []string) string {
display := make([]string, 0, len(slots))
for _, slot := range cleanStringList(slots) {
display = append(display, slotDisplayName(slot, lang))
}
return strings.Join(display, "、")
}
func missingRequiredActionSlots(skillName, action string, values map[string]string) []string {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return nil
}
missing := make([]string, 0, len(runtime.Action.RequiredSlots))
for _, slot := range runtime.Action.RequiredSlots {
if strings.TrimSpace(values[slot]) == "" {
missing = append(missing, slot)
}
}
return missing
}
func cachedSkillContext(key string, build func() string) string {
if cached, ok := skillContextCache.Load(key); ok {
if s, ok := cached.(string); ok {
return s
}
}
value := build()
skillContextCache.Store(key, value)
return value
}

View File

@@ -1,55 +0,0 @@
package agent
import "testing"
func TestSkillRegistryLoadsDefinitions(t *testing.T) {
names := listSkillNames()
if len(names) < 4 {
t.Fatalf("expected skill registry to load definitions, got %v", names)
}
for _, name := range []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
"exchange_diagnosis",
"model_diagnosis",
} {
if _, ok := getSkillDefinition(name); !ok {
t.Fatalf("missing skill definition %q", name)
}
}
}
func TestTraderManagementDefinitionHasCreateAction(t *testing.T) {
def, ok := getSkillDefinition("trader_management")
if !ok {
t.Fatalf("missing trader_management definition")
}
action, ok := def.Actions["create"]
if !ok {
t.Fatalf("missing create action in trader_management")
}
if len(action.RequiredSlots) == 0 {
t.Fatalf("expected required slots for trader_management create action")
}
}
func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) {
if !actionNeedsConfirmation("exchange_management", "delete") {
t.Fatalf("expected exchange_management delete to require confirmation")
}
if actionNeedsConfirmation("exchange_management", "query") {
t.Fatalf("did not expect exchange_management query to require confirmation")
}
}
func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) {
if !actionRequiresSlot("model_management", "create", "provider") {
t.Fatalf("expected model_management create to require provider")
}
if actionRequiresSlot("model_management", "create", "target_ref") {
t.Fatalf("did not expect model_management create to require target_ref")
}
}

View File

@@ -89,7 +89,7 @@ func slotDisplayName(slot, lang string) string {
case "exchange_type":
return "交易所类型"
case "provider":
return "provider"
return "模型提供商"
default:
return slot
}
@@ -115,6 +115,39 @@ func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
}
func formatTargetConfirmationLabel(lang string, session *skillSession, targetLabel string) string {
targetLabel = strings.TrimSpace(targetLabel)
if session == nil || session.TargetRef == nil || targetLabel == "" {
return targetLabel
}
source := strings.TrimSpace(session.TargetRef.Source)
if source == "" {
return targetLabel
}
if lang == "zh" {
sourceLabel := "系统上下文"
switch source {
case "user_mention":
sourceLabel = "你刚才点名的对象"
case "tool_output":
sourceLabel = "刚刚工具返回的对象"
case "inferred_from_context":
sourceLabel = "上下文推断对象"
}
return fmt.Sprintf("%s当前识别来源%s", targetLabel, sourceLabel)
}
sourceLabel := "context"
switch source {
case "user_mention":
sourceLabel = "your explicit mention"
case "tool_output":
sourceLabel = "recent tool output"
case "inferred_from_context":
sourceLabel = "context inference"
}
return fmt.Sprintf("%s (current reference source: %s)", targetLabel, sourceLabel)
}
func formatStillWaitingConfirmationMessage(lang string) string {
if lang == "zh" {
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
@@ -122,13 +155,80 @@ func formatStillWaitingConfirmationMessage(lang string) string {
return "This flow is still waiting for your confirmation."
}
func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
func referenceKindForSkill(skillName string) string {
switch strings.TrimSpace(skillName) {
case "strategy_management":
return "strategy"
case "trader_management":
return "trader"
case "model_management":
return "model"
case "exchange_management":
return "exchange"
default:
return ""
}
}
func referenceKindDisplayName(lang, kind string) string {
if lang == "zh" {
switch kind {
case "strategy":
return "策略"
case "trader":
return "交易员"
case "model":
return "模型"
case "exchange":
return "交易所"
}
return "对象"
}
return kind
}
func (a *Agent) formatConfirmationTargetLabel(userID int64, lang string, session *skillSession, targetLabel string) string {
label := formatTargetConfirmationLabel(lang, session, targetLabel)
if session == nil || session.TargetRef == nil {
return label
}
kind := referenceKindForSkill(session.Name)
if kind == "" {
return label
}
state := a.getExecutionState(userID)
recentNames := map[string]struct{}{}
for _, item := range state.ReferenceHistory {
if item.Kind != kind {
continue
}
name := strings.TrimSpace(defaultIfEmpty(item.Name, item.ID))
if name == "" {
continue
}
recentNames[name] = struct{}{}
}
targetName := strings.TrimSpace(defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID))
_, inferred := recentNames[targetName]
if targetName == "" {
return label
}
if len(recentNames) <= 1 && strings.TrimSpace(session.TargetRef.Source) != "inferred_from_context" && inferred {
return label
}
if lang == "zh" {
return fmt.Sprintf("%s。系统当前理解你要操作的%s是“%s”。", label, referenceKindDisplayName(lang, kind), targetName)
}
return fmt.Sprintf("%s. The current %s I'm about to operate on is %q.", label, referenceKindDisplayName(lang, kind), targetName)
}
func (a *Agent) beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
return "", false
}
if session.Phase != "await_confirmation" {
session.Phase = "await_confirmation"
return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true
return formatAwaitConfirmationMessage(lang, session.Action, a.formatConfirmationTargetLabel(userID, lang, session, targetLabel)), true
}
return "", false
}

View File

@@ -0,0 +1,246 @@
package agent
import (
"encoding/json"
"strings"
"nofx/store"
)
func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action string) string {
fieldNames := make([]string, 0, 20)
add := func(field string) {
field = strings.TrimSpace(field)
if field == "" {
return
}
for _, existing := range fieldNames {
if existing == field {
return
}
}
fieldNames = append(fieldNames, field)
}
switch skillName {
case "model_management":
if lang == "zh" {
add("Provider")
} else {
add("provider")
}
add(displayCatalogFieldName("name", lang))
for _, field := range manualModelEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "exchange_management":
add(slotDisplayName("exchange_type", lang))
for _, field := range manualExchangeEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "trader_management":
if strings.TrimSpace(action) == "create" {
add(slotDisplayName("name", lang))
}
for _, field := range manualTraderEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "strategy_management":
add(slotDisplayName("name", lang))
for _, field := range manualStrategyEditableFieldKeys() {
add(strategyConfigFieldDisplayName(field, lang))
}
}
if len(fieldNames) == 0 {
return ""
}
prefix := "Visible UI fields"
if lang == "zh" {
prefix = "当前可见字段"
}
return prefix + "" + strings.Join(fieldNames, "、")
}
func (a *Agent) strategyTypeForTarget(storeUserID string, target *EntityReference) (string, bool) {
if a == nil || a.store == nil || target == nil {
return "", false
}
var strategy *store.Strategy
var err error
if id := strings.TrimSpace(target.ID); id != "" {
strategy, err = a.store.Strategy().Get(storeUserID, id)
} else if name := strings.TrimSpace(target.Name); name != "" {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return "", false
}
for _, item := range strategies {
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), name) {
strategy = item
break
}
}
} else {
return "", false
}
if err != nil || strategy == nil {
return "", false
}
cfg := store.GetDefaultStrategyConfig("zh")
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
strategyType := strings.TrimSpace(cfg.StrategyType)
if strategyType == "" {
strategyType = "ai_trading"
}
return strategyType, true
}
func (a *Agent) skillVisibleOptionSummary(storeUserID, lang, skillName, action string) string {
switch skillName {
case "model_management":
return a.modelSkillOptionSummary(lang)
case "exchange_management":
return a.exchangeSkillOptionSummary(lang)
case "trader_management":
return a.traderSkillOptionSummary(storeUserID, lang)
case "strategy_management":
return a.strategySkillOptionSummary(storeUserID, lang)
default:
return ""
}
}
func (a *Agent) modelSkillOptionSummary(lang string) string {
if lang == "zh" {
return modelProviderChoicePrompt(lang)
}
return modelProviderChoicePrompt(lang)
}
func (a *Agent) exchangeSkillOptionSummary(lang string) string {
options := enumOptionValues("exchange_management", "exchange_type")
if len(options) == 0 {
options = []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"}
}
if lang == "zh" {
return "交易所类型选项:" + strings.Join(options, "、")
}
return "Exchange type options: " + strings.Join(options, ", ")
}
func enumOptionValues(skillName, field string) []string {
def, ok := getSkillDefinition(skillName)
if !ok {
return nil
}
constraint, ok := def.FieldConstraints[field]
if !ok || len(constraint.Values) == 0 {
return nil
}
values := make([]string, 0, len(constraint.Values))
for _, value := range constraint.Values {
if value == "" {
continue
}
switch value {
case "openai":
values = append(values, "OpenAI")
case "deepseek":
values = append(values, "DeepSeek")
case "claude":
values = append(values, "Claude")
case "gemini":
values = append(values, "Gemini")
case "qwen":
values = append(values, "Qwen")
case "kimi":
values = append(values, "Kimi")
case "grok":
values = append(values, "Grok")
case "minimax":
values = append(values, "Minimax")
case "binance":
values = append(values, "Binance")
case "okx":
values = append(values, "OKX")
case "bybit":
values = append(values, "Bybit")
case "gate":
values = append(values, "Gate")
case "kucoin":
values = append(values, "KuCoin")
case "bitget":
values = append(values, "Bitget")
case "hyperliquid":
values = append(values, "Hyperliquid")
case "aster":
values = append(values, "Aster")
case "lighter":
values = append(values, "Lighter")
case "indodax":
values = append(values, "Indodax")
default:
values = append(values, value)
}
}
return values
}
func (a *Agent) traderSkillOptionSummary(storeUserID, lang string) string {
parts := []string{
formatSkillOptionList(lang, "可选模型", "Available models", a.loadEnabledModelOptions(storeUserID)),
formatSkillOptionList(lang, "可选交易所", "Available exchanges", a.loadExchangeOptions(storeUserID)),
formatSkillOptionList(lang, "可选策略", "Available strategies", a.loadStrategyOptions(storeUserID)),
}
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func (a *Agent) strategySkillOptionSummary(storeUserID, lang string) string {
parts := []string{
"",
formatSkillOptionList(lang, "现有策略", "Existing strategies", a.loadStrategyOptions(storeUserID)),
}
sourceOptions := []string{"static", "ai500", "oi_top", "oi_low"}
if lang == "zh" {
parts[0] = "选币来源选项static、ai500、oi_top、oi_low"
} else {
parts[0] = "Coin source options: static, ai500, oi_top, oi_low"
}
_ = sourceOptions
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func formatSkillOptionList(lang, zhPrefix, enPrefix string, options []traderSkillOption) string {
names := make([]string, 0, len(options))
for _, option := range options {
label := strings.TrimSpace(defaultIfEmpty(option.Name, option.ID))
if label == "" {
continue
}
names = append(names, label)
}
if len(names) == 0 {
if lang == "zh" {
return zhPrefix + ":暂无"
}
return enPrefix + ": none"
}
if lang == "zh" {
return zhPrefix + "" + strings.Join(names, "、")
}
return enPrefix + ": " + strings.Join(names, ", ")
}
func filterNonEmptyStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
}

View File

@@ -2,5 +2,17 @@
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用、余额读取失败、下单失败或仓位模式错误等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入与执行故障。不用于创建、修改、删除或查询交易所配置这类管理操作。",
"capabilities": [
"区分凭证缺失、签名错误、时间戳偏差、IP 白名单、权限不足、余额不足、仓位模式和 symbol 不可交易等原因",
"解释不同交易所的必填字段差异,尤其是 OKX/Bitget/KuCoin passphrase、Hyperliquid 钱包地址、Aster signer/private key、Lighter API key private key",
"把交易所原始错误翻译成新手可执行的修复步骤"
],
"dynamic_rules": [
"交易所连接失败优先按顺序排查:配置是否启用 -> 必填凭证是否齐全 -> API Key/Secret/Passphrase 是否填反或过期 -> 系统时间/timestamp -> IP 白名单 -> 合约/交易权限 -> 测试网/主网是否选错。",
"OKX、Bitget、KuCoin 的 passphrase/API 口令不是可选项;如果缺失,必须明确提示补齐。",
"下单失败时继续排查:账户余额/可用保证金 -> 杠杆限制 -> 仓位模式(单向/双向) -> symbol 是否支持合约交易 -> 最小下单金额/数量。",
"Hyperliquid、Aster、Lighter 这类钱包/DEX 配置错误时,不要用 CEX 的 API Key/Secret 逻辑套用;按各自 required fields 解释。",
"诊断回复不得展示完整 API Key、Secret、Passphrase 或私钥。"
]
}

View File

@@ -3,30 +3,205 @@
"kind": "management",
"domain": "exchange",
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
"field_constraints": {
"exchange_type": {
"type": "enum",
"required": true,
"values": ["binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax"],
"aliases": {"币安": "binance", "欧易": "okx", "必安": "binance", "bitget": "bitget", "bitget futures": "bitget", "bitget合约": "bitget", "库币": "kucoin", "gate.io": "gate", "hyper": "hyperliquid", "印尼站": "indodax"},
"description": "交易所类型,必填,决定后续需要哪些凭证字段。"
},
"account_name": {
"type": "string",
"max_length": 50,
"description": "账户显示名称,可选,用于区分同一交易所的多个账户。"
},
"api_key": {
"type": "credential",
"pattern": "^[A-Za-z0-9_\\-]{8,}$",
"description": "交易所 API Key至少 8 位字母数字。"
},
"secret_key": {
"type": "credential",
"pattern": "^([A-Za-z0-9_\\-]{8,}|(0x)?[A-Fa-f0-9]{16,})$",
"description": "交易所 Secret Key至少 8 位字母数字,或十六进制格式。"
},
"passphrase": {
"type": "credential",
"required_for": ["okx", "bitget", "kucoin"],
"description": "OKX、Bitget、KuCoin 专用 Passphrase/API 口令对这些交易所启用前必须填写Binance、Bybit、Gate、Indodax 通常不需要。"
},
"testnet": {
"type": "bool",
"default": false,
"description": "是否使用测试网(沙盒环境),默认 false主网。"
},
"enabled": {
"type": "bool",
"default": true,
"description": "是否启用该交易所配置。只要必要字段齐全并配置成功,就默认启用。"
},
"hyperliquid_wallet_addr": {
"type": "credential",
"required_for": ["hyperliquid"],
"description": "Hyperliquid 主钱包地址Hyperliquid 账户启用前必须填写。"
},
"hyperliquid_unified_account": {
"type": "bool",
"default": false,
"required_for": ["hyperliquid"],
"description": "是否启用 Hyperliquid unified account 模式。"
},
"aster_user": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster 用户地址Aster 账户启用前必须填写。"
},
"aster_signer": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster Signer 地址Aster 账户启用前必须填写。"
},
"aster_private_key": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster 私钥Aster 账户启用前必须填写。"
},
"lighter_wallet_addr": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter 钱包地址Lighter 账户启用前必须填写。"
},
"lighter_private_key": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter 私钥,某些 Lighter 账户模式下启用前必须填写。"
},
"lighter_api_key_private_key": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter API Key 私钥Lighter 账户启用前必须填写。"
},
"lighter_api_key_index": {
"type": "int",
"min": 0,
"max": 255,
"required_for": ["lighter"],
"description": "Lighter API Key Index范围 0255超出范围自动收敛并告知用户。"
}
},
"validation_rules": [
"api_key 格式:至少 8 位字母数字,不符合时提示用户重新输入完整 Key。",
"secret_key 格式:至少 8 位字母数字,或十六进制格式,不符合时提示用户重新输入。",
"OKX 账户启用前必须填写 passphrase否则拒绝启用并提示补填。",
"Bitget 和 KuCoin 页面流程里也需要 passphrase/API 口令,不能回答“没有就留空”;缺失时应明确提示补填。",
"Hyperliquid 创建/更新时应与手动页面保持一致:至少收集 api_key + hyperliquid_wallet_addr。",
"Hyperliquid 账户启用前必须填写 hyperliquid_wallet_addr。",
"若用户使用 Hyperliquid unified account 模式,应明确记录 hyperliquid_unified_account 开关状态。",
"Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段,任一缺失都不能启用。",
"Lighter 账户启用前必须填写 lighter_wallet_addr + lighter_api_key_private_key若当前账户模式还依赖 lighter_private_key也要先补齐后再启用。",
"lighter_api_key_index 超出 0255 时自动收敛到边界值并告知用户。",
"删除操作不可逆,必须先向用户确认再执行。"
],
"per_exchange_required_fields": {
"binance": ["api_key", "secret_key"],
"okx": ["api_key", "secret_key", "passphrase"],
"bybit": ["api_key", "secret_key"],
"bitget": ["api_key", "secret_key", "passphrase"],
"gate": ["api_key", "secret_key"],
"kucoin": ["api_key", "secret_key", "passphrase"],
"indodax": ["api_key", "secret_key"],
"hyperliquid": ["api_key", "hyperliquid_wallet_addr"],
"aster": ["aster_user", "aster_signer", "aster_private_key"],
"lighter": ["lighter_wallet_addr", "lighter_api_key_private_key"]
},
"actions": {
"create": {
"description": "创建新的交易所配置。",
"required_slots": ["exchange_type"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"]
"description": "创建新的交易所配置。根据 exchange_type 决定需要收集哪些凭证字段。",
"required_slots": ["exchange_type", "account_name"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"],
"goal": "创建一个可供 trader 绑定使用的交易所配置。",
"dynamic_rules": [
"确认 exchange_type 后,根据 per_exchange_required_fields 决定需要追问哪些凭证字段。",
"Binance/Bybit/Gate/Indodax 需要 API Key + SecretOKX/Bitget/KuCoin 还必须追问 passphraseHyperliquid 必须追问 api_key + 钱包地址,并允许记录 unified account 开关Aster 必须追问 user/signer/private_keyLighter 必须追问钱包地址和 api_key_private_key。",
"如果用户选择 OKX、Bitget 或 KuCoin不能把 passphrase 说成可选项;没有 passphrase 时应停在补字段,不要创建半成品。",
"凭证字段格式不符时,用人话告知用户正确格式,不要静默丢弃。",
"若当前父任务只是缺一个可用交易所,本动作完成后应允许父任务恢复并消费新的 exchange_id。",
"若请求只是在启用已有交易所,不应误走 create应改走 update_status。"
],
"success_output": "返回新 exchange_id 和创建后的交易所配置摘要(类型、账户名、是否启用)。",
"failure_output": "明确指出缺失的必填字段或非法凭证格式,禁止返回含糊的成功信息。"
},
"update": {
"description": "更新已有交易所配置。",
"description": "更新已有交易所配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"]
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"],
"goal": "更新一个已有交易所配置的指定字段,而不影响未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"更新凭证字段时,格式不符则提示用户重新输入。"
],
"success_output": "返回 exchange_id 和更新后的交易所配置摘要。",
"failure_output": "明确指出目标交易所不存在、凭证格式非法,或仍缺哪个字段。"
},
"update_name": {
"description": "修改交易所配置中的账户显示名称字段。",
"required_slots": ["target_ref", "account_name"],
"goal": "修改交易所配置中的账户显示名称,而不影响其他字段。",
"dynamic_rules": [
"若用户同时提到其他字段,应优先走更通用的 update。"
],
"success_output": "返回 exchange_id并明确告知交易所配置已更新。",
"failure_output": "明确指出目标交易所不存在,或新的账户名称仍缺失。"
},
"update_status": {
"description": "修改交易所配置中的启用开关。启用前系统会校验凭证完整性。",
"required_slots": ["target_ref", "enabled"],
"goal": "修改交易所配置中的启用状态字段。",
"dynamic_rules": [
"启用前根据 exchange_type 校验必填凭证是否齐全,不齐全则提示用户补填后再启用。"
],
"success_output": "返回 exchange_id并明确告知交易所配置已更新。",
"failure_output": "明确指出目标交易所不存在、缺少必填凭证,或当前状态切换失败。"
},
"delete": {
"description": "删除交易所配置。",
"description": "删除交易所配置,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"needs_confirmation": true,
"goal": "删除一个交易所配置。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。"
],
"success_output": "返回删除成功结果,并明确告知该交易所配置已被移除。",
"failure_output": "明确指出缺少确认、目标交易所不存在,或删除失败原因。"
},
"query": {
"description": "查询交易所配置。"
"query_list": {
"description": "查询所有交易所配置列表,包含类型、账户名、启用状态。",
"goal": "列出当前用户可用的交易所配置,便于后续绑定或选择。",
"dynamic_rules": [
"优先返回类型、账户名、启用状态,不返回敏感凭证明文。"
],
"success_output": "返回交易所配置列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有交易所配置。"
},
"query_detail": {
"description": "查询某个交易所配置的详细信息。",
"required_slots": ["target_ref"],
"goal": "读取一个交易所配置的详细信息和当前状态。",
"dynamic_rules": [
"详情返回中只能暴露凭证存在性,不得返回凭证明文。"
],
"success_output": "返回目标交易所配置的详细摘要。",
"failure_output": "明确指出目标交易所不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_exchange_config:create",
"update": "manage_exchange_config:update",
"update_name": "manage_exchange_config:update",
"update_status": "manage_exchange_config:update",
"delete": "manage_exchange_config:delete",
"query": "get_exchange_configs"
"query_list": "get_exchange_configs",
"query_detail": "get_exchange_configs"
}
}

View File

@@ -2,5 +2,16 @@
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用、claw402 钱包余额不足或支付失败等问题时调用。适用于用户在接入或测试大模型时遇到的配置兼容性、支付和调用故障。不用于创建、修改、删除或查询模型配置这类管理操作。",
"capabilities": [
"区分模型未启用、凭证缺失、endpoint/model name 配置错误、钱包余额不足、上游限流或网关异常",
"对 claw402 / blockrun-base 这类钱包付费模型解释钱包地址、USDC 余额和支付状态",
"给出不泄露敏感凭证的下一步修复建议"
],
"dynamic_rules": [
"诊断模型不可用时,按顺序检查:是否存在该模型配置 -> enabled 是否为 true -> provider 是否支持 -> 凭证/API Key 或钱包私钥是否存在 -> custom_api_url 是否合法 HTTPS 或可留空 -> custom_model_name 是否有默认值或已填写 -> 钱包余额/支付状态 -> 上游限流、超时或网关错误。",
"claw402 是模型 provider使用 Base USDC 钱包按次付费;余额为 0 USDC 时应明确说需要充值,不要说成“未配置模型”。",
"429/rate_limit_error、空响应、超时不应默认归因为余额不足只有工具结果或错误文本指向余额/支付失败时才这么判断。",
"任何诊断回复都不得展示 API Key、钱包私钥或完整敏感凭证。"
]
}

View File

@@ -3,30 +3,155 @@
"kind": "management",
"domain": "model",
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
"field_constraints": {
"provider": {
"type": "enum",
"required": true,
"values": ["openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax", "claw402", "blockrun-base", "blockrun-sol"],
"description": "模型提供商,必填。决定默认模型、凭证类型以及可选配置项。"
},
"name": {
"type": "string",
"max_length": 50,
"description": "模型配置显示名称,可选,用于区分同一 provider 的多个配置。"
},
"api_key": {
"type": "credential",
"description": "模型凭证。普通 provider 使用 API Keyclaw402 和 blockrun 使用钱包私钥。启用前必须填写。"
},
"custom_api_url": {
"type": "url",
"must_be_https": true,
"description": "自定义 API Base URL必须是合法的 HTTPS 地址。普通 provider 可留空走默认地址claw402 / blockrun 不需要。"
},
"custom_model_name": {
"type": "string",
"description": "实际调用的模型 ID例如 gpt-5.1、deepseek-chat。若 provider 有默认模型,可留空走默认值。"
},
"enabled": {
"type": "bool",
"default": false,
"description": "是否启用该模型配置。启用前必须填写 provider 对应的凭证;若 provider 没有默认模型,还需要 custom_model_name。"
}
},
"validation_rules": [
"provider 必须是支持列表之一openai、deepseek、claude、gemini、qwen、kimi、grok、minimax、claw402、blockrun-base、blockrun-sol。",
"OpenAI 的 api_key 格式校验:必须以 sk- 开头,不符合时提示用户检查 Key 是否完整。",
"custom_api_url 若填写,必须是合法 HTTPS 地址,系统拒绝 HTTP 地址,提示用户改用 HTTPS。",
"启用enabled=true前必须填写 provider 对应的凭证;如果 custom_model_name 留空,则系统应先尝试使用 provider 默认模型。",
"启用enabled=truecustom_api_url 若填写必须是合法 HTTPS 地址;不允许用 HTTP 地址硬启用。",
"claw402 是 AI 模型 provider不是交易所、策略或交易员名称用户说“用 claw402”时应解释为选择/绑定 claw402 模型配置。",
"claw402 使用 Base 链 EVM 钱包 + USDC 按次付费enabled=true 只代表模型配置已启用,不代表钱包一定有余额。",
"claw402 或 blockrun-base 钱包余额为 0 USDC 时,应明确提示“钱包余额不足/需要充值”,不要说成“模型未启用”或静默改用其他模型。",
"用户明确指定某个 provider 或模型时,如果当前不可用,必须先说明不可用原因,再让用户选择修复该模型或改用其他已可用模型;不得静默替换。",
"删除操作不可逆,必须先向用户确认再执行。"
],
"actions": {
"create": {
"description": "创建新的模型配置。",
"required_slots": ["provider"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"]
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"],
"goal": "创建一个可供 trader 绑定使用的模型配置。",
"dynamic_rules": [
"确认 provider 后,先说明该 provider 的默认模型和凭证类型,再按 provider 特性补充追问。",
"普通 provideropenai、deepseek、claude 等)通常需要 api_keycustom_api_url 和 custom_model_name 可留空走默认值。",
"claw402 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 deepseek。",
"创建 claw402 后若钱包余额为 0 USDC应提示用户充值 Base USDC 后再用于稳定调用;不要把余额不足误报为配置未启用。",
"blockrun-base 和 blockrun-sol 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 auto。",
"若用户提供了 custom_api_url校验是否为合法 HTTPS 地址,不合法则提示修正。",
"OpenAI 的 api_key 不以 sk- 开头时,提示用户检查 Key 格式。",
"若用户要在父任务里使用现有模型,应优先选择当前已启用模型,而不是误开新的 create。",
"若当前父任务只是缺一个可用模型,本动作完成后应允许父任务恢复并消费新的 model_id。"
],
"success_output": "返回 model_id 和创建后的模型配置摘要provider、名称、是否启用。",
"failure_output": "明确指出缺失字段、非法 endpoint 或不支持的 provider禁止只说泛化失败。"
},
"update": {
"description": "更新已有模型配置。",
"description": "更新已有模型配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"]
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"],
"goal": "更新一个已有模型配置的指定字段,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"如果用户只是想给 trader 改用 claw402不要在模型配置里误改显示名称应把 claw402 作为 provider/model 选择处理。",
"更新 custom_api_url 时校验 HTTPS 格式。",
"更新 api_key 时对 OpenAI 校验 sk- 前缀。"
],
"success_output": "返回 model_id 和更新后的模型配置摘要。",
"failure_output": "明确指出目标模型不存在、provider/endpoint 非法,或仍缺哪个关键字段。"
},
"update_status": {
"description": "启用或禁用模型配置。启用前系统会校验 api_key 和 custom_model_name 是否已填写。",
"required_slots": ["target_ref", "enabled"],
"goal": "切换模型配置的启用状态。",
"dynamic_rules": [
"启用前必须确保 provider 对应凭证已经齐全;若 provider 有默认模型custom_model_name 可按默认值处理。",
"启用 claw402 只校验钱包私钥等配置完整性;若钱包 0 USDC应提示充值但不要把它等同于 enabled=false。"
],
"success_output": "返回 model_id并明确告知该模型已启用或已禁用。",
"failure_output": "明确指出目标模型不存在、缺少启用前必填项,或当前状态切换失败。"
},
"update_endpoint": {
"description": "仅修改模型的 custom_api_url。",
"required_slots": ["target_ref", "custom_api_url"],
"goal": "仅更新模型配置的 custom_api_url。",
"dynamic_rules": [
"custom_api_url 必须是合法 HTTPS 地址;若不合法,先让用户修正而不是继续执行。"
],
"success_output": "返回 model_id并明确告知新的接口地址。",
"failure_output": "明确指出目标模型不存在,或接口地址仍不合法。"
},
"update_name": {
"description": "仅修改模型配置的 custom_model_name实际调用的模型 ID。",
"required_slots": ["target_ref", "custom_model_name"],
"goal": "仅更新模型配置的实际调用模型 ID。",
"dynamic_rules": [
"若用户其实是在改显示名称或 provider应转去更通用的 update而不是误用本动作。"
],
"success_output": "返回 model_id并明确告知新的 custom_model_name。",
"failure_output": "明确指出目标模型不存在,或新的模型 ID 仍未收齐。"
},
"delete": {
"description": "删除模型配置。",
"description": "删除模型配置,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"needs_confirmation": true,
"goal": "删除一个模型配置。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。"
],
"success_output": "返回删除成功结果,并明确告知该模型配置已被移除。",
"failure_output": "明确指出缺少确认、目标模型不存在,或删除失败原因。"
},
"query": {
"description": "查询模型配置。"
"query_list": {
"description": "查询所有模型配置列表,包含 provider、名称、启用状态。",
"goal": "列出当前用户可见的模型配置,便于后续选择或绑定。",
"dynamic_rules": [
"优先返回 provider、名称、启用状态不返回 API Key 明文。",
"对于 claw402 / blockrun-base若工具结果包含钱包地址或 USDC 余额,应用它解释支付状态;余额不足时要说“需要充值”,不要说“没配置”。"
],
"success_output": "返回模型配置列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有模型配置。"
},
"query_detail": {
"description": "查询某个模型配置的详细信息。",
"required_slots": ["target_ref"],
"goal": "读取一个模型配置的详细信息。",
"dynamic_rules": [
"详情返回中只能暴露 API Key/钱包私钥是否存在,不得返回明文凭证。",
"对于 claw402应区分三种状态配置未启用、钱包凭证缺失、钱包余额不足。"
],
"success_output": "返回目标模型配置的详细摘要。",
"failure_output": "明确指出目标模型不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_model_config:create",
"update": "manage_model_config:update",
"update_status": "manage_model_config:update",
"update_endpoint": "manage_model_config:update",
"update_name": "manage_model_config:update",
"delete": "manage_model_config:delete",
"query": "get_model_configs"
"query_list": "get_model_configs",
"query_detail": "get_model_configs"
}
}

View File

@@ -2,5 +2,21 @@
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
"description": "当用户反馈策略未生效、候选币为空、策略输出异常、提示词或配置结果与预期不一致、AI 一直 hold/wait、策略执行表现异常时调用。适用于策略内容、候选币、风控边界和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。",
"capabilities": [
"区分策略模板配置问题、交易员绑定问题、市场数据/候选币问题、AI 决策为 hold/wait、风控拦截和交易所下单失败",
"解释 AI 策略与网格策略的字段边界、页面范围和 System enforced 字段",
"指出策略模板不能直接运行,必须由交易员绑定后执行"
],
"dynamic_rules": [
"策略没生效时,先区分:只是策略模板未被交易员绑定,还是交易员已绑定但运行结果不符合预期。",
"若候选币为空,检查 source_type/static_coins/AI500/OI 榜单/排除币/量化数据开关,不要直接归因为模型问题。",
"若 AI 一直 hold/wait先检查 min_confidence、min_risk_reward_ratio、提示词是否过于保守、行情是否满足入场条件再判断是否需要放宽策略。",
"若交易员绑定了策略但没有下单,应与 trader_diagnosis 协作区分策略无信号、风控拦截和交易所下单失败。",
"策略诊断必须区分可编辑策略字段和 System enforced 字段。AI 智能策略里的 max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 只能解释,不能建议用户修改。",
"如果不开单原因来自最小下单金额、保证金或仓位价值边界,不要建议修改 min_position_size 或 position_size_usd应建议增加账户权益、换更适合小资金的标的、调整可编辑风险偏好或让策略在资金不足时等待。",
"策略页不存在 position_size_usd 这类固定配置项position_size_usd 是 AI 每轮决策输出,不是策略模板字段。不要把 AI 决策里的 position_size_usd 说成可以在策略页手动修改的参数。",
"后台 402/404/EOF 类数据源错误只能作为策略分析质量的辅助影响,不能在决策记录已经显示明确风控/最小金额拒绝时作为主因。",
"策略模板本身不保存交易所、模型、扫描间隔或初始余额;这些问题应引导到 trader/model/exchange 相关诊断。"
]
}

View File

@@ -2,41 +2,472 @@
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。",
"field_constraints": {
"name": {
"type": "string",
"required": true,
"max_length": 50,
"description": "策略模板名称,必填,最多 50 个字符。"
},
"description": {
"type": "string",
"description": "策略描述,可选。"
},
"is_public": {
"type": "bool",
"default": false,
"description": "是否发布到策略市场。"
},
"config_visible": {
"type": "bool",
"default": true,
"description": "发布到市场后,是否允许别人查看策略配置。"
},
"lang": {
"type": "enum",
"values": ["zh", "en"],
"default": "zh",
"description": "策略语言zh 或 en影响 AI 决策时使用的语言。"
},
"strategy_type": {
"type": "enum",
"values": ["ai_trading", "grid_trading"],
"description": "策略类型ai_tradingAI 量化)或 grid_trading网格策略。创建策略时必须先由用户选择或从用户话语明确识别不能默认成 ai_trading。"
},
"symbol": {
"type": "enum",
"values": ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"],
"description": "网格策略页面交易对下拉选项,只能从 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。用户问“交易对有哪些选项”时,直接列出这些选项。"
},
"source_type": {
"type": "enum",
"values": ["static", "ai500", "oi_top", "oi_low"],
"description": "选币来源类型。static=用户指定静态币池ai500=AI500榜单oi_top=持仓量增长oi_low=持仓量下降。"
},
"static_coins": {
"type": "string_array",
"max_items": 10,
"description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"]source_type=static 时使用,手动页面最多 10 个。页面支持常规合约币种,也支持 xyz: 前缀资产(如 xyz:TSLA、xyz:GOLD、xyz:XYZ100。"
},
"excluded_coins": {
"type": "string_array",
"description": "排除币列表,所有来源均会排除这些币。"
},
"primary_timeframe": {
"type": "string",
"values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"],
"description": "主 K 线周期,例如 5m、15m、1h。"
},
"selected_timeframes": {
"type": "string_array",
"values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"],
"max_items": 4,
"description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"];手动页面最多选择 4 个。"
},
"btceth_max_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "BTC/ETH 最大杠杆倍数,范围 120。"
},
"altcoin_max_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "山寨币最大杠杆倍数,范围 120。"
},
"min_confidence": {
"type": "int",
"min": 50,
"max": 100,
"description": "最小开仓置信度,手动页面范围 50100数值越高开单越谨慎。"
},
"min_risk_reward_ratio": {
"type": "float",
"min": 1,
"max": 10,
"description": "最小盈亏比,手动页面范围 110步进 0.5;例如 1.5 表示每笔交易至少 1.5 倍风险收益比。"
},
"custom_prompt": {
"type": "text",
"description": "自定义 AI 提示词,追加到策略基础提示词之后。"
},
"role_definition": {
"type": "text",
"description": "AI 角色定义,描述 AI 的交易风格和定位。"
},
"trading_frequency": {
"type": "text",
"description": "交易频率描述,例如:每天最多开 3 笔。"
},
"entry_standards": {
"type": "text",
"description": "入场标准描述,例如:只在趋势明确时开仓。"
},
"decision_process": {
"type": "text",
"description": "决策流程描述,例如:先看大周期趋势,再看小周期入场点。"
},
"grid_count": {
"type": "int",
"min": 5,
"max": 50,
"description": "网格数量grid_trading 类型专用,手动页面范围 550。"
},
"total_investment": {
"type": "float",
"min": 100,
"description": "网格总投入金额grid_trading 类型专用,表示用户实际投入/保证金预算,不是杠杆后的名义仓位;名义仓位约等于 total_investment × leverage。手动页面最小 100 USDT步进 100。"
},
"leverage": {
"type": "int",
"min": 1,
"max": 5,
"description": "网格策略杠杆倍数,手动页面当前范围 15。"
},
"upper_price": {
"type": "float",
"description": "网格上边界价格grid_trading 类型专用。"
},
"lower_price": {
"type": "float",
"description": "网格下边界价格grid_trading 类型专用,必须小于 upper_price。"
},
"distribution": {
"type": "enum",
"values": ["uniform", "gaussian", "pyramid"],
"description": "网格分布方式uniform=均匀gaussian=正态pyramid=金字塔。"
},
"use_atr_bounds": {
"type": "bool",
"default": false,
"description": "网格边界是否改为按 ATR 动态计算。"
},
"atr_multiplier": {
"type": "float",
"min": 1,
"max": 5,
"description": "ATR 边界倍数use_atr_bounds=true 时使用,手动页面范围 15步进 0.5。"
},
"enable_direction_adjust": {
"type": "bool",
"default": false,
"description": "是否启用方向偏置调整。"
},
"direction_bias_ratio": {
"type": "float",
"min": 0.55,
"max": 0.9,
"description": "方向偏置比例,决定多空倾向强弱;手动页面范围 0.550.90,通常以 55%90% 展示。"
},
"max_drawdown_pct": {
"type": "float",
"min": 5,
"max": 50,
"description": "网格策略最大回撤百分比,手动页面范围 550。"
},
"stop_loss_pct": {
"type": "float",
"min": 1,
"max": 20,
"description": "网格策略止损百分比,手动页面范围 120。"
},
"daily_loss_limit_pct": {
"type": "float",
"min": 1,
"max": 30,
"description": "网格策略每日最大亏损比例,手动页面范围 130达到后当天停止新开仓。"
},
"use_maker_only": {
"type": "bool",
"default": false,
"description": "是否优先只挂 maker 单。"
},
"use_ai500": {
"type": "bool",
"default": false,
"description": "是否启用 AI500 榜单作为候选币来源。"
},
"ai500_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "AI500 榜单选取数量,手动页面范围 110。"
},
"use_oi_top": {
"type": "bool",
"default": false,
"description": "是否启用 OI Top 作为候选币来源。"
},
"oi_top_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "OI Top 选取数量,手动页面范围 110。"
},
"use_oi_low": {
"type": "bool",
"default": false,
"description": "是否启用 OI Low 作为候选币来源。"
},
"oi_low_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "OI Low 选取数量,手动页面范围 110。"
},
"primary_count": {
"type": "int",
"min": 10,
"max": 30,
"description": "主周期 K 线样本数量,手动页面范围 1030。"
},
"enable_ema": {
"type": "bool",
"default": false,
"description": "是否启用 EMA 指标。"
},
"enable_macd": {
"type": "bool",
"default": false,
"description": "是否启用 MACD 指标。"
},
"enable_rsi": {
"type": "bool",
"default": false,
"description": "是否启用 RSI 指标。"
},
"enable_atr": {
"type": "bool",
"default": false,
"description": "是否启用 ATR 指标。"
},
"enable_boll": {
"type": "bool",
"default": false,
"description": "是否启用布林带指标。"
},
"enable_volume": {
"type": "bool",
"default": false,
"description": "是否启用成交量指标。"
},
"enable_oi": {
"type": "bool",
"default": false,
"description": "是否启用持仓量指标。"
},
"enable_funding_rate": {
"type": "bool",
"default": false,
"description": "是否启用资金费率指标。"
},
"ema_periods": {
"type": "int_array",
"description": "EMA 周期列表,例如 [9,21,55]。"
},
"rsi_periods": {
"type": "int_array",
"description": "RSI 周期列表。"
},
"atr_periods": {
"type": "int_array",
"description": "ATR 周期列表。"
},
"boll_periods": {
"type": "int_array",
"description": "布林带周期列表。"
},
"nofxos_api_key": {
"type": "credential",
"description": "量化数据 API Key。"
},
"enable_quant_data": {
"type": "bool",
"default": false,
"description": "是否启用量化数据增强。"
},
"enable_quant_oi": {
"type": "bool",
"default": false,
"description": "是否启用量化持仓量数据。"
},
"enable_quant_netflow": {
"type": "bool",
"default": false,
"description": "是否启用量化净流入数据。"
},
"enable_oi_ranking": {
"type": "bool",
"default": false,
"description": "是否启用 OI 排行榜。"
},
"oi_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h"],
"description": "OI 排行榜统计周期,页面选项为 1h、4h、24h。"
},
"oi_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "OI 排行榜返回数量,页面选项为 5、10、15、20。"
},
"enable_netflow_ranking": {
"type": "bool",
"default": false,
"description": "是否启用净流入排行榜。"
},
"netflow_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h"],
"description": "净流入排行榜统计周期,页面选项为 1h、4h、24h。"
},
"netflow_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "净流入排行榜返回数量,页面选项为 5、10、15、20。"
},
"enable_price_ranking": {
"type": "bool",
"default": false,
"description": "是否启用价格波动排行榜。"
},
"price_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h", "1h,4h,24h"],
"description": "价格排行榜统计周期,页面选项为 1h、4h、24h、1h,4h,24h。"
},
"price_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "价格排行榜返回数量,页面选项为 5、10、15、20。"
}
},
"validation_rules": [
"本 skill 只负责策略模板创建、查看、修改、删除、激活和复制。",
"字段选项和范围来自 field_constraints产品行为规则由 active session prompt 负责。"
],
"actions": {
"create": {
"description": "创建策略模板。",
"required_slots": ["name"],
"optional_slots": ["config", "description", "lang"]
"optional_slots": ["strategy_type", "config_patch"],
"goal": "创建一个可供 trader 绑定使用的策略模板。",
"success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。",
"failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。"
},
"update": {
"description": "更新策略模板。",
"description": "更新策略模板的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "config", "description"]
"optional_slots": ["name", "description", "is_public", "config_visible", "config_patch"],
"goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"杠杆超出 120 范围时,自动收敛并告知用户。",
"grid_trading 类型时lower_price 必须小于 upper_price。"
],
"success_output": "返回 strategy_id 和更新后的策略摘要。",
"failure_output": "明确指出目标策略不存在、参数非法,或仍缺哪个关键字段。"
},
"delete": {
"description": "删除策略模板。",
"update_name": {
"description": "仅修改策略模板名称。",
"required_slots": ["target_ref", "name"],
"goal": "仅修改策略模板名称。",
"dynamic_rules": [
"若输入里还包含其他配置项,应优先转去更通用的 update 或 update_config。"
],
"success_output": "返回 strategy_id并明确告知新的策略名称。",
"failure_output": "明确指出目标策略不存在,或新的名称仍未收齐。"
},
"update_prompt": {
"description": "仅修改策略的 custom_prompt 或 prompt_sectionsrole_definition、trading_frequency、entry_standards、decision_process。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"optional_slots": ["custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process"],
"goal": "更新策略模板的提示词相关内容,而不改动其他配置。",
"dynamic_rules": [
"若用户一次修改多个 prompt section应整体应用并在结果里清楚说明。",
"若用户实际是在改纯配置项,应转去 update_config。",
"当需要收集 custom_prompt 或 prompt_sections 等长文本槽位,而用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次机械索要原文。",
"此时你必须直接以量化专家身份先拟出一版高质量文本,将生成内容写入对应字段,并在回复里展示草稿让用户确认是否直接采用。"
],
"success_output": "返回 strategy_id并明确告知哪些 prompt 字段已更新。",
"failure_output": "明确指出目标策略不存在,或新的 prompt 内容仍不完整。"
},
"update_config": {
"description": "修改策略的某个具体配置参数(选币来源、指标、风控参数等)。",
"required_slots": ["target_ref"],
"optional_slots": ["config_patch"],
"goal": "修改策略模板中的一个或一组具体配置参数。",
"dynamic_rules": [
"配置变更统一通过 config_patch 表达,字段必须来自当前策略类型的产品模板。",
"字段选项、范围和非策略字段拦截由 active session prompt 与后端 schema 负责。"
],
"success_output": "返回 strategy_id并明确告知已修改的配置字段及其最终值。",
"failure_output": "明确指出目标策略不存在、配置字段非法,或值仍需用户澄清。"
},
"activate": {
"description": "激活策略模板。",
"required_slots": ["target_ref"]
"description": "策略模板设为默认模板(激活)。",
"required_slots": ["target_ref"],
"goal": "将某个策略模板设为默认模板。",
"success_output": "返回 strategy_id并明确告知该策略已被设为默认模板。",
"failure_output": "明确指出目标策略不存在,或激活失败原因。"
},
"duplicate": {
"description": "复制策略模板。",
"required_slots": ["target_ref", "name"]
"description": "复制策略模板,生成一个新的同配置模板。",
"required_slots": ["target_ref", "name"],
"goal": "复制一个现有策略模板并生成新的模板名称。",
"dynamic_rules": [
"新名称必须单独收齐;若名称有歧义或为空,应先继续追问。"
],
"success_output": "返回新的 strategy_id并明确告知复制后的策略名称。",
"failure_output": "明确指出目标策略不存在,或新名称仍未收齐。"
},
"query": {
"description": "查询策略模板。"
"delete": {
"description": "删除策略模板,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "删除一个策略模板。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。",
"若策略是默认模板或受系统保护,应向用户解释限制。"
],
"success_output": "返回删除成功结果,并明确告知该策略模板已被移除。",
"failure_output": "明确指出缺少确认、目标策略不存在,或删除失败原因。"
},
"query_list": {
"description": "查询所有策略模板列表,包含名称、类型、是否为默认模板。",
"goal": "列出当前用户可见的策略模板,便于后续选择或绑定。",
"dynamic_rules": [
"优先返回名称、类型、默认状态,不必展开全部详细配置。"
],
"success_output": "返回策略模板列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有策略模板。"
},
"query_detail": {
"description": "查询某个策略模板的详细配置,包括选币来源、指标、风控参数、提示词等。",
"required_slots": ["target_ref"],
"goal": "读取一个策略模板的详细配置。",
"dynamic_rules": [
"若目标有歧义,应先澄清再返回详情。"
],
"success_output": "返回目标策略模板的详细配置摘要。",
"failure_output": "明确指出目标策略不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_strategy:create",
"update": "manage_strategy:update",
"delete": "manage_strategy:delete",
"update_name": "manage_strategy:update",
"update_prompt": "manage_strategy:update",
"update_config": "manage_strategy:update",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"query": "get_strategies"
"delete": "manage_strategy:delete",
"query_list": "get_strategies",
"query_detail": "get_strategies"
}
}

View File

@@ -0,0 +1,63 @@
{
"name": "trade_execution",
"kind": "execution",
"domain": "trade",
"description": "当用户明确要求开仓、平仓、买入、卖出,或确认待执行的大额订单时调用。负责真实下单前的安全校验、待确认订单、确认执行与交易历史查询。",
"intents": [
"下单交易",
"开多开空",
"平仓",
"确认大额订单",
"查询交易历史"
],
"actions": {
"execute": {
"description": "创建一笔待确认交易。不会直接成交,而是先做风险检查,再给用户确认指令。",
"required_slots": ["action", "symbol", "quantity"],
"optional_slots": ["leverage", "trader_id"],
"needs_confirmation": true,
"goal": "在真实执行前先做风险检查,并给用户一个可确认的待执行订单。",
"dynamic_rules": [
"只有当用户明确要求交易时才允许进入本动作;分析、建议、解释行情都不应触发下单。",
"开仓数量必须大于 0单笔数量硬上限为 1000000超过时直接拒绝。",
"会先按实时价格估算名义价值;单笔名义价值硬上限为 100000 USDT超过时直接拒绝。",
"若单笔名义价值达到 5000 USDT或达到账户权益的 25%,必须标记为大额订单,要求用户发送“确认大额 trade_xxx”后才执行。",
"若单笔名义价值超过账户权益的 100%,直接拒绝,不允许创建待确认订单。",
"加密货币订单的杠杆上限受策略 btceth_max_leverage / altcoin_max_leverage 约束,默认上限为 5x超出时直接拒绝。",
"BTC/ETH 单笔最大仓位价值默认不超过 5 倍账户权益,山寨币默认不超过 1 倍账户权益;若策略里有自定义比例,以策略为准。",
"最小仓位价值固定为 12 USDT这是系统强制项不允许通过 Agent 修改。低于最小值时直接拒绝。",
"创建后的待确认订单默认 5 分钟有效,超时自动失效。"
],
"success_output": "返回 trade_id、估算仓位价值、是否触发大额确认、确认命令和 5 分钟有效期。",
"failure_output": "用简单清楚的话说明是哪条风控挡住了,例如数量过大、仓位太小、杠杆过高、超过权益上限。"
},
"confirm_large_order": {
"description": "确认一笔已创建的大额待执行订单。",
"required_slots": ["trade_id"],
"needs_confirmation": true,
"goal": "在用户明确确认后,执行已通过初步检查的大额订单。",
"dynamic_rules": [
"用户必须发送“确认大额 trade_xxx”或“confirm large trade_xxx”才能执行大额订单。",
"若订单已过期、已不存在,或 trade_id 无效,要直接说明这笔订单已经失效。",
"若用户只发送普通确认,但订单被标记为大额订单,必须继续要求“大额确认”,不能直接放行。"
],
"success_output": "明确告知订单已执行,并展示方向、品种、数量。",
"failure_output": "明确说明订单已过期、风控未通过,或执行失败原因。"
},
"query_history": {
"description": "查询最近的交易历史。",
"optional_slots": ["limit", "trader_id"],
"goal": "让用户快速查看最近成交记录和交易结果。",
"dynamic_rules": [
"优先返回最近几笔最重要的交易,不要一次性给太长的开发者原始日志。",
"若当前没有交易记录,要直接说明当前还没有成交记录。"
],
"success_output": "返回最近交易记录摘要,包括方向、品种、时间和结果。",
"failure_output": "若没有记录或查询失败,要明确告知用户。"
}
},
"tool_mapping": {
"execute": "execute_trade",
"query_history": "get_trade_history"
}
}

View File

@@ -2,5 +2,38 @@
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
"description": "当用户反馈交易员无法启动、启动后不交易、反复报错、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。",
"capabilities": [
"读取交易员当前状态、账户、持仓和最近决策记录",
"读取交易员绑定的策略、模型、交易所配置摘要,并把它们纳入不开单诊断证据包",
"在用户明确指定目标交易员后,读取该交易员最近的后端日志",
"把完整证据合并成适合新手理解的最终原因和下一步行动"
],
"dynamic_rules": [
"当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。",
"如果已经能唯一确定目标交易员,应一次性收集完整诊断证据包:交易员配置/运行状态、绑定策略、绑定模型、绑定交易所、账户权益/可用余额、当前持仓、get_decisions 最近决策记录、get_backend_logs 后台日志。不要只查其中一项就下结论。",
"面向普通用户的诊断回复只说最终原因和该怎么办不要输出证据包清单、工具名、后台日志片段、HTTP 状态码或工程排障过程。",
"诊断结论内部必须区分:直接原因、次要影响、待确认因素。直接原因必须来自最近决策记录、交易所下单结果、风控校验或明确运行状态;后台日志里的零散错误只能作为辅助证据。",
"证据优先级固定为:最近决策记录 > 交易员运行状态/账户/持仓 > 交易所下单结果 > 后台日志。除非最近决策记录本身显示数据获取失败或 AI 决策中断,否则不要让 backend logs 盖过决策记录。",
"交易员不下单的排查顺序固定为:是否运行中 -> 是否已到扫描间隔 -> 策略候选币/行情数据是否为空 -> 最近 AI 决策是否为 hold/wait -> 风控是否拦截 -> 交易所下单是否报错 -> 余额、杠杆、仓位模式或权限是否限制。",
"判断“不下单/不开单”的主因时,最近决策记录优先级高于零散 backend error 日志;如果最近决策显示 wait succeeded应解释为 AI 主动等待;如果最新决策 error_message 显示 opening amount too small / below minimum / must be ≥,应解释为开仓金额低于系统或交易所最小下单门槛。",
"遇到 opening amount too small、position value below minimum、must be ≥ 这类错误时,不要建议用户修改 AI 智能策略的 min_position_size 或 position_size_usd。先说明这是系统/交易所门槛或 System enforced 边界,再建议增加账户权益、换更适合小资金的交易标的、调整可编辑策略偏好,或让策略在资金不足时等待。",
"AI 智能策略里的 System enforced 字段max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size只能解释不能建议用户修改如果限制来自这些字段行动建议必须落在产品实际可改项或用户账户/标的选择上。",
"不要只因为 backend logs 里出现 402、404、EOF、payment retry failed 就直接归因为数据服务、订阅到期或付款失败;这些内部异常不应在普通用户回答里出现,除非用户明确追问后台日志或技术细节。",
"402 不要直接翻译成“订阅到期”。在没有钱包余额、支付状态或服务侧确认前,不能说订阅过期;普通用户回答里也不要主动说 402。",
"如果最近决策记录显示 candidate_coins 非空、AI call completed、wait succeeded 或 open_* 决策已生成,则说明核心决策链路并非完全拿不到数据;此时不要把 402/404/EOF 说成不开单主因。",
"行动建议必须对应产品里真实存在且可修改的字段或操作。不要编造策略页不存在的 position_size_usd 参数,不要建议修改 System enforced 字段。",
"如果模型是 claw402 或 blockrun-base应单独检查钱包 USDC 余额;余额不足时应说“支付余额不足/需要充值”,不要泛化成“模型没启用”。",
"如果日志显示 AI 返回 hold/wait应解释为模型判断当前没有足够交易信号不应误判为系统没有运行。",
"如果日志显示下单失败应优先归因到交易所权限、API 凭证、仓位模式、余额、杠杆或 symbol 可交易性,而不是策略没有生效。",
"当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 get_backend_logs。",
"当证据中已经出现明确错误原因时,直接用人话解释最终原因和下一步,不要复述原始日志。"
],
"tool_mapping": {
"query_runtime_state": "get_trader_system_status",
"query_positions": "get_positions",
"query_account": "get_account_info",
"query_recent_decisions": "get_decisions",
"query_backend_logs": "get_backend_logs"
}
}

View File

@@ -2,7 +2,7 @@
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略。编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
@@ -11,42 +11,221 @@
"停止交易员",
"查询交易员"
],
"field_constraints": {
"name": {
"type": "string",
"required": true,
"max_length": 50,
"description": "交易员名称,用于识别和管理,最多 50 个字符。"
},
"exchange_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的交易所配置 ID必须是已存在且已启用的交易所配置。"
},
"ai_model_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的 AI 模型配置 ID必须是已存在且已启用的模型配置。"
},
"strategy_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的策略模板 ID必须是已存在的策略模板。"
},
"scan_interval_minutes": {
"type": "int",
"min": 3,
"max": 60,
"default": 5,
"description": "AI 扫描决策间隔,单位分钟,手动面板可配置范围 360 分钟。超出范围会自动收敛到边界值并告知用户。"
},
"is_cross_margin": {
"type": "bool",
"default": true,
"description": "保证金模式。true = 全仓cross marginfalse = 逐仓isolated margin。"
},
"show_in_competition": {
"type": "bool",
"default": true,
"description": "是否在竞技场中显示该交易员的成绩。"
},
"auto_start": {
"type": "bool",
"default": false,
"description": "创建后是否立即启动交易员。启动前系统会校验绑定的交易所、模型、策略均可用。"
}
},
"validation_rules": [
"exchange_id 对应的交易所配置必须已启用enabled=true否则无法创建或启动交易员。",
"ai_model_id 对应的模型配置必须已启用enabled=true且配置完整api_key、custom_model_name 不为空custom_api_url 若填写必须为合法 HTTPS否则无法创建或启动交易员。",
"strategy_id 对应的策略模板必须存在,否则无法创建交易员。",
"scan_interval_minutes 超出 360 范围时,系统自动收敛到边界值,并通过 LLM 告知用户已调整,询问是否接受。",
"交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。",
"交易员名称不能从模型 provider 自动推断;用户说“用 claw402”表示模型选择不表示交易员名称叫 claw402。",
"用户明确指定模型、交易所或策略时,若该资源不存在、被禁用、配置不完整或钱包余额不足,必须说明具体原因并让用户确认修复或替换;不得静默换成另一个资源。",
"若用户指定 claw402 作为模型,但 claw402 钱包余额为 0 USDC应提示先充值或确认临时改用其他可用模型不得说成 claw402 未启用,除非 enabled 确实为 false。",
"启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。",
"若绑定的是 OKX 交易所,启用前必须已有 passphrase若绑定的是 Hyperliquid启用前必须已有 wallet_addr若绑定的是 Aster启用前必须已有 user、signer、private_key若绑定的是 Lighter启用前必须已有 wallet_addr 和 api_key_private_key。",
"启动start和停止stop操作属于高风险操作必须先向用户确认再执行。",
"删除delete操作不可逆必须先向用户确认再执行。"
],
"actions": {
"create": {
"description": "创建新的交易员。",
"required_slots": ["name", "exchange", "model"],
"optional_slots": ["strategy", "auto_start"]
"description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。",
"required_slots": ["name", "exchange", "model", "strategy"],
"optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "创建并初始化一个交易员。",
"dynamic_rules": [
"若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。",
"如果用户明确指定某个模型 provider如 claw402应先尝试匹配该 provider 对应的模型配置;只有在说明原因并得到用户确认后,才可改用其他模型。",
"若用户没有提供交易员名称,应生成一个来自交易所/策略/方向的清晰名称,或向用户追问;不要把模型 provider、交易所类型或策略字段误用为交易员名称。",
"若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。",
"子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。",
"scan_interval_minutes 超出 360 时,自动收敛并告知用户。",
"不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。",
"创建完成后询问用户是否立即启动auto_start启动前再次确认。"
],
"success_output": "返回 trader_id并给出创建结果摘要名称、绑定的交易所/模型/策略、是否已启动)。",
"failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。"
},
"update": {
"description": "更新已有交易员。",
"description": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"]
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"换绑交易所/模型/策略时,新的资源必须已存在且已启用;若是钱包付费模型,还要解释余额不足等支付状态。",
"用户明确要求换成某个模型/交易所/策略时,不能自动选择另一个看起来可用的资源,除非用户确认。",
"如果用户要求改名,应明确告知交易员改名不在这里处理。",
"如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update应切到对应 management skill。"
],
"success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。",
"failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"
},
"delete": {
"description": "删除交易员。",
"update_bindings": {
"description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。",
"dynamic_rules": [
"新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。",
"当指定模型是 claw402 或 blockrun-base 且钱包余额不足时,应提示充值或让用户确认临时切换模型。",
"扫描间隔超出 360 时,自动收敛并告知用户。"
],
"success_output": "返回 trader_id并明确展示新的模型/交易所/策略绑定结果。",
"failure_output": "明确指出缺少哪个绑定目标,或当前依赖资源为什么不可直接绑定。"
},
"configure_strategy": {
"description": "仅修改交易员绑定的策略。",
"required_slots": ["target_ref", "strategy_id"],
"goal": "为指定交易员换绑一个策略模板。",
"dynamic_rules": [
"若用户提到的是不存在的策略,应优先澄清或引导创建,而不是静默失败。"
],
"success_output": "返回 trader_id并明确告知当前生效的 strategy_id/策略名称。",
"failure_output": "明确指出目标交易员或策略不存在,或策略仍需用户澄清。"
},
"configure_exchange": {
"description": "仅修改交易员绑定的交易所。",
"required_slots": ["target_ref", "exchange_id"],
"goal": "为指定交易员换绑一个交易所配置。",
"dynamic_rules": [
"新的交易所配置必须已启用且可用,否则提示用户先启用或补齐凭证。"
],
"success_output": "返回 trader_id并明确告知当前生效的 exchange_id/交易所名称。",
"failure_output": "明确指出目标交易员或交易所不存在,或交易所当前不可用。"
},
"configure_model": {
"description": "仅修改交易员绑定的 AI 模型。",
"required_slots": ["target_ref", "ai_model_id"],
"goal": "为指定交易员换绑一个 AI 模型配置。",
"dynamic_rules": [
"新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。",
"若用户指定的是 claw402应优先绑定 claw402只有在钱包余额不足、凭证缺失或配置不可用且用户确认后才允许改绑其他模型。"
],
"success_output": "返回 trader_id并明确告知当前生效的 ai_model_id/模型名称。",
"failure_output": "明确指出目标交易员或模型不存在,或模型当前不可用。"
},
"start": {
"description": "启动交易员。",
"description": "启动交易员,使其开始自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"needs_confirmation": true,
"goal": "让一个已配置好的交易员进入运行状态。",
"dynamic_rules": [
"启动前系统会自动校验绑定的交易所、模型、策略是否均可用。",
"若绑定模型为 claw402 或 blockrun-base 且钱包余额不足,应提示充值或换模型;不要把它泛化成“模型不可用”。",
"若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。"
],
"success_output": "返回 trader_id并明确告知交易员已开始运行。",
"failure_output": "明确指出缺少确认、依赖资源不可用,或启动未通过校验。"
},
"stop": {
"description": "停止交易员。",
"description": "停止交易员,使其停止自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true
"needs_confirmation": true,
"goal": "让一个运行中的交易员停止自动交易。",
"dynamic_rules": [
"若交易员当前并未运行,也应给用户清晰说明,而不是假装停止成功。"
],
"success_output": "返回 trader_id并明确告知交易员已停止。",
"failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。"
},
"query": {
"description": "查询交易员列表或状态。"
"delete": {
"description": "删除交易员,不可逆操作,必须确认。支持删除单个、多个或全部交易员。",
"required_slots": [],
"needs_confirmation": true,
"goal": "删除一个、多个或全部交易员及其运行入口。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒该操作不可逆。",
"删除范围可以是单个 target_ref、多个目标或 bulk_scope=all。",
"删除前必须确认目标交易员都已停止;若存在运行中的交易员,不能删除,应要求用户先停止这些交易员。"
],
"success_output": "返回删除成功结果,并明确告知哪些交易员已被移除。",
"failure_output": "明确指出缺少确认、目标交易员不存在、目标仍在运行,或删除失败原因。"
},
"query_list": {
"description": "查询所有交易员列表,包含名称、运行状态、绑定信息。",
"goal": "列出当前用户可见的交易员,并给出足够的摘要用于后续选择。",
"dynamic_rules": [
"优先返回名称、运行状态、绑定的模型/交易所/策略,不要冗余展开全部详情。"
],
"success_output": "返回交易员列表摘要,便于用户继续指定目标对象。",
"failure_output": "若列表为空,应明确告知当前没有交易员,而不是返回模糊空结果。"
},
"query_running": {
"description": "查询当前运行中的交易员列表。",
"goal": "仅列出处于运行状态的交易员。",
"dynamic_rules": [
"若当前没有运行中的交易员,应明确告知为空。"
],
"success_output": "返回当前运行中的交易员列表摘要。",
"failure_output": "若没有运行中的交易员,应明确返回空列表说明。"
},
"query_detail": {
"description": "查询某个交易员的详细配置,包括绑定的交易所、模型、策略、扫描间隔、保证金模式等。",
"required_slots": ["target_ref"],
"goal": "读取一个交易员的详细配置和当前绑定信息。",
"dynamic_rules": [
"若目标对象有歧义,应先澄清再读取详情。"
],
"success_output": "返回目标交易员的详细配置摘要。",
"failure_output": "明确指出目标交易员不存在,或当前引用需要重新指定。"
}
},
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"delete": "manage_trader:delete",
"update_bindings": "manage_trader:update",
"configure_strategy": "manage_trader:update",
"configure_exchange": "manage_trader:update",
"configure_model": "manage_trader:update",
"start": "manage_trader:start",
"stop": "manage_trader:stop",
"query": "manage_trader:list"
"delete": "manage_trader:delete",
"query_list": "manage_trader:list",
"query_running": "manage_trader:list",
"query_detail": "manage_trader:list"
}
}

27
agent/strategy_draft.go Normal file
View File

@@ -0,0 +1,27 @@
package agent
import (
"strings"
)
func inferStandaloneStrategyName(text string) string {
value := strings.TrimSpace(text)
if value == "" || len([]rune(value)) > 50 {
return ""
}
if strategyCreateConfirmationReply(value) || strategyCreateDefaultConfigReply(value) || isCancelSkillReply(value) {
return ""
}
if parseStrategyTypeValue(value) != "" {
return ""
}
if containsAny(strings.ToLower(value), []string{"创建", "新建", "create", "grid_trading", "ai_trading"}) {
return ""
}
return value
}
func activeHistoryMessageAsksStrategyName(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
}

View File

@@ -0,0 +1,224 @@
package agent
func manualStrategyEditableFieldKeys() []string {
return []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
}
}
func manualStrategyEditableFieldKeysForType(strategyType string) []string {
common := []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
}
switch strategyType {
case "grid_trading":
return append(common,
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
)
case "ai_trading":
return append(common,
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
)
default:
return manualStrategyEditableFieldKeys()
}
}
func agentStrategyUpdatableFieldKeys() []string {
return []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
}
}

49
agent/stream_text.go Normal file
View File

@@ -0,0 +1,49 @@
package agent
import "strings"
func emitStreamText(onEvent func(event, data string), text string) {
if onEvent == nil {
return
}
for _, chunk := range splitStreamText(text) {
onEvent(StreamEventDelta, chunk)
}
}
func splitStreamText(text string) []string {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
lines := strings.Split(text, "\n")
chunks := make([]string, 0, len(lines)*2)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
start := 0
for i, r := range line {
switch r {
case '。', '', '', '.', '!', '?', ';', '', '', ':', '', ',':
part := strings.TrimSpace(line[start : i+len(string(r))])
if part != "" {
chunks = append(chunks, part)
}
start = i + len(string(r))
}
}
if start < len(line) {
part := strings.TrimSpace(line[start:])
if part != "" {
chunks = append(chunks, part)
}
}
}
if len(chunks) == 0 {
return []string{text}
}
return chunks
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
package agent
import "testing"
func TestIsStockSymbol(t *testing.T) {
tests := []struct {
sym string
want bool
}{
// Known crypto base symbols — must NOT be detected as stock
{"BTC", false},
{"ETH", false},
{"SOL", false},
{"BNB", false},
{"XRP", false},
{"DOGE", false},
{"ADA", false},
{"AVAX", false},
{"DOT", false},
{"LINK", false},
{"PEPE", false},
{"SHIB", false},
{"TRUMP", false},
{"USDT", false},
{"USDC", false},
{"W", false}, // single letter crypto
// Crypto pairs — must NOT be stock
{"BTCUSDT", false},
{"ETHUSDT", false},
{"SOLUSDT", false},
{"DOGEUSDT", false},
// Real stock tickers — must be detected as stock
{"AAPL", true},
{"TSLA", true},
{"NVDA", true},
{"MSFT", true},
{"GOOGL", true},
{"AMZN", true},
{"META", true},
{"AMD", true},
{"PLTR", true},
{"BA", true},
{"F", true}, // Ford — 1 letter
{"GM", true}, // 2 letters
{"JPM", true}, // 3 letters
// Mixed / edge cases
{"btc", false}, // lowercase crypto
{"aapl", true}, // lowercase stock (uppercased internally)
{"BTC123", false}, // not pure letters
{"123456", false}, // digits
{"", false},
}
for _, tt := range tests {
t.Run(tt.sym, func(t *testing.T) {
got := isStockSymbol(tt.sym)
if got != tt.want {
t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want)
}
})
}
}

View File

@@ -5,22 +5,50 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"nofx/store"
"strings"
"sync"
"time"
)
const (
tradeAbsoluteMaxQuantity = 1_000_000.0
tradeLargeOrderNotionalUSDT = 5_000.0
tradeHardMaxOrderNotionalUSDT = 100_000.0
tradeLargeOrderEquityRatio = 0.25
tradeHardMaxOrderEquityRatio = 1.00
tradeLargeOrderConfirmCommandZH = "确认大额 %s"
tradeLargeOrderConfirmCommandEN = "confirm large %s"
)
type tradeSelectedTrader interface {
GetStrategyConfig() *store.StrategyConfig
GetAccountInfo() (map[string]interface{}, error)
}
type tradeUnderlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
GetMarketPrice(symbol string) (float64, error)
}
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
Error string `json:"error,omitempty"`
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
EstimatedPrice float64 `json:"estimated_price,omitempty"`
EstimatedNotional float64 `json:"estimated_notional,omitempty"`
RequiresLargeOrderConfirmation bool `json:"requires_large_order_confirmation,omitempty"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
@@ -149,57 +177,12 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
return fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return fmt.Errorf("no traders configured")
wantStock, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade)
if err != nil {
return err
}
// Determine if this is a stock trade to route to the right exchange
wantStock := isStockSymbol(trade.Symbol)
// Find a running trader's underlying exchange interface
var underlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
}
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
// Route stock symbols to alpaca traders, crypto to others
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue // Skip non-stock traders for stock symbols
}
if !wantStock && isAlpaca {
continue // Skip stock traders for crypto symbols
}
underlyingTrader = ut
break
}
}
if underlyingTrader == nil {
if wantStock {
return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return fmt.Errorf("no running trader supports trade execution")
}
// Sanity caps to prevent LLM hallucinations or input errors from causing damage.
const maxQuantity = 100000.0
const maxLeverage = 125
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage %dx exceeds maximum allowed (%dx)", trade.Leverage, maxLeverage)
if err := validateTradeAction(trade, wantStock, selectedTrader, underlyingTrader); err != nil {
return err
}
switch trade.Action {
@@ -207,18 +190,12 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
@@ -232,6 +209,172 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
}
}
func (a *Agent) resolveTradeExecutionContext(trade *TradeAction) (bool, tradeSelectedTrader, tradeUnderlyingTrader, error) {
if a.traderManager == nil {
return false, nil, nil, fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return false, nil, nil, fmt.Errorf("no traders configured")
}
wantStock := isStockSymbol(trade.Symbol)
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if !running {
continue
}
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue
}
if !wantStock && isAlpaca {
continue
}
return wantStock, t, ut, nil
}
if wantStock {
return true, nil, nil, fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return false, nil, nil, fmt.Errorf("no running trader supports trade execution")
}
func validateTradeAction(
trade *TradeAction,
wantStock bool,
selectedTrader tradeSelectedTrader,
underlyingTrader tradeUnderlyingTrader,
) error {
if trade == nil {
return fmt.Errorf("trade is required")
}
if math.IsNaN(trade.Quantity) || math.IsInf(trade.Quantity, 0) {
return fmt.Errorf("quantity must be a finite number")
}
if !strings.HasPrefix(trade.Action, "open_") {
return nil
}
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > tradeAbsoluteMaxQuantity {
return fmt.Errorf("quantity %.4f exceeds hard sanity cap %.0f", trade.Quantity, tradeAbsoluteMaxQuantity)
}
price, err := underlyingTrader.GetMarketPrice(trade.Symbol)
if err != nil {
return fmt.Errorf("failed to fetch market price for %s: %w", trade.Symbol, err)
}
if price <= 0 {
return fmt.Errorf("invalid market price for %s", trade.Symbol)
}
positionValue := trade.Quantity * price
trade.EstimatedPrice = price
trade.EstimatedNotional = positionValue
if positionValue > tradeHardMaxOrderNotionalUSDT {
return fmt.Errorf("position value %.2f exceeds hard safety cap %.2f USDT", positionValue, tradeHardMaxOrderNotionalUSDT)
}
var equity float64
if selectedTrader != nil {
accountInfo, err := selectedTrader.GetAccountInfo()
if err != nil {
return fmt.Errorf("failed to load trader account info: %w", err)
}
equity = toFloat(accountInfo["total_equity"])
if equity <= 0 {
equity = toFloat(accountInfo["totalEquity"])
}
if equity <= 0 {
return fmt.Errorf("invalid trader equity for risk validation")
}
if positionValue > equity*tradeHardMaxOrderEquityRatio {
return fmt.Errorf(
"position value %.2f USDT exceeds hard safety cap %.2f USDT (equity %.2f x %.2f)",
positionValue,
equity*tradeHardMaxOrderEquityRatio,
equity,
tradeHardMaxOrderEquityRatio,
)
}
if positionValue >= equity*tradeLargeOrderEquityRatio {
trade.RequiresLargeOrderConfirmation = true
}
}
if positionValue >= tradeLargeOrderNotionalUSDT {
trade.RequiresLargeOrderConfirmation = true
}
if wantStock {
if trade.Leverage < 0 {
return fmt.Errorf("leverage must be >= 0")
}
return nil
}
cfg := store.GetDefaultStrategyConfig("zh")
if selectedTrader != nil && selectedTrader.GetStrategyConfig() != nil {
cfg = *selectedTrader.GetStrategyConfig()
}
riskControl := cfg.RiskControl
maxLeverage := riskControl.AltcoinMaxLeverage
maxPositionValueRatio := riskControl.AltcoinMaxPositionValueRatio
if isBTCETHSymbol(trade.Symbol) {
maxLeverage = riskControl.BTCETHMaxLeverage
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
}
if maxLeverage <= 0 {
maxLeverage = 5
}
if trade.Leverage <= 0 {
return fmt.Errorf("leverage must be > 0")
}
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage exceeds configured limit (%dx > %dx)", trade.Leverage, maxLeverage)
}
minPositionSize := riskControl.MinPositionSize
if minPositionSize <= 0 {
minPositionSize = 12
}
if positionValue < minPositionSize {
return fmt.Errorf("position value %.2f USDT is below configured minimum %.2f USDT", positionValue, minPositionSize)
}
if maxPositionValueRatio <= 0 {
if isBTCETHSymbol(trade.Symbol) {
maxPositionValueRatio = 5.0
} else {
maxPositionValueRatio = 1.0
}
}
maxPositionValue := equity * maxPositionValueRatio
if positionValue > maxPositionValue {
return fmt.Errorf(
"position value %.2f USDT exceeds configured limit %.2f USDT (equity %.2f x %.2f)",
positionValue,
maxPositionValue,
equity,
maxPositionValueRatio,
)
}
return nil
}
func isBTCETHSymbol(symbol string) bool {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
@@ -260,6 +403,13 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string {
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
if trade.EstimatedNotional > 0 {
msg += fmt.Sprintf("估算仓位价值: %.2f USDT\n", trade.EstimatedNotional)
}
if trade.RequiresLargeOrderConfirmation {
msg += fmt.Sprintf("\n⚠ 该订单已触发大额风控,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
@@ -273,6 +423,13 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string {
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
if trade.EstimatedNotional > 0 {
msg += fmt.Sprintf("Estimated notional: %.2f USDT\n", trade.EstimatedNotional)
}
if trade.RequiresLargeOrderConfirmation {
msg += fmt.Sprintf("\n⚠ This order triggered high-risk protection. Send `"+tradeLargeOrderConfirmCommandEN+"` to execute, or ignore to cancel.", trade.ID)
return msg
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
@@ -282,7 +439,14 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text,
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
largeConfirm := false
if strings.HasPrefix(upper, "确认大额 ") || strings.HasPrefix(upper, "CONFIRM LARGE ") {
largeConfirm = true
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[len(parts)-1]
}
} else if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
@@ -304,6 +468,12 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text,
}
return "❌ Trade expired or not found.", true
}
if trade.RequiresLargeOrderConfirmation && !largeConfirm {
if lang == "zh" {
return fmt.Sprintf("⚠️ 这是一笔大额订单,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 继续执行。", trade.ID), true
}
return fmt.Sprintf("⚠️ This is a high-risk order. Send `"+tradeLargeOrderConfirmCommandEN+"` to continue.", trade.ID), true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"

2027
agent/trader_scope_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
package agent
import (
"context"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
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 TestParseUnifiedTurnDecisionAcceptsSkillTaskList(t *testing.T) {
raw := `{
"topic_intent": "start_new",
"business_action": "skill_tasks",
"context_mode": "fresh_context",
"tasks": [
{"id":"task_1","skill":"strategy_management","action":"create","request":"创建高频交易策略","depends_on":[]},
{"id":"task_2","skill":"trader_management","action":"configure_strategy","request":"绑定到交易员","depends_on":["task_1"]}
],
"confidence": 0.86
}`
decision, err := parseUnifiedTurnDecision(raw)
if err != nil {
t.Fatalf("parse unified decision: %v", err)
}
if decision.BusinessAction != "skill_tasks" {
t.Fatalf("expected skill_tasks, got %q", decision.BusinessAction)
}
if len(decision.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %+v", decision.Tasks)
}
if decision.Tasks[0].Skill != "strategy_management" || decision.Tasks[0].Action != "create" {
t.Fatalf("unexpected first task: %+v", decision.Tasks[0])
}
if !decision.reliable() {
t.Fatalf("expected task-list decision to be reliable: %+v", decision)
}
}
func TestUnifiedTurnDecisionNewSkillCanUseSingleTask(t *testing.T) {
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
TopicIntent: "start_new",
BusinessAction: "new_skill",
ContextMode: "fresh_context",
Tasks: []WorkflowTask{{
Skill: "strategy_management",
Action: "create",
Request: "创建高频交易策略",
}},
Confidence: 0.9,
})
if !decision.reliable() {
t.Fatalf("expected new_skill with task list 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 TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "continue-active-router.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), nil)
userID := int64(102)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = "创建网格策略"
session.CollectedFields["name"] = "我的网格策略"
session.CollectedFields["strategy_type"] = "grid_trading"
setActiveSessionPendingHint(&session, "现在还需要确认网格交易对、网格数量、总投入、杠杆和价格区间。")
a.saveActiveSkillSession(session)
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
TopicIntent: "continue_active",
BusinessAction: "planned_agent",
ContextMode: "use_current",
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 active session continuation to be handled")
}
if !strings.Contains(answer, "还缺") || !strings.Contains(answer, "交易对") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
t.Fatalf("expected strategy session to continue without planner/trader handoff, got: %s", answer)
}
if _, ok := a.getActiveSkillSession(userID); !ok {
t.Fatalf("expected strategy active session to remain pending")
}
}
func TestGuardUnexecutedActiveTaskCompletionBlocksCreationClaim(t *testing.T) {
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
}
reply, blocked := guardUnexecutedActiveTaskCompletion("zh", session, "已经创建好了。策略现在就在你的策略列表里。")
if !blocked {
t.Fatalf("expected unexecuted active create completion claim to be blocked")
}
if !strings.Contains(reply, "还没有真正创建") {
t.Fatalf("expected honest not-created reply, got: %s", reply)
}
_, blocked = guardUnexecutedActiveTaskCompletion("zh", session, "我建议先用 BTCUSDT 做新手网格策略。")
if blocked {
t.Fatalf("non-completion proposal should not be blocked")
}
}
func TestGuardUnsupportedAsyncPromiseBlocksFakeDiagnosisProgress(t *testing.T) {
reply, blocked := guardUnsupportedAsyncPromise("zh", "诊断还在进行中,请再稍等一下。我马上分析完“小小”的历史交易记录,找到亏损原因后会立刻告诉您。")
if !blocked {
t.Fatal("expected fake async diagnosis progress to be blocked")
}
for _, want := range []string{"没有后台异步任务", "当前回复"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected guarded reply to contain %q, got: %s", want, reply)
}
}
_, blocked = guardUnsupportedAsyncPromise("zh", "我需要策略名称和历史记录范围,才能开始诊断。")
if blocked {
t.Fatal("missing-info diagnosis reply should not be blocked")
}
_, blocked = guardUnsupportedAsyncPromise("zh", "好的,参数已确认,正在为您创建“餐巾纸”网格策略。")
if !blocked {
t.Fatal("expected fake async strategy create progress to be blocked")
}
}
func TestFinishTaskGuardBlocksFakeCreateProgressPromise(t *testing.T) {
reply, blocked := guardUnsupportedAsyncPromise("zh", "策略正在创建中,请稍等一会儿。创建成功后我会立刻告诉您。")
if !blocked {
t.Fatal("expected fake create progress promise to be blocked")
}
if !strings.Contains(reply, "没有后台异步任务") || !strings.Contains(reply, "实际执行") {
t.Fatalf("expected honest execution correction, got: %s", reply)
}
}
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",
"tasks format",
"skill_tasks",
"topic_intent as the primary decision",
} {
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")
}
}

View File

@@ -0,0 +1,3 @@
package agent
const cleanUserFacingReplyInstruction = "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."

View File

@@ -0,0 +1,12 @@
package agent
import "testing"
func TestCleanUserFacingReplyInstruction(t *testing.T) {
if cleanUserFacingReplyInstruction == "" {
t.Fatal("expected clean user-facing reply instruction to be defined")
}
if got, want := cleanUserFacingReplyInstruction, "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."; got != want {
t.Fatalf("unexpected instruction\nwant: %q\ngot: %q", want, got)
}
}

View File

@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -13,6 +14,14 @@ import (
)
type storeUserIDContextKey struct{}
type sessionPolicyContextKey struct{}
type SessionPolicy struct {
Authenticated bool
IsAdmin bool
CanExecuteTrade bool
CanViewSensitiveSecrets bool
}
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
@@ -26,6 +35,17 @@ func storeUserIDFromContext(ctx context.Context) string {
return "default"
}
func WithSessionPolicy(ctx context.Context, policy SessionPolicy) context.Context {
return context.WithValue(ctx, sessionPolicyContextKey{}, policy)
}
func sessionPolicyFromContext(ctx context.Context) SessionPolicy {
if v, ok := ctx.Value(sessionPolicyContextKey{}).(SessionPolicy); ok {
return v
}
return SessionPolicy{}
}
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
@@ -80,7 +100,7 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
@@ -93,7 +113,7 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
if err != nil {
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
writeJSON(rw, 500, map[string]string{"error": "I ran into a problem while handling that message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
@@ -122,7 +142,7 @@ func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
@@ -146,11 +166,21 @@ func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
defer cancel()
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
if ctx.Err() != nil {
return
}
writeSSE(rw, flusher, event, data)
})
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
w.logger.Info("agent stream cancelled", "user_id", req.UserID, "error", err)
return
}
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
writeSSE(rw, flusher, "error", "Failed to process message. Please try again.")
writeSSE(rw, flusher, "error", "I ran into a problem while handling that message. Please try again.")
return
}
if ctx.Err() != nil {
return
}
// Send final done event with complete response

View File

@@ -161,49 +161,50 @@ func supportedWorkflowSkill(skill, action string) bool {
if _, ok := getSkillDAG(skill, action); ok {
return true
}
if def, ok := getSkillDefinition(skill); ok {
if _, ok := def.Actions[action]; ok {
return true
}
}
switch skill {
case "trader_management", "strategy_management", "model_management", "exchange_management":
switch action {
case "create", "query_list", "query_detail", "query_running", "activate":
if action == "query_running" {
return true
}
}
return false
}
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
if err != nil || len(decomposition.Tasks) <= 1 {
return "", false, err
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
if lang == "zh" {
return "已取消当前任务流。", true, nil
}
return "Cancelled the current workflow.", true, nil
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent)
decision, _ := a.resolveSkillSessionTurn(ctx, userID, lang, text, activeSkill)
switch decision.Intent {
case "cancel":
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
case "instant_reply":
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
case "resume_snapshot", "start_new":
if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" {
answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent)
return answer, handled, err
}
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
return "", false, nil
}
answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, text, activeSkill.Name, activeSkill.Action, onEvent)
if !handled {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
session = a.getWorkflowSession(userID)
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
@@ -221,9 +222,78 @@ func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, u
return answer, true, nil
}
if decision := a.classifyWorkflowSessionInput(ctx, userID, lang, session, text); decision.Intent != "" && decision.Intent != "continue_active" {
switch decision.Intent {
case "cancel":
a.clearWorkflowSession(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
case "instant_reply":
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
case "resume_snapshot", "start_new":
if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" {
answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent)
return answer, handled, err
}
a.clearWorkflowSession(userID)
return "", false, nil
}
}
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func (a *Agent) classifyWorkflowSessionInput(ctx context.Context, userID int64, lang string, session WorkflowSession, text string) unifiedFlowDecision {
text = strings.TrimSpace(text)
if text == "" {
return unifiedFlowDecision{Intent: "continue_active"}
}
if isExplicitFlowAbort(text) {
return unifiedFlowDecision{Intent: "cancel"}
}
if isInstantDirectReplyText(text) {
return unifiedFlowDecision{Intent: "instant_reply"}
}
if a == nil || a.aiClient == nil {
if looksLikeNewTopLevelIntent(text) && !strings.EqualFold(text, strings.TrimSpace(session.OriginalRequest)) {
return unifiedFlowDecision{Intent: "start_new"}
}
return unifiedFlowDecision{Intent: "continue_active"}
}
currentTask, _, _ := nextRunnableWorkflowTask(session)
recentConversationCtx := a.buildRecentConversationContext(userID, text)
flowContext := fmt.Sprintf(
"Workflow original request: %s\nCurrent runnable task: %s / %s / %s\nWorkflow tasks JSON: %s",
session.OriginalRequest,
currentTask.Skill,
currentTask.Action,
currentTask.Request,
mustMarshalJSON(session.Tasks),
)
state := a.getExecutionState(userID)
systemPrompt, userPrompt := buildActiveFlowClassifierPrompt(
lang,
"workflow_session",
flowContext,
text,
recentConversationCtx,
state.CurrentReferences,
a.SnapshotManager(userID).List(),
)
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 unifiedFlowDecision{}
}
return unifiedFlowDecisionFromIntent(parseActiveFlowIntentDecision(raw), "")
}
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
task, index, ok := nextRunnableWorkflowTask(session)
if !ok {
@@ -238,7 +308,7 @@ func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, us
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
onEvent(StreamEventDelta, summary)
emitStreamText(onEvent, summary)
}
return summary, true, nil
}
@@ -253,13 +323,14 @@ func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, us
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent)
answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, task.Request, task.Skill, task.Action, onEvent)
if !handled {
session.Tasks[index].Status = workflowTaskFailed
session.Tasks[index].Error = "task_not_handled"
a.saveWorkflowSession(userID, session)
return "", false, nil
}
a.recordSkillInteraction(userID, task.Request, answer)
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = a.getWorkflowSession(userID)
@@ -332,7 +403,8 @@ func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang
defer cancel()
systemPrompt := `You are summarizing a finished workflow for NOFXi.
Return one short user-facing summary in the user's language.
Do not mention internal DAG, scheduler, or JSON.`
Do not mention internal DAG, scheduler, or JSON.
` + cleanUserFacingReplyInstruction
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
@@ -374,24 +446,88 @@ func looksLikeMultiTaskIntent(text string) bool {
count++
}
}
return count > 0
if count > 0 {
return true
}
if looksLikeCompoundStrategyIntent(text) || looksLikeCompoundTraderIntent(text) ||
looksLikeCompoundModelIntent(text) || looksLikeCompoundExchangeIntent(text) {
return true
}
return false
}
func looksLikeCompoundStrategyIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "strategy") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"})
hasConfigUpdate := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasLifecycle := containsAny(lower, []string{"激活", "activate", "复制", "duplicate", "删除", "删了", "删掉", "delete"})
hasMetaUpdate := containsAny(lower, []string{"发布", "公开", "可见", "描述", "改成", "改为"})
return (hasCreate && (hasConfigUpdate || hasLifecycle || hasMetaUpdate)) ||
(hasConfigUpdate && hasLifecycle)
}
func looksLikeCompoundTraderIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasLifecycle := containsAny(lower, []string{"启动", "开始", "start", "停止", "stop"})
return (hasCreate && (hasBindingsOrConfig || hasLifecycle)) ||
(hasBindingsOrConfig && hasLifecycle)
}
func looksLikeCompoundModelIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "model") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "启用", "禁用", "api key"})
hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"})
return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle)
}
func looksLikeCompoundExchangeIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "exchange") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包", "启用", "禁用"})
hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"})
return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle)
}
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You decompose one NOFXi user request into a small task graph.
systemPrompt := `You decompose one NOFXi user request into a small task graph for execution.
Return JSON only. No markdown.
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
Only use one atomic action per task.
You are the action decomposition layer. Split complex requests into atomic management steps and decide dependencies.
Each task must include:
- id
- skill
- action
- request
- depends_on (array, may be empty)
If the request is effectively a single task, return one task only.`
Rules:
- Prefer atomic actions such as create, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail.
- If one request contains create plus follow-up edits in the same skill, split them into multiple tasks.
- If later tasks need an entity created earlier, make the dependency explicit in depends_on.
- Keep each request user-readable and self-contained enough for a single skill handler to execute.
- Do not merge two actions into one task.
- If the request is effectively a single task, return one task only.`
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
if skillContext := buildManagementSkillRoutingContext(lang); skillContext != "" {
userPrompt += "\n\n" + skillContext
}
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
@@ -451,21 +587,256 @@ func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposi
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
for i, segment := range segments {
task, ok := classifyWorkflowTask(segment)
if !ok {
continue
}
task.ID = fmt.Sprintf("task_%d", i+1)
task.Status = workflowTaskPending
nextID := 1
for _, segment := range segments {
prevSkill := ""
if len(tasks) > 0 {
task.DependsOn = []string{tasks[len(tasks)-1].ID}
prevSkill = tasks[len(tasks)-1].Skill
}
compound := classifyCompoundWorkflowTasksWithContext(segment, prevSkill)
if len(compound) == 0 {
task, ok := classifyWorkflowTaskWithContext(segment, prevSkill)
if !ok {
continue
}
compound = []WorkflowTask{task}
}
for i := range compound {
compound[i].ID = fmt.Sprintf("task_%d", nextID)
compound[i].Status = workflowTaskPending
if len(tasks) > 0 && len(compound[i].DependsOn) == 0 {
compound[i].DependsOn = []string{tasks[len(tasks)-1].ID}
}
if i > 0 {
compound[i].DependsOn = []string{compound[i-1].ID}
}
tasks = append(tasks, compound[i])
nextID++
}
tasks = append(tasks, task)
}
return workflowDecomposition{Tasks: tasks}
}
func classifyCompoundWorkflowTasksWithContext(text, previousSkill string) []WorkflowTask {
if tasks := classifyCompoundWorkflowTasks(text); len(tasks) > 1 {
return tasks
}
switch strings.TrimSpace(previousSkill) {
case "strategy_management":
return classifyContextualStrategyWorkflowTasks(text)
case "trader_management":
return classifyContextualTraderWorkflowTasks(text)
}
return nil
}
func classifyCompoundWorkflowTasks(text string) []WorkflowTask {
segment := strings.TrimSpace(text)
if segment == "" {
return nil
}
if tasks := classifyCompoundStrategyWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundTraderWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundModelWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundExchangeWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
return nil
}
func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask {
lower := strings.ToLower(strings.TrimSpace(text))
hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasActivate := containsAny(lower, []string{"激活", "activate"})
hasDuplicate := containsAny(lower, []string{"复制", "duplicate"})
if !hasConfig && !hasActivate && !hasDuplicate {
return nil
}
var tasks []WorkflowTask
if hasConfig {
action := "update_config"
if containsAny(lower, []string{"prompt", "提示词"}) {
action = "update_prompt"
}
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text})
}
if hasActivate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text})
}
if hasDuplicate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text})
}
if len(tasks) == 0 {
return nil
}
return tasks
}
func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask {
lower := strings.ToLower(strings.TrimSpace(text))
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
if !hasUpdate && !hasStart && !hasStop {
return nil
}
var tasks []WorkflowTask
if hasUpdate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
}
if hasStop {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text})
}
if len(tasks) == 0 {
return nil
}
return tasks
}
func classifyWorkflowTaskWithContext(text, previousSkill string) (WorkflowTask, bool) {
if task, ok := classifyWorkflowTask(text); ok {
return task, true
}
switch strings.TrimSpace(previousSkill) {
case "strategy_management":
if tasks := classifyContextualStrategyWorkflowTasks(text); len(tasks) > 0 {
return tasks[0], true
}
case "trader_management":
if tasks := classifyContextualTraderWorkflowTasks(text); len(tasks) > 0 {
return tasks[0], true
}
}
return WorkflowTask{}, false
}
func classifyCompoundStrategyWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "strategy") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasActivate := containsAny(lower, []string{"激活", "activate"})
hasDuplicate := containsAny(lower, []string{"复制", "duplicate"})
if !hasCreate && !hasConfig && !hasActivate && !hasDuplicate {
return nil
}
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "create", Request: text})
}
if hasConfig {
action := "update_config"
if containsAny(lower, []string{"prompt", "提示词"}) {
action = "update_prompt"
}
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text})
}
if hasActivate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text})
}
if hasDuplicate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask {
if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "create", Request: text})
}
if hasUpdate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
}
if hasStop {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundModelWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "model") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "api key"})
hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "create", Request: text})
}
if hasConfig {
action := "update_endpoint"
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: action, Request: text})
}
if hasStatus {
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "update_status", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundExchangeWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "exchange") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"})
hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "create", Request: text})
}
if hasConfig {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_name", Request: text})
}
if hasStatus {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_status", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func splitWorkflowSegments(text string) []string {
parts := []string{strings.TrimSpace(text)}
separators := []string{"", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
@@ -490,27 +861,94 @@ func classifyWorkflowTask(text string) (WorkflowTask, bool) {
if segment == "" {
return WorkflowTask{}, false
}
lower := strings.ToLower(segment)
switch {
case detectCreateTraderSkill(segment):
case hasExplicitCreateIntentForDomain(segment, "trader"):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
case detectTraderManagementIntent(segment):
action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader"))
case hasExplicitManagementDomainCue(segment, "trader"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启动", "开始", "run", "start"}):
action = "start"
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}):
action = "stop"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}):
action = "update_bindings"
case containsAny(lower, []string{"修改", "更新", "改"}):
action = "update_bindings"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("trader_management", action) {
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
}
case detectExchangeManagementIntent(segment):
action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange"))
case hasExplicitManagementDomainCue(segment, "exchange"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}):
action = "update_status"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"}):
action = "update"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("exchange_management", action) {
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
}
case detectModelManagementIntent(segment):
action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model"))
case hasExplicitManagementDomainCue(segment, "model"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}):
action = "update_status"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"接口地址", "endpoint", "url"}):
action = "update_endpoint"
case containsAny(lower, []string{"修改", "更新", "改", "模型名", "api key"}):
action = "update"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("model_management", action) {
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
}
case detectStrategyManagementIntent(segment):
action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy"))
if action == "" && wantsStrategyDetails(segment) {
case hasExplicitManagementDomainCue(segment, "strategy"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"激活", "activate"}):
action = "activate"
case containsAny(lower, []string{"复制", "duplicate"}):
action = "duplicate"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"prompt", "提示词"}):
action = "update_prompt"
case containsAny(lower, []string{"修改", "更新", "改", "参数", "配置"}):
action = "update_config"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}) || hasExplicitStrategyDetailIntent(segment):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if action == "" && hasExplicitStrategyDetailIntent(segment) {
action = "query_detail"
}
if supportedWorkflowSkill("strategy_management", action) {

View File

@@ -1,37 +0,0 @@
package agent
import "testing"
func TestSplitWorkflowSegments(t *testing.T) {
got := splitWorkflowSegments("把策略删了,再把交易所改名")
if len(got) != 2 {
t.Fatalf("expected 2 segments, got %d: %#v", len(got), got)
}
}
func TestClassifyWorkflowTask(t *testing.T) {
task, ok := classifyWorkflowTask("把策略删了")
if !ok {
t.Fatal("expected task")
}
if task.Skill != "strategy_management" || task.Action != "delete" {
t.Fatalf("unexpected task: %+v", task)
}
}
func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) {
a := &Agent{}
out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名")
if len(out.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(out.Tasks))
}
if out.Tasks[0].Skill != "strategy_management" {
t.Fatalf("unexpected first task: %+v", out.Tasks[0])
}
if out.Tasks[1].Skill != "exchange_management" {
t.Fatalf("unexpected second task: %+v", out.Tasks[1])
}
if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID {
t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn)
}
}

View File

@@ -39,6 +39,10 @@ func (s *Server) handleCreateAgentPreference(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
if len([]rune(strings.TrimSpace(req.Text))) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"error": "text too long"})
return
}
created, err := agent.NewPersistentPreference(req.Text)
if err != nil {

View File

@@ -11,11 +11,27 @@ import (
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
isAdmin := c.GetString("user_id") == "admin"
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
Authenticated: true,
IsAdmin: isAdmin,
CanExecuteTrade: true,
CanViewSensitiveSecrets: false,
})
req := c.Request.WithContext(ctx)
h.HandleChat(c.Writer, req)
})
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
isAdmin := c.GetString("user_id") == "admin"
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
Authenticated: true,
IsAdmin: isAdmin,
CanExecuteTrade: true,
CanViewSensitiveSecrets: false,
})
req := c.Request.WithContext(ctx)
h.HandleChatStream(c.Writer, req)
})
// Public endpoints — read-only market data

View File

@@ -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

View File

@@ -10,6 +10,7 @@ import (
"nofx/crypto"
"nofx/logger"
"nofx/security"
"nofx/store"
"nofx/wallet"
"github.com/gin-gonic/gin"
@@ -77,8 +78,11 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
logger.Infof("✅ Found %d AI model configs", len(models))
// Convert to safe response structure, remove sensitive information
safeModels := make([]SafeModelConfig, len(models))
for i, model := range models {
safeModels := make([]SafeModelConfig, 0, len(models))
for _, model := range models {
if !store.IsVisibleAIModel(model) {
continue
}
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
@@ -100,7 +104,23 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
}
}
safeModels[i] = safeModel
safeModels = append(safeModels, safeModel)
}
if len(safeModels) == 0 {
logger.Infof("⚠️ No visible AI models in database, returning defaults")
defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
}
c.JSON(http.StatusOK, defaultModels)
return
}
c.JSON(http.StatusOK, safeModels)
@@ -217,10 +237,12 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
}

View File

@@ -4,10 +4,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
)
@@ -30,11 +32,39 @@ type SafeExchangeConfig struct {
Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid 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)
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)
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
}
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
return SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
HasAPIKey: exchange.APIKey != "",
HasSecretKey: exchange.SecretKey != "",
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
}
}
type UpdateExchangeConfigRequest struct {
@@ -96,21 +126,12 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
logger.Infof("✅ Found %d exchange configs", len(exchanges))
// Convert to safe response structure, remove sensitive information
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
for i, exchange := range exchanges {
safeExchanges[i] = SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
for _, exchange := range exchanges {
if !store.IsVisibleExchange(exchange) {
continue
}
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
}
c.JSON(http.StatusOK, safeExchanges)
@@ -179,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
@@ -271,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,

View File

@@ -0,0 +1,45 @@
package api
import (
"testing"
"nofx/crypto"
"nofx/store"
)
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
cfg := &store.Exchange{
ID: "ex-1",
ExchangeType: "okx",
AccountName: "OKX Main",
Name: "OKX Main",
Type: "cex",
Enabled: true,
APIKey: crypto.EncryptedString("api-test-123"),
SecretKey: crypto.EncryptedString("secret-test-123"),
Passphrase: crypto.EncryptedString("passphrase-test-123"),
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
}
safe := safeExchangeConfigFromStore(cfg)
if !safe.HasAPIKey {
t.Fatalf("expected has_api_key to be true")
}
if !safe.HasSecretKey {
t.Fatalf("expected has_secret_key to be true")
}
if !safe.HasPassphrase {
t.Fatalf("expected has_passphrase to be true")
}
if !safe.HasAsterPrivateKey {
t.Fatalf("expected has_aster_private_key to be true")
}
if !safe.HasLighterPrivateKey {
t.Fatalf("expected has_lighter_private_key to be true")
}
if !safe.HasLighterAPIKey {
t.Fatalf("expected has_lighter_api_key_private_key to be true")
}
}

View File

@@ -14,6 +14,11 @@ import (
"gorm.io/gorm"
)
const (
maxManualBTCETHLeverage = 20
maxManualAltLeverage = 20
)
// AI trader management related structures
type CreateTraderRequest struct {
Name string `json:"name" binding:"required"`
@@ -65,6 +70,16 @@ func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
@@ -306,13 +321,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
return
}
// Validate leverage values
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
return
}
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
// Validate leverage values against the same limits exposed by manual user config.
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
@@ -574,6 +585,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
return
}
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Set default values
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
if req.IsCrossMargin != nil {

View File

@@ -0,0 +1,17 @@
package api
import "testing"
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
}

View File

@@ -259,7 +259,7 @@ CRITICAL: Always use the "id" field for strategy_id.`,
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
StrategyConfig fields:
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short)
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection

View File

@@ -20,6 +20,9 @@ import (
// validateStrategyConfig validates strategy configuration and returns warnings
func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string
if config.StrategyType == "grid_trading" {
return warnings
}
// Validate NofxOS API key if any NofxOS feature is enabled
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
@@ -31,6 +34,17 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
if config == nil || strategy == nil {
return
}
config.ClampLimits()
config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
}
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
@@ -71,6 +85,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
if st.ConfigVisible {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
item["config"] = config
}
@@ -101,6 +116,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
for _, st := range strategies {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
result = append(result, gin.H{
"id": st.ID,
@@ -139,6 +155,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -162,10 +179,12 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -182,6 +201,19 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
beforeClamp := *req.Config
req.Config.ClampLimits()
hadPublishConfig := req.Config.PublishConfig != nil
isPublic := req.IsPublic
configVisible := req.ConfigVisible
if hadPublishConfig {
isPublic = req.Config.PublishConfig.IsPublic
configVisible = req.Config.PublishConfig.ConfigVisible
}
req.Config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: isPublic,
ConfigVisible: configVisible,
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
@@ -197,7 +229,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
Description: req.Description,
IsActive: false,
IsDefault: false,
Config: string(configJSON),
IsPublic: isPublic,
// Existing default is true; keep that behavior when no explicit publish config is sent.
ConfigVisible: configVisible || !hadPublishConfig,
Config: string(configJSON),
}
if err := s.store.Strategy().Create(strategy); err != nil {
@@ -207,6 +242,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
// Validate configuration and collect warnings
warnings := validateStrategyConfig(req.Config)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
response := gin.H{
"id": strategy.ID,
@@ -263,14 +299,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
// Apply incoming config on top while preserving nested fields that were not sent.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
var patch map[string]any
if err := json.Unmarshal(req.Config, &patch); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
if err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
beforeClamp := mergedConfig
mergedConfig.ClampLimits()
// Preserve existing name/description when not supplied
name := req.Name
@@ -324,6 +367,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -417,6 +461,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,

27
main.go
View File

@@ -2,18 +2,18 @@ package main
import (
"log/slog"
"nofx/api"
nofxiagent "nofx/agent"
"nofx/api"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/manager"
"nofx/telemetry"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"nofx/telegram"
"nofx/telemetry"
"os"
"os/signal"
"path/filepath"
@@ -121,10 +121,10 @@ func main() {
status = "✅ Running"
}
idShort := t.ID
if len(idShort) > 8 {
idShort = idShort[:8]
}
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
if len(idShort) > 8 {
idShort = idShort[:8]
}
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
}
}
@@ -137,20 +137,19 @@ func main() {
telegramReloadCh := make(chan struct{}, 1)
server.SetTelegramReloadCh(telegramReloadCh)
// Start the NOFXi web agent on top of the current dev branch services.
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
server.RegisterAgentHandler(agentWeb)
nofxiAgent.Start()
defer nofxiAgent.Stop()
go func() {
if err := server.Start(); err != nil {
logger.Fatalf("❌ Failed to start API server: %v", err)
}
}()
// Start the NOFXi web agent on top of the current dev branch services.
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
nofxiAgent.Start()
defer nofxiAgent.Stop()
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
server.RegisterAgentHandler(agentWeb)
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh)

View File

@@ -32,10 +32,13 @@ var (
"no such host",
"stream error", // HTTP/2 stream error
"INTERNAL_ERROR", // Server internal error
"status 502", // Bad Gateway
"status 503", // Service Unavailable
"status 520", // Cloudflare origin error
"status 524", // Cloudflare timeout
"status 429", // Rate limit / upstream gateway throttling
"rate_limit_error",
"upstream_empty_output",
"status 502", // Bad Gateway
"status 503", // Service Unavailable
"status 520", // Cloudflare origin error
"status 524", // Cloudflare timeout
}
// TokenUsageCallback is called after each AI request with token usage info
@@ -197,7 +200,9 @@ func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string,
if attempt < maxRetries {
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
time.Sleep(waitTime)
if err := sleepWithContext(context.Background(), waitTime); err != nil {
return "", err
}
}
}
@@ -332,6 +337,38 @@ func (client *Client) BuildRequest(url string, jsonData []byte) (*http.Request,
return req, nil
}
func contextFromRequest(req *Request) context.Context {
if req != nil && req.Ctx != nil {
return req.Ctx
}
return context.Background()
}
func (client *Client) buildHTTPRequestWithContext(ctx context.Context, url string, jsonData []byte) (*http.Request, error) {
if ctx == nil {
ctx = context.Background()
}
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
if err != nil {
return nil, err
}
return httpReq.WithContext(ctx), nil
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Call single AI API call (fixed flow, cannot be overridden)
func (client *Client) Call(systemPrompt, userPrompt string) (string, error) {
// Print current AI configuration
@@ -450,7 +487,9 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
if attempt < maxRetries {
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
time.Sleep(waitTime)
if err := sleepWithContext(contextFromRequest(req), waitTime); err != nil {
return "", err
}
}
}
@@ -482,7 +521,9 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
}
if attempt < maxRetries {
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
time.Sleep(waitTime)
if err := sleepWithContext(contextFromRequest(req), waitTime); err != nil {
return nil, err
}
}
}
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
@@ -499,7 +540,7 @@ func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
}
url := client.Hooks.BuildUrl()
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -537,7 +578,7 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
url := client.Hooks.BuildUrl()
client.Log.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
@@ -679,7 +720,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
}
url := client.Hooks.BuildUrl()
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
if err != nil {
return "", err
}
@@ -687,7 +728,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 60 seconds.
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
const idleTimeout = 60 * time.Second
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(contextFromRequest(req))
defer cancel()
resetCh := make(chan struct{}, 1)
go func() {

View File

@@ -345,6 +345,11 @@ func TestClient_IsRetryableError(t *testing.T) {
err: errors.New("connection reset by peer"),
expected: true,
},
{
name: "upstream empty output",
err: errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`),
expected: true,
},
{
name: "normal error",
err: errors.New("bad request"),

View File

@@ -35,13 +35,34 @@ const (
X402Timeout = 5 * time.Minute
)
func x402ContextFromRequest(req *mcp.Request) context.Context {
if req != nil && req.Ctx != nil {
return req.Ctx
}
return context.Background()
}
func x402Sleep(ctx context.Context, d time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// ── Shared x402 types ────────────────────────────────────────────────────────
// X402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
type X402v2PaymentRequired struct {
X402Version int `json:"x402Version"`
X402Version int `json:"x402Version"`
Accepts []X402AcceptOption `json:"accepts"`
Resource *X402Resource `json:"resource"`
Resource *X402Resource `json:"resource"`
}
// X402AcceptOption is a payment option from the x402 v2 header.
@@ -114,16 +135,21 @@ func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
func DoX402Request(
ctx context.Context,
httpClient *http.Client,
buildReqFn func() (*http.Request, error),
signFn X402SignFunc,
providerTag string,
logger mcp.Logger,
) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
req, err := buildReqFn()
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req = req.WithContext(ctx)
resp, err := httpClient.Do(req)
if err != nil {
@@ -157,6 +183,7 @@ func DoX402Request(
if err != nil {
return nil, fmt.Errorf("failed to build retry request: %w", err)
}
req2 = req2.WithContext(ctx)
req2.Header.Set("X-Payment", paymentSig)
req2.Header.Set("Payment-Signature", paymentSig)
@@ -166,7 +193,9 @@ func DoX402Request(
wait := X402RetryBaseWait * time.Duration(attempt)
logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...",
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
time.Sleep(wait)
if err := x402Sleep(ctx, wait); err != nil {
return nil, err
}
continue
}
return nil, fmt.Errorf("failed to send payment retry: %w", err)
@@ -221,7 +250,9 @@ func DoX402Request(
providerTag, resp2.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
}
time.Sleep(wait)
if err := x402Sleep(ctx, wait); err != nil {
return nil, err
}
continue
}
@@ -256,11 +287,15 @@ func DoX402RequestStream(
providerTag string,
logger mcp.Logger,
) (*http.Response, error) {
// Initial request — use background context (no idle timeout yet).
if ctx == nil {
ctx = context.Background()
}
// Initial request also inherits ctx so stage timeouts cancel the 402 handshake.
req, err := buildReqFn()
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req = req.WithContext(ctx)
resp, err := httpClient.Do(req)
if err != nil {
@@ -314,7 +349,9 @@ func DoX402RequestStream(
wait := X402RetryBaseWait * time.Duration(attempt)
logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...",
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
time.Sleep(wait)
if err := x402Sleep(ctx, wait); err != nil {
return nil, err
}
continue
}
return nil, fmt.Errorf("failed to send payment retry: %w", err)
@@ -369,7 +406,9 @@ func DoX402RequestStream(
providerTag, resp2.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
}
time.Sleep(wait)
if err := x402Sleep(ctx, wait); err != nil {
return nil, err
}
continue
}
@@ -500,7 +539,7 @@ func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, user
return "", err
}
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
if err != nil {
@@ -526,7 +565,7 @@ func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Reque
return nil, err
}
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
if err != nil {

View File

@@ -1,8 +1,13 @@
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// ============================================================
@@ -342,6 +347,110 @@ func TestClient_CallWithRequest_Success(t *testing.T) {
}
}
func TestClient_CallWithRequest_AttachesRequestContextToHTTP(t *testing.T) {
type contextKey string
const key contextKey = "stage"
ctx := context.WithValue(context.Background(), key, "planner")
mockHTTP := NewMockHTTPClient()
mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) {
if req.Context().Value(key) != "planner" {
t.Fatalf("expected HTTP request to inherit mcp.Request context")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
Header: make(http.Header),
}, nil
}
client := NewClient(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(NewMockLogger()),
WithAPIKey("sk-test-key"),
)
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
request.Ctx = ctx
result, err := client.CallWithRequest(request)
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "ok" {
t.Fatalf("expected ok, got %q", result)
}
}
func TestClient_CallWithRequest_RetrySleepStopsWhenContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
mockHTTP := NewMockHTTPClient()
mockHTTP.SetNetworkError(io.EOF)
client := NewClient(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(NewMockLogger()),
WithAPIKey("sk-test-key"),
WithMaxRetries(2),
WithRetryWaitBase(time.Hour),
)
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
request.Ctx = ctx
start := time.Now()
_, err := client.CallWithRequest(request)
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("expected context canceled during retry wait, got %v", err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("retry sleep did not respect context cancellation, elapsed=%v", elapsed)
}
if got := len(mockHTTP.GetRequests()); got != 1 {
t.Fatalf("expected no retry after context cancellation, got %d requests", got)
}
}
func TestClient_CallWithRequest_RetriesUpstreamEmptyOutput(t *testing.T) {
mockHTTP := NewMockHTTPClient()
attempts := 0
mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
body := `{"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok after retry"}}]}`)),
Header: make(http.Header),
}, nil
}
client := NewClient(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(NewMockLogger()),
WithAPIKey("sk-test-key"),
WithMaxRetries(2),
WithRetryWaitBase(time.Millisecond),
)
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
result, err := client.CallWithRequest(request)
if err != nil {
t.Fatalf("should retry upstream empty output and succeed: %v", err)
}
if result != "ok after retry" {
t.Fatalf("expected retry result, got %q", result)
}
if attempts != 2 {
t.Fatalf("expected 2 attempts, got %d", attempts)
}
}
func TestClient_CallWithRequest_MultiRound(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("Multi-round response")

View File

@@ -98,6 +98,7 @@ func (c *Claw402DataClient) DoRequest(endpoint string) ([]byte, error) {
signFn := payment.MakeClaw402SignFunc(c.privateKey)
body, err := payment.DoX402Request(
context.Background(),
c.httpClient,
buildReq,
signFn,

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"nofx/crypto"
"nofx/logger"
"os"
"strings"
"time"
@@ -18,16 +19,16 @@ type AIModelStore struct {
// AIModel AI model configuration
type AIModel struct {
ID string `gorm:"primaryKey" json:"id"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Provider string `gorm:"not null" json:"provider"`
Enabled bool `gorm:"default:false" json:"enabled"`
ID string `gorm:"primaryKey" json:"id"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Provider string `gorm:"not null" json:"provider"`
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AIModel) TableName() string { return "ai_models" }
@@ -145,32 +146,64 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
}
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
var model AIModel
var models []AIModel
err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
Order("updated_at DESC, id ASC").
First(&model).Error
Find(&models).Error
if err != nil {
return nil, err
}
return &model, nil
for i := range models {
if hasUsableAPIKey(models[i]) {
return &models[i], nil
}
}
return nil, gorm.ErrRecordNotFound
}
// GetAnyEnabled returns the first enabled AI model across all users.
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
var model AIModel
err := s.db.Where("enabled = ? AND api_key != ''", true).
var models []AIModel
err := s.db.Where("enabled = ?", true).
Order("updated_at DESC, id ASC").
First(&model).Error
Find(&models).Error
if err != nil {
return nil, err
}
return &model, nil
for i := range models {
if hasUsableAPIKey(models[i]) {
return &models[i], nil
}
}
return nil, gorm.ErrRecordNotFound
}
func hasUsableAPIKey(model AIModel) bool {
if strings.TrimSpace(string(model.APIKey)) != "" {
return true
}
envKeyByProvider := map[string]string{
"deepseek": "DEEPSEEK_API_KEY",
"openai": "OPENAI_API_KEY",
"claude": "ANTHROPIC_API_KEY",
"gemini": "GEMINI_API_KEY",
"grok": "XAI_API_KEY",
"kimi": "MOONSHOT_API_KEY",
"minimax": "MINIMAX_API_KEY",
"qwen": "DASHSCOPE_API_KEY",
}
envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))]
return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != ""
}
// Update updates AI model, creates if not exists
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
return s.UpdateWithName(userID, id, "", enabled, apiKey, customAPIURL, customModelName)
}
func (s *AIModelStore) UpdateWithName(userID, id, name string, enabled bool, apiKey, customAPIURL, customModelName string) error {
// Try exact ID match first
var existingModel AIModel
err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error
@@ -182,6 +215,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"custom_model_name": customModelName,
"updated_at": time.Now().UTC(),
}
if strings.TrimSpace(name) != "" {
updates["name"] = strings.TrimSpace(name)
}
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey)
@@ -200,6 +236,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"custom_model_name": customModelName,
"updated_at": time.Now().UTC(),
}
if strings.TrimSpace(name) != "" {
updates["name"] = strings.TrimSpace(name)
}
if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey)
}
@@ -218,31 +257,35 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
}
}
// Try to get name from existing model with same provider
// Try to get a sensible default name from an existing model with the same provider.
var refModel AIModel
var name string
defaultName := ""
if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil {
name = refModel.Name
defaultName = refModel.Name
} else {
if provider == "deepseek" {
name = "DeepSeek AI"
defaultName = "DeepSeek AI"
} else if provider == "qwen" {
name = "Qwen AI"
defaultName = "Qwen AI"
} else {
name = provider + " AI"
defaultName = provider + " AI"
}
}
finalName := strings.TrimSpace(name)
if finalName == "" {
finalName = strings.TrimSpace(defaultName)
}
newModelID := id
if id == provider {
newModelID = fmt.Sprintf("%s_%s", userID, provider)
}
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, finalName)
newModel := &AIModel{
ID: newModelID,
UserID: userID,
Name: name,
Name: finalName,
Provider: provider,
Enabled: enabled,
APIKey: crypto.EncryptedString(apiKey),

View File

@@ -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,

View File

@@ -17,10 +17,25 @@ const (
MaxTimeframes = 4
MinKlineCount = 10
MaxKlineCount = 30
MinLeverage = 1
MaxBTCETHLeverage = 20
MaxAltLeverage = 20
MinPositionRatio = 0.5
MaxPositionRatio = 10.0
MinRiskReward = 1.0
MaxRiskReward = 10.0
MinMarginUsage = 0.1
MaxMarginUsage = 1.0
MinPositionSize = 10.0
MaxPositionSize = 1000.0
MinConfidence = 50
MaxConfidence = 100
)
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
func (c *StrategyConfig) ClampLimits() {
c.NormalizeProductSchema()
// Clamp coin source limits
if c.CoinSource.AI500Limit > MaxCandidateCoins {
c.CoinSource.AI500Limit = MaxCandidateCoins
@@ -54,10 +69,426 @@ func (c *StrategyConfig) ClampLimits() {
}
// Clamp max positions
if c.RiskControl.MaxPositions < 1 {
c.RiskControl.MaxPositions = 1
}
if c.RiskControl.MaxPositions > MaxPositions {
c.RiskControl.MaxPositions = MaxPositions
}
// Clamp leverage limits to the same bounds as the manual config UI.
if c.RiskControl.BTCETHMaxLeverage < MinLeverage {
c.RiskControl.BTCETHMaxLeverage = MinLeverage
}
if c.RiskControl.BTCETHMaxLeverage > MaxBTCETHLeverage {
c.RiskControl.BTCETHMaxLeverage = MaxBTCETHLeverage
}
if c.RiskControl.AltcoinMaxLeverage < MinLeverage {
c.RiskControl.AltcoinMaxLeverage = MinLeverage
}
if c.RiskControl.AltcoinMaxLeverage > MaxAltLeverage {
c.RiskControl.AltcoinMaxLeverage = MaxAltLeverage
}
// Clamp position value ratio limits.
if c.RiskControl.BTCETHMaxPositionValueRatio < MinPositionRatio {
c.RiskControl.BTCETHMaxPositionValueRatio = MinPositionRatio
}
if c.RiskControl.BTCETHMaxPositionValueRatio > MaxPositionRatio {
c.RiskControl.BTCETHMaxPositionValueRatio = MaxPositionRatio
}
if c.RiskControl.AltcoinMaxPositionValueRatio < MinPositionRatio {
c.RiskControl.AltcoinMaxPositionValueRatio = MinPositionRatio
}
if c.RiskControl.AltcoinMaxPositionValueRatio > MaxPositionRatio {
c.RiskControl.AltcoinMaxPositionValueRatio = MaxPositionRatio
}
// Clamp risk parameters and entry requirements.
if c.RiskControl.MinRiskRewardRatio < MinRiskReward {
c.RiskControl.MinRiskRewardRatio = MinRiskReward
}
if c.RiskControl.MinRiskRewardRatio > MaxRiskReward {
c.RiskControl.MinRiskRewardRatio = MaxRiskReward
}
if c.RiskControl.MaxMarginUsage < MinMarginUsage {
c.RiskControl.MaxMarginUsage = MinMarginUsage
}
if c.RiskControl.MaxMarginUsage > MaxMarginUsage {
c.RiskControl.MaxMarginUsage = MaxMarginUsage
}
if c.RiskControl.MinPositionSize < MinPositionSize {
c.RiskControl.MinPositionSize = MinPositionSize
}
if c.RiskControl.MinPositionSize > MaxPositionSize {
c.RiskControl.MinPositionSize = MaxPositionSize
}
if c.RiskControl.MinConfidence < MinConfidence {
c.RiskControl.MinConfidence = MinConfidence
}
if c.RiskControl.MinConfidence > MaxConfidence {
c.RiskControl.MinConfidence = MaxConfidence
}
}
// NormalizeProductSchema keeps saved strategy JSON aligned with the product
// editor schema. LLMs may emit user-facing labels such as "AI500"; persistence
// must use the exact frontend/backend enum values.
func (c *StrategyConfig) NormalizeProductSchema() {
c.StrategyType = normalizeStrategyType(c.StrategyType)
c.CoinSource.SourceType = normalizeCoinSourceType(c.CoinSource.SourceType)
if c.CoinSource.SourceType == "" {
c.CoinSource.SourceType = inferCoinSourceType(c.CoinSource)
}
switch c.CoinSource.SourceType {
case "ai500":
c.CoinSource.UseAI500 = true
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
if c.CoinSource.AI500Limit <= 0 {
c.CoinSource.AI500Limit = 3
}
case "oi_top":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = true
c.CoinSource.UseOILow = false
if c.CoinSource.OITopLimit <= 0 {
c.CoinSource.OITopLimit = 3
}
case "oi_low":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = true
if c.CoinSource.OILowLimit <= 0 {
c.CoinSource.OILowLimit = 3
}
case "static":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
default:
c.CoinSource.SourceType = "ai500"
c.CoinSource.UseAI500 = true
if c.CoinSource.AI500Limit <= 0 {
c.CoinSource.AI500Limit = 3
}
}
c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins)
c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins)
c.Indicators.Klines.PrimaryTimeframe = normalizeTimeframe(c.Indicators.Klines.PrimaryTimeframe)
c.Indicators.Klines.LongerTimeframe = normalizeTimeframe(c.Indicators.Klines.LongerTimeframe)
c.Indicators.Klines.SelectedTimeframes = normalizeTimeframes(c.Indicators.Klines.SelectedTimeframes)
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
c.Indicators.Klines.EnableMultiTimeframe = true
}
}
func normalizeStrategyType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "网格", "网格策略", "网格交易":
return "grid_trading"
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai策略", "ai 策略", "ai交易策略", "ai智能策略":
return "ai_trading"
default:
return value
}
}
func normalizeCoinSourceType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "数据源", "", "选币", "", "币种", "").Replace(value)
switch {
case compact == "":
return ""
case strings.Contains(compact, "ai500"):
return "ai500"
case strings.Contains(compact, "oitop") || strings.Contains(value, "oi top") || strings.Contains(value, "持仓量最高") || strings.Contains(value, "持仓量靠前"):
return "oi_top"
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"):
return "oi_low"
case strings.Contains(value, "static") || strings.Contains(value, "固定") || strings.Contains(value, "静态"):
return "static"
default:
return value
}
}
func inferCoinSourceType(source CoinSourceConfig) string {
switch {
case len(source.StaticCoins) > 0:
return "static"
case source.UseAI500:
return "ai500"
case source.UseOITop:
return "oi_top"
case source.UseOILow:
return "oi_low"
default:
return "ai500"
}
}
func normalizeSymbols(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range splitLooseStringList(values) {
value = strings.ToUpper(strings.TrimSpace(value))
value = strings.Trim(value, ",; ")
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func normalizeTimeframes(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range splitLooseStringList(values) {
tf := normalizeTimeframe(value)
if tf == "" || seen[tf] {
continue
}
seen[tf] = true
out = append(out, tf)
}
return out
}
func splitLooseStringList(values []string) []string {
if len(values) == 0 {
return nil
}
joined := strings.TrimSpace(strings.Join(values, ","))
if strings.HasPrefix(joined, "[") && strings.HasSuffix(joined, "]") {
var parsed []string
if err := json.Unmarshal([]byte(joined), &parsed); err == nil {
return parsed
}
}
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
var parsed []string
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
parts = append(parts, parsed...)
continue
}
}
value = strings.Trim(value, "[]")
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '' || r == ';' || r == '' || r == '\n'
}) {
part = strings.Trim(strings.TrimSpace(part), "\"'")
if part != "" {
parts = append(parts, part)
}
}
}
return parts
}
func normalizeTimeframe(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.Trim(value, "\"',。 ")
if value == "" {
return ""
}
aliases := map[string]string{
"1分钟": "1m",
"3分钟": "3m",
"5分钟": "5m",
"15分钟": "15m",
"30分钟": "30m",
"1小时": "1h",
"2小时": "2h",
"4小时": "4h",
"6小时": "6h",
"8小时": "8h",
"12小时": "12h",
"1天": "1d",
"3天": "3d",
"1周": "1w",
}
if alias, ok := aliases[value]; ok {
return alias
}
allowed := map[string]bool{
"1m": true, "3m": true, "5m": true, "15m": true, "30m": true,
"1h": true, "2h": true, "4h": true, "6h": true, "8h": true, "12h": true,
"1d": true, "3d": true, "1w": true,
}
if !allowed[value] {
return ""
}
return value
}
// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config.
// Nested objects are merged recursively so omitted fields keep their previous values.
func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) {
baseJSON, err := json.Marshal(base)
if err != nil {
return StrategyConfig{}, err
}
var mergedMap map[string]any
if err := json.Unmarshal(baseJSON, &mergedMap); err != nil {
return StrategyConfig{}, err
}
normalizeStrategyConfigPatch(patch)
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
ensureDefaultGridConfigMap(mergedMap)
}
mergeJSONMaps(mergedMap, patch)
mergedJSON, err := json.Marshal(mergedMap)
if err != nil {
return StrategyConfig{}, err
}
var merged StrategyConfig
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
return StrategyConfig{}, err
}
return merged, nil
}
func DefaultGridStrategyConfig() GridStrategyConfig {
return GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 10,
TotalInvestment: 1000,
Leverage: 5,
UpperPrice: 0,
LowerPrice: 0,
UseATRBounds: true,
ATRMultiplier: 2.0,
Distribution: "gaussian",
MaxDrawdownPct: 15,
StopLossPct: 5,
DailyLossLimitPct: 10,
UseMakerOnly: true,
EnableDirectionAdjust: false,
DirectionBiasRatio: 0.7,
}
}
func ensureDefaultGridConfigMap(config map[string]any) {
if config == nil {
return
}
if existing, ok := config["grid_config"].(map[string]any); ok && len(existing) > 0 {
return
}
defaultGrid := DefaultGridStrategyConfig()
raw, err := json.Marshal(defaultGrid)
if err != nil {
return
}
var gridMap map[string]any
if err := json.Unmarshal(raw, &gridMap); err != nil {
return
}
config["grid_config"] = gridMap
}
func normalizeStrategyConfigPatch(patch map[string]any) {
if patch == nil {
return
}
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
if _, hasType := patch["strategy_type"]; !hasType {
patch["strategy_type"] = "grid_trading"
}
}
aiKeys := []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"}
for _, key := range aiKeys {
value, ok := patch[key]
if !ok {
continue
}
aiConfig, _ := patch["ai_config"].(map[string]any)
if aiConfig == nil {
aiConfig = map[string]any{}
patch["ai_config"] = aiConfig
}
aiConfig[key] = value
delete(patch, key)
}
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
delete(patch, "ai_config")
}
if _, hasType := patch["strategy_type"]; hasType {
return
}
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
patch["strategy_type"] = "grid_trading"
}
}
func mergeJSONMaps(dst, src map[string]any) {
for key, srcVal := range src {
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeJSONMaps(dstMap, srcMap)
continue
}
dst[key] = srcVal
}
}
func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
if lang != "zh" {
lang = "en"
}
warnings := make([]string, 0, 8)
appendInt := func(labelZH, labelEN string, from, to int) {
if from == to {
return
}
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to))
return
}
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to))
}
appendFloat := func(labelZH, labelEN string, from, to float64) {
if from == to {
return
}
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to))
return
}
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to))
}
appendInt("最大持仓数", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
appendInt("BTC/ETH 最大杠杆", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
appendInt("山寨币最大杠杆", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
appendFloat("BTC/ETH 最大仓位价值倍数", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
appendFloat("山寨币最大仓位价值倍数", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
appendFloat("最小盈亏比", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
appendFloat("最大保证金使用率", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
appendFloat("最小开仓金额", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
appendInt("最低置信度", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence)
return warnings
}
// StrategyStore strategy storage
@@ -90,19 +521,128 @@ type StrategyConfig struct {
// language setting: "zh" for Chinese, "en" for English
// This determines the language used for data formatting and prompt generation
Language string `json:"language,omitempty"`
// coin source configuration
CoinSource CoinSourceConfig `json:"coin_source"`
// quantitative data configuration
Indicators IndicatorConfig `json:"indicators"`
// custom prompt (appended at the end)
CustomPrompt string `json:"custom_prompt,omitempty"`
// risk control configuration
RiskControl RiskControlConfig `json:"risk_control"`
// editable sections of System Prompt
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
// AI trading configuration fields are kept on the Go struct for engine
// compatibility, but JSON persistence nests them under ai_config.
CoinSource CoinSourceConfig `json:"-"`
Indicators IndicatorConfig `json:"-"`
CustomPrompt string `json:"-"`
RiskControl RiskControlConfig `json:"-"`
PromptSections PromptSectionsConfig `json:"-"`
// Grid trading configuration (only used when StrategyType == "grid_trading")
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
// Publish settings are shared by AI and grid strategies. The database still
// stores the authoritative booleans on Strategy, but config JSON may carry
// this object for agent/frontend schema consistency.
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
}
// AIStrategyConfig contains fields only used by AI trading strategies.
type AIStrategyConfig struct {
CoinSource CoinSourceConfig `json:"coin_source"`
Indicators IndicatorConfig `json:"indicators"`
CustomPrompt string `json:"custom_prompt,omitempty"`
RiskControl RiskControlConfig `json:"risk_control"`
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
}
// PublishStrategyConfig contains settings shared by all strategy types.
type PublishStrategyConfig struct {
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
// MarshalJSON writes the product-facing strategy schema:
// strategy_type + grid_config or ai_config + shared publish_config.
func (c StrategyConfig) MarshalJSON() ([]byte, error) {
strategyType := strings.TrimSpace(c.StrategyType)
if strategyType == "" {
strategyType = "ai_trading"
}
out := struct {
StrategyType string `json:"strategy_type"`
Language string `json:"language,omitempty"`
AIConfig *AIStrategyConfig `json:"ai_config,omitempty"`
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
}{
StrategyType: strategyType,
Language: c.Language,
PublishConfig: c.PublishConfig,
}
if strategyType == "grid_trading" {
out.GridConfig = c.GridConfig
} else {
out.AIConfig = &AIStrategyConfig{
CoinSource: c.CoinSource,
Indicators: c.Indicators,
CustomPrompt: c.CustomPrompt,
RiskControl: c.RiskControl,
PromptSections: c.PromptSections,
}
}
return json.Marshal(out)
}
// UnmarshalJSON accepts both the new nested schema and old flat configs. Old
// top-level AI fields are normalized into the Go compatibility fields.
func (c *StrategyConfig) UnmarshalJSON(data []byte) error {
type rawStrategyConfig struct {
StrategyType string `json:"strategy_type"`
Language string `json:"language"`
AIConfig *AIStrategyConfig `json:"ai_config"`
GridConfig *GridStrategyConfig `json:"grid_config"`
PublishConfig *PublishStrategyConfig `json:"publish_config"`
CoinSource *CoinSourceConfig `json:"coin_source"`
Indicators *IndicatorConfig `json:"indicators"`
CustomPrompt *string `json:"custom_prompt"`
RiskControl *RiskControlConfig `json:"risk_control"`
PromptSections *PromptSectionsConfig `json:"prompt_sections"`
}
var raw rawStrategyConfig
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
c.StrategyType = raw.StrategyType
c.Language = raw.Language
c.GridConfig = raw.GridConfig
c.PublishConfig = raw.PublishConfig
if raw.AIConfig != nil {
c.CoinSource = raw.AIConfig.CoinSource
c.Indicators = raw.AIConfig.Indicators
c.CustomPrompt = raw.AIConfig.CustomPrompt
c.RiskControl = raw.AIConfig.RiskControl
c.PromptSections = raw.AIConfig.PromptSections
} else {
if raw.CoinSource != nil {
c.CoinSource = *raw.CoinSource
}
if raw.Indicators != nil {
c.Indicators = *raw.Indicators
}
if raw.CustomPrompt != nil {
c.CustomPrompt = *raw.CustomPrompt
}
if raw.RiskControl != nil {
c.RiskControl = *raw.RiskControl
}
if raw.PromptSections != nil {
c.PromptSections = *raw.PromptSections
}
}
if strings.TrimSpace(c.StrategyType) == "" && c.GridConfig != nil {
c.StrategyType = "grid_trading"
}
return nil
}
// GridStrategyConfig grid trading specific configuration
@@ -153,7 +693,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
// source type shown in the product editor: "static" | "ai500" | "oi_top" | "oi_low"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -850,16 +1390,6 @@ func (c *StrategyConfig) getEffectiveCoinCount() int {
count = c.CoinSource.OITopLimit
case "oi_low":
count = c.CoinSource.OILowLimit
case "mixed":
if c.CoinSource.UseAI500 {
count += c.CoinSource.AI500Limit
}
if c.CoinSource.UseOITop {
count += c.CoinSource.OITopLimit
}
if c.CoinSource.UseOILow {
count += c.CoinSource.OILowLimit
}
default:
count = c.CoinSource.AI500Limit
}

View File

@@ -0,0 +1,124 @@
package store
import (
"encoding/json"
"testing"
)
func TestStrategyConfigMarshalSeparatesGridAndAIConfig(t *testing.T) {
cfg := GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 20,
TotalInvestment: 200,
Leverage: 2,
UseATRBounds: true,
ATRMultiplier: 2,
Distribution: "uniform",
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal grid config: %v", err)
}
var asMap map[string]any
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal grid config map: %v", err)
}
if asMap["strategy_type"] != "grid_trading" {
t.Fatalf("expected grid strategy_type, got %v", asMap["strategy_type"])
}
if _, ok := asMap["grid_config"]; !ok {
t.Fatalf("expected grid_config in grid strategy JSON: %s", string(raw))
}
for _, key := range []string{"ai_config", "coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} {
if _, ok := asMap[key]; ok {
t.Fatalf("did not expect %s in grid strategy JSON: %s", key, string(raw))
}
}
}
func TestStrategyConfigUnmarshalLegacyFlatAIConfig(t *testing.T) {
raw := []byte(`{
"strategy_type":"ai_trading",
"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},
"indicators":{"klines":{"primary_timeframe":"15m"}},
"risk_control":{"max_positions":2,"min_confidence":80},
"prompt_sections":{"entry_standards":"trend only"},
"custom_prompt":"prefer ETH"
}`)
var cfg StrategyConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("unmarshal legacy flat config: %v", err)
}
if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "ETHUSDT" {
t.Fatalf("legacy coin source was not normalized: %+v", cfg.CoinSource)
}
if cfg.Indicators.Klines.PrimaryTimeframe != "15m" {
t.Fatalf("legacy indicators were not normalized: %+v", cfg.Indicators.Klines)
}
normalized, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal normalized config: %v", err)
}
var asMap map[string]any
if err := json.Unmarshal(normalized, &asMap); err != nil {
t.Fatalf("unmarshal normalized map: %v", err)
}
if _, ok := asMap["ai_config"]; !ok {
t.Fatalf("expected ai_config after normalizing legacy config: %s", string(normalized))
}
if _, ok := asMap["coin_source"]; ok {
t.Fatalf("did not expect legacy coin_source at top level: %s", string(normalized))
}
}
func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) {
cfg := GetDefaultStrategyConfig("zh")
patch := map[string]any{
"strategy_type": "AI 策略",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": "AI500",
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "1分钟",
"selected_timeframes": []any{`["1m"`, `"5m"`, `"15m"]`},
},
},
},
}
merged, err := MergeStrategyConfig(cfg, patch)
if err != nil {
t.Fatalf("merge strategy config: %v", err)
}
merged.ClampLimits()
if merged.StrategyType != "ai_trading" {
t.Fatalf("strategy_type = %q, want ai_trading", merged.StrategyType)
}
if merged.CoinSource.SourceType != "ai500" {
t.Fatalf("source_type = %q, want ai500", merged.CoinSource.SourceType)
}
if !merged.CoinSource.UseAI500 || merged.CoinSource.UseOITop || merged.CoinSource.UseOILow {
t.Fatalf("coin source flags not normalized: %+v", merged.CoinSource)
}
if merged.Indicators.Klines.PrimaryTimeframe != "1m" {
t.Fatalf("primary_timeframe = %q, want 1m", merged.Indicators.Klines.PrimaryTimeframe)
}
want := []string{"1m", "5m", "15m"}
if len(merged.Indicators.Klines.SelectedTimeframes) != len(want) {
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
}
for i := range want {
if merged.Indicators.Klines.SelectedTimeframes[i] != want[i] {
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
}
}
}

View File

@@ -110,12 +110,20 @@ func (s *TraderStore) Update(trader *Trader) error {
trader.ID, trader.Name, trader.AIModelID, trader.StrategyID)
updates := map[string]interface{}{
"name": trader.Name,
"ai_model_id": trader.AIModelID,
"exchange_id": trader.ExchangeID,
"strategy_id": trader.StrategyID,
"is_cross_margin": trader.IsCrossMargin,
"show_in_competition": trader.ShowInCompetition,
"name": trader.Name,
"ai_model_id": trader.AIModelID,
"exchange_id": trader.ExchangeID,
"strategy_id": trader.StrategyID,
"is_cross_margin": trader.IsCrossMargin,
"show_in_competition": trader.ShowInCompetition,
"btc_eth_leverage": trader.BTCETHLeverage,
"altcoin_leverage": trader.AltcoinLeverage,
"trading_symbols": trader.TradingSymbols,
"use_coin_pool": trader.UseAI500,
"use_oi_top": trader.UseOITop,
"custom_prompt": trader.CustomPrompt,
"override_base_prompt": trader.OverrideBasePrompt,
"system_prompt_template": trader.SystemPromptTemplate,
}
// Only update these if > 0

96
store/visibility.go Normal file
View File

@@ -0,0 +1,96 @@
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
}
return model.Enabled ||
strings.TrimSpace(string(model.APIKey)) != "" ||
strings.TrimSpace(model.CustomAPIURL) != "" ||
strings.TrimSpace(model.CustomModelName) != ""
}
func IsVisibleExchange(exchange *Exchange) bool {
if exchange == nil {
return false
}
return exchange.Enabled ||
strings.TrimSpace(string(exchange.APIKey)) != "" ||
strings.TrimSpace(string(exchange.SecretKey)) != "" ||
strings.TrimSpace(string(exchange.Passphrase)) != "" ||
strings.TrimSpace(exchange.HyperliquidWalletAddr) != "" ||
strings.TrimSpace(exchange.AsterUser) != "" ||
strings.TrimSpace(exchange.AsterSigner) != "" ||
strings.TrimSpace(string(exchange.AsterPrivateKey)) != "" ||
strings.TrimSpace(exchange.LighterWalletAddr) != "" ||
strings.TrimSpace(string(exchange.LighterPrivateKey)) != "" ||
strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)) != "" ||
exchange.LighterAPIKeyIndex != 0
}
func IsVisibleTrader(trader *Trader) bool {
if trader == nil {
return false
}
return strings.TrimSpace(trader.Name) != "" &&
strings.TrimSpace(trader.AIModelID) != "" &&
strings.TrimSpace(trader.ExchangeID) != ""
}
func IsVisibleStrategy(strategy *Strategy) bool {
if strategy == nil {
return false
}
return strings.TrimSpace(strategy.Name) != ""
}

View File

@@ -1,9 +1,4 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
import type { AgentStep } from '../../types/agent'
interface AgentStepPanelProps {
steps?: AgentStep[]
@@ -23,6 +18,16 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
return null
}
const sanitizedSteps = steps.filter((step) => {
const label = step.label.trim().toLowerCase()
const detail = (step.detail || '').trim().toLowerCase()
return !(label.startsWith('tool:') || detail === 'central_brain')
})
if (sanitizedSteps.length === 0) {
return null
}
return (
<div
style={{
@@ -46,7 +51,7 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
{sanitizedSteps.map((step) => {
const style = statusStyles[step.status]
return (
<div

View File

@@ -1,5 +1,12 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
import {
useRef,
useState,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react'
import { ArrowUp, Square } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
@@ -10,43 +17,60 @@ export interface ChatInputHandle {
interface ChatInputProps {
language: string
loading: boolean
value: string
onChange: (value: string) => void
onSend: (text: string) => void
onStop: () => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
function ChatInput(
{ language, loading, value, onChange, onSend, onStop },
ref
) {
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => input,
}))
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => value,
}),
[onChange, value]
)
const resizeInput = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}, [])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
onChange(e.target.value)
},
[]
[onChange]
)
const handleSend = () => {
const msg = input.trim()
const msg = value.trim()
if (!msg || loading) return
setInput('')
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
useEffect(() => {
resizeInput()
}, [resizeInput, value])
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -84,7 +108,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
>
<textarea
ref={inputRef}
value={input}
value={value}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
@@ -115,26 +139,40 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
onClick={loading ? onStop : handleSend}
disabled={!loading && !value.trim()}
title={
loading
? language === 'zh'
? '停止当前回复'
: 'Stop current response'
: language === 'zh'
? '发送'
: 'Send'
}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
background: loading
? 'rgba(239,68,68,0.16)'
: !value.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
color: loading ? '#f87171' : !value.trim() ? '#3c3c52' : '#000',
cursor: !loading && !value.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
{loading ? (
<Square size={13} strokeWidth={2.6} fill="currentColor" />
) : (
<ArrowUp size={16} strokeWidth={2.5} />
)}
</button>
</div>
<div

View File

@@ -2,22 +2,7 @@ import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface Message {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
import type { AgentMessage as Message, AgentStep } from '../../types/agent'
interface ChatMessagesProps {
messages: Message[]
@@ -25,7 +10,14 @@ interface ChatMessagesProps {
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
return steps.some((step) => {
const label = step.label.trim().toLowerCase()
const detail = (step.detail || '').trim().toLowerCase()
if (label.startsWith('tool:') || detail === 'central_brain') {
return false
}
return step.status !== 'planning'
})
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(

Some files were not shown because too many files have changed in this diff Show More