MINARA

系統設計

核心模塊、不變量,以及本節所有設計文檔的目錄

👥 本節適合:希望理解 Minara 為何如此構建的研究型開發者,以及需要了解不變量和變更風險邊界的維護者。每個頁面以通俗的"存在意義"說明開頭,以使用示例鏈接收尾,指向對應的用戶功能頁。貢獻者專屬內容均有明確標註。

新人推薦閱讀順序:先讀 Agent 循環(單輪運行的核心),再讀 技能系統(能力的封裝方式),然後根據所關心的功能跳轉到對應子系統。

本節記錄 Minara Agent 的構建方式。先通讀下方的核心模塊概覽,再按目錄跳轉至所需子系統的深度文檔。

目錄

運行時

  • Agent 循環:單輪從用戶消息到最終回答的完整流程。
  • 技能系統:路由、激活、風險門控,以及完整技能目錄。
  • LLM 集成:Provider 抽象、提示詞緩存、模型路由。
  • 場景分類器:L0.5 意圖分類,注入流程手冊並預加載技能。

狀態與存儲

  • 記憶(子節概覽):四個相互關聯的存儲(會話記憶、個性化、角色反思、學習系統)如何在單輪中協同工作。
  • 工作區:Markdown 形式的身份與記憶基準,以及承載持久化對話內容(圖表、報告、上傳文件)的產物與文件存儲。

安全與執行

I/O 與運維


核心模塊

Agent 由少量模塊組成,通過顯式接口相互協作。本頁逐一介紹每個模塊:其職責範圍、依賴關係,以及所維護的不變量。

core-modules diagram

app.ts:組合根

apps/agent/src/app.ts 是代碼庫中唯一瞭解所有其他模塊的文件。它導出單一的 createApp() 函數,執行以下操作:

  1. 以 WAL 模式、啟用 FTS5 的方式在 $dataDir/ 下打開 SQLite 數據庫。
  2. 構建 ToolRegistry,安裝權限等級鉤子和"分析 → 交易"邊界鉤子。
  3. 實例化所有工具工廠(createReadToolscreateTradeToolscreateMemoryToolscreateFileTools 等)並註冊。依賴缺失環境變量的工廠返回 [],啟動時不拋出異常。
  4. BUILTIN_SKILLSbuildExternalDomainSkills() 的輸出(apps/agent/src/skills/external/ 下的外部技能)構建 SkillRegistry
  5. 連接 AgentLoop,注入 LLM 客戶端、註冊表、沙盒解析器和審計日誌寫入器。
  6. 返回組裝好的 App 對象供網關驅動。

createApp() 是純函數(給定環境變量和配置),CLI REPL 與 HTTP 網關共享完全相同的運行時。若想確認"X 在哪裡接入",答案始終是 app.ts

core/tool-registry.ts:類型化分發與權限等級

工具註冊表是 Agent 能力的具體表示。每個工具對應一個 ToolEntry

interface ToolEntry {
  name: string;
  toolSet: string;
  schema: ToolSchema;          // JSON Schema for LLM tool-use
  handler: ToolHandler;        // async (args) => string
  permissionTier: PermissionTier;
  isAsync: boolean;
  description: string;
  checkFn?: () => boolean;     // optional runtime availability gate
}

權限等級

enum PermissionTier {
  READ_ONLY      = 1, // price, balance, trending, fear_greed, search
  CONFIRM_ONCE   = 2, // analyze, research, small swap, write_file
  ALWAYS_CONFIRM = 3, // perps, large swap, autopilot start/modify
  MANUAL_ONLY    = 4, // withdraw, external-address send, emergency stop
}

等級由 BeforeToolCallHook 強制執行,非建議性約束。鉤子拋出 ToolCallBlockedError 時,Agent 循環捕獲該錯誤,將其作為工具結果返回給 LLM。LLM 將其視為普通工具錯誤;審計日誌對其單獨記錄。

工具集

BUILTIN_TOOL_SETS 將工具分組為具名包(readtradeperpsfilebrowser 等),支持可選的 includes 組合。技能按名稱引用工具,而註冊表和網關則以工具集為單位進行管理:更易推理,也更易門控。定時 Autopilot 輪次以 allowedToolSets: ["read", "memory", "web"] 運行,不包含其他工具集。

上下文傳播

註冊表使用 AsyncLocalStorage 在異步工具調用中傳遞 ToolCallContext,包括通過 subagent 發起的子 Agent 調用和技能執行的工具序列。鉤子通過 ToolRegistry.currentContext() 讀取上下文,以強制執行每輪不變量(分析 → 交易邊界、allowedToolSets 白名單、緊急停止開關)。

core/agent-loop.ts:規劃 → 調用 → 觀察 → 決策

Agent 循環是一個普通的 while (iterations < max) 編排器:

  1. 使用當前 SkillSession 狀態,通過 prompt-builder.ts 構建系統提示詞。
  2. 以當前激活技能所允許的工具集(與當前輪次的 allowedToolSets 取交集)調用 LLM。
  3. 對每次工具調用執行:
    • 驗證聲明的意圖與實際工具調用一致。
    • 運行註冊表分發,觸發 BeforeToolCallHook
    • 將調用及結果持久化至審計日誌。
  4. 將結果作為新用戶消息反饋。
  5. stop_reason: "end_turn" 或達到 max_iterations 時退出。

輪次賬本(風險上限、信號上下文)存儲於 ToolCallContext 對象中;循環通過 runInContext(ctx, fn) 包裝,使每次工具調用都能訪問同一狀態。

