一个自己开发的 Agent Harness-子 Agent篇

本文讲 区域 8 · 知识与子 Agent 的后半------子代理与委托信封:主 Agent 派生出别的 Agent 并行干活时,「子权限 ≤ 父」这条铁律,怎么被钉死。

0. 先建立心智模型

一个 Agent 不够,多个 Agent 才有规模

单个 Agent 是「一个人干活」;真正的规模化,是「一个人派一队人干活」。Grodex 里的子代理(delegate_task 工具)做的是这件事:主 Agent 遇到「大而独立」的子任务(比如「把整个仓库的 lint 错误都修了」),不是自己一步步啃,而是派一个子代理去干,自己继续主线

但派生子代理立刻引出一个安全难题:子代理是半自主的,它会自己调工具、自己跑命令。怎么保证它不会借父代理的权限越权? 如果父能写 /etc,子就绝不能写 /etc------这是铁律,记作不变量 #12:子权限 ≤ 父

这一篇就讲:这个「≤」号,代码里怎么做到。

先交代一个诚实的现状

写这篇之前,我把子代理的代码摸了个底朝天,发现有两条并存的路径,必须分开说清,否则会误导读者:

路径 状态
A. DelegationEnvelope(委托信封) 设计完整、有四重校验、有测试,但生产里没有代码构造它(只有测试构造)
B. delegate_task 工具 生产实际使用的路径,子代理走「共享权限管理器 + 只读白名单」

换句话说:那张漂亮的「授权书」机制已经建好、等待接线 ,而当前真正在跑的 delegate_task 子代理,是用一套更保守、更简单的方案兜底的。下面分别讲,最后说明这个差距意味着什么。

1. 一张不可变的授权书:DelegationEnvelope

DelegationEnvelope(委托信封)是路径 A 的核心------父代理派生子代理时签的一张授权书 ,字段如下(crates/grodex-subagent/src/delegation.rs):

rust 复制代码
pub struct DelegationEnvelope {
    parent_agent_id: String,
    child_agent_id: String,
    capabilities: CapabilitySubset,        // 允许的工具/技能/MCP 清单
    policy_ceiling: PolicyDecision,        // 子可自我授权的「最宽松」决策
    sandbox: SandboxProfile,               // 每次副作用强制的沙箱
    budget: DelegationBudget,              // 轮次/时长/token 上限
    authority_ceiling: u8,                 // authority 上限(0-255)
    revocation_epoch: u64,                 // 授权时钉住的撤销纪元
    delegation_generation: u64,            // 父重新授权时递增
}

「不可变」是它的灵魂:模块文档明说「spawn 之后信封里任何东西都不能改------子代理执行时面对的,恰好就是这一份授权」。任何超出(工具不在子集、策略比上限松、写越沙箱)都在副作用发生前被拒。

默认值也是 fail-closed 的:policy_ceiling = Ask、沙箱 restricted(deny /)、authority_ceiling = 0。父代理想给子代理「更宽的权限」,必须显式、逐项地放宽------默认是一张几乎什么都干不了的授权书。

2. 不变量 #12:四重校验

授权书写好了,但「≤」这个关系要在每次工具调用 时被重新验证,而不是派发时验一次就完事(因为父可能在子运行中途撤销)。校验函数 authorize_tool_call,四重顺序检查:

scss 复制代码
1. 父没撤销?       live_revocation_epoch > envelope.revocation_epoch → 拒绝
2. 工具在清单内?   capabilities.permits_tool(tool) → 拒绝
3. authority 没超上限?  tool_authority > authority_ceiling → 拒绝
4. 请求权限不比上限宽松?  strictness(requested) < strictness(ceiling) → 拒绝

第 4 重是精髓:strictness 定义 Allow=0 < Ask=1 < Deny=2。子代理可以比上限更严 (父给 Ask,子自己再要求 Deny),但绝不许更松 (父给 Ask,子不许 Allow)。

「父撤销」的机制也很有意思:父「作废」子代理,不是去找到子代理进程发信号,而是提升共享 PermissionManager 的撤销纪元 (revoke_all() 让 epoch + 1)。子代理下一次调用工具时,发现 live epoch 超过了信封里钉住的 epoch,就知道自己被撤销了。这跟 06 篇的 LiveRevocationFence 是同一套纪元机制------撤销是「拉闸」,不是「逐一通知」。

3. delegate_task:生产路径

现在讲真正在跑的路径 B。delegate_task 工具的执行(delegate_tool.rs)分三步:

① 配额门 :先查 running_count >= max_concurrent(默认 4)或 spawned_total >= max_total(默认 16),超了返回配额拒绝文本------不 Err,让模型自己调整策略(「现在太挤了,晚点再派」)。

② spawn :创建子任务,拿到一个 ChildControls(cancel token + 消息邮箱)。

③ run_subagent_turn :子代理跑一个独立的轻量循环:

  • 独立 ModelBinding(可和父用不同模型);
  • 全新上下文 :只有一个 User { content: task },加一句「You are a sub-agent...」的系统提示------不继承父的任何对话历史(ContextFork::None);
  • 独立循环上限 MAX_SUBAGENT_STEPS = 15,每步 sample → 执行工具 → 推回 ToolResult,直到出最终文本或超步数;
  • 工具「上限」由只读白名单 构成:只注入 read_file / grep / glob / read_artifact------子代理天生没有写文件、跑 shell 的能力

