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),应以重构设计来解决,而非添加反向依赖边。

本页目录