26 Commits

Author SHA1 Message Date
tinkle-community
ea74f91de2 Update README demo video 2026-06-30 23:05:00 +08:00
tinkle-community
a6884998b3 docs: embed inline quick-demo video in READMEs
Replace the cover-image link (Google Drive) with the GitHub-hosted demo
video so it plays inline, across all language READMEs. Add an
"illustrative simulated preview — not real trading performance" note
next to it in each locale.
2026-06-30 22:35:09 +08:00
tinkle-community
277b5ad9c6 refactor: drop equity curve + AI rationale panels, rebalance dashboard footer
- Remove the equity curve (only ever 2 points → a meaningless straight line);
  also drop the now-unused equity-history SWR poll and path calc
- Remove the Latest Decision / AI rationale panel (redundant with Execution Log)
- Rebalance the footer: Market Net Inflow + By Symbol now form one two-column
  row (1.5fr / 1fr) instead of full-width inflow + a lonely by-symbol column
2026-06-30 16:13:59 +08:00
tinkle-community
110bf52908 feat: cream terminal redesign, English-only UI, autopilot launch fixes
- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
2026-06-30 16:03:52 +08:00
tinkle-community
eba28bcf0e Sync manual closes into position history 2026-06-28 12:17:45 +08:00
tinkle-community
c4e79d9579 Fix Claw402 autopilot launch and accounting 2026-06-28 11:36:56 +08:00
tinkle-community
a4983d2cb0 fix: enable full-size Claw402 autopilot trading 2026-06-27 11:31:19 +08:00
tinkle-community
24f6421a73 feat: simplify Claw402 autopilot trading flow 2026-06-27 00:37:59 +08:00
tinkle-community
961e016d33 fix: harden Hyperliquid agent renewal 2026-06-21 19:23:20 +08:00
tinkle-community
b95da3ed42 chore: remove stray screenshot from repo, ignore img.png 2026-06-11 22:18:27 +08:00
tinkle-community
a47811f5ab fix(ai500): flat API response for the panel, list-format chat replies
- /api/ai500 returned a success/data envelope but the web httpClient
  wraps the raw body as data, so the panel read coins as undefined and
  showed the empty state; return a flat {coins,count} body like
  /api/symbols
- the agent rendered AI500 rankings as a markdown table that the chat
  UI flattens into an unreadable line: tool note + system prompt now
  mandate numbered lists (one coin per line) and ban tables outright
2026-06-11 22:18:05 +08:00
tinkle-community
2c6e2827e8 feat(agent): surface the AI500 index board in chat, tools, and sidebar
- provider/nofxos: GetAI500ListCached — 5min TTL cache with stale
  fallback on upstream failure; ResolveClient routes through the claw402
  gateway when a wallet key is configured (user's claw402 model key ->
  CLAW402_WALLET_KEY env -> direct nofxos)
- new GET /api/ai500 endpoint serving the score-sorted board
- new get_ai500_list agent tool + prompt rule: when the user wants coin
  picks or creates a strategy without naming coins, consult AI500's
  high-scoring entries by default
- web: AI500 sidebar panel (rank, score badge, gain since entry,
  5min auto-refresh); clicking an entry asks the agent to analyze it
2026-06-11 22:11:03 +08:00
tinkle-community
953240565f fix(trader): stop order-sync goroutine leak and rate-limit hammering
Every StartOrderSync spawned a ticker goroutine that ran forever — it
survived trader stop AND deletion, so each quick-created trader left a
permanent 30s Hyperliquid poll behind. Stacked leaks turned into an
~8s effective hammer that tripped Hyperliquid's 429 rate limit, which
then broke the symbol board, trader creation, and order sync itself.

- new trader/syncloop package: shared stoppable sync loop with
  exponential failure backoff (30s base, 5min cap)
- all 9 exchanges' StartOrderSync now take the trader's stop channel
  and stop when the trader stops (close broadcast from AutoTrader.Stop)
- provider/hyperliquid: GetPerpDexCoins now serves a 5min TTL cache and
  falls back to the stale board when the upstream returns 429, so the
  symbol panel keeps working through rate limiting
2026-06-11 21:45:31 +08:00
tinkle-community
133ef51de8 fix(hyperliquid): stop SDK init panic from crashing trader creation with 500
go-hyperliquid's NewExchange auto-fetches meta/spotMeta/perpDexs and
panics if any of those API calls fail (NewInfo: panic(err)). The quick
trade flow constructs a probe trader inside POST /api/traders, so any
transient Hyperliquid API hiccup crashed the request with a recovered
panic and a bare 500. Wrap the constructor in initExchangeClient, which
converts the panic into an error that surfaces through the existing
exchange-probe validation path as an honest, retryable message.
2026-06-11 21:33:55 +08:00
tinkle-community
bebe51bf89 fix(deps): resolve remaining 2 Dependabot advisories
- gnark-crypto 0.19.0 -> 0.19.2 (GHSA-fj2x-735w-74vq, HIGH: unchecked
  memory allocation during vector deserialization; indirect, uncalled)
- pgx/v5 5.9.0 -> 5.9.2 (GHSA-j88v-2chj-qfwx, LOW: SQL injection via
  dollar-quote placeholder confusion; indirect via gorm postgres driver)
2026-06-11 01:56:11 +08:00
tinkle-community
3a048876bd fix(agent): keep suspended-task snapshots on the legacy stack
suspendActiveContexts clears all active contexts after parking a task on
the snapshot stack, so the active-context guard alone let the agentic
loop hijack resume requests and strand suspended tasks. Check the
snapshot stack in shouldUseAgenticTurn.
2026-06-11 01:12:36 +08:00
tinkle-community
4f3869c81c feat(agent): defaults-first prompts and one-shot creation flows
- system prompt (zh+en): finish-the-work-then-report rule — chain tools in
  the current turn, never promise background work; prefill industry-standard
  defaults instead of interrogating field by field, batch any unavoidable
  questions into one
- manage_strategy tool description: create only needs name, omitted config
  merges from the default template, one-shot summary + confirmed=true flow
- manage_trader tool description: resolve exchange/model/strategy bindings
  via list tools instead of asking the user for IDs
2026-06-11 01:06:11 +08:00
tinkle-community
f3c33b55d7 feat(agent): make the agentic loop the primary brain path
thinkAndAct/thinkAndActStream now try the native function-calling loop
first for fresh conversations; in-flight legacy flows (skill sessions,
workflows, execution states, pending proposals) stay on the legacy stack
until they finish. NOFX_AGENT_V2=off restores the old routing entirely.
2026-06-11 01:03:08 +08:00
tinkle-community
785922697b feat(agent): add native function-calling agentic loop as the new brain core
One standard tool-use loop replaces the need for layered JSON routing:
the LLM sees all 22 tools plus real multi-turn history, every tool result
(including errors) returns to the loop as an observation, and the final
user-facing reply is always LLM-written. Interruptions report exactly
which tools already executed so side effects are never silently lost or
repeated by fallback paths. Gated by NOFX_AGENT_V2 (default on).
2026-06-11 01:00:41 +08:00
tinkle-community
332ddf61ef fix(trader): stop over-attributing entry fees on partial position closes
The FIFO matcher reduced an open trade's remaining quantity but not its
remaining fee, so each subsequent partial close re-attributed entry fee
that earlier closes had already counted (e.g. open 2.0 with fee 0.4,
two 1.0 closes attributed 0.6 total). Deduct the consumed fee portion
alongside the quantity so attributed fees sum to the fee actually paid.
2026-06-11 00:45:06 +08:00
tinkle-community
41c2625bb2 test(manager,market,trader): cover previously untested core paths
- manager: 15 tests incl. concurrent map access under -race (was 0 tests)
- market: timeframe normalization regression tests
- trader: FIFO position rebuild tests (partial closes, hedge/one-way mode,
  PnL-fallback entry price, invalid input)
2026-06-11 00:37:45 +08:00
tinkle-community
c0d8a9a375 refactor(trader): name trading-logic magic numbers
- marginOverheadFactor/takerFeeRate/positionSizeSafetyFactor in sizing math
- aggressiveBuyPriceFactor/aggressiveSellPriceFactor in hyperliquid and aster
  simulated market orders
- dustQuantityEpsilon in FIFO position rebuild
2026-06-11 00:33:11 +08:00
tinkle-community
9ea9bd705f fix(trader): harden API calls with timeouts, strict balance parsing, error context
- binance/bybit/gate: SDK default http.DefaultClient has no timeout; use a
  dedicated 30s-timeout client so a hung connection cannot stall the loop
- bybit: stop mutating http.DefaultClient.Transport, which leaked the
  referer header into every other HTTP request in the process
- add types.ParseFloatField: empty exchange fields stay zero, but malformed
  numeric values now surface as errors instead of silently becoming zero
  balances (applied to GetBalance across 8 exchanges)
- wrap order/market-data errors in auto_trader_orders and okx cancel paths
  with symbol context; log per-order cancel failures in okx CancelAllOrders
2026-06-11 00:30:34 +08:00
tinkle-community
094ab45476 fix(trader): stop swallowing critical errors in order paths
- check CancelAllOrders errors in okx/kucoin/bitget/gate open/close paths
  (aligns with existing binance/hyperliquid/aster/bybit pattern)
- log saveDecision failures in auto_trader_loop instead of discarding
- remove dead MustNormalizeTimeframe that panicked in market package
- web: npm audit fix resolves react-router HIGH CVEs (GHSA-49rj-9fvp-4h2h,
  GHSA-2j2x-hqr9-3h42, GHSA-8x6r-g9mw-2r78, GHSA-rxv8-25v2-qmq8)
2026-06-11 00:12:58 +08:00
tinkle-community
220cb7428b fix(deps): resolve 3 critical Dependabot advisories
- go: bump github.com/jackc/pgx/v5 v5.6.0 -> v5.9.0 (CVE-2026-33815 /
  CVE-2026-33816, memory-safety in the Postgres driver). govulncheck reports
  0 affecting vulnerabilities after the bump.
- ci: pin aquasecurity/trivy-action to commit SHA ed142fd (v0.36.0) instead of
  the mutable @0.28.0 tag (GHSA-69fq-xp46-6x23, brief upstream supply-chain
  compromise). Dependabot now updates the SHA.
- web: bump vitest ^4.0.16 -> ^4.1.0 (lockfile now 4.1.8) for
  GHSA-5xrq-8626-4rwp (Vitest UI server arbitrary file read/exec; dev-only).
2026-06-05 22:19:27 +08:00
tinkle-community
1aea7abc38 fix(security): remove decrypt oracle, redact secret logs, harden auth, bump Go
Address multiple vulnerabilities found during security review:

- Remove unauthenticated POST /api/crypto/decrypt decryption oracle (route,
  handler, dead frontend helper) + regression test. Transport encryption is
  one-directional; the server never needs to decrypt arbitrary client payloads.
- Redact secrets in config-update logs: handler_ai_model/handler_exchange logged
  %+v of decrypted requests, leaking API keys / secret keys / passphrases /
  private keys. Use named types shared with the log sanitizer so the masking
  can never drift again; extend masking to passphrase + lighter_api_key_private_key.
- crypto: require a valid timestamp in DecryptPayload (a missing ts previously
  skipped replay protection entirely).
- crypto: EncryptedString.Value() now fails closed instead of silently
  persisting plaintext secrets when encryption errors.
- auth: per-IP token-bucket rate limiting on /login and /register against online
  brute-force; raise registration password minimum 6 -> 8; add dummy bcrypt
  compare on unknown-email login to close the user-enumeration timing channel.
- IDOR: getTraderFromQuery no longer falls back to the global in-memory trader
  map; trader access is strictly scoped to the authenticated caller.
- Bump Go 1.25.10 -> 1.25.11 to resolve reachable net/textproto and crypto/x509
  stdlib advisories (govulncheck now reports 0 affecting vulnerabilities).
2026-06-05 22:08:26 +08:00
330 changed files with 18328 additions and 44164 deletions

View File

@@ -273,12 +273,11 @@ jobs:
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
# SECURITY: never use @master — upstream compromise = CI compromise.
# TODO: pin to a full 40-char SHA from
# https://github.com/aquasecurity/trivy-action/releases and configure Dependabot
# to keep it current. A version tag is still mutable but is a major upgrade
# over @master.
uses: aquasecurity/trivy-action@0.28.0
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
# version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
# trivy-action's published artifacts were briefly poisoned). The trailing
# comment records the human-readable version; Dependabot updates the SHA.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'

1
.gitignore vendored
View File

@@ -138,3 +138,4 @@ PR_DESCRIPTION.md
# Local AI agent / skill scaffolding (not part of the runtime app)
.agents/
skills-lock.json
img.png

View File

@@ -63,15 +63,7 @@ Use the links below to open trading accounts for crypto and supported US stock,
## Quick demo
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Click the cover image to watch the demo video.
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -1,272 +0,0 @@
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")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +0,0 @@
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

@@ -1,128 +0,0 @@
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

@@ -1,87 +0,0 @@
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,237 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
)
// Brain handles proactive intelligence: signals, news, market briefs.
type Brain struct {
agent *Agent
logger *slog.Logger
http *http.Client
stopCh chan struct{}
stopOnce sync.Once
recentSignals sync.Map // debounce
}
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
logger: logger,
http: &http.Client{Timeout: 15 * time.Second},
stopCh: make(chan struct{}),
}
}
func (b *Brain) Stop() {
b.stopOnce.Do(func() {
close(b.stopCh)
})
}
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
cutoff := time.Now().Add(-30 * time.Minute)
b.recentSignals.Range(func(key, value any) bool {
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
b.recentSignals.Delete(key)
}
return true
})
}
func (b *Brain) HandleSignal(sig Signal) {
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
if v, ok := b.recentSignals.Load(key); ok {
if time.Since(v.(time.Time)) < 10*time.Minute {
return
}
}
b.recentSignals.Store(key, time.Now())
emoji := map[string]string{"info": "", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
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 <-ticker.C:
b.scanNews(seen, &seenOrder)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
}
}
}
})
}
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
}
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
}
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
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
}
seen[d.URL] = true
*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++
}
}
if bc == 0 && brc == 0 {
continue
}
emoji := "📰"
sentiment := "NEUTRAL"
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 the oldest half when seen grows large so recent URLs stay deduped deterministically.
if len(seen) > 1000 {
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]
}
}
}
func (b *Brain) StartMarketBriefs(hours []int) {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
for {
select {
case <-b.stopCh:
return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
sent[key] = true
b.sendBrief(h)
}
}
}
}
})
}
func (b *Brain) sendBrief(hour int) {
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
}
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
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"]
}
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
b.agent.notifyAll(brief)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +0,0 @@
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,466 +0,0 @@
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

@@ -1,692 +0,0 @@
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, 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, false, "", "", "", "", "", "", 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, false, "", "", "", "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, 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, 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, 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

@@ -1,111 +0,0 @@
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

