Hitl cn
本文档系统性地剖析 Jianmu 的人机交互挂起恢复机制:从底层 InteractionController 的状态持久化原语,到 ReactiveRunner 的挂起检测与恢复调度,再到 ApprovalManager 的审批决议管道与 Approval 装饰器的节点级审批门控——形成一条从行为树执行暂停到外部宿主回调解除的完整链路。
架构概览:三层挂起恢复模型¶
Jianmu 的人机交互挂起恢复采用分层解耦设计,每一层解决不同抽象级别的问题。理解这三层之间的职责边界是掌握整个机制的关键。
flowchart TB
subgraph HOST["宿主层 (Host / UI)"]
direction LR
H1["检测 suspension 事件"]
H2["呈现审批/输入界面"]
H3["调用 resume 入口"]
H1 --> H2 --> H3
end
subgraph RUNNER["运行时层 (ReactiveRunner)"]
direction TB
R1["tick 循环"]
R2["挂起检测"]
R3["checkpoint 持久化"]
R4["resume 验证与注入"]
R1 --> R2 --> R3 --> R4
end
subgraph INTERACTION["交互原语层 (Interaction / Approval)"]
direction TB
I1["InteractionController\n(suspend / resume payload)"]
I2["ApprovalManager\n(request / resolve)"]
I3["ApprovalStore\n(InMemory / SQLite)"]
I1 --- I2 --- I3
end
subgraph NODE["节点层 (Node / Decorator)"]
N1["Approval 装饰器\n(before / after)"]
N2["ConfirmChecker\n(工具确认)"]
N3["自定义 SuspendNode\n(用户输入/外部回调)"]
end
HOST --> RUNNER --> INTERACTION --> NODE
- 节点层:行为树节点通过
InteractionController.suspend()主动声明挂起点,或通过Approval装饰器声明审批门控 - 交互原语层:提供与宿主无关的挂起/恢复协议——
SuspensionRecord作为挂起声明,ApprovalRequest/ApprovalResult作为审批决议载体 - 运行时层:在事件循环中检测挂起状态,执行 checkpoint 持久化,并在恢复时验证
request_id后将决议数据注入状态 - 宿主层:监听运行时事件(
execution.suspended),渲染对应 UI,并调用run_until_suspend()或resume_and_continue()回传决议
挂起协议:三种挂起类别与 SuspensionRecord¶
挂起是对行为树执行流程的可恢复中断。Jianmu 定义了三种语义明确的挂起类别,每种对应不同的宿主交互模式,但共享同一套 InteractionController 操作 API。
| 类别 | SuspensionCategory | SuspensionReason | 典型场景 |
|---|---|---|---|
| 审批等待 | REQUIRE_APPROVAL |
APPROVAL_PENDING |
工具调用需人工确认、节点执行需审批 |
| 用户输入 | REQUIRE_USER_INPUT |
AWAITING_USER_INPUT |
LLM 中途请求用户补充信息 |
| 外部回调 | REQUIRE_EXTERNAL_EXECUTION |
EXTERNAL_RESULT_PENDING |
等待外部系统异步任务完成 |
每个挂起点由 SuspensionRecord 描述,它是一个不可变的可序列化数据结构,包含 request_id(用于精确匹配恢复请求)、reason、category、message 和 payload。request_id 通过 SHA1 哈希稳定派生,确保同一逻辑挂起点在多次执行中产生一致的标识符。
stateDiagram-v2
[*] --> Active: suspend()
Active --> Active: suspend() 再次调用\n(返回已有记录)
Active --> Inactive: deactivate_suspension()
Active --> Cleared: clear_suspension()
Inactive --> Active: suspend() 再次调用\n(创建新记录)
Inactive --> Cleared: clear_suspension()
Cleared --> Active: suspend()
InteractionController 将所有挂起状态持久化在 StateManager 的 interaction 命名空间下,与业务状态隔离。节点通过依赖注入获得的 interaction 属性访问控制器,无需感知底层存储细节。
三种恢复载荷的标准化¶
不同类型的挂起需要宿主回传不同形状的恢复载荷,Jianmu 提供了三种载荷构建与标准化函数:
用户输入载荷 通过 build_user_input_suspension_payload() 构建,包含 input_kind(如 text、select)、prompt、可选的 schema(JSON Schema 验证)、default 和 options。恢复时通过 normalize_user_input_resume_payload() 标准化,自动将 text 字段映射为 value。
外部执行载荷 通过 build_external_execution_suspension_payload() 构建,包含 execution_kind、task 字典和可选的 callback_hint。恢复时通过 normalize_external_execution_resume_payload() 标准化,提取 result 和 status 字段。
审批载荷 不通过通用载荷通道,而是走专用的 ApprovalManager → InteractionController 审批收件箱路径(详见下一节)。
审批子系统:ApprovalManager 与决议管道¶
审批是挂起恢复机制中最复杂的分支。其核心是 ApprovalManager,一个统一入口协调审批请求、决议和事件发射。
审批请求的三种状态¶
ApprovalState 定义了审批请求的三种稳定结果,其中 PENDING 是整个挂起恢复机制的关键——它表示审批需要外部宿主参与,当前运行必须挂起等待。
| 状态 | 含义 | 运行行为 |
|---|---|---|
APPROVED |
已批准 | 继续执行,不挂起 |
REJECTED |
已拒绝 | 返回拒绝状态,不挂起 |
PENDING |
等待外部决议 | 挂起行为树,等待宿主回调 |
回调决议顺序¶
ApprovalManager.request() 按以下优先级链解析审批结果:
flowchart LR
A["ApprovalRequest"] --> B{"有显式 callback?"}
B -->|是| C["调用 callback(req)"]
B -->|否| D{"kind == 'tool'?"}
D -->|是| E["tool_callback\n或 default_confirm_callback"]
D -->|否| F{"kind == 'node'?"}
F -->|是| G["node_callback\n或 default_node_approval_callback"]
F -->|否| H["REJECTED"]
C --> I["_normalize_result()"]
E --> I
G --> I
I --> J{"pending?"}
J -->|是| K["存入 pending_store"]
J -->|否| L["发射 approval.resolved 事件"]
默认的 default_confirm_callback 在无 TTY 且 non_tty_pending=True 时自动返回 PENDING 状态,使非交互式环境(如 Web 服务)中的审批请求自然地进入挂起状态等待外部宿主处理。
审批决议存储的生命周期¶
ApprovalStoreProtocol 定义了审批请求/结果的持久化契约,InMemoryApprovalStore 用于测试和短生命周期运行,SQLiteApprovalStore 提供跨进程持久化。存储采用 ack/prune 两阶段清理语义:
sequenceDiagram
participant N as Node/Checker
participant M as ApprovalManager
participant S as ApprovalStore
participant R as ReactiveRunner
N->>M: request()
M->>S: put_request()
M->>M: callback → PENDING
M->>S: (请求保持 pending)
M-->>N: ApprovalResult(PENDING)
Note over R: 检测到挂起,yield 给宿主
R->>M: Host 调用 resolve_request()
M->>S: resolve_request() → 删除请求,写入结果
M-->>R: ApprovalResult(APPROVED/REJECTED)
R->>M: ack_result()
M->>S: ack_result() → 标记已消费
R->>M: prune_result()
M->>S: prune_result() → 仅在 acked 后删除
这种设计确保即使在 checkpoint 持久化与审批决议之间存在竞态,也不会丢失审批结果——结果在 ack 之前始终可被查询。
ReactiveRunner 中的挂起检测与恢复调度¶
ReactiveRunner 的 run_until_suspend() 是宿主集成的核心入口。它扩展了 run() 的事件驱动循环,在每次 tick 后注入挂起检测逻辑。
SuspensionMode:YIELD 与 WAIT¶
| 模式 | 行为 | 适用场景 |
|---|---|---|
YIELD |
检测到挂起后立即保存 checkpoint 并返回 RunResult(outcome="suspended") |
Web 服务、TUI 等异步交互场景 |
WAIT |
检测到挂起后继续 tick 循环(节点保持 RUNNING 直到恢复载荷到达) | 同一进程内同步等待的场景 |
flowchart TB
subgraph LOOP["run_until_suspend 主循环"]
A["_wait_for_tick()"] --> B["_sync_live_approval_subscription()"]
B --> C["_resume_live_approval_if_ready()"]
C --> D["tree.tick()"]
D --> E{"suspension 且\nstatus == RUNNING?"}
E -->|否| F["_maybe_save_checkpoint()"]
E -->|是| G{"mode == YIELD?"}
G -->|是| H["_save_checkpoint_now()\n返回 RunResult(suspended)"]
G -->|否| F
F --> I{"status == SUCCESS?"}
I -->|是| J["返回 RunResult(success)"]
I -->|否| K{"status == FAILURE?"}
K -->|是| L["返回 RunResult(failure)"]
K -->|否| A
end
关键设计:run_until_suspend() 在挂起时发出 execution.suspended 运行时事件,其 payload 包含 request_id、reason、category 和 thread_id——宿主通过这些字段展示对应 UI 并在恢复时精确回传。
实时审批桥接¶
ReactiveRunner 维护一个实时审批订阅(_sync_live_approval_subscription),通过 ApprovalManager.subscribe_resolution() 注册回调。当外部宿主在同一进程中直接调用 manager.resolve_request() 时,回调被触发 → _bridge_live_approval_resolution() 将审批决议写入 InteractionController 的审批收件箱 → _resume_live_approval_if_ready() 在下次 tick 前检测到决议就绪 → 自动解除挂起并 _signal_tick() 唤醒事件循环。
这一机制使得同进程内的审批(如 TUI Chat 中的 confirm_tool)无需通过 run_until_suspend 的 YIELD/RESUME 循环,实现了零延迟的挂起恢复。
resume_and_continue:一站式恢复入口¶
resume_and_continue() 是对 run_until_suspend() 的便捷封装,固定使用 RestorePolicy.REQUIRED,强制从 checkpoint 恢复。它将 payload 和 request_id 直接转发,内部自动根据挂起类别分发:
- 若当前挂起为
REQUIRE_APPROVAL:通过resolve_approval_resume()桥接到ApprovalManager,将审批决议写入交互控制器的收件箱 - 若为
REQUIRE_USER_INPUT或REQUIRE_EXTERNAL_EXECUTION:直接将载荷通过InteractionController.write_resume_payload()写入状态
审批装饰器:节点级人机交互门控¶
Approval 是 py_trees 的 Decorator 节点,为任意子行为树节点包裹人机审批门控。它支持两个阶段的审批:
stateDiagram-v2
[*] --> PreApproval: tick() 且 needs_pre()
PreApproval --> ChildExecute: pre_approved
PreApproval --> Suspended: PENDING
ChildExecute --> PostApproval: child_completed 且 needs_post()
PostApproval --> [*]: post_approved
PostApproval --> Suspended: PENDING
Suspended --> PreApproval: resume (before 阶段)
Suspended --> PostApproval: resume (after 阶段)
审批进度持久化¶
Approval 装饰器通过 InteractionController 将审批进度持久化到运行时元数据中。每个装饰器实例使用稳定键 node:{node_locator}:{decorator_name} 存储 NodeApprovalProgressRecord,包含 before 和 after 两个 NodeApprovalPhaseProgress 子记录。这使得 checkpoint 恢复后审批状态不会丢失——若 before 阶段已通过但 after 尚未完成,恢复后直接进入 after 阶段的审批等待。
| 配置项 | 可选值 | 说明 |
|---|---|---|
phase |
before / after / both |
审批阶段:执行前、执行后或两者 |
on_reject |
failure / success_skip |
拒绝时的行为:返回 FAILURE 或跳过并返回 SUCCESS |
callback |
Callable[[ApprovalRequest], Awaitable[bool] \| bool] |
自定义审批回调 |
context_fn |
Callable[[], dict \| None] |
提供额外审批上下文 |
工具审批:ConfirmChecker 与 ToolApprovalRuntime¶
工具级别的审批通过 GuardEnforcer 的 ConfirmChecker 实现。与 Approval 装饰器的节点级审批不同,工具审批发生在 Guard 检查阶段,拦截在工具实际执行之前。
sequenceDiagram
participant TE as ToolExecutor
participant TR as ToolRunner
participant GE as GuardEnforcer
participant CC as ConfirmChecker
participant AM as ApprovalManager
participant IC as InteractionController
TE->>TR: 执行工具调用
TR->>GE: check(action, context)
GE->>CC: check()
CC->>IC: pop_resolved_approval(request_id)
IC-->>CC: None (未决议)
CC->>AM: confirm_tool(tool_name, args)
AM-->>CC: ApprovalResult(PENDING)
CC-->>GE: Decision.PENDING
GE-->>TR: Decision.PENDING
TR->>IC: suspend(APPROVAL_PENDING)
TR-->>TE: Status.RUNNING
Note over TE: 树挂起,等待宿主决议
TE->>IC: pop_resolved_approval(request_id)
IC-->>TE: {"approved": true}
TE->>TR: 重新执行工具
ToolApprovalRuntime 为每个工具调用维护持久化的审批进度,使用键 tool:{node_locator}:{tool_call_id} 存储 ToolApprovalProgressRecord。与节点审批一样,工具审批进度在 checkpoint 中存活,支持跨进程恢复。
Checkpoint 与恢复策略¶
挂起恢复机制与 checkpoint 系统深度耦合。当 SuspensionMode.YIELD 触发时,ReactiveRunner 调用 _save_checkpoint_now() 立即持久化当前状态——包括树状态(每个节点的 path:id → status 映射)、业务状态数据和运行时元数据(含挂起记录和审批进度)。
恢复时 RestorePolicy 控制行为:
| 策略 | 行为 | 使用场景 |
|---|---|---|
NEVER |
不从 checkpoint 恢复,全新开始 | 首次运行 |
IF_EXISTS |
有 checkpoint 则恢复,无则全新开始 | 容错恢复 |
REQUIRED |
必须有 checkpoint,否则抛出异常 | resume_and_continue() 的强制模式 |
树状态恢复通过 _build_path_maps() 构建节点路径映射,然后 _apply_status() 逐个回放状态。关键细节:组合节点直接恢复为 RUNNING,叶子节点降级为 INVALID 确保 py_trees 干净地重新进入。_repair_running_composite_pointers() 额外修复 Sequence 和 Selector 的 current_child 指针,确保组合逻辑在恢复后正确推进。
审批结果的多层清理语义¶
审批结果的生命周期管理涉及三个存储层,每个层的清理时机和语义不同:
flowchart LR
subgraph INBOX["InteractionController 收件箱"]
I1["RESOLVED_APPROVALS\n(审批决议等待树消费)"]
I2["RESOLVED_APPROVAL_ACKS\n(树已消费,等待落盘)"]
end
subgraph STORE["ApprovalStore"]
S1["pending 请求"]
S2["已决议结果\n(未被 ack)"]
S3["已 ack 结果\n(等待 prune)"]
end
I1 -->|"树 pop_resolved_approval()"| I2
I2 -->|"_prune_acknowledged_approval_results()\n(仅在 checkpoint 落盘后)"| S3
S2 -->|"ack_result()"| S3
S3 -->|"prune_result()"| [*]
关键安全保证:已 ack 的审批结果只有在 checkpoint 成功落盘后才会被 prune。这确保了如果在 ack 之后、prune 之前发生崩溃,恢复时审批结果仍可从 ApprovalStore 获取,但不会导致重复消费——因为树的消费状态(RESOLVED_APPROVAL_ACKS)也随 checkpoint 持久化。
运行时事件:宿主集成的观察窗口¶
整个挂起恢复流程通过 RuntimeEventBus 发射结构化事件,宿主通过订阅这些事件驱动 UI 更新。关键事件序列:
| 事件类型 | 触发时机 | 关键 payload 字段 |
|---|---|---|
approval.requested |
审批请求发起 | request_id, kind, target, description |
approval.resolved |
审批决议完成 | request_id, state, approved |
execution.suspended |
运行挂起 | request_id, reason, category, message, thread_id |
execution.resumed |
运行恢复 | request_id, resume_kind, lifecycle_category |
checkpoint.saved |
checkpoint 落盘 | thread_id, step |
checkpoint.restored |
checkpoint 恢复 | thread_id |
宿主集成的最小可行模式:订阅 execution.suspended → 展示审批/输入 UI → 收集用户决策 → 调用 runner.run_until_suspend(resume_data=..., resume_request_id=...) 或 runner.resume_and_continue(...)。
完整交互时序:从挂起到恢复¶
以下时序图展示了一个完整的工具审批挂起恢复流程,覆盖从 run_until_suspend 调用到 resume_and_continue 恢复的全路径:
sequenceDiagram
participant Host as 宿主 (Web/TUI)
participant RR as ReactiveRunner
participant Tree as 行为树
participant IC as InteractionController
participant AM as ApprovalManager
participant CP as Checkpointer
Host->>RR: run_until_suspend(input_data)
RR->>CP: _try_restore_from_checkpoint()
CP-->>RR: None (首次运行)
loop 事件循环
RR->>Tree: tick()
Tree->>IC: suspend(APPROVAL_PENDING)
Tree-->>RR: Status.RUNNING
RR->>RR: 检测到 suspension
RR->>RR: emit "execution.suspended"
RR->>CP: _save_checkpoint_now()
RR-->>Host: RunResult(outcome="suspended")
end
Host->>Host: 展示审批 UI
Host->>AM: resolve_request(request_id, approved=true)
AM->>AM: 写入 ApprovalStore
Host->>RR: resume_and_continue(thread_id, request_id, payload)
RR->>CP: _try_restore_from_checkpoint()
CP-->>RR: checkpoint data
RR->>Tree: _restore_tree_status()
RR->>IC: get_active_suspension() → request_id 匹配
RR->>AM: resolve_approval_resume()
AM-->>RR: ApprovalResumeResolution(SUCCESS)
RR->>IC: write_resolved_approval(request_id, decision)
RR->>IC: deactivate_suspension()
RR->>RR: emit "execution.resumed"
loop 事件循环 (继续)
RR->>Tree: tick()
Tree->>IC: pop_resolved_approval(request_id)
IC-->>Tree: {"approved": true}
Tree->>Tree: 审批通过,继续执行
Tree-->>RR: Status.SUCCESS
RR-->>Host: RunResult(outcome="success")
end
数据流全景¶
flowchart TB
subgraph STATE["StateManager 命名空间"]
direction TB
NS1["interaction.suspension\n(SuspensionRecord)"]
NS2["interaction.resume_payload\n(host 回传数据)"]
NS3["interaction.termination\n(TerminationRecord)"]
NS4["runtime_metadata.thread_id"]
NS5["runtime_metadata.resolved_approvals\n({request_id: decision})"]
NS6["runtime_metadata.approval_progress\n({key: record})"]
end
subgraph APPROVAL["ApprovalManager"]
AM1["pending_store\n(ApprovalStoreProtocol)"]
AM2["_resolution_subscribers\n(request-scoped callbacks)"]
end
subgraph RUNNER["ReactiveRunner 内部"]
R1["_last_suspended_event_key\n(去重)"]
R2["_live_approval_subscription\n(同进程桥接)"]
end
NS1 -->|"挂起检测"| RUNNER
NS5 -->|"审批决议读取"| Tree
NS6 -->|"进度持久化"| Tree
AM1 -->|"pending/resolved 查询"| APPROVAL
R2 -->|"resolve 回调"| AM2
与其它文档的关系¶
挂起恢复机制是 Jianmu 运行时架构的横向切面,与多个子系统存在耦合:
- ReactiveRunner:事件驱动的异步 tick 调度与挂起恢复机制:深入理解
_event_loop的 tick 调度细节、wake-up 信号机制和热循环检测 - Guard 体系:工具策略检查、预算控制、频率限制与确认拦截:了解 ToolPolicyChecker、BudgetChecker、RateLimitChecker 如何与 ConfirmChecker 协作构成完整的 Guard 流水线
- Checkpoint 与长期记忆:理解 checkpoint 存储的完整协议和跨运行状态恢复机制
- TUI Chat:基于 Textual 的终端交互界面与运行时适配:查看挂起恢复在终端交互应用中的实际宿主集成示例