Background Agent 调研结论与 lovia 方案
TL;DR:core runtime 零改动——所需的每一个 seam 都已存在。建议以一个 core 插件 Subagents(与 Todo 同级,~300 行,零新依赖)提供"派生后台子 agent + 结果回送"能力;web 层通过两个窄回调复用现有 RunSupervisor/Scheduler 基础设施。应用层今天就能用纯公开 API 自建(文末附 recipe 证明)。不建议引入 ADK 式"工具挂起、结果后补"机制——它解决的是另一个问题,且与 lovia 的 append-only 不变量冲突。
一、外部调研:两类产品,收敛于两种模式
| 产品 | 原语 | 父阻塞? | 结果送达 | 实现层 |
|---|---|---|---|---|
| Claude Code | Agent 工具(v2.1.198 起默认后台)、后台 Bash、Monitor | 否 | 推:完成通知在后续 turn 重新唤起模型;SendMessage 续聊 | harness 核心 |
| Codex | spawn_agent/send_input/wait/close_agent | 否,但靠阻塞 wait 拉取 | 拉 | Rust 核心 |
| opencode | task(background=true, task_id) | 否 | 推:child 完成后把结果作为合成消息注入父会话(ops.prompt());task_status 可轮询 | 服务端核心 |
| Amp | 子 agent 即工具;跨线程 agent(2026) | 回合内等汇总 | 工具结果 / 线程间消息 | 闭源应用层 |
| Gemini CLI | 每个子 agent 注册为一个工具 | 是(委托-等待) | 工具结果 | core 包 |
| OpenAI Agents SDK | 无——官方示例直接用 asyncio.create_task/gather | — | to_input_list() / Session | 留给宿主 |
| Pydantic AI | 无后台原语;有 CallDeferred(见下) | — | resume 时按 tool_call_id 配对 | 留给宿主 |
| LangGraph | OSS 无(一切工作在 super-step 汇合);后台 run/cron/webhook 是平台服务端功能 | — | webhook / runs.join | 服务端 |
| Google ADK | 进程内无;LongRunningFunctionTool 会暂停 run | run 暂停 | 客户端带原 FunctionCall.id 重新注入 | — |
三个关键收敛点:
纯库不做后台原语,harness 都做。 OpenAI Agents SDK、Pydantic AI、LangGraph OSS 的 core 一致选择"交给宿主 asyncio / 服务端";而所有交互式 harness(Claude Code、Codex、opencode)都内置了 spawn 工具。lovia 两者都是——库 core + web harness——所以答案天然分层。
送达只有两条路,且不互斥:推(完成时注入消息/通知,Claude Code、opencode)与拉(阻塞
wait工具,Codex)。最好的设计两者都给:推保证不遗漏,拉让模型能主动汇合。"deferred tool result"是另一个问题。 ADK 的
LongRunningFunctionTool和 Pydantic AI 的CallDeferred都会暂停/结束当前 run,等外部完成后带着配对 id 重新进入——那是 human-in-the-loop/外部完成场景,不是"父 agent 继续干活"。没有任何框架在 run 内部把 tool_result 留空、事后回填,因为 provider 协议(tool_call 后必须紧跟配对 result)不允许。
技能生态的预期(mattpocock 的 research skill 等)是:harness 能把一个任务甩到后台、主对话继续,结果以文件+通知落地——即 Claude Code 模式。
二、core 要不要支持?——分层回答
Runtime loop:不需要、也不应该改。 检查过全部相关 seam 后,结论是"后台子 agent"在 lovia 里不需要任何运行时新原语:
派生:
Runner.stream()/Runner.run()就是普通协程,asyncio.create_task即可并发驱动——web 的RunController._run()(supervisor.py:419)驱动的就是同一个RunHandle;Memory 插件的curate_in_background(plugin.py:935)已经是 core 内"插件持有 asyncio 任务"的先例。回送:
Mailbox正是为此设计的——"a plugin feeding in fresh context"(steering.py:14),ctx.mailbox恒在,turn 边界注入,transcript 永远合法(结果以 user 消息进入,不碰 tool 配对)。收尾:插件
aclose在finally中执行,覆盖成功/取消/失败/流被弃全部路径(loop.py:997)——end-of-run 策略有可靠挂点。取消/预算/用量:
CancelToken可逐 child 持有、replace(budget)逐次复制(agent_as_tool已是此模式)、ctx.usage.add(result.usage)公开可用。
反面明确排除:不要做 ADK 式 deferred tool result。 在 run 内让工具返回 pending、事后改写结果,会同时违反 lovia 四条硬不变量:Session append-only("Don't add a replace",AGENTS.md)、Checkpointer append-only、safe_window() 的 call/result 配对、compaction 的 byte-stable prefix(缓存)。而它服务的场景——工具需外部完成、run 暂停等待——lovia 已有等价物:approval 通道(环内暂停)+ checkpoint resume(跨进程续跑)。
能力本身:建议以 core 插件提供。 理由:(a) 技能生态把"background agent"当作 harness 标配,lovia 的 Skills 插件已经在吃这些 SKILL.md;(b) 裸 recipe 有真实尖角——final-turn 送达丢失、任务泄漏、等待语义、教模型协议的 instructions 文本——每个用户都会重新踩一遍,这正是 Todo(242 行)级别的"电池";(c) 默认实现零 web 依赖(mailbox 送达),放 lovia/plugins/ 完全干净。这符合"minimal general seams over core coupling":能力在插件里,loop 一行不动。
三、实现方案:Subagents 插件
@dataclass
class Subagents: # lovia/plugins/subagents.py
agents: dict[str, Agent[Any]] # 可派生的子 agent 目录(名字 → Agent)
max_concurrent: int = 5 # 超出即 spawn 返回"稍后再试"
max_turns: int = 25 # 转发给子 run,同 agent_as_tool 的绑定理由
budget: RunBudget | None = None # 每次 spawn 用 replace() 复制
deliver: DeliverFn | None = None # seam:None = 推入 ctx.mailbox(run-scoped)
name: str = "subagents"setup() 建 run-scoped 注册表 {tid: _TaskRecord(handle, token, status, result, delivered)},贡献三个工具 + instructions(+ 可选一个 view injector,像 Todo 一样每 turn 重现"运行中任务"清单,防模型遗忘):
spawn_subagent(agent, prompt) -> tid—asyncio.create_task驱动Runner.run(child, prompt, cancel_token=<child 自己的 token>, max_turns=..., budget=replace(...), tracer=ctx._tracer, _parent_usage=ctx.usage),立即返回"任务 {tid} 已启动,完成后结果将以消息送达"。完成回调:成功/失败都渲染成一条[subagent {tid} 完成/失败] …推入deliver(默认ctx.mailbox.push),并标记delivered。join_subagents(ids?, timeout?) -> str— 拉端:立即返回已完成且未送达的结果;都没完成则阻塞至第一个完成或超时(内部asyncio.wait+ 周期检查ctx.cancel_token.is_cancelled,token 是纯标志位无 waitable)。这是有界 run 里让后台任务可用的关键:模型先 spawn 三个调研、自己继续干活、最后 join 汇合——全程一个 run,无需 harness 重新唤起。cancel_subagent(tid)— 触发该 child 的 token,协作停止。
送达去重:mailbox 推送与 join 拉取共用 delivered 标记,结果只进入上下文一次。
收尾策略(aclose):默认(deliver=None,run-scoped 语义)取消所有仍在跑的 child——库用户得到干净的"任务不出 run"承诺;设置了 deliver 的(web/自定义宿主)child 脱离 run 存活,由 deliver 负责后续送达。两种语义都显式、无静默泄漏。
child 上下文:同 agent_as_tool——全新上下文,只见 prompt,只回传 final output。不做 fork/继承历史(handoff 已覆盖"带全history换人";上下文靠父在 prompt 里自述,Claude Code 亦如此)。嵌套:不主动阻止(child 的 Agent 配置是开发者给的),文档建议 child 不带 Subagents(外部先例都限深:CC=3、Codex=1)。
尺寸预估 ~300 行 + 测试,与 todo.py/scheduling.py 同量级。ScriptedProvider 可全流程无网络测试。
四、若不进 core:应用层今天就能自建
全部所需接口已经公开,~25 行:
def spawn_tool(researcher: Agent) -> Tool:
@tool
async def spawn_research(ctx: RunContext[Any], prompt: str) -> str:
tid = uuid4().hex[:8]
token = CancelToken()
async def _go() -> None:
try:
r = await Runner.run(researcher, prompt, cancel_token=token, max_turns=25)
ctx.usage.add(r.usage) # 用量折叠,公开 API
ctx.mailbox.push(f"[任务 {tid} 完成]\n{r.output}")
except Exception as exc:
ctx.mailbox.push(f"[任务 {tid} 失败] {exc}")
asyncio.create_task(_go()) # 引用管理略
return f"任务 {tid} 已在后台启动,结果将以消息送达。"
return spawn_researchcore 不缺任何接口:Runner 可并发驱动、ctx.mailbox 恒在、ctx.usage.add 公开、CancelToken 公开、插件可打包。仅有的两个内部便利(_parent_usage、ctx._tracer)都有公开等价物(完成时 usage.add;tracer 显式传入)。缺的只是策略(final-turn 空窗、泄漏、去重、教模型的文本)——这正是插件的价值,而非新接口的理由。
五、web UI:core 插件 + web 注入两个窄 seam,基础设施全复用
web 已经拥有后台任务需要的全部重资产,新代码只是接线:
child 怎么跑:复用
Scheduler._fire的 clientless 模式(scheduler.py:257-277)——为每个 spawn 建隐藏 child session,supervisor.start(..., autostart=True, source=f"subagent:{parent_sid}")。child 由此免费获得:运行记录(RunRow/用量)、检查点与断点续跑、max_background_runs并发上限、审批超时自动拒绝(_await_approval)、SSE 可观察可取消。结果怎么回:复用 scheduler 已实现的 inject-or-start(scheduler.py:239-248)——父 session 有活跃 run 就
live.inject()(mailbox 注入,下个 turn 可见),没有就supervisor.start新起一条 run 消费结果。注意 supervisor 的 auto-chain(supervisor.py:538-545)已兜住"父正在收尾"的空窗:run 成功结束时 mailbox 有剩余会自动追加一个 leg。所以 web 里推送永不丢失。UI 呈现:
/api/events生命周期总线已有 run_started/run_finished;child session 可在侧栏隐藏或按 source 标记,点开即看子任务全过程(opencode 的 child-session 导航同款体验)。
接线方式:web 在装配 agent 时给 Subagents 传 deliver=(用 ctx.session_id + supervisor 做 inject-or-start)和(可选)spawn=(改走 supervisor 而非裸 Runner.run)。core 默认语义与 web 语义只差这两个回调——web 对齐 core,没有 serving 层私货。
六、开放决策点(建议 grill 的地方)
命名:插件
SubagentsvsTasksvsBackground;工具spawn_subagent/join_subagents/cancel_subagentvs opencode 式task/task_status。我倾向Subagents——与agent_as_tool(同步委托)、handoff(移交)构成清晰的多 agent 三件套。aclose 默认策略:默认 cancel(run-scoped,干净)是否正确?还是跟随 Claude Code 语义(session 内存活)?我倾向 cancel——库语境里 run 是生命周期单位,web 语境由 deliver seam 升级。
web child 是否为独立(隐藏)session:换来可观察/可恢复,代价是 store 里多行记录与 UI 隐藏逻辑。我倾向是。
spawnseam 要不要(web 让 child 走 supervisor):不加则 web 的并发上限/记录对 child 失效。倾向加,但可以 Phase 2 再加——Phase 1 纯 core 插件已完整可用。是否值得进 core:如果你认为技能生态驱动不足,退路是:core 只在文档里放上面的 recipe,插件整个放 web(像
Scheduling)。代价是库用户(CLI/脚本/评测)拿不到这能力。
调研报告全文来自两个后台 agent 的一手来源核查(Claude Code/Codex/Amp/opencode/Gemini CLI + OpenAI SDK/LangGraph/ADK/MAF/AutoGen/Pydantic AI/Claude Agent SDK/Temporal,均附 URL),需要原始细节可以随时展开某一家。若方向认可,下一步我可以先出 Subagents 插件的完整设计稿(含 instructions 文本与测试用例清单)再动手。