一、前言:Agent 控制面的"六个难题"

假设你已经按前几篇博客把对话、流式输出、工具调用跑通了。现在你要把这个 Agent 嵌进一个真正的鸿蒙 App,给用户用。上真机后你会立刻撞上六个难题:

  1. 怎么在不动 Runtime 主路径的情况下加埋点、审计、风控? 你想加"每次模型调用前记录 token 数""每次工具调用前校验权限""每次流式 chunk 时过滤敏感词"。直接改 Runtime 代码会把主路径越改越脏;用全局回调又没法保证执行顺序和短路语义。

  2. 多个 Hook 谁先谁后? 安全 Hook 决定 abort,业务 Hook 想改写参数,审计 Hook 想记录结果——三个 Hook 的执行顺序、改写传递、短路优先级怎么定?并行执行能保证"第一个改写了参数,第二个看到的是改写后的"吗?

  3. 流式 chunk 期间 Hook 决定 drop 或 abort,怎么保证不晚于 modelComplete? 模型流式吐字是异步的,如果 Hook 是 fire-and-forget,drop 决策可能比 modelComplete 事件还晚到——用户已经看到被 drop 的字符了。

  4. 危险工具审批弹出来了,用户切后台被系统回收,怎么续? Ability 生命周期不可控,你必须在用户离开前把"待审批状态"落盘,重载时能恢复。但落盘时机错了——比如先发审批事件再落盘——就会"声称可恢复但实际没存"。

  5. 终态(cancel/complete/error/loop_detected)的持久化能不能被 Hook 跳过? 业务 Hook 觉得"自动保存太频繁了想跳过",但如果它把"终态落盘"也跳过了,下一次重载就会看到一个明明已经结束却还能 resume 的 Run,状态彻底错乱。

  6. 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.historystate 字段虽然在类型上是 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 集合。下面用最复杂的两个槽位——beforeToolCallafterToolCall——拆解。

4.1 ToolCallHookResult:四种 action

beforeToolCall 是工具安全的核心关卡,action 集合最丰富:

action

含义

携带数据

效果

proceed

放行

可选改写后的 call

工具正常执行(用改写后的 call)

deny

拒绝

可选 syntheticResult

不执行工具,把合成结果回填给模型

defer

推迟

reason

冻结 batch,进入 awaiting_approval

abort

中止

可选 error

立即终止 Run,终态 aborted

源码:

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

含义

效果

proceed

放行

结果进入历史(可用改写后的 result)

stop

停止

结果进入历史,但 Agent 循环终止

abort

中止

立即终止 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 支持两种交互模式:

模式

签名

语义

异常处理

publish(event)

一对多通知

所有匹配监听器都收到

监听器异常隔离,不影响 publisher

request(event)

一对一请求

唯一注册的 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=2pendingApproval 字段,被 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 冻结了这条边界:

写入类型

beforePersistState.skip

reason 字段示例

普通自动保存

允许跳过

after_user_input / after_model_text / after_tool_batch

确认暂停(awaiting_approval)

不可跳过

awaiting_approval

生命周期 suspend

不可跳过

suspend

终态(cancel/complete/error/loop_detected/abort)

不可跳过

terminal

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.historystate.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(安全策略中止)

hook_abort

security

否(Run 已结束)

"安全策略阻止了此次操作"

用户拒绝 Tool(合成结果)

tool_denied

security

否(结果已回填)

不提示(模型自己处理)

等待确认(suspend)

awaiting_approval

security

是(可恢复)

审批框

循环检测

loop_detected

runtime

否(Run 已结束)

"Agent 陷入循环,已停止"

用户取消

cancelled

cancelled

否(Run 已结束)

"已取消"

Provider 错误

provider 映射

provider/transport

视情况

"模型服务异常"

落盘失败

state_save_failed

storage

视情况

"状态保存失败"

待确认阻输入

pending_approval_blocks_input

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 直接改 runtime.state.isRunning

并发不可控、职责模糊

通过 ApprovalHost 单向桥接,Controller 不持有状态

Hook 用对象字面量实现

arkts-no-untyped-obj-literals 报错

显式 class extends AgentHook

Hook 返回布尔表示"是否继续"

无法表达 deny/defer/stop 等细语义

返回 *HookResult 带 action 枚举

Pipeline 用 Promise.all 并行执行 Hook

改写传递失效,顺序依赖断裂

for 循环严格 await 串行

Hook 改写工具时改了 call id

Tool Result 对不上 Call,历史断裂

preserveToolCallId 保留原始 id

afterToolCall 的 injectedMessages 覆盖

丢失前面 Hook 注入的消息

按 Hook 顺序累加 push

afterRun 抛异常覆盖业务终态

成功的 Run 变成失败

异常转诊断消息,不传播

afterRun 被多条终态路径重复触发

埋点重复、审计错乱

afterRunDone 标志保证恰好一次

Controller.listPendingApprovals 返回引用

外部改它影响 Runtime 内部状态

返回 clone()

approve/deny 非幂等

UI 重试时多次决策错乱

检查 pending 才改,已决策返回当前状态

EventBus publish 监听器异常传播

一个监听器崩溃拖垮全部

try/catch 隔离,绝不影响 publisher

EventBus 用裸字符串订阅

拼写错误难排查

ControllerEventTypes 常量

多个 request handler 注册同一事件类型

返回值冲突

重复注册抛 request_handler_duplicate

unknown tool 触发审批

给不存在的工具弹审批框(踩坑一)

unknown 放行,交给 Registry 报 tool_not_found

待确认期间入口直接清空 pendingApproval

留下未配对 Tool Call(踩坑二)

入口拒绝 pending_approval_blocks_input

onModelChunk fire-and-forget

drop/abort 晚于 modelComplete(踩坑三)

ModelStreamCollector 串行 Promise chain

业务 Hook 的 skip 跳过终态落盘

终态丢失,状态错乱

mandatory=true 时 Pipeline 忽略 skip

terminal 落盘失败仍声称可恢复

重载一个损坏的状态

markNotResumable,绝不假承诺

chunk abort 用 cancelled 错误码

UI 误导为"用户取消"

hook_abort(security 层)

流式 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 交付物):

测试文件

覆盖范围

EventBus.test.ets

subscribe/unsubscribe、publish 异常隔离、request 异常传播、关闭语义

HookPipeline.test.ets

串行执行、改写传递、短路优先级、preserveToolCallId、injectedMessages 累加、afterRun 恰好一次

ControlSafety.test.ets

危险 Tool 确认/拒绝/重载、batch 冻结、unknown tool 放行、pending_approval_blocks_input

LoopDetector.test.ets

工具签名检测、规范化 JSON、滑动窗口、LLM 诊断降级

HookRuntime.test.ets

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 静默永不听。 先成功再称可恢复,安全红线一条径。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