詳見 Agent 循環

core/prompt-builder.ts:系統提示詞組裝

系統提示詞由聲明式塊構建,而非字符串拼接:

system: [
  { type: "text", text: identityPrompt,     cache: true  },
  { type: "text", text: skillCatalog,       cache: true  },
  { type: "text", text: activeSkillPrompts, cache: false },
  { type: "text", text: signalContextBlock, cache: false },
]

標記 cache: true 的塊會傳遞給 Anthropic 的提示詞緩存(TTL 5 分鐘,節省 10% 費用),使身份標識和技能目錄保持熱緩存。每輪變化的塊(激活技能、信號上下文、待確認項)不緩存。此設計至關重要:提示詞塊順序不當,會將看似"一次 Claude 調用"變成三次緩存未命中。

buildSystemPrompt() 對需要純字符串的調用方(測試、CLI 的 --dump-prompt 模式)封裝了 buildSystemPromptBlocks()

skills/:技能層

DomainSkill 是完全聲明式的單元:

{
  id: "minara.core",
  kind: "domain_skill",
  description: "Unified Minara API — markets, portfolio, spot/perps trading, sub-account management",
  prompt: "...≤ 800 tokens of instructions...",
  tool_names: [
    "get_price", "get_trending", "get_fear_greed", "search_tokens",
    "minara_account", "lookup_token", "minara_total_balance",
    "get_portfolio", "get_perps_positions", "minara_pnl",
    "swap_tokens", "buy_token", "sell_token", "transfer_token",
    "open_perps_position", "close_perps_position",
    "minara_perps_wallets_list", "minara_perps_wallet_sweep", /* ... */
  ],
  activation: "auto",
  priority: 50,
  routing: {
    lifecycle_stages: ["discover", "decide", "manage"],
    keywords: ["minara", "balance", "swap", "long", "short", "perp", "perps", "perps sub-account"],
    asset_classes: ["crypto_major", "crypto_alt", "crypto_meme", "stablecoin", "perps"],
  },
}

各組件說明:

  • SkillRegistryapps/agent/src/skills/registry.ts):維護技能目錄,在註冊時執行 requires_env 門控,渲染未路由和已路由的目錄塊,拼接激活 ID 的提示詞片段,並彙總工具白名單。
  • SkillSessionapps/agent/src/skills/session.ts):每輪可變狀態,包括哪些技能已激活、適用的風險上限、待處理的確認項。註冊表無狀態;會話負責激活管理。
  • Routerapps/agent/src/skills/router.ts):運行 L0 確定性預過濾,包括關鍵詞評分、階段分類、資產類別檢測,以及在用戶未確認時短路高風險激活的 L3 風險門控。
  • FinanceTaxonomyapps/agent/src/skills/finance-taxonomy.ts):路由器與技能共用的詞彙表(LifecycleStageAssetClassRiskTier)。

路由算法和激活流程詳見 技能系統

tools/_shared/sandbox.ts:文件系統邊界

每個文件工具調用 resolveInSandbox(relativePath),執行以下操作:

  1. 拒絕絕對路徑。
  2. $dataDir/sandbox/files/ 下解析路徑。
  3. 逐段遍歷路徑,跟隨符號鏈接,驗證解析後的真實路徑仍以沙盒根目錄為前綴。
  4. 返回規範化絕對路徑,或拋出 SandboxEscapeError

工具處理器從不將原始用戶輸入作為 fs.* 參數使用。工具若嘗試路徑遍歷(本不應如此),解析器會在任何系統調用觸及磁盤之前拒絕該請求。

不要編寫使用不同解析器打開文件的並行輔助函數。 單一入口點是沙盒可防禦性的基礎。

tools/_shared/result.ts:工具結果信封

所有工具處理器返回字符串,但該字符串由 ok({...})err("...")errFromThrow(e) 封裝為統一的帶標籤信封,LLM 可可靠解析:

{"ok": true,  "data": {...}}
{"ok": false, "error": "..."}

設計上刻意保持簡單。每個工具返回相同結構,提示詞邏輯(如"結果為錯誤時,道歉並重試")只需編寫一次,無需針對每個工具單獨處理。

gateway/:兩個入口,一套運行時

  • cli.ts:將 LLM 輸出流式傳輸到 stdout,通過同一 AgentLoop 路由工具調用的 REPL。執行的第一行導入 config/load-env.ts,確保 .env 在任何代碼讀取 process.env 之前已填充。
  • server.ts:暴露 HTTP API,詳見 HTTP 網關。使用同一 createApp() 輸出,CLI 與 HTTP 行為無偏差。
  • skills-cli.tsminara skills add/list/upgrade/remove 子命令。將外部 SKILL.md 包引入 apps/agent/src/skills/external/,並在輸出中展示許可證檢測結果。

config/load-env.ts:環境變量初始化

僅有兩項職責:

  1. .env 存在,調用 Node 22 原生的 process.loadEnvFile() 加載它。
  2. 記錄哪些變量來自文件、哪些來自 shell,用於啟動日誌。

作為副作用導入,且必須是每個入口的第一個導入。若新增入口時遺漏此導入,下游會出現無聲的環境變量缺失故障。


架構規則:本頁列出的每個模塊只有一項職責,棧中較高層的模塊只依賴較低層的模塊。 Agent 循環依賴註冊表;註冊表依賴工具層;工具層依賴沙盒。若發現自己向上依賴(例如工具處理器導入 agent-loop.ts),應以重構設計來解決,而非添加反向依賴邊。

本頁目錄