@@ -1,650 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
executionStatusPlanning = "planning"
executionStatusRunning = "running"
executionStatusWaitingUser = "waiting_user"
executionStatusCompleted = "completed"
executionStatusFailed = "failed"
)
const (
planStepTypeTool = "tool"
planStepTypeReason = "reason"
planStepTypeAskUser = "ask_user"
planStepTypeRespond = "respond"
)
const (
planStepStatusPending = "pending"
planStepStatusRunning = "running"
planStepStatusCompleted = "completed"
planStepStatusFailed = "failed"
)
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"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
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 {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
Instruction string `json:"instruction,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
OutputSummary string `json:"output_summary,omitempty"`
Error string `json:"error,omitempty"`
}
type Observation struct {
StepID string `json:"step_id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RawJSON string `json:"raw_json,omitempty"`
CreatedAt string `json:"created_at"`
}
type WaitingState struct {
Question string `json:"question,omitempty"`
Intent string `json:"intent,omitempty"`
PendingFields []string `json:"pending_fields,omitempty"`
ConfirmationTarget string `json:"confirmation_target,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type EntityReference struct {
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 {
Strategy *EntityReference `json:"strategy,omitempty"`
Trader *EntityReference `json:"trader,omitempty"`
Model *EntityReference `json:"model,omitempty"`
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"`
}
const (
executionLogMaxEntries = 8
summaryNotesMaxEntries = 4
)
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{}
}
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ExecutionState{}
}
var state ExecutionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
return normalizeExecutionState(state)
}
func (a *Agent) saveExecutionState(state ExecutionState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeExecutionState(state)
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
}
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
}
func (a *Agent) clearExecutionState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
}
}
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{
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
UserID: userID,
Goal: strings.TrimSpace(goal),
Status: executionStatusPlanning,
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
UpdatedAt: now,
})
}
func normalizeExecutionState(state ExecutionState) ExecutionState {
state.Goal = strings.TrimSpace(state.Goal)
state.Status = strings.TrimSpace(state.Status)
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
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
}
for i := range state.Steps {
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
if state.Steps[i].ID == "" {
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
}
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
if state.Steps[i].Status == "" {
state.Steps[i].Status = planStepStatusPending
}
}
if len(state.Observations) > 0 {
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
state.Observations = nil
}
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
state = compactExecutionLog(state)
if state.UpdatedAt == "" && state.SessionID != "" {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
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
}
waiting.Question = strings.TrimSpace(waiting.Question)
waiting.Intent = strings.TrimSpace(waiting.Intent)
waiting.PendingFields = cleanStringList(waiting.PendingFields)
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
return nil
}
return waiting
}
func normalizeEntityReference(ref *EntityReference) *EntityReference {
if ref == nil {
return nil
}
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
}
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
if refs == nil {
return nil
}
refs.Strategy = normalizeEntityReference(refs.Strategy)
refs.Trader = normalizeEntityReference(refs.Trader)
refs.Model = normalizeEntityReference(refs.Model)
refs.Exchange = normalizeEntityReference(refs.Exchange)
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
return nil
}
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
}
out := make([]Observation, 0, len(values))
for _, value := range values {
value.StepID = strings.TrimSpace(value.StepID)
value.Kind = strings.TrimSpace(value.Kind)
value.Summary = strings.TrimSpace(value.Summary)
value.RawJSON = strings.TrimSpace(value.RawJSON)
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
continue
}
if value.CreatedAt == "" {
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
func compactExecutionLog(state ExecutionState) ExecutionState {
if len(state.ExecutionLog) <= executionLogMaxEntries {
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
return state
}
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
summary := summarizeExecutionOverflow(overflow)
if summary != nil {
state.SummaryNotes = append(state.SummaryNotes, *summary)
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
}
return state
}
func summarizeExecutionOverflow(values []Observation) *Observation {
if len(values) == 0 {
return nil
}
summaries := make([]string, 0, len(values))
for _, value := range values {
label := value.Kind
if label == "" {
label = "observation"
}
if value.Summary != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
} else if value.RawJSON != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
}
}
if len(summaries) == 0 {
return nil
}
text := strings.Join(summaries, " | ")
if len(text) > 500 {
text = text[:500] + "..."
}
return &Observation{
Kind: "execution_summary",
Summary: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
}
func appendExecutionLog(state *ExecutionState, obs Observation) {
state.ExecutionLog = append(state.ExecutionLog, obs)
*state = normalizeExecutionState(*state)
}
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,
}
}

View File

@@ -1,117 +0,0 @@
package agent
import (
"strings"
"sync"
"time"
)
// chatMessage represents a single message in conversation history.
type chatMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// chatHistory stores conversation history per user.
type chatHistory struct {
mu sync.RWMutex
sessions map[int64][]chatMessage
maxTurns int // hard safety cap in messages per user
}
func newChatHistory(maxTurns int) *chatHistory {
if maxTurns <= 0 {
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
}
return &chatHistory{
sessions: make(map[int64][]chatMessage),
maxTurns: maxTurns,
}
}
// Add appends a message to the user's history.
func (h *chatHistory) Add(userID int64, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[userID] = append(h.sessions[userID], chatMessage{
Role: role,
Content: content,
Timestamp: time.Now(),
})
// Hard safety cap in case summarization is unavailable.
msgs := h.sessions[userID]
if len(msgs) > h.maxTurns {
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
}
}
// Get returns the conversation history for a user.
func (h *chatHistory) Get(userID int64) []chatMessage {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[userID]
if msgs == nil {
return nil
}
// Return a copy
result := make([]chatMessage, len(msgs))
copy(result, msgs)
return result
}
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
h.mu.Lock()
defer h.mu.Unlock()
if len(msgs) == 0 {
delete(h.sessions, userID)
return
}
if len(msgs) > h.maxTurns {
msgs = msgs[len(msgs)-h.maxTurns:]
}
cloned := make([]chatMessage, len(msgs))
copy(cloned, msgs)
h.sessions[userID] = cloned
}
// Clear resets conversation history for a user.
func (h *chatHistory) Clear(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, userID)
}
// CleanOld removes sessions older than the given duration.
func (h *chatHistory) CleanOld(maxAge time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for uid, msgs := range h.sessions {
if len(msgs) > 0 {
lastMsg := msgs[len(msgs)-1]
if now.Sub(lastMsg.Timestamp) > maxAge {
delete(h.sessions, uid)
}
}
}
}
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

@@ -1,88 +0,0 @@
package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\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 /clear /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\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 /clear /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
},
"no_traders": {
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
"en": "📭 No traders configured. Create one in Web UI.",
},
"no_running_trader": {
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
"en": "⚠️ No running trader. Start one in Web UI.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"positions_header": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_pnl": {
"zh": "💰 *总未实现盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"balance_header": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balances*\n\n",
},
"traders_header": {
"zh": "🤖 *Traders*\n\n",
"en": "🤖 *Traders*\n\n",
},
"trade_usage": {
"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",
"en": "❓ Invalid quantity: %s",
},
"analysis_header": {
"zh": "🔍 *%s 市场分析*",
"en": "🔍 *%s Analysis*",
},
"sentinel_off": {
"zh": "⚠️ Sentinel 未启用。",
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"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.",
},
}
func (a *Agent) msg(lang, key string) string {
if m, ok := i18nMessages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}

View File

@@ -1,578 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,694 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
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) 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
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
if decision, ok, err := a.routeTurnUnifiedWithLLM(ctx, userID, lang, text); err == nil && ok {
if answer, handled, execErr := a.executeUnifiedTurnDecision(ctx, storeUserID, userID, lang, text, decision, onEvent); handled || execErr != nil {
return answer, handled, execErr
}
}
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
func parseUnifiedTurnDecision(raw string) (unifiedTurnDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision unifiedTurnDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeUnifiedTurnDecision(decision), nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
return normalizeUnifiedTurnDecision(decision), nil
}
}
return unifiedTurnDecision{}, fmt.Errorf("invalid unified turn decision json")
}
func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecision {
decision.TopicIntent = strings.TrimSpace(strings.ToLower(decision.TopicIntent))
decision.BusinessAction = strings.TrimSpace(strings.ToLower(decision.BusinessAction))
decision.TargetSkill = strings.TrimSpace(decision.TargetSkill)
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode))
decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser)
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
}
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()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return unifiedTurnDecision{}, false, err
}
decision, err := parseUnifiedTurnDecision(raw)
if err != nil {
return unifiedTurnDecision{}, false, err
}
if !decision.reliable() {
return decision, false, nil
}
return decision, true, nil
}
func (a *Agent) buildUnifiedTurnRouterPrompt(userID int64, lang, text string) (string, string) {
activeSkill := a.getSkillSession(userID)
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
activeWorkflow := a.getWorkflowSession(userID)
activeExec := a.getExecutionState(userID)
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
previousAssistantReply := a.currentPendingHintText(userID)
snapshots := a.SnapshotManager(userID).List()
snapshotJSON, _ := json.Marshal(snapshots)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
recentConversation := a.buildRecentConversationContext(userID, text)
if strings.TrimSpace(recentConversation) == "" {
recentConversation = "(empty)"
}
activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal)
if strings.TrimSpace(activeFlowSummary) == "" {
activeFlowSummary = "none"
}
activeTaskDetails := "none"
if hasActiveTask {
activeTaskDetails = buildBrainUserPrompt(lang, text, previousAssistantReply, recentConversation, currentRefs, activeTask, true)
}
systemPrompt := prependNOFXiAdvisorPreamble(`You are the unified turn router for NOFXi.
Return JSON only. No markdown.
You must make ONE combined decision for this user turn:
1. Topic/context decision: continue active context, start fresh/new context, resume snapshot, cancel, or direct conversational reply.
2. Business routing decision: answer directly, start/continue a management skill, or hand off to the planner.
3. Context policy: whether downstream modules may use current references, must use fresh context, or must resume a snapshot.
topic_intent values:
- "continue_active": user is answering or continuing the active flow
- "start_new": user starts or switches to a new task/topic
- "resume_snapshot": user wants to resume one suspended snapshot
- "cancel": user cancels the current active flow
- "instant_reply": user only greets, thanks, chats, or asks a direct explanation
business_action values:
- "direct_answer": reply_to_user is the final answer; do not change state
- "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
}
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)
}
}
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
}
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)
}
return answer, true, err
}
func countPendingWorkflowTasks(session WorkflowSession) int {
count := 0
for _, task := range session.Tasks {
switch task.Status {
case workflowTaskPending, workflowTaskRunning:
count++
}
}
return count
}
func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string {
if refs == nil {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
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)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
return
}
line := fmt.Sprintf("- Current %s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf(" (source: %s)", source)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
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 {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}

View File

@@ -1,107 +0,0 @@
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

@@ -1,468 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
recentConversationRounds = 6
recentConversationMessages = recentConversationRounds * 2
chatHistoryMaxTurns = recentConversationMessages * 2 // fallback cap when compression is unavailable
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6
incrementalTaskStateTokenLimit = 500
)
type DecisionMemory struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
StillValid bool `json:"still_valid,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
type TaskState struct {
CurrentGoal string `json:"current_goal,omitempty"`
ActiveFlow string `json:"active_flow,omitempty"`
// OpenLoops stores only high-level unresolved issues that still matter across turns.
// Step-level pending work belongs in ExecutionState, not here.
OpenLoops []string `json:"open_loops,omitempty"`
ImportantFacts []string `json:"important_facts,omitempty"`
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func TaskStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_state_%d", userID)
}
func (a *Agent) getTaskState(userID int64) TaskState {
if a.store == nil {
return TaskState{}
}
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
return TaskState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskState{}
}
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
return TaskState{}
}
return normalizeTaskState(state)
}
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
}
func (a *Agent) clearTaskState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
}
}
func normalizeTaskState(state TaskState) TaskState {
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
state.ImportantFacts = cleanStringList(state.ImportantFacts)
if state.LastDecision != nil {
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
state.LastDecision = nil
}
}
if state.UpdatedAt == "" && !isZeroTaskState(state) {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func isZeroTaskState(state TaskState) bool {
return state.CurrentGoal == "" &&
state.ActiveFlow == "" &&
len(state.OpenLoops) == 0 &&
len(state.ImportantFacts) == 0 &&
state.LastDecision == nil
}
func cleanStringList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
func filterTaskStateOpenLoops(values []string) []string {
if len(values) == 0 {
return nil
}
rejectedPrefixes := []string{
"wait for ",
"waiting for ",
"ask for ",
"call ",
"run ",
"execute ",
"invoke ",
"use tool",
"step ",
}
rejectedContains := []string{
"current step",
"tool call",
"api key",
"api secret",
"secret key",
"passphrase",
"model id",
"exchange id",
}
filtered := make([]string, 0, len(values))
for _, value := range values {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
continue
}
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
continue
}
filtered = append(filtered, value)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func matchesAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func matchesAnyContains(value string, patterns []string) bool {
for _, pattern := range patterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
func buildTaskStateContext(state TaskState) string {
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return ""
}
var sb strings.Builder
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
if state.CurrentGoal != "" {
sb.WriteString("- Current goal: ")
sb.WriteString(state.CurrentGoal)
sb.WriteString("\n")
}
if state.ActiveFlow != "" {
sb.WriteString("- Active flow: ")
sb.WriteString(state.ActiveFlow)
sb.WriteString("\n")
}
for _, loop := range state.OpenLoops {
sb.WriteString("- High-level open loop: ")
sb.WriteString(loop)
sb.WriteString("\n")
}
for _, fact := range state.ImportantFacts {
sb.WriteString("- Important fact: ")
sb.WriteString(fact)
sb.WriteString("\n")
}
if state.LastDecision != nil {
sb.WriteString("- Last decision: ")
sb.WriteString(state.LastDecision.Action)
if state.LastDecision.Reason != "" {
sb.WriteString(" | reason: ")
sb.WriteString(state.LastDecision.Reason)
}
if state.LastDecision.StillValid {
sb.WriteString(" | still valid")
}
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func estimateChatMessagesTokens(msgs []chatMessage) int {
total := 0
for _, msg := range msgs {
total += len([]rune(msg.Content))/3 + 10
}
return total
}
func formatChatMessagesForSummary(msgs []chatMessage) string {
var sb strings.Builder
for _, msg := range msgs {
if strings.TrimSpace(msg.Content) == "" {
continue
}
role := "User"
if msg.Role == "assistant" {
role = "Assistant"
}
sb.WriteString(role)
sb.WriteString(": ")
sb.WriteString(msg.Content)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) <= recentConversationMessages {
return
}
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
return
}
splitAt := len(msgs) - recentConversationMessages
if splitAt <= 0 {
return
}
oldPart := msgs[:splitAt]
recentPart := msgs[splitAt:]
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
if err != nil {
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
return
}
a.history.Replace(userID, recentPart)
}
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) < 2 {
return
}
window := msgs
if len(window) > incrementalTaskStateMessages {
window = window[len(window)-incrementalTaskStateMessages:]
}
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
if err != nil {
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
}
}
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(oldPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state using the existing state plus archived dialogue.
Return JSON only. Do not return markdown.
Rules:
- Keep only durable, non-derivable context useful for future turns.
- Do not store market prices, balances, positions, or anything tools can fetch again.
- Do not store chit-chat or repeated wording.
- current_goal: the user's active objective, if any.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- Do not put execution-step pending work into open_loops.
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
- important_facts: non-derivable facts worth remembering briefly.
- last_decision: keep only one current relevant decision; omit if none.
- Replace stale items instead of appending blindly.
- If a field is no longer relevant, return it empty or omit it.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(taskStateSummaryTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
return state, nil
}
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(recentPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state incrementally using the existing state plus the latest conversation window.
Return JSON only. Do not return markdown.
Rules:
- Capture newly confirmed facts from the latest few turns immediately.
- Preserve important existing facts that still matter; replace stale items when contradicted.
- Keep only durable, non-derivable context useful for the next turns.
- current_goal: the user's active objective right now.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
- Do not store execution-step pending work or tool instructions.
- Do not store market prices, balances, or anything tools can fetch again.
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
return state, nil
}
func parseTaskStateJSON(raw string) (TaskState, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err == nil {
return state, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
return state, nil
}
}
return TaskState{}, fmt.Errorf("invalid task state json")
}
func intPtr(v int) *int {
return &v
}

View File

@@ -1,75 +0,0 @@
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

@@ -1,242 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -1,94 +0,0 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
func isModelWalletBalanceQuestion(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
// Direct wallet address questions: "我的钱包地址", "wallet address", etc.
if containsAny(lower, []string{"钱包", "wallet"}) && containsAny(lower, []string{"地址", "address"}) {
return true
}
// Balance questions with wallet context
if containsAny(lower, []string{"余额", "balance", "usdc"}) &&
containsAny(lower, []string{"钱包", "wallet", "主钱包", "base", "claw402"}) {
return true
}
return false
}
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

