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

+40
View File
@@ -16,6 +16,7 @@ import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts";
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
import { descendants, descendantUsage } from "./subagents.ts";
// Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。
@@ -57,11 +58,34 @@ export function runToApi(r: AgentRunRow) {
createdBy: r.created_by,
mode: r.mode,
dedupeKey: r.dedupe_key,
depth: r.depth,
spawnReason: r.spawn_reason,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
};
}
/** 会话树节点(父会话 / 子孙会话) */
export function runToNode(r: AgentRunRow) {
const scope = (r.scope ?? {}) as { capabilities?: string[] };
return {
id: r.id,
parentRunId: r.parent_run_id,
goal: r.goal,
status: r.status,
mode: r.mode,
depth: r.depth,
spawnReason: r.spawn_reason,
toolCallCount: r.tool_call_count,
usage: r.usage as Record<string, number>,
summary: r.summary,
capabilities: scope.capabilities ?? [],
budget: r.budget as Record<string, number>,
createdAt: r.created_at.toISOString(),
finishedAt: toIso(r.finished_at),
};
}
/** snake_case 行 → camelCase(Date → ISO) */
function camel(row: object): Record<string, unknown> {
const out: Record<string, unknown> = {};
@@ -98,6 +122,7 @@ export function registerAgentRoutes(
let q = db().selectFrom("agent_run").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
@@ -138,8 +163,23 @@ export function registerAgentRoutes(
.where("kind", "=", "report")
.orderBy("created_at", "desc")
.executeTakeFirst();
// 会话树:父会话 + 全部子孙会话;合计用量 = 本会话 + 子孙会话
const parent = run.parent_run_id
? await db().selectFrom("agent_run").selectAll().where("id", "=", run.parent_run_id).executeTakeFirst()
: undefined;
const kids = await descendants(ctx, run.id);
const own = (run.usage ?? {}) as { totalTokens?: number; costUsd?: number };
const ext = await descendantUsage(ctx, run.id);
return {
run: runToApi(run),
parent: parent ? runToNode(parent) : null,
children: kids.map(runToNode),
familyUsage: {
runs: kids.length + 1,
toolCalls: run.tool_call_count + ext.toolCalls,
totalTokens: (own.totalTokens ?? 0) + ext.totalTokens,
costUsd: (own.costUsd ?? 0) + ext.costUsd,
},
events: page.map((e) => ({
runId: e.run_id,
seq: Number(e.seq),