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

+54
View File
@@ -16,6 +16,9 @@ export const AgentRunEventType = z.enum([
"compaction",
"run_completed",
"run_failed",
"subagent_spawned",
"subagent_event",
"subagent_completed",
]);
export type AgentRunEventType = z.infer<typeof AgentRunEventType>;
@@ -78,6 +81,36 @@ const RunFailed = z.object({
budget: AgentBudgetKind.optional(),
});
// 子代理(agent.spawn):子会话的事件镜像进父会话事件流,由父会话的运行进程按父 seq 写入(单写者,seq 不冲突)。
const SubagentSpawned = z.object({
type: z.literal("subagent_spawned"),
childRunId: z.string(),
goal: z.string(),
mode: z.enum(["execute", "plan_only"]),
depth: NonNegInt,
capabilities: z.array(z.string()),
/** 从父会话划拨给子会话的预算 */
budget: z.record(z.string(), z.number()),
});
/** 子会话的一条事件(工具调用 / 开始 / 结束);childSeq 是子会话内的 seq,正文已脱敏截断 */
const SubagentEvent = z.object({
type: z.literal("subagent_event"),
childRunId: z.string(),
childSeq: NonNegInt,
childType: z.string(),
capabilityId: z.string().optional(),
summary: z.string(),
});
const SubagentCompleted = z.object({
type: z.literal("subagent_completed"),
childRunId: z.string(),
status: z.string(),
/** 子会话汇报摘要(业务核查结论 + 未经核查的模型文本),已脱敏截断 */
summary: z.string(),
usage: AgentUsageTotals,
toolCalls: NonNegInt,
});
/** 事件主体(不含 runId/seq/at) */
export const AgentRunEventBody = z.discriminatedUnion("type", [
RunStarted,
@@ -89,6 +122,9 @@ export const AgentRunEventBody = z.discriminatedUnion("type", [
Compaction,
RunCompleted,
RunFailed,
SubagentSpawned,
SubagentEvent,
SubagentCompleted,
]);
export type AgentRunEventBody = z.infer<typeof AgentRunEventBody>;
@@ -111,6 +147,9 @@ export const AgentRunEvent = z.discriminatedUnion("type", [
Compaction.extend(EventMeta),
RunCompleted.extend(EventMeta),
RunFailed.extend(EventMeta),
SubagentSpawned.extend(EventMeta),
SubagentEvent.extend(EventMeta),
SubagentCompleted.extend(EventMeta),
]);
export type AgentRunEvent = AgentRunEventBody & { runId: string; seq: number; at: string };
@@ -176,6 +215,21 @@ export function agentRunEventToBusEvent(e: AgentRunEvent): TypedEvent | null {
else if (e.type === "tool_call_rejected") [capabilityId, summary] = [e.capabilityId ?? e.toolName, e.reason];
return { type: "agent.tool_call", payload: { runId: e.runId, runSeq: e.seq, capabilityId, phase, summary } };
}
// 子会话事件:借 agent.tool_call(能力 agent.spawn)提示父会话页"有新事件",页面按 seq 补读
if (e.type === "subagent_spawned" || e.type === "subagent_event" || e.type === "subagent_completed") {
const phase =
e.type === "subagent_completed" ? (e.status === "succeeded" ? "succeeded" : "failed") : ("started" as const);
const summary =
e.type === "subagent_spawned"
? `派生子会话 ${e.childRunId}:${e.goal}`
: e.type === "subagent_event"
? `子会话 ${e.childRunId}:${e.childType} ${e.capabilityId ?? ""} ${e.summary}`.trim()
: `子会话 ${e.childRunId} ${e.status}:${e.summary}`;
return {
type: "agent.tool_call",
payload: { runId: e.runId, runSeq: e.seq, capabilityId: "agent.spawn", phase, summary: summary.slice(0, 600) },
};
}
if (e.type === "text_delta") {
return { type: "agent.text_delta", payload: { runId: e.runId, runSeq: e.seq, delta: e.delta } };
}
+40
View File
@@ -68,6 +68,10 @@ export const AgentRun = EntityBase.extend({
mode: AgentRunMode,
/** 后台触发按根因去重(如 investigate:<decisionId>);用户委托为 null */
dedupeKey: z.string().nullable(),
/** 派生深度:用户/后台发起的会话为 0,agent.spawn 派生的子会话 = 父 + 1 */
depth: NonNegInt,
/** 父会话派生时给的理由 */
spawnReason: z.string().nullable(),
});
export type AgentRun = z.infer<typeof AgentRun>;
export const AgentRunCreate = AgentRun.pick({
@@ -314,6 +318,8 @@ export const AgentRunListQuery = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
status: AgentRunStatus.optional(),
trigger: AgentTrigger.optional(),
/** 只列某个会话直接派生的子会话 */
parentRunId: Id.optional(),
});
export type AgentRunListQuery = z.infer<typeof AgentRunListQuery>;
@@ -366,8 +372,42 @@ export const AgentRunMessage = EntityBase.extend({
});
export type AgentRunMessage = z.infer<typeof AgentRunMessage>;
/** 会话树节点(父会话 / 子会话,子会话页与父会话页互相跳转) */
export const AgentRunNode = z.object({
id: Id,
parentRunId: Id.nullable(),
goal: z.string(),
status: AgentRunStatus,
mode: AgentRunMode,
depth: NonNegInt,
spawnReason: z.string().nullable(),
toolCallCount: NonNegInt,
usage: AgentUsage,
summary: z.string().nullable(),
/** 能力白名单(继承信息展示) */
capabilities: z.array(z.string()),
budget: AgentBudget,
createdAt: Timestamp,
finishedAt: Timestamp.nullable(),
});
export type AgentRunNode = z.infer<typeof AgentRunNode>;
/** 本会话 + 全部子孙会话的用量合计(父会话总预算含子会话用量) */
export const AgentFamilyUsage = z.object({
runs: NonNegInt,
toolCalls: NonNegInt,
totalTokens: NonNegInt,
costUsd: z.number().nonnegative(),
});
export type AgentFamilyUsage = z.infer<typeof AgentFamilyUsage>;
export const AgentRunDetail = z.object({
run: AgentRun,
/** 父会话(子会话才有) */
parent: AgentRunNode.nullable().optional(),
/** 全部子孙会话(扁平,按 parentRunId 组树) */
children: z.array(AgentRunNode).optional(),
familyUsage: AgentFamilyUsage.optional(),
events: z.array(AgentRunEventRow),
/** 下一页起点(传给 afterSeq);null 表示已到最新 */
nextAfterSeq: z.number().int().nullable(),
+58
View File
@@ -186,6 +186,56 @@ export const InventoryQueryOutput = z.object({
),
});
// ── 子代理(W7-B) ──
/** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */
export const SubagentBudget = z.object({
maxToolCalls: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
maxCostUsd: z.number().positive().optional(),
maxDurationSec: z.number().int().positive().optional(),
});
export const AgentSpawnInput = z.object({
goal: z.string().trim().min(1).max(4000),
/** 为什么要派生(展示在会话树里) */
reason: z.string().max(500).optional(),
/** 必须是父会话能力白名单的子集;缺省继承父会话白名单 */
capabilityAllowlist: z.array(z.string()).min(1).max(50).optional(),
budget: SubagentBudget.optional(),
/** 不得高于父会话:plan_only 的父会话只能派生 plan_only 子会话;缺省同父会话 */
mode: z.enum(["execute", "plan_only"]).optional(),
/** 覆盖子会话使用的 Agent 上游;缺省同父会话 */
upstreamId: Id.optional(),
/** true:派生后等它结束再返回(等待不调用模型) */
await: z.boolean().default(false),
timeoutSec: z.number().int().min(1).max(3600).default(600),
});
export const SubagentResult = z.object({
runId: Id,
goal: z.string(),
status: z.string(),
/** 汇报摘要(业务核查结论 + 未经核查的模型文本),已脱敏截断;未结束时为 null */
summary: z.string().nullable(),
toolCalls: NonNegInt,
usage: z.object({ totalTokens: NonNegInt, costUsd: z.number().nonnegative() }),
pending: z.boolean(),
});
export const AgentSpawnOutput = z.object({
runId: Id,
depth: NonNegInt,
mode: z.enum(["execute", "plan_only"]),
capabilities: z.array(z.string()),
budget: SubagentBudget,
/** await=true 时的结果 */
result: SubagentResult.nullable(),
});
export const AgentAwaitInput = z.object({
/** 缺省等本会话派生的全部子会话 */
runIds: z.array(Id).min(1).max(20).optional(),
timeoutSec: z.number().int().min(1).max(3600).default(600),
});
export const AgentAwaitOutput = z.object({ results: z.array(SubagentResult), allDone: z.boolean() });
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
@@ -203,6 +253,8 @@ export const CAPABILITY_IO = {
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
"agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput },
"agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
type CapabilityIo = typeof CAPABILITY_IO;
@@ -252,3 +304,9 @@ export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
export type AgentSpawnInput = z.infer<typeof AgentSpawnInput>;
export type AgentSpawnOutput = z.infer<typeof AgentSpawnOutput>;
export type AgentAwaitInput = z.infer<typeof AgentAwaitInput>;
export type AgentAwaitOutput = z.infer<typeof AgentAwaitOutput>;
export type SubagentResult = z.infer<typeof SubagentResult>;
export type SubagentBudget = z.infer<typeof SubagentBudget>;
+26
View File
@@ -66,6 +66,8 @@ export const CAPABILITY_IDS = [
"policy.preview",
"policy.apply",
"inventory.query",
"agent.spawn",
"agent.await",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
@@ -271,6 +273,30 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
true,
false,
),
// 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用,
// "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验
"agent.spawn": d(
"agent.spawn",
"派生子会话",
"把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报",
"propose",
"AgentSpawn",
"organize.execute",
"medium",
true,
false,
),
"agent.await": d(
"agent.await",
"等待子会话",
"等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending",
"read",
"AgentAwait",
"read",
"low",
true,
false,
),
};
function d(
+32 -2
View File
@@ -75,7 +75,17 @@ export const PiSettings = z.object({
/** 达到上下文预算的该比例时触发压缩 */
thresholdRatio: z.number().min(0.3).max(0.95),
}),
/** @deprecated 同 subagents.enabled(合并设置时两者保持一致);新代码读 subagents.enabled */
subagentsEnabled: z.boolean(),
/**
* 子代理(agent.spawn,W7-B):关闭时不向模型提供派生能力、服务端拒绝派生;
* maxConcurrent = 一个会话同时在跑的子会话上限,maxDepth = 派生层数上限(1 = 子会话不能再派生)。
*/
subagents: z.object({
enabled: z.boolean(),
maxConcurrent: z.number().int().min(1).max(8),
maxDepth: z.number().int().min(1).max(3),
}),
/** @deprecated */
apiKeySecretRef: Id.nullable(),
});
@@ -182,6 +192,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
contextBudget: 128_000,
compaction: { enabled: true, thresholdRatio: 0.8 },
subagentsEnabled: false,
subagents: { enabled: false, maxConcurrent: 2, maxDepth: 1 },
apiKeySecretRef: null,
},
budget: {
@@ -220,6 +231,7 @@ export const GlobalSettingsUpdate = z.object({
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
subagents: PiSettings.shape.subagents.partial().optional(),
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
apiKey: z.string().min(1).max(4096).nullable().optional(),
})
@@ -238,20 +250,38 @@ export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
const { apiKey: _apiKey, compaction, subagents, ...pi } = patch.pi ?? {};
const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {};
return {
automation: { ...current.automation, ...patch.automation },
cache: { ...current.cache, ...patch.cache },
organize: { ...current.organize, ...patch.organize },
retention: { ...current.retention, ...patch.retention },
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
pi: mergePi(current.pi, pi, compaction, subagents),
budget: { ...current.budget, ...patch.budget },
agent: { ...current.agent, ...patch.agent },
metadata: { ...current.metadata, ...metadata },
};
}
/** 子代理开关:subagents.enabled 优先,旧字段 subagentsEnabled 作别名,合并后两者一致 */
function mergePi(
current: PiSettings,
pi: Partial<Omit<PiSettings, "compaction" | "subagents">>,
compaction: Partial<PiSettings["compaction"]> | undefined,
subagents: Partial<PiSettings["subagents"]> | undefined,
): PiSettings {
const base = current.subagents ?? DEFAULT_SETTINGS.pi.subagents;
const enabled = subagents?.enabled ?? pi.subagentsEnabled ?? base.enabled;
return {
...current,
...pi,
compaction: { ...current.compaction, ...compaction },
subagents: { ...base, ...subagents, enabled },
subagentsEnabled: enabled,
};
}
/** settings 表行 */
export const SettingEntry = z.object({
key: SettingsKey,