@@ -1,604 +0,0 @@
package agent
import (
"fmt"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"nofx/store"
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
// When there's no trader configured, the agent guides the user.
// SetupState tracks where the user is in the setup flow.
type SetupState struct {
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
Exchange string
ExchangeID string
APIKey string
APISecret string
Passphrase string
AIProvider string
AIModel string
AIModelID string
AIKey string
AIBaseURL string
}
// needsSetup returns true if no traders are configured.
func (a *Agent) needsSetup() bool {
if a.traderManager == nil {
return true
}
return len(a.traderManager.GetAllTraders()) == 0
}
// 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{}
}
return &SetupState{
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
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)
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)
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
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 {
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
return v
}
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) {
return a.handleSetupFlowForStoreUser("default", userID, text, L)
}
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
state := a.getSetupState(userID)
lower := strings.ToLower(text)
// Cancel setup — explicit or implicit (user asking unrelated questions)
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
a.clearSetupState(userID)
return a.setupMsg(L, "cancelled"), true
}
// If in a step that expects a key/secret, check if user is NOT sending a key
// Keys are typically long strings without spaces and Chinese characters
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
trimmed := strings.TrimSpace(text)
hasChinese := false
for _, r := range trimmed {
if r >= 0x4e00 && r <= 0x9fff {
hasChinese = true
break
}
}
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
tooShort := len(trimmed) < 8
if hasChinese || hasSpaces || tooShort {
// User is probably asking a question, not providing a key
a.clearSetupState(userID)
if L == "zh" {
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
}
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
}
}
switch state.Step {
case "await_exchange":
return a.handleExchangeChoice(userID, text, state, L)
case "await_api_key":
state.APIKey = strings.TrimSpace(text)
state.Step = "await_api_secret"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_secret"), true
case "await_api_secret":
state.APISecret = strings.TrimSpace(text)
// OKX/Bitget/KuCoin need passphrase
if needsPassphrase(state.Exchange) {
state.Step = "await_passphrase"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_passphrase"), true
}
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 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"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_passphrase":
state.Passphrase = strings.TrimSpace(text)
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 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"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_ai_model":
return a.handleAIChoice(storeUserID, userID, text, state, L)
case "await_ai_key":
state.AIKey = strings.TrimSpace(text)
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次或稍后去 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)
}
// Not in setup flow — only enter setup for a tiny set of explicit commands.
// Natural-language configuration requests should go to the planner first,
// including phrases like "开始配置" or "帮我配置交易所".
if isDirectSetupCommand(lower) {
state.Step = "await_exchange"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_exchange"), true
}
// Everything else — let normal routing handle it
return "", false
}
func isDirectSetupCommand(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
switch text {
case "setup", "/setup":
return true
default:
return false
}
}
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
exchanges := map[string]string{
"binance": "binance", "币安": "binance", "1": "binance",
"okx": "okx", "欧易": "okx", "2": "okx",
"bybit": "bybit", "3": "bybit",
"bitget": "bitget", "4": "bitget",
"gate": "gate", "5": "gate",
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
"hyperliquid": "hyperliquid", "7": "hyperliquid",
}
ex, ok := exchanges[lower]
if !ok {
return a.setupMsg(L, "invalid_exchange"), true
}
state.Exchange = ex
state.Step = "await_api_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key", titleCaser.String(ex)), true
}
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
}
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
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"},
"通义": {"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": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
if !ok {
return a.setupMsg(L, "invalid_ai"), true
}
if choice.model == "" {
// Skip AI, just create trader with exchange
state.AIProvider = ""
state.AIModel = ""
state.AIModelID = ""
state.AIKey = ""
return a.finishSetup(storeUserID, userID, state, L)
}
state.AIProvider = choice.provider
state.AIModel = choice.model
state.AIBaseURL = choice.url
state.Step = "await_ai_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key", choice.model), true
}
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
}
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
// Create exchange in store
a.logger.Info("creating trader from setup",
"exchange", state.Exchange,
"ai_model", state.AIModel,
"store_user_id", storeUserID,
)
// TODO: Use store to create exchange + trader config
// For now, log the config and tell user
a.clearSetupState(userID)
result := ""
maskedKey := maskKey(state.APIKey)
if L == "zh" {
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
"• 交易所: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
}
result += "\n正在创建 Trader..."
} else {
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
"• Exchange: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
}
result += "\nCreating Trader..."
}
// Actually create the trader via store
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
if err != nil {
a.logger.Error("create trader failed", "error", err)
if L == "zh" {
result += fmt.Sprintf("\n\n⚠ 创建失败: %v\n交易所配置已保存下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
} else {
result += fmt.Sprintf("\n\n⚠ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
}
} else {
if L == "zh" {
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
} else {
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
}
}
return result, true
}
func (a *Agent) createTraderFromSetup(state *SetupState) error {
return a.createTraderFromSetupForStoreUser("default", state)
}
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
if a.store == nil {
return fmt.Errorf("store not available")
}
exchangeID := state.ExchangeID
if exchangeID == "" {
var err error
exchangeID, err = a.saveSetupExchange(storeUserID, state)
if err != nil {
return fmt.Errorf("save exchange: %w", err)
}
}
aiModelID := state.AIModelID
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
var err error
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model", "error", err)
}
}
// Reuse an existing trader if the same exchange/model pair already exists.
existingTraders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Errorf("list traders: %w", err)
}
for _, existing := range existingTraders {
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
a.logger.Info("reusing existing trader created via chat setup",
"trader", existing.Name,
"exchange_id", exchangeID,
"ai_model_id", aiModelID,
)
return nil
}
}
// Create trader config
exchangeIDShort := exchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
modelPart := aiModelID
if modelPart == "" {
modelPart = "manual"
}
trader := &store.Trader{
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
UserID: storeUserID,
ExchangeID: exchangeID,
AIModelID: aiModelID,
IsRunning: false,
}
if err := a.store.Trader().Create(trader); err != nil {
return fmt.Errorf("save trader: %w", err)
}
a.logger.Info("trader created via chat",
"trader", trader.Name,
"exchange", state.Exchange,
"ai", aiModelID,
)
return nil
}
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
hlWallet := ""
hlUnified := false
passphrase := state.Passphrase
apiKey := state.APIKey
apiSecret := state.APISecret
if state.Exchange == "hyperliquid" {
hlWallet = state.APISecret
apiKey = ""
apiSecret = state.APIKey
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return "", err
}
for _, ex := range exchanges {
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
if err := a.store.Exchange().Update(
storeUserID, ex.ID, true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified, false,
"", "", "",
"", "", "", 0,
); err != nil {
return "", err
}
return ex.ID, nil
}
}
return a.store.Exchange().Create(
storeUserID,
state.Exchange,
setupExchangeAccountName,
true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified, false,
"", "", "",
"", "", "", 0,
)
}
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
if state.AIProvider == "" {
return "", nil
}
modelID := state.AIProvider
if err := a.store.AIModel().Update(
storeUserID,
modelID,
true,
state.AIKey,
state.AIBaseURL,
state.AIModel,
); err != nil {
return "", err
}
if modelID == state.AIProvider {
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
}
return modelID, nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
func needsPassphrase(exchange string) bool {
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
var setupMessages = map[string]map[string]string{
"welcome": {
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
"发送 *开始配置* 或 *setup* 开始\n" +
"发送 *取消* 随时退出",
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
"I see you haven't configured an exchange yet. Let me help!\n\n" +
"Send *setup* to begin\n" +
"Send *cancel* to exit anytime",
},
"ask_exchange": {
"zh": "🏦 *选择你的交易所*\n\n" +
"1⃣ Binance币安\n" +
"2⃣ OKX欧易\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin库币\n" +
"7⃣ Hyperliquid\n\n" +
"发送数字或名称选择:",
"en": "🏦 *Choose your exchange*\n\n" +
"1⃣ Binance\n" +
"2⃣ OKX\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin\n" +
"7⃣ Hyperliquid\n\n" +
"Send number or name:",
},
"invalid_exchange": {
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
},
"ask_secret": {
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*",
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
},
"ask_passphrase": {
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
},
"ask_ai": {
"zh": "🤖 *选择 AI 模型*\n\n" +
"1⃣ DeepSeek推荐便宜好用\n" +
"2⃣ 通义千问 (Qwen)\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ 跳过(不配置 AI\n\n" +
"发送数字或名称选择:",
"en": "🤖 *Choose AI model*\n\n" +
"1⃣ DeepSeek (recommended, affordable)\n" +
"2⃣ Qwen\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ Skip (no AI)\n\n" +
"Send number or name:",
},
"invalid_ai": {
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
},
"cancelled": {
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
},
}
func (a *Agent) setupMsg(L, key string) string {
if m, ok := setupMessages[key]; ok {
if s, ok := m[L]; ok {
return s
}
return m["en"]
}
return key
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,103 +0,0 @@
package agent
import (
"encoding/json"
"testing"
"nofx/mcp"
)
// plannerToolsForText now always returns the FULL toolset (no per-domain
// trimming) so the LLM can cross-domain reason. The old "if market intent,
// hide manage_trader" filter was making cross-domain questions like "BTC
// dropped, how much am I losing?" impossible to answer because the agent
// couldn't see both market AND position tools in the same turn.
//
// We still trim the giant strategy schema for non-mutation intents because
// that one is genuinely huge and uninformative for read-only use.
func TestPlannerToolsExposeFullSetForMarketIntent(t *testing.T) {
tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线")
names := toolNamesForTest(tools)
// Market tools must be present.
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)
}
}
// Cross-domain tools (positions, balance, trader management) must ALSO be
// present so the agent can answer "how much am I losing" follow-ups
// without losing the market context.
for _, expected := range []string{"get_positions", "get_balance", "manage_trader"} {
if !containsString(names, expected) {
t.Fatalf("expected cross-domain tool %q in market context %v", expected, names)
}
}
}
func TestPlannerToolsExposeFullSetForExchangeIntent(t *testing.T) {
tools := plannerToolsForText("帮我添加 okx 交易所 API key")
names := toolNamesForTest(tools)
// At least the exchange management tools must show up.
for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} {
if !containsString(names, expected) {
t.Fatalf("expected exchange tool %q in %v", expected, names)
}
}
// And the agent still has the broader surface available — adding an
// exchange often leads to "now create a trader" so trader/strategy tools
// must be reachable in the same turn.
for _, expected := range []string{"manage_trader", "get_strategies"} {
if !containsString(names, expected) {
t.Fatalf("expected adjacent tool %q in exchange context %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

@@ -1,166 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"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 {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at,omitempty"`
}
func NewPersistentPreference(text string) (PersistentPreference, error) {
text = strings.TrimSpace(text)
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{
ID: now.Format("20060102150405.000000000"),
Text: text,
CreatedAt: now.Format(time.RFC3339),
}, nil
}
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
// auth) to the int64 session id expected by the current agent implementation.
func SessionUserIDFromKey(userKey string) int64 {
if strings.TrimSpace(userKey) == "" {
return 1
}
h := fnv.New64a()
_, _ = h.Write([]byte(userKey))
sum := h.Sum64() & 0x7fffffffffffffff
if sum == 0 {
return 1
}
return int64(sum)
}
func PreferencesConfigKey(userID int64) string {
return fmt.Sprintf("agent_preferences_%d", userID)
}
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var prefs []PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
return nil
}
return prefs
}
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
}
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
created, err := NewPersistentPreference(text)
if err != nil {
return nil, PersistentPreference{}, err
}
prefs := a.getPersistentPreferences(userID)
prefs = append([]PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, PersistentPreference{}, err
}
return prefs, created, nil
}
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
replacement = strings.TrimSpace(replacement)
if match == "" || replacement == "" {
return nil, nil, fmt.Errorf("match and replacement are required")
}
prefs := a.getPersistentPreferences(userID)
for i := range prefs {
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
prefs[i].Text = replacement
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, nil, err
}
return prefs, &prefs[i], nil
}
}
return prefs, nil, fmt.Errorf("preference not found")
}
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
if match == "" {
return nil, nil, fmt.Errorf("match required")
}
prefs := a.getPersistentPreferences(userID)
filtered := make([]PersistentPreference, 0, len(prefs))
var removed *PersistentPreference
for i := range prefs {
p := prefs[i]
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
cp := p
removed = &cp
continue
}
filtered = append(filtered, p)
}
if removed == nil {
return prefs, nil, fmt.Errorf("preference not found")
}
if err := a.savePersistentPreferences(userID, filtered); err != nil {
return nil, nil, err
}
return filtered, removed, nil
}
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
prefs := a.getPersistentPreferences(userID)
if len(prefs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
for _, pref := range prefs {
if strings.TrimSpace(pref.Text) == "" {
continue
}
sb.WriteString("- ")
sb.WriteString(pref.Text)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}

View File

@@ -1,74 +0,0 @@
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")
}

View File

@@ -1,25 +0,0 @@
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
}

View File

@@ -1,101 +0,0 @@
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

@@ -1,127 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"sync"
"time"
)
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
}
func (s *Scheduler) Start(ctx context.Context) {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
lastCheck := time.Time{}
for {
select {
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 {
s.dailyReport()
lastReport = now
}
// Position risk check every 4h
if now.Sub(lastCheck) > 4*time.Hour {
s.riskCheck()
lastCheck = now
}
// Clean expired pending trades every hour.
if now.Minute() == 0 {
if s.agent.pending != nil {
s.agent.pending.CleanExpired()
}
}
}
}
})
}
func (s *Scheduler) Stop() {
s.stopOnce.Do(func() {
close(s.stopCh)
})
}
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil {
return
}
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 {
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
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 = "📉"
}
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
}
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 {
continue
}
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100
if pnlPct < -5 {
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
p["symbol"], p["side"], pnlPct, pnl))
}
}
}
}
if len(alerts) > 0 {
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
}
}

View File

@@ -1,222 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
"time"
)
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout"
SignalVolumeSpike SignalType = "volume_spike"
SignalFundingRate SignalType = "funding_rate"
)
type Signal struct {
Type SignalType
Symbol string
Severity string
Title string
Detail string
Price float64
Change float64
}
type SignalCallback func(Signal)
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePt
onSignal SignalCallback
http *http.Client
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
type pricePt struct {
Price float64
Volume float64
Time time.Time
}
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePt),
onSignal: cb,
http: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (s *Sentinel) Start() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
})
}
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` 添加。"
}
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")
}
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
}
}
return sb.String()
}
func (s *Sentinel) scan() {
s.mu.RLock()
syms := make([]string, len(s.symbols))
copy(syms, s.symbols)
s.mu.RUnlock()
for _, sym := range syms {
s.check(sym)
}
}
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
}
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
}
var t map[string]interface{}
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)
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 {
h = h[len(h)-60:]
}
s.history[symbol] = h
s.mu.Unlock()
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"
}
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)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
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
}
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),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, 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)
}
}

View File

