三处 API 细节都验证通过:ApprovalRequired 事件自带通道可代答(reject)、Agent.clone(**overrides) 可剥插件、Mailbox.remove(token) 的撤回语义可做送达去重。以下先给 review 结论(相对上一版砍了什么、补了什么坑),再给设计稿。
Review 结论:上一版的修订
砍掉(过度设计):
砍
spawnseam(web 让 child 走 supervisor 的第二个回调)。两个回调有微妙耦合,v1 只留一个deliver;child 的 UI 观察性推后,真有需求再议。砍
retry/context_policy转发参数。子 Agent 自带 posture(None 即继承),要覆盖就child.clone(context_policy=...)——不为已有能力重复开参数。砍
dict[str, Agent]注册表。双命名(注册键 vsagent.name)是 scheduler 已经踩过的坑(scheduler.py:200-207 的名字恢复逻辑),直接以agent.name为唯一键。砍独立 status 工具。view injector 每 turn 重现任务状态(Todo 的成熟模式),工具面收敛到 3 个(Codex 是 5 个)。
砍 fork/继承历史、嵌套深度机制、跨重启耐久。分别由 handoff、文档约定、(未来的)supervisor-backed child 覆盖。
补上(review 发现的真坑):
子 run 审批会永久挂起。
needs_approval工具在无人驱动的Runner.run里会 park 在ApprovalRequired上——后台 child 必须由 spawner 驱动事件流并自动拒绝审批(web clientless run 的超时拒绝是先例,RunSupervisor._await_approval注释同理)。这也顺带修正了驱动方式:不能Runner.run,要Runner.stream+ 消费事件。推/拉双通道的双投问题。完成即推 mailbox;模型又调
wait_subagents主动拉——用Mailbox.remove(token)的撤回语义解决:拉成功=撤回未消费的推送直接返回,拉失败=已入上下文、只报"已送达"。零重复,且全是现有 API。统一送达规则。
deliver设了就全走它、不设就是 mailbox——而非"两条通道并存"。在 web 里 controller 的 mailbox 就是ctx.mailbox,所以 live 场景两者本就同一。零配置默认。
Subagents()不给 catalog 时,child = 当前 agentclone(plugins=(), handoffs=())——技能场景("spin up a background agent")无需预注册研究员,对齐 Claude Code 的 general-purpose 子 agent。
设计稿:Subagents 插件
定位
多 agent 三件套的第三块:handoff(移交对话)、as_tool(同步委托,父等子)、Subagents(异步委托,父继续干活)。core runtime 零改动——全部构建在既有公开 seam 上(Runner.stream、Mailbox、CancelToken、Plugin、aclose);新文件仅 lovia/plugins/subagents.py(~300 行)+ 测试。
非目标:不做 deferred tool result(违反 append-only/配对/byte-stable 不变量);不做图编排;不做跨进程耐久。
公开 API
from lovia import Agent, Subagents
agent = Agent(
name="assistant", model="glm-5.2",
plugins=[Subagents([researcher, coder])], # catalog:按 agent.name 派生
)
# 或零配置:Subagents() —— child = 当前 agent 去掉 plugins/handoffs 的克隆@dataclass
class Subagents:
"""Plugin: spawn background subagents that run while the parent keeps working."""
agents: Agent[Any] | Sequence[Agent[Any]] = () # 空 = self-clone 模式
deliver: DeliverFn | None = None # None = 结果推入 ctx.mailbox(bounded 模式)
max_concurrent: int = 4 # 满则 spawn 返回"稍后再试"(不排队)
max_turns: int = 50 # 同 as_tool:绑定子 run 的回合上限
budget: RunBudget | None = None # 每次 spawn 以 replace() 复制(计数器清零)
max_result_chars: int = 16_000 # 报告注入上限,head+tail 截断(防失控载荷)
instructions: str | None = None # None = 内置文本(随模式变化);显式覆盖用
name: str = "subagents"
@dataclass
class SubagentReport:
"""One finished subagent, as handed to ``deliver``."""
id: str # "t1"…,run 内递增
agent: str # child 的 agent.name
prompt: str
session_id: str | None # 父 run 的 session,spawn 时捕获
result: RunResult | None # 失败时 None
error: BaseException | None
text: str # 渲染好的送达消息(mailbox 形式同款)
DeliverFn = Callable[[SubagentReport], Awaitable[None]]工具面(3 个)
| 工具 | 参数 | 行为 |
|---|---|---|
spawn_subagent | prompt;agent(catalog >1 时必填,schema 用 enum 枚举名字;单 agent/self-clone 时省略) | 立即返回 "t1 已启动(researcher)。结果完成后自动以消息送达。";满载则返回提示语而非排队 |
wait_subagents | ids?: list[str],timeout_seconds: int = 60(上限 600) | 有未送达的完成结果→立即返回;否则阻塞至首个完成或超时;超时返回仍在跑的清单让模型自行决定。内部 0.5s 步进轮询 ctx.cancel_token(token 是纯标志位),用户取消不被 wait 拖住 |
cancel_subagent | id | 触发该 child 的 token,协作停止;不投递取消报告(噪音) |
三个工具都 parallel=True(无副作用冲突;wait 阻塞期间同 turn 其他调用照常并行)。
运行语义:一条规则、两种模式
bounded(默认,deliver=None) | detached(deliver=fn) | |
|---|---|---|
| 生命周期 | 不出 run:aclose 取消存活 child(finally 保证覆盖成功/取消/失败/弃流) | 可跨 run 存活;引用存 plugin 对象级 set(Memory _curation_tasks 同款),done 即 discard |
| 送达 | 完成即 ctx.mailbox.push,下个 turn 可见 | 完成即 await deliver(report)(失败仅记日志,不炸 child) |
| 模型收尾约束 | instructions + injector 强提示:结束回复前必须 wait 或 cancel,未收割的工作即丢失 | 可直接收尾,报告稍后以新消息到达 |
机制细节
驱动循环(spawn 内 asyncio.create_task):
async def _drive(rec: _Record) -> None:
handle = Runner.stream(
rec.child, rec.prompt,
cancel_token=rec.token, # child 自己的 token(cancel_subagent 用)
max_turns=self.max_turns,
budget=replace(self.budget) if self.budget else None,
tracer=ctx._tracer, # span 并入父 trace(as_tool 同款)
_parent_usage=ctx.usage, # 用量折叠,含失败路径(as_tool 同款)
)
try:
async for ev in handle:
if isinstance(ev, events.ApprovalRequired):
ev.reject() # headless:自动拒绝,免于永久挂起
rec.result = await handle.result()
except Exception as exc:
rec.error = exc
finally:
self._finish(rec) # 渲染 text → mailbox 或 deliver;记 delivered token审批:自动拒绝 + 在 child 收到的拒绝理由里说明"后台运行无人审批"。建议(写入文档)child 配
allow型 workspace 预设。未来可升级为"上浮到父会话审批"(Claude Code v2.1.186 模式),非 v1。送达去重:
_finish在 bounded 模式token = ctx.mailbox.push(text)并把 token 存进 record;wait_subagents对每个完成项先ctx.mailbox.remove(token)——成功即撤回、直接把text作为工具结果返回;失败说明已被 drain 进上下文,只回一句"t1 的报告已在消息里"。失败报告:
[subagent t2: failed] RunCancelled: …同样送达——静默失败是最差 DX。渲染:
[subagent t1: done] agent=researcher elapsed=93s\n<output>;超过max_result_chars头尾截断加标记(与max_tool_output_chars的 tripwire 哲学一致)。aclose(bounded):对每个存活 childtoken.cancel()+task.cancel()+gather(return_exceptions=True)——不等协作收尾(child 可能卡在长 shell 上,不能拖住父 run 的 teardown;child 无 checkpoint,硬取消无损)。self-clone 模式:
child = ctx.agent.clone(name=f"{ctx.agent.name}-sub", plugins=(), handoffs=())——剥插件防递归(Subagents 自身)与并发写(Memory);workspace/tools/model 继承。嵌套:catalog 里的 Agent 若自带
Subagents即形成嵌套,不做机制阻止,文档建议 child 不带(外部先例均限深:CC=3、Codex=1)。
view injector(每 turn 尾部瞬态注入,不进 transcript):
[subagents] running: t1 researcher (93s) · t3 coder (12s) | done, undelivered: t2
Finish only after wait_subagents or cancel_subagent settles every task. # bounded 版才有第二行instructions 内置文本(bounded 版;detached 版把收尾约束句替换为"结果会在你结束后以新消息到达"):
Background subagents
You can delegate self-contained tasks to background subagents that run while you continue working:
spawn_subagent(agent, prompt). Writepromptstandalone — the subagent sees nothing of this conversation. Reports arrive automatically as[subagent …]messages at your next step; callwait_subagentsto block for pending ones,cancel_subagent(id)to stop one. Spawn early, then do your own work while they run. Never end your reply while subagents are still running — wait for them or cancel them; uncollected work is lost.
web 接线(Phase 2,~40 行)
不动 create_app 的默认行为(web 对齐 core:默认同为 bounded)。detached 是显式一行:
from lovia.web import create_app, wire_subagents
app = create_app(agent, db_path="lovia.db")
wire_subagents(app) # 找到 served agents 上 deliver=None 的 Subagents,注入 web deliverweb 的 deliver = 现成的 inject-or-start(scheduler.py:239-248 同款):supervisor.get(report.session_id) 活着→live.inject(report.text);不在→supervisor.start(session_id, input=report.text, autostart=True, source=f"subagent:{report.id}");429(并发满)→指数退避重试几次,最终失败记日志。父 run 收尾窗口无空隙:live run 的 mailbox 剩余会触发 supervisor 的 auto-chain(supervisor.py:538-545)。已知限制(记入文档):detached child 在父 RunRow 落账后完成的用量不进 web 记录;要账目/检查点/UI 可观察,未来让 child 走 supervisor(即被砍的 spawn seam,有需求再复活)。
不变量核对
transcript:结果以 user 消息进入(mailbox)或工具结果返回(wait 撤回路径)——永不回填 tool_result,配对/append-only/byte-stable prefix 全部无损。
Session/Checkpointer:child 不带 checkpoint、不写 Session;父的持久化路径一行未动。
插件契约:全部状态建于
setup()内(并发安全),仅 detached 引用集挂 plugin 对象(与 Memory 一致);loop 的六个贡献槽之外零新挂点。
测试清单(ScriptedProvider,无网络)
spawn→自动送达于下 turn|wait 撤回去重(无双投)|wait 超时返回 pending 清单|满载 spawn 提示|cancel_subagent|父取消→children 全停(bounded)|aclose 覆盖失败/弃流路径|detached:父 run 结束后 deliver 仍被调用|child 失败→失败报告|用量折叠(含失败 child)|审批自动拒绝不挂起|self-clone 剥插件|截断标记|catalog 外名字→报错提示可用名。
仍开放的决策点
命名:插件
Subagents;工具spawn_subagent/wait_subagents/cancel_subagent。备选曾考虑as_tool(background=True)——被否:后台需要注册表/送达/收尾等跨切状态,塞进无状态工具工厂两头受损。max_result_chars=16_000的默认值(或对齐max_tool_output_chars风格给 200K?我倾向小:这是注入 user 消息,不走 offload/recall 恢复路径)。self-clone 零配置模式要不要保留(我倾向留:6 行换
Subagents()开箱即用)。web
wire_subagents是独立 helper 还是create_app(subagents="detach")参数(我倾向 helper:不给 create_app 添开关)。
方向确认后我可以直接开分支实现(插件 + 测试 + 双语 README 段落)。