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)`);
+24
View File
@@ -2,6 +2,7 @@ import type {
AgentBudgetKind,
AgentFailureReason,
AgentRunEvent,
AgentRunEventBody,
AgentUsageTotals,
CapabilityKind,
} from "@umt/contracts";
@@ -10,6 +11,11 @@ import type { AgentSettings } from "../settings.ts";
// 事件、用量、失败原因的类型统一取自 @umt/contracts(AgentRunEvent / AgentUsageTotals / AgentFailureReason /
// AgentBudgetKind);本文件只定义运行时端口。
export type SubagentEventBody = Extract<
AgentRunEventBody,
{ type: "subagent_spawned" | "subagent_event" | "subagent_completed" }
>;
/** 调用能力时使用的业务身份;授权回调据此到 automation_grant / approval 查询 */
export interface AgentActor {
actorId: string;
@@ -36,6 +42,17 @@ export interface CreateRunSpec {
* (agent_run_event 中无记录时为 0),保证 (run_id, seq) 不冲突;默认 0。
*/
firstSeq?: number;
/**
* 子代理:本会话派生的子会话已用掉的量(工具调用 / token / 费用)。父会话总预算含子会话用量——
* 运行时在每次工具调用与每轮请求前把它加到自己的用量上再比预算。由调用方缓存,须同步返回。
*/
externalUsage?: () => ExternalUsage;
}
export interface ExternalUsage {
toolCalls: number;
totalTokens: number;
costUsd: number;
}
export interface RunOutcome {
@@ -62,6 +79,13 @@ export interface AgentRunHandle {
/** 崩溃/重启后继续未完成的操作 */
resume(): Promise<RunOutcome>;
abort(reason?: string): Promise<void>;
/**
* 往本会话事件流追加一条子代理事件(subagent_*),与运行时自身事件共用 seq 计数(单写者)。
* 会话已关闭时返回 false,调用方自行落库。
*/
appendEvent(body: SubagentEventBody): boolean;
/** 本会话自身已用量(不含子会话) */
stats(): { toolCalls: number; usage: AgentUsageTotals };
compact(customInstructions?: string): Promise<void>;
close(): Promise<void>;
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const AgentSettingsSchema = z.object({
threshold: z.number().min(0.5).max(0.95).default(0.8),
})
.default({ mode: "threshold", threshold: 0.8 }),
/** v0.1 未实现子代理;false 时忽略,true 也只记录(由 agent.delegate 能力后续实现) */
/** 子代理开关(仅记录);派生由服务端 agent.spawn 能力实现,开关与并发/深度上限在服务端校验 */
subagentsEnabled: z.boolean().default(false),
maxToolCalls: z.number().int().min(1).default(40),
maxDurationMs: z