@@ -1,97 +0,0 @@
package agent
func skillCatalogPrompt(lang string) string {
if lang == "zh" {
return `## 多轮与 Skill-First 工作模式
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
- 如果用户仍在同一任务里,继续当前 flow不要重新路由
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置必须单独确认
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
## 当前重点技能
### 1. 模型配置与诊断
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
1. 是否已启用模型
2. API Key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或填错
5. 保存后是否需要重新加载 trader
- 已知事实:
- 系统会拒绝非 HTTPS 的 custom_api_url
- 已启用模型如果缺少 API Key 或 custom_api_url会导致 agent 不可用
### 2. 交易所配置与诊断
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
1. 系统时间是否同步
2. API Key / Secret 是否填反或过期
3. IP 白名单是否包含服务器 IP
4. 是否启用了合约/交易权限
5. OKX 是否遗漏 passphrase
- 已知事实:
- OKX 除 API Key 和 Secret 外还需要 passphrase
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
### 3. Trader 启动与运行诊断
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
1. 是否存在可用且启用的模型配置
2. 是否存在可用且启用的交易所配置
3. trader 绑定的 strategy / exchange / model 是否齐全
4. 账户余额和权限是否满足下单要求
5. AI 是否一直返回 wait / hold
- 如果用户问“为什么没有开仓”,要明确区分:
- 系统没启动
- 启动了但 AI 决策为 wait
- 有信号但下单失败
### 4. 交易行为异常诊断
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode
2. 是否为子账户杠杆限制
3. 是否为合约权限或 symbol 不可交易
4. 是否为余额不足或保证金占用过高
- 已知事实:
- Binance 若不是 Hedge Mode可能出现 position side mismatch 或只开单边
- 某些子账户杠杆受限,超过限制会直接报错
### 5. 策略与提示词诊断
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
1. 查看当前 strategy 配置
2. 区分策略模板本身和 trader 上的 custom prompt
3. 必要时预览 prompt 或读取当前保存值后再判断
## 回答格式要求
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
- 配置指导类问题尽量按步骤回答
- 如果已有工具能验证当前状态,先查再下结论
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
}
return `## Multi-turn and Skill-First Operating Mode
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
- If the user is still in the same task, continue the active flow
- Ask only for the minimum missing fields required to proceed
- Require explicit confirmation for destructive or financially sensitive actions
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
## Priority Skills
- skill_model_api_setup / skill_model_config_diagnosis
- skill_exchange_api_setup / skill_exchange_api_diagnosis
- skill_trader_start_diagnosis
- skill_order_execution_diagnosis
- skill_strategy_diagnosis
Known facts:
- custom_api_url must be a valid HTTPS URL
- OKX requires passphrase in addition to API key and secret
- invalid signature / timestamp often means clock skew or mismatched credentials
- missing enabled model or exchange config can block trader startup
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
Response style:
- Diagnostics: symptom -> cause -> checks -> fix
- Setup guidance: step-by-step
- Verify with tools when possible before concluding`
}

View File

@@ -1,291 +0,0 @@
package agent
import "strings"
type SkillDAG struct {
SkillName string
Action string
Steps []SkillDAGStep
}
type SkillDAGStep struct {
ID string
Kind string
RequiredFields []string
OptionalFields []string
Next []string
Terminal bool
}
var skillDAGRegistry = buildSkillDAGRegistry()
func buildSkillDAGRegistry() map[string]SkillDAG {
dags := []SkillDAG{
{
SkillName: "trader_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
{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_bindings",
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", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
{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",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "stop",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
},
},
{
SkillName: "strategy_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: "strategy_management",
Action: "update_prompt",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{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},
},
},
{
SkillName: "strategy_management",
Action: "duplicate",
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_duplicate"}},
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "activate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_endpoint",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
}
registry := make(map[string]SkillDAG, len(dags))
for _, dag := range dags {
dag = normalizeSkillDAG(dag)
if dag.SkillName == "" || dag.Action == "" {
continue
}
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
}
return registry
}
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
dag.SkillName = strings.TrimSpace(dag.SkillName)
dag.Action = strings.TrimSpace(dag.Action)
steps := make([]SkillDAGStep, 0, len(dag.Steps))
for _, step := range dag.Steps {
step.ID = strings.TrimSpace(step.ID)
step.Kind = strings.TrimSpace(step.Kind)
step.RequiredFields = cleanStringList(step.RequiredFields)
step.OptionalFields = cleanStringList(step.OptionalFields)
step.Next = cleanStringList(step.Next)
if step.ID == "" {
continue
}
steps = append(steps, step)
}
dag.Steps = steps
return dag
}
func skillDAGKey(skillName, action string) string {
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
}
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
return dag, ok
}
func listSkillDAGs() []SkillDAG {
out := make([]SkillDAG, 0, len(skillDAGRegistry))
for _, dag := range skillDAGRegistry {
out = append(out, dag)
}
return out
}

View File

@@ -1,51 +0,0 @@
package agent
const skillDAGStepField = "_dag_step"
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok || len(dag.Steps) == 0 {
return SkillDAGStep{}, false
}
stepID := fieldValue(session, skillDAGStepField)
if stepID == "" {
return dag.Steps[0], true
}
for _, step := range dag.Steps {
if step.ID == stepID {
return step, true
}
}
return dag.Steps[0], true
}
func setSkillDAGStep(session *skillSession, stepID string) {
ensureSkillFields(session)
if stepID == "" {
delete(session.Fields, skillDAGStepField)
return
}
session.Fields[skillDAGStepField] = stepID
}
func clearSkillDAGStep(session *skillSession) {
if session == nil || session.Fields == nil {
return
}
delete(session.Fields, skillDAGStepField)
}
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok {
return
}
for _, step := range dag.Steps {
if step.ID != currentStepID || len(step.Next) == 0 {
continue
}
setSkillDAGStep(session, step.Next[0])
return
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +0,0 @@
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

@@ -1,175 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
const (
skillOutcomeSuccess = "success"
skillOutcomeNeedMoreInfo = "need_more_info"
skillOutcomeRecoverableError = "recoverable_error"
skillOutcomeFatalError = "fatal_error"
skillOutcomeNotHandled = "not_handled"
)
type skillOutcome struct {
Skill string `json:"skill"`
Action string `json:"action"`
Status string `json:"status"`
GoalAchieved bool `json:"goal_achieved"`
UserMessage string `json:"user_message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type taskReviewDecision struct {
Route string `json:"route"`
Answer string `json:"answer,omitempty"`
}
func normalizeAtomicSkillAction(skill, action string) string {
action = strings.TrimSpace(strings.ToLower(action))
switch skill {
case "trader_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_running":
return "query_running"
case "query_detail", "query_strategy_binding", "query_exchange_binding", "query_model_binding":
return action
case "query_binding":
return "query_detail"
case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return action
}
case "exchange_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_status":
return action
}
case "model_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_config", "update_prompt":
return action
}
}
return action
}
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
outcome := skillOutcome{
Skill: skill,
Action: action,
Status: skillOutcomeSuccess,
UserMessage: strings.TrimSpace(answer),
Data: data,
}
if activeSession.Name != "" {
outcome.Status = skillOutcomeNeedMoreInfo
outcome.GoalAchieved = false
return outcome
}
lower := strings.ToLower(strings.TrimSpace(answer))
switch {
case lower == "":
outcome.Status = skillOutcomeNotHandled
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
outcome.Status = skillOutcomeRecoverableError
outcome.Error = strings.TrimSpace(answer)
default:
outcome.GoalAchieved = true
}
return outcome
}
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision taskReviewDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return 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 {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
}
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
}
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
if a.aiClient == nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return taskReviewDecision{Route: "replan"}, nil
}
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
outcomeJSON, _ := json.Marshal(outcome)
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
You are reviewing the JSON result returned by one structured skill execution.
Return JSON only. Do not return markdown.
Rules:
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
- 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":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
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 taskReviewDecision{}, err
}
return parseTaskReviewDecision(raw)
}

View File

