系统设计
核心模块、不变量,以及本节所有设计文档的目录
👥 本节适合:希望理解 Minara 为何如此构建的研究型开发者,以及需要了解不变量和变更风险边界的维护者。每个页面以通俗的"存在意义"说明开头,以使用示例链接收尾,指向对应的用户功能页。贡献者专属内容均有明确标注。
新人推荐阅读顺序:先读 Agent 循环(单轮运行的核心),再读 技能系统(能力的封装方式),然后根据所关心的功能跳转到对应子系统。
本节记录 Minara Agent 的构建方式。先通读下方的核心模块概览,再按目录跳转至所需子系统的深度文档。
目录
运行时
- Agent 循环:单轮从用户消息到最终回答的完整流程。
- 技能系统:路由、激活、风险门控,以及完整技能目录。
- LLM 集成:Provider 抽象、提示词缓存、模型路由。
- 场景分类器:L0.5 意图分类,注入流程手册并预加载技能。
状态与存储
- 记忆(子节概览):四个相互关联的存储(会话记忆、个性化、角色反思、学习系统)如何在单轮中协同工作。
- 工作区:Markdown 形式的身份与记忆基准,以及承载持久化对话内容(图表、报告、上传文件)的产物与文件存储。
安全与执行
- 资金安全与沙盒:沙盒、权限等级、钩子流水线,以及六阶段资金安全栈。
I/O 与运维
核心模块
Agent 由少量模块组成,通过显式接口相互协作。本页逐一介绍每个模块:其职责范围、依赖关系,以及所维护的不变量。
app.ts:组合根
apps/agent/src/app.ts 是代码库中唯一了解所有其他模块的文件。它导出单一的 createApp() 函数,执行以下操作:
- 以 WAL 模式、启用 FTS5 的方式在
$dataDir/下打开 SQLite 数据库。 - 构建
ToolRegistry,安装权限等级钩子和"分析 → 交易"边界钩子。 - 实例化所有工具工厂(
createReadTools、createTradeTools、createMemoryTools、createFileTools等)并注册。依赖缺失环境变量的工厂返回[],启动时不抛出异常。 - 从
BUILTIN_SKILLS及buildExternalDomainSkills()的输出(apps/agent/src/skills/external/下的外部技能)构建SkillRegistry。 - 连接
AgentLoop,注入 LLM 客户端、注册表、沙盒解析器和审计日志写入器。 - 返回组装好的
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 将工具分组为具名包(read、trade、perps、file、browser 等),支持可选的 includes 组合。技能按名称引用工具,而注册表和网关则以工具集为单位进行管理:更易推理,也更易门控。定时 Autopilot 轮次以 allowedToolSets: ["read", "memory", "web"] 运行,不包含其他工具集。
上下文传播
注册表使用 AsyncLocalStorage 在异步工具调用中传递 ToolCallContext,包括通过 subagent 发起的子 Agent 调用和技能执行的工具序列。钩子通过 ToolRegistry.currentContext() 读取上下文,以强制执行每轮不变量(分析 → 交易边界、allowedToolSets 白名单、紧急停止开关)。
core/agent-loop.ts:规划 → 调用 → 观察 → 决策
Agent 循环是一个普通的 while (iterations < max) 编排器:
- 使用当前
SkillSession状态,通过prompt-builder.ts构建系统提示词。 - 以当前激活技能所允许的工具集(与当前轮次的
allowedToolSets取交集)调用 LLM。 - 对每次工具调用执行:
- 验证声明的意图与实际工具调用一致。
- 运行注册表分发,触发
BeforeToolCallHook。 - 将调用及结果持久化至审计日志。
- 将结果作为新用户消息反馈。
- 在
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"],
},
}各组件说明:
SkillRegistry(apps/agent/src/skills/registry.ts):维护技能目录,在注册时执行requires_env门控,渲染未路由和已路由的目录块,拼接激活 ID 的提示词片段,并汇总工具白名单。SkillSession(apps/agent/src/skills/session.ts):每轮可变状态,包括哪些技能已激活、适用的风险上限、待处理的确认项。注册表无状态;会话负责激活管理。Router(apps/agent/src/skills/router.ts):运行 L0 确定性预过滤,包括关键词评分、阶段分类、资产类别检测,以及在用户未确认时短路高风险激活的 L3 风险门控。FinanceTaxonomy(apps/agent/src/skills/finance-taxonomy.ts):路由器与技能共用的词汇表(LifecycleStage、AssetClass、RiskTier)。
路由算法和激活流程详见 技能系统。
tools/_shared/sandbox.ts:文件系统边界
每个文件工具调用 resolveInSandbox(relativePath),执行以下操作:
- 拒绝绝对路径。
- 在
$dataDir/sandbox/files/下解析路径。 - 逐段遍历路径,跟随符号链接,验证解析后的真实路径仍以沙盒根目录为前缀。
- 返回规范化绝对路径,或抛出
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.ts:minara skills add/list/upgrade/remove子命令。将外部 SKILL.md 包引入apps/agent/src/skills/external/,并在输出中展示许可证检测结果。
config/load-env.ts:环境变量初始化
仅有两项职责:
- 若
.env存在,调用 Node 22 原生的process.loadEnvFile()加载它。 - 记录哪些变量来自文件、哪些来自 shell,用于启动日志。
作为副作用导入,且必须是每个入口的第一个导入。若新增入口时遗漏此导入,下游会出现无声的环境变量缺失故障。
架构规则:本页列出的每个模块只有一项职责,栈中较高层的模块只依赖较低层的模块。 Agent 循环依赖注册表;注册表依赖工具层;工具层依赖沙盒。若发现自己向上依赖(例如工具处理器导入 agent-loop.ts),应以重构设计来解决,而非添加反向依赖边。