跳到主要内容

Hooks 与 Context JSAPI:V8 桥与边界注入

30 秒导读: Yao 让你写两个 TypeScript 函数 CreateNext,分别插在"调 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完整历史
completionLLM 这轮生成的回答
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)只是把 CreateCompletionTools 各结果装进一个标准 Response(types.go:398,注释说它 "100% 兼容 OpenAI API");委派则复用 handleDelegation(next.go:43)——它加载目标 assistant,把 Referer 标成 RefererAgent,再用同一个 ctxtargetAssistant.Stream(next.go:66),从而保持栈追踪的父子关系。

一个坑:没有 Next 钩子时会怎样? 如果 assistant 压根没写钩子,但这轮有工具结果,管道不会傻等——它会进"工具循环"(tool loop)自动把工具结果喂回模型(agent.go:549)。工具循环是 04-toolloop-mcp 的主题,这里只点明:Next 钩子和自动工具循环是二选一的——写了 Next,循环就交给你的钩子逻辑管。

3.3 三种执行模式,共用同一副钩子

它要解决的小问题: 有的 agent 要调云端 LLM,有的要跑本地 CLI(claude/opencode 之类),有的根本不需要模型——纯粹用钩子写业务逻辑。这三种怎么不各搞一套?

答案:钩子接口不变,中间那段可换。 CreateNext 永远在两头,中间的"执行体"按 assistant 的配置三选一。判断点在 agent.go:283——只有配了 PromptsMCP 才进 LLM 段:

// agent.go:283 —— 中间执行体的总开关
if ast.Prompts != nil || ast.MCP != nil {
// …BuildRequest → 自动检索 → executeLLMStream 或 executeSandboxV2Stream
}

三种模式对照:

模式触发条件中间发生什么Create/Next
LLMPrompts调云端模型流式生成(agent.go:283 段)照常两头跑
CLI(沙箱)HasSandboxV2()走本地 runner(yaocode/tai/claude/opencode)(agent.go:317)照常两头跑
纯 HookPrompts、无 MCP什么都不调,completionResponse 为空只跑钩子逻辑

纯 Hook 模式最能说明"钩子就是笼子"这句话。 没有模型,Createcontext.Send(...)context.mcp.CallTool(...)context.agent.Call(...) 就是全部业务;Next 返回 { data: ... } 把结果交回用户。这时 agent 退化成"一段能调各种能力的可编程 TS",LLM 只是它可选的一味料。CLI 沙箱执行器的细节见 06-memory-sandbox


4. 深入实现:V8 桥怎么把 Go 的 ctx 变成 JS 对象

前面一直说钩子里能调 ctx.Sendctx.llmctx.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_idassistant_idlocale 这些原始值直接 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)。其中 computerworkspace条件挂载——只有 assistant 真的配了它们才出现(jsapi.go:156jsapi.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 取回内部字段的 goValueIDbridge.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 + flushjsapi.go:252 sendMethod
ctx.SendStream(msg)开一条流式消息,不发 end 事件,待后续 Appendjsapi.go:307 sendStreamMethod
ctx.Append(id, content, path?)往已有消息追加(delta append)jsapi.go:460 appendMethod
ctx.End(id, final?)收尾流式消息,发 message_endjsapi.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.llmStream / GenerateImage / All / Any / Race直接调 LLM,含并行(仿 Promise)jsapi_llm.go:68 newLlmObject
ctx.mcpCallTool / CallTools / ListTools / GetPrompt / All / Any / Race调 MCP 工具与资源jsapi_mcp.go:583 newMCPObject
ctx.searchWeb / KB / DB / All / Any / Race三种检索(网页/知识库/数据库)jsapi_search.go:48 newSearchObject
ctx.agentCall / All / Any / Race调别的 agent(委派见第5章)jsapi_agent.go:66 newAgentObject
ctx.computerExec / VNC / Proxy / Info操控 computer-use 环境(条件挂载)jsapi_computer.go:50 createComputerInstance
ctx.workspaceReadFile / WriteFile / ReadDir / Remove / Stat沙箱工作区文件操作(条件挂载)jsapi_workspace.go:12 createWorkspaceInstance

并行方法的统一心智模型: llmmcpsearchagent 四个都提供 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
}

读这段的重点:

  • Createreturn 决定 LLM 之前的一切;delegate 一出现,后面全跳过。
  • ctx.search.Web 就是 §5.2 那个桥过来的 Go 能力,同步返回,像调本地函数。
  • Next{ data: ... } 会被 processNextResponse 塞进 Response.Next(next.go:24),前端拿到的就是你这个自定义结构,而非 LLM 原文。

7. 边界与局限(诚实)

  • 钩子改不了自己的身份。 AssistantIDapplyContextAdjustments 里被明确排除,钩子能改 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.goScript.Create
Create 返回改写会话/参数agent/assistant/hook/create.goapplyContextAdjustments / applyOptionsAdjustments
Next 钩子封装agent/assistant/hook/next.goScript.Next
Next 响应三岔路agent/assistant/next.goprocessNextResponse
标准响应组装agent/assistant/next.gobuildStandardResponse
委派执行agent/assistant/next.gohandleDelegation
管道里 Create 调用点agent/assistant/agent.go:225HookScript.Create
管道里 Next 调用点agent/assistant/agent.go:512HookScript.Next
LLM/CLI/纯Hook 总开关agent/assistant/agent.go:283ast.Prompts || ast.MCP
钩子执行的 V8 上下文agent/assistant/scripts.goScript.ExecuteWithAuthorized
ctx 桥成 JS 对象agent/context/jsapi.goContext.NewObject
释放 ctx 桥句柄agent/context/jsapi.goobjectRelease / bridge.ReleaseGoObject
流式输出方法agent/context/jsapi.gosendMethod / sendStreamMethod / appendMethod / endMethod
ctx.llm 能力agent/context/jsapi_llm.gonewLlmObject
ctx.mcp 能力agent/context/jsapi_mcp.gonewMCPObject
ctx.search 能力agent/context/jsapi_search.gonewSearchObject
ctx.agent 能力agent/context/jsapi_agent.gonewAgentObject
ctx.computer / workspaceagent/context/jsapi_computer.go / jsapi_workspace.gocreateComputerInstance / createWorkspaceInstance
响应/负载类型定义agent/context/types.goHookCreateResponse / NextHookPayload / NextHookResponse / DelegateConfig