@@ -1,721 +0,0 @@
package agent
import (
"embed"
"encoding/json"
"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"`
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 {
Description string `json:"description,omitempty"`
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()
if err != nil {
panic(err)
}
return registry
}
func loadSkillRegistry() (map[string]SkillDefinition, error) {
entries, err := embeddedSkillDefinitions.ReadDir("skills")
if err != nil {
return nil, err
}
registry := make(map[string]SkillDefinition, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
if err != nil {
return nil, err
}
var def SkillDefinition
if err := json.Unmarshal(raw, &def); err != nil {
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
}
def = normalizeSkillDefinition(def)
if def.Name == "" {
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
}
registry[def.Name] = def
}
return registry, nil
}
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Name = strings.TrimSpace(def.Name)
def.Kind = strings.TrimSpace(def.Kind)
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))
for key, action := range def.Actions {
key = strings.TrimSpace(key)
if key == "" {
continue
}
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
}
if len(def.ToolMapping) > 0 {
normalized := make(map[string]string, len(def.ToolMapping))
for key, value := range def.ToolMapping {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
normalized[key] = value
}
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
}
func getSkillDefinition(name string) (SkillDefinition, bool) {
def, ok := skillRegistry[strings.TrimSpace(name)]
return def, ok
}
func listSkillNames() []string {
names := make([]string, 0, len(skillRegistry))
for name := range skillRegistry {
names = append(names, name)
}
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,244 +0,0 @@
package agent
import (
"fmt"
"strings"
)
type skillActionRuntime struct {
Skill SkillDefinition
Name string
Action SkillActionDefinition
}
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
def, ok := getSkillDefinition(skillName)
if !ok {
return skillActionRuntime{}, false
}
action = strings.TrimSpace(action)
if action == "" {
return skillActionRuntime{Skill: def}, true
}
actionDef, ok := def.Actions[action]
if !ok {
return skillActionRuntime{}, false
}
return skillActionRuntime{
Skill: def,
Name: action,
Action: actionDef,
}, true
}
func actionNeedsConfirmation(skillName, action string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
return runtime.Action.NeedsConfirmation
}
func actionRequiresSlot(skillName, action, slot string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
slot = strings.TrimSpace(slot)
for _, candidate := range runtime.Action.RequiredSlots {
if candidate == slot {
return true
}
}
return false
}
func slotDisplayName(slot, lang string) string {
slot = strings.TrimSpace(slot)
if lang != "zh" {
switch slot {
case "target_ref":
return "target"
case "name":
return "name"
case "exchange":
return "exchange"
case "model":
return "model"
case "strategy":
return "strategy"
case "exchange_type":
return "exchange type"
case "provider":
return "provider"
default:
return slot
}
}
switch slot {
case "target_ref":
return "目标对象"
case "name":
return "名称"
case "exchange":
return "交易所"
case "model":
return "模型"
case "strategy":
return "策略"
case "exchange_type":
return "交易所类型"
case "provider":
return "模型提供商"
default:
return slot
}
}
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
actionLabel := action
if lang == "zh" {
switch action {
case "start":
actionLabel = "启动"
case "stop":
actionLabel = "停止"
case "delete":
actionLabel = "删除"
case "activate":
actionLabel = "激活"
default:
actionLabel = action
}
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作请回复“确认”继续回复“取消”终止。", actionLabel, targetLabel)
}
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 "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
}
return "This flow is still waiting for your confirmation."
}
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, a.formatConfirmationTargetLabel(userID, lang, session, targetLabel)), true
}
return "", false
}
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
return "", false
}
if isYesReply(text) {
return "", false
}
return formatStillWaitingConfirmationMessage(lang), true
}

View File

@@ -1,246 +0,0 @@
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

@@ -1,18 +0,0 @@
{
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"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

@@ -1,207 +0,0 @@
{
"name": "exchange_management",
"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": "创建新的交易所配置。根据 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": "更新已有交易所配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"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": "删除交易所配置,不可逆操作,必须确认。",
"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_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_list": "get_exchange_configs",
"query_detail": "get_exchange_configs"
}
}

View File

@@ -1,17 +0,0 @@
{
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"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

@@ -1,157 +0,0 @@
{
"name": "model_management",
"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"],
"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": "更新已有模型配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"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": "删除模型配置,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "删除一个模型配置。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。"
],
"success_output": "返回删除成功结果,并明确告知该模型配置已被移除。",
"failure_output": "明确指出缺少确认、目标模型不存在,或删除失败原因。"
},
"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_list": "get_model_configs",
"query_detail": "get_model_configs"
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"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

@@ -1,473 +0,0 @@
{
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"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": ["strategy_type", "config_patch"],
"goal": "创建一个可供 trader 绑定使用的策略模板。",
"success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。",
"failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。"
},
"update": {
"description": "更新策略模板的任意可编辑字段。",
"required_slots": ["target_ref"],
"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": "明确指出目标策略不存在、参数非法,或仍缺哪个关键字段。"
},
"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"],
"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"],
"goal": "将某个策略模板设为默认模板。",
"success_output": "返回 strategy_id并明确告知该策略已被设为默认模板。",
"failure_output": "明确指出目标策略不存在,或激活失败原因。"
},
"duplicate": {
"description": "复制策略模板,生成一个新的同配置模板。",
"required_slots": ["target_ref", "name"],
"goal": "复制一个现有策略模板并生成新的模板名称。",
"dynamic_rules": [
"新名称必须单独收齐;若名称有歧义或为空,应先继续追问。"
],
"success_output": "返回新的 strategy_id并明确告知复制后的策略名称。",
"failure_output": "明确指出目标策略不存在,或新名称仍未收齐。"
},
"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",
"update_name": "manage_strategy:update",
"update_prompt": "manage_strategy:update",
"update_config": "manage_strategy:update",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"delete": "manage_strategy:delete",
"query_list": "get_strategies",
"query_detail": "get_strategies"
}
}

View File

@@ -1,63 +0,0 @@
{
"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

@@ -1,39 +0,0 @@
{
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"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

@@ -1,231 +0,0 @@
{
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略。编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
"删除交易员",
"启动交易员",
"停止交易员",
"查询交易员"
],
"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": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 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": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"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": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"
},
"update_bindings": {
"description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"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": "启动交易员,使其开始自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "让一个已配置好的交易员进入运行状态。",
"dynamic_rules": [
"启动前系统会自动校验绑定的交易所、模型、策略是否均可用。",
"若绑定模型为 claw402 或 blockrun-base 且钱包余额不足,应提示充值或换模型;不要把它泛化成“模型不可用”。",
"若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。"
],
"success_output": "返回 trader_id并明确告知交易员已开始运行。",
"failure_output": "明确指出缺少确认、依赖资源不可用,或启动未通过校验。"
},
"stop": {
"description": "停止交易员,使其停止自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "让一个运行中的交易员停止自动交易。",
"dynamic_rules": [
"若交易员当前并未运行,也应给用户清晰说明,而不是假装停止成功。"
],
"success_output": "返回 trader_id并明确告知交易员已停止。",
"failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。"
},
"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",
"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",
"delete": "manage_trader:delete",
"query_list": "manage_trader:list",
"query_running": "manage_trader:list",
"query_detail": "manage_trader:list"
}
}

View File

@@ -1,444 +0,0 @@
package agent
import (
"nofx/safe"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// stockHTTPClient is a shared HTTP client for stock API requests.
// Reused across calls for connection pooling.
var stockHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
}
// StockQuote holds real-time stock data.
type StockQuote struct {
Name string
Code string
Market string // "A股", "港股", "美股"
Currency string // "CNY", "HKD", "USD"
Open float64
PrevClose float64
Price float64
High float64
Low float64
Volume float64
Turnover float64
Date string
Time string
Change float64
ChangePct float64
// 盘前盘后 (美股)
ExtPrice float64 // 盘前/盘后价格
ExtChangePct float64 // 盘前/盘后涨跌幅%
ExtChange float64 // 盘前/盘后涨跌额
ExtTime string // 盘前/盘后时间
IsExtHours bool // 是否在盘前盘后时段
}
// knownStocks maps Chinese names to stock codes.
var knownStocks = map[string]string{
// A股
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
"三六零": "sh601360", "中兴通讯": "sz000063",
// 港股
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
// 华为 is not publicly listed — removed incorrect Tencent fallback
// 美股
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
}
// US stock ticker mapping
var usTickerMap = map[string]string{
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
}
func resolveStockCode(text string) (string, string) {
// Known Chinese names
for name, code := range knownStocks {
if strings.Contains(text, name) {
return code, name
}
}
// US ticker symbols (uppercase)
upper := strings.ToUpper(text)
for ticker, code := range usTickerMap {
if strings.Contains(upper, ticker) {
return code, ticker
}
}
// 6-digit A-share code
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
prefix := "sz"
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
return prefix + w, w
}
}
// 5-digit HK code
if len(w) == 5 {
if _, err := strconv.Atoi(w); err == nil {
return "hk" + w, w
}
}
}
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil { return nil, err }
line := string(body)
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
data := line[start+1 : end]
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
return parseAShare(code, data)
} else if strings.HasPrefix(code, "hk") {
return parseHKShare(code, data)
} else if strings.HasPrefix(code, "gb_") {
return parseUSShare(code, data)
}
return nil, fmt.Errorf("unsupported market: %s", code)
}
func parseAShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
q.Open, _ = strconv.ParseFloat(f[1], 64)
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
q.Price, _ = strconv.ParseFloat(f[3], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Volume, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
q.Date = f[30]; q.Time = f[31]
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
return q, nil
}
func parseHKShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
q.Open, _ = strconv.ParseFloat(f[2], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Price, _ = strconv.ParseFloat(f[6], 64)
q.Change, _ = strconv.ParseFloat(f[7], 64)
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
q.Volume, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
return q, nil
}
func parseUSShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
q.Price, _ = strconv.ParseFloat(f[1], 64)
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
q.Change, _ = strconv.ParseFloat(f[4], 64)
q.Open, _ = strconv.ParseFloat(f[5], 64)
q.High, _ = strconv.ParseFloat(f[6], 64)
q.Low, _ = strconv.ParseFloat(f[7], 64)
// 52wk high/low
high52, _ := strconv.ParseFloat(f[8], 64)
low52, _ := strconv.ParseFloat(f[9], 64)
q.Volume, _ = strconv.ParseFloat(f[10], 64)
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
q.PrevClose = q.Price - q.Change
_ = high52; _ = low52
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
if len(f) > 24 {
extPrice, _ := strconv.ParseFloat(f[21], 64)
extPct, _ := strconv.ParseFloat(f[22], 64)
extChg, _ := strconv.ParseFloat(f[23], 64)
if extPrice > 0 {
q.ExtPrice = extPrice
q.ExtChangePct = extPct
q.ExtChange = extChg
q.ExtTime = strings.TrimSpace(f[24])
q.IsExtHours = true
}
}
return q, nil
}
func formatStockQuote(q *StockQuote) string {
emoji := "🟢"
if q.ChangePct < 0 { emoji = "🔴" }
sym := "¥"
if q.Currency == "USD" { sym = "$" }
if q.Currency == "HKD" { sym = "HK$" }
volStr := fmt.Sprintf("%.0f", q.Volume)
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
turnStr := fmt.Sprintf("%.0f", q.Turnover)
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
result := fmt.Sprintf(`%s *%s* (%s · %s)
💰 现价: %s%.2f (%+.2f%%)
📊 开盘: %s%.2f | 昨收: %s%.2f
📈 最高: %s%.2f | 最低: %s%.2f
📦 成交: %s | 额: %s
🕐 %s`,
emoji, q.Name, q.Code, q.Market,
sym, q.Price, q.ChangePct,
sym, q.Open, sym, q.PrevClose,
sym, q.High, sym, q.Low,
volStr, turnStr,
q.Date)
// 盘前盘后数据
if q.IsExtHours && q.ExtPrice > 0 {
extEmoji := "🟢"
if q.ExtChangePct < 0 { extEmoji = "🔴" }
extLabel := "🌙 盘后"
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
extLabel = "🌅 盘前"
}
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
}
return result
}

View File

@@ -1,27 +0,0 @@
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

@@ -1,224 +0,0 @@
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",
}
}

View File

@@ -1,49 +0,0 @@
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,538 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"nofx/market"
"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"`
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.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
// Only append USDT for crypto symbols, not stock tickers
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
wantStock, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade)
if err != nil {
return err
}
if err := validateTradeAction(trade, wantStock, selectedTrader, underlyingTrader); err != nil {
return err
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, 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")
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
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 isMajorTradeSymbol(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")
}
// isMajorTradeSymbol mirrors trader/auto_trader_risk.isMajorAsset for the
// chat-execute path. BTC/ETH crypto perps and Hyperliquid XYZ assets
// (US stocks, commodities, forex) get the higher BTC/ETH risk tier — their
// per-position caps should not be clamped to the 1x altcoin tier.
func isMajorTradeSymbol(symbol string) bool {
if isBTCETHSymbol(symbol) {
return true
}
return market.IsXyzDexAsset(symbol)
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
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
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
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
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
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]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
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"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +0,0 @@
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

@@ -1,3 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,373 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"nofx/safe"
"regexp"
"time"
)
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 {
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
}
func storeUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
return v
}
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}$`)
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
// binanceClient is a shared HTTP client for proxying Binance API requests.
// Reused across requests to benefit from connection pooling.
var binanceClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// WebHandler provides HTTP endpoints for the NOFXi agent.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
// Limit request body to 64KB to prevent abuse
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
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": "I ran into a problem while handling that message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
// Sends server-sent events with types including planning, plan, step_start,
// step_complete, replan, tool, delta, done, error.
func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
// Set SSE headers
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
rw.WriteHeader(200)
flusher, ok := rw.(http.Flusher)
if !ok {
writeSSE(rw, nil, "error", "streaming not supported")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
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", "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
writeSSE(rw, flusher, "done", resp)
}
// writeSSE writes a single SSE event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
if flusher != nil {
flusher.Flush()
}
}
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
func sseEscape(s string) string {
// SSE spec: multi-line data uses multiple "data:" lines
// But we use JSON encoding to avoid this complexity
b, _ := json.Marshal(s)
return string(b)
}
// HandleKlines proxies kline data from Binance.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
if !validIntervalRe.MatchString(interval) {
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
// Batch endpoint: fetches multiple tickers concurrently, returns array.
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
symbolsParam := r.URL.Query().Get("symbols")
if symbolsParam == "" {
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
}
// Validate symbols
var symbols []string
for _, s := range splitComma(symbolsParam) {
if validSymbolRe.MatchString(s) {
symbols = append(symbols, s)
}
}
if len(symbols) == 0 {
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
return
}
if len(symbols) > 20 {
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
return
}
// Fetch all tickers concurrently with context propagation
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
type result struct {
idx int
data json.RawMessage
}
results := make(chan result, len(symbols))
for i, sym := range symbols {
idx, s := i, sym
safe.GoNamed("ticker-fetch-"+s, func() {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
if err != nil {
results <- result{idx: idx}
return
}
resp, err := binanceClient.Do(req)
if err != nil {
results <- result{idx: idx}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
results <- result{idx: idx}
return
}
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
if err != nil {
results <- result{idx: idx}
return
}
results <- result{idx: idx, data: body}
})
}
// Collect results in order
ordered := make([]json.RawMessage, len(symbols))
for range symbols {
r := <-results
if r.data != nil {
ordered[r.idx] = r.data
}
}
// Filter out nil entries and write response
out := make([]json.RawMessage, 0, len(ordered))
for _, d := range ordered {
if d != nil {
out = append(out, d)
}
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(out)
}
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
var commaRe = regexp.MustCompile(`\s*,\s*`)
// splitComma splits a comma-separated string, trims whitespace, skips empty.
func splitComma(s string) []string {
var parts []string
for _, p := range commaRe.Split(s, -1) {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
return
}
resp, err := binanceClient.Do(req)
if err != nil {
// Distinguish client cancellation from upstream failures
if ctx.Err() != nil {
return // Client disconnected, no point writing response
}
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// Forward upstream error status codes instead of silently proxying bad data
if resp.StatusCode != http.StatusOK {
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
return
}
rw.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
// Limit response body to 2MB to prevent memory exhaustion
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -1,959 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
workflowTaskPending = "pending"
workflowTaskRunning = "running"
workflowTaskCompleted = "completed"
workflowTaskFailed = "failed"
)
type WorkflowTask struct {
ID string `json:"id,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Request string `json:"request,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
}
type WorkflowSession struct {
UserID int64 `json:"user_id"`
OriginalRequest string `json:"original_request,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type workflowDecomposition struct {
Tasks []WorkflowTask `json:"tasks"`
}
func workflowSessionConfigKey(userID int64) string {
return fmt.Sprintf("agent_workflow_session_%d", userID)
}
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
normalized := make([]WorkflowTask, 0, len(session.Tasks))
for i, task := range session.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
task.Status = strings.TrimSpace(task.Status)
if task.Status == "" {
task.Status = workflowTaskPending
}
task.Error = strings.TrimSpace(task.Error)
if task.Skill == "" || task.Action == "" || task.Request == "" {
continue
}
normalized = append(normalized, task)
}
session.Tasks = normalized
if len(session.Tasks) == 0 {
return WorkflowSession{}
}
if session.UpdatedAt == "" {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return session
}
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
if a.store == nil {
return WorkflowSession{}
}
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return WorkflowSession{}
}
var session WorkflowSession
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return WorkflowSession{}
}
return normalizeWorkflowSession(session)
}
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
if a.store == nil {
return
}
session = normalizeWorkflowSession(session)
if len(session.Tasks) == 0 {
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
return
}
session.UserID = userID
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(session)
if err != nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
}
func (a *Agent) clearWorkflowSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
}
func hasActiveWorkflowSession(session WorkflowSession) bool {
if len(session.Tasks) == 0 {
return false
}
for _, task := range session.Tasks {
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
return true
}
}
return false
}
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
for i, task := range session.Tasks {
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
continue
}
depsReady := true
for _, dep := range task.DependsOn {
ok := false
for _, candidate := range session.Tasks {
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
ok = true
break
}
}
if !ok {
depsReady = false
break
}
}
if depsReady {
return task, i, true
}
}
return WorkflowTask{}, -1, false
}
func supportedWorkflowSkill(skill, action string) bool {
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
if skill == "" || action == "" {
return false
}
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":
if action == "query_running" {
return true
}
}
return false
}
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)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
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, "")
a.saveWorkflowSession(userID, session)
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
if final != "" && answer != "" {
return answer + "\n\n" + final, true, err
}
if answer != "" {
return answer, true, err
}
return final, true, err
}
}
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 {
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
a.clearWorkflowSession(userID)
if summary == "" {
if lang == "zh" {
summary = "已完成当前任务流。"
} else {
summary = "Completed the current workflow."
}
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
emitStreamText(onEvent, summary)
}
return summary, true, nil
}
session.Tasks[index].Status = workflowTaskRunning
a.saveWorkflowSession(userID, session)
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
a.saveSkillSession(userID, taskSession)
if onEvent != nil {
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
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)
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
if answer != "" && more != "" {
return answer + "\n\n" + more, true, err
}
if answer != "" {
return answer, true, err
}
return more, true, err
}
}
return answer, true, nil
}
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
for i := range session.Tasks {
if session.Tasks[i].Status == workflowTaskRunning {
session.Tasks[i].Status = status
session.Tasks[i].Error = strings.TrimSpace(errMsg)
return session
}
}
return session
}
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
parts := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
label := task.Request
if label == "" {
label = task.Skill + ":" + task.Action
}
switch task.Status {
case workflowTaskCompleted:
label = "✓ " + label
case workflowTaskRunning:
label = "→ " + label
default:
label = "· " + label
}
parts = append(parts, label)
}
if lang == "zh" {
return "任务流:" + strings.Join(parts, " | ")
}
return "Workflow: " + strings.Join(parts, " | ")
}
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
completed := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
if task.Status == workflowTaskCompleted {
completed = append(completed, task.Request)
}
}
if len(completed) == 0 {
return ""
}
if a.aiClient == nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
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.
` + 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{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
return strings.TrimSpace(raw)
}
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
if !looksLikeMultiTaskIntent(text) {
return workflowDecomposition{}, nil
}
if a.aiClient != nil {
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
return dec, nil
}
}
return a.decomposeWorkflowIntentFallback(text), nil
}
func looksLikeMultiTaskIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
connectors := []string{"", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
count := 0
for _, c := range connectors {
if strings.Contains(lower, c) {
count++
}
}
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 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)
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),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return workflowDecomposition{}, err
}
return parseWorkflowDecomposition(raw)
}
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out workflowDecomposition
if err := json.Unmarshal([]byte(raw), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
}
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
}
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
normalized := make([]WorkflowTask, 0, len(out.Tasks))
for i, task := range out.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
continue
}
task.Status = workflowTaskPending
normalized = append(normalized, task)
}
out.Tasks = normalized
return out
}
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
nextID := 1
for _, segment := range segments {
prevSkill := ""
if len(tasks) > 0 {
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++
}
}
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 "}
for _, sep := range separators {
next := make([]string, 0, len(parts))
for _, part := range parts {
split := strings.Split(part, sep)
for _, candidate := range split {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
next = append(next, candidate)
}
}
}
parts = next
}
return parts
}
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
segment := strings.TrimSpace(text)
if segment == "" {
return WorkflowTask{}, false
}
lower := strings.ToLower(segment)
switch {
case hasExplicitCreateIntentForDomain(segment, "trader"):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
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 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 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 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) {
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
}
}
return WorkflowTask{}, false
}

View File

@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"nofx/agent"
"github.com/gin-gonic/gin"
)
type agentPreferencePayload struct {
Text string `json:"text"`
}
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
if err != nil || strings.TrimSpace(raw) == "" {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
var req agentPreferencePayload
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prefs := s.loadAgentPreferences(uid)
prefs = append([]agent.PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := s.saveAgentPreferences(uid, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
prefs := s.loadAgentPreferences(uid)
filtered := prefs[:0]
for _, pref := range prefs {
if pref.ID != id {
filtered = append(filtered, pref)
}
}
if err := s.saveAgentPreferences(uid, filtered); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
}
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return []agent.PersistentPreference{}
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
return []agent.PersistentPreference{}
}
return prefs
}
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
}

View File

@@ -1,42 +0,0 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
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) {
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) {
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
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
}

View File

@@ -1,7 +1,6 @@
package api
import (
"log"
"net/http"
"nofx/config"
"nofx/crypto"
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
})
}
// ==================== Encrypted Data Decryption Endpoint ====================
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
var payload crypto.EncryptedPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// Decrypt
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
if err != nil {
log.Printf("❌ Decryption failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
return
}
c.JSON(http.StatusOK, map[string]string{
"plaintext": decrypted,
})
}
// ==================== Encrypted Data Decryption ====================
//
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
// encryption is one-directional — clients encrypt sensitive fields to the
// server's RSA public key and the authenticated config-update handlers
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
// generic decrypt route would turn the server into a decryption oracle that any
// unauthenticated caller could use to recover the plaintext of a captured
// ciphertext, defeating the entire transport-encryption layer.
// ==================== Audit Log Query Endpoint ====================

View File

@@ -38,13 +38,19 @@ type SafeModelConfig struct {
BalanceUSDC string `json:"balanceUsdc,omitempty"`
}
// ModelConfigUpdate is a single model's update payload. It is a named type
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
// guaranteed to stay in sync with this shape — a mismatch there is what let
// plaintext credentials reach the logs previously.
type ModelConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}
type UpdateModelConfigRequest struct {
Models map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
} `json:"models"`
Models map[string]ModelConfigUpdate `json:"models"`
}
// handleGetModelConfigs Get AI model configurations
@@ -225,7 +231,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// Don't return error here since model config was successfully updated to database
}
logger.Infof("✓ AI model config updated: %+v", req.Models)
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
}

View File

