跳到主要内容

控制面:hooks、middleware、干预与人类介入

30 秒导读: README 承诺你能「Stay in control / 加 Guardrails / 随时 Steering」。这一章讲清这些承诺在代码里怎么落地——四套彼此叠放的机制,让你在 agent 跑起来之后,依然能观察它、拦住它、改它、甚至把它按下暂停等人拍板。这正是 Strands 作为一个 harness(可管控运行时)、而不是一个"薄薄的模型调用包装"的分水岭。

本章上游是主线:递归事件循环——那里讲了循环怎么转;这里讲的是怎么在循环转的时候插手。两章互为表里:控制面的每个挂点,都对应循环里某个真实的消费点。


1. 这是什么(零基础也能懂)

先说一个直觉。

一个 agent 循环 = 「模型说要做什么 → 真去做 → 把结果喂回模型 → 再问模型」。控制面就是在这条流水线的每个关节上,预留的一排「插座」:你可以往插座里插一个小函数,在关节发生前 / 发生后被叫到,去看一眼、改一改、或者干脆喊停。

它解决谁的什么问题? 举几个真实场景:

  • 你想在每次调用 delete_file 工具前弹一句"确定吗?"等人点头——这是人类介入(human-in-the-loop)
  • 你想让某个用户永远不能调用某些工具——这是授权 / guardrail
  • 模型跑偏了,你想在它下一轮之前塞一句"你偏题了,回到 X"——这是steering(纠偏)
  • 你想给每一次模型调用记账、打日志、算 token、注入记忆——这是可观测性 / 上下文注入

这些需求形状各异,但共同的本质只有一个:在 agent 自主运转的过程中,让外部代码有机会介入。Strands 没有把它们糊成一坨 if/else,而是分成了四种插座,各管一档。

一句话类比: 把 agent 循环想成一条工厂流水线,控制面就是流水线上的质检站、闸门和急停按钮——产品(工具调用 / 模型调用)经过时,你能检查、能拦下返工、能一键停线等人来看。


2. 顶层全景(四种插座,各管一档)

Strands 的控制面由四个子系统组成,从「最底层的通用机制」到「最上层的语义封装」层层叠放:

子系统白话职责是什么层级代码位置
Hook 系统最底层的通用事件总线:在生命周期各点广播强类型事件,谁想听谁注册回调机制底座strands-py/src/strands/hooks/
中间件管线专门把「一次模型调用」包成可插拔的洋葱圈,能改输入、改输出、甚至短路缓存单点的深度插座strands-py/src/strands/_middleware/
干预处理器在 hook 之上的语义糖:你只管返回 Deny / Guide / Confirm / Transform,由它翻译成底层操作高层策略封装strands-py/src/strands/interventions/
人类介入中断让 agent 能在工具调用处真正挂起、把控制权交还给人、等回复后恢复跨越单次调用的暂停机制strands-py/src/strands/interrupt.py

它们不是四个独立王国,而是下层支撑上层:干预处理器其实是注册到 hook 系统上的一批回调;人类介入中断则是 hook 回调里抛出的一种特殊异常。理解顺序应当是从下往上——先懂 hook,后面三个都好懂。

这张图怎么读

从左到右是 agent 的一次「回合」;竖线是四种插座插进流水线的位置。Before 类在动作发生前触发(能拦、能改输入),After 类在动作完成后触发(能改结果、能要求重试)。

一次 agent 回合(cycle)
┌──────────────────────────────────────────────────────────────┐
│ │
│ [模型调用] [工具执行] │
│ │
│ BeforeModelCall ──► 中间件洋葱 ──► AfterModelCall │
│ │ (Input/Wrap/ │ │
│ │ Output 三相) │ │
│ ▼ ▼ │
│ 拦/改 prompt 改结果 / 要求重试 │
│ │
│ …模型说"调用工具 X"… │
│ │
│ BeforeToolCall ──► 执行 ──► AfterToolCall │
│ │ │ │
│ ▼ ▼ │
│ 拦/换工具/ 改结果/重试 │
│ 确认/抛中断 │
│ │ │
│ └──抛 InterruptException──► 挂起整个循环 │
│ 等人回复→恢复 │
└──────────────────────────────────────────────────────────────┘

