三处 API 细节都验证通过:ApprovalRequired 事件自带通道可代答(reject)、Agent.clone(**overrides) 可剥插件、Mailbox.remove(token) 的撤回语义可做送达去重。以下先给 review 结论(相对上一版砍了什么、补了什么坑),再给设计稿。

Review 结论:上一版的修订

砍掉(过度设计):

  1. spawn seam(web 让 child 走 supervisor 的第二个回调)。两个回调有微妙耦合,v1 只留一个 deliver;child 的 UI 观察性推后,真有需求再议。

  2. retry/context_policy 转发参数。子 Agent 自带 posture(None 即继承),要覆盖就 child.clone(context_policy=...)——不为已有能力重复开参数。

  3. dict[str, Agent] 注册表。双命名(注册键 vs agent.name)是 scheduler 已经踩过的坑(scheduler.py:200-207 的名字恢复逻辑),直接以 agent.name 为唯一键。

  4. 砍独立 status 工具。view injector 每 turn 重现任务状态(Todo 的成熟模式),工具面收敛到 3 个(Codex 是 5 个)。

  5. 砍 fork/继承历史、嵌套深度机制、跨重启耐久。分别由 handoff、文档约定、(未来的)supervisor-backed child 覆盖。

补上(review 发现的真坑):

  1. 子 run 审批会永久挂起needs_approval 工具在无人驱动的 Runner.run 里会 park 在 ApprovalRequired 上——后台 child 必须由 spawner 驱动事件流并自动拒绝审批(web clientless run 的超时拒绝是先例,RunSupervisor._await_approval 注释同理)。这也顺带修正了驱动方式:不能 Runner.run,要 Runner.stream + 消费事件。

  2. 推/拉双通道的双投问题。完成即推 mailbox;模型又调 wait_subagents 主动拉——用 Mailbox.remove(token) 的撤回语义解决:拉成功=撤回未消费的推送直接返回,拉失败=已入上下文、只报"已送达"。零重复,且全是现有 API。

  3. 统一送达规则deliver 设了就全走它、不设就是 mailbox——而非"两条通道并存"。在 web 里 controller 的 mailbox 就是 ctx.mailbox,所以 live 场景两者本就同一。

  4. 零配置默认Subagents() 不给 catalog 时,child = 当前 agent clone(plugins=(), handoffs=())——技能场景("spin up a background agent")无需预注册研究员,对齐 Claude Code 的 general-purpose 子 agent。

设计稿:Subagents 插件

定位

多 agent 三件套的第三块:handoff(移交对话)、as_tool(同步委托,父等子)、Subagents(异步委托,父继续干活)。core runtime 零改动——全部构建在既有公开 seam 上(Runner.streamMailboxCancelToken、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_subagentprompt;agent(catalog >1 时必填,schema 用 enum 枚举名字;单 agent/self-clone 时省略)立即返回 "t1 已启动(researcher)。结果完成后自动以消息送达。";满载则返回提示语而非排队
wait_subagentsids?: list[str],timeout_seconds: int = 60(上限 600)有未送达的完成结果→立即返回;否则阻塞至首个完成或超时;超时返回仍在跑的清单让模型自行决定。内部 0.5s 步进轮询 ctx.cancel_token(token 是纯标志位),用户取消不被 wait 拖住
cancel_subagentid触发该 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

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). Write prompt standalone — the subagent sees nothing of this conversation. Reports arrive automatically as [subagent …] messages at your next step; call wait_subagents to 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 deliver

web 的 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,有需求再复活)。

不变量核对

测试清单(ScriptedProvider,无网络)

spawn→自动送达于下 turn|wait 撤回去重(无双投)|wait 超时返回 pending 清单|满载 spawn 提示|cancel_subagent|父取消→children 全停(bounded)|aclose 覆盖失败/弃流路径|detached:父 run 结束后 deliver 仍被调用|child 失败→失败报告|用量折叠(含失败 child)|审批自动拒绝不挂起|self-clone 剥插件|截断标记|catalog 外名字→报错提示可用名。

仍开放的决策点

  1. 命名:插件 Subagents;工具 spawn_subagent/wait_subagents/cancel_subagent。备选曾考虑 as_tool(background=True)——被否:后台需要注册表/送达/收尾等跨切状态,塞进无状态工具工厂两头受损。

  2. max_result_chars=16_000 的默认值(或对齐 max_tool_output_chars 风格给 200K?我倾向小:这是注入 user 消息,不走 offload/recall 恢复路径)。

  3. self-clone 零配置模式要不要保留(我倾向留:6 行换 Subagents() 开箱即用)。

  4. web wire_subagents 是独立 helper 还是 create_app(subagents="detach") 参数(我倾向 helper:不给 create_app 添开关)。

方向确认后我可以直接开分支实现(插件 + 测试 + 双语 README 段落)。