鸿蒙HarmonyOS Agent 控制与安全实战 —— Hook、Pipeline、Controller、审批路由与终态持久化
一、前言:Agent 控制面的"六个难题"
假设你已经按前几篇博客把对话、流式输出、工具调用跑通了。现在你要把这个 Agent 嵌进一个真正的鸿蒙 App,给用户用。上真机后你会立刻撞上六个难题:
-
怎么在不动 Runtime 主路径的情况下加埋点、审计、风控? 你想加"每次模型调用前记录 token 数""每次工具调用前校验权限""每次流式 chunk 时过滤敏感词"。直接改 Runtime 代码会把主路径越改越脏;用全局回调又没法保证执行顺序和短路语义。
-
多个 Hook 谁先谁后? 安全 Hook 决定 abort,业务 Hook 想改写参数,审计 Hook 想记录结果——三个 Hook 的执行顺序、改写传递、短路优先级怎么定?并行执行能保证"第一个改写了参数,第二个看到的是改写后的"吗?
-
流式 chunk 期间 Hook 决定 drop 或 abort,怎么保证不晚于 modelComplete? 模型流式吐字是异步的,如果 Hook 是 fire-and-forget,drop 决策可能比
modelComplete事件还晚到——用户已经看到被 drop 的字符了。 -
危险工具审批弹出来了,用户切后台被系统回收,怎么续? Ability 生命周期不可控,你必须在用户离开前把"待审批状态"落盘,重载时能恢复。但落盘时机错了——比如先发审批事件再落盘——就会"声称可恢复但实际没存"。
-
终态(cancel/complete/error/loop_detected)的持久化能不能被 Hook 跳过? 业务 Hook 觉得"自动保存太频繁了想跳过",但如果它把"终态落盘"也跳过了,下一次重载就会看到一个明明已经结束却还能 resume 的 Run,状态彻底错乱。
-
Agent 跑飞了陷入死循环,Hook 抛了异常,要不要覆盖业务终态? afterRun 是观察型 Hook,它崩溃了能不能让整个 Run 失败?不能。但如果放任异常传播,又会把已经定好的"成功"终态覆盖成"异常"。
这六个问题不是独立的——它们都横跨在"业务主路径"和"外部控制面"之间。你需要的不是六个补丁,而是一套贯通的 Hook SPI + Pipeline 执行语义 + Controller 控制面 + 终态持久化安全边界。本文就拆解这套体系。
ArkAgent 阶段 5 把它落成了四个文件:hook/AgentHook.ets(SPI 基类)、hook/AgentHookPipeline.ets(串行短路管线)、controller/AgentController.ets(零状态控制面)、core/EventBus.ets(类型化事件总线),并由 ADR-0011 冻结了决策优先级和持久化安全边界。
二、整体架构:控制面 vs 业务主路径
先看全局。ArkAgent 把"控制与安全"显式拆成两层,业务主路径(AgentRuntime.runStream)负责跑 Agent Loop,控制面(AgentController + EventBus + Hook Pipeline)负责观察、干预、路由审批。两层之间通过三个明确接口耦合,绝不互相侵入。
┌────────────────────────────────────────────────────────────────┐
│ 外部世界(UI / 跨 Ability / 开发者代码) │
│ │ subscribe(eventType) │ approve(id)/deny(id) │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AgentController(零状态控制面) │ │
│ │ - 不持有 Runtime 可变状态 │ │
│ │ - 只观察(listPendingApprovals)、发布、路由审批 │ │
│ │ - bindApprovalHost(Runtime):单向桥接 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ eventBus.publish │ approvalHost.applyDecision │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────────────────────────┐ │
│ │ EventBus │ │ AgentRuntime(业务主路径,唯一状态所有者)│ │
│ │ 类型化 │ │ runStream() → Agent Loop │ │
│ │ publish / │ │ │ │ │
│ │ request │◄───│ ├── Hook Pipeline.beforeModelCall │ │
│ │ 异常隔离 │ │ ├── Provider.callModel │ │
│ └─────────────┘ │ ├── Hook Pipeline.onModelChunk │ │
│ │ ├── Hook Pipeline.afterModelCall │ │
│ │ ├── Hook Pipeline.beforeToolCall │ │
│ │ ├── ToolRegistry.execute │ │
│ │ ├── Hook Pipeline.afterToolCall │ │
│ │ └── Hook Pipeline.afterRun │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
三个关键判断:
第一,Controller 不持有 Runtime 可变状态。 Controller 是个观察者和路由器,不是"Runtime 的遥控器"。它不能直接改 state.isRunning、不能直接清 pendingApproval、不能直接调 ToolRegistry。它唯一能做的是"问 Runtime 要待审批列表"和"把审批决策路由回 Runtime"——通过 ApprovalHost 这个单向接口。
第二,EventBus 是旁路观察,不参与主路径决策。 Runtime 在关键节点 publishController(event) 发事件,UI 和日志订阅这些事件做展示和埋点。事件监听器抛异常绝不能影响 Runtime——EventBus 的 publish 把异常吞掉,只做隔离记录。
第三,Hook Pipeline 是主路径的一部分,但严格串行短路。 Hook 不是事件——它的决策会改变 Run 的走向(abort/deny/defer/stop)。Pipeline 是 Runtime 主路径的"关卡",但执行语义被严格约束:串行、改写传递、第一个短路立即生效。
这三条判断合起来,回答了"控制面和业务路径怎么分"这个根本问题。下面拆解每个组件。
三、AgentHook:九槽位 SPI 基类
Hook 体系的地基是 AgentHook 基类——一个 SPI(Service Provider Interface),开发者继承它、重写关心的方法,注册到 Pipeline。
3.1 九个槽位的时间线
先看九个 Hook 槽位在整个 Run 生命周期中的位置:
runStream(input)
│
├── beforeRun(input) ← 1. Run 开始前,可改写输入或 abort
│
├── [Agent Loop:每轮]
│ │
│ ├── beforeModelCall(request) ← 2. 调模型前,可改写请求/直接响应/中止
│ │
│ ├── Provider.callModel(stream)
│ │ │
│ │ ├── onModelChunk(text) ← 3. 流式 chunk,串行 await,可 drop/rewrite/abort
│ │ │ (每个 chunk 都过一遍 Pipeline)
│ │ └── modelComplete(response)
│ │
│ ├── afterModelCall(response) ← 4. 模型响应后,可改写/重试/中止
│ │
│ ├── [如果有 Tool Call]
│ │ ├── beforeToolCall(call) ← 5. 工具执行前,可改写/拒绝/defer/中止
│ │ ├── ToolRegistry.execute
│ │ └── afterToolCall(result) ← 6. 工具执行后,可改写/停止/中止/注入消息
│ │
│ └── onTurnCompletion(msg) ← 7. 一轮结束,可 accept/continue/中止
│
├── beforePersistState(ctx) ← 8. 落盘前,可 proceed/skip/中止
│ (每次自动保存、suspend、终态都触发)
│
├── afterPersistState(ctx) ← 落盘后,纯观察(异常隔离)
│
└── afterRun(ctx) ← 9. Run 终态恰好一次,纯观察(异常隔离)
九个槽位按"干预能力"分两类:
|
类型 |
槽位 |
能改 Run 走向? |
默认行为 |
|---|---|---|---|
|
决策型 |
beforeRun / beforeModelCall / onModelChunk / afterModelCall / beforeToolCall / afterToolCall / onTurnCompletion / beforePersistState |
能(返回 action) |
proceed(透传) |
|
观察型 |
afterPersistState / afterRun |
不能(返回 void) |
空实现 |
决策型 Hook 默认全 pass-through——基类返回 proceed,原样放行。你只重写关心的槽位,其他槽位走默认。观察型 Hook 返回 void,只做副作用(埋点、审计、清理),异常被 Pipeline 隔离,绝不覆盖业务终态。
3.2 源码:基类的九个默认实现
// hook/AgentHook.ets
export class AgentHook {
// 1. Run 开始前
async beforeRun(context: BeforeRunHookContext): Promise<BeforeRunHookResult> {
return BeforeRunHookResult.proceed(context.input)
}
// 2. 调模型前
async beforeModelCall(context: ModelCallHookContext): Promise<ModelCallHookResult> {
return ModelCallHookResult.proceed(context.request)
}
// 3. 流式 chunk
async onModelChunk(context: ModelChunkHookContext): Promise<ModelChunkHookResult> {
return ModelChunkHookResult.proceed(context.text, context.reasoning)
}
// 4. 模型响应后
async afterModelCall(context: ModelResponseHookContext): Promise<ModelResponseHookResult> {
return ModelResponseHookResult.proceed(context.response)
}
// 5. 工具执行前
async beforeToolCall(context: ToolCallHookContext): Promise<ToolCallHookResult> {
return ToolCallHookResult.proceed(context.call)
}
// 6. 工具执行后
async afterToolCall(context: ToolResultHookContext): Promise<ToolResultHookResult> {
return ToolResultHookResult.proceed(context.result)
}
// 7. 一轮结束
async onTurnCompletion(context: TurnCompletionHookContext): Promise<TurnCompletionHookResult> {
return TurnCompletionHookResult.accept()
}
// 8. 落盘前
async beforePersistState(context: StatePersistenceHookContext): Promise<StatePersistenceHookResult> {
return StatePersistenceHookResult.proceed()
}
// 观察型:落盘后
async afterPersistState(_context: StatePersistenceHookContext): Promise<void> {
}
// 观察型:Run 终态
async afterRun(_context: AfterRunHookContext): Promise<void> {
}
}
注意几个细节:
第一,每个方法都是 async,返回 Promise。 这是 ADR-0011 强制的——Hook 可能要查数据库、调远程服务、等用户输入,必须支持异步。Pipeline 严格 await 每个 Hook,不允许 fire-and-forget(这正是踩坑三的根源)。
第二,每个决策型方法返回 *HookResult,里面带 action。 action 是枚举,不是布尔。比如 ToolCallHookAction 有四个值:proceed/deny/defer/abort——分别表示"放行""拒绝并注入合成结果""defer 配合审批冻结""立即中止 Run"。布尔只能表达"要不要继续",但 Hook 需要表达更细的语义(拒绝时还要回填一个假结果给模型,否则模型会以为工具没响应)。
第三,*HookResult 用静态工厂方法构造,构造器是 private。 比如 ToolCallHookResult.deny(syntheticResult, reason)——这强制开发者用语义化的工厂方法,而不是 new ToolCallHookResult(action, ...) 这种容易传错参数的写法。
3.3 HookContext:DTO 快照,不是活的对象
每个 Hook 方法的入参是一个 *HookContext。关键约束:Context 是 DTO 快照,不是 Runtime 内部对象的引用。
export class HookContextBase {
readonly sessionId: string
readonly state: AgentState // ← 是快照,不是 Runtime 的活状态
// ...
}
export class ToolCallHookContext extends HookContextBase {
readonly call: ToolCall
readonly modelMessage: AgentMessage
readonly availableTools: ToolDefinition[]
copyWith(call?: ToolCall): ToolCallHookContext {
return new ToolCallHookContext(this.sessionId, this.state, call ?? this.call,
this.modelMessage, this.availableTools)
}
}
为什么是快照?因为 Hook 是外部代码(开发者写的),不能让它拿到 Runtime 的活引用然后偷偷改 state.history。state 字段虽然在类型上是 AgentState,但 Runtime 调用 Hook 时传的是 this.state.snapshot()——一个只读快照。Hook 想改请求/响应/工具调用,只能通过 copyWith 返回新的 Context 给下一个 Hook,不能 mutate 原对象。
copyWith 的设计很关键:它让"改写"成为显式动作——你必须调用 context.copyWith(newCall) 才算改写,不能直接 context.call = newCall(字段是 readonly)。这让 Pipeline 能精确追踪"谁改了什么"。
四、HookResult:四种 action 与三种 action 的语义
每个决策型槽位的 *HookResult 携带不同的 action 集合。下面用最复杂的两个槽位——beforeToolCall 和 afterToolCall——拆解。
4.1 ToolCallHookResult:四种 action
beforeToolCall 是工具安全的核心关卡,action 集合最丰富:
|
action |
含义 |
携带数据 |
效果 |
|---|---|---|---|
|
|
放行 |
可选改写后的 |
工具正常执行(用改写后的 call) |
|
|
拒绝 |
可选 |
不执行工具,把合成结果回填给模型 |
|
|
推迟 |
reason |
冻结 batch,进入 awaiting_approval |
|
|
中止 |
可选 error |
立即终止 Run,终态 |
源码:
export enum ToolCallHookAction {
proceed = 'proceed',
deny = 'deny',
defer = 'defer',
abort = 'abort'
}
export class ToolCallHookResult {
readonly action: ToolCallHookAction
readonly call?: ToolCall // proceed 时携带改写后的 call
readonly syntheticResult?: ToolResult // deny 时携带合成结果
readonly reason?: string
readonly error?: ArkAgentError
// 工厂方法
static proceed(call?: ToolCall): ToolCallHookResult { ... }
static deny(syntheticResult?: ToolResult, reason?: string): ToolCallHookResult { ... }
static defer(reason?: string): ToolCallHookResult { ... }
static abort(reason?: string, error?: ArkAgentError): ToolCallHookResult { ... }
}
为什么 deny 要带 syntheticResult? 因为模型发了 Tool Call,它在等 Tool Result。如果 Hook 直接 deny 不回填任何结果,模型会以为工具没响应,可能重试或卡住。deny 必须回填一个结构化的"拒绝结果":
// 业务 Hook:拒绝清空操作,回填拒绝原因
async beforeToolCall(context: ToolCallHookContext): Promise<ToolCallHookResult> {
if (context.call.name === 'clear_baking_cache') {
return ToolCallHookResult.deny(
new ToolResult([ContentPart.text('用户拒绝清空缓存')], true), // isError: true
'安全策略:清空操作需用户确认'
)
}
return ToolCallHookResult.proceed(context.call)
}
模型拿到这个 isError: true 的结果,就知道"清空失败了",会换策略(比如问用户要不要手动清理)。
为什么有独立的 defer? 因为"拒绝"和"等用户确认"是两件事。deny 是"我替用户决定了不准",defer 是"我不决定,让用户决定"。defer 会冻结整个 batch,触发 awaiting_approval 终态,落盘 pendingApproval,等用户通过 Controller.approve/deny 路由回来。
4.2 ToolResultHookResult:三种 action + 注入消息
afterToolCall 在工具执行后触发,可以改写结果、停止循环、追加额外消息:
|
action |
含义 |
效果 |
|---|---|---|
|
|
放行 |
结果进入历史(可用改写后的 result) |
|
|
停止 |
结果进入历史,但 Agent 循环终止 |
|
|
中止 |
立即终止 Run |
额外能力:injectedMessages——在工具结果后追加额外消息(比如系统提醒"你刚刚执行了危险操作,请向用户确认结果")。
export class ToolResultHookResult {
readonly action: ToolResultHookAction
readonly result?: ToolResult
readonly injectedMessages: AgentMessage[] // ← 注入消息数组
readonly reason?: string
readonly error?: ArkAgentError
static proceed(result?: ToolResult, injectedMessages: AgentMessage[] = []): ToolResultHookResult { ... }
static stop(result?: ToolResult, injectedMessages: AgentMessage[] = [],
reason?: string): ToolResultHookResult { ... }
static abort(reason?: string, error?: ArkAgentError): ToolResultHookResult { ... }
}
injectedMessages 是个常被忽略但很有用的能力。比如你做了个"敏感操作审计 Hook"——每次执行完 clear_* 工具,自动注入一条 system message 提醒模型"已记录审计日志,日志ID xxx"。这些注入消息会按 Hook 顺序累加(见下一节 Pipeline)。
4.3 其他槽位的 action 一览
|
槽位 |
action 集合 |
短路 action |
|---|---|---|
|
beforeRun |
proceed / abort |
abort |
|
beforeModelCall |
proceed / respond / abort |
respond(直接响应)、abort |
|
onModelChunk |
proceed / drop / abort |
drop(丢弃 chunk)、abort |
|
afterModelCall |
proceed / retry / abort |
retry(重试本轮)、abort |
|
beforeToolCall |
proceed / deny / defer / abort |
deny / defer / abort |
|
afterToolCall |
proceed / stop / abort |
stop / abort |
|
onTurnCompletion |
accept / continue_run / abort |
continue_run(继续下一轮)、abort |
|
beforePersistState |
proceed / skip / abort |
skip(仅非 mandatory)、abort |
记住一条总原则:安全类 action(abort/deny/defer)一旦触发,立即短路,后续 Hook 不执行。这是 ADR-0011 的硬约束,下一节展开。
五、AgentHookPipeline:串行短路 + 改写传递
AgentHookPipeline 是 Hook 体系的执行引擎。它把多个 Hook 按注册顺序串成一条链,严格执行,改写传递,第一个短路立即生效。
5.1 四条铁律(ADR-0011)
1. 严格按注册顺序串行执行(不是并行,不是 fire-and-forget)
2. 改写结果传递给下一个 Hook(A 改写 → B 看到改写后的)
3. 第一个短路决策立即生效(abort/deny/defer/stop/respond/retry/drop/continue)
4. 工具改写必须保留原始 call id(preserveToolCallId)
为什么串行而不是并行?因为 Hook 之间有顺序依赖。比如安全 Hook 把工具参数里的 force=true 改成 false,审计 Hook 要记录的是改写后的参数。并行执行没法保证"审计看到的是安全改写后的"。
为什么第一个短路立即生效?因为安全决策不能被覆盖。如果安全 Hook 决定 abort(比如检测到 prompt injection),后续的业务 Hook 不应该能"取消"这个 abort。安全优先,这是控制面设计的铁律。
5.2 源码:beforeToolCall 的串行短路
以最复杂的 beforeToolCall 为例,看 Pipeline 怎么实现这四条铁律:
// hook/AgentHookPipeline.ets
async beforeToolCall(context: ToolCallHookContext): Promise<ToolCallHookResult> {
let call = context.call
const originalId = context.call.id // ← 记住原始 id
for (let i = 0; i < this.hooks.length; i++) {
const result = await this.hooks[i].beforeToolCall(context.copyWith(call))
// 短路 action 立即返回
if (result.action === ToolCallHookAction.abort ||
result.action === ToolCallHookAction.deny ||
result.action === ToolCallHookAction.defer) {
return result // ← 后续 Hook 不执行
}
// 改写传递:下一个 Hook 看到改写后的 call
if (result.call !== undefined) {
call = preserveToolCallId(originalId, result.call) // ← 强制保 id
}
}
return ToolCallHookResult.proceed(call)
}
四个要点逐个拆:
要点一:for 循环严格串行 await。 不是 Promise.all,不是 forEach。每个 Hook 必须等上一个完成。这保证了改写传递和短路语义。
要点二:context.copyWith(call) 把改写传递给下一个 Hook。 每轮循环用当前的 call(可能是上一个 Hook 改写过的)构造新的 Context。下一个 Hook 拿到的是"累积改写后"的工具调用。
要点三:短路 action 立即 return。 abort/deny/defer 任何一个触发,直接返回,后续 Hook 不执行。这就是"第一个短路生效"。
要点四:preserveToolCallId 强制保留原始 id。 这是工具改写的特殊规则,单独说。
5.3 preserveToolCallId:为什么改写不能改 id
function preserveToolCallId(originalId: string, call: ToolCall): ToolCall {
if (call.id === originalId) {
return call
}
return new ToolCall(originalId, call.name, call.argumentsJson, call.index)
}
模型返回的每个 Tool Call 有唯一 id。Tool Result 必须用相同的 id 回填,否则模型收到的 Result 对不上原来的 Call,导致历史不一致。
如果 Hook 改写工具时把 id 也改了(比如重新生成 uuid),模型那边的 Call/Result 配对就断了。所以 Pipeline 强制:你可以改 name、改 argumentsJson,但 id 必须保留原始值。
模型返回:ToolCall(id="call_abc", name="search", args={"q":"天气"})
↓
Hook 改写:ToolCall(id="call_xyz"❌, name="search", args={"q":"北京天气"})
↓
Pipeline 修正:ToolCall(id="call_abc"✓, name="search", args={"q":"北京天气"})
↓
执行后回填:ToolResult(id="call_abc", ...) ← 模型能配对上
5.4 afterToolCall:注入消息按 Hook 顺序累加
afterToolCall 有个特殊点——injectedMessages 不是"覆盖",是"累加"。多个 Hook 注入的消息按 Hook 注册顺序依次追加:
async afterToolCall(context: ToolResultHookContext): Promise<ToolResultHookResult> {
let toolResult = context.result
const injected: AgentMessage[] = [] // ← 累加数组
for (let i = 0; i < this.hooks.length; i++) {
const hookResult = await this.hooks[i].afterToolCall(context.copyWith(toolResult))
if (hookResult.action === ToolResultHookAction.abort) {
return hookResult
}
if (hookResult.result !== undefined) {
toolResult = hookResult.result // result 改写传递
}
for (let j = 0; j < hookResult.injectedMessages.length; j++) {
injected.push(hookResult.injectedMessages[j]) // ← 按 Hook 顺序追加
}
if (hookResult.action === ToolResultHookResult.stop) {
return ToolResultHookResult.stop(toolResult, injected, hookResult.reason)
}
}
return ToolResultHookResult.proceed(toolResult, injected)
}
为什么累加而不是覆盖?因为注入消息是"附加语义"——审计 Hook 注入审计日志,安全 Hook 注入安全提醒,两个都要保留。如果覆盖,后注册的 Hook 会丢掉前面 Hook 的注入。
注意 stop 短路时也要带上已累加的 injected——不能因为停止就丢掉前面 Hook 已经注入的消息。
5.5 afterRun:每个 Run 终态恰好一次 + 诊断隔离
afterRun 是观察型 Hook,语义最特殊——每个 Run 终态恰好执行一次,异常隔离,收集诊断。
async afterRun(context: AfterRunHookContext): Promise<string[]> {
const diagnostics: string[] = []
for (let i = 0; i < this.hooks.length; i++) {
try {
await this.hooks[i].afterRun(context)
} catch (e) {
const msg = e instanceof Error ? e.message : 'afterRun hook failed'
diagnostics.push(msg) // ← 异常转诊断消息
}
}
return diagnostics // ← 返回给 Runtime 发布事件
}
两个关键设计:
第一,异常被 catch,转成诊断消息,不让它传播。 因为 afterRun 是观察型——它崩溃了不能覆盖已经定好的业务终态。比如 Run 已经成功了(stopReason=complete),afterRun 里某个 Hook 抛异常,Run 的终态必须是"成功",不能变成"异常"。诊断消息通过 hookDiagnostic 事件发布出去,开发者能看到,但不影响 Run 结果。
第二,"每个 Run 终态恰好一次"由 Runtime 保证。 Runtime 用 ctx.afterRunDone 标志确保 fireAfterRun 只调用一次:
// runtime/AgentRuntime.ets
private async fireAfterRun(observer: StreamObserver<AgentEvent>,
ctx: ActiveRunContext): Promise<void> {
if (ctx.afterRunDone) {
return // ← 已执行,跳过
}
ctx.afterRunDone = true
const diagnostics = await this.hookPipeline.afterRun(...)
for (let i = 0; i < diagnostics.length; i++) {
this.publishController(new HookDiagnosticEvent(
this.config.sessionId, ctx.runId, 'afterRun', diagnostics[i]))
}
}
为什么强调"恰好一次"?因为 Run 的终态路径有多条——completeRun、cancelRun、failRun、loopDetected、chunkAbort 都可能触发终态。如果每条路径都调一次 afterRun,Hook 会被执行多次,埋点重复、审计错乱。Runtime 用 afterRunDone 标志兜底,保证不管走哪条路径,afterRun 只跑一次。
六、AgentController:零状态控制面
Controller 是外部世界(UI、跨 Ability、开发者代码)操作 Agent 的入口。它的核心约束是不持有 Runtime 可变状态。
6.1 设计动机:为什么 Controller 不能持有状态
假设 Controller 直接持有 Runtime 引用,能调 runtime.state.isRunning = false、能调 runtime.state.pendingApproval = undefined。会发生什么?
-
并发不可控:UI 线程通过 Controller 改状态,Runtime 主循环在另一个异步任务里读状态,竞态频发。
-
职责模糊:Controller 成了"第二个 Runtime",谁该改状态、谁该观察,边界消失。
-
不可测试:Controller 测试必须 mock 整个 Runtime,单测变集成测。
ArkAgent 的选择是:Controller 只做三件事——观察(list)、发布(publish)、路由审批(approve/deny)。它通过 ApprovalHost 接口单向桥接到 Runtime,绝不直接持有 Runtime 引用或改其状态。
6.2 源码:Controller 的精简结构
// controller/AgentController.ets
export class AgentController {
readonly eventBus: EventBus
private approvalHost?: ApprovalHost // ← Runtime 绑定,单向桥接
constructor(eventBus?: EventBus) {
this.eventBus = eventBus ?? new EventBus()
}
/** 由 Runtime 调用一次,把自己绑定为 ApprovalHost */
bindApprovalHost(host: ApprovalHost): void {
this.approvalHost = host
}
// —— 观察面:EventBus 代理 ——
publish(event: BusEvent): void {
this.eventBus.publish(event)
}
subscribe(eventType: string, listener: EventListener): () => void {
return this.eventBus.subscribe(eventType, listener)
}
subscribeAll(listener: EventListener): () => void {
return this.eventBus.subscribe('*', listener)
}
// —— 审批路由面:委托给 ApprovalHost ——
listPendingApprovals(): PendingApprovalItem[] {
const batch = this.approvalHost?.getPendingApprovalBatch()
if (batch === undefined) {
return []
}
const pending: PendingApprovalItem[] = []
for (let i = 0; i < batch.items.length; i++) {
if (batch.items[i].isPending()) {
pending.push(batch.items[i].clone()) // ← 返回 clone,不是引用
}
}
return pending
}
async approve(approvalId: string): Promise<ApprovalDecisionResult> {
if (this.approvalHost === undefined) {
return ApprovalDecisionResult.failure(approvalId, new ArkAgentError(
ErrorLayer.runtime, 'approval_host_missing',
'No Runtime bound for approval decisions', ErrorRetryability.never))
}
return this.approvalHost.applyApprovalDecision(approvalId, ApprovalDecision.approved)
}
async deny(approvalId: string, reason?: string): Promise<ApprovalDecisionResult> {
if (this.approvalHost === undefined) {
return ApprovalDecisionResult.failure(approvalId, new ArkAgentError(...))
}
return this.approvalHost.applyApprovalDecision(approvalId, ApprovalDecision.denied, reason)
}
}
四个设计要点:
第一,listPendingApprovals 返回的是 clone(),不是引用。 UI 拿到的是审批项的快照副本,改它不影响 Runtime 内部的真实状态。这是"零状态持有"的具体体现——Controller 不让外部代码碰到 Runtime 的活对象。
第二,approve/deny 委托给 approvalHost.applyApprovalDecision。 Controller 自己不"决定"审批生效,它把决策路由给 Runtime。Runtime 在 applyApprovalDecision 里检查 id 是否有效、是否已决策(幂等)、是否全部决策完毕要解冻。
第三,未绑定 ApprovalHost 时返回明确错误。 approval_host_missing 错误码 + ErrorRetryability.never(不可重试)。这告诉调用方"Controller 没连上 Runtime",不是"审批失败"。
第四,ApprovalHost 是接口,不是具体类。 Controller 依赖抽象,不依赖 Runtime 具体实现。理论上任何实现 ApprovalHost 的类都能接上(虽然实际只有 Runtime 实现)。
6.3 ApprovalHost 接口:单向桥接
export interface ApprovalHost {
getPendingApprovalBatch(): PendingApprovalBatch | undefined
applyApprovalDecision(approvalId: string, decision: ApprovalDecision,
denyReason?: string): Promise<ApprovalDecisionResult>
getPendingPlanReview(): PendingPlanReview | undefined
applyPlanReviewDecision(decision: PlanReviewDecision,
reason?: string): Promise<PlanReviewDecisionResult>
}
四个方法,两两配对:审批 batch(get + apply)、计划评审(get + apply)。接口由 Runtime 实现,Controller 持有。绑定关系是单向的——Runtime 创建 Controller 时调 controller.bindApprovalHost(this),Controller 之后能问 Runtime 要数据,但 Runtime 不持有 Controller 引用(Runtime 通过 publishController 发事件单向通知 Controller)。
6.4 ApprovalDecisionResult:幂等性的载体
export class ApprovalDecisionResult {
readonly ok: boolean
readonly approvalId: string
readonly decision: string
readonly allDecided: boolean // ← 整个 batch 是否都决策完毕
readonly error?: ArkAgentError
static success(approvalId: string, decision: string, allDecided: boolean): ApprovalDecisionResult {
return new ApprovalDecisionResult(true, approvalId, decision, allDecided)
}
static failure(approvalId: string, error: ArkAgentError): ApprovalDecisionResult {
return new ApprovalDecisionResult(false, approvalId, 'error', false, error)
}
}
approve/deny 是幂等的——对同一个 approvalId 多次调用是安全的。allDecided 字段告诉调用方"整个 batch 是不是都决策完了"——当 allDecided=true 时,Runtime 会自动解冻 batch,继续执行(approve 全部)或回填拒绝结果(任一 deny)。
阶段 5 报告明确记录了这条:"approve/deny(幂等)"。幂等性的实现要点是:Runtime 在 applyApprovalDecision 里先检查 item.decision === ApprovalDecision.pending,只有 pending 的才改,已决策的返回当前状态不报错。这样 UI 重试、网络抖动场景下都不会出问题。
七、EventBus:类型化事件总线
Controller 内部持有一个 EventBus,用于发布/订阅 Agent 事件。EventBus 是控制面和外部世界(UI、日志、埋点)的桥梁。
7.1 为什么用字符串 eventType
ArkTS 没有 reified generics(运行期泛型类型信息)。你没法写 EventBus<MyEvent> 然后用 MyEvent 的类型键来分发。ArkAgent 的方案是:每个事件类实现 eventType() 返回一个稳定的字符串类型键,EventBus 用字符串匹配。
// core/EventBus.ets
export abstract class BusEvent {
readonly eventId: string
readonly timestampMs: number
abstract eventType(): string // ← 子类返回类型键
}
// 集中管理类型键,避免拼写错误
export class ControllerEventTypes {
static readonly agentStarted: string = 'agent.started'
static readonly agentResumed: string = 'agent.resumed'
static readonly agentSuccess: string = 'agent.success'
static readonly beforeCallLlm: string = 'llm.before'
static readonly afterCallLlm: string = 'llm.after'
static readonly llmChunk: string = 'llm.chunk'
static readonly beforeToolCall: string = 'tool.before'
static readonly afterToolCall: string = 'tool.after'
static readonly approvalRequired: string = 'approval.required'
static readonly loopDetected: string = 'loop.detected'
static readonly hookDiagnostic: string = 'hook.diagnostic'
// ...
}
export class AgentStartedEvent extends BusEvent {
readonly sessionId: string
readonly runId: string
readonly input: AgentMessage[]
// ...
eventType(): string {
return ControllerEventTypes.agentStarted
}
}
ControllerEventTypes 把所有类型键集中成 static readonly string 常量,订阅时用常量而不是裸字符串:
// ✅ 正确:用常量,避免拼写错误
controller.subscribe(ControllerEventTypes.approvalRequired, (event) => {
const e = event as ApprovalRequiredEvent
showApprovalDialog(e.batch)
})
// ❌ 错误:裸字符串,容易拼错
controller.subscribe('approval.required', ...)
7.2 两种语义:publish(通知) vs request(请求)
EventBus 支持两种交互模式:
|
模式 |
签名 |
语义 |
异常处理 |
|---|---|---|---|
|
|
一对多通知 |
所有匹配监听器都收到 |
监听器异常隔离,不影响 publisher |
|
|
一对一请求 |
唯一注册的 handler 处理并返回值 |
handler 异常传播给 requester |
publish(event: BusEvent): void {
if (this.closed) return
const type = event.eventType()
const snapshot = this.listeners.slice() // ← 快照防并发修改
for (let i = 0; i < snapshot.length; i++) {
const entry = snapshot[i]
if (entry.eventType === '*' || entry.eventType === type) {
try {
entry.listener(event)
} catch (_e) {
// Observer isolation: never alter publisher / Agent behavior.
}
}
}
}
async request(event: BusEvent, defaultValue?: Object): Promise<Object | undefined> {
const handler = this.requestHandlers.get(event.eventType())
if (handler !== undefined) {
return await handler(event) // ← 异常传播
}
if (defaultValue !== undefined) {
return defaultValue
}
throw new ArkAgentError(ErrorLayer.config, 'request_handler_missing', ...)
}
为什么 publish 要异常隔离? 因为 publish 是"通知"——比如 Runtime 发 agent.started 事件,3 个监听器(UI 刷新、日志记录、埋点上报)订阅了。如果 UI 监听器抛异常,日志和埋点不应该跟着失败,更不能让 Runtime 因为"发事件失败"而崩。隔离是铁律。
为什么 request 要异常传播? 因为 request 是"请求"——调用方明确要一个返回值,handler 失败就是请求失败,必须让调用方知道。request 用于"需要双向通信"的场景,比如 Controller 查 Runtime 状态。
为什么 request handler 只能注册一个? 因为"请求"语义要求明确的返回值。如果多个 handler 都处理,谁的返回值算数?所以 registerRequestHandler 对重复注册抛 request_handler_duplicate 错误。
7.3 subscribe 返回取消订阅函数
subscribe(eventType: string, listener: EventListener): () => void {
if (this.closed) {
return (): void => { } // ← 关闭后返回 noop
}
const id = this.nextListenerId++
this.listeners.push(new ListenerEntry(id, eventType, listener))
return (): void => {
this.unsubscribe(id)
}
}
subscribe 返回一个 unsubscribe 函数——这是函数式资源管理的惯例。调用方拿到这个函数,在不需要监听时调用它(比如 ArkUI 组件 aboutToDisappear 时):
// ArkUI 页面
@Component
struct AgentMonitorPage {
private unsubscribe?: () => void
aboutToAppear() {
this.unsubscribe = controller.subscribe(
ControllerEventTypes.llmChunk,
(e) => this.appendText((e as LlmChunkEvent).text)
)
}
aboutToDisappear() {
this.unsubscribe?.() // ← 组件销毁时取消订阅
}
}
不取消订阅会导致内存泄漏——监听器闭包持有组件引用,组件销毁后仍被 EventBus 持有。鸿蒙 Ability 生命周期敏感,这点尤其重要。
八、可持久化审批:batch 冻结 + 重载续审
审批是控制面最复杂的场景。本节讲审批的完整生命周期——从 beforeToolCall defer 触发,到 batch 冻结、落盘、suspend,到 Controller 路由决策,到 Runtime 解冻继续。
8.1 审批的完整时间线
模型返回 batch:[call_A(dangerous), call_B(safe)]
↓
beforeToolCall Hook Pipeline(串行)
├── Hook1.proceed(call_A)
└── 审批策略 Hook:call_A 是 dangerous → defer ← 短路
↓
Runtime 冻结整个 batch(零提前副作用)
↓
构造 PendingApprovalBatch(含 call_A、call_B 的 approval item)
↓
autoSave(mandatory=true, 'awaiting_approval') ← 必须先成功落盘
↓
publishController(ApprovalRequiredEvent) ← 落盘成功后才发事件
↓
publishController(AgentSuspendedEvent) ← 进入 awaiting_approval 终态
↓
[用户切后台 / 杀进程 / 第二天重载]
↓
Controller.listPendingApprovals() → 返回 pending items
↓
用户点"批准 call_A"
↓
Controller.approve('approval_call_A')
↓
ApprovalHost.applyApprovalDecision (Runtime 实现)
├── 校验 id 有效
├── 设置 item.decision = approved
├── 检查 allDecided?
│ └── 否(call_B 还没决定)→ 返回 allDecided=false
↓
用户点"批准 call_B"
↓
Controller.approve('approval_call_B')
↓
applyApprovalDecision
├── 设置 item.decision = approved
├── allDecided=true → 解冻 batch
├── publishController(ApprovalResolvedEvent)
└── 继续 Run(执行 call_A、call_B)
五个关键设计:
第一,整 batch 冻结,零提前副作用。 batch 里只要有一个 dangerous 需审批,整个 batch 都冻结——不能"先执行 safe 的,等 dangerous 的审批"。因为 batch 内的工具可能有依赖关系,部分执行会导致状态不一致。阶段 5 报告明确记录:"batch 任一 defer → 整批冻结;先落盘再 approvalRequired + suspended"。
第二,先落盘再发事件。 必须等 autoSave(mandatory=true) 成功后,才能发 ApprovalRequiredEvent。否则会出现"事件发出去了 UI 弹了审批框,但用户切后台时落盘还没完成,第二天重载审批没了"。这条在 mandatory persist 一节展开。
第三,awaiting_approval 是 suspend 终态,不是 error。 suspend 意味着 Run 可恢复——重载后能继续审批。这和 cancel/complete/error 这些不可恢复终态区分开。
第四,Controller.approve/deny 幂等。 同一个 approvalId 多次 approve 安全——Runtime 检查 item.decision === pending 才改,已决策的返回当前状态不报错。UI 重试、网络抖动场景都安全。
第五,未知/已消费 id 返回明确错误。 approve 一个不存在的 id,或 approve 一个已经终态的 batch 的 id,Runtime 返回明确错误(不是静默成功)。这让 UI 能区分"审批成功""id 无效""batch 已过期"。
8.2 PendingApprovalBatch:可序列化的审批状态
// state/PendingApproval.ets
export class PendingApprovalItem {
readonly approvalId: string
readonly toolCall: ToolCall
readonly riskLevel: ToolRiskLevel
readonly reason: string
readonly createdAtMs: number
decision: ApprovalDecision // pending / approved / denied
denyReason?: string
decidedAtMs?: number
isPending(): boolean {
return this.decision === ApprovalDecision.pending
}
}
export class PendingApprovalBatch {
readonly batchId: string
readonly runId: string
readonly items: PendingApprovalItem[]
readonly modelMessage: AgentMessage // ← 模型发的 Tool Call 消息(resume 用)
readonly createdAtMs: number
allDecided(): boolean {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].isPending()) {
return false
}
}
return this.items.length > 0
}
}
PendingApprovalBatch 是可序列化的——它进入 AgentState.schemaVersion=2 的 pendingApproval 字段,被 StateStorage 落盘。重载时 Codec 把它从 JSON 还原成对象,Runtime 据此恢复审批流程。
关键:modelMessage 字段保存的是模型发的 Tool Call 消息。 为什么?因为 resume 时要"继续执行 batch 里的工具",而工具执行的上下文(模型发了哪些 Tool Call、call id 是什么)就在这条消息里。没有它,resume 后 Runtime 不知道要执行什么。
PendingApprovalItem 不存回调、不存 executor、不存密钥——它是纯数据。阶段 5 报告明确写了:"Never stores callbacks, executors, or secrets." 这保证了序列化安全——落盘的内容是可重建的纯数据,不含运行期对象。
8.3 ApprovalDecisionResult:决策结果的语义
Controller.approve/deny 返回 ApprovalDecisionResult,告诉调用方"这次调用发生了什么":
const result = await controller.approve('approval_call_A')
if (result.ok) {
console.log(`决策: ${result.decision}`)
if (result.allDecided) {
console.log('整个 batch 都决策完了,Runtime 会自动解冻继续')
} else {
console.log('还有未决策的 item,继续等待用户')
}
} else {
console.error(`失败: ${result.error?.code} - ${result.error?.message}`)
}
allDecided 字段让 UI 知道"是不是该关闭审批框"——只有 allDecided=true 时 Runtime 才会解冻,UI 才该收起审批界面。
九、终态持久化:mandatory 不可跳过
这是控制面最微妙的安全边界。beforePersistState Hook 允许业务跳过"自动保存"以减少写盘噪声,但安全/终态落盘绝不能被跳过——否则会出现"声称可恢复但实际没存"或"Run 已结束却还能 resume"的灾难性状态错乱。
9.1 mandatory 标志:Context 里的开关
// hook/AgentHook.ets
export class StatePersistenceHookContext extends HookContextBase {
readonly reason: string
readonly runError?: ArkAgentError
/** When true, skip is not honored (safety/terminal writes). */
readonly mandatory: boolean // ← 关键标志
constructor(sessionId: string, state: AgentState, reason: string,
mandatory: boolean = false, runError?: ArkAgentError) {
super(sessionId, state)
this.reason = reason
this.mandatory = mandatory
this.runError = runError
}
}
mandatory=true 时,即使 Hook 返回 skip,Pipeline 也忽略,继续执行落盘。ADR-0011 冻结了这条边界:
|
写入类型 |
|
reason 字段示例 |
|---|---|---|
|
普通自动保存 |
允许跳过 |
|
|
确认暂停(awaiting_approval) |
不可跳过 |
|
|
生命周期 suspend |
不可跳过 |
|
|
终态(cancel/complete/error/loop_detected/abort) |
不可跳过 |
|
9.2 Pipeline:mandatory 时忽略 skip
// hook/AgentHookPipeline.ets
async beforePersistState(context: StatePersistenceHookContext): Promise<StatePersistenceHookResult> {
for (let i = 0; i < this.hooks.length; i++) {
const result = await this.hooks[i].beforePersistState(context)
if (result.action === StatePersistenceHookAction.proceed) {
continue
}
// Mandatory safety/terminal writes ignore skip.
if (result.action === StatePersistenceHookAction.skip && context.mandatory) {
continue // ← mandatory 时 skip 被忽略
}
return result // abort 仍然生效(安全失败)
}
return StatePersistenceHookResult.proceed()
}
注意三个细节:
第一,mandatory 时只有 skip 被忽略,abort 仍然生效。 因为 abort 是"明确拒绝",可能是 Hook 检测到了真正的问题(比如状态不一致)。但 skip 只是"我觉得这次不用存",在 mandatory 场景下这种"觉得"不能凌驾于安全要求。
第二,proceed 永远继续到下一个 Hook。 多个 Hook 都 proceed,最终 proceed。
第三,mandatory 标志从 Context 传入,不是 Hook 自己声明。 Hook 不知道自己是在"普通保存"还是"终态保存"——这由 Runtime 在调用 Pipeline 时通过 mandatory 参数指定。Hook 只能看到 context.mandatory,决定要不要尊重 skip。
9.3 Runtime:调用处标注 mandatory
// runtime/AgentRuntime.ets
private async persistSafe(_observer: StreamObserver<AgentEvent>, ctx: ActiveRunContext,
phase: string, mandatory: boolean): Promise<void> {
// ...
await this.autoSave(mandatory, phase)
}
private async autoSave(mandatory: boolean = false, reason: string = 'auto'): Promise<boolean> {
// ...
const before = await this.hookPipeline.beforePersistState(
new StatePersistenceHookContext(
this.config.sessionId, this.state.snapshot(), reason, mandatory))
// ...
if (before.action === StatePersistenceHookAction.skip && !mandatory) {
this.publishController(new PersistEvent(this.config.sessionId, reason, true))
return true // ← 非 mandatory 的 skip 生效
}
// mandatory 时继续往下执行落盘
// ...
}
private async persistNonResumableTerminal(salvage: boolean = true): Promise<boolean> {
const saved = await this.autoSave(true, 'terminal') // ← mandatory=true
if (saved) {
return true
}
// terminal 落盘失败 → 标记 notResumable,绝不"声称可恢复"
if (!salvage || this.config.stateStorage === undefined) {
return false
}
try {
await this.config.stateStorage.markNotResumable(this.config.sessionId)
return false
} catch (markError) {
// ...
return false
}
}
persistNonResumableTerminal 是终态落盘的兜底——它用 mandatory=true 调 autoSave,如果还失败(比如磁盘满),调 markNotResumable 标记这个 session "不可恢复"。这保证了"宁可让用户重开,也不能让一个损坏的状态被恢复"。
ADR-0011 的原话:"确认暂停落盘失败不得报告可恢复。" 这是终态持久化的核心安全红线——先成功落盘,再声称可恢复。
9.4 终态触发的多条路径
Runtime 在多条路径都触发 mandatory persist:
// completeRun(正常完成)
private async completeRun(observer, ctx, reason: AgentStopReason): Promise<void> {
// ...
const fullSaved = await this.persistNonResumableTerminal(true)
// ...
}
// cancelRun(用户取消)
private async cancelRun(observer, ctx, error?): Promise<void> {
// ...
// 同样走 persistNonResumableTerminal
}
// failRun(异常)
private async failRun(observer, ctx, error): Promise<void> {
// ...
// 同样走 persistNonResumableTerminal
}
不管 Run 怎么结束——正常完成、用户取消、异常失败——都要走 mandatory 终态落盘。这保证了 Run 终态一定被持久化,不会被 skip 静默吞掉。
十、踩坑:三个真机翻车现场
下面三个踩坑全部来自阶段 5 报告第一轮验收的 P1 修正记录。它们都是"编译通过但运行时语义错乱"的典型——代码能跑,但行为和预期不符,在真机上才暴露。
10.1 ⚠️ 踩坑一:unknown tool 被误判为需要审批
症状:测试 tool_not_found 用例(模型调用一个没注册的工具)时,Run 没有按预期返回 tool_not_found 错误,反而进入了 awaiting_approval 状态——审批框弹出来,问用户"是否批准执行 unknown_tool"。用户一脸懵:这工具根本不存在,批准了能执行啥?
根因:默认审批策略的逻辑有缺陷。它对"未知工具"的处理是 requireConfirmation(需要确认),和"已注册的 dangerous 工具"走同一条审批路径。阶段 5 报告记录:
"现象:tool_not_found 用例进入 awaiting_approval;根因:unknown 被当成 requireConfirmation。"
策略代码大致是这样:
// ❌ 错误逻辑(示意)
function decidePolicy(call: ToolCall, registry: ToolRegistry): ToolApprovalPolicy {
const tool = registry.get(call.name)
if (tool === undefined) {
return ToolApprovalPolicy.requireConfirmation // ← unknown 当成需确认
}
if (tool.riskLevel === ToolRiskLevel.dangerous) {
return ToolApprovalPolicy.requireConfirmation
}
return ToolApprovalPolicy.allow
}
把"未注册"和"已注册危险"混为一谈,导致 unknown tool 也走审批。
修复:unknown 放行,交给 Registry 返回结构化错误:
// ✅ 正确逻辑
function decidePolicy(call: ToolCall, registry: ToolRegistry): ToolApprovalPolicy {
const tool = registry.get(call.name)
if (tool === undefined) {
return ToolApprovalPolicy.allow // ← unknown 放行
// Registry 执行时会返回 tool_not_found 结构化错误
}
if (tool.riskLevel === ToolRiskLevel.dangerous) {
return ToolApprovalPolicy.requireConfirmation
}
return ToolApprovalPolicy.allow
}
unknown 放行后,ToolRegistry.execute 发现工具没注册,返回 ToolResult(isError: true, "tool_not_found")。模型拿到这个错误,知道工具不存在,会换策略(比如告诉用户"这个功能我没有")。
预防:审批策略必须区分"未注册"和"已注册危险"两个概念。审批是给"存在但需要用户把关的工具"用的,不是给"根本不存在的工具"用的。给不存在的工具弹审批框,既误导用户又掩盖了"工具未注册"这个真正的问题。
10.2 ⚠️ 踩坑二:待确认期间新输入清空 pendingApproval
症状:模型请求执行一个 dangerous tool,审批框弹出,batch 冻结在 awaiting_approval。这时用户没点批准/拒绝,而是在输入框敲了条新消息"算了别执行了"。结果:pendingApproval 被清空了,但历史里留下了一条"模型发了 Tool Call"的 assistant message,没有对应的 Tool Result——Call/Result 配对断裂,下一次模型推理会因为历史不一致而行为异常。
根因:runStream 入口没有检查 pendingApproval 状态。新消息进来,Runtime 直接进入新一轮 Run,初始化阶段把 state.pendingApproval = undefined(清空),但没清理历史里那条未配对的 Tool Call。
// ❌ 错误:入口不检查,直接清空 pendingApproval
async runStream(messages: AgentMessage[], observer, signal) {
// ...
if (this.state.pendingApproval !== undefined) {
this.state.pendingApproval = undefined // ← 直接清空,留下未配对 Tool Call
}
// 继续新一轮 Run...
}
阶段 5 报告记录:
"现象:新消息清空 pendingApproval,留下未配对 Tool Call;方案:入口拒绝 pending_approval_blocks_input。"
修复:入口拒绝新输入,强制用户先处理完待审批的 batch:
// runtime/AgentRuntime.ets
async runStream(messages: AgentMessage[], observer, signal) {
// ...
// Pending tool approval freezes the batch: new user input must not clear it
// and leave unpaired assistant tool calls in history.
if (messages.length > 0 && this.state.pendingApproval !== undefined) {
const error = new ArkAgentError(
ErrorLayer.security, 'pending_approval_blocks_input',
'Cannot start a new run while tool approvals are pending; approve/deny and resume first',
ErrorRetryability.never)
// 发 runError 事件,observer.onError,throw
// ...
throw error
}
// 类似地,plan review pending 也阻止
if (messages.length > 0 && this.isAwaitingPlanApproval()) {
throw new ArkAgentError(
ErrorLayer.security, 'pending_plan_review_blocks_input', ...)
}
// ...
}
错误码 pending_approval_blocks_input(security 层,不可重试)明确告诉调用方:"你有待审批的 batch,必须先 approve/deny,不能发新消息。"
预防:任何会改 history 的路径,都必须先检查"是否有冻结的审批"。这是阶段 5 报告的实施心得:"任何会改 history 的路径先检查冻结审批。" 不只是 runStream——任何能修改 state.history 或 state.pendingApproval 的入口,都要先过这个检查。
10.3 ⚠️ 踩坑三:onModelChunk fire-and-forget 竞态
症状:流式输出期间,chunk Hook 决定 drop 某个 chunk(比如敏感词过滤),但用户屏幕上还是闪过了那个 chunk。更严重的是,chunk Hook 决定 abort(检测到 prompt injection),但 abort 信号比 modelComplete 事件还晚到——Run 已经"完成"了,abort 才生效,终态错乱。
根因:onModelChunk 一开始是 fire-and-forget 的——Runtime 在 onNext 里触发 Hook,但不 await 它。Hook 的决策(drop/abort)和流式事件的推进是并发的,没有顺序保证:
// ❌ 错误:fire-and-forget
onNext(event: StreamingEvent): void {
if (event.type === StreamingEventType.textDelta) {
this.parent.applyChunkHookPublic(...) // ← 没 await,异步执行
this.forwardStreamingEventPublic(...) // ← 同时转发原 chunk
}
}
async applyChunkHook(...) {
const result = await this.hookPipeline.onModelChunk(...)
if (result.action === ModelChunkHookAction.drop) {
return // ← 但 chunk 已经被转发出去了!
}
}
竞态时间线:
t1: chunk "敏感词" 到达
t2: onNext 触发 applyChunkHook(异步,未 await)
t3: onNext 同时转发原 chunk 给 observer → 用户看到"敏感词" ❌
t4: applyChunkHook 返回 drop → 但已经晚了
...
t10: modelComplete 事件到达
t11: onNext 转发 modelComplete
t12: applyChunkHook 的 abort 才返回 → 但 Run 已经 complete 了 ❌
阶段 5 报告记录:
"现象:fire-and-forget 导致 drop/abort 晚于 modelComplete;方案:ModelStreamCollector 串行 Promise chain。"
修复:用 ModelStreamCollector 把所有流式事件(包括 chunk Hook)串成一条 Promise chain,严格顺序执行:
// runtime/AgentRuntime.ets
class ModelStreamCollector implements StreamObserver<StreamingEvent> {
// Serial event chain: every streaming event (including chunk hooks) is processed
// in order. modelComplete / onComplete / onError wait for prior hooks so
// drop/rewrite/abort stay ordered relative to terminal model events.
private chain: Promise<void> = Promise.resolve()
onNext(event: StreamingEvent): void {
this.chain = this.chain.then((): Promise<void> => this.handleEvent(event))
}
onError(error: ArkAgentError): void {
this.chain = this.chain.then((): Promise<void> => {
if (!this.done) {
this.streamError = error
this.finish()
}
return Promise.resolve()
})
}
onComplete(): void {
this.chain = this.chain.then((): Promise<void> => {
this.finish()
return Promise.resolve()
})
}
private async handleEvent(event: StreamingEvent): Promise<void> {
if (this.done || this.chunkAborted) {
return
}
// ...
if (event.type === StreamingEventType.textDelta ||
event.type === StreamingEventType.reasoningDelta) {
// ← 严格 await Hook 决策,任何后续事件(含 modelComplete)都等它完成
const abortErr = await this.parent.applyChunkHookPublic(
this.agentObserver, this.ctx, event)
if (abortErr !== undefined) {
this.chunkAborted = true
this.streamError = abortErr
this.finish()
}
return
}
// 其他事件类型转发
this.parent.forwardStreamingEventPublic(this.agentObserver, this.ctx, event)
if (event.type === StreamingEventType.modelComplete && event.response !== undefined) {
this.completeResponse = event.response
}
}
}
关键设计:每个事件都 append 到 chain 的末尾——this.chain = this.chain.then(...)。这样所有事件严格按到达顺序串行处理,chunk Hook 的 drop/abort 一定先于 modelComplete 生效。
修复后的时间线:
t1: chunk "敏感词" 到达 → 加入 chain
t2: modelComplete 到达 → 加入 chain(排在 chunk 处理之后)
t3: chain 执行:处理 chunk,await Hook → Hook 返回 drop → 不转发
t4: chain 执行:处理 modelComplete → 但此时 chunkAborted=true,finish() 已调用
→ modelComplete 不再生效,Run 进入 aborted 终态 ✓
chunk abort 的终态是 hook_abort(security 层),不是用户取消。阶段 5 报告明确:"chunk abort → hook_abort + AgentStopReason.aborted(非用户取消)。" 这个区分让 UI 知道"是安全策略中止的,不是用户主动取消的",展示不同的提示。
预防:流式观察者禁止在 onNext 内启动"不 await 的业务 Hook"。所有改写决策必须和流式事件共享同一条串行链。这条经验不只适用于 chunk Hook——任何流式场景(音视频流、传感器流)的干预逻辑都该这么做。
十一、错误码分层:让终态可区分
控制面涉及多种终态,每种终态的语义、UI 提示、是否可恢复都不同。ArkAgent 用错误码 + 层级(layer)做严格区分,ADR-0011 冻结了这张表:
|
场景 |
code |
layer |
是否可恢复 |
UI 提示 |
|---|---|---|---|---|
|
Hook abort(安全策略中止) |
|
security |
否(Run 已结束) |
"安全策略阻止了此次操作" |
|
用户拒绝 Tool(合成结果) |
|
security |
否(结果已回填) |
不提示(模型自己处理) |
|
等待确认(suspend) |
|
security |
是(可恢复) |
审批框 |
|
循环检测 |
|
runtime |
否(Run 已结束) |
"Agent 陷入循环,已停止" |
|
用户取消 |
|
cancelled |
否(Run 已结束) |
"已取消" |
|
Provider 错误 |
provider 映射 |
provider/transport |
视情况 |
"模型服务异常" |
|
落盘失败 |
|
storage |
视情况 |
"状态保存失败" |
|
待确认阻输入 |
|
security |
是(先处理审批) |
"有待审批操作,请先处理" |
为什么 suspend(awaiting_approval)和 cancel 都用 security/cancelled 层,但语义相反? 因为它们的 stopReason 不同。suspend 的 stopReason 是 awaiting_approval(可恢复),cancel 的 stopReason 是 cancelled(不可恢复)。stopReason 和 code 配合,让 UI 能精确判断"这个终态意味着什么"。
为什么 chunk abort 用 security 层的 hook_abort,不用 cancelled? 因为 chunk abort 是"安全策略中止"(Hook 决定的),不是"用户主动取消"。如果用 cancelled,UI 会显示"已取消",误导用户以为是自己的操作。用 hook_abort,UI 显示"安全策略阻止",语义准确。
错误码分层是控制面可观察性的基础。没有这层区分,UI 只能拿到一个"Run 失败了",没法告诉用户"为什么失败、要不要重试、要不要处理审批"。
十二、最佳实践清单
12.1 Hook SPI 设计
-
✅ 每个决策型槽位返回
*HookResult(带 action 枚举),不返回布尔。 -
✅ action 用静态工厂方法构造(
ToolCallHookResult.deny(...)),不直接 new。 -
✅ 默认实现全 pass-through,开发者只重写关心的槽位。
-
✅ Context 是 DTO 快照(
state是 snapshot),不是 Runtime 活引用。 -
✅ 改写用
copyWith返回新 Context,不 mutate 原对象。 -
✅ 观察型 Hook(afterPersistState/afterRun)异常被 Pipeline 隔离,不覆盖业务终态。
-
❌ 不要在 Hook 里持有 Runtime 引用或直接改其状态。
-
❌ 不要用对象字面量实现 Hook(
arkts-no-untyped-obj-literals),用显式 class extends AgentHook。 -
❌ 不要让 Hook 方法同步阻塞(必须 async,支持 await 远程查询)。
12.2 Pipeline 执行
-
✅ 严格按注册顺序串行执行,每个 Hook 都 await。
-
✅ 改写结果传递给下一个 Hook(A 改写 → B 看到改写后的)。
-
✅ 第一个短路决策(abort/deny/defer/stop/respond/retry/drop/continue)立即生效。
-
✅ 工具改写强制保 id(
preserveToolCallId)。 -
✅ afterToolCall 的 injectedMessages 按 Hook 顺序累加。
-
✅ afterRun 每个 Run 终态恰好执行一次(用
afterRunDone标志兜底)。 -
❌ 不要用
Promise.all并行执行 Hook(破坏改写传递语义)。 -
❌ 不要让后续 Hook 能"取消"前一个 Hook 的 abort(安全优先)。
-
❌ 不要在 onNext 里 fire-and-forget 触发 Hook(竞态,见踩坑三)。
12.3 Controller 控制面
-
✅ Controller 不持有 Runtime 可变状态,只观察、发布、路由审批。
-
✅ 通过
ApprovalHost接口单向桥接 Runtime(bindApprovalHost)。 -
✅ listPendingApprovals 返回 clone,不是引用。
-
✅ approve/deny 幂等(重复调用安全)。
-
✅ 未绑定 ApprovalHost 时返回
approval_host_missing(never retryable)。 -
❌ 不要让 Controller 直接改
runtime.state.*。 -
❌ 不要让 Controller 持有 Runtime 的 ToolRegistry 或 Provider 引用。
12.4 EventBus
-
✅ 用
ControllerEventTypes的常量订阅,不用裸字符串。 -
✅ 监听器异常必须隔离,不影响 publisher 和其他监听器。
-
✅ request 模式异常传播给 requester(让调用方知道失败)。
-
✅ request handler 只能注册一个(重复注册抛
request_handler_duplicate)。 -
✅ subscribe 返回的 unsubscribe 函数,在组件销毁时调用(避免泄漏)。
-
❌ 不要在 publish 的监听器里抛异常期望被捕获(语义不清,用 request 模式)。
-
❌ 不要让监听器同步阻塞(虽然允许,但会拖慢 publisher)。
12.5 审批与终态持久化
-
✅ 整 batch 冻结——一个 dangerous,全 batch 等待(零提前副作用)。
-
✅ 先落盘(mandatory=true)再发
ApprovalRequiredEvent。 -
✅ awaiting_approval 是可恢复 suspend 终态,不是 error。
-
✅ 重载后能续审(PendingApproval 进入 AgentState schemaVersion=2)。
-
✅ 未知/已消费 approvalId 返回明确错误,不静默成功。
-
✅ 终态落盘用
mandatory=true,skip 被忽略。 -
✅ terminal 落盘失败时调
markNotResumable,绝不"声称可恢复"。 -
❌ 不要"先执行部分副作用,等其余审批"。
-
❌ 不要让业务 Hook 的 skip 跳过终态/awaiting_approval/suspend 落盘。
-
❌ 不要在 runStream 入口直接清空 pendingApproval(用
pending_approval_blocks_input阻止新输入)。
12.6 流式 chunk Hook
-
✅ onModelChunk 严格串行 await(ModelStreamCollector Promise chain)。
-
✅ drop/rewrite/abort 决策必须先于 modelComplete 生效。
-
✅ chunk abort 的终态是
hook_abort(security 层),不是用户取消。 -
❌ 不要在 onNext 内启动不 await 的业务 Hook。
-
❌ 不要用 afterModelCall 代替 chunk 语义(chunk 是逐 token 干预,afterModelCall 是整段)。
十三、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
Controller 直接改 |
并发不可控、职责模糊 |
通过 ApprovalHost 单向桥接,Controller 不持有状态 |
|
Hook 用对象字面量实现 |
|
显式 class extends AgentHook |
|
Hook 返回布尔表示"是否继续" |
无法表达 deny/defer/stop 等细语义 |
返回 |
|
Pipeline 用 |
改写传递失效,顺序依赖断裂 |
for 循环严格 await 串行 |
|
Hook 改写工具时改了 call id |
Tool Result 对不上 Call,历史断裂 |
|
|
afterToolCall 的 injectedMessages 覆盖 |
丢失前面 Hook 注入的消息 |
按 Hook 顺序累加 push |
|
afterRun 抛异常覆盖业务终态 |
成功的 Run 变成失败 |
异常转诊断消息,不传播 |
|
afterRun 被多条终态路径重复触发 |
埋点重复、审计错乱 |
|
|
Controller.listPendingApprovals 返回引用 |
外部改它影响 Runtime 内部状态 |
返回 |
|
approve/deny 非幂等 |
UI 重试时多次决策错乱 |
检查 pending 才改,已决策返回当前状态 |
|
EventBus publish 监听器异常传播 |
一个监听器崩溃拖垮全部 |
try/catch 隔离,绝不影响 publisher |
|
EventBus 用裸字符串订阅 |
拼写错误难排查 |
用 |
|
多个 request handler 注册同一事件类型 |
返回值冲突 |
重复注册抛 |
|
unknown tool 触发审批 |
给不存在的工具弹审批框(踩坑一) |
unknown 放行,交给 Registry 报 |
|
待确认期间入口直接清空 pendingApproval |
留下未配对 Tool Call(踩坑二) |
入口拒绝 |
|
onModelChunk fire-and-forget |
drop/abort 晚于 modelComplete(踩坑三) |
ModelStreamCollector 串行 Promise chain |
|
业务 Hook 的 skip 跳过终态落盘 |
终态丢失,状态错乱 |
|
|
terminal 落盘失败仍声称可恢复 |
重载一个损坏的状态 |
调 |
|
chunk abort 用 cancelled 错误码 |
UI 误导为"用户取消" |
用 |
|
流式 chunk 与 modelComplete 不在同一条 chain |
顺序不确定,竞态 |
共享 ModelStreamCollector 的 chain |
|
组件销毁不取消 EventBus 订阅 |
内存泄漏 |
subscribe 返回的函数在 aboutToDisappear 调用 |
十四、验证清单(真机 Review)
Hook SPI 与 Pipeline
-
自定义 Hook 用显式 class extends AgentHook
-
每个决策型槽位返回正确的 action(proceed/deny/defer/abort)
-
Context 是快照,改写用 copyWith
-
多个 Hook 按注册顺序串行执行
-
改写结果传递给下一个 Hook(A 改写 → B 看到改写后的)
-
第一个短路(abort/deny/defer/stop)立即生效,后续 Hook 不执行
-
工具改写保留原始 call id
-
afterToolCall 的 injectedMessages 按 Hook 顺序累加
-
afterRun 每个 Run 终态恰好执行一次
-
afterRun 异常转诊断消息,不覆盖业务终态
AgentController
-
Controller 不持有 Runtime 可变状态
-
bindApprovalHost 单向桥接
-
listPendingApprovals 返回 clone
-
approve/deny 幂等(重复调用安全)
-
未绑定 Host 时返回 approval_host_missing
-
ApprovalDecisionResult.allDecided 字段正确反映 batch 状态
EventBus
-
用 ControllerEventTypes 常量订阅
-
publish 监听器异常被隔离,不影响 publisher
-
request 异常传播给 requester
-
重复注册 request handler 抛错
-
组件销毁时取消订阅(无泄漏)
审批与可恢复
-
dangerous 工具触发 ApprovalRequiredEvent
-
整 batch 冻结(零提前副作用)
-
先落盘(mandatory)再发审批事件
-
awaiting_approval 是可恢复 suspend 终态
-
杀进程后重载,PendingApproval 恢复
-
重载后能继续 approve/deny
-
未知/已消费 approvalId 返回明确错误
-
unknown tool 不触发审批(放行给 Registry)
-
待确认期间新输入被
pending_approval_blocks_input拒绝
流式 chunk Hook
-
onModelChunk 严格串行(ModelStreamCollector chain)
-
drop 决策生效,用户看不到被 drop 的 chunk
-
chunk abort 先于 modelComplete 生效
-
chunk abort 终态是 hook_abort(非 cancelled)
终态持久化
-
终态落盘 mandatory=true,业务 Hook 的 skip 被忽略
-
普通自动保存可被 skip(减少噪声)
-
terminal 落盘失败时调 markNotResumable
-
completeRun/cancelRun/failRun 都触发 mandatory 持久化
-
错误码分层正确(hook_abort/tool_denied/awaiting_approval/loop_detected/cancelled)
十五、构建验证
# 构建 agent_core HAR
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemon
# 运行控制面相关单元测试
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw test --no-daemon
# 构建 entry HAP(真机验证)
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --no-daemon
构建结果(阶段 5 报告,2026-07-12):
CompileArkTS passed
assembleHar passed
test passed: 167 pass / 0 fail / 0 error
assembleHap passed
BUILD SUCCESSFUL
(Hvigor 提示非阻塞的版本校验警告,记为阶段 9 发布前技术债)
控制安全体系的测试覆盖(来自阶段 5 交付物):
|
测试文件 |
覆盖范围 |
|---|---|
|
|
subscribe/unsubscribe、publish 异常隔离、request 异常传播、关闭语义 |
|
|
串行执行、改写传递、短路优先级、preserveToolCallId、injectedMessages 累加、afterRun 恰好一次 |
|
|
危险 Tool 确认/拒绝/重载、batch 冻结、unknown tool 放行、pending_approval_blocks_input |
|
|
工具签名检测、规范化 JSON、滑动窗口、LLM 诊断降级 |
|
|
respond/retry/drop/chunk abort/串行改写/defer/stop/continue/persist skip/确认期间阻止输入(端到端集成) |
真机验收场景(人工,2026-07-12):
场景一:危险 Tool 确认 → 杀进程 → 重载 → 批准/拒绝
结果:通过(entry 危险 Tool 模式)
场景二:智谱 LLM 循环诊断
结果:通过(entry 循环诊断模式 + 智谱 Provider)
场景三:DeepSeek LLM 循环诊断
结果:通过(entry 循环诊断模式 + DeepSeek Provider)
十六、写在最后
控制与安全是 Agent 从"能跑"到"能用"的分水岭。在鸿蒙上做好这件事,关键不是堆功能,而是把"业务主路径"和"控制面"显式分层,并在它们之间定义清晰的契约。ArkAgent 的做法是:Runtime 独占状态所有权,Controller 零状态只观察和路由,Hook Pipeline 作为主路径的关卡但严格串行短路,EventBus 作为旁路观察但异常隔离,终态落盘用 mandatory 标志守住安全边界。
整个体系的执行链路:
Run 开始
↓
beforeRun Hook(可改写输入/abort)
↓
[Agent Loop]
├── beforeModelCall(可改写请求/直接响应/abort)
├── onModelChunk(串行,可 drop/rewrite/abort)
├── afterModelCall(可改写/重试/abort)
├── beforeToolCall(可改写/deny 注入合成结果/defer 冻结/abort)
├── ToolRegistry.execute(四道关卡)
├── afterToolCall(可改写/stop 终止/注入消息/abort)
└── onTurnCompletion(accept/continue/abort)
↓
beforePersistState(proceed/skip[mandatory时忽略]/abort)
↓
[终态:complete/cancel/error/loop_detected/abort/awaiting_approval]
↓
afterPersistState(观察,异常隔离)
↓
afterRun(每个终态恰好一次,异常转诊断)
记住这几条口诀:
Controller 不持状,观察发布路由审。 Hook 串行不并行,改写传递短路赢。 工具改写保 id,配对断裂历史崩。 chunk 决策须 await,drop 先于 complete 赢。 终态落盘 mandatory,skip 静默永不听。 先成功再称可恢复,安全红线一条径。
更多推荐



所有评论(0)