Hooks 与 Context JSAPI:V8 桥与边界注入
30 秒导读: Yao 让你写两个 TypeScript 函数
Create和Next,分别插在"调 LLM 之前"和"拿到 LLM 结果之后"。它们就是 index 里说的"笼子的边界"——请求进出模型都要先过你这道关。钩子里能拿到一个ctx对象,上面挂了发流式消息、调 LLM、调工具、调别的 agent 的方法。这个ctx不是普通 JS 对象,而是 Go 用 V8 桥"喂"进 JavaScript 的一层壳。本章讲这两件事:钩子怎么拦截,ctx怎么桥。
本章位置:主管道的时序在 02-pipeline 已经走过一遍;这里只聚焦"钩子这一环里发生了什么"。memory 的读写细节归 06-memory-sandbox,ctx.agent.Call/All 的委派语义归 05-multiagent-stack(本章只点名它存在)。
1. 这是什么(零基础也能懂)
一句话定义: 钩子(Hook)= 你写在 src/index.ts 里、由 Yao 在固定时机自动调用的 TypeScript 函数;Create 在调模型前跑,Next 在调模型后跑。
它解决什么问题。 一个纯 LLM agent 是个黑盒:消息进去,回答出来,你插不上手。真实业务要在中间做很多事——
- 调模型前:改写用户输入、换个模型、塞入检索到的资料、或者干脆判断"这个请求不该我处理,转给别的 agent"。
- 调模型后:校验模型输出、把结果落库、决定"要不要再问一轮"、或者把结果二次委派出去。
钩子就是官方给你的这两个"插入点"。没有钩子,agent 只是个 LLM 代理;有了钩子,它才是个可编程的管道。
一个最小钩子长什么样。 下面是一个 src/index.ts,只做一件事:在用户消息前面加一句系统提示,然后照常让 LLM 处理。
// src/index.ts —— 示意,展示钩子的形状
function Create(context, messages, options) {
// context 就是本章主角 ctx;messages 是完整历史;options 是调用参数
context.Send("正在思考…"); // 直接往前端推一条流式消息
return { // 返回 HookCreateResponse
messages: [
{ role: "system", content: "你只用中文回答。" },
...messages,
],
};
}
function Next(context, payload, options) {
// payload.completion 是 LLM 刚生成的回答
return; // 返回 undefined = 什么都不改,走标准返回
}
一句话直觉: 把 agent 想成一条流水线,Create 是"进料口的质检+改料员",Next 是"出料口的质检+分拣员"。两道关中间夹着 LLM(或不夹,见 §3)。
2. 顶层全景(钩子在管道里的位置)
一次请求从进入 Assistant.Stream 到返回,钩子卡在两个位置。下图从上到下是时间顺序,方框里带 file:line 的是真实调用点。
用户输入 messages
│
▼
┌───────────────────────────────────────────────┐
│ ① Create 钩子 agent.go:225 HookScript.Create │ ← 调 LLM 之前
│ · 可改写 messages / 换 connector / 调温度 │
│ · 可返回 Delegate → 提前路由,跳过 LLM │ ──┐ 命中就直接
└───────────────────────────────────────────────┘ │ 走委派、返回
│ (未委派) │
▼ │
┌───────────────────────────────────────────────┐ │
│ ② LLM / CLI 执行 agent.go:283 (Prompts||MCP) │ │
│ 三选一:LLM 流式 / CLI 沙箱 / 纯钩子(不调) │ │
└───────────────────────────────────────────────┘ │
│ │
▼ │
┌ ───────────────────────────────────────────────┐ │
│ ③ Next 钩子 agent.go:512 HookScript.Next │ │
│ · 拿到 completion + tools 结果 │ │
│ · 可返回 Delegate(再委派)/ Data(自定义) │ │
└───────────────────────────────────────────────┘ │
│ │
▼ processNextResponse (next.go:11) │
最终 Response ◄──────────────────────────────────────┘
三个部件各干什么:
| 部件 | 职责 | 源码锚点 |
|---|---|---|
Create 钩子 | LLM 前:改写输入、调参、提前委派 | agent/assistant/hook/create.go:13 Script.Create |
Next 钩子 | LLM 后:后处理、再委派、返回自定义数据 | agent/assistant/hook/next.go:13 Script.Next |
ctx 对象 | 钩子手里的"遥控器",桥到 Go 能力面 | agent/context/jsapi.go:20 Context.NewObject |
主线走一遍(高层): 输入 →Create 有机会拦下或改料 → 若未提前委派,进 LLM/CLI/空转 → Next 有机会改判 →processNextResponse 根据 Next 的返回决定"标准返回 / 委派 / 自定义数据"→ 出 Response。
3. 核心原理
3.1 Create 钩子:进料口的三种权力
它要解决的小问题: 在模型看到输入之前,把最后一道人为逻辑塞进去。
签名与职责。 Create 收三样东西,返回一个 HookCreateResponse(可为空):
Create(ctx, messages, options) → HookCreateResponse | undefined
Go 侧的封装在 hook/create.go:13 Script.Create:它把 options 转成 map 传给 JS(ToMap()),执行 JS 的 Create 方法,再把返回 值 JSON 化回 HookCreateResponse(create.go:83 getHookCreateResponse)。
返回值能改三层东西(字段定义见 agent/context/types.go:411 HookCreateResponse):
| 想改什么 | 返回字段 | 生效方式 |
|---|---|---|
| 发给模型的消息 | messages | 直接替换本轮 messages |
| 换模型 | connector | 调用级覆盖,写回 options(create.go:75 applyOptionsAdjustments) |
| 会话字段(locale/theme/route/metadata) | 同名字段 | 写回 ctx(create.go:44 applyContextAdjustments) |
| 生成参数 | temperature/max_tokens/… | 传给 LLM 连接器 |
| 提前路由 | delegate | 跳过 LLM,直接转别的 agent |
注意 applyContextAdjustments 里明确写着 AssistantID 不可覆盖——它在初始化时定死,是这个笼子的身份,钩子不能自己改名。
最关键的一手:Delegate 提前路由。 如果 Create 返回里带了 delegate,管道会跳过 LLM 调用和 Next 钩子,直接把请求转给目标 agent。这就是"笼子边界"最硬的体现:某些请求根本不该进这只笼子,Create 在门口就把它转走。真实判断点在 agent/assistant/agent.go:246:
// agent.go:246 —— Create 返回带 Delegate 时的提前路由
if createResponse != nil && createResponse.Delegate != nil {
delegateResponse, err := ast.handleDelegation(ctx, createResponse.Delegate, streamHandler)
// …
return delegateResponse, nil // 直接返回,不碰 LLM、不跑 Next
}
DelegateConfig 只需三个字段:目标 agent_id、要发的 messages、可选 options(types.go:487)。委派的具体栈语义(父子 Stack、A2A)见 05-multiagent-stack。
3.2 Next 钩子:出料口的再委派与改判
它要解决的小问题: 模型答完了,但"答完"不等于"该返回给用户了"——可能还要再问一轮、转交、或返回一个跟 LLM 原文完全不同的结构。
签名。 Next 收到的是一个 payload,里面装着这一轮的全部产物:
Next(ctx, payload, options) → NextHookResponse | undefined
payload 的组装在 hook/next.go:22,对应 NextHookPayload(types.go:456)四个字段:
| 字段 | 内容 |
|---|---|
messages | 完整历史 |
completion | LLM 这轮生成的回答 |
tools | 工具调用的结果(若有) |
error | 失败信息(若有) |
返回值决定三条出路。 NextHookResponse 只有两个"有效载荷"字段(types.go:474),但组合出三种结局,由 processNextResponse(next.go:11)裁决:
Next 返回什么 → processNextResponse 怎么处理
─────────────────────────────────────────────────────────
undefined / 空 → buildStandardResponse (照常返回 completion)
{ delegate: {...} } → handleDelegation (再委派给另一个 agent)
{ data: <任意> } → 把 data 塞进 Response.Next (返回自定义结构)
对应真实分支(agent/assistant/next.go:11):
// next.go:11 —— Next 响应的三岔路
func (ast *Assistant) processNextResponse(npc *NextProcessContext) (*agentContext.Response, error) {
if npc.NextResponse == nil {
return ast.buildStandardResponse(npc), nil // 出路一:标准
}
if npc.NextResponse.Delegate != nil {
return ast.handleDelegation(...) // 出路二:再委派
}
if npc.NextResponse.Data != nil {
return &agentContext.Response{ /* … */ Next: npc.NextResponse.Data }, nil // 出路三:自定义
}
return ast.buildStandardResponse(npc), nil
}
buildStandardResponse(next.go:70)只是把 Create、Completion、Tools 各结果装进一个标准 Response(types.go:398,注释说它 "100% 兼容 OpenAI API");委派则复用 handleDelegation(next.go:43)——它加载目标 assistant,把 Referer 标成 RefererAgent,再用同一个 ctx 调 targetAssistant.Stream(next.go:66),从而保持栈追踪的父子关系。
一个坑:没有 Next 钩子时会怎样? 如果 assistant 压根没写钩子,但这轮有工具结果,管道不会傻等——它会进"工具循环"(tool loop)自动把工具结果喂回模型(agent.go:549)。工具循环是 04-toolloop-mcp 的主题,这里只点明:Next 钩子和自动工具循环是二选一的——写了 Next,循环就交给你的钩子逻辑管。
3.3 三种执行模式,共用同一副钩子
它要解决的小问题: 有的 agent 要调云端 LLM,有的要跑本地 CLI(claude/opencode 之类),有的根本不需要模型——纯粹用钩子写业务逻辑。这三种怎么不各搞一套?
答案:钩子接口不变,中间那段可换。 Create 和 Next 永远在两头,中间的"执行体"按 assistant 的配置三选一。判断点在 agent.go:283——只有配了 Prompts 或 MCP 才进 LLM 段:
// agent.go:283 —— 中间执行体的总开关
if ast.Prompts != nil || ast.MCP != nil {
// …BuildRequest → 自动检索 → executeLLMStream 或 executeSandboxV2Stream
}
三种模式对照:
| 模式 | 触发条件 | 中间发生什么 | Create/Next |
|---|---|---|---|
| LLM | 有 Prompts | 调云端模型流式生成(agent.go:283 段) | 照常两头跑 |
| CLI(沙箱) | HasSandboxV2() | 走本地 runner(yaocode/tai/claude/opencode)(agent.go:317) | 照常两头跑 |
| 纯 Hook | 无 Prompts、无 MCP | 什么都不调,completionResponse 为空 | 只跑钩子逻辑 |
纯 Hook 模式最能说明"钩子就是笼子"这句话。 没有模型,Create 里 context.Send(...)、context.mcp.CallTool(...)、context.agent.Call(...) 就是全部业务;Next 返回 { data: ... } 把结果交回用户。这时 agent 退化成"一段能调各种能力的可编程 TS",LLM 只是它可选的一味料。CLI 沙箱执行器的细节见 06-memory-sandbox。
4. 深入实现:V8 桥怎么把 Go 的 ctx 变成 JS 对象
前面一直说钩子里能调 ctx.Send、ctx.llm、ctx.mcp……这些方法不是 JS 写的,是 Go 函数。V8 桥(bridge)就是把 Go 对象"包装成"JS 对象的机制。 这一节讲这层壳怎么搭、怎么拆。
4.1 一次钩子调用 = 一个短命的 V8 脚本上下文
每次执行钩子,Yao 都新建一个 V8 脚本上下文,用完即关。核心在 scripts.go:27 ExecuteWithAuthorized:
// scripts.go:27 —— 每次钩子调用的生命周期
scriptCtx, err := s.NewContext("", nil) // 新建 V8 脚本上下文
defer scriptCtx.Close() // 用完即关(触发 ctx.__release)
// …
result, err := scriptCtx.CallWith(ctx, method, args...) // 调 JS 的 Create/Next
method 就是 "Create" 或 "Next",args 里第一个参数就是被桥过来的 ctx。注意 defer scriptCtx.Close()——它是后面 §4.3 生命周期的关键:关脚本上下文时,V8 的清理会回调 ctx.__release。
4.2 NewObject:一层模板 + 内部字段藏 ID
把 Go 的 *Context 变成 JS 对象的全部逻辑在 agent/context/jsapi.go:20 Context.NewObject。它分四步:
第一步:建模板,留一个内部字段。
// jsapi.go:22 —— 建对象模板,内部字段用来藏 Go 对象的句柄
jsObject := v8go.NewObjectTemplate(v8ctx.Isolate())
jsObject.SetInternalFieldCount(1) // 预留 1 个内部字段(JS 侧看不见)
goValueID := bridge.RegisterGoObject(ctx) // 把 Go 的 ctx 注册进全局表,拿到一个字符串 ID
关键设计:内部字段(internal field)对 JavaScript 不可见。Go 侧把 ctx 登记进一个全局桥接表(RegisterGoObject)拿到 goValueID,把这个 ID 藏进内部字段 0(jsapi.go:95 SetInternalField(0, goValueID))。方法被调用时,Go 从内部字段取回 ID、反查真实 ctx。JS 代码碰不到这个 ID,拿不到 Go 指针——这是安全边界,注释里写得很直白:"Internal fields are not accessible from JavaScript, providing better security"。
第二步:挂基本字段和方法。 像 chat_id、assistant_id、locale 这些原始值直接 Set(jsapi.go:40-46);Send/SendStream/Replace/Append/Merge/Set/End 这些方法用 FunctionTemplate 挂(jsapi.go:49-55)。
第三步:挂能力子对象。 mcp/search/agent/llm 四个子对象在模板阶段挂(jsapi.go:66-75),各自由 newMCPObject 等工厂函数造。
第四步:实例化后再补复杂对象。 client/metadata/authorized/memory/computer/workspace 这些依赖运行时数据的,得等实例建出来后用 bridge.JsValue 逐个塞进去(jsapi.go:108-171)。其中 computer 和 workspace 是条件挂载——只有 assistant 真的配了它们才出现(jsapi.go:156、jsapi.go:164)。
4.3 __release 生命周期:谁在什么时候拆壳
桥接表是全局的,注册了就得释放,否则泄漏。NewObject 给对象挂了两个同义的释放方法(jsapi.go:36-37):__release(内部,GC 或脚本上下文关闭时触发)和 Release(公开,给 try-finally 手动调)。
一个 Context ──► 一个 Trace ──► 多次 Hook 执行(Create / Next / Done)
每次 Hook:
NewContext → 桥出 ctx → CallWith
scriptCtx.Close() → 触发 ctx.__release
这里有个容易踩的坑,源码用一大段注释专门解释(jsapi.go:184-201):__release 不会顺手释放 Trace 对象。原因是每次钩子执行都新建、又关闭一个脚本上下文,如果每次 __release 都把 Trace 也释放了,那第一次钩子跑完 Trace 就没了,后续操作会报 "context canceled"。所以 Trace 的生命周期挂在整个 agent Context 上(Stream 开始时建、结束时 Context.Release() 时清),跨越所有钩子执行;__release 只负责把当前 ctx 从桥接表摘除(jsapi.go:204 取回内部字段的 goValueID → bridge.ReleaseGoObject)。
一句话记住: 每次钩子桥一次、拆一次 ctx 壳;但 Trace 这类要跨钩子活着的东西,绝不能在 __release 里拆。
5. ctx 的能力面清点(钩子里能调什么)
ctx 上挂的方法分两类:直接方法(流式输出、ID 生成)和能力子对象(ctx.llm.* 这种)。下面逐块清点,方便你写钩子时按图索骥。
5.1 流式输出:边算边推
这组方法让钩子在不等 LLM 的情况下就往前端推消息,是"纯 Hook 模式"下唯一的产出方式。全部在 jsapi.go,底层桥到 agent/context/output.go 的 Go 方法。
| JS 调用 | 干什么 | 锚点 |
|---|---|---|
ctx.Send(msg) | 发一条完整消息,自动生成 ID + flush | jsapi.go:252 sendMethod |
ctx.SendStream(msg) | 开一条流式消息,不发 end 事件,待后续 Append | jsapi.go:307 sendStreamMethod |
ctx.Append(id, content, path?) | 往已有消息追加(delta append) | jsapi.go:460 appendMethod |
ctx.End(id, final?) | 收尾流式消息,发 message_end | jsapi.go:352 endMethod |
ctx.Replace(id, msg) | 整条替换 | jsapi.go:403 replaceMethod |
ctx.Merge(id, data, path?) | 合并进对象(delta merge) | jsapi.go:522 mergeMethod |
ctx.Set(id, data, path) | 在指定路径设新字段(delta set) | jsapi.go:583 setMethod |
ctx.EndBlock(blockId) | 结束一个消息块 | jsapi.go:718 endBlockMethod |
Send vs SendStream 的分工: Send 是"一锤子"——发完就自动带 message_end;SendStream 是"开头"——它只起头,后面必须靠 Append 续、End 收(见 sendStreamMethod 注释,jsapi.go:301-306)。两者内部都在发送后自动 ctx.Flush()(jsapi.go:288),所以 JS 侧不用手动刷。
流式消息的 delta 机制(DeltaReplace/DeltaAppend/DeltaMerge/DeltaSet)是这组方法共享的底层协议,细节见 02-pipeline。
5.2 能力子对象:桥到 Go 的六个能力包
这六个子对象是钩子调用外部能力的入口,每个由一个工厂函数造、挂在 ctx 上。注意它们大多用接口 + Factory 变量的模式(如 LlmAPIFactory),目的是避免 context 包和实现包的循环依赖——实现在别的包,context 只持接口。
| 子对象 | 挂了什么关键方法 | 一句话 | 锚点 |
|---|---|---|---|
ctx.llm | Stream / GenerateImage / All / Any / Race | 直接调 LLM,含并行(仿 Promise) | jsapi_llm.go:68 newLlmObject |
ctx.mcp | CallTool / CallTools / ListTools / GetPrompt / All / Any / Race … | 调 MCP 工具与资源 | jsapi_mcp.go:583 newMCPObject |
ctx.search | Web / KB / DB / All / Any / Race | 三种检索(网页/知识库/数据库) | jsapi_search.go:48 newSearchObject |
ctx.agent | Call / All / Any / Race | 调别的 agent(委派见第5章) | jsapi_agent.go:66 newAgentObject |
ctx.computer | Exec / VNC / Proxy / Info | 操控 computer-use 环境(条件挂载) | jsapi_computer.go:50 createComputerInstance |
ctx.workspace | ReadFile / WriteFile / ReadDir / Remove / Stat … | 沙箱工作区文件操作(条件挂载) | jsapi_workspace.go:12 createWorkspaceInstance |
并行方法的统一心智模型: llm、mcp、search、agent 四个都提供 All/Any/Race,语义直接照搬 JavaScript 的 Promise.all/any/race(见 jsapi_llm.go:20-26 接口注释)。想同时问三个模型、取最快那个,一行 ctx.llm.Race([...]) 即可。
回调的线程安全巧思(值得一看): 并行 LLM 调用如果带 onChunk 回调,Go 侧不敢在后台 goroutine 里直接回调 JS(V8 不是线程安全的)。做法是开一个 channel,后台 goroutine 只往 channel 塞消息,主 V8 线程从 channel 取出来再调 JS 回调,从而把回调串行化(jsapi_llm.go:342 executeLlmBatchWithCallback 的注释与实现)。
ctx.memory 单独说明: 它也挂在 ctx 上(jsapi.go:148,四个命名空间 user/team/chat/context,每个支持 Get/Set/Del/Incr/… ,见 jsapi.go:751 createMemoryObject),但记忆的语义和分层是 06-memory-sandbox 的主题,本章不展开。
6. 动手:写一个会拦截和注入的钩子
把前面的东西拼起来,一个"边界注入"钩子长这样。它演示三件事:Create 里注入检索结果、条件提前委派、Next 里改判返回。
// src/index.ts —— 示意,非源码;演示拦截 + 注入 + 改判
function Create(ctx, messages, options) {
const last = messages[messages.length - 1]?.content ?? "";
// 拦截:不该我处理的,门口转走(跳过 LLM 和 Next)
if (last.startsWith("/bill")) {
return { delegate: { agent_id: "billing", messages } }; // → §3.1 提前路由
}
// 注入:把网页检索结果塞进上下文再交给模型
const hits = ctx.search.Web(last, { limit: 3 }); // → §5.2 ctx.search
const note = { role: "system", content: "参考资料:" + JSON.stringify(hits) };
return { messages: [note, ...messages], connector: "gpt-4o" }; // 顺手换模型
}
function Next(ctx, payload, options) {
// 后处理:LLM 答完,做一次结构化改判
if (!payload.completion) return; // 纯保险
if (needsAnotherRound(payload)) {
return { delegate: { agent_id: "reviewer", messages: payload.messages } }; // 再委派
}
return { data: { answer: payload.completion, sources: "web" } }; // 返回自定义结构 → Response.Next
}
读这段的重点:
Create的return决定 LLM 之前的一切;delegate一出现,后面全跳过。ctx.search.Web就是 §5.2 那个桥过来的 Go 能力,同步返回,像调本地函数。Next的{ data: ... }会被processNextResponse塞进Response.Next(next.go:24),前端拿到的就是你这个自定义结构,而非 LLM 原文。
7. 边界与局限(诚实)
- 钩子改不了自己的身份。
AssistantID在applyContextAdjustments里被明确排除,钩子能改 locale/theme/route/metadata,但改不了"我是谁"(create.go:44注释)。 Create委派和Next委派不对称。Create委派会同时跳过 LLM 和 Next(agent.go:271直接 return);Next委派发生在 LLM 之后,只是把结果再转一手。- 纯 Hook 模式下
payload.completion为空。 没配Prompts/MCP就不调模型,Next收到的completion是 nil——钩子逻辑必须自己判空(见 §6 的保险)。 ctx每次钩子都是新桥的一层壳。 别指望在Create里往ctx上挂个 JS 属性、Next里还能读到——它们是两次独立的NewObject。要跨钩子传状态,用ctx.memory(第6章)。- 回调必须走主 V8 线程。 这不是你能绕开的约束;并行方法的回调由 Yao 用 channel 串行化(
jsapi_llm.go:342),你的回调里别做长阻塞操作,会卡住主线程。
8. 代码地图(导航索引)
按符号名可 grep 定位;行号 as-of sourceCommit。
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| Create 钩子封装 | agent/assistant/hook/create.go | Script.Create |
| Create 返回改写会话/参数 | agent/assistant/hook/create.go | applyContextAdjustments / applyOptionsAdjustments |
| Next 钩子封装 | agent/assistant/hook/next.go | Script.Next |
| Next 响应三岔路 | agent/assistant/next.go | processNextResponse |
| 标准响应组装 | agent/assistant/next.go | buildStandardResponse |
| 委派执行 | agent/assistant/next.go | handleDelegation |
| 管道里 Create 调用点 | agent/assistant/agent.go:225 | HookScript.Create |
| 管道里 Next 调用点 | agent/assistant/agent.go:512 | HookScript.Next |
| LLM/CLI/纯Hook 总开关 | agent/assistant/agent.go:283 | ast.Prompts || ast.MCP |
| 钩子执行的 V8 上下文 | agent/assistant/scripts.go | Script.ExecuteWithAuthorized |
| ctx 桥成 JS 对象 | agent/context/jsapi.go | Context.NewObject |
| 释放 ctx 桥句柄 | agent/context/jsapi.go | objectRelease / bridge.ReleaseGoObject |
| 流式输出方法 | agent/context/jsapi.go | sendMethod / sendStreamMethod / appendMethod / endMethod |
| ctx.llm 能力 | agent/context/jsapi_llm.go | newLlmObject |
| ctx.mcp 能力 | agent/context/jsapi_mcp.go | newMCPObject |
| ctx.search 能力 | agent/context/jsapi_search.go | newSearchObject |
| ctx.agent 能力 | agent/context/jsapi_agent.go | newAgentObject |
| ctx.computer / workspace | agent/context/jsapi_computer.go / jsapi_workspace.go | createComputerInstance / createWorkspaceInstance |
| 响应/负载类型定义 | agent/context/types.go | HookCreateResponse / NextHookPayload / NextHookResponse / DelegateConfig |