贯穿始终:MessageAddedEvent(每加一条消息就广播)
BeforeInvocation / AfterInvocation(整次请求的头尾)
取消信号 _cancel_signal / Limits(在回合边界检查)

下面逐层拆开讲。


3. Hook 系统:强类型的事件总线(底座)

3.1 它要解决的小问题

「让外部代码在生命周期各点被回调」——最土的做法是留一个 callback_handler 让你塞一个函数。但那样一个点只能挂一个人,而且传给你的是一坨 **kwargs,你得自己猜里面有啥。

Strands 换成了强类型事件 + 多订阅者:每个生命周期节点是一个事件类(如 BeforeToolCallEvent),字段清清楚楚;你按事件类型注册回调,同一个事件可以有任意多个订阅者。strands-py/src/strands/hooks/__init__.py:27 的模块注释直说这是"replaces the older callback_handler approach"。

3.2 三个核心角色

Hook 系统就三个概念:

  • 事件(Event) —— 一个 @dataclass,携带该节点的上下文。基类 BaseHookEventstrands-py/src/strands/hooks/registry.py:49
  • 回调(HookCallback) —— 一个收单个事件参数的函数,可同步可 async。协议定义在 registry.py:141
  • 注册表(HookRegistry) —— 事件类型 → 回调列表的映射,负责分发。定义在 registry.py:169

再加一个便利角色 HookProvider(registry.py:113):一个带 register_hooks(registry) 方法的对象,用来批量把一组相关回调注册进去——SDK 内部的会话管理器、对话管理器、重试策略,都是以 HookProvider 身份挂进来的(见 strands-py/src/strands/agent/agent.py:449-455)。

3.3 有哪些挂点(事件目录)

单 agent 生命周期的核心事件如下(都在 strands-py/src/strands/hooks/events.py):

事件触发时机可写字段(能改什么)定义
AgentInitializedEventagent 构造完成后events.py:27
BeforeInvocationEvent每次请求开始(__call__/stream_async/structured_output)messagescancelevents.py:39
AfterInvocationEvent每次请求结束(逆序回调)resumeevents.py:70
MessageAddedEvent框架每往历史加一条消息events.py:118
BeforeToolCallEvent每个工具执行前selected_tooltool_usecancel_toolevents.py:137
AfterToolCallEvent每个工具执行后(逆序回调)resultretryevents.py:177
BeforeModelCallEvent每次模型调用前cancelevents.py:229
AfterModelCallEvent每次模型调用后(逆序回调)retryevents.py:259

注:还有一组多 agent 编排事件(BeforeNodeCallEvent 等,events.py:335 起),那属于越过单 agent的话题,这里不展开。

3.4 精华一:事件是「只读默认 + 白名单可写」

这是很妙的一个设计。一个 hook 回调能改什么、不能改什么,不是靠文档口头约定,而是在类型层面被强制的。

BaseHookEvent 重写了 __setattr__(registry.py:79):初始化完成后,任何赋值默认都AttributeError;只有子类在 _can_write(name) 里显式列白名单的字段才放行。

# 真实源码 registry.py:79 —— 事件默认不可写
def __setattr__(self, name, value):
# 初始化期间放行;或子类显式声明该字段可写才放行
if not hasattr(self, "_disallow_writes") or self._can_write(name):
return super().__setattr__(name, value)
raise AttributeError(f"Property {name} is not writable")

于是 BeforeToolCallEvent._can_write(events.py:160)只允许改 cancel_tool / selected_tool / tool_use——你想在 before-tool 回调里改结果?门都没有,那是 after 的事。能改什么由挂点语义决定,越权直接报错。

3.5 精华二:执行顺序 = 优先级 + 注册序 + 可逆

多个回调听同一个事件,谁先谁后?HookOrder(registry.py:27)给了几个命名档位:

# registry.py:27 —— 数字越小越先执行
SDK_FIRST = -100
INTERVENTION_OUTPUT = -90
DEFAULT = 0
INTERVENTION_INPUT = 90
SDK_LAST = 100

注册时用 bisect.insortorder 有序插入(registry.py:258),同档位保持注册顺序。分发时(get_callbacks_for,registry.py:408)按序取出。

