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:
1 parent
daa85a8bb4
commit
459991421e
27 files changed
+1720
-32
No files matched your search
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user