@@ -165,34 +165,80 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
}
// Use the balance of the first record as initial balance to calculate return rate
initialBalance := snapshots[0].Balance
initialBalance := trader.InitialBalance
if initialBalance <= 0 {
initialBalance = snapshots[0].TotalEquity
}
if initialBalance == 0 {
initialBalance = 1 // Avoid division by zero
}
var history []EquityPoint
var lastSnapshotTime time.Time
for _, snap := range snapshots {
// Calculate PnL percentage
totalPnL := snap.TotalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
TotalEquity: snap.TotalEquity,
AvailableBalance: snap.Balance,
TotalPnL: snap.UnrealizedPnL,
AvailableBalance: equitySnapshotAvailableBalance(snap),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: snap.PositionCount,
MarginUsedPct: snap.MarginUsedPct,
})
if snap.Timestamp.After(lastSnapshotTime) {
lastSnapshotTime = snap.Timestamp
}
}
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
TotalEquity: totalEquity,
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: int(floatFromMap(accountInfo, "position_count")),
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
})
}
}
c.JSON(http.StatusOK, history)
}
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
if snap == nil {
return 0
}
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
return snap.AvailableBalance
}
return snap.Balance
}
func floatFromMap(values map[string]interface{}, key string) float64 {
if value, ok := values[key].(float64); ok {
return value
}
if value, ok := values[key].(int); ok {
return float64(value)
}
return 0
}
// handlePublicTraderList Get public trader list (no authentication required)
func (s *Server) handlePublicTraderList(c *gin.Context) {
// Get trader information from all users
@@ -386,18 +432,20 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
history := make([]map[string]interface{}, 0, len(snapshots)+1)
var lastSnapshotTime time.Time
for _, snap := range snapshots {
totalPnL := snap.TotalEquity - initialBalance
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
pnlPct = totalPnL / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": snap.Timestamp,
"total_equity": snap.TotalEquity,
"total_pnl": snap.UnrealizedPnL,
"total_pnl_pct": pnlPct,
"balance": snap.Balance,
"timestamp": snap.Timestamp,
"total_equity": snap.TotalEquity,
"available_balance": equitySnapshotAvailableBalance(snap),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": snap.Balance,
})
if snap.Timestamp.After(lastSnapshotTime) {
lastSnapshotTime = snap.Timestamp
@@ -410,29 +458,21 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
if accountInfo, err := trader.GetAccountInfo(); err == nil {
// Only append if it's been more than 30 seconds since last snapshot
if now.Sub(lastSnapshotTime) > 30*time.Second {
totalEquity := 0.0
if v, ok := accountInfo["total_equity"].(float64); ok {
totalEquity = v
}
totalPnL := 0.0
if v, ok := accountInfo["total_pnl"].(float64); ok {
totalPnL = v
}
walletBalance := 0.0
if v, ok := accountInfo["wallet_balance"].(float64); ok {
walletBalance = v
}
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
walletBalance := floatFromMap(accountInfo, "wallet_balance")
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": now,
"total_equity": totalEquity,
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": walletBalance,
"timestamp": now,
"total_equity": totalEquity,
"available_balance": floatFromMap(accountInfo, "available_balance"),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": walletBalance,
})
}
}

View File

@@ -37,6 +37,7 @@ type SafeExchangeConfig struct {
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
@@ -59,6 +60,7 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
@@ -69,24 +71,30 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
}
}
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
// named type (rather than an inline anonymous struct) so the log-sanitizer in
// utils.go is guaranteed to cover every sensitive field — a drift between the
// two shapes is what let passphrases / private keys reach the logs previously.
type ExchangeConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
} `json:"exchanges"`
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
}
// CreateExchangeRequest request structure for creating a new exchange account
@@ -99,7 +107,7 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
@@ -141,6 +149,19 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
c.JSON(http.StatusOK, safeExchanges)
}
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
if requested != nil {
return *requested
}
if strings.EqualFold(exchangeType, "hyperliquid") {
if len(fallback) > 0 {
return fallback[0]
}
return true
}
return false
}
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
@@ -249,6 +270,11 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
if exchangeData.HyperliquidBuilderApproved != nil {
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
}
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
existing.ExchangeType,
exchangeData.HyperliquidUnifiedAcct,
existing.HyperliquidUnifiedAcct,
)
if missing := store.MissingRequiredExchangeCredentialFields(
existing.ExchangeType,
@@ -275,7 +301,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
tradersToReload[t.ID] = true
}
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
@@ -297,7 +323,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// Don't return error here since exchange config was successfully updated to database
}
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
}
@@ -381,10 +407,11 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
}
// Exchange configs only persist once complete; persisted configs are always enabled.
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, true,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)

View File

@@ -18,6 +18,7 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T
APIKey: crypto.EncryptedString("api-test-123"),
SecretKey: crypto.EncryptedString("secret-test-123"),
Passphrase: crypto.EncryptedString("passphrase-test-123"),
HyperliquidUnifiedAcct: true,
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
@@ -42,4 +43,24 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T
if !safe.HasLighterAPIKey {
t.Fatalf("expected has_lighter_api_key_private_key to be true")
}
if !safe.HyperliquidUnifiedAcct {
t.Fatalf("expected hyperliquid unified account to be exposed")
}
}
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
t.Fatalf("expected new hyperliquid accounts to default unified account on")
}
if effectiveHyperliquidUnifiedAccount("binance", nil) {
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
}
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
if fallbackFalse {
t.Fatalf("expected omitted update field to preserve existing false value")
}
requestedTrue := true
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
t.Fatalf("expected explicit true to override existing false value")
}
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -15,12 +16,15 @@ import (
const (
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
// 0.05% (5) — matches BuilderInfo.Fee=50 charged at order placement.
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
// New wallet approvals sign this exact value; existing approvals at the
// prior 0.1% cap remain valid because 0.05% is within their approved max.
defaultHyperliquidBuilderMaxFee = "0.05%"
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
nofxHyperliquidAgentName = "NOFX Agent"
)
type hyperliquidSubmitRequest struct {
@@ -50,6 +54,19 @@ type hyperliquidAccountSummary struct {
UpdatedAt int64 `json:"updatedAt"`
}
type hyperliquidAgentInfo struct {
Name string `json:"name"`
Address string `json:"address"`
ValidUntil int64 `json:"validUntil"` // unix milliseconds
}
type hyperliquidAgentResponse struct {
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
Agent *hyperliquidAgentInfo `json:"agent"`
// Agents lists every approved agent for the wallet (for visibility/cleanup).
Agents []hyperliquidAgentInfo `json:"agents"`
}
type hyperliquidClearinghouseState struct {
MarginSummary struct {
AccountValue string `json:"accountValue"`
@@ -68,6 +85,15 @@ type hyperliquidClearinghouseState struct {
} `json:"assetPositions"`
}
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
// it from the stored name, but we strip defensively before matching the slot.
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
func baseAgentName(name string) string {
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
}
func hyperliquidBuilderAddress() string {
return defaultHyperliquidBuilderAddress
}
@@ -159,6 +185,64 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) {
})
}
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
// including the NOFX agent's validUntil so the UI can show the expiry date and
// warn before the 180-day authorization lapses.
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
return
}
// extraAgents returns null when no agents are approved.
agents := []hyperliquidAgentInfo{}
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
if err := json.Unmarshal(respBody, &agents); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
return
}
}
out := hyperliquidAgentResponse{Agents: agents}
for i := range agents {
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
agent := agents[i]
out.Agent = &agent
break
}
}
c.JSON(http.StatusOK, out)
}
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
var req hyperliquidSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -223,6 +307,24 @@ func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
return
}
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
// {"status":"err","response":"<message>"}. Without this check a rejected
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
// to the user as success while nothing changes on-chain.
var hlResp struct {
Status string `json:"status"`
Response json.RawMessage `json:"response"`
}
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
if msg == "" {
msg = "Hyperliquid rejected the action"
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
}

View File

@@ -3,6 +3,7 @@ package api
import (
"net/http"
"strconv"
"strings"
"nofx/logger"
"nofx/market"
@@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
return
}
userID := c.GetString("user_id")
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
if syncErr := s.syncOrdersFromExchange(
trader.GetUnderlyingTrader(),
trader.GetID(),
fullCfg.Exchange.ID,
fullCfg.Exchange.ExchangeType,
); syncErr != nil {
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
}
}
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
// Older one-click launches created new Autopilot trader rows. When a row was
// deleted, its closed position records remained under the old generated ID.
// The generated Autopilot ID embeds userID + "claw402", so this safely
// restores same-user history continuity without joining deleted rows.
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
// Get closed positions
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
if err != nil {
SafeInternalError(c, "Get position history", err)
return
}
// Get statistics
stats, _ := store.Position().GetFullStats(trader.GetID())
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
// Get symbol stats
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
// Get direction stats
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
c.JSON(http.StatusOK, gin.H{
"positions": positions,

58
api/handler_stats_full.go Normal file
View File

@@ -0,0 +1,58 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handleStatisticsFull returns the full set of computed performance metrics for
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
// average win/loss amounts. These are derived from the trader's CLOSED positions
// via store.Position().GetFullStatsByTraderFilters — the same computation the
// strategy engine feeds to the AI, so the dashboard and the model see identical
// numbers.
//
// The existing GET /statistics endpoint only returns cycle/position counts; this
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
func (s *Server) handleStatisticsFull(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
// Aggregate across the trader's historical IDs exactly like the position
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
// create fresh trader rows, but the closed positions stay under the old
// generated IDs (which embed userID + "claw402"). Without this, a freshly
// relaunched Autopilot would report only the current incarnation's trades
// instead of its real lifetime history.
userID := c.GetString("user_id")
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
if err != nil {
SafeInternalError(c, "Get full statistics", err)
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -61,21 +61,21 @@ type UpdateTraderRequest struct {
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s", reason)
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s", reason, nextStep)
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
@@ -90,15 +90,15 @@ func isSupportedTraderSymbol(symbol string) bool {
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
return "the selected exchange account"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
return "the selected exchange account"
}
func missingExchangeFields(exchange *store.Exchange) []string {
@@ -127,10 +127,10 @@ func missingExchangeFields(exchange *store.Exchange) []string {
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "私钥")
missing = append(missing, "Private Key")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
missing = append(missing, "Wallet Address")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
@@ -144,7 +144,7 @@ func missingExchangeFields(exchange *store.Exchange) []string {
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "钱包地址")
missing = append(missing, "Wallet Address")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
@@ -168,21 +168,21 @@ func mapStringPairs(kv ...string) map[string]string {
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
@@ -194,8 +194,8 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
"Please switch to an exchange account supported by the current version, then create the bot again",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
@@ -214,28 +214,28 @@ func classifyTraderSetupReason(reason string) (string, string) {
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster UserSigner 和私钥是否正确"
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
default:
return "trader.reason.unknown", trimmed
}
@@ -270,54 +270,54 @@ func traderSetupReasonParams(err error, fallback string, kv ...string) map[strin
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动原因是%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存但当前还没有通过启动前校验。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动原因是%s。请先检查模型、策略和交易所配置修正后再点击启动。", traderName, reason)
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动原因是%s。请检查模型、策略和交易所配置后再重新点击启动。", traderName, reason)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s", reason)
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
}
return fmt.Sprintf("这次未能启动机器人:%s。%s", reason, nextStep)
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
}
// handleCreateTrader Create new AI trader
@@ -325,7 +325,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
return
}
@@ -344,7 +344,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
symbol = strings.TrimSpace(symbol)
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的SYMBOL-USDC", symbol),
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
@@ -354,32 +354,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
return
}
@@ -387,11 +387,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
@@ -434,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// Set scan interval default value
scanIntervalMinutes := req.ScanIntervalMinutes
if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = 15
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
}
// Query exchange actual balance, override user input
@@ -443,7 +445,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
@@ -467,9 +469,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」没有通过初始化校验原因是%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
@@ -519,9 +521,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
@@ -779,8 +781,8 @@ func (s *Server) handleStartTrader(c *gin.Context) {
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
@@ -816,25 +818,25 @@ func (s *Server) handleStartTrader(c *gin.Context) {
}
// Check AI model
if fullCfg.AIModel == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
"Please go to \"Settings > Model Config\" to enable it, then click start again",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}

View File

@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Record order to database (for chart markers and history)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
// Backfill the just-closed fill immediately. Manual closes may happen while
// the bot runtime is stopped, so the background OrderSync loop is not enough.
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
}
c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully",
@@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) {
})
}
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
switch t := exchangeTrader.(type) {
case *binance.FuturesTrader:
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
case *hyperliquidtrader.HyperliquidTrader:
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
case *aster.AsterTrader:
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
case *bybit.BybitTrader:
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
case *okx.OKXTrader:
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
case *bitget.BitgetTrader:
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
case *gate.GateTrader:
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
case *kucoin.KuCoinTrader:
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
case *lighter.LighterTraderV2:
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
default:
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
}
}
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
if attempt > 1 {
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
}
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
lastErr = err
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("manual close sync did not run")
}
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates

View File

