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

@@ -3,6 +3,7 @@ import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent";
import type { CapabilitySpec } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { SettingsReader } from "../types.ts";
import { exceedsAncestors } from "./subagents.ts";
// 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中
// - 单次审批 approval:status=approved、绑定 plan 类型 + plan id + payload hash + revision、未过期(用后 consumed);或
@@ -114,6 +115,19 @@ export function createAuthorizer(deps: {
}
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
// 子代理:子会话的授权以全部祖先会话为上限(白名单 / 类别取交集),模型不能靠派生提升权限
const runId = ctx.agentRunId && isUuid(ctx.agentRunId) ? ctx.agentRunId : null;
if (runId) {
const over = await exceedsAncestors(db, runId, d.id, d.kind);
if (over) return { allowed: false, reason: over };
}
// agent.spawn 只建有界子会话(无外部副作用);开关 / 深度 / 并发 / 白名单 / 预算由处理器校验
if (d.id === "agent.spawn") {
return runId && (ctx.source === "agent" || ctx.source === "task")
? { allowed: true }
: { allowed: false, reason: "只有 Agent 会话可以派生子会话" };
}
// 1) 单次审批
const ap = APPROVAL_PLAN[d.id];
const approvalId = typeof inp.approvalId === "string" ? inp.approvalId : null;
@@ -203,6 +217,8 @@ export function createAuthorizer(deps: {
};
}
const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
/** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */
function kindCovered(kinds: readonly string[], entityKind: string): boolean {
if (kinds.includes(entityKind)) return true;
@@ -2,6 +2,7 @@ import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type TaskStatus } from "@umt/contracts";
import { sql, type TaskRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { cascadeCancel } from "./subagents.ts";
// 任务类能力:task.inspect / task.retry / task.cancel。业务任务行(task 表)是时间线投影,
// 引擎只管推进;重试 = 按原输入重新入队(副作用由各处理器的操作账本去重),取消 = 写取消标志 + 引擎取消。
@@ -172,6 +173,7 @@ export async function cancelTask(ctx: ServerContext, taskId: string, reason: str
.where("id", "=", t.subject_id)
.where("cancel_requested_at", "is", null)
.execute();
if (t.kind === "agentRun" && t.subject_id) await cascadeCancel(ctx, t.subject_id, reason);
let cancelled = false;
if (t.engine_run_id) cancelled = await ctx.engine.cancel(t.engine_run_id, reason).catch(() => false);
const row = await ctx.db
+21
View File
@@ -3,6 +3,8 @@ import { registerTaskCapabilities } from "./capabilities.ts";
import { registerDownloadRecoveryCapabilities } from "./capabilities-download.ts";
import { registerAgentRoutes } from "./routes.ts";
import type { AgentEnv, AgentModuleOptions } from "./runner.ts";
import { startRun } from "./service.ts";
import { registerSubagentCapabilities } from "./subagents.ts";
import { agentOutboxRoutes, registerAgentTasks } from "./tasks.ts";
export type { AgentModuleOptions } from "./runner.ts";
@@ -36,6 +38,25 @@ export function createAgentModule(opts: AgentModuleOptions = {}): ServerModule &
registerCapabilities(registry, ctx) {
registerTaskCapabilities(registry, ctx);
registerDownloadRecoveryCapabilities(registry, ctx, opts);
registerSubagentCapabilities(registry, ctx, {
start: (p) =>
startRun(ctx, {
goal: p.goal,
mode: p.mode,
trigger: "subagent",
// 子会话继承父会话的发起人(授权上限随之继承)
createdBy: p.parent.created_by ?? `agent:${p.parent.id}`,
dedupeKey: p.dedupeKey,
budget: p.budget,
subagent: {
parentRunId: p.parent.id,
depth: p.parent.depth + 1,
reason: p.reason,
upstreamId: p.upstreamId,
scope: { capabilities: p.capabilities, allowedKinds: p.allowedKinds },
},
}),
});
},
};
}
+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),
+31 -3
View File
@@ -27,6 +27,7 @@ import { type RunUpstream, resolveRunUpstream } from "../pi/index.ts";
import type { ServerContext } from "../types.ts";
import { DEMO_SCRIPTS, parseFakeScripts } from "./fake-scripts.ts";
import { buildGoalReport } from "./report.ts";
import { attachLiveRun, type LiveRun, subagentSettings } from "./subagents.ts";
// runAgent 任务的执行体:从设置装配 Pi(或 fake)运行时 → 事件先写 agent_run_event 再推 SSE →
// 崩溃恢复(pendingOperations>0 时先按幂等键对账未知结果再 resume)→ 消费追加消息 → 汇报以业务表为准。
@@ -253,12 +254,22 @@ export async function reconcilePending(
}
}
function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
function systemPromptFor(
run: AgentRunRow,
scope: RunScope,
sub: { canSpawn: boolean; maxConcurrent: number; maxDepth: number },
): string {
const constraints: string[] = [
`可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
];
if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。");
if (sub.canSpawn)
constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
);
if (run.parent_run_id) constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。");
if (run.trigger === "event" && run.mode === "execute")
constraints.push(
"本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
@@ -379,21 +390,36 @@ export async function drive(
scopeIds: [] as string[],
...(scope.allowedKinds ? { allowedKinds: scope.allowedKinds } : {}),
};
// 子代理:开关关闭或已到层数上限时不向模型提供 agent.spawn;没有子会话可等时也不提供 agent.await
const sub = subagentSettings(s);
const canSpawn = sub.enabled && run.depth < sub.maxDepth;
const hasChildren = !!(await ctx.db
.selectFrom("agent_run")
.select("id")
.where("parent_run_id", "=", runId)
.executeTakeFirst());
const capabilities = (scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG)).filter(
(id) => (id === "agent.spawn" ? canSpawn : id === "agent.await" ? canSpawn || hasChildren : true),
);
let external: LiveRun | null = null;
let handle: AgentRunHandle;
try {
handle = await runtime.createRun({
runId,
goal: run.goal,
systemPrompt: systemPromptFor(run, scope),
capabilities: scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG),
systemPrompt: systemPromptFor(run, { ...scope, capabilities }, { canSpawn, ...sub }),
capabilities,
settings,
sessionDir: sessionDirOf(env, s.agent.sessionDir),
actor,
firstSeq: maxSeq?.m === null || maxSeq?.m === undefined ? 0 : Number(maxSeq.m) + 1,
externalUsage: () => external?.externalUsage() ?? { toolCalls: 0, totalTokens: 0, costUsd: 0 },
});
} catch (err) {
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };
}
const liveRun = await attachLiveRun(env, runId, handle);
external = liveRun.run;
// 取消传播:任务取消信号或 agent_run.cancel_requested_at → 中止会话
let aborting = false;
@@ -411,6 +437,7 @@ export async function drive(
.executeTakeFirst()
.catch(() => null);
if (r?.cancel_requested_at) abortOnce();
void liveRun.run.pump();
}, 1000);
try {
@@ -454,6 +481,7 @@ export async function drive(
} finally {
clearInterval(watch);
signal.removeEventListener("abort", abortOnce);
await liveRun.stop();
await handle.close();
}
}
+50 -7
View File
@@ -5,12 +5,14 @@ import type {
AgentRunMode,
AgentRunStatus,
AgentTrigger,
CapabilityKind,
DecisionKind,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, INVESTIGATE_CAPABILITIES, publishStatus, scopeFor } from "./runner.ts";
import { cascadeCancel, descendants, descendantUsage, recordChildCompletion } from "./subagents.ts";
// agent_run 生命周期:建行 + 业务任务行 + 入队 runAgent;追加消息 / 中止;终态汇报(业务表核查)与后台调查去重。
@@ -35,6 +37,14 @@ export interface StartRunInput {
dedupeKey?: string | null;
budget?: AgentBudget;
allowedKinds?: ("read" | "preview" | "propose")[];
/** 子代理(agent.spawn):父会话、深度、理由、上游与已算好的范围(白名单 ⊆ 父、类别取交集) */
subagent?: {
parentRunId: string;
depth: number;
reason: string | null;
upstreamId: string | null;
scope: { capabilities: string[]; allowedKinds: CapabilityKind[] | null };
};
}
const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
@@ -42,17 +52,23 @@ const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
/** 建 agent_run + task 行,入队 runAgent(不在调用方进程里跑模型) */
export async function startRun(ctx: ServerContext, p: StartRunInput): Promise<{ run: AgentRunRow; created: boolean }> {
let scope: ReturnType<typeof scopeFor>;
try {
scope = scopeFor(p.mode, p.capabilityAllowlist);
} catch (err) {
throw new AgentRunError("bad_request", (err as Error).message);
if (p.subagent) scope = p.subagent.scope;
else {
try {
scope = scopeFor(p.mode, p.capabilityAllowlist);
} catch (err) {
throw new AgentRunError("bad_request", (err as Error).message);
}
}
if (p.allowedKinds) scope = { ...scope, allowedKinds: p.allowedKinds };
const sub = p.subagent;
const ins = await ctx.db.transaction().execute(async (trx) => {
const row = await sql<AgentRunRow>`
insert into agent_run (trigger, goal, status, scope, budget, mode, dedupe_key, created_by)
insert into agent_run (trigger, goal, status, scope, budget, mode, dedupe_key, created_by,
parent_run_id, depth, spawn_reason, upstream_id)
values (${p.trigger}, ${p.goal}, 'queued', ${JSON.stringify(scope)}::jsonb, ${JSON.stringify(p.budget ?? {})}::jsonb,
${p.mode}, ${p.dedupeKey ?? null}, ${p.createdBy})
${p.mode}, ${p.dedupeKey ?? null}, ${p.createdBy},
${sub?.parentRunId ?? null}, ${sub?.depth ?? 0}, ${sub?.reason ?? null}, ${sub?.upstreamId ?? null})
on conflict (dedupe_key) where dedupe_key is not null do nothing
returning *`.execute(trx);
const run = row.rows[0];
@@ -66,6 +82,7 @@ export async function startRun(ctx: ServerContext, p: StartRunInput): Promise<{
subject_type: "agent_run",
subject_id: run.id,
triggered_by: p.createdBy,
...(sub ? { parent_task_id: await parentTaskId(trx, sub.parentRunId) } : {}),
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -89,6 +106,11 @@ export async function startRun(ctx: ServerContext, p: StartRunInput): Promise<{
return { run: ins, created: true };
}
async function parentTaskId(trx: Kysely<DB>, parentRunId: string): Promise<string | null> {
const r = await trx.selectFrom("agent_run").select("task_id").where("id", "=", parentRunId).executeTakeFirst();
return r?.task_id ?? null;
}
async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
try {
const { runId } = await ctx.engine.enqueue(
@@ -157,6 +179,8 @@ export async function abortRun(ctx: ServerContext, runId: string, reason: string
.set({ cancel_requested_at: now, cancel_reason: reason ?? "用户中止" })
.where("id", "=", run.task_id)
.execute();
// 子代理共享取消状态:递归传播到全部未结束的子孙会话
await cascadeCancel(ctx, runId, reason ?? "用户中止");
return row;
}
@@ -214,6 +238,19 @@ export async function finalize(
}
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`;
// 子代理:父会话总预算含子会话用量
const kids = await descendants(ctx, runId);
if (kids.length) {
const own = (run.usage ?? {}) as { totalTokens?: number };
const fresh = await ctx.db
.selectFrom("agent_run")
.select(["tool_call_count", "usage"])
.where("id", "=", runId)
.executeTakeFirst();
const ownTokens = ((fresh?.usage ?? own) as { totalTokens?: number }).totalTokens ?? 0;
const ext = await descendantUsage(ctx, runId);
summary += `\n子会话 ${kids.length} 个;会话树合计工具调用 ${(fresh?.tool_call_count ?? run.tool_call_count) + ext.toolCalls} 次、token ${ownTokens + ext.totalTokens}。`;
}
// 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本
if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`;
await ctx.db
@@ -297,6 +334,12 @@ export async function finalize(
});
}
void publishStatus(ctx, runId, status, summary.slice(0, 2000));
if (status === "cancelled") await cascadeCancel(ctx, runId, "父会话已取消");
// 子会话结束:父会话不在运行时直接补记 subagent_completed(在运行时由父会话镜像)
if (run.parent_run_id && status !== "waiting") {
const fin = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirst();
if (fin) await recordChildCompletion(ctx, fin);
}
// 结束前刚到的追加消息:再唤醒一次
const pending = await ctx.db
.selectFrom("agent_run_message")
+661
View File
@@ -0,0 +1,661 @@
import {
type AgentRunHandle,
CapabilityError,
type CapabilityRegistry,
type ExternalUsage,
type HandlerContext,
redactString,
type SubagentEventBody,
} from "@umt/agent";
import {
type AgentBudget,
type AgentRunEvent,
type AgentRunMode,
type AgentRunStatus,
type AgentUsageTotals,
agentRunEventToBusEvent,
CAPABILITY_CATALOG,
type CapabilityKind,
capabilitySpec,
type GlobalSettings,
type KnownCapabilityId,
type SubagentBudget,
type SubagentResult,
} from "@umt/contracts";
import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import type { AgentEnv, RunScope } from "./runner.ts";
// 子代理(W7-B,REQ §10 "一个会话处理一个有界任务;子代理继承父任务授权上限,共享总预算与取消状态"):
// - agent.spawn:父会话派生子 agent_run(parent_run_id / depth / spawn_reason),同 runAgent 任务、独立会话文件;
// 能力白名单 ⊆ 父、模式不高于父、预算从父剩余预算划拨;settings.pi.subagents{enabled,maxConcurrent,maxDepth}。
// - 并发:子会话在 runAgent 里先占名额(父会话下同时 running 的子会话 < maxConcurrent,咨询锁内原子判定),
// 没名额就 waitForFact 挂起(不占 worker、不占模型)。
// - agent.await:等子会话结束(轮询业务表,不调用模型),返回脱敏后的汇报摘要。
// - 事件:父会话运行进程把子会话事件按父 seq 镜像进父事件流(subagent_spawned / subagent_event / subagent_completed);
// 父会话已不在运行时,由直接写入器在咨询锁内补记 subagent_completed。
// - 取消:父会话取消递归传播到全部子孙会话;预算:父会话运行时把子孙会话用量计入自己的预算。
export const SUBAGENT_CAPABILITIES = ["agent.spawn", "agent.await"] as const;
/** 子会话"已结束":终态;waiting(模型已结束,只在等下载/入库落定)对父会话来说也算有结果 */
export const TERMINAL: AgentRunStatus[] = ["succeeded", "failed", "cancelled", "budget_exceeded"];
const SETTLED: AgentRunStatus[] = [...TERMINAL, "waiting"];
const ALL_KINDS: CapabilityKind[] = ["read", "preview", "propose", "approve", "execute"];
const PLAN_ONLY_KINDS: CapabilityKind[] = ["read", "preview", "propose"];
/** 一个会话最多派生的子会话数(预算之外的兜底) */
export const MAX_CHILDREN_PER_RUN = 10;
const MIRRORED_TYPES = [
"run_started",
"tool_call_start",
"tool_call_end",
"tool_call_rejected",
"run_completed",
"run_failed",
] as const;
type SubagentSettings = GlobalSettings["pi"]["subagents"];
export function subagentSettings(s: GlobalSettings): SubagentSettings {
return s.pi.subagents ?? { enabled: s.pi.subagentsEnabled, maxConcurrent: 2, maxDepth: 1 };
}
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
const safe = (s: string | null | undefined, n = 1500) => clip(redactString(s ?? ""), n);
// ── 进程内活跃会话(镜像泵 + 用量缓存) ──
export interface LiveRun {
handle: AgentRunHandle;
/** 把子会话的新事件 / 新派生 / 结束镜像进本会话事件流(串行) */
pump(): Promise<void>;
/** 子孙会话已用量(同步读缓存,pump 刷新) */
externalUsage(): ExternalUsage;
}
const live = new Map<string, LiveRun>();
export const liveRun = (runId: string) => live.get(runId);
/** drive 打开会话后调用;返回的 stop() 在关闭前做最后一次镜像 */
export async function attachLiveRun(
env: AgentEnv,
runId: string,
handle: AgentRunHandle,
): Promise<{ run: LiveRun; stop(): Promise<void> }> {
const db = env.ctx.db;
const prior = await db
.selectFrom("agent_run_event")
.select(["type", "payload"])
.where("run_id", "=", runId)
.where("type", "in", ["subagent_spawned", "subagent_event", "subagent_completed"])
.execute();
const spawned = new Set<string>();
const completed = new Set<string>();
const mirrored = new Map<string, number>();
for (const e of prior) {
const p = e.payload as { childRunId: string; childSeq?: number };
if (e.type === "subagent_spawned") spawned.add(p.childRunId);
else if (e.type === "subagent_completed") completed.add(p.childRunId);
else mirrored.set(p.childRunId, Math.max(mirrored.get(p.childRunId) ?? -1, p.childSeq ?? -1));
}
let usage: ExternalUsage = await descendantUsage(env.ctx, runId);
let chain: Promise<void> = Promise.resolve();
const once = async () => {
const children = await db
.selectFrom("agent_run")
.selectAll()
.where("parent_run_id", "=", runId)
.orderBy("created_at")
.execute();
for (const c of children) {
if (!spawned.has(c.id)) {
if (!handle.appendEvent(spawnedBody(c))) return;
spawned.add(c.id);
}
const events = await db
.selectFrom("agent_run_event")
.select(["seq", "type", "payload"])
.where("run_id", "=", c.id)
.where("seq", ">", mirrored.get(c.id) ?? -1)
.where("type", "in", [...MIRRORED_TYPES])
.orderBy("seq")
.limit(200)
.execute();
for (const e of events) {
if (!handle.appendEvent(childEventBody(c.id, Number(e.seq), e.type, e.payload as Record<string, unknown>)))
return;
mirrored.set(c.id, Number(e.seq));
}
if (TERMINAL.includes(c.status) && !completed.has(c.id)) {
if (!handle.appendEvent(completedBody(c))) return;
completed.add(c.id);
}
}
usage = await descendantUsage(env.ctx, runId);
};
const pump = () => {
const next = chain
.then(once)
.catch((err) => env.ctx.logger.warn({ runId, err: { message: (err as Error).message } }, "子会话事件镜像失败"));
chain = next;
return next;
};
const run: LiveRun = { handle, pump, externalUsage: () => usage };
live.set(runId, run);
return {
run,
async stop() {
await pump();
if (live.get(runId) === run) live.delete(runId);
},
};
}
function spawnedBody(c: AgentRunRow): SubagentEventBody {
const scope = (c.scope ?? {}) as Partial<RunScope>;
const budget = Object.fromEntries(
Object.entries((c.budget ?? {}) as Record<string, unknown>).filter(([, v]) => typeof v === "number"),
) as Record<string, number>;
return {
type: "subagent_spawned",
childRunId: c.id,
goal: safe(c.goal, 500),
mode: c.mode,
depth: c.depth,
capabilities: scope.capabilities ?? [],
budget,
};
}
function childEventBody(childId: string, seq: number, type: string, p: Record<string, unknown>): SubagentEventBody {
const str = (k: string) => (typeof p[k] === "string" ? (p[k] as string) : "");
const summary =
type === "tool_call_start"
? str("inputSummary")
: type === "tool_call_end"
? `${p.isError ? "失败 " : ""}${str("outputSummary")}`
: type === "tool_call_rejected"
? str("reason")
: type === "run_completed"
? str("finalText")
: type === "run_failed"
? `${str("reason")} ${str("message")}`
: type === "run_started"
? p.resumed
? "续跑"
: "开始"
: "";
return {
type: "subagent_event",
childRunId: childId,
childSeq: seq,
childType: type,
...(typeof p.capabilityId === "string" ? { capabilityId: p.capabilityId } : {}),
summary: safe(summary, 300),
};
}
function usageOf(r: { usage: unknown }): AgentUsageTotals {
const u = (r.usage ?? {}) as Partial<AgentUsageTotals>;
return {
inputTokens: u.inputTokens ?? 0,
outputTokens: u.outputTokens ?? 0,
cacheReadTokens: u.cacheReadTokens ?? 0,
cacheWriteTokens: u.cacheWriteTokens ?? 0,
totalTokens: u.totalTokens ?? 0,
costUsd: u.costUsd ?? 0,
};
}
function completedBody(c: AgentRunRow): SubagentEventBody {
return {
type: "subagent_completed",
childRunId: c.id,
status: c.status,
summary: safe(c.summary, 1500),
usage: usageOf(c),
toolCalls: c.tool_call_count,
};
}
// ── 用量与会话树 ──
/** 全部子孙会话(不含自身) */
export async function descendants(ctx: ServerContext, runId: string): Promise<AgentRunRow[]> {
const r = await sql<AgentRunRow>`
with recursive tree as (
select * from agent_run where parent_run_id = ${runId}
union all
select a.* from agent_run a join tree t on a.parent_run_id = t.id where t.depth < 16
)
select * from tree order by depth, created_at`.execute(ctx.db);
return r.rows;
}
/** 子孙会话已用量(工具调用实时;token/费用在各自本轮结束时写入) */
export async function descendantUsage(ctx: ServerContext, runId: string): Promise<ExternalUsage> {
const rows = await descendants(ctx, runId);
return rows.reduce(
(a, r) => {
const u = usageOf(r);
return {
toolCalls: a.toolCalls + r.tool_call_count,
totalTokens: a.totalTokens + u.totalTokens,
costUsd: a.costUsd + u.costUsd,
};
},
{ toolCalls: 0, totalTokens: 0, costUsd: 0 },
);
}
/** 祖先链(不含自身,近到远) */
export async function ancestors(db: Kysely<DB>, runId: string): Promise<AgentRunRow[]> {
const r = await sql<AgentRunRow & { lvl: number }>`
with recursive up as (
select p.*, 1 as lvl from agent_run c join agent_run p on p.id = c.parent_run_id where c.id = ${runId}
union all
select p.*, u.lvl + 1 from agent_run p join up u on p.id = u.parent_run_id where u.lvl < 16
)
select * from up order by lvl`.execute(db);
return r.rows;
}
// ── 取消传播 ──
/** 父会话取消 → 全部未结束的子孙会话写取消标志(各自的会话监视器中止模型、finalize 按取消收尾) */
export async function cascadeCancel(ctx: ServerContext, runId: string, reason: string): Promise<number> {
const rows = (await descendants(ctx, runId)).filter((r) => !TERMINAL.includes(r.status));
if (!rows.length) return 0;
const now = new Date();
const ids = rows.map((r) => r.id);
await ctx.db
.updateTable("agent_run")
.set({
cancel_requested_at: now,
error: JSON.stringify({ abortReason: `父会话已取消:${reason}`, by: `agent:${runId}` }),
})
.where("id", "in", ids)
.where("cancel_requested_at", "is", null)
.execute();
const taskIds = rows.map((r) => r.task_id).filter((x): x is string => !!x);
if (taskIds.length)
await ctx.db
.updateTable("task")
.set({ cancel_requested_at: now, cancel_reason: `父会话已取消:${reason}` })
.where("id", "in", taskIds)
.where("cancel_requested_at", "is", null)
.execute();
return rows.length;
}
// ── 父会话不在运行时:直接补记 subagent_completed(咨询锁内去重) ──
export async function recordChildCompletion(ctx: ServerContext, child: AgentRunRow): Promise<boolean> {
const parentId = child.parent_run_id;
if (!parentId || !TERMINAL.includes(child.status)) return false;
const event = await ctx.db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext(${`agent_run_event:${parentId}`}))`.execute(trx);
const parent = await trx.selectFrom("agent_run").select(["status"]).where("id", "=", parentId).executeTakeFirst();
// 父会话正在运行:由它的镜像泵按自身 seq 写入
if (!parent || parent.status === "running" || parent.status === "queued") return null;
const dup = await sql<{ n: number }>`
select count(*)::int as n from agent_run_event
where run_id = ${parentId} and type = 'subagent_completed' and payload->>'childRunId' = ${child.id}`.execute(
trx,
);
if ((dup.rows[0]?.n ?? 0) > 0) return null;
const max = await sql<{ m: string | null }>`
select max(seq)::text as m from agent_run_event where run_id = ${parentId}`.execute(trx);
const seq = max.rows[0]?.m === null || max.rows[0]?.m === undefined ? 0 : Number(max.rows[0].m) + 1;
const body = completedBody(child);
const { type, ...payload } = body;
const at = new Date();
await trx
.insertInto("agent_run_event")
.values({ run_id: parentId, seq, type, at, payload: JSON.stringify(payload) })
.execute();
await trx
.updateTable("agent_run")
.set({ last_event_seq: sql`greatest(last_event_seq, ${seq})` })
.where("id", "=", parentId)
.execute();
return { ...body, runId: parentId, seq, at: at.toISOString() } as AgentRunEvent;
});
if (!event) return false;
const bus = agentRunEventToBusEvent(event);
if (bus) void ctx.events.publish(bus.type, bus.payload);
return true;
}
/** 父会话收尾后:把已结束但还没记进父事件流的子会话补记上 */
export async function catchUpChildren(ctx: ServerContext, runId: string): Promise<number> {
const rows = await ctx.db
.selectFrom("agent_run")
.selectAll()
.where("parent_run_id", "=", runId)
.where("status", "in", TERMINAL)
.execute();
let n = 0;
for (const r of rows) if (await recordChildCompletion(ctx, r)) n++;
return n;
}
// ── 并发名额 ──
/**
* 子会话占名额:父会话下同时 running 的子会话 < maxConcurrent 时把自己置为 running(咨询锁内原子判定)。
* 返回 "claimed" | "cancelled" | null(没名额,继续等)。非子会话或已占过名额直接 "claimed"。
*/
export async function claimSubagentSlot(ctx: ServerContext, runId: string): Promise<"claimed" | "cancelled" | null> {
const s = subagentSettings(await ctx.settings.all());
return ctx.db.transaction().execute(async (trx) => {
const run = await trx
.selectFrom("agent_run")
.select(["parent_run_id", "status", "cancel_requested_at", "started_at"])
.where("id", "=", runId)
.executeTakeFirst();
if (!run?.parent_run_id) return "claimed";
if (run.cancel_requested_at) return "cancelled";
if (run.started_at) return "claimed";
await sql`select pg_advisory_xact_lock(hashtext(${`subagent_slot:${run.parent_run_id}`}))`.execute(trx);
const busy = await sql<{ n: number }>`
select count(*)::int as n from agent_run
where parent_run_id = ${run.parent_run_id} and id <> ${runId} and status = 'running'`.execute(trx);
if ((busy.rows[0]?.n ?? 0) >= s.maxConcurrent) return null;
await trx
.updateTable("agent_run")
.set({ status: "running", started_at: new Date() })
.where("id", "=", runId)
.execute();
return "claimed";
});
}
// ── 预算划拨 ──
interface Limits {
maxToolCalls: number;
maxTokens: number | null;
maxCostUsd: number | null;
maxDurationSec: number;
}
function limitsOf(run: AgentRunRow, s: GlobalSettings): Limits {
const b = (run.budget ?? {}) as AgentBudget;
return {
maxToolCalls: b.maxToolCalls ?? s.agent.maxToolCalls,
maxTokens: b.maxTokens ?? s.agent.maxTokensPerRun ?? null,
maxCostUsd:
b.maxCostUsd ?? (s.agent.maxCostUsdPerRun && s.agent.maxCostUsdPerRun > 0 ? s.agent.maxCostUsdPerRun : null),
maxDurationSec: b.maxDurationSec ?? s.agent.maxDurationSec,
};
}
/**
* 父会话剩余预算 = 上限 − 自身已用 − Σ 子会话占用(已结束按实际用量,未结束按划拨额与实际用量取大)。
* 子会话预算:请求值(超过剩余直接拒绝)或剩余的一半。
*/
export async function allocateBudget(
ctx: ServerContext,
parent: AgentRunRow,
s: GlobalSettings,
requested: SubagentBudget | undefined,
): Promise<SubagentBudget> {
const L = limitsOf(parent, s);
const own = liveRun(parent.id)?.handle.stats();
const ownCalls = own?.toolCalls ?? parent.tool_call_count;
const ownUsage = own?.usage ?? usageOf(parent);
const kids = await descendants(ctx, parent.id);
const direct = kids.filter((k) => k.parent_run_id === parent.id);
let calls = ownCalls;
let tokens = ownUsage.totalTokens;
let cost = ownUsage.costUsd;
for (const k of direct) {
const sub = [k, ...kids.filter((x) => x.depth > k.depth && isUnder(kids, x, k.id))];
const actual = sub.reduce(
(a, r) => ({
calls: a.calls + r.tool_call_count,
tokens: a.tokens + usageOf(r).totalTokens,
cost: a.cost + usageOf(r).costUsd,
}),
{ calls: 0, tokens: 0, cost: 0 },
);
const b = (k.budget ?? {}) as SubagentBudget;
const done = TERMINAL.includes(k.status);
calls += done ? actual.calls : Math.max(actual.calls, b.maxToolCalls ?? 0);
tokens += done ? actual.tokens : Math.max(actual.tokens, b.maxTokens ?? 0);
cost += done ? actual.cost : Math.max(actual.cost, b.maxCostUsd ?? 0);
}
const elapsed = parent.started_at ? (Date.now() - parent.started_at.getTime()) / 1000 : 0;
const remain = {
maxToolCalls: Math.floor(L.maxToolCalls - calls),
maxTokens: L.maxTokens === null ? null : Math.floor(L.maxTokens - tokens),
maxCostUsd: L.maxCostUsd === null ? null : L.maxCostUsd - cost,
maxDurationSec: Math.floor(L.maxDurationSec - elapsed),
};
const out: SubagentBudget = {};
const pick = <K extends keyof SubagentBudget>(k: K, left: number | null, label: string, min: number) => {
const want = requested?.[k];
if (left === null) {
if (want !== undefined) out[k] = want;
return;
}
if (left < min) throw new CapabilityError("budget", `本会话剩余${label}不足,不能再派生子会话(剩余 ${left})`);
if (want !== undefined && want > left)
throw new CapabilityError("budget", `子会话${label} ${want} 超过本会话剩余预算 ${left}`);
const v = want ?? (k === "maxCostUsd" ? left / 2 : Math.max(min, Math.floor(left / 2)));
out[k] = v as SubagentBudget[K];
};
pick("maxToolCalls", remain.maxToolCalls, "工具调用次数", 1);
pick("maxTokens", remain.maxTokens, " token", 1);
pick("maxCostUsd", remain.maxCostUsd, "费用", 0.0001);
pick("maxDurationSec", remain.maxDurationSec, "时长(秒)", 5);
return out;
}
function isUnder(all: AgentRunRow[], x: AgentRunRow, rootId: string): boolean {
let cur: AgentRunRow | undefined = x;
for (let i = 0; cur && i < 16; i++) {
if (cur.parent_run_id === rootId) return true;
const pid: string | null = cur.parent_run_id;
cur = all.find((r) => r.id === pid);
}
return false;
}
// ── 能力白名单与模式 ──
export function spawnScope(
parent: AgentRunRow,
mode: AgentRunMode,
requested: readonly string[] | undefined,
): { capabilities: string[]; allowedKinds: CapabilityKind[] | null } {
const ps = (parent.scope ?? {}) as Partial<RunScope>;
const parentCaps = ps.capabilities?.length ? ps.capabilities : Object.keys(CAPABILITY_CATALOG);
const parentKinds = ps.allowedKinds ?? null;
const kinds = (mode === "plan_only" ? PLAN_ONLY_KINDS : ALL_KINDS).filter(
(k) => !parentKinds || parentKinds.includes(k),
);
const kindOf = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind;
if (requested?.length) {
const outside = requested.filter((id) => !parentCaps.includes(id));
if (outside.length) throw new CapabilityError("unauthorized", `子会话能力超出本会话白名单:${outside.join(", ")}`);
const tooStrong = requested.filter((id) => !kinds.includes(kindOf(id) as CapabilityKind));
if (tooStrong.length)
throw new CapabilityError("unauthorized", `子会话模式/授权上限不允许这些能力:${tooStrong.join(", ")}`);
}
const caps = (requested?.length ? [...new Set(requested)] : parentCaps).filter((id) =>
kinds.includes(kindOf(id) as CapabilityKind),
);
return {
capabilities: caps,
allowedKinds: kinds.length === ALL_KINDS.length ? null : kinds,
};
}
// ── 等待子会话 ──
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
});
function resultOf(r: AgentRunRow): SubagentResult {
const u = usageOf(r);
const settled = SETTLED.includes(r.status);
return {
runId: r.id,
goal: safe(r.goal, 300),
status: r.status,
summary: settled ? safe(r.summary, 1500) : null,
toolCalls: r.tool_call_count,
usage: { totalTokens: u.totalTokens, costUsd: u.costUsd },
pending: !settled,
};
}
/** 轮询业务表等子会话结束(不调用模型);父会话取消 / 调用被中止 / 超时即返回当前状态 */
export async function awaitChildren(
ctx: ServerContext,
parentId: string,
ids: readonly string[] | undefined,
timeoutSec: number,
signal: AbortSignal,
pollMs = 500,
): Promise<{ results: SubagentResult[]; allDone: boolean }> {
const deadline = Date.now() + timeoutSec * 1000;
for (;;) {
let q = ctx.db.selectFrom("agent_run").selectAll().where("parent_run_id", "=", parentId);
if (ids?.length) q = q.where("id", "in", [...ids]);
const rows = await q.orderBy("created_at").execute();
if (ids?.length && rows.length !== new Set(ids).size) {
const missing = ids.filter((id) => !rows.some((r) => r.id === id));
throw new CapabilityError("not_found", `不是本会话派生的子会话:${missing.join(", ")}`);
}
await liveRun(parentId)?.pump();
const allDone = rows.every((r) => SETTLED.includes(r.status));
const parent = await ctx.db
.selectFrom("agent_run")
.select("cancel_requested_at")
.where("id", "=", parentId)
.executeTakeFirst();
if (allDone || signal.aborted || parent?.cancel_requested_at || Date.now() >= deadline)
return { results: rows.map(resultOf), allDone };
await sleep(pollMs, signal);
}
}
// ── 能力处理器 ──
export interface SpawnDeps {
/** 建子 agent_run + 任务行并入队 runAgent(service.startRun) */
start(p: {
parent: AgentRunRow;
goal: string;
mode: AgentRunMode;
capabilities: string[];
allowedKinds: CapabilityKind[] | null;
budget: SubagentBudget;
upstreamId: string | null;
reason: string | null;
dedupeKey: string;
}): Promise<{ run: AgentRunRow; created: boolean }>;
}
async function parentOf(ctx: ServerContext, c: HandlerContext): Promise<AgentRunRow> {
const id = c.agentRunId;
if (!id) throw new CapabilityError("precondition", "只有 Agent 会话可以派生 / 等待子会话");
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", id).executeTakeFirst();
if (!run) throw new CapabilityError("not_found", "Agent 会话不存在");
return run;
}
export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx: ServerContext, deps: SpawnDeps) {
registry.register(capabilitySpec("agent.spawn"), async (input, c) => {
const parent = await parentOf(ctx, c);
const s = await ctx.settings.all();
const cfg = subagentSettings(s);
if (!cfg.enabled) throw new CapabilityError("precondition", "子代理未开启(设置 → Agent → 允许子代理)");
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
const existing = await ctx.db
.selectFrom("agent_run")
.selectAll()
.where("dedupe_key", "=", dedupeKey)
.executeTakeFirst();
let child = existing;
if (!child) {
if (parent.depth + 1 > cfg.maxDepth)
throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`);
const mode: AgentRunMode = input.mode ?? parent.mode;
if (parent.mode === "plan_only" && mode === "execute")
throw new CapabilityError("unauthorized", "只给方案的会话只能派生只给方案的子会话");
const scope = spawnScope(parent, mode, input.capabilityAllowlist);
if (!scope.capabilities.length) throw new CapabilityError("precondition", "子会话没有可用能力");
const count = await sql<{ n: number }>`
select count(*)::int as n from agent_run where parent_run_id = ${parent.id}`.execute(ctx.db);
if ((count.rows[0]?.n ?? 0) >= MAX_CHILDREN_PER_RUN)
throw new CapabilityError("budget", `一个会话最多派生 ${MAX_CHILDREN_PER_RUN} 个子会话`);
const budget = await allocateBudget(ctx, parent, s, input.budget);
const r = await deps.start({
parent,
goal: input.goal,
mode,
capabilities: scope.capabilities,
allowedKinds: scope.allowedKinds,
budget,
upstreamId: input.upstreamId ?? parent.upstream_id ?? null,
reason: input.reason ?? null,
dedupeKey,
});
child = r.run;
}
await liveRun(parent.id)?.pump();
const result = input.await
? ((await awaitChildren(ctx, parent.id, [child.id], input.timeoutSec, c.signal)).results[0] ?? null)
: null;
const cs = (child.scope ?? {}) as Partial<RunScope>;
return {
runId: child.id,
depth: child.depth,
mode: child.mode,
capabilities: cs.capabilities ?? [],
budget: (child.budget ?? {}) as SubagentBudget,
result,
};
});
registry.register(capabilitySpec("agent.await"), async (input, c) => {
const parent = await parentOf(ctx, c);
return awaitChildren(ctx, parent.id, input.runIds, input.timeoutSec, c.signal);
});
}
/**
* 授权上限:子会话的每次授权调用都要落在全部祖先会话的能力白名单与类别上限之内(再与自身范围取交集)。
* 返回拒绝原因或 null。
*/
export async function exceedsAncestors(
db: Kysely<DB>,
runId: string,
capabilityId: string,
kind: CapabilityKind,
): Promise<string | null> {
const chain = await ancestors(db, runId);
for (const a of chain) {
const sc = (a.scope ?? {}) as Partial<RunScope>;
if (sc.capabilities?.length && !sc.capabilities.includes(capabilityId))
return `超出父会话 ${a.id} 的能力白名单:${capabilityId}`;
if (sc.allowedKinds && !sc.allowedKinds.includes(kind)) return `父会话 ${a.id} 不允许 ${kind} 类能力`;
}
return null;
}
+23 -1
View File
@@ -17,6 +17,7 @@ import {
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { finalize } from "./service.ts";
import { catchUpChildren, claimSubagentSlot } from "./subagents.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
@@ -52,7 +53,26 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
.execute();
return null;
});
const res: DriveResult = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
// 子代理:先占并发名额(父会话下同时 running 的子会话 < maxConcurrent);没名额就挂起等待,不占 worker、不占模型
const parentId = await t.step.run("subagent-parent", async () => {
const r = await e.ctx.db
.selectFrom("agent_run")
.select("parent_run_id")
.where("id", "=", runId)
.executeTakeFirst();
return r?.parent_run_id ?? null;
});
let res: DriveResult;
const slot = parentId
? await t.step.waitForFact<"claimed" | "cancelled">("subagent-slot", {
check: () => claimSubagentSlot(e.ctx, runId),
timeout: "1s",
maxRounds: 3600,
})
: "claimed";
if (slot === null)
res = { status: "failed", reason: "error", budget: null, message: "等待子会话名额超时", finalText: "" };
else res = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
let settled: AgentGoalReport | null = null;
const run = await e.ctx.db
.selectFrom("agent_run")
@@ -102,6 +122,8 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
const out = await t.step.run("finalize", () =>
finalize(e, runId, res, settled, recovery ? { summaryPrefix: recovery.summary } : {}),
);
// 父会话收尾后才结束的子会话:补记进父事件流
await t.step.run("subagent-catchup", () => catchUpChildren(e.ctx, runId));
if (recovery) {
// 已在 finalize 内带上核实结论;保留该步骤兼容旧 run 的步骤历史(已带前缀时幂等跳过)
const rec = recovery;