还有一个巧思:should_reverse_callbacks(registry.py:52)。像 AfterToolCallEventAfterInvocationEvent 这类收尾事件返回 True——同优先级内逆序回调(events.py:223 / events.py:112)。这符合直觉:setup 是 A→B→C,teardown 就该 C→B→A,像栈一样先进后出。

3.6 事件怎么被真正广播出来

分发入口是 invoke_callbacks_async(registry.py:301)。它遍历该事件的回调,同步就直接调、asyncawait。以工具执行为例,strands-py/src/strands/tools/executors/_executor.py:59-64 构造 BeforeToolCallEventawait agent.hooks.invoke_callbacks_async(event);模型侧则在 strands-py/src/strands/event_loop/event_loop.py:590 广播 AfterModelCallEvent

invoke_callbacks_async 的返回值是个 tuple:(event, list[Interrupt])——它顺手把回调抛出的中断收集起来了。这一句为下面第 6 节的人类介入埋了线,先记住。


4. 中间件管线:把「模型调用」包成洋葱(单点深度插座)

4.1 为什么模型调用需要单独一套机制

Hook 的 before/after 是两个离散的点——你能在调用前看一眼、调用后看一眼,但没法把这一次调用整个"包裹"起来:比如你想"如果缓存命中就根本别调模型"、或"给整个调用套一层计时/重试/限流"。这种"环绕(around)"语义,离散的 before/after 表达不了。

中间件就是干这个的。它把一次模型调用做成一圈圈可叠放的洋葱,每一圈都能决定"要不要往里走、往里传什么、拿到什么再往外传"。目前只实现了 InvokeModelStage 这一个阶段(strands-py/src/strands/_middleware/README.md 明说 tool/stream 阶段"will be added as needed")。

重要:_middleware/内部包(带下划线),不是公开 API。用户通过 agent._middleware_registry.add_middleware(...) 接触它。SDK 自带的记忆注入(memory/memory_manager.py:639)和 agentic 上下文的 token 计量(agent/agent.py:407)就是这么挂进去的。

4.2 三个相位:Input / Wrap / Output

一个中间件"阶段"(MiddlewareStage,_middleware/types.py:70)暴露三个子相位,分别对应三种插法:

相位你拿到什么 / 返回什么用来干嘛
Input拿到 context,返回改过的 context调用前改输入(注入 system prompt、塞 token 预算)
Wrap拿到 (context, next),自己是个 async 生成器完全环绕:计时、短路缓存、异常兜底
Output拿到 MiddlewareResult 包裹的结果,返回改过的调用后改结果事件

InvokeModelStage 的上下文是 InvokeModelContext(_middleware/stages.py:17),携带 messages / system_prompt / tool_specs / tool_choice / invocation_state

4.3 精华:Python 没有生成器返回值,于是「最后一个 yield 就是结果」

这是 Strands 从 TypeScript SDK 移植过来时,一个非常聪明的对齐。

TS 用 async generatorreturn 值 + yield* 传递结果。但 Python 的 async 生成器不能 return。怎么办?README.md 给出的答案是:流里最后一个 yield 出来的事件,本身就是结果——这刚好契合 Python SDK 既有的约定(ModelStopReason 本就是 stream_messages() 流的最后一个事件)。中间件链是透明的,事件(包括那个"结果事件")自然流穿过去,没有额外的哨兵类型。

于是"透传"和"短路缓存"分别长这样:

# 示意,非源码(取自 _middleware/README.md 的模式)
async def passthrough(context, next_fn): # 透传:啥也不改,往下走
async for event in next_fn(context):
yield event

async def cached(context, next_fn): # 短路:直接吐结果,根本不调 next_fn → 不碰模型
yield ModelStopReason(stop_reason="end_turn", message=cached_msg, ...)

Output 相位要改结果,又不能污染中间流过的普通事件,于是引入了一个薄包装 MiddlewareResult(_middleware/types.py:16):注册表在调用 Output 处理器前把"最后那个结果事件"包进 MiddlewareResult,处理器改完再拆包塞回流里。真实的拆/包逻辑在 _add_output(_middleware/registry.py:74-100)。

4.4 链条怎么组装与运行

