backend/packages/harness/deerflow/agents/lead_agent/agent.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @7e7f041 backend/packages/harness/deerflow/sandbox/middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/view_image_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py @7e7f041 第一次点开 build_middlewares(),它看起来并不起眼——只是把一串对象依次 append 进一个 list。但这个 list 的排布,实际上决定了后面一整次 run 的运行时行为:用户的 prompt 会先经过一组 middleware,再进入模型调用。它们负责准备 thread 的文件路径、处理上传附件、建立 sandbox 的生命周期、注入日期和 memory、修补 provider 要求的消息协议。等到模型调用开始时,request 已经被整理成 DeerFlow 期望的样子。
上一站我们看了工具是怎么被收集和暴露出去的。这一站继续沿 run 生命周期往下看:一次 run 在模型调用前会经过哪些 middleware,哪些状态和协议边界会在这里建立。 模型返回之后的那一半——after_model、wrap_tool_call、after_agent 如何按逆序处理结果——留给下一站。
flowchart TD RUN["run 进入"] --> BA["before_agent · 整个 run 只跑一次"] BA --> BM["before_model · 每轮模型调用前"] BM --> WMC["wrap_model_call · 包住模型调用"] WMC --> MODEL["model call"] MODEL -.->|"返回结果向外走 → 下一站"| OUT["after_model / wrap_tool_call / after_agent"]
一个 list,两段拼出来
装配入口是 agent.py::build_middlewares 。逻辑上分两步:先取一段 lead 和 subagent 共用的 runtime 基座,再往上叠 lead agent 专属的 middleware。
共享基座来自 tool_error_handling_middleware.py::build_lead_runtime_middlewares :
共享基座(lead 与 subagent 复用)
ToolOutputBudget 大工具输出落盘,消息里只留预览和路径
ThreadData 计算 thread_data 路径,给 workspace/uploads/outputs 定位
Uploads 把上传文件信息呈现给模型(只有 lead 有)
Sandbox 管理 sandbox 生命周期和 sandbox_id 持久化
DanglingToolCall 修补历史里悬空的工具调用协议
LLMErrorHandling 模型调用失败时重试、熔断、兜底
Guardrail? 可选运行时授权(配置了 provider 才有)
SandboxAudit bash command 的 pass/warn/block 闸门
ToolErrorHandling 工具异常转成 ToolMessage(status="error")
然后 build_middlewares() 继续追加 lead 专属段:
lead 专属段(带 ? 的按条件装配)
DynamicContext 注入日期和记忆,不动 system prompt
SkillActivation 用户用 /skill 时,把完整 SKILL.md 注入本轮 request
Summarization? 历史太长时压缩旧消息
TodoList? plan mode 的任务清单和完成提醒
TokenUsage? 记录 token usage,给动作做产品语义归因
Title 第一轮完整对话后生成标题
Memory run 结束后把对话送入 memory queue
ViewImage? 视觉模型可用时,把已查看图片呈现给模型
DeferredToolFilter? 隐藏未 promoted 的 deferred tool schema,拦截非法调用
SubagentLimit? 限制并行 task/subagent 数量
LoopDetection? 检测并打断重复工具调用循环
SafetyFinishReason? provider safety stop 后清空不可信 tool_calls
Clarification 把 ask_clarification 变成 run 级暂停,始终最后注册
两张表里带 ? 的项,意思是它不一定出现在每次 run 里。build_middlewares() 装配它们时外面包了一个 if:条件成立才 append 进链,不成立就根本不进这次 run 的列表。条件来自运行时配置和模型能力——Summarization? 看有没有开摘要,TodoList? 看是不是 plan mode,ViewImage? 看当前模型支不支持视觉,DeferredToolFilter? 看有没有开 tool_search。不带 ? 的(DynamicContext、Title、Memory、Clarification……)则每次必装。
这里有两件事值得在往下读之前说清楚。
第一,顺序本身就是运行时契约。 build_middlewares() 上方的注释并非可有可无——它描述的是源码无法用类型系统表达、但真实存在的数据依赖:ThreadData 先算出 workspace / uploads / outputs 路径,Uploads 要用其中的 uploads 路径去扫文件、Sandbox 的挂载也要用这套路径,所以两者都必须排在 ThreadData 之后;而 Clarification 要能在工具执行前把 ask_clarification 拦成 run 级暂停,就必须守在工具边界的最里侧,所以总是最后注册。改顺序就是改行为,而且不会有任何编译期检查提醒你改错了。
第二,注册顺序不等于每个 hook 的执行顺序。 同一个 middleware 可能挂多个 hook,而不同 hook 的派发方向不一样——有的正序,有的逆序。这一点是后面所有内容的地基,下一小节详细解释。
怎么理解注册顺序和执行顺序?
这是整章最容易混淆的部分,先记住一句话:
先注册的 middleware,位于调用链更外层。
一个 middleware 可以同时挂好几个 hook(before_model、after_model、wrap_model_call……)。它们共用同一份注册顺序,但派发方向不一样。用调用链的包裹关系理解会更清晰:
请求进入模型调用前 → 先注册的先执行
模型返回结果向外处理时 → 后注册的先执行
落到三类 hook 上,其实是同一条规则的三个切面:
before_*(调用前) 正序:先注册的先执行
after_* (返回后) 逆序:后注册的先执行
wrap_* (调用前后都管) 先注册的把后注册的包在里面
举个最小的例子。注册顺序是 A(在上)、B(在下),两个都挂了 before_model 和 after_model:
before_model(请求进来): A ──▶ B ──▶ 模型
after_model (回答出去): 模型 ──▶ B ──▶ A
A、B 的先后整个反过来了:进来时 A 先,出去时 B 先。所以光看 list 里「A 在 B 上面」,根本推不出”A 一定先执行”——必须先问是哪个 hook。
wrap_model_call 把”进”和”出”压进同一个函数,还是用A、B举例子:
进入 → A 拿到 request,改一改,调 handler
└─ B 拿到 request,改一改,调 handler
└─ 调用模型,拿到 response
┌─ B 先处理 response
返回 ← A 后处理 response
A 是外皮:最先碰到请求、最后碰到回答;B 在里层:最后碰到请求、最先碰到回答。
下一站会讲的 SafetyFinishReasonMiddleware 故意注册得靠后(靠里),它的注释写明:就是为了在 after_model 的逆序派发里最先跑,赶在循环检测、子 agent 计数之前,把 provider 安全截断后不可信的 tool_calls 清掉。注册靠里 → 出去时先执行。
所以这一章按 run 的生命周期讲:请求先经过 before_agent → before_model → wrap_model_call,再抵达模型调用;模型返回后的处理放到下一站。这样的阅读顺序,和运行时实际执行顺序一致。
middleware 的产物写到哪里?
顺序之外,读 middleware 还要盯住第二条线:每个 middleware 的产物,到底写到哪里。 后面你会反复看到两种说法——「写回 graph state」和「只改这次 request、不写 state」——先把它们的分界立住,后面就不用每次重新判断。
graph state(也就是 ThreadState)是一块被 checkpoint 持久化、跨轮存活、所有 middleware 和工具共享的黑板。一个东西要不要写进去,就问三件事:
要跨轮 / 跨 resume 活下来吗? 会被 checkpoint 存下、恢复时还原
要被别的 middleware 或工具看到吗? state 是大家共享的黑板
要按字段的 reducer 规则合并吗? 多次更新怎么 merge
三个都不需要,就不该写进 state——它会去下面三个地方之一:
只改这一次 model request → turn-local,下一轮就没了(如 skill 正文、各种 reminder)
留在 middleware 实例内存里 → 进程级运行时控制(如循环计数、熔断状态)
派发去外部 → 副作用(如 memory queue、审计日志、SSE 事件)
一个关键判断:该不该进 state,看的是”暂停后是否还要继续使用、或者别的 middleware 是否还要读取”,而不是”它重不重要”。 一条转瞬即逝的循环警告也很重要,但它绝不该被 checkpoint 存下来、resume 时再还原回来。按照这个标准,每个 middleware 写不写 state,你都能自己判断。
这一篇覆盖的三类 hook
下面这张表是后面几节的导航——每个 middleware 都会放到对应阶段展开:
| Hook | 时机 | 一次 run 跑几次 | 本章涉及 |
|---|---|---|---|
before_agent | 进入 agent graph 时 | 1 次 | ThreadData、Uploads、Sandbox、DynamicContext、Todo、LoopDetection |
before_model | 每轮 model call 前 | 每轮 | Summarization、Todo、ViewImage |
wrap_model_call | 包住一次 model call | 每轮 | ToolOutputBudget、DanglingToolCall、LLMErrorHandling、LoopDetection、SkillActivation、Todo、DeferredToolFilter |
剩下三类要到模型返回之后才执行,是下一站的内容:
after_model
wrap_tool_call
after_agent
下面就从 run 的第一个阶段 before_agent 开始,走向内的半程。
before_agent:run 的开场
before_agent 在进入 agent graph 的时候跑一次。它负责 run 级别的准备工作:确定路径、呈现上传文件、建立 sandbox 生命周期、注入日期与 memory,或者清理上一轮 run 的残留状态。
这一层的完整清单:
ThreadData
Uploads
Sandbox
DynamicContext
Todo(before_agent cleanup, plan mode only)
LoopDetection(before_agent reset, if enabled)
ThreadData 来自 thread_data_middleware.py::ThreadDataMiddleware 。它根据 thread_id 和可选的 user_id 计算出:
workspace_path
uploads_path
outputs_path
默认 lazy_init=True 的时候,它主要是计算并写入路径,不一定立刻创建目录。只有 lazy_init=False 时才会 eager create。也就是说,ThreadDataMiddleware 不是”文件系统工具”——它更像是给后面的 middleware 和工具提供一套坐标:这个 thread 的文件该往哪里放。
它还会给最后一条用户消息补上 run_id 和 timestamp,方便前端或 runtime 识别这一轮输入。这个动作也透露了一个容易忽略的点:middleware 可以在 run 一开场就改写消息对象,不一定要等到 model call 前才动手。
Uploads 来自 uploads_middleware.py::UploadsMiddleware 。它不负责”上传文件”这个动作——文件已经由前端或 gateway 放进了 thread 的 uploads 目录。它的职责是扫描当前消息里附带的新文件、加上目录里已有的历史文件,生成一段 <uploaded_files> 上下文,prepend 到最后一条 HumanMessage 前面。
这一步只有 lead agent 装配。subagent 不装 UploadsMiddleware,因为 subagent 继承的是执行上下文,不需要重新处理用户的上传入口。
这里还埋了一个后面很多 middleware 都会用到的设计模式:
真实文件:
留在 thread uploads 目录
模型看到的内容:
文件名、大小、虚拟路径、outline/preview
前端需要的结构化信息:
保留在 message.additional_kwargs
也就是说,Uploads 只告诉模型”有哪些文件、放在哪里、是什么类型、大致内容是什么”,不会把文件内容整段塞进 prompt。模型如果确实需要完整内容,后面再通过文件工具去读。
Sandbox 来自 middleware.py::SandboxMiddleware 。它默认是lazy_init=True。也就是说,before_agent 阶段通常不会马上 acquire 一个 sandbox;acquire 发生在第一次 sandbox tool 调用时。
这层的职责是建立 sandbox 的生命周期和状态契约。
state["sandbox"] 里保存 sandbox_id
sandbox provider 管理 sandbox 实例
after_agent 负责 release
wrap_tool_call 负责把工具执行中新产生的 sandbox_id 持久化回 graph state
先记住一个分工:
ThreadData → 文件路径属于哪个 thread
Sandbox → 工具命令在哪个执行环境里跑
具体的 local / container / Kubernetes / AIO sandbox 的隔离边界,放到后面的 sandbox 章节再展开。这里有一个关键区分:
sandbox_id:
写进 graph state 的资源身份
Sandbox instance:
provider 持有的真实执行对象
工具里通过 runtime.state["sandbox"] 只能拿到类似 {"sandbox_id": "..."} 的状态;能执行命令、读写文件的是 provider 根据这个 id 找回来的 sandbox 实例。
DynamicContext 来自 dynamic_context_middleware.py 。它把日期和记忆注入为隐藏的 HumanMessage,而不是去改 system prompt。
不改 system prompt 的原因:prefix cache。模型供应商会把一段稳定 prompt 前缀的计算结果缓存下来,下次遇到同样的前缀直接复用,省时也省钱;可前缀只要变一个字,这份缓存就作废,得从头重算。日期、memory 这种每轮都可能变的动态内容,要是塞进 system prompt,就会不断击穿这层缓存——所以它们只能走 message stream(放进消息列表里),让 system prompt 一直保持不变。
那怎么把一条 reminder 插到用户问题前面,同时保持用户原文不变?DynamicContext 用的是 ID-swap(ID 互换)。先补一个前提:LangGraph 的消息 reducer 有条规则——新消息的 id 和已有消息相同,就原地替换那一条;id 不同,才追加。DynamicContext 借这条规则,把 reminder 的 id 设成原用户消息的 id,再把原始用户文本挪到一个派生 id {原id}__user 上:
注入前:
[id = X] 用户的问题
注入后:
[id = X] 隐藏的 system-reminder(复用原 id → 顶替掉原消息的位置)
[id = X__user] 用户的问题(换成派生 id,紧跟在 reminder 后面)
于是三件事一次做到:reminder 排到了用户问题前面、用户原文一字未改(只换了 id)、而且这条 reminder 带着 hide_from_ui 标记,前端不会把它当成用户消息显示。
它还有一个可靠性设计:异步路径会把可能阻塞的 memory 构建放进 asyncio.to_thread(),再用 5 秒超时兜底。这样即便 memory 变慢,也不会拖住整个 run。
注入策略不是”每轮都塞一条”:
first turn:
在第一条真实用户消息前注入 date + memory
same day:
不重复注入
date changed:
在最新真实用户消息前补一条 date-only reminder
这里有两个语义层要分清楚:
content envelope:
<system-reminder>...</system-reminder>
给模型读的,帮模型理解这不是用户的普通正文
additional_kwargs:
hide_from_ui / dynamic_context_reminder 等标记
给前端和 middleware 读的,用来隐藏、识别、去重
所以”模型可见”和”前端可见”不是一回事。DynamicContext 的 reminder 模型能看到,但通过 metadata 告诉前端不要把它当成用户消息展示。
Todo 和 LoopDetection 在 before_agent 里只负责”清场”。先补一个容易忽略的前提:middleware 实例是长期存活、被同一进程里多个 run 复用的,不会每个 run 新建一份。所以它们可能保留上一轮 run 的临时簿记——Todo 那边攒着一份”别让模型在任务没做完时就收尾”的提醒,LoopDetection 那边攒着一份重复调用的计数。这些都不该进入新的 run,于是两者在开场各自清零:Todo 清掉遗留的提醒,LoopDetection 重置循环检测状态。
这两套机制留到后面(before_model、wrap_model_call)再展开。这里它们做的只是每个 run 开场的清理;少了这一步,run 之间就会互相污染。
before_model:每轮模型调用之前
before_model 每次 model call 前都会跑。它适合做两件事:
1. 控制上下文预算
2. 把模型本轮应该看到的内容补进 messages
这一层的完整清单:
Summarization
Todo(before_model reminder, plan mode only)
ViewImage
Summarization 来自 summarization_middleware.py 。历史太长的时候,它会整段替换 messages,而不是简单截断:
{
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages, # 一条 name="summary" 的摘要
*preserved_messages, # 保留的近段
]
}
RemoveMessage(REMOVE_ALL_MESSAGES) 是”把现有消息全部清掉”,后面紧跟着新生成的摘要和保留的原文——所以这是一次整体替换,不是在末尾追加。
摘要本身是 HumanMessage(name="summary")。用 HumanMessage 是为了让模型把它当背景来读;name="summary" 是让 runtime 和 middleware 知道它不是用户的真实输入——这样后面的 middleware 才不会把摘要当成用户 prompt 去解析 slash command 或者注入上下文。
DeerFlow 在此基础上做了一个结构化的保留策略:如果待压缩区里有最近读取 /mnt/skills 的 tool result,会把相关的 AIMessage/ToolMessage bundle 从待压缩区救回 preserved 区。原因是 skill 内容通常是精确的执行指令,压成散文会丢掉路径、约束和格式。
但这种救回不是无限的,它有预算:
preserve_recent_skill_count:
最多救回几个最近的 skill bundle
preserve_recent_skill_tokens:
救回内容的总 token budget
preserve_recent_skill_tokens_per_skill:
单个 skill 的 token cap
这就避免了大量旧 skill 内容被一次性全部救回、把 summarization 的预算击穿。背后的设计判断是:「最近读进来的 skill 文件,多半还是当前要照着做的指令」。因此它只把待压缩区里最近、且在预算内的几次 /mnt/skills 读取原样留住,其余照常压缩。
Todo 在 before_model 里负责补另一类上下文:如果 todo state 还在,但当初的 write_todos 调用已经被 summarization 或窗口裁剪移出了当前消息,它会注入一条隐藏 reminder,提醒模型当前还有 active todo list。
这里的本质是:state["todos"] 还在,但模型能看见的消息历史里已经找不到 todo 是怎么来的了。runtime 知道任务没做完,但模型可能认为可以收尾了。TodoMiddleware 做的事,就是把”运行时还记得、但消息窗口里已经丢失”的任务状态,重新变成模型能看到的 reminder。
它和 DynamicContext 的区别:
DynamicContext:
补当前日期 / 记忆这类外部上下文
Todo before_model:
补 graph state 里还活着、但消息窗口里已经丢失的任务上下文
ViewImage 来自 view_image_middleware.py ,只有模型支持 vision 时才会装配。它把模型已经通过 view_image 工具读过的图片,转成 provider-compatible 的 multimodal message,让后续 model call 接收到图片内容,而不是只看到一条”工具执行成功”。
这里要和工具本身分开理解:
view_image tool:
读取图片,把图片信息写进 state["viewed_images"]
返回 ToolMessage("Successfully read image")
ViewImageMiddleware:
在 before_model 阶段读取 state["viewed_images"]
构造 provider-compatible multimodal HumanMessage
也就是说,工具执行完成只代表 runtime 拿到了图片数据;模型下一轮能不能”看见”这些图片,还要靠 middleware 把图片注入 model request。
wrap_model_call:包住模型调用
wrap_model_call 是最接近 model provider 的向内层。它拿到一个 handler,handler(request) 表示继续往内走、最终调用真实模型。在调用 handler 之前可以改 request;handler 返回之后也可以处理 response;如果 handler 抛异常,则进入异常路径。
这一层涉及的 middleware :
ToolOutputBudget(model side)
DanglingToolCall
LLMErrorHandling
LoopDetection(wrap_model_call soft warning)
SkillActivation
Todo(wrap_model_call completion reminder)
DeferredToolFilter(model side)
ToolOutputBudget 在 model 侧做兜底修剪。它会检查历史里的超大 ToolMessage,把内容换成 head/tail preview 和外置路径,避免下一次 model call 被巨大工具结果拖垮。它不是摘要器——完整输出仍然尽量保存在文件里。
这一点很容易和 Summarization 混淆:
Summarization:
conversation history boundary
用摘要替换旧对话
ToolOutputBudget:
single tool result boundary
尽量外置完整原始输出,只把预览留在 ToolMessage
head/tail preview 也不是语义摘要,它只是导航信号:
head:
帮模型判断这是什么输出、结构长什么样
tail:
帮模型看到结尾状态、错误、总结行
path:
如果确实需要完整内容,再用 read_file 读取
所以它保护的是 model context budget,而不是替模型理解文件内容。
DanglingToolCall 来自 dangling_tool_call_middleware.py ,负责修补 provider 工具调用协议。它防的是这种历史:
AIMessage(tool_calls=[call_1])
...缺少 ToolMessage(tool_call_id=call_1)
很多 provider 要求 AIMessage.tool_calls[*].id 后面必须有对应的 ToolMessage.tool_call_id。DanglingToolCall 的算法是”先索引,再重建”:
tool_messages_by_id:
dict[tool_call_id, deque[ToolMessage]]
tool_call_ids:
所有 AIMessage 声明过的 tool_call id
patched:
重新构造后的本次 request.messages
第一步把已有的 ToolMessage 按自己的 tool_call_id 分组:
tool_messages_by_id[msg.tool_call_id].append(msg)
第二步遍历到 AIMessage 时,按它的 tool_calls 顺序查这个索引。查到了真实的 ToolMessage 就搬到 AIMessage 后面;查不到就补一条 synthetic ToolMessage(status="error")。所以最终顺序由 AIMessage.tool_calls[].id == ToolMessage.tool_call_id 这个协议关系重建,不依赖原消息里 ToolMessage 的物理位置。
还有一个细节值得留意:_message_tool_calls() 在收集 tool_call id 时,会同时检查三个来源——msg.tool_calls、msg.additional_kwargs["tool_calls"]、msg.invalid_tool_calls。这是因为不同 provider 的 adapter 可能把 tool-call 信息放在不同的字段里,有些甚至把解析失败的调用标成 invalid_tool_calls。DanglingToolCall 的原则是宁可多修也不少修——只要某个 AIMessage 声称自己发出了工具调用,不管存在哪个字段,都得保证后面有对应的 ToolMessage。
注意它修的是本次 ModelRequest,不是直接持久改 graph state:
request.override(messages=patched)
这就是它选择 wrap_model_call 而不是 before_model 的原因:必须把ToolMessage插到对应的 AIMessage 后面,不能简单 append 到末尾。
LLMErrorHandling 来自 llm_error_handling_middleware.py 。它守的是 model provider 的边界:超时、连接失败、5xx、429、quota/auth 等异常都在这里分类处理。可重试的 transient/busy 会退避重试;不可重试或重试耗尽,会返回 fallback AIMessage。
它处理的是 handler(request) 抛出的异常——也就是”没拿到正常 AIMessage”的情况。它不管 provider 正常返回但返回内容不可信的情况。这个边界很重要:下一站的 SafetyFinishReasonMiddleware 就是处理”provider 正常返回 AIMessage,但 finish_reason 表示安全截断,tool_calls 不可信”的场景。
它还有一个熔断器(circuit breaker):
closed 电路连通,模型调用正常通过
open 电路断开,快速失败走 fallback
half_open 恢复探测,只放一个调用测试 provider 是否恢复
注意 closed 在这里是健康态,不是”关闭请求”——这是 circuit breaker 的通用术语,并非 DeerFlow 刻意反着定义。另一个关键点:GraphBubbleUp 必须原样抛出,因为它代表 LangGraph 的中断/暂停/恢复控制流,不是普通异常,不能当成 provider 错误吞掉。
LoopDetection 管两挡刹车:hard——重复到达上限时直接改写模型刚生成的 AIMessage、清空 tool_calls,强制停下——发生在 after_model,留到下一站讲;这里 wrap_model_call 只管soft。soft是:上一轮 after_model 发现有打转的苗头、但还没到 hard stop 那条线,于是先排一条提醒;等这一轮工具结果都回来之后,再把它作为一条新的 HumanMessage 追加到 model request 的末尾。之所以不在 after_model 当场插,是因为那会夹在 AIMessage(tool_calls) 和它配对的 ToolMessage 中间,破坏配对协议——所以soft warning必须等工具结果回齐了才注入。
SkillActivation 来自 skill_activation_middleware.py 。用户以 /skill-name 开头时,它会读取完整 SKILL.md,构造一条隐藏 HumanMessage 注入本次 request。注意它不写回 graph state,只改这一次发给模型的 request——也就是说它是 turn-local(只作用于当前这一轮)的 request overlay(只覆盖本次请求,下一轮就没了)。这样 skill 只在被显式激活时才进入上下文,不会每轮污染context。
它和 DynamicContext 的区别:
DynamicContext:
before_agent
写 graph state messages
conversation-level context
SkillActivation:
wrap_model_call
只 override 当前 ModelRequest.messages
turn-local context
SkillActivation 还会把 SKILL.md 包进 XML-like envelope:
<slash_skill_activation>
<user_request>...</user_request>
<skill ...>
<skill_content encoding="xml-escaped">...</skill_content>
</skill>
</slash_skill_activation>
这里的 xml-escaped 指的是把 skill 内容里的 <、>、& 等字符转义成普通文本(实现上用的是 Python 的 html.escape()),避免 skill 文档里的标签样式内容破坏外层 prompt envelope。
如果 skill 不存在、被禁用、或者当前 agent 没权限使用,middleware 会直接返回自己构造的 AIMessage,不浪费一次模型请求。
Todo 在 wrap_model_call 里负责消费 after_model 阶段排队的 completion reminder。如果模型试图在 todo 未完成时收尾,Todo 的 after_model 会先 jump 回 model;下一次 wrap_model_call 再把隐藏 reminder 注入本轮 request。
DeferredToolFilter 来自 deferred_tool_filter_middleware.py 。先说清两个词:deferred tool(延迟工具)是一开始不暴露给模型的工具,典型就是数量庞大的 MCP 工具——全塞进去会撑爆上下文、也干扰模型选工具;模型要用,得先调 tool_search 把需要的那几个 promote(提升) 出来。这个 middleware 的职责,就是确保没被 promote 的工具既看不见、也调不动。它的 enforcement 是双层的,缺一不可:
wrap_model_call(model 侧):
从 request.tools 里过滤掉 hidden schemas
模型看不到这些工具,自然不会主动调用
wrap_tool_call(tool 侧):
如果模型还是设法调用了 hidden tool
(比如从历史记忆或 prompt 泄漏中猜到了工具名)
直接返回 ToolMessage(status="error"),不执行
只做 model 侧过滤是不够的——模型仍可能从历史、prompt 提示或者 provider 的特殊行为中猜到未 promoted 工具的名字。两层都守住才算 enforcement。
向内分层的好处
- 横切准备逻辑不进 graph,主循环保持简单
- lead 和 subagent 能复用 runtime 基座
- 动态内容走 message stream,system prompt 保持稳定,prefix cache 友好
代价
- 顺序是隐式约定,主要靠注释和测试守住
- 链按 run 条件变化,排查问题前要先还原本次装配结果
- 同步 hook 如果做阻塞 IO,会卡住共享事件循环
易误解的要点
before_agent不是每轮都跑——整个 run 只跑一次。 每轮都得做的事(摘要、todo 提醒、图片注入)要挂before_model;放错 hook,逻辑要么只生效一次、要么白跑很多遍。- 「注册了 middleware」≠「资源已经建好」。 默认 lazy:
ThreadData只算路径不建目录,Sandbox在before_agent根本不 acquire——资源要到第一次真用到时才出现。 - 不要假设这条 run 一定挂了某个 middleware。 链是按条件拼的(plan mode / vision / tool_search / subagent / guardrail 都会改列表)。排查异常行为前,先确认它到底在不在这次的列表里。
- 工具里
runtime.state[...] = x,不等于把状态持久写回。 那只是本次调用的局部修改,函数一返回就没了——只有从 hook 返回{...}或Command(update=...),reducer 才会合并。(Sandbox 第一次 acquire 后靠wrap_tool_call兜底提交,就是这个原因;细节见前文。) - middleware 实例会跨 run 复用,状态又留在进程内存里。 这一条性质可能造成的影响:① 同一进程内、跨 run——实例上的状态不会自动清空,所以凡是往实例记 per-run 状态的 middleware,都需要在
before_agent/after_agent清理;漏写这步清理,上个 run 的残留就会串进下一个 run(Todo、LoopDetection 正是为此在开场和收尾各清一次)。② 跨进程——这些内存状态不共享,多 worker 部署(Gateway 起成多进程 / 多副本扩容)时,同一 thread 的两次 run 若落到不同 worker,循环检测、熔断、memory 去抖这些 best-effort 功能就会分别计算,效果下降。这是「状态放内存」的已知局限;不过对话本身(messages、todos、sandbox_id等)由 checkpointer / store 兜底、跨 worker 一致,不会因此错乱。要彻底消除,需要给负载均衡配 thread 亲和路由,或把这类状态挪到共享存储(如 Redis)。
这一站只走到模型调用。请求已经经过 before_agent、before_model、wrap_model_call,模型拿到了整理好的 request。下一站走另一半:模型返回之后,after_model 按逆序处理结果,工具执行前后经过 wrap_tool_call,run 结束时进入 after_agent。安全策略、循环检测、工具边界和副作用——最密集的逻辑发生在那一半。