@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Password string `json:"password" binding:"required,min=8"`
Lang string `json:"lang"`
}
@@ -129,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
})
}
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
// against when the submitted email does not exist so that login takes roughly
// the same time whether or not the account exists — closing the timing side
// channel that would otherwise let an attacker enumerate valid emails (a fast
// "no such user" vs. a slow bcrypt compare). It is not a secret.
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
// handleLogin Handle user login request
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
@@ -144,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
// Get user information
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
// Perform a dummy comparison so the response time does not reveal
// whether the email exists (anti user-enumeration), then fail uniformly.
auth.CheckPassword(req.Password, dummyPasswordHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
@@ -215,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
name, description string
}
type strategyLocale struct {
trend, megaCap, breakout strategyI18n
defaultStrategy strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
trend: strategyI18n{"美股趋势策略", "开箱即用的 Hyperliquid 美股 USDC 策略。只扫描流动性更好的美股合约,低杠杆、低频率,适合直接创建 Agent 后运行。"},
megaCap: strategyI18n{"美股大盘稳健策略", "开箱即用的 Hyperliquid 美股 USDC 策略。固定关注 AAPL、MSFT、GOOGL、AMZN、META 等大盘股,强调趋势确认和回撤控制。"},
breakout: strategyI18n{"美股突破策略", "开箱即用的 Hyperliquid 美股 USDC 策略。扫描 24h 强势美股,等待突破确认后再开仓,避免频繁追涨。"},
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"en": {
trend: strategyI18n{"US Stock Trend Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans liquid US stock perps with low leverage and low trade frequency, suitable for one-click Agent deployment."},
megaCap: strategyI18n{"US Mega-Cap Steady Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Fixed universe: AAPL, MSFT, GOOGL, AMZN and META, with trend confirmation and drawdown control."},
breakout: strategyI18n{"US Stock Breakout Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans 24h strong US stocks and waits for breakout confirmation before entering, avoiding impulsive chasing."},
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"id": {
trend: strategyI18n{"Strategi Tren Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai perp saham AS likuid dengan leverage rendah dan frekuensi rendah."},
megaCap: strategyI18n{"Strategi Stabil Mega-Cap AS", "Strategi saham AS USDC Hyperliquid siap jalan. Universe tetap: AAPL, MSFT, GOOGL, AMZN, META, dengan konfirmasi tren."},
breakout: strategyI18n{"Strategi Breakout Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai saham AS kuat 24 jam dan menunggu konfirmasi breakout."},
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
},
}
locale, ok := locales[lang]
@@ -246,76 +250,42 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
applyConfig func(*store.StrategyConfig)
}
setStockRank := func(c *store.StrategyConfig, direction string, limit int) {
c.CoinSource.SourceType = "hyper_rank"
setClaw402Strategy := func(c *store.StrategyConfig) {
c.CoinSource.SourceType = "vergex_signal"
c.CoinSource.StaticCoins = nil
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
c.CoinSource.HyperRankCategory = "stock"
c.CoinSource.HyperRankDirection = direction
c.CoinSource.HyperRankLimit = limit
}
setStaticStocks := func(c *store.StrategyConfig, symbols []string) {
c.CoinSource.SourceType = "static"
c.CoinSource.StaticCoins = symbols
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
}
setStableRisk := func(c *store.StrategyConfig) {
c.CoinSource.HyperRankCategory = "all"
c.CoinSource.VergexLimit = 10
c.CoinSource.VergexMarketType = "all"
c.CoinSource.VergexChain = "hyperliquid"
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 3
c.RiskControl.AltcoinMaxLeverage = 3
c.RiskControl.BTCETHMaxPositionValueRatio = 2.0
c.RiskControl.AltcoinMaxPositionValueRatio = 0.6
c.RiskControl.MaxMarginUsage = 0.45
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 10
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
c.RiskControl.MaxMarginUsage = 1.0
c.RiskControl.MinConfidence = 78
c.RiskControl.MinRiskRewardRatio = 3.0
c.Indicators.Klines.PrimaryTimeframe = "15m"
c.Indicators.Klines.LongerTimeframe = "4h"
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
c.Indicators.EnableEMA = true
c.Indicators.EnableMACD = true
c.Indicators.EnableRSI = true
c.Indicators.EnableATR = true
c.Indicators.EnableVolume = true
c.Indicators.Klines.PrimaryCount = 30
c.Indicators.Klines.LongerTimeframe = ""
c.Indicators.Klines.LongerCount = 0
c.Indicators.Klines.EnableMultiTimeframe = false
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
c.Indicators.EnableRawKlines = true
}
definitions := []strategyDef{
{
name: locale.trend.name,
description: locale.trend.description,
name: locale.defaultStrategy.name,
description: locale.defaultStrategy.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
setStockRank(c, "volume", 5)
setStableRisk(c)
},
},
{
name: locale.megaCap.name,
description: locale.megaCap.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
setStaticStocks(c, []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"})
setStableRisk(c)
c.RiskControl.MaxPositions = 2
c.RiskControl.MinConfidence = 80
},
},
{
name: locale.breakout.name,
description: locale.breakout.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
setStockRank(c, "gainers", 5)
setStableRisk(c)
c.RiskControl.MinConfidence = 82
c.RiskControl.MinRiskRewardRatio = 3.5
setClaw402Strategy(c)
},
},
}
@@ -348,9 +318,12 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
}
legacyDefaultNames := []string{
"均衡策略", "稳健策略", "积极策略",
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
}
return s.store.Transaction(func(tx *gorm.DB) error {

View File

@@ -7,7 +7,7 @@ import (
"nofx/store"
)
func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
@@ -24,8 +24,8 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
if len(strategies) != 3 {
t.Fatalf("expected 3 default strategies, got %d", len(strategies))
if len(strategies) != 1 {
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
}
byName := map[string]*store.Strategy{}
@@ -35,7 +35,7 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
if strategy.IsActive {
activeCount++
}
if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
}
}
@@ -43,40 +43,27 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
}
trend := byName["美股趋势策略"]
if trend == nil || !trend.IsActive {
t.Fatalf("美股趋势策略 should exist and be active")
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
if defaultStrategy == nil || !defaultStrategy.IsActive {
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
}
trendCfg, err := trend.ParseConfig()
trendCfg, err := defaultStrategy.ParseConfig()
if err != nil {
t.Fatalf("trend ParseConfig failed: %v", err)
t.Fatalf("default ParseConfig failed: %v", err)
}
if trendCfg.CoinSource.SourceType != "hyper_rank" || trendCfg.CoinSource.HyperRankCategory != "stock" || trendCfg.CoinSource.HyperRankDirection != "volume" {
t.Fatalf("trend strategy should use Hyperliquid stock volume ranking, got %+v", trendCfg.CoinSource)
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
}
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 || trendCfg.RiskControl.MaxMarginUsage > 0.45 {
t.Fatalf("trend strategy should be low-risk Hyperliquid native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
t.Fatalf("default strategy should be Claw402/Vergex native with at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
}
megaCap := byName["美股大盘稳健策略"]
if megaCap == nil {
t.Fatalf("美股大盘稳健策略 should exist")
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
megaCfg, err := megaCap.ParseConfig()
if err != nil {
t.Fatalf("megaCap ParseConfig failed: %v", err)
}
if megaCfg.CoinSource.SourceType != "static" {
t.Fatalf("mega-cap strategy should use static stock symbols, got %+v", megaCfg.CoinSource)
}
wantSymbols := []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"}
if len(megaCfg.CoinSource.StaticCoins) != len(wantSymbols) {
t.Fatalf("unexpected static stock list: %+v", megaCfg.CoinSource.StaticCoins)
}
for i, want := range wantSymbols {
if megaCfg.CoinSource.StaticCoins[i] != want {
t.Fatalf("static stock %d: want %s got %s", i, want, megaCfg.CoinSource.StaticCoins[i])
}
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
}
@@ -92,7 +79,7 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust
legacy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "均衡策略",
Name: "Balanced Strategy",
Description: "legacy",
IsActive: false,
}
@@ -137,13 +124,11 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust
activeNames = append(activeNames, strategy.Name)
}
}
if byName["均衡策略"] != 0 {
if byName["Balanced Strategy"] != 0 {
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
}
for _, name := range []string{"美股趋势策略", "美股大盘稳健策略", "美股突破策略"} {
if byName[name] != 1 {
t.Fatalf("expected exactly one %s, got names=%+v", name, byName)
}
if byName["NOFX Claw402 Auto Strategy"] != 1 {
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
}
if len(activeNames) != 1 || activeNames[0] != "aa" {
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)

137
api/handler_vergex.go Normal file
View File

@@ -0,0 +1,137 @@
package api
import (
"context"
"fmt"
"net/http"
"nofx/logger"
"nofx/provider/vergex"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-ranking failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
marketType := strings.TrimSpace(c.Query("marketType"))
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
c.JSON(http.StatusOK, gin.H{
"items": items,
"raw": data.Raw,
})
}
func (s *Server) handleVergexSignalLab(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetSignalLab(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-lab failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
limit := parsePositiveInt(c.Query("limit"), 25)
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
if err != nil {
logger.Warnf("Vergex flow-markets failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return nil, false
}
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if walletKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
return nil, false
}
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
return client, true
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func withDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

101
api/ratelimit.go Normal file
View File

@@ -0,0 +1,101 @@
package api
import (
"math"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
// client IP. It is used to throttle the unauthenticated auth endpoints
// (login / register) against online brute-force attacks.
//
// Design notes:
// - Per-IP token bucket with lazy refill (no background goroutine).
// - Idle buckets are evicted opportunistically so a flood of distinct source
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
// - This is a throttle, not an authenticator. Behind a reverse proxy the
// effective key is whatever gin's ClientIP() resolves; operators who
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
// reflects the real peer rather than a spoofable header.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*rlBucket
rate float64 // tokens added per second
burst float64 // maximum tokens (and initial fill)
lastGC time.Time
}
type rlBucket struct {
tokens float64
last time.Time
}
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
// and then refills at `ratePerSec` tokens/second per client IP.
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*rlBucket),
rate: ratePerSec,
burst: burst,
}
}
// allow reports whether a request from key is permitted at time now, consuming
// one token when it is.
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
// under a spoofed-IP flood without needing a background goroutine.
if l.lastGC.IsZero() {
l.lastGC = now
}
if now.Sub(l.lastGC) > time.Minute {
for k, b := range l.buckets {
if now.Sub(b.last) > 10*time.Minute {
delete(l.buckets, k)
}
}
l.lastGC = now
}
b, ok := l.buckets[key]
if !ok {
b = &rlBucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
// Refill based on elapsed time, capped at burst.
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
// caller exceeds the configured rate.
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !l.allow(c.ClientIP(), time.Now()) {
c.Header("Retry-After", "60")
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many requests. Please slow down and try again in a minute.",
})
c.Abort()
return
}
c.Next()
}
}

54
api/ratelimit_test.go Normal file
View File

@@ -0,0 +1,54 @@
package api
import (
"testing"
"time"
)
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
// immediate attempts and is then throttled until tokens refill.
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
// 1 token/sec, burst of 3.
l := newIPRateLimiter(1.0, 3)
now := time.Unix(1_700_000_000, 0)
// First 3 requests in the same instant are allowed (the burst).
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4", now) {
t.Fatalf("request %d in burst should be allowed", i+1)
}
}
// 4th in the same instant is throttled.
if l.allow("1.2.3.4", now) {
t.Fatalf("request beyond burst should be throttled")
}
// After 1 second, one token refills → exactly one more request allowed.
now = now.Add(time.Second)
if !l.allow("1.2.3.4", now) {
t.Fatalf("one token should have refilled after 1s")
}
if l.allow("1.2.3.4", now) {
t.Fatalf("only one token should refill per second")
}
}
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
// not throttle a different IP.
func TestIPRateLimiterIsolatesClients(t *testing.T) {
l := newIPRateLimiter(1.0, 2)
now := time.Unix(1_700_000_000, 0)
// Exhaust IP A.
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
t.Fatalf("IP A burst should be allowed")
}
if l.allow("10.0.0.1", now) {
t.Fatalf("IP A should be throttled after burst")
}
// IP B is unaffected.
if !l.allow("10.0.0.2", now) {
t.Fatalf("IP B should be allowed regardless of IP A")
}
}

View File