MiddlewareRegistry.compose(_middleware/registry.py:102)把某阶段所有处理器从里到外包成一个大函数,invoke(_middleware/registry.py:142)则组装并驱动它。两个工程细节值得记:

  • 零开销快路径:没注册任何中间件时,compose 直接返回 terminal(registry.py:108-109),不套任何一层。
  • 生成器一定被清理:composetry/finally + 显式 aclose()(registry.py:128-134)确保内层生成器都被关闭,防止资源泄漏。

事件循环端的消费点在 strands-py/src/strands/event_loop/event_loop.py:551:构造 InvokeModelContext(用深拷贝做防御,见 4.5),然后 async for event in agent._middleware_registry.invoke(InvokeModelStage, ctx, terminal),最后一个事件即 ModelStopReason

4.5 精华:防御性拷贝,把「能碰的」和「不能碰的」分清

InvokeModelContext 的注释(_middleware/stages.py:20)点破了一条纪律:messages / system_prompt / tool_specs / tool_choice 都是深拷贝——中间件改坏了也波及不到 agent 真实状态;而 invocation_state按引用共享的(hook 和工具要往里写流式状态)。更狠的是 model_state 根本不放进 context(event_loop.py:542-546):在链外做快照,链跑完成功了才写回,中间件碰都碰不到模型状态。哪些能改、哪些是只读、哪些干脆不给看——分得明明白白。


5. 干预处理器:写策略的人只管「说要干嘛」(高层语义封装)

5.1 它要解决的小问题

Hook 很强,但太底层。你想写一条"未授权就拦掉这个工具"的策略,用裸 hook 得自己去改 event.cancel_tool = "..."、自己记得短路、自己拼消息字符串。写授权、写 guardrail 的人,不该关心这些管道。

干预处理器(intervention)就是架在 hook 之上的语义层:你继承 InterventionHandler,重写你关心的生命周期方法,返回一个语义化的动作——Proceed(放行)、Deny(拦)、Guide(纠偏)、Confirm(要人确认)、Transform(改内容)。翻译成底层 hook 操作的脏活,由 InterventionRegistry 全包了。__init__.py:2 直接把它定位成"authorization、steering、guardrails"的一等原语。

5.2 五种动作,一张兼容矩阵

动作都是冻结的 @dataclass(strands-py/src/strands/interventions/actions.py):

动作语义定义
Proceed放行,啥也不改actions.py:44
Deny拦掉,reason 作为取消消息喂给模型actions.py:56
Guide给反馈纠偏(具体行为随挂点而变)actions.py:64
Confirm要人确认, before_tool_call 支持actions.py:88
Transform就地改事件内容,apply(event) 变异actions.py:104

关键在于:同一个动作,在不同挂点含义不同actions.py:120InterventionAction 文档里嵌了一张兼容矩阵。挑重点看 Guide:在 before_tool_call 上它设 cancel_tool 让模型看到反馈;在 before_model_call 上它把反馈注入成一条 user 消息;在 after_model_call 上它丢弃这次回复并要求模型带着反馈重试。这三种落法分别对应 registry.py:141 / registry.py:166 / registry.py:178 三段代码。

5.3 精华:只为「被重写的方法」注册 hook

InterventionHandler(handler.py:43)的所有生命周期方法都有默认实现——直接返回 Proceed()。你只重写你在乎的那个。

那注册表怎么知道你重写了哪些?靠 _is_overridden(registry.py:58):拿子类的方法和基类的方法比是不是同一个对象

# 真实源码 registry.py:58
def _is_overridden(self, handler, method):
handler_method = getattr(type(handler), method, None)
base_method = getattr(InterventionHandler, method, None)
return handler_method is not base_method # 不是基类那个 → 你重写了

然后 _register_hooks(registry.py:64)按需注册:只有至少一个 handler 重写了 before_tool_call,才给 BeforeToolCallEvent 挂回调。没人用的挂点一个回调都不挂——零成本抽象。注意注册用的 order 正是第 3.5 节那两个档位:输入类用 INTERVENTION_INPUT,输出类用 INTERVENTION_OUTPUT(registry.py:69 / registry.py:81)。

5.4 精华:多 handler 的仲裁——Deny 短路、Guide 累积

