feat(agent): Pi subagents via agent.spawn / agent.await

- agent_run gains depth / spawn_reason (0029); agent_run_event types
  subagent_spawned / subagent_event / subagent_completed
- agent.spawn (propose, medium, replay never): allowlist ⊆ parent, mode not
  above parent, budget carved from parent's remainder, idempotent per run+input;
  settings.pi.subagents {enabled, maxConcurrent, maxDepth} (subagentsEnabled kept as alias)
- child runs claim a concurrency slot under an advisory lock (waitForFact while
  queued); agent.await polls business tables without calling the model
- parent abort cascades to all descendants; parent runtime budget counts
  descendant usage; child events mirrored into the parent stream by parent seq
- authorize caps every child call by all ancestors' allowlists/kinds
- session page shows parent link, inherited info and the subagent tree
- e2e E11; runbook section on subagents

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:49:37 +08:00
1 parent daa85a8bb4
commit 459991421e
27 files changed
+1720 -32

No files matched your search

+23 -1
View File
@@ -17,6 +17,7 @@ import {
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { finalize } from "./service.ts";
import { catchUpChildren, claimSubagentSlot } from "./subagents.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
@@ -52,7 +53,26 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
.execute();
return null;
});
const res: DriveResult = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
// 子代理:先占并发名额(父会话下同时 running 的子会话 < maxConcurrent);没名额就挂起等待,不占 worker、不占模型
const parentId = await t.step.run("subagent-parent", async () => {
const r = await e.ctx.db
.selectFrom("agent_run")
.select("parent_run_id")
.where("id", "=", runId)
.executeTakeFirst();
return r?.parent_run_id ?? null;
});
let res: DriveResult;
const slot = parentId
? await t.step.waitForFact<"claimed" | "cancelled">("subagent-slot", {
check: () => claimSubagentSlot(e.ctx, runId),
timeout: "1s",
maxRounds: 3600,
})
: "claimed";
if (slot === null)
res = { status: "failed", reason: "error", budget: null, message: "等待子会话名额超时", finalText: "" };
else res = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
let settled: AgentGoalReport | null = null;
const run = await e.ctx.db
.selectFrom("agent_run")
@@ -102,6 +122,8 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
const out = await t.step.run("finalize", () =>
finalize(e, runId, res, settled, recovery ? { summaryPrefix: recovery.summary } : {}),
);
// 父会话收尾后才结束的子会话:补记进父事件流
await t.step.run("subagent-catchup", () => catchUpChildren(e.ctx, runId));
if (recovery) {
// 已在 finalize 内带上核实结论;保留该步骤兼容旧 run 的步骤历史(已带前缀时幂等跳过)
const rec = recovery;