Flue Agent Hooks API 完全指南:从渲染契约到 16 个内置 Hook 的实战解析
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
Flue(sandbox agent framework)把「Agent 函数」定义为一组在每次模型调用前执行的渲染(render),而useModel、useTool、useAgentStart等 16 个内置 Hook 就是在这个渲染期间声明能力、读取输入、挂载资源与感知生命周期的唯一入口。本文以仓库中的 Agent Hooks API 参考文档 为主体骨架,结合 packages/runtime/src/hooks 目录下的源码实现,系统讲解渲染契约、每个 Hook 的参数与运行时机、事件 Hook 的共享语义,以及如何用自定义 Hook 组合出可复用的 agent 逻辑,让你能独立编写结构正确、语义严谨的 Flue agent。
渲染(Render)与 Hook 规则:先理解「帧」
Flue 运行时会在每一次模型调用之前执行一次 agent 函数——这一过程被称为一次渲染(render)。无论是对话的每一轮,还是某个 delivery 加入一个进行中的 response 的瞬间,都会触发渲染。每一次渲染都从一个**全新的帧(frame)**开始,Hook 按调用顺序把声明记录到帧上;渲染结束后帧即被清空。
这个「帧」机制在源码中有非常清晰的实现:frame.ts 里用一个模块级变量currentFrame作为「当前渲染帧」的槽位,renderWithFrame在进入渲染前创建全新的RenderFrame,在finally中无论成败都会清空槽位;而requireRenderFrame在帧为空时抛出错误:
[flue] <hook>() was called outside an agent function.这决定了 Hook 的调用点规则:
- Hook 只能在 agent 函数渲染期间同步调用——即 agent 函数体内,或它调用的自定义 Hook 中。
- 在工具
run函数、事件 Hook 回调、模块顶层等任何非渲染位置调用 Hook,都会抛出上面的错误。 - 渲染是纯读操作。Hook 返回的写函数(
usePersistentState的 setter、useDataWriter的 writer、useDispatchMessage的 dispatcher)在渲染期间调用会直接抛错;它们只应在工具run函数等 agent 响应期间的回调中调用。
渲染之间:什么可以变,什么必须不变
不同渲染之间,声明的可变性分为三类(这是整个 Hook 体系最容易踩坑的部分):
| 类别 | 包含的 Hook | 规则 |
|---|---|---|
| 条件声明、可重排 | useTool、useSkill、useSubagent、useMcpConnection、usePersistentState、四个事件 Hook、useSandbox(仅「存在性」) | 集合变化会被运行时以resources/environment信号叙述给模型(见下文) |
| 身份不变 | useDataWriter | 每个渲染必须用完全相同的名字无条件声明;连续渲染间的差异会抛出带有精确增删名单的错误 |
| 必须且恰好一次 | useModel | 参数可以随渲染变化,但调用绝不能消失;一次渲染内调用两次会抛错 |
此外,一次渲染内的重名在一切「以名字标识」的地方都会抛错:工具名、MCP 服务器名、技能名、子代理名、持久状态名、数据部件名;useModel与useSandbox调用两次也会抛错。
渲染结构的「身份不变」校验在 render.ts 的assertRenderStructureInvariance中实现:它只比较messageDataNames(数据部件名),一旦发现增删就抛出带精确差集的错误,而资源、状态、事件 Hook 与 sandbox 明确豁免。
值的作用域:submission 级 vs render 级
- submission 级(提交级):
useModel的取值(model、thinkingLevel、compaction)、useSandbox的 factory 与cwd、useMcpConnection的定义,在一次 submission 开始时只读取一次。后面渲染计算出的新值只对下一次 submission生效,不会中途切换。唯一例外是useSandbox的存在性——它在每个回合边界都会被重新读取。 - render 级:资源集合(tools、skills、subagents)与指令文本,每次模型调用使用当前渲染声明的版本。
根渲染与子代理渲染
当模型通过task工具委托工作时,被委托的 agent 函数会在委托时刻以独立的subagent 帧渲染,每次任务都是全新的一次。在 subagent 帧内:
useTool、useSkill、useInstruction、嵌套useSubagent和自定义 Hook 照常工作。- 实例级、面向客户端的 Hook一律抛错:
useModel(委托者的模型来自useSubagent定义)、useSandbox(委托者共享父环境)、useMcpConnection、usePersistentState、useDataWriter、useDispatchMessage,以及全部四个事件 Hook。 - 例外:
useInitialData()在 subagent 帧返回undefined(不抛错);useDelivery()返回父级的 task 提示词(作为kind: 'user'消息)。
帧的kind: 'agent' | 'subagent'字段在 frame.ts 中定义,各 Hook 实现里都能看到frame.kind === 'subagent'的分支抛错逻辑。
事件 Hook 的共享契约
四个事件 Hook(useAgentStart、useAgentFinish、useResponseStart、useResponseFinish)在 response 生命周期的固定接缝处运行回调,共享一套契约:
- 一个response可能吸收多条已投递消息(在回合边界加入的 deliveries)。
useAgentStart每条投递消息运行一次;useAgentFinish在每个「将要停止」点运行;useResponseStart/useResponseFinish每个 response 各运行一次,分别在真实开始与真实结束处。 - 声明没有持久身份:可以条件声明、重排、跨部署增删——每个接缝运行「当前渲染声明」的内容。渲染内的身份就是声明顺序。
useAgentStart/useAgentFinish会被await,可以是异步的,并接收 harness;useResponseStart/useResponseFinish是同步观察者——返回 Promise 会使 submission 失败。- 回调抛错会使 submission 失败。
- 回调至少执行一次(at-least-once):它们的持久结果(信号追加、状态写入)在每个接缝原子提交,因此接缝中途崩溃不会留下任何持久痕迹,下次尝试会重跑全部回调。持久效果不会重复;但外部副作用(网络调用、文件写入)可能罕见地执行两次——请让它们幂等,或用持久状态做防护。
useModel():声明模型与调优(必选)
function useModel(model: string, options?: UseModelOptions): void; interface UseModelOptions { thinkingLevel?: ThinkingLevel; compaction?: false | CompactionConfig; } type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';useModel是唯一必须调用的 Hook:一次没有useModel的渲染无法启动(运行时会报[flue] ... requires a model. Call useModel('provider-id/model-id') in the agent function.)。每个渲染恰好调用一次,且只能调用一次——实现位于 use-model.ts,二次调用、subagent 帧调用、空字符串模型、未知选项字段都会抛错。
model— 模型标识字符串,格式为'provider-id/model-id'(如'anthropic/claude-sonnet-4-6')。无法解析的标识符会在 submission 初始化阶段直接失败。模型目录参见 Models 指南,注册 provider 参见 Provider API。options.thinkingLevel— agent 级别的默认推理强度。单次harness.prompt()调用可以覆盖它。未设置时运行时默认替换为'medium'。未知取值抛错。options.compaction— 阈值压缩配置(见下节CompactionConfig),传false可禁用阈值压缩;溢出恢复与显式harness.compact()仍然会在需要时压缩。- 未知选项字段抛错;值属于submission 级——从状态计算出的模型变更在下次 submission 生效,而不是当前运行中途。参考 Changing models mid-conversation,典型的升级模式是用持久状态在便宜的模型与强模型之间切换:
export function Reviewer() { const [escalated, setEscalated] = usePersistentState('escalated', false); useModel(escalated ? 'anthropic/claude-opus-4-6' : 'anthropic/claude-haiku-4-5'); // ... escalate_review 工具将 escalated 置为 true return 'Review the proposed change and leave actionable feedback.'; }CompactionConfig:阈值压缩配置
interface CompactionConfig { reserveTokens?: number; keepRecentTokens?: number; model?: string; }reserveTokens— 在上下文窗口中预留的 token 余量;当已用 token 超过contextWindow - reserveTokens时触发压缩。默认值是模型感知的,上限 20000 token,对输出上限较小的模型会缩小,且当余量会占用小上下文窗口一半以上时会调整。正整数。keepRecentTokens— 压缩后原样保留的最近 token 数,更早的消息折叠进摘要。默认8000。更小的值压缩更激进,但会牺牲近期上下文的保真度。正整数。model— 摘要调用使用的模型标识符覆盖,'provider-id/model-id'格式。默认使用会话的模型。- 未知字段抛错。
useSandbox():附加执行环境
function useSandbox(sandbox: SandboxFactory, options?: UseSandboxOptions): void; interface UseSandboxOptions { cwd?: string; }useSandbox把 agent 实例的运行环境(文件系统/执行面)附加进来。factory 的createSandbox()在每个已初始化的 harness 上构建一次文件系统/执行面(adapter 以实例 id 为键持久化资源),其tools()(如果存在)会替换由 sandbox 支撑的模型面向工具集。没有调用该 Hook,agent 就没有环境:内置的文件与 shell 工具不会被添加,sandbox 支撑的操作(harness.sandbox、工作区发现)也不会发生。SandboxFactory契约见 Sandbox Adapter API;第一方 factory 包括基于 just-bash 实例的bash()和来自@flue/runtime/node的local()。
源码层面的校验细节见 use-sandbox.ts:
sandbox— 直接传SandboxFactory值(factory 本身就是惰性的;昂贵的createSandbox()在初始化时只执行一次)。没有createSandbox函数(或已废弃的createSessionEnv)的值抛错;非函数的tools属性抛错。options.cwd— agent 在已初始化环境内的工作目录。非空字符串。submission 开始时读取一次。未知选项字段抛错。- 每渲染至多一次,二次调用抛错;subagent 渲染抛错(委托者共享父环境,用 task 调用的
cwd来限定工作范围)。 - 重复渲染永远不会重建环境。
- 调用可以是条件的。存在性在初始化和每个回合边界读取:翻转时运行时会在下一次模型调用前切换环境(attach 解析声明的 factory,detach 移除环境及其工具,什么都不带走),并以一条
environment信号向模型宣告完整的当前状态(见 Dynamic resources)。 - 只有存在性跨渲染可观察(factory 每次渲染都是新对象)。保持附加状态下把一个 factory 换成另一个,要到下次 submission 的初始化才生效。
- 从持久状态推导的条件会持久重放:后续每次 submission 重新附加相同的声明,以实例 id 为键的 adapter 会解析回相同的持久工作区。参考 Conditional attachment 中的「调查模式」示例——工具把持久状态翻转为
true后才挂载local({ cwd })。
资源类 Hook:useTool()、useMcpConnection()、useSkill()、useSubagent()
这四个 Hook 声明「模型可以使用什么」。它们的共同点是:可以条件声明,集合变化会被叙述为resources信号(见 Dynamic resources),且同名重复声明会在一个渲染内抛错。
useTool():挂载模型可调用工具
function useTool(tool: ToolDefinition): void;接收defineTool(...)的值或内联定义对象——挂载点应用相同的校验(use-tool.ts 调用assertToolDefinition)。无论从 agent 体内还是自定义 Hook 中调用,工具都汇入该渲染的单一扁平工具集。工具的完整定义契约、ToolContext、harness/durable标志见defineTool()。
- 挂载可以条件化;集合变化会叙述给模型。未挂载的工具完全无法被调用。
- 整个渲染中重复的工具名抛
ToolNameConflictError(render.ts 的assertUniqueToolNames负责执行)。 - 无效定义在挂载时抛出与
defineTool()相同的错误。
useMcpConnection():声明远程 MCP 服务器
function useMcpConnection(definition: McpConnectionDefinition): void;声明 agent 使用的远程 MCP 服务器。接受McpConnectionDefinition——通常是defineMcpConnection(...)导出的冻结对象,或相同形状的内联对象(挂载点应用相同校验,字段白名单与传输类型校验见 use-mcp-connection.ts,支持streamable-http与sse两种传输)。运行时在submission 初始化时建立连接(在请求上下文中,所有目标上并行连接所有声明的服务器),并把服务器的工具以mcp__<server>__<tool>的命名挂进渲染的扁平工具集。定义形状与适配契约见 Agent API,用法见 MCP 指南。
- 定义在每次 submission 初始化时读取一次。条件声明在下次 submission 生效,并叙述为
resources信号。 - 连接在实例的内存生命周期内复用;定义在首次连接时读取(
auth除外——每个请求重新解析)。连接失败会在模型运行前使 submission 失败,且不会缓存——除非定义设置了optional: true,此时本次 submission 挂载零个工具并向模型宣告缺口。 - 一个渲染内重复的服务器名抛错;subagent 渲染抛错(请在根 agent 上声明连接)。
useSkill():挂载技能
function useSkill(skill: Skill): void; type Skill = SkillReference | SkillDefinition; interface SkillReference { readonly __flueSkillReference: true; readonly id: string; readonly name: string; readonly description: string; }技能采用渐进式披露:每个已挂载技能只在系统提示中占据一行常驻目录(名称 + 描述),模型用框架的activate_skill工具按需拉取完整指令——简报以工具结果形式到达,提示前缀永不改变。支持文件保持惰性,直到被显式读取(use-skill.ts 通过__flueSkillReference标记区分引用与内联定义)。
- 接受
SkillReference——SKILL.md导入的值(构建时自动打包,见 Skills 指南)或defineSkill(...)的结果——或内联SkillDefinition对象,挂载点校验。定义契约见 Agent API。 - 一次渲染内挂载同名技能两次抛错。
- 挂载可以条件化;目录变化会被叙述。
- 始终在线的内容不需要技能:把 markdown 作为字符串导入(任何
.md导入都以文本加载)并传给useInstruction()即可。
useSubagent():声明委托者
function useSubagent(subagent: SubagentDefinition): void;声明模型可以通过框架task工具把专注工作交给的委托者。委托是声明出来的能力:task工具始终在工具集中,且规范完全静态(任何一端变化都会重写序列化的工具块并使 provider 的提示缓存失效);名册位于系统提示的 "Available Agents" 部分;工具的必填agent参数只对已声明的子代理解析——名册为空时工具是惰性的。定义形状——SubagentDefinition、defineSubagent()助手、以及空白的通用委托者GeneralSubagent——见 Agent API。
- 一次渲染内重复的委托者名抛错。声明可以条件化;名册变化被叙述。
- 委托者的
agent函数在委托时刻渲染,拥有自己的帧,每次任务都是全新的——闭包读取当前值,两次委托给同一子代理会独立渲染(render.ts 的resolveSubagentDefinition负责此流程)。 - 委托者与父级隔离:除共享环境以及(除非在定义中覆盖)父级的模型与推理强度外,什么都不流入。它运行一个分离的会话,只有最终文本返回父级。见 Subagents 指南 与本页的 subagent 渲染规则。
useInstruction():低级别的指令逃生舱
function useInstruction(text: string): void;为当前渲染追加原始指令文本——刻意保留的低级别逃生舱。文本按调用顺序落在 agent 返回指令之后,以空行连接;格式完全由作者负责。没有结构、没有身份、没有逐片段的变更追踪(组合后的文档整体做摘要追踪,见instructions信号)。
text— 必填,去除首尾空白后必须非空,否则抛错(use-instruction.ts)。- 根渲染与 subagent 渲染都可调用,次数不限。
实际组合逻辑见 render.ts 的composeAgentDocument:agent 返回的指令(非空时)在前,useInstruction贡献按调用顺序在后,以'\n\n'连接。另外注意assertAgentInstruction强制 agent 函数必须是同步的——它只能返回指令字符串(或什么都不返回),异步工作要放进工具或资源 factory。
状态与输入类 Hook:usePersistentState()、useInitialData()、useDelivery()
这三个 Hook 构成 Flue 的三段式输入模型:useInitialData()是实例关于什么,useDelivery()是这条消息说什么,usePersistentState是 agent学到了什么。
usePersistentState():实例级持久状态
function usePersistentState<T>(name: string, defaultValue: T): [T, StateSetter<T>]; function usePersistentState<T = unknown>(name: string): [T | undefined, StateSetter<T | undefined>]; type StateSetter<T> = (value: T | ((previous: T) => T)) => void;实例记录日志之上的持久状态 API。Hook 读取截至本次渲染的值并返回一个 setter——直接设置新值,或通过调用时解析的 updater 设置。读是渲染时快照;写是静默的——从不发布消息、从不唤醒 agent、从不中途重渲染。下次渲染读到最新持久值。完整语义见 use-persistent-state.ts:
- 值是 JSON:写入经过 JSON 往返归一化,非可序列化输入抛错。设置
undefined抛错——没有「取消设置」;一个名字一旦写入就永远有值。defaultValue在首次写入前填充,自身永不被持久化。 - updater 形式是读-改-写路径:
previous在调用时通过本次尝试的写缓冲解析,而非闭包诞生时的渲染快照——同一回合两个回调用 updater 组合不会互相丢写。任何函数参数都被当作 updater(函数从来不是合法值)。 - 写入与当前值深相等时是 no-op,不追加记录。
- 工具做出的写入与产生它们的工具批次原子持久:批次落定则写入持久;恢复把批次结算为中断则写入从未发生。见 Durability 指南。
- setter 在渲染期间(渲染是纯读)以及背后没有持久运行时的裸工具/测试渲染中抛错。
- 状态按
name键定界在 agent 实例上。一次渲染内声明同名两次抛错;跨渲染条件声明合法——跳过声明的渲染就是没碰它的渲染,声明回归时重新读取已记录的值。 - subagent 渲染抛错——持久状态是实例级的,委托者运行分离任务。把委托者需要的东西通过 task 提示词传过去。
- 类型参数仅编译期有效;运行时不会解析持久值。需要强制时在调用点断言,或在其上组合一个做 schema 校验的自定义 Hook。
useInitialData():读取实例创建数据
function useInitialData<T = unknown>(): T;读取实例的创建数据——调用者随本实例首次接触发送的initialData,创建时记录一次,实例整个生命周期恒定。演化的事实属于usePersistentState;逐消息的事实属于useDelivery(use-initial-data.ts)。
- agent 上带有
initialDataschema 静态时,值在创建时校验(不匹配会使创建发送失败,因此这里总是有值),Hook 返回 schema 解析后的输出。没有 schema 时,创建者发送什么就原样返回什么(未类型化)。 - 发给既有实例的
initialData被忽略;记录值无法改变。 - 返回类型恰好是你断言的类型参数。运行时在创建未携带数据、裸工具/测试渲染、subagent 渲染(委托者没有自己的创建数据)时值是
undefined——遇到这些情况就在类型里说明:useInitialData<Config | undefined>()。 - 记录值是实例持久记录流的一部分,但从不提供给客户端。它仍然不是密钥通道——密钥和 token 留在环境中。
useDelivery():读取模型面前的消息
function useDelivery(): DeliveredMessage;读取当前位于模型面前的消息,形状与每个传输接纳的已校验DeliveredMessage相同(use-delivery.ts)。该值是一个游标:从唤醒 response 的投递开始,每当新消息到达模型就前进——无论是回合边界加入进行中 response 的投递,还是事件 Hook 回调追加的信号。它让代码获得与模型相同的访问权,工具不再依赖模型把值回显进输入。
- 传输与来源无关:直接 HTTP 提示、
dispatch()调用、事件 Hook 的append在这里产生相同的形状。 - 框架叙述信号(
resources/instructions/environment)不会推进游标——关于 agent 自身声明面的记账永远不会挤掉 response 正在应答的输入。 - 一个渲染内恒定,下个渲染刷新。渲染发生在每次模型调用前;多条消息汇集进一个 response 时,游标按模型读取它们的顺序行走。为加入的消息触发的
useAgentStart回调读取的正是那条消息。 - 崩溃安全:恢复的尝试从持久记录流推导出与实时尝试相同的游标。
- subagent 渲染中,投递是父级的 task 提示词,为
kind: 'user'消息(任务图片以attachments携带)。 - 运行时总是存在:每个 response 都从一条已投递消息开始。背后没有投递的裸工具/测试渲染抛错。
useDispatchMessage():绑定到实例的调度器
function useDispatchMessage(): (message: DeliveredMessageInput) => Promise<DispatchReceipt>;获取绑定到本 agent 实例的调度器——顶层dispatch()的 agent 作用域形式。返回的函数只需消息:实例已存在,因此没有initialData也没有uid条件。语义与全局动词同构(同一队列、同一接纳、同一投递,与直接 HTTP 提示共享一个被接纳的顺序)。实现见 use-dispatch-message.ts:dispatcher 从渲染帧的agentName/instanceId绑定自身,经由enqueueDispatch入队。
- 两种消息类型都可用;裸字符串是
{ kind: 'user', body }的简写。 - 向忙碌的本实例派发会在下个回合边界加入进行中的 response——持久接纳、运行自己的
useAgentStart、模型在下回合读取——不打断进行中的回合。向空闲实例派发会唤醒新的 response。错过进行中 response 的投递会作为自己的 submission 从持久队列运行,永不丢失。 - 加入的投递随承载它的 response 一起结算,结果相同,在宿主 response 的持久性预算下。加入的 HTTP 提示仍会写自己的
submission_settled记录,因此 SDK 的wait()解析得与单独运行完全一致。 - 每次调用都是带自己回执的持久投递。像重试工具中的任何外部副作用一样,重跑会再次派发——请按 at-least-once 设计。
- dispatcher 在渲染期间、裸工具/测试渲染上、以及运行时未配置时抛错。Hook 本身在 subagent 渲染抛错——委托者没有自己的实例;它把产出作为 task 结果返回即可。
useDataWriter():向客户端流式输出命名数据部件
function useDataWriter<TSchema extends v.GenericSchema>( name: string, options: { schema: TSchema }, ): (data: v.InferOutput<TSchema>) => void; function useDataWriter(name: string): (data: unknown) => void;声明一个命名的、面向客户端的数据部件,并拿回一个流式写入它的只写函数。输出是单向、非响应式的:模型永远看不到数据部件,写入永不重跑 agent,也从不读回任何东西。每次写入被持久追加并立即流式传输给客户端,因此部件可以在工具运行中途展示实时进度。实现与校验见 use-data-writer.ts。
name— 部件在 response 内的身份(AI SDK 约定:response 消息部件中的data-<name>)。首次写入放置部件;后续写入就地更新。挂载本身不产生任何东西;部件在首次写入后才存在。options.schema— 校验每次写入的 Valibot schema;不匹配时 writer 抛错。未知选项字段抛错。- 值是 JSON:写入经 JSON 往返归一化;
undefined和不可序列化值抛错。 - writer 在渲染期间和背后没有持久运行时的裸工具/测试渲染上抛错。
- 名字每次渲染唯一,且是渲染结构身份的一部分——无条件声明
useDataWriter,每个渲染完全相同;渲染间差异抛错。声明它的自定义 Hook 继承该规则。Hook 在 subagent 渲染抛错。 - 部件作为对话消息的数据部件和
AgentReply.data上线;端到端示例(含@flue/react客户端的part.type === 'data-orderCard'渲染分支)见 Streaming data to the client。
事件 Hook:useAgentStart()、useAgentFinish()、useResponseStart()、useResponseFinish()
useAgentStart():投递消息的「入口接缝」
function useAgentStart(run: (ctx: AgentStartContext) => void | Promise<void>): void; interface AgentStartContext { readonly append: (message: AgentAppendMessage) => void; readonly harness: FlueHarness; readonly log: FlueLogger; readonly signal: AbortSignal; }当 agent 开始处理一条已投递消息时运行回调——输入已持久、模型首回合之前。这是入口接缝:加载模型醒来该知道的内容、播种文件、写持久状态,并通过派发信号宣告。回调被 await,可以是异步的;抛错使 submission 失败,执行是 at-least-once 且持久结果在每个接缝原子提交(use-agent-start.ts 只是把回调压入frame.agentStarts;真正的执行在 message-output 会话层)。
- 每条已投递消息运行一次,在模型读取它之前——包括中途加入进行中 response 的投递。非响应式:回调永不为已处理的消息重跑。一次性实例级工作用持久状态防护。
- 一条投递的回调并发运行,顺序无保证;模型等待最慢的。绝不要依赖兄弟回调的写入——需要排序的工作组合进一个回调。追加的信号无论完成顺序如何,都按声明顺序分组到达对话。
- 所有输出都是显式的:面向模型的信号走
useDispatchMessage()dispatcher(每次都是真实投递,本身也会触发这些 Hook——用持久状态防护)、持久值走状态 setter、文件走 harness。 ctx.append把信号写进当前 response而不登记投递——没有自己的useAgentStart运行,没有 submission。它接受与useAgentFinish的append相同的AgentAppendMessage形状与校验,且只在回调执行窗口内合法;捕获的引用之后调用会抛错。优先派发;只有投递不对时才用append。ctx.harness是 harness,首次访问时惰性物化。ctx.signal是 submission 的中止信号。ctx.log把进度行发进对话流;模型永远看不到它们。- 压缩最终可能把信号折叠掉——把回调的实质内容留在持久状态和文件中;信号是宣告,不是存储。
典型「加载一次」模式(来自 Agent Hooks 指南):
export function AccountSupport({ id }: AgentProps) { useModel('anthropic/claude-haiku-4-5'); const [customer, setCustomer] = usePersistentState<Customer | null>('customer', null); useAgentStart(async () => { if (customer) return; // 每个对话只加载一次 setCustomer(await crm.lookupCustomer(id)); }); return customer ? `Help ${customer.name} (${customer.plan} plan) with their account.` : 'Help the customer with their account.'; }useAgentFinish():会停止点的「强制接缝」
function useAgentFinish(run: (ctx: AgentFinishContext) => void | Promise<void>): void; interface AgentFinishContext { readonly response: { readonly toolCalls: readonly AgentResponseToolCall[]; readonly usage: PromptUsage; }; readonly append: (message: AgentAppendMessage) => void; readonly harness: FlueHarness; readonly log: FlueLogger; readonly signal: AbortSignal; } interface AgentResponseToolCall { tool: string; isError: boolean; } interface AgentAppendMessage { kind: 'signal'; type: string; body: string; attributes?: Record<string, string>; tagName?: string; }当 agent 本来要结束响应时运行回调——模型不再有工具调用,response 即将结算。这是强制接缝:检查 response 实际做了什么,如果工作没做完,append一条信号把模型在同一 response 内送回去继续。回调被 await、可异步、接收 harness,抛错使 submission 失败(use-agent-finish.ts)。
- 控制接缝,不是被动监听:回调在 response 结算前被 await。
ctx.append把信号导入同一 response——再跑一个回合,续篇处理完后 Hook 在下个会停止点再次运行。只有当一个周期以无追加且没有等待中的已投递输入完成时,response 才结算;排队中的投递在任何 finish 评估前加入,因此多条消息汇集为多次useAgentStart运行和一次最终useAgentFinish。 append只接受kind: 'signal'消息,校验与已投递信号相同(非空type、字符串body、字符串到字符串的attributes、XML 名的tagName);kind: 'user'消息抛错——真正的新输入属于useDispatchMessage()。框架保留信号类型也抛错。它只在回调执行窗口内合法。- append vs dispatch:append 是 response 自我转向——没有
useAgentStart运行、没有自己的 submission、计入续篇上限。本回调里的 dispatch 是真实投递——它加入同一 response,Hook 在新真实结束处再次触发,带自己的useAgentStart运行,永不计数进入上限。 - 只在已投递 submission 上运行,按声明顺序串行;多个 Hook 共享每个周期,任一追加则 response 继续。
response.toolCalls聚合 response 已做出的每次工具调用——跨所有回合与重试,从持久记录推导。response.usage是迄今的聚合用量(结算总量属于useResponseFinish)。- 持久:续篇周期是 response 控制检查点,与其信号原子记录——恢复的 response 驱动检查点的待定续篇而非重新评估,因此绝不重跑已完成的周期或追加两次。检查点前被中断的评估在重试时整体重跑(at-least-once)。
- 失控防护是固定的框架上限:每 response 32 个续篇周期,不可配置——无条件追加的 Hook 会响亮地使 submission 失败,而不是作为成功结算。submission 的持久性超时仍是总墙钟兜底——续篇和加入都不会延长它。
useResponseStart():观察 response 的真实开始
function useResponseStart(run: ResponseMetadataCallback<ResponseStartContext>): void; type ResponseMetadataCallback<TCtx> = (ctx: TCtx) => Record<string, unknown> | void; interface ResponseStartContext { readonly metadata: Record<string, unknown>; readonly log: FlueLogger; }每个 response 一次、同步、在首次模型调用之前、在任何useAgentStart回调之前观察真实开始。返回一个普通对象以深合并到 response 消息的 metadata 上(AI SDK 约定:消息的metadata字段——客户端在内容流之外读取的信封事实)。什么都不返回则只观察不附加(use-response-start.ts)。
- 每 response 一次:加入进行中 response 的投递会重新触发
useAgentStart,但 response 只醒来一次——本 Hook 不重触发。已带持久 assistant 步骤的 response 的恢复会跳过它;从首个持久步骤之前开始的重试会重跑(at-least-once)。 - 同步观察者:没有 append、没有 dispatch、没有 harness。返回 Promise 使 submission 失败;开始接缝的异步工作属于
useAgentStart。 ctx.metadata是本次 response 迄今积累的 metadata(更早 Hook 的贡献,按声明顺序),调用时传入——绝不是陈旧的渲染捕获。返回值深合并;后键胜出,undefined值跳过,原型污染键(__proto__、constructor、prototype)丢弃。非对象、数组或 Promise 返回使 submission 失败。- 快速失败:抛错使 submission 失败——不重试、不恢复。
- metadata 模型不可见、非响应式;运行时不自盖任何键。它经对话流和
AgentReply.metadata到达客户端。
useResponseFinish():观察 response 的真实结束
function useResponseFinish(run: ResponseMetadataCallback<ResponseFinishContext>): void; interface ResponseFinishContext { readonly metadata: Record<string, unknown>; readonly response: { readonly usage: PromptUsage; readonly toolCalls: readonly AgentResponseToolCall[]; }; readonly log: FlueLogger; }每个 response 一次、同步、在最后一个useAgentFinish周期结算且每个排队输出写入都刷新之后观察真实结束。返回契约、合并规则与失败语义同useResponseStart(use-response-finish.ts)。
- 在最终 finish 周期之后、response 真正结算时运行;它的
response.usage与response.toolCalls聚合是最终的。 ctx.metadata包含useResponseStartHook 附加的内容——从持久记录日志读取,因此跨重试存活——加上更早 finish Hook 的贡献。- 结束接缝的异步工作属于
useAgentFinish。
时间戳与用量统计的经典组合(两个观察者 + 深合并 metadata):
function useRunStats() { useResponseStart(() => ({ startedAt: Date.now() })); useResponseFinish(({ metadata, response }) => ({ finishedAt: Date.now(), elapsed: Date.now() - (metadata.startedAt as number), totalTokens: response.usage.totalTokens, toolCalls: response.toolCalls.length, })); }自定义 Hook:组合的原子单位
自定义 Hook 就是一个普通函数,按惯例以use前缀命名,内部调用其他 Hook。没有注册、没有包装:因为渲染帧是环境性的,自定义 Hook 内部调用的 Hook 记录得与 agent 体直接调用完全相同、同一调用顺序(frame.ts 的注释把这个机制与 Preact 的currentComponent类比)。自定义 Hook 可以向 agent 体传参并返回值,也可以互相组合。
function useRetention(active: () => boolean) { useTool({ ...offerCredit, run: (ctx) => (active() ? offerCredit.run(ctx) : 'Refused: no churn risk on record.'), }); useInstruction( 'Only while the customer is weighing cancellation: you may offer retention incentives.', ); }所有 Hook 规则经自定义 Hook 原样生效——渲染之外调用自定义 Hook 会在第一个内部 Hook 处抛错;每渲染唯一性(一个useModel、一个useSandbox、名字唯一)会计入任意深度自定义 Hook 中的调用。
全部符号的导出面
所有 Hook 都从@flue/runtime导出,导出清单集中在 packages/runtime/src/index.ts:useAgentFinish、useAgentStart、useDataWriter、useDelivery、useDispatchMessage、useInitialData、useInstruction、defineMcpConnection/useMcpConnection、useModel(含UseModelOptions类型)、usePersistentState(含StateSetter)、useResponseFinish、useResponseStart、useSandbox(含UseSandboxOptions)、useSkill、defineSubagent/GeneralSubagent/useSubagent、useTool。需要深入资源形状(defineTool/defineSkill/defineSubagent/defineMcpConnection)与 agent 周围的可编程面(dispatch()、init()、start()、路由、harness)时,继续阅读 Agent API;逐能力向导参见 Agent Hooks 指南、Tools、Skills、Subagents、Models、Sandboxes;Hook 与工具抛出的错误类见 Errors。
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考