多个 handler 对同一挂点发话,谁说了算?_dispatch(registry.py:189)定了仲裁规则:

  • 按注册顺序逐个 handler 求值(支持 async,registry.py:210inspect.isawaitable 分支)。
  • 碰到 Deny(以及被拒的 Confirm),apply 返回 True立即短路,后面的 handler 不再问(registry.py:225-227)。第一个"不"就否决全场。
  • Guide 不短路,是累积的(registry.py:221-222):所有 handler 的纠偏反馈攒到最后,拼成一条 [handlerA] ...\n[handlerB] ... 一起注入(registry.py:236-239)。多个纠偏声音都被听见。

5.5 精华:on_error 的三种失败姿态

策略检查器自己抛异常了怎么办?这在安全场景是要命的问题。OnError(handler.py:32)给了三档,由 _handle_error(registry.py:241)执行:

on_error行为姿态
"throw"(默认)重新抛出,坏掉的策略阻断执行最安全
"deny"记日志,当作 Denyfail-closed(失败即拒)
"proceed"记日志,当作 Proceed 继续fail-open(失败即放行)

handler.py:39 的注释对 "proceed" 特意加了警告:这是 fail-open,一个坏掉的 handler 会悄悄停止执行它的策略,只在"可用性比强制执行更重要"时才用。默认给 throw 而不是 proceed,是一个"安全优先"的正确默认值。


6. 人类介入中断:把 agent 真正按下暂停(跨调用的暂停机制)

6.1 它要解决的小问题

前面几种机制,都在一次进程内、一个 await做决定——哪怕是 Confirm,也得当场有个答案。但真正的 human-in-the-loop 常常是异步的:agent 要删库,得停下来,把请求返回给调用方,可能几分钟、几小时后人才回一句"批准",然后 agent 从原地继续。

这要求的不是"回调里 sleep",而是把整个 agent 循环挂起、序列化、之后恢复。这就是 strands-py/src/strands/interrupt.py 干的事。

6.2 从使用者视角看一遍

strands-py/src/strands/types/interrupt.py:18 的文档给了完整示例,提炼其骨架:

# 示意(浓缩自 types/interrupt.py 的 docstring)
class ToolInterruptHook(HookProvider):
def register_hooks(self, registry, **kwargs):
registry.add_callback(BeforeToolCallEvent, self.approve)

def approve(self, event: BeforeToolCallEvent):
if event.tool_use["name"] != "delete_tool":
return
# 第一次调用 interrupt():没有回复 → 抛异常,挂起 agent
# 恢复后再次调用:返回人给的回复
approval = event.interrupt("for_delete_tool", reason="APPROVAL")
if approval != "A":
event.cancel_tool = "approval was not granted"

result = agent("delete object with key 'X'")
if result.stop_reason == "interrupt": # agent 停在这
responses = [{"interruptResponse": {"interruptId": it.id, "response": "A"}}
for it in result.interrupts]
result = agent(responses) # 带回复重新 invoke → 从原地恢复

重点看那个「第一次抛、第二次返回」的双相行为——它是整个机制的心脏。

6.3 机制全貌:一次挂起-恢复的完整链路

四个数据结构撑起这套机制:

结构职责定义
Interrupt一个中断请求(id / name / reason / response)interrupt.py:11
InterruptException携带 Interrupt 的异常,用来"跳出"interrupt.py:32
_InterruptState挂在 agent 上的挂起状态机interrupt.py:40
_Interruptible给事件加 interrupt() 方法的混入协议types/interrupt.py:79

_Interruptible.interrupt() 的双相逻辑(types/interrupt.py:82-111)是关键。它去 agent 的 _interrupt_state.interrupts 里按 id 找这个中断:

# 真实源码 types/interrupt.py:107
interrupt_ = state.interrupts.setdefault(id, Interrupt(id, name, reason, response))
if interrupt_.response is not None: # 已经有人回复了 → 直接把回复返回
return interrupt_.response
raise InterruptException(interrupt_) # 还没回复 → 抛异常,往上冒

同一个 name,BeforeToolCallEvent._interrupt_id(events.py:164)会用 tool_use['toolUseId'] 拼出稳定的 id——所以恢复后再问同一个中断,能对上号

