跳到主要内容

装载:从 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 连哪个模型、全局 runnertypes.DSL(agent/types/types.go:10)
单体层/assistants/<id>/package.yao某一个具体 agent:它的名字、连接器、MCP、沙箱……store.AssistantModel(agent/store/types/types.go:436)

先看全局层,再钻单体层。

2.1 全局 DSL(agent.ymltypes.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)。挑装载阶段最要紧的字段:

字段类型作用
namestring必需,agent 显示名(loadMap 里 name 缺失直接报错)
typestringagent 类型,默认 "assistant"
connectorstring用哪个模型连接器;可写 "use::<role>" 走角色解析
mcpobject挂载的 MCP 服务器(工具来源,见 04-toolloop-mcp)
db / kbobject数据库 / 知识库配置(检索)
sandboxobject沙箱配置(仅 V2,见 06-memory-sandbox)
usesobject本 agent 级的 Uses 覆盖,会和全局 Uses 合并
optionsobject传给模型的选项
promptsarray系统提示词(也可放 prompts.yml)

连接器里的环境变量:package.yaoconnector 若写成 $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 的字段——配置即对象。在此之上,内存对象额外多了两样磁盘上没有、只有装载时才生成的东西:编译好的脚本(HookScriptScripts)。这两样是第 4/5 节的主角。

Scripthook.Script 都只是对 V8 引擎里 *v8.Script 的一层包装(agent/assistant/types.go:24agent/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)是"读文件夹"的主力,顺序做这些事:

  1. 读主配置:loadPackagepackage.yao、解析、处理 $ENV. 连接器(agent/assistant/load.go:281)。
  2. 推 ID:从路径算出 assistant_id,写进 data map(load.go:329-331)。
  3. 收集周边文件:prompts.ymlprompts;prompts/ 目录 → prompt_presets;src/ → 脚本(见 §5);locales/ → i18n;sandbox.yao → V2 沙箱(load.go:340-405)。
  4. 灌进结构体:调 loadMap(data) 得到 *Assistant(load.go:407)。
  5. 沙箱收尾:补上 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)。

合并全局配置也发生在这里:usessearch 走"全局 < 本 agent"的覆盖——本 agent 没设的字段用全局值,设了的覆盖全局。usesmergeUses(load.go:827-851 + mergeUsesload.go:914),searchmergeSearchConfig(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:启动时批量扫盘

启动时 initAssistantSetStorage/SetGlobalUses 等把全局配置注入,再依次 LoadSystemAgents()(装 __yao. 系统 agent)、LoadBuiltIn()(装应用自带 agent)(agent/load.go:205-268)。

LoadBuiltIn(agent/assistant/load.go:34)扫 /assistants 下每个含 package.yao 的目录,对每个:LoadPathSave() 存进 store → initialize()loaded.Put 进缓存(load.go:80-121)。

两个装载期就定死的策略:

  • 系统命名空间强制只读:id 以 yao. 开头(isSystemNamespace,load.go:1133)的 agent,装载时被强制 Readonly=trueBuiltIn=true(load.go:91-96)。
  • 清库对账:扫描前先列出 store 里已有的 built-in agent,扫完把磁盘上已删除的从 store 里删掉(load.go:48-66123-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-83161-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:149168),源码字符串走 v8.MakeScriptInMemory(支持 TypeScript 语法、不碰文件系统,scripts.go:159-165source.go:25)。因为 v8.Load 不是线程安全的,LoadScriptsscriptsMutex 串行化(scripts.go:1988-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 的内容。本章只负责把它编译好、挂到对象上


6. 边界:本章到哪为止

  • 不讲执行:对象装好后,一次请求怎么流经它(Stream)——见 02-pipeline
  • 不讲 hook 内部:HookScript 里的钩子 API、V8 桥注入了什么——见 03-hooks-jsapi
  • 不讲工具/MCP 落地、多智能体编排、记忆/沙箱运行时——分别见 04 / 05 / 06 章。

一句话收束:读完本章,你应该能回答"一个 agent 由哪些文件组成、它怎么变成内存里一个可运行的 *Assistant"——目录 + package.yao 是定义,LoadPath/LoadStoreloadMapinitialize 是装载,LRU 缓存是它运行期的落脚点。


7. 代码地图(导航索引)

主题文件路径符号
全局 DSL 结构agent/types/types.goDSL / Uses / System
全局配置装载入口agent/load.goLoad / initAssistant
单体配置结构(package.yao 字段)agent/store/types/types.goAssistantModel
内存对象agent/assistant/types.goAssistant / Script
从目录装载agent/assistant/load.goLoadPath / loadPackage
逐字段灌入agent/assistant/load.goloadMap / initialize
从 store/缓存装载agent/assistant/load.goLoadStore / Get(assistant.go)
批量扫盘agent/assistant/load.goLoadBuiltIn / isSystemNamespace
全局合并agent/assistant/load.gomergeUses / mergeSearchConfig
LRU 缓存agent/assistant/cache.goCache / NewCache / ClearExcept
脚本装载agent/assistant/scripts.goLoadScripts / LoadScriptsFromData / generateScriptID
脚本注册为 processagent/assistant/scripts.goRegisterScripts / makeScriptHandler
源码编译(store 型)agent/assistant/source.goloadSource
Hook 脚本包装类型agent/assistant/hook/types.goScript