装载:从 DSL 到可运行的 Assistant
30 秒导读: Yao 里一个 "agent"(它内部叫 assistant)不是代码里 new 出来的对象,而是磁盘上一个目录:一份
package.yao描述它是谁、用哪个模型,几份prompts.yml给它系统提示,一个src/放它的 TypeScript 脚本。本章只讲一件事——这堆文件怎么被读进内存、变成一个可以跑的*Assistant对象。至于对象怎么真正处理一次请求(Stream),留给 02-pipeline。
这是整个货架最浅、最先该懂的一层:先知道"一个 agent 由哪些文件组成、怎么变成运行时对象",后面几章讲的执行、hook、工具循环才有落脚点。
1. 这是什么(零基础也能懂)
一句话: 装载 = 把"一个 agent 在磁盘上的定义"翻译成"内存里一个填好字段的 Go 结构体"。
Yao 用约定目录 + 声明式配置来定义 agent,而不是让你写 Go 代码。你想加一个新 agent,就在应用的 /assistants/ 下建个文件夹,丢一个 package.yao 进去。系统启动时扫这个目录,把每个文件夹读成一个 assistant。
用一个真实的最小 agent 感受一下——这是仓库自带的 "标题生成器",整个 agent 就这么点东西(yao/assistants/title/package.yao):
{
"name": "Title Generator",
"description": "Generate conversation titles",
"type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" },
"options": {}
}
connector: "use::light"—— 不写死某个模型,而是说"用 light 角色对应的连接器"(轻量任务,如起标题)。- 没有
src/脚本、没有 MCP —— 它就是个纯 prompt agent。
心智模型: 把一个 assistant 目录当成一个"agent 的安装包"(名字就叫 package.yao 不是巧合)。装载 = 解压安装。装完你手上是一个 *Assistant 对象,揣着它的连接器、提示词、脚本、MCP 配置,随时能被叫起来干活。
本节到此不碰代码。记住三个词:目录 → package.yao → 内存对象。
2. 两层定义:全局 DSL 与单个 Assistant
装载分两层,别混:
| 层 | 文件 | 管什么 | 对应 Go 类型 |
|---|---|---|---|
| 全局层 | agent/agent.yml | 整个 agent 子系统 的默认值:用哪个 assistant 当默认、系统 agent 连哪个模型、全局 runner | types.DSL(agent/types/types.go:10) |
| 单体层 | /assistants/<id>/package.yao | 某一个具体 agent:它的名字、连接器、MCP、沙箱…… | store.AssistantModel(agent/store/types/types.go:436) |
先看全局层,再钻单体层。
2.1 全局 DSL(agent.yml → types.DSL)
agent.yml 解析进 DSL 结构体。它不是某个 agent,而是给所有 agent 定基调的一张总配置(agent/types/types.go:10-40)。核心是三块:
① Uses —— 各种"默认用哪个 agent"的指派表(agent/types/types.go:44)。
系统内部有很多"辅助活儿"要交给某个 agent 干,Uses 就是这张指派表:
| 字段 | 指派谁去干 |
|---|---|
Default | 用户没指定时的默认 assistant |
Title | 给对话起标题的 agent |
Prompt | 生成 prompt 的 agent |
Vision / Audio | 模型不支持图/音时,拿来转文字的 agent |
Web / Keyword / QueryDSL / Rerank | 检索链路上的 NLP 处理器 |
这些字段的值可以是 "builtin"、某个 <assistant-id>,或 "mcp:<server>.<tool>"(见字段注释,agent/types/types.go:55-58)。
② System —— 系统 agent 连哪个模型(agent/types/types.go:99)。
Yao 自带一批 __yao. 开头的系统 agent(__yao.keyword、__yao.title……)。System 块给它们配连接器,分两级:
- 角色级默认:
Default/Light/Vision/Audio/Heavy五个角色,写进 llmprovider(agent/types/types.go:101-105)。 - 单 agent 覆盖:
Keyword/Title/Prompt… 给某个系统 agent 单独指一个连接器,优先级最高(agent/types/types.go:108-114)。
③ Runner —— 全局默认执行器(agent/types/types.go:27),如 "yaocode"、"claude",所有 assistant 不另指定时都用它。
agent.yml 怎么变成 DSL:Load() 读文件 → application.Parse 解析 → 填默认值(如 Uses.Default 缺省为 "mohe",Title 缺省为 "__yao.title")→ 存进包级变量 agentDSL(agent/load.go:42-89)。
2.2 单个 Assistant 的目录布局
一个 assistant 就是 /assistants/ 下一个目录。完整长这样(以带脚本的 agent 为例,参考 yao/assistants/fetch/):
/assistants/
└── my-agent/ ← 目录名决定 assistant_id
├── package.yao ← 必需:主配置(见下表)
├── prompts.yml ← 可选:默认系统提示词
├── prompts/ ← 可选:按文件名分组的 prompt 预设
├── src/
│ ├── index.ts ← 可选:Hook 脚本(生命周期钩子)
│ └── xxx.ts ← 可选:其它可调脚本
├── sandbox.yao ← 可选:V2 沙箱配置
└── locales/ ← 可选:i18n 翻译
ID 是从路径推出来的,不是你写在文件里的:路径 /assistants/foo/bar → 把 /assistants/ 前缀去掉、/ 换成 . → id = foo.bar(agent/assistant/load.go:329-331,LoadPath)。
2.3 package.yao 的关键字段
package.yao 解析后填进 AssistantModel(agent/store/types/types.go:436)。挑装载阶段最要紧的字段:
| 字段 | 类型 | 作用 |
|---|---|---|
name | string | 必需,agent 显示名(loadMap 里 name 缺失直接报错) |
type | string | agent 类型,默认 "assistant" |
connector | string | 用哪个模型连接器;可写 "use::<role>" 走角色解析 |
mcp | object | 挂载的 MCP 服务器(工具来源,见 04-toolloop-mcp) |
db / kb | object | 数据库 / 知识库配置(检索) |
sandbox | object | 沙箱配置(仅 V2,见 06-memory-sandbox) |
uses | object | 本 agent 级的 Uses 覆盖,会和全局 Uses 合并 |
options | object | 传给模型的选项 |
prompts | array | 系统提示词(也可放 prompts.yml) |
连接器里的环境变量:package.yao 的 connector 若写成 $ENV.XXX,loadPackage 会在读文件时替换成 环境变量的值(agent/assistant/load.go:304-311)。
3. 内存里的对象:Assistant 结构体
装载的终点产物是一个 *Assistant(agent/assistant/types.go:29):
type Assistant struct {
store.AssistantModel // 内嵌:package.yao 的全部配置字段
HookScript *hook.Script // Hook 脚本(src/index.ts)
Scripts map[string]*Script // 其它脚本(src 下非 index 的 .ts/.js)
vision bool // 是否支持视觉(内部)
}
关键点:Assistant 内嵌了 store.AssistantModel。也就是说 §2.3 那张表里的所有配置字段(name/connector/mcp/…),直接就是 Assistant 的字段——配置即对象。在此之上,内存对象额外多了两样磁盘上没有、只有装载时才生成的东西:编译好的脚本(HookScript、Scripts)。这两样是第 4/5 节的主角。
Script和hook.Script都只是对 V8 引擎里*v8.Script的一层包装(agent/assistant/types.go:24、agent/assistant/hook/types.go:8)——脚本装载 = 把.ts文件编译成一个能被 V8 调用的对象。
4. 装载流程:从文件/store 到对象
4.1 全景图
装载有两个入口:启动时批量扫盘(LoadBuiltIn),和运行时按 id 取用(Get/LoadStore)。两条路最后都汇到同一个核心函数 loadMap。先看怎么读这张图:从左边两个来源出发,都收敛到中间的 loadMap,再向右产出对象并进缓存。
来源①:磁盘目录 核心解析 产出
/assistants/*/package.yao
│ LoadBuiltIn (启动时扫盘)
▼
LoadPath ──读 package.yao/prompts/src/locales──┐
▼
来源②:store(数据库/内存) ┌──► loadMap ──► initialize ──► *Assistant
│ Get(id) = LoadStore(id) │ (逐字段解码 (注册脚本) │
▼ │ + 合并全局) ▼
storage.GetAssistant(id) │ loaded.Put(缓存)
├─ 有 Path ─► LoadPath ────────────┘
└─ 无 Path ─► 从 store model 直接建 + loadSource(Source 字段)
一句话:
LoadPath管"从文件夹装",LoadStore管"从库里装或走缓存",loadMap是两者共用的"把 map 灌进结构体"的车间。
4.2 LoadPath:从目录装一个 agent
LoadPath(path)(agent/assistant/load.go:317)是"读文件夹"的主力,顺序做这些事:
- 读主配置:
loadPackage读package.yao、解析、处 理$ENV.连接器(agent/assistant/load.go:281)。 - 推 ID:从路径算出
assistant_id,写进 data map(load.go:329-331)。 - 收集周边文件:
prompts.yml→prompts;prompts/目录 →prompt_presets;src/→ 脚本(见 §5);locales/→ i18n;sandbox.yao→ V2 沙箱(load.go:340-405)。 - 灌进结构体:调
loadMap(data)得到*Assistant(load.go:407)。 - 沙箱收尾:补上
SandboxV2、算ConfigHash(load.go:413-437)。
4.3 loadMap:逐字段把 map 灌进结构体
loadMap(agent/assistant/load.go:442)是最核心、最长的一段。它做的就是一字段一字段地从 map[string]interface{} 里取值、做类型转换、塞进 Assistant。两条硬规则:
assistant_id缺失 → 报错(load.go:447-451);name缺失 → 报错(load.go:453-458)。- 复杂字段(
mcp/db/kb/sandbox/workflow)各有一个store.ToXxx转换器把原始 map 转成强类型(load.go:746-799)。
合并全局配置也发生在这里:uses 和 search 走"全局 < 本 agent"的覆盖——本 agent 没设 的字段用全局值,设了的覆盖全局。uses 用 mergeUses(load.go:827-851 + mergeUses 在 load.go:914),search 用 mergeSearchConfig(load.go:676-699)。
loadMap 最后调 LoadScriptsFromData 装脚本(§5),再调 initialize() 收尾(load.go:854-897)。
4.4 LoadStore 与两种来源
运行时想拿某个 agent,走 Get(id),它就是 LoadStore(id) 的别名(agent/assistant/assistant.go:94)。LoadStore(agent/assistant/load.go:222)的决策:
LoadStore(id)
├─ 缓存命中? ──► 直接返回(loaded.Get)
├─ storage.GetAssistant(id)
│ ├─ storeModel.Path != "" ──► LoadPath(Path) ← 本质还是回去读文件夹
│ └─ Path == "" ──► 用 store model 直接建对象
│ └─ 有 Source 字段? ──► loadSource 编译成 HookScript
└─ initialize() ──► loaded.Put(缓存)
两种来源的差别:文件型 agent 的定义在磁盘,store 里只存了它的 Path,取用时再回 LoadPath;纯 store 型 agent(比如用户在 UI 里建的)没有磁盘目录,配置和脚本源码(Source 字段)都存在库里,直接从 model 建对象,脚本用 loadSource 现场编译(load.go:257-266)。
4.5 LoadBuiltIn:启动时批量扫盘
启动时 initAssistant 先 SetStorage/SetGlobalUses 等把全局配置注入,再依次 LoadSystemAgents()(装 __yao. 系统 agent)、LoadBuiltIn()(装应用自带 agent)(agent/load.go:205-268)。
LoadBuiltIn(agent/assistant/load.go:34)扫 /assistants 下每个含 package.yao 的目录,对每个:LoadPath → Save() 存进 store → initialize() → loaded.Put 进缓存(load.go:80-121)。
两个装载期就定死的策略:
- 系统命名空间强制只读:id 以
yao.开头(isSystemNamespace,load.go:1133)的 agent,装载时被强制Readonly=true、BuiltIn=true(load.go:91-96)。 - 清库对账:扫描前先列出 store 里已有的 built-in agent,扫完把磁盘上已删除的从 store 里删掉(
load.go:48-66、123-133),让 store 跟磁盘一致。
4.6 缓存:一个 LRU
装好的对象进 loaded 这个包级缓存,默认容量 200(agent/assistant/load.go:24)。它是一个线程安全的 LRU(agent/assistant/cache.go:9,Cache),用 container/list + map 实现:Get 命中就把节点移到队首(cache.go:32),Put 超容量就淘汰队尾(cache.go:44-70)。
一个和脚本挂钩的细节:agent 被移出缓存(Remove/removeOldest/Clear/ClearExcept)前,会先 UnregisterScripts() 把它注册的脚本处理器注销掉(cache.go:80-83、161-164),避免脚本 handler 泄漏。
ClearExcept 是重载时用的:清缓存但保留 __yao. 系统 agent(LoadBuiltIn 开头就这么调,load.go:37-39),因为系统 agent 由另一条路(LoadSystemAgents)管理,不该被应用 agent 的重载连累。
5. 脚本装载:HookScript 与其它脚本
一个 agent 的 src/ 目录里可以有多个 .ts/.js。装载时它们被分成两类,规则很简单:
| 文件 | 归类 | 存到哪 |
|---|---|---|
src/index.ts(仅根 index) | Hook 脚本 | Assistant.HookScript |
src/ 下其它 .ts/.js | 普通脚本 | Assistant.Scripts map |
*_test.ts / *_test.js | 跳过 | —— |
注意"仅根 index":
src/foo/index.ts不是 hook 脚本,只有src/index.ts是(agent/assistant/scripts.go:83-85,LoadScripts)。index这个键永远保留给 HookScript,普通脚本 map 里会被过滤掉(loadScriptsField里对id == "index"一律continue,scripts.go:290)。
普通脚本的 key 用相对路径推:src/foo/bar/test.ts → key foo.bar.test(generateScriptID,scripts.go:128)。
四条来源、有优先级:LoadScriptsFromData(agent/assistant/scripts.go:199)按顺序找脚本,先命中先用:
① data["script"] ── 单个 hook 脚本(字符串源码或已编译对象)
② data["scripts"] ── 脚本 map(其中 "index" 键取出来当 HookScript)
③ data["source"] ── 旧式:整段 hook 源码 → loadSource 编译
④ 文件系统 ── 扫 assistants/<id>/src 目录(LoadScripts)
编译落到 V8:无论哪条路,.ts 最终都被 V8 引擎编译成 *v8.Script。文件走 v8.Load(scripts.go:149、168),源码字符串走 v8.MakeScriptInMemory(支持 TypeScript 语法、不碰文件系统,scripts.go:159-165、source.go:25)。因为 v8.Load 不是线程安全的,LoadScripts 用 scriptsMutex 串行化(scripts.go:19、88-109)。
普通脚本会被注册成可调用的 process:initialize()(load.go:904)发现 Scripts 非空就调 RegisterScripts(),把每个脚本注册成一个 process handler,命名 agents.<assistantID>.<scriptID>(scripts.go:332-350)。这样这些脚本后续能被 Yao 的 process 机制按名字调起来。
至于 HookScript 里那些钩子(如
Create/Next)在执行期怎么被调、桥到 V8 里能拿到什么,是 03-hooks-jsapi 的内容。本章只负责把它编译好、挂到对象上。