异常怎么变成"挂起"而不是"崩溃"? 回到第 3.6 节:invoke_callbacks_async(registry.py:301)专门 catch InterruptException(registry.py:335),把它收进一个 interrupts 字典(每个回调只准抛一个,重名报错),作为返回值的一部分吐出去——中断被"驯化"成了数据,而非崩溃

数据一路往上传到循环层。 工具执行器拿到非空 interrupts 就 yield 一个 ToolInterruptEvent 并 return(tools/executors/_executor.py:157-159);事件循环把这些中断收集起来(event_loop.py:792-793),然后做关键三步(event_loop.py:805-818):

# 真实源码 event_loop.py:805
if interrupts:
agent._interrupt_state.context = {"tool_use_message": message, "tool_results": tool_results}
agent._interrupt_state.activate() # ① 把状态机点亮
yield EventLoopStopEvent("interrupt", message, ..., interrupts) # ② stop_reason="interrupt" 停循环

activate()(interrupt.py:57)把 activated=True。这个标志位就是挂起-恢复的总开关:

  • 挂起态下会话可被持久化:_InterruptState.to_dict/from_dict(interrupt.py:120-140)可序列化,types/session.py 会把它存进会话——这就是"等几小时"也不丢状态的底气(详见上下文与持久化)。
  • 恢复时跳过已做的事:用户带回复重新 invoke,_run_loop_interrupt_state.resume(prompt)(agent/agent.py:1158)。resume(interrupt.py:72)校验回复格式,把每个回复按 id 填回对应 Interrupt.response
  • 恢复后循环的"重入"处理:事件循环开头见 activated跳过模型调用(event_loop.py:283,因为消息里已有 tool_use),并把上次存的 tool_results 接回来、只重跑当时被中断的那个工具(event_loop.py:739-744)。于是第二次 interrupt() 命中 response is not None 分支,直接返回"A",工具正常往下走。
  • 恢复完清场:一轮走完 deactivate()(interrupt.py:62)清空 interrupts 和 context。

为什么第 3 节说 invoke_callbacks_async 返回 tuple 是"埋线" —— 现在看明白了:那第二个返回值 list[Interrupt],就是这整条挂起链路的起点。

6.4 边界:中断只支持工具调用处

event_loop.py:282 的注释直说:"interrupts are currently only supported for tool calls"。模型调用中途不能挂起(想想也对,一次流式生成没法从中间恢复)。此外直接工具调用(tool_caller)里若撞上中断态会直接 RuntimeError(tools/_caller.py:84_caller.py:121)——挂起中的 agent 不允许被旁路直调工具,以免状态错乱。


7. 取消与 Limits:控制面里的"急停"与"预算闸"

还有两个轻量但属于控制面的机制,顺带交代。

协作式取消(cooperative cancellation)。 agent 上挂着一个 threading.Event_cancel_signal(agent/agent.py:345)。外部调 agent.cancel()(agent/agent.py:582)只是 self._cancel_signal.set()(agent/agent.py:611),幂等、线程安全。真正的停,发生在循环的检查点:工具执行前会看 _cancel_signal.is_set()(event_loop.py:750),命中就给每个待执行工具补一个"cancelled"结果(维持消息状态合法)、然后以 stop_reason="cancelled" 收尾。SDK 不接受外部 cancel token,而是这套内部信号 + 边界检查——这是"协作式"的含义:不粗暴打断,在安全的关节停。

Limits:每次调用的预算闸。 Limits(strands-py/src/strands/types/agent.py:17)是个 TypedDict,给单次 invoke 设三种上限:turns(循环轮数)、output_tokenstotal_tokens。它们是每次调用独立在每轮循环顶部检查的软上限(types/agent.py:24 注释:同时触发时优先级 turns > total_tokens > output_tokens,对应 stop_reason 分别是 limit_turns 等)。它在控制面的位置:和取消信号一样,是循环边界上的闸门,只不过一个由人手动扳、一个由预算自动扳。