@@ -27,6 +27,7 @@ type Server struct {
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
authLimiter *ipRateLimiter // per-IP throttle for login/register
}
// NewServer Creates API server
@@ -49,6 +50,10 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
cryptoHandler: cryptoHandler,
exchangeAccountStateCache: NewExchangeAccountStateCache(),
port: port,
// Auth throttle: allow a small burst (typos / page reloads) then ~1
// attempt every 6s (10/min) sustained per IP. Generous for a human,
// hostile to online password brute-force.
authLimiter: newIPRateLimiter(1.0/6.0, 8),
}
// Setup routes
@@ -119,6 +124,12 @@ func corsMiddleware() gin.HandlerFunc {
// setupRoutes Setup routes
func (s *Server) setupRoutes() {
// Ensure the auth throttle exists even when the Server was constructed
// directly (e.g. in tests) rather than via NewServer.
if s.authLimiter == nil {
s.authLimiter = newIPRateLimiter(1.0/6.0, 8)
}
// API route group
api := s.router.Group("/api")
{
@@ -139,12 +150,19 @@ func (s *Server) setupRoutes() {
api.POST("/wallet/generate", s.handleWalletGenerate)
s.route(api, "GET", "/hyperliquid/connect-config", "Get NOFX Hyperliquid builder authorization config", s.handleHyperliquidConnectConfig)
s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount)
s.route(api, "GET", "/hyperliquid/agent", "Get Hyperliquid approved agent wallets and authorization expiry", s.handleHyperliquidAgent)
s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange)
// Crypto related endpoints (no authentication required, not exposed to bot)
// Crypto related endpoints (no authentication required, not exposed to bot).
// SECURITY: only the config + public-key endpoints are exposed. Transport
// encryption is one-directional (client encrypts to the server's public key;
// the server decrypts internally on the authenticated config-update handlers).
// A public POST /crypto/decrypt would be a decryption oracle: any
// unauthenticated caller could replay a captured ciphertext and get the
// plaintext (exchange/API credentials) back. It is intentionally NOT
// registered. See crypto_handler.go.
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
// Public competition data (no authentication required)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
@@ -162,9 +180,13 @@ func (s *Server) setupRoutes() {
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
// Authentication related routes (no authentication required)
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// Authentication related routes (no authentication required).
// These are throttled per-IP to blunt online password brute-force; see
// ratelimit.go. Everything else in the public block is read-only or
// idempotent, so the throttle is scoped to the credential endpoints.
authRoutes := api.Group("/", rateLimitMiddleware(s.authLimiter))
s.route(authRoutes, "POST", "/register", "Register new user", s.handleRegister)
s.route(authRoutes, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// SECURITY: password/account recovery is NOT exposed over HTTP. An
// unauthenticated recovery endpoint is a remote auth-bypass on any
// public-facing deployment (the confirm phrase is in the frontend and
@@ -180,9 +202,6 @@ func (s *Server) setupRoutes() {
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
// User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
@@ -192,6 +211,11 @@ func (s *Server) setupRoutes() {
// Server IP query (requires authentication, for whitelist configuration)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
s.route(protected, "GET", "/vergex/flow-markets", "Vergex net-flow market ranking via claw402 (?chain=mainnet&window=1h&limit=25)", s.handleVergexFlowMarkets)
// AI trader management
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
@@ -201,7 +225,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
`:id = trader_id from GET /api/my-traders`,
s.handleGetTraderConfig)
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
s.handleCreateTrader)
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
@@ -312,10 +336,10 @@ 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)
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
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
coin_source.vergex_chain: "hyperliquid"
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
indicators.klines.primary_count: number of candles (20-100)
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
@@ -412,6 +436,10 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
s.handleStatistics)
s.routeWithSchema(protected, "GET", "/statistics/full", "Full trade-quality metrics (win rate, profit factor, Sharpe, max drawdown)",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"total_trades","win_trades","loss_trades","win_rate","profit_factor","sharpe_ratio","total_pnl","total_fee","avg_win","avg_loss","max_drawdown_pct"}`,
s.handleStatisticsFull)
}
}
@@ -564,13 +592,15 @@ func isPrivateIP(ip net.IP) bool {
return false
}
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter.
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter,
// strictly scoped to the authenticated caller.
//
// This project is single-user by design, so a strict cross-tenant ownership
// check would be theatre. We still perform a soft check (the requested trader
// must appear in the caller's store list when present) — this is cheap defense
// in depth that future-proofs against accidental multi-account drift and
// catches typos that would otherwise return another account's data.
// Ownership is always enforced against the caller's own trader list in the
// store. We deliberately never fall back to the global in-memory trader map
// (TraderManager holds every account's traders): returning an entry from it for
// a trader the caller does not own is a cross-tenant data leak (IDOR) — a
// freshly-registered user with no traders of their own could otherwise pass any
// other account's trader_id and read its balance, positions and AI decisions.
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id")
traderID := c.Query("trader_id")
@@ -580,33 +610,27 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
}
// Resolve strictly from the caller's own trader list.
userTraders, err := s.store.Trader().List(userID)
if err != nil {
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
}
if len(userTraders) == 0 {
return nil, "", fmt.Errorf("No available traders")
}
if traderID == "" {
// No trader_id specified — return first trader for this user, falling
// back to the first in-memory trader if no per-user list exists yet.
userTraders, err := s.store.Trader().List(userID)
if err == nil && len(userTraders) > 0 {
return s.traderManager, userTraders[0].ID, nil
}
ids := s.traderManager.GetTraderIDs()
if len(ids) == 0 {
return nil, "", fmt.Errorf("No available traders")
}
return s.traderManager, ids[0], nil
// No trader_id specified — default to the caller's first trader.
return s.traderManager, userTraders[0].ID, nil
}
// Soft ownership check: if the caller owns any traders in the store and
// the requested ID is NOT among them, treat as not-found instead of
// silently returning whatever happens to be in the global in-memory map.
if userTraders, err := s.store.Trader().List(userID); err == nil && len(userTraders) > 0 {
for _, t := range userTraders {
if t.ID == traderID {
return s.traderManager, traderID, nil
}
// A trader_id was supplied — it must belong to the caller.
for _, t := range userTraders {
if t.ID == traderID {
return s.traderManager, traderID, nil
}
return nil, "", fmt.Errorf("trader not found for this account")
}
return s.traderManager, traderID, nil
return nil, "", fmt.Errorf("trader not found for this account")
}
// authMiddleware JWT authentication middleware

View File

@@ -2,11 +2,38 @@ package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nofx/store"
"github.com/gin-gonic/gin"
)
// TestPublicDecryptRouteNotRegistered is a security regression test: the
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
// must never be re-registered. A built server's router must not route to it.
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
s := &Server{router: gin.New()}
s.setupRoutes()
for _, r := range s.router.Routes() {
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
}
}
// Also assert at the HTTP layer that the route is not handled.
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
}
}
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
tests := []struct {

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -636,6 +637,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
symbols = append(symbols, c.Symbol)
}
quantDataMap := engine.FetchQuantDataBatch(symbols)
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
// Fetch OI ranking data (market-wide position changes)
oiRankingData := engine.FetchOIRankingData()
@@ -666,6 +668,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
PromptVariant: req.PromptVariant,
MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap,
VergexDataMap: vergexDataMap,
OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,

View File

@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
return s[:4] + "****" + s[length-4:]
}
// SanitizeModelConfigForLog Sanitize model configuration for log output
func SanitizeModelConfigForLog(models map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}) map[string]interface{} {
// SanitizeModelConfigForLog Sanitize model configuration for log output.
// Takes the same ModelConfigUpdate type used by the request handler so the two
// can never drift out of sync.
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
safe := make(map[string]interface{})
for modelID, cfg := range models {
safe[modelID] = map[string]interface{}{
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
return safe
}
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}) map[string]interface{} {
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
// Takes the same ExchangeConfigUpdate type used by the request handler so every
// sensitive field is guaranteed to be masked — adding a field to the request
// type without masking it here would not compile around this helper, but more
// importantly keeps the masking exhaustive.
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
safe := make(map[string]interface{})
for exchangeID, cfg := range exchanges {
safeExchange := map[string]interface{}{
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
if cfg.SecretKey != "" {
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
}
if cfg.Passphrase != "" {
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
}
if cfg.AsterPrivateKey != "" {
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
}
if cfg.LighterPrivateKey != "" {
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
}
if cfg.LighterAPIKeyPrivateKey != "" {
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
}
// Add non-sensitive fields directly
if cfg.HyperliquidWalletAddr != "" {

View File

@@ -1,6 +1,8 @@
package api
import (
"fmt"
"strings"
"testing"
)
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
}
func TestSanitizeModelConfigForLog(t *testing.T) {
models := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}{
models := map[string]ModelConfigUpdate{
"deepseek": {
Enabled: true,
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
}
func TestSanitizeExchangeConfigForLog(t *testing.T) {
exchanges := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}{
exchanges := map[string]ExchangeConfigUpdate{
"binance": {
Enabled: true,
APIKey: "binance_api_key_1234567890abcdef",
SecretKey: "binance_secret_key_1234567890abcdef",
Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
},
"okx": {
Enabled: true,
APIKey: "okx_api_key_1234567890abcdef",
SecretKey: "okx_secret_key_1234567890abcdef",
Passphrase: "okx_passphrase_supersecret_value",
},
"lighter": {
Enabled: true,
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
},
"hyperliquid": {
Enabled: true,
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
},
}
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
}
// Check OKX passphrase is masked (regression: previously not covered)
okxConfig, ok := result["okx"].(map[string]interface{})
if !ok {
t.Fatal("okx config not found or wrong type")
}
maskedPassphrase, ok := okxConfig["passphrase"].(string)
if !ok {
t.Fatal("okx passphrase not found or wrong type")
}
if maskedPassphrase != "okx_****alue" {
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
}
// Check Lighter API key private key is masked (regression: previously not covered)
lighterConfig, ok := result["lighter"].(map[string]interface{})
if !ok {
t.Fatal("lighter config not found or wrong type")
}
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
if !ok {
t.Fatal("lighter_api_key_private_key not found or wrong type")
}
if maskedLighterAPIKey != "ligh****cdef" {
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
}
// Check Hyperliquid configuration
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
if !ok {
@@ -160,6 +180,41 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
}
}
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
// output exactly as the handler does (`%+v`) and asserts that no plaintext
// secret — including the passphrase and lighter API key private key that were
// historically not redacted — survives into the log line.
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
secrets := map[string]string{
"api_key": "binance_api_key_1234567890abcdef",
"secret_key": "binance_secret_key_1234567890abcdef",
"passphrase": "okx_passphrase_supersecret_value",
"aster_private_key": "aster_private_key_1234567890abcdef",
"lighter_private_key": "lighter_private_key_1234567890abcdef",
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
}
exchanges := map[string]ExchangeConfigUpdate{
"okx": {
Enabled: true,
APIKey: secrets["api_key"],
SecretKey: secrets["secret_key"],
Passphrase: secrets["passphrase"],
AsterPrivateKey: secrets["aster_private_key"],
LighterPrivateKey: secrets["lighter_private_key"],
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
},
}
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
for field, secret := range secrets {
if strings.Contains(rendered, secret) {
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
}
}
}
func TestMaskEmail(t *testing.T) {
tests := []struct {
name string

View File

@@ -282,12 +282,16 @@ func isEncryptedStorageValue(value string) bool {
}
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
// 1. Validate timestamp (prevent replay attacks)
if payload.TS != 0 {
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
}
// 1. Validate timestamp (prevent replay attacks).
// The timestamp is mandatory: a missing/zero ts previously skipped this check
// entirely, which let a captured ciphertext be replayed indefinitely. The
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
if payload.TS == 0 {
return nil, errors.New("missing timestamp")
}
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
}
// 2. Decode base64url
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
if globalCryptoService != nil {
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
if err != nil {
// If encryption fails, return the original value
return string(es), nil
// Fail closed: never silently persist a plaintext secret when
// encryption was expected to happen. Returning the error aborts the
// write so a misconfigured/broken crypto service cannot leak
// credentials into the database in cleartext.
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
}
return encrypted, nil
}

View File

@@ -63,15 +63,7 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## クイックデモ
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
カバー画像をクリックしてデモ動画をご覧ください。
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -63,15 +63,7 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## 빠른 데모
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
커버 이미지를 클릭해 데모 영상을 보세요.
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -63,15 +63,7 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## Короткая демонстрация
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Нажмите на обложку, чтобы посмотреть демо.
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -63,15 +63,7 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## Коротка демонстрація
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Натисніть на обкладинку, щоб переглянути демо.
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -63,15 +63,7 @@ Sử dụng các liên kết bên dưới để mở tài khoản giao dịch ch
## Demo nhanh
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Nhấp vào ảnh bìa để xem video demo.
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

View File

@@ -65,15 +65,7 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## 快速演示
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
点击封面图观看演示视频。
</p>
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---

8
go.mod
View File

@@ -1,6 +1,6 @@
module nofx
go 1.25.10
go 1.25.11
require (
github.com/adshao/go-binance/v2 v2.8.9
@@ -22,6 +22,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
@@ -37,7 +38,7 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/consensys/gnark-crypto v0.19.0 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
@@ -57,7 +58,7 @@ require (
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -96,7 +97,6 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.1 // indirect

8
go.sum
View File

@@ -22,8 +22,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
@@ -104,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=

View File

@@ -10,11 +10,13 @@ import (
"nofx/market"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/provider/vergex"
"nofx/security"
"nofx/store"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -104,6 +106,7 @@ type Context struct {
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
OITopDataMap map[string]*OITopData `json:"-"`
QuantDataMap map[string]*QuantData `json:"-"`
VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"`
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
@@ -183,8 +186,10 @@ type OIDeltaData struct {
// StrategyEngine strategy execution engine
type StrategyEngine struct {
config *store.StrategyConfig
nofxosClient *nofxos.Client
config *store.StrategyConfig
nofxosClient *nofxos.Client
vergexClient *vergex.Client
vergexRankingCache map[string]*vergex.SignalRankItem
}
// NewStrategyEngine creates strategy execution engine.
@@ -217,11 +222,25 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
} else {
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
}
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
if err == nil {
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
} else {
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
}
return &StrategyEngine{
config: config,
nofxosClient: client,
vergexClient: vergexClient,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
return &StrategyEngine{
config: config,
nofxosClient: client,
config: config,
nofxosClient: client,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
@@ -230,7 +249,7 @@ func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
return false
}
source := e.config.CoinSource
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.UseHyperAll || source.UseHyperMain {
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
return true
}
for _, symbol := range source.StaticCoins {
@@ -392,6 +411,20 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
return e.filterExcludedCoins(coins), nil
case "vergex_signal":
coins, err := e.getVergexSignalCoins(
coinSource.VergexLimit,
coinSource.VergexMarketType,
coinSource.VergexChain,
coinSource.VergexLiqBand,
coinSource.HyperRankCategory,
coinSource.StaticCoins,
)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "mixed":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -694,6 +727,204 @@ func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int
return candidates, nil
}
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
if e.vergexClient == nil {
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
}
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain = vergex.QueryChain(chain)
if limit <= 0 {
limit = 5
}
if limit > store.MaxCandidateCoins {
limit = store.MaxCandidateCoins
}
category = strings.ToLower(strings.TrimSpace(category))
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
Chain: chain,
LiqBand: liqBand,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
}
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
LiqBand: liqBand,
})
if fallbackErr == nil {
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
if len(fallbackItems) > 0 {
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
ranking = fallbackRanking
rankedItems = fallbackItems
}
} else {
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
}
}
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
for _, item := range rankedItems {
itemCopy := item
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
e.vergexRankingCache[symbol] = &itemCopy
}
}
if len(selectedSymbols) > 0 {
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
seen := make(map[string]bool)
for _, raw := range selectedSymbols {
symbol := vergex.TradableSymbolForMarket(marketType, raw)
if symbol == "" || seen[symbol] {
continue
}
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
seen[symbol] = true
if len(candidates) >= limit {
break
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
}
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
return candidates, nil
}
// Direction-balanced selection: interleave the top bullish and top bearish
// signals so the candidate universe carries BOTH long and short ideas every
// cycle (instead of filling up with whichever bias ranks highest). This is
// what lets the AI actually judge — and trade — both directions.
var bullItems, bearItems, otherItems []vergex.SignalRankItem
for _, item := range rankedItems {
if category != "" && category != "all" && item.Category != category {
continue
}
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
case "bearish", "short", "sell":
bearItems = append(bearItems, item)
case "bullish", "long", "buy":
bullItems = append(bullItems, item)
default:
otherItems = append(otherItems, item)
}
}
items := make([]vergex.SignalRankItem, 0, limit)
bi, ri, oi := 0, 0, 0
for len(items) < limit {
progressed := false
if bi < len(bullItems) {
items = append(items, bullItems[bi])
bi++
progressed = true
if len(items) >= limit {
break
}
}
if ri < len(bearItems) {
items = append(items, bearItems[ri])
ri++
progressed = true
if len(items) >= limit {
break
}
}
if !progressed {
if oi < len(otherItems) {
items = append(items, otherItems[oi])
oi++
} else {
break
}
}
}
if len(items) == 0 {
if category != "" && category != "all" {
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
}
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
}
candidates := make([]CandidateCoin, 0, len(items))
for _, item := range items {
itemCopy := item
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
if symbol == "" {
continue
}
e.vergexRankingCache[symbol] = &itemCopy
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
}
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
return candidates, nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// DirectionalCandidates returns bullish (long) and bearish (short) candidate
// symbols from the most recent Vergex signal ranking, each ordered by upstream
// rank (strongest first). Only populated for vergex_signal coin sources, since
// that is the only source carrying a per-symbol directional bias.
func (e *StrategyEngine) DirectionalCandidates() (bullish []string, bearish []string) {
if e == nil || len(e.vergexRankingCache) == 0 {
return nil, nil
}
type ranked struct {
sym string
rank int
}
rankKey := func(r int) int {
if r > 0 {
return r
}
return 1 << 30
}
var bl, br []ranked
for sym, item := range e.vergexRankingCache {
if item == nil {
continue
}
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
case "bearish", "short", "sell":
br = append(br, ranked{sym, item.Rank})
case "bullish", "long", "buy":
bl = append(bl, ranked{sym, item.Rank})
}
}
sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) })
sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) })
for _, r := range bl {
bullish = append(bullish, r.sym)
}
for _, r := range br {
bearish = append(bearish, r.sym)
}
return bullish, bearish
}
func withDefaultText(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// ============================================================================
// External & Quant Data
// ============================================================================
@@ -879,6 +1110,282 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
return result
}
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
result := make(map[string]*vergex.MarketAnalysis)
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
return result
}
if e.vergexClient == nil {
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
return result
}
if ctx == nil {
ctx = context.Background()
}
source := e.config.CoinSource
marketType := source.VergexMarketType
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain := source.VergexChain
chain = vergex.QueryChain(chain)
seen := make(map[string]bool)
limited := make([]string, 0, store.MaxCandidateCoins)
for _, symbol := range symbols {
symbol = vergexDetailSymbolForLookup(marketType, symbol)
if symbol == "" {
continue
}
if seen[symbol] {
continue
}
seen[symbol] = true
limited = append(limited, symbol)
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
break
}
}
type vergexAnalysisResult struct {
symbol string
analysis *vergex.MarketAnalysis
}
resultCh := make(chan vergexAnalysisResult, len(limited))
var wg sync.WaitGroup
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
for _, symbol := range limited {
symbol := symbol
querySymbol := vergex.QuerySymbol(symbol)
if querySymbol == "" {
continue
}
itemMarketType := marketType
itemCategory := ""
var ranking *vergex.SignalRankItem
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
ranking = cached
if cached.MarketType != "" {
itemMarketType = cached.MarketType
}
itemCategory = cached.Category
}
analysis := &vergex.MarketAnalysis{
Symbol: symbol,
QuerySymbol: querySymbol,
MarketType: itemMarketType,
Ranking: ranking,
}
query := vergex.Query{
MarketType: itemMarketType,
Symbol: symbol,
Chain: chain,
LiqBand: source.VergexLiqBand,
Category: itemCategory,
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
analysis.SignalLabError = ctx.Err().Error()
analysis.HeatmapError = ctx.Err().Error()
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
return
}
e.populateVergexDetailData(ctx, analysis, query)
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
}
}()
}
wg.Wait()
close(resultCh)
for item := range resultCh {
result[item.symbol] = item.analysis
}
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
return result
}
func vergexDetailSymbolForLookup(marketType, symbol string) string {
return vergex.TradableSymbolForMarket(marketType, symbol)
}
const (
vergexDetailRequestTimeout = 45 * time.Second
vergexDetailSymbolConcurrency = 2
)
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
type endpointResult struct {
name string
body json.RawMessage
err error
}
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
defer cancel()
body, err := fetch(requestCtx, query)
out <- endpointResult{name: name, body: body, err: err}
}
out := make(chan endpointResult, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
}()
go func() {
defer wg.Done()
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
}()
wg.Wait()
close(out)
for item := range out {
switch item.name {
case "signal-lab":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
analysis.SignalLabError = item.err.Error()
} else {
analysis.SignalLab = item.body
}
case "heatmap":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
analysis.HeatmapError = item.err.Error()
} else {
analysis.Heatmap = item.body
}
}
}
}
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
marketTypes := vergexDetailMarketTypeCandidates(query)
chains := uniqueValues(query.Chain, "mainnet", "")
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
for _, marketType := range marketTypes {
for _, chain := range chains {
candidate := query
candidate.MarketType = marketType
candidate.Chain = chain
candidates = append(candidates, candidate)
}
}
return candidates
}
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
if isVergexAllMarketType(query.MarketType) {
if market.IsXyzDexAsset(query.Symbol) {
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
}
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
}
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
return uniqueNonEmpty(values...)
}
func isVergexAllMarketType(marketType string) bool {
switch strings.ToLower(strings.TrimSpace(marketType)) {
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
return true
default:
return false
}
}
func isRetryableVergexDetailError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "invalid markettype") ||
strings.Contains(msg, "invalid_request") ||
strings.Contains(msg, "invalid chain") ||
strings.Contains(msg, "market not found") ||
strings.Contains(msg, "not_found")
}
func uniqueNonEmpty(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func uniqueValues(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
// FetchOIRankingData fetches market-wide OI ranking data
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
indicators := e.config.Indicators

View File

@@ -85,6 +85,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
}
}
pruneCandidateCoinsWithoutMarketData(ctx)
enrichVergexDataWithStrategy(ctx, engine)
// Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil {
@@ -142,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
return decision, nil
}
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
return
}
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
return
}
symbolSet := make(map[string]bool)
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
for _, coin := range ctx.CandidateCoins {
if !symbolSet[coin.Symbol] {
symbolSet[coin.Symbol] = true
symbols = append(symbols, coin.Symbol)
}
}
for _, pos := range ctx.Positions {
if !symbolSet[pos.Symbol] {
symbolSet[pos.Symbol] = true
symbols = append(symbols, pos.Symbol)
}
}
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
}
// ============================================================================
// Market Data Fetching
// ============================================================================

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