跳转至

08 · Agent 识别与状态仲裁

Herdr 要回答两个不同问题:这个终端里运行的是哪个 Agent?它现在在工作、等待用户,还是空闲? 前者主要依赖进程身份,后者需要综合屏幕、OSC 和官方 integration hook。把它们混成一条正则,会在 wrapper、全屏重绘和迟到事件下迅速失效。

8.1 原始状态模型

AgentState 只有四个原始值:

状态 含义 典型证据
Idle 可接收新任务/没有活跃工作 prompt、idle OSC、hook stop
Working 正在推理、执行或输出 spinner、working OSC、hook start
Blocked 等待用户授权/选择/输入 permission form、Action Required、hook blocked
Unknown 证据不足或界面不适合更新 transcript/menu/未知 Agent

UI 中的 Done 不是第五个 raw state。它由 TerminalState 已回到 Idle,而关联 PaneState.seen == false 推导,表示“工作结束且用户还没看”。这避免把视图未读信息写回 Agent 生命周期。

8.2 四类证据

flowchart TB
    P["进程组 / 进程树"] --> I["Agent identity"]
    S["VT 底部近期内容"] --> M["manifest rule engine"]
    O["OSC title / progress"] --> M
    H["official integration hook"] --> A["TerminalState arbiter"]
    I --> A
    M --> A
    X["sequence / generation / recency"] --> A
    A --> E["effective AgentState"]
    E --> UI["badge / API / events"]
证据 优势 弱点
进程树 不依赖 UI 文案,适合识别 Agent wrapper 多、进程名可泛化、难判断状态
屏幕文本 能看见真实权限提示 文案/语言变化、transcript 残留、viewport 干扰
OSC 结构化、低成本、比 cell 文本稳定 不是所有 Agent 都发,可能残留
integration hook 最接近 Agent 内部生命周期 需安装、报告可能迟到、不同集成完整度不同

架构重点不是选出“唯一最好来源”,而是明确谁在什么条件下有权覆盖谁。

8.3 Agent 身份识别

Agent 在固定快照中有 21 个变体。识别入口会查看终端前台 process group/job,并沿进程树处理常见包装:

  • shell 启动脚本;
  • node / bun;
  • Python entrypoint;
  • cmd.exe / PowerShell;
  • package manager 或 shim;
  • Agent 自己派生的工具子进程。

identify_agent_in_job 的核心意图是识别“这组前台工作属于哪个 Agent”,而不是只比较最末进程 basename。Agent 执行 cargo 时,前台叶子可能是编译器,但终端仍属于该 Agent 会话。

8.4 检测文本不等于用户视口

屏幕规则读取 VT 底部的近期内容,而不是用户当前 scroll viewport。原因可用一个场景说明:

  1. Claude 曾显示权限提示;
  2. 用户回答后 Agent 继续工作;
  3. 用户向上滚回旧提示;
  4. 若 detector 读 viewport,会重新判为 Blocked。

检测源应跟随 buffer 的最新底部,并用专门区域函数聚焦当前 prompt/form。用户浏览历史只影响呈现,不影响 Agent 真相。

8.5 Manifest 规则引擎

19 个 Agent 的 screen detection 规则放在 src/detect/manifests/,由 src/detect/manifest.rs 解析、编译和求值。

规则形状包括:

[[rules]]
id = "live_blocked_form"
state = "blocked"
priority = 980
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["esc to cancel"]
any = [
  { contains = ["enter to confirm"] },
  { contains = ["enter to select"] }
]

这是对 Claude manifest 中规则结构的短摘;完整实现以固定源码为准。

Rule 字段

字段 作用
id 可解释、可测试的稳定规则名
state 匹配时的 raw state
priority 多规则同时命中时排序
region 从输入中先裁出相关区域
all / any / not 组合 gate
contains 字面包含条件
regex 区域正则
line_regex 逐行正则,降低跨行误判
visible_* 标记证据来自当前可见强信号
skip_state_update 识别到特殊 UI,但不改已有状态

解析使用 deny_unknown_fields,能把拼错字段变成加载错误,而不是静默失效。