8. 巧妙之处(可带走的技术)

  • 事件用 __setattr__ 做"白名单可写"(registry.py:79 + 各 _can_write):把"这个挂点能改什么"从口头约定变成类型层强制,越权即 AttributeError。可迁移到任何"回调能改一部分上下文"的场景。
  • should_reverse_callbacks 让 after 类天然逆序(registry.py:52):setup/teardown 用同一套注册、自动镜像执行顺序,像栈。
  • 中间件"最后一个 yield 即结果"(_middleware/README.md):绕开 Python async 生成器不能 return 值的限制,同时复用了 SDK"流的末事件即结果"的既有约定,零新概念。
  • _is_overridden 按需注册(registry.py:58):没人用的挂点一个回调都不挂,零成本抽象;比"注册一堆空回调再判断"干净得多。
  • on_error 显式区分 fail-open/fail-closed(handler.py:32):把安全领域的关键取舍做成一个字段,并给了"throw"这个安全默认——而不是让实现者不小心 fail-open。
  • 中断把异常"驯化"成数据(registry.py:335):InterruptException 一路被捕获、聚合、序列化,让"崩溃式跳出"变成"可持久化、可恢复的挂起",这是 human-in-the-loop 能跨越小时级等待的根本。

9. 边界与局限(诚实说)

  • 中断只在工具调用处(event_loop.py:282):模型生成中途无法挂起。
  • 中间件只实现了 InvokeModelStage(_middleware/README.md):tool/stream 阶段尚未落地。而且中间件、中断都不支持注销(README.md "No removal"、hook 系统同样不支持移除)——注册即终身。
  • _middleware/ 是私有 API:签名可能变,别在生产里依赖 agent._middleware_registry
  • Guide 在 model 前后注入的消息绕过会话管理(actions.py:77 的 note):session manager 不会跟踪这些注入消息;after_model_call 上的 Guide 触发重试且框架不设重试上限(actions.py:72 警告:handler 自己得保证收敛)。
  • 取消/Limits 都是软的、边界检查的:不会瞬时打断正在跑的工具或超大的单次响应,只在下个关节生效。

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

用符号名 grep 比行号更抗漂移。下表按"想插手哪一档"组织。

主题文件路径关键符号
Hook 注册与分发strands-py/src/strands/hooks/registry.pyHookRegistryadd_callbackinvoke_callbacks_asyncget_callbacks_forHookOrder
事件只读/可写强制strands-py/src/strands/hooks/registry.pyBaseHookEvent.__setattr___can_write
生命周期事件目录strands-py/src/strands/hooks/events.pyBeforeToolCallEventAfterToolCallEventBeforeModelCallEventAfterModelCallEventMessageAddedEventBeforeInvocationEvent
HookProvider 批量注册strands-py/src/strands/hooks/registry.pyHookProvideradd_hook
中间件阶段与相位strands-py/src/strands/_middleware/types.pyMiddlewareStageMiddlewareInputPhaseMiddlewareResult
中间件组装/驱动strands-py/src/strands/_middleware/registry.pyMiddlewareRegistrycomposeinvoke_add_output
模型调用上下文strands-py/src/strands/_middleware/stages.pyInvokeModelContextInvokeModelStage
中间件消费点strands-py/src/strands/event_loop/event_loop.py_middleware_registry.invoke_make_invoke_model_terminal
干预基类与动作strands-py/src/strands/interventions/handler.pyinterventions/actions.pyInterventionHandlerOnErrorProceed/Deny/Guide/Confirm/Transform
干预仲裁/桥接strands-py/src/strands/interventions/registry.pyInterventionRegistry_is_overridden_dispatch_handle_error
中断核心状态机strands-py/src/strands/interrupt.pyInterruptInterruptException_InterruptStateactivate/resume/deactivate
中断触发接口strands-py/src/strands/types/interrupt.py_Interruptible.interrupt_interrupt_id
中断在循环里的挂起/恢复strands-py/src/strands/event_loop/event_loop.py_interrupt_state.activated(:283/:739)、activate(:808)、EventLoopStopEvent("interrupt")
取消与预算strands-py/src/strands/agent/agent.pytypes/agent.pycancel_cancel_signalLimitsConcurrentInvocationMode
控制面在 Agent 上的装配strands-py/src/strands/agent/agent.pyhooks_middleware_registry_intervention_registry_interrupt_state(:398-471)

继续读: 中断态如何被序列化、跨会话恢复 → 上下文与持久化;这些挂点在完整回合里的触发时序 → 主线:递归事件循环;工具执行如何消费 BeforeToolCallEvent/中断 → 工具系统