权限上,子代理和主 Agent 共享同一个 PermissionManager。每次工具调用走 pm.check(...),Allowed 执行、Denied 拒绝;关键的一条 fail-closed:如果返回 ApprovalRequired,子代理没法做审批往返(它没有 UI),立即 pm.resolve(ticket_id, Deny) 清掉票,当作拒绝处理。子代理的哲学是「宁可干不成,也不挂起等一个它等不到的审批」。

4. 两套机制的差距意味着什么

读完这两条路径,读者可能会问:为什么漂亮的 DelegationEnvelope 没接线,反而用了一套「只读白名单」的保守方案?

我的判断是:这是刻意的、正确的保守

DelegationEnvelope 解决的是「子代理要有可写、可执行 能力,但又不能越权」的精细场景------这需要父代理能精确表达「子可以写哪些文件、跑哪些命令、最多几次」。这套表达能力已经建好(四重校验 + 不可变信封),但安全上最稳的第一步,是让子代理只读。先把「只读 + 共享权限」这条路走通、验证了子代理的调度/回收/恢复都可靠,再放开「可写子代理」并挂上信封。

这跟 07 篇沙箱的「Landlock 没接线、只做 Seatbelt」是同一个工程哲学:先做最硬、最保守、最不可能错的那条,剩下的设计就绪、等待时机。

5. 子代理的生命周期:一棵会恢复的树

子代理不是平铺的一堆任务,而是一棵 (子代理可以再派子代理)。内存里由 SubAgentSupervisor 管状态机:Pending → Running → Completed/Failed/Cancelled,cancel递归级联的(取消一个父,级联取消所有子孙)。

真正硬核的是 DurableSubAgentSupervisor------它把子代理的生命周期也写进 journal:

  • spawnSubAgentTaskStarted,journal 写失败会回滚内存 spawn(P1-5 修复:不能「内存里活了、日志里没有」);
  • complete / failSubAgentTaskFinished;
  • cancel 先快照非终止任务、再级联取消、为每个被取消的写一条 Finished(cancelled);
  • 崩溃恢复 recover_from_journal:两阶段重放------先回放所有 Started 重建内存树,再回放 Finished 置终止态;仍在飞的任务计入 unrestored 返回。

这是 04 篇「日志即事实、内存即投影」在子代理上的延伸:子代理这棵树,崩溃后能从 journal 精确重建。

6. 并行与回收

delegate_task 标为 ConcurrencyClass::Parallel,所以主 Agent 的工具分发器能同时跑多个子代理(配额卡在 4 并发)。每个子代理是内联 await 的自包含循环,共享同一个 SamplingActor 连接池。

回收时有两个细节值得一提:

  • 子代理最终文本作为 delegate_taskToolResult 返回,带 [Subagent 'label'] 前缀;
  • 大报告走 offload_if_large:超过 8KB 就写临时文件,主上下文只放 2048 字符预览 + 文件路径------防止多个 16K 的报告把父代理的窗口撑爆。这和 03 篇说的「大工具结果外置」是同一套 offload 思路。

7. 子代理的「心跳」:怎么流到 TUI

子代理跑起来是异步的,主 Agent 不会干等。它的进度通过 SubagentProgress 事件一路流到前端:

php 复制代码
SubagentProgress { Started / Step / Finished }
  → 进度通道 → runtime fan-out → LoopSessionEvent::SubagentProgress
    → ACP UpdateContent::SubagentProgress → TUI
      → TUI 渲染成可折叠的「子代理卡片」(ChatMessage::Subagent)

用户在 TUI 里看到的每一张子代理卡片(展开能看到执行日志),就是这条链路的终点。子代理对用户是「可见的」------不是一个黑盒里悄悄跑完的线程。

写在最后

  • 默认授权书几乎什么都干不了,放宽要逐项显式声明;
  • 每次工具调用都重新验四重,而不是派发时验一次;
  • 撤销是「拉闸」(抬纪元),不是「逐一通知」;
  • 子代理天生只读,可写能力是「设计就绪、等待接线」的下一阶段;
  • 崩溃后,这棵树能从 journal 重建。
相关推荐
KoPa13 分钟前
HeySmart:事件总线——异步解耦的艺术
前端·后端
吃饱了得干活14 分钟前
MySQL 内核剖析:ACID 实现、引擎对决、B+树、索引与主从复制闭环
后端·mysql
一百昏18 分钟前
cen19c01(Oracle 19c RAC 单节点)网络与 CRS 故障修复报告
后端
小刘是地理大王18 分钟前
OpenFeign 兜底回调(fallback / fallbackFactory)实战指南
java·后端
用户78136671144520 分钟前
C++中锁的深入分析
后端
程序员麻辣烫21 分钟前
OpenClaw Hook系统:Agent框架的非侵入式扩展机制
后端·aigc
LiaCode22 分钟前
Redis 接入 AI 学习总结:从向量检索到 Agent 上下文引擎
后端
步行cgn23 分钟前
Spring Boot 保证版本一致性的核心机制
后端
步行cgn27 分钟前
Spring Boot 启动器(Starter)详解:依赖组合的标准模式
后端