8.6 Region 是准确率的关键

规则并不总扫整个 history。常见 region 包括:

  • whole_recent;
  • bottom_non_empty_lines(n);
  • after_last_horizontal_rule;
  • after_last_prompt_marker;
  • prompt_box_body;
  • osc_title;
  • osc_progress。

Region 先把“可能仍活跃的 UI 部分”切出来,再跑条件。这样可以区分:

  • 对话历史里出现“Do you want to proceed?”;
  • 当前底部真的有可选 Yes/No form。

规则越靠近当前交互区域,越适合设置 visible_blocker 这种强证据标志。

8.7 Priority 与负条件

同一屏幕可能同时含有工作 spinner、旧 permission 文本和当前 transcript menu。优先级让更具体的当前 UI 赢过泛化 fallback。

以 Codex 为例:

  1. OSC title Action Required 以高优先级判 Blocked;
  2. OSC spinner 以略低优先级判 Working;
  3. transcript viewer 命中后 skip_state_update;
  4. 当前强 blocker 再判 Blocked;
  5. 屏幕 working fallback;
  6. 普通非 spinner OSC title 最后作为 Idle 证据。

not 条件同样重要。一个 idle prompt 规则通常排除“enter to select”“esc to cancel”等 form 文本,防止光看到 prompt glyph 就覆盖 blocker。

8.8 skip_state_update 为什么不是 Unknown

transcript viewer、model picker 等界面往往不能从静态文本判断 Agent 生命周期。若直接返回 Unknown 并覆盖现有 Working/Blocked,UI 会因为用户打开辅助界面而抖动。

skip_state_update 表达更精确的语义:

“我确认看见了一个不应参与状态判断的界面,因此保留之前的有效状态。”

manifest 校验要求使用它的规则 state 为 unknown,且不能同时声明 visible idle/blocker/working,防止规则自相矛盾。

8.9 可解释输出

规则引擎不仅返回 state,还能产生 DetectionExplain/matched rule/evidence:

  • 使用了哪个 manifest/source/version;
  • 哪条 rule 命中;
  • region 内容和 gate 结果;
  • priority;
  • visible flags;
  • 是否 skip update。

这对维护检测规则极其重要。没有 explain,误判只能靠用户截图猜;有 explain,测试和 bug report 可以直接定位具体规则与证据。

8.10 Hook 来源的完整度不同

官方 integration 会主动报告 session 和状态,但并非所有来源都提供完整 lifecycle。固定快照的仲裁逻辑区分:

  • full-lifecycle authority:例如 Herdr Pi、OMP、MastraCode、OpenCode、Kilo、Kimi 等来源,在对应进程存活时对状态有较强权威;
  • session-only/partial:例如某些 Hermes、Antigravity 路径主要提供 session 身份,状态仍交给 screen detection。

“装了 integration”不自动等于“永远信 hook”。权威取决于 source capability、当前 Agent identity、会话是否仍活跃和报告是否新鲜。

8.11 TerminalState 的仲裁层

状态合并主要落在大型 TerminalState。可把它理解为保存:

screen fallback state + visible evidence timestamps
hook-reported state + source + sequence + session generation
detected agent + process liveness
effective state + revision

recompute_effective_state 的决策可概括为:

flowchart TD
    Start["recompute"] --> VB{"近期 visible blocker 可覆盖?"}
    VB -->|是| Blocked["Blocked"]
    VB -->|否| Hook{"存在有效 hook authority?"}
    Hook -->|是| HookState["采用 hook state"]
    Hook -->|否| Fallback["采用 screen/process fallback"]
    Blocked --> Rev["若变化则 bump revision + event"]
    HookState --> Rev
    Fallback --> Rev

但“visible blocker 优先”有边界:它可以纠正非完整 lifecycle hook 漏报的当前权限框;对完整 lifecycle authority 则不能随意用屏幕残留覆盖。源码用 source capability 与 recency 条件细化这一点。

8.12 Sequence 拒绝迟到报告

假设 hook 快速报告:

seq 41: Working
seq 42: Blocked
seq 43: Working

