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

+34 -8
View File
@@ -23,6 +23,7 @@ import {
type CreateRunSpec,
emptyUsage,
type RunOutcome,
type SubagentEventBody,
} from "../runtime/port.ts";
import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts";
import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts";
@@ -199,8 +200,12 @@ class PiAgentRun implements AgentRunHandle {
return { block: { reason: decision.reason } };
}
this.toolCalls += 1;
if (this.toolCalls > this.settings.maxToolCalls) {
const reason = `工具调用次数超出预算(${this.settings.maxToolCalls})`;
const extCalls = this.spec.externalUsage?.().toolCalls ?? 0;
if (this.toolCalls + extCalls > this.settings.maxToolCalls) {
const reason =
extCalls > 0
? `工具调用次数超出预算(${this.settings.maxToolCalls},含子会话 ${extCalls} 次)`
: `工具调用次数超出预算(${this.settings.maxToolCalls})`;
this.exceed("tool_calls", reason);
this.rejected.add(event.toolCallId);
this.emit({
@@ -216,6 +221,7 @@ class PiAgentRun implements AgentRunHandle {
});
hooks.on("before_request", () => {
this.checkDeadline();
this.checkCost();
if (this.budget) this.scheduleAbort();
return undefined;
});
@@ -262,12 +268,7 @@ class PiAgentRun implements AgentRunHandle {
});
events.on("usage", (e) => {
this.usage = toAgentUsage(e.totals);
const cost = this.settings.costBudget;
if (cost?.maxTotalTokens !== undefined && this.usage.totalTokens > cost.maxTotalTokens) {
this.exceed("tokens", `token 用量 ${this.usage.totalTokens} 超出预算 ${cost.maxTotalTokens}`);
} else if (cost?.maxCostUsd !== undefined && this.usage.costUsd > cost.maxCostUsd) {
this.exceed("cost", `费用 $${this.usage.costUsd.toFixed(4)} 超出预算 $${cost.maxCostUsd}`);
}
this.checkCost();
});
events.on("compaction_start", (e) => this.emit({ type: "compaction", phase: "start", reason: e.reason }));
events.on("compaction_end", (e) =>
@@ -302,6 +303,16 @@ class PiAgentRun implements AgentRunHandle {
await this.lane.abort(BACKGROUND_CONTEXT).catch(() => undefined);
}
appendEvent(body: SubagentEventBody): boolean {
if (this.closed) return false;
this.emit(body);
return true;
}
stats(): { toolCalls: number; usage: AgentUsageTotals } {
return { toolCalls: this.toolCalls, usage: { ...this.usage } };
}
async compact(customInstructions = DEFAULT_COMPACTION_INSTRUCTIONS): Promise<void> {
const r = await this.lane.compact({ customInstructions }, BACKGROUND_CONTEXT);
if (!r.ok && r.error._tag !== "NothingToCompact") throw new Error(`压缩失败: ${r.error._tag}`);
@@ -398,6 +409,21 @@ class PiAgentRun implements AgentRunHandle {
}, 0);
}
/** token / 费用预算:自身用量 + 子会话用量(externalUsage) */
private checkCost(): void {
const cost = this.settings.costBudget;
if (!cost) return;
const ext = this.spec.externalUsage?.();
const tokens = this.usage.totalTokens + (ext?.totalTokens ?? 0);
const usd = this.usage.costUsd + (ext?.costUsd ?? 0);
const note = ext && (ext.totalTokens > 0 || ext.costUsd > 0) ? "(含子会话)" : "";
if (cost.maxTotalTokens !== undefined && tokens > cost.maxTotalTokens) {
this.exceed("tokens", `token 用量 ${tokens}${note} 超出预算 ${cost.maxTotalTokens}`);
} else if (cost.maxCostUsd !== undefined && usd > cost.maxCostUsd) {
this.exceed("cost", `费用 $${usd.toFixed(4)}${note} 超出预算 $${cost.maxCostUsd}`);
}
}
private checkDeadline(): void {
if (this.startedAt !== undefined && Date.now() - this.startedAt > this.settings.maxDurationMs) {
this.exceed("duration", `运行时长超出预算(${this.settings.maxDurationMs}ms)`);