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

+31 -3
View File
@@ -27,6 +27,7 @@ import { type RunUpstream, resolveRunUpstream } from "../pi/index.ts";
import type { ServerContext } from "../types.ts";
import { DEMO_SCRIPTS, parseFakeScripts } from "./fake-scripts.ts";
import { buildGoalReport } from "./report.ts";
import { attachLiveRun, type LiveRun, subagentSettings } from "./subagents.ts";
// runAgent 任务的执行体:从设置装配 Pi(或 fake)运行时 → 事件先写 agent_run_event 再推 SSE →
// 崩溃恢复(pendingOperations>0 时先按幂等键对账未知结果再 resume)→ 消费追加消息 → 汇报以业务表为准。
@@ -253,12 +254,22 @@ export async function reconcilePending(
}
}
function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
function systemPromptFor(
run: AgentRunRow,
scope: RunScope,
sub: { canSpawn: boolean; maxConcurrent: number; maxDepth: number },
): string {
const constraints: string[] = [
`可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
];
if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。");
if (sub.canSpawn)
constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
);
if (run.parent_run_id) constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。");
if (run.trigger === "event" && run.mode === "execute")
constraints.push(
"本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
@@ -379,21 +390,36 @@ export async function drive(
scopeIds: [] as string[],
...(scope.allowedKinds ? { allowedKinds: scope.allowedKinds } : {}),
};
// 子代理:开关关闭或已到层数上限时不向模型提供 agent.spawn;没有子会话可等时也不提供 agent.await
const sub = subagentSettings(s);
const canSpawn = sub.enabled && run.depth < sub.maxDepth;
const hasChildren = !!(await ctx.db
.selectFrom("agent_run")
.select("id")
.where("parent_run_id", "=", runId)
.executeTakeFirst());
const capabilities = (scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG)).filter(
(id) => (id === "agent.spawn" ? canSpawn : id === "agent.await" ? canSpawn || hasChildren : true),
);
let external: LiveRun | null = null;
let handle: AgentRunHandle;
try {
handle = await runtime.createRun({
runId,
goal: run.goal,
systemPrompt: systemPromptFor(run, scope),
capabilities: scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG),
systemPrompt: systemPromptFor(run, { ...scope, capabilities }, { canSpawn, ...sub }),
capabilities,
settings,
sessionDir: sessionDirOf(env, s.agent.sessionDir),
actor,
firstSeq: maxSeq?.m === null || maxSeq?.m === undefined ? 0 : Number(maxSeq.m) + 1,
externalUsage: () => external?.externalUsage() ?? { toolCalls: 0, totalTokens: 0, costUsd: 0 },
});
} catch (err) {
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };
}
const liveRun = await attachLiveRun(env, runId, handle);
external = liveRun.run;
// 取消传播:任务取消信号或 agent_run.cancel_requested_at → 中止会话
let aborting = false;
@@ -411,6 +437,7 @@ export async function drive(
.executeTakeFirst()
.catch(() => null);
if (r?.cancel_requested_at) abortOnce();
void liveRun.run.pump();
}, 1000);
try {
@@ -454,6 +481,7 @@ export async function drive(
} finally {
clearInterval(watch);
signal.removeEventListener("abort", abortOnce);
await liveRun.stop();
await handle.close();
}
}