线程/IPC 调度可能让 seq 42 最后到达。若只按到达时间写 state,Agent 会错误停在 Blocked。保存 last sequence 后,只接受更大的同源报告即可拒绝旧事件。

注意 sequence 只能在相同 source/session 作用域比较;不同 Agent session 的 counter 可能重置。

8.13 Session generation 防止“进程复活”

更危险的竞态是旧进程退出后,迟到 hook 到达:

sequenceDiagram
    participant A1 as Agent generation 7
    participant T as TerminalState
    participant A2 as New shell / generation 8

    A1->>T: Working(seq 10, gen 7)
    A1--xT: process exits
    T->>T: advance/suppress generation 7
    A2->>T: Idle/process identity gen 8
    A1-->>T: late Blocked(seq 11, gen 7)
    T-->>A1: reject stale generation

只比较 seq 不够,因为旧 generation 的 11 可能大于新会话初始 seq。身份 + generation 是正确的复合新鲜度证明。

8.14 Visible evidence 的时效

visible_blocker 表示当前检测区域明确存在等待用户的 UI,但 VT 历史不会自动忘记所有文本。仲裁会结合更新时间/content sequence/recency,让强屏幕证据在短窗口内有效;新输出或界面变化后,应重新求值,而不是永久锁住 Blocked。

visible_idle 和 visible_working 也帮助区分“规则猜测”与“当前 UI 强证据”,用于调节 hook/fallback 的覆盖关系。

8.15 Manifest 更新与来源优先级

检测规则需要比二进制发布更快迭代,因为 Agent UI 文案会频繁变化。Herdr 支持 bundled manifest 与更新/override 来源,并记录 manifest version/source。

安全要求包括:

  • engine version 不兼容时拒绝;
  • 解析/正则编译失败不替换当前可用规则;
  • source precedence 明确;
  • reload 原子切换已编译 cache;
  • explain 输出显示实际使用版本;
  • 测试 fixture 覆盖更新前后的典型屏幕。

这把易变的启发式从 Rust 控制流中抽出来,同时仍用严格 schema 和测试限制风险。

8.16 从 raw state 到 UI 和 API

有效状态改变后会影响:

  • sidebar/pane badge;
  • workspace 聚合状态;
  • agent.list/get/wait API;
  • EventHub 的 Agent 事件;
  • 系统通知或声音;
  • pane seen 与 Done 投影;
  • 自动化 prompt/wait 完成条件。

因此状态变化应只在仲裁结果真正不同且 revision 推进时发布,不能每次 screen poll 都制造重复事件。

8.17 测试策略

Agent 检测适合四层测试:

  1. 规则单测:给 screen/OSC fixture,断言 matched rule/state/flags;
  2. manifest lint:未知字段、重复 id、错误 regex、矛盾 flags、版本约束;
  3. 仲裁状态机:hook/screen/sequence/generation 的排列组合;
  4. 进程集成:wrapper/process group/exit 与实际 PTY 生命周期。

最重要的测试不是“某字符串等于 Working”,而是反例:旧 transcript 不应覆盖当前状态、迟到旧 generation 不应复活、full-lifecycle hook 不应被弱 screen fallback 推翻。

8.18 设计取舍

优点

  • 易变 UI 规则数据化,能独立版本和解释;
  • 进程身份与状态证据分离;
  • 权威、sequence、generation 明确处理时间竞态;
  • skip_state_update 避免特殊界面制造状态抖动;
  • Done 留在呈现层,领域语义干净。

代价

  • TerminalState 的仲裁逻辑体量大,新增 source 要理解全局 precedence;
  • 屏幕启发式仍会随上游 UI 漂移;
  • process tree 在三平台上的可见性/命名不同;
  • 多种时间窗口和 flags 增加测试矩阵。

可迁移原则

对无法从单一权威获得的状态,不要假装有一个完美 detector;应把来源、强度、新鲜度和会话身份作为一等数据,再显式仲裁。

下一章处理另一个时间问题:服务器重启或升级后,如何恢复结构,如何尽量原生恢复 Agent,以及 Unix Live Handoff 如何让同一批 PTY 进程跨 server 可执行文件继续存活。