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;
+346
View File
@@ -0,0 +1,346 @@
import { type FakeScript, type FakeToolResult, isCapabilityError } from "@umt/agent";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./harness.ts";
// E11 Pi 子代理(agent.spawn / agent.await):父会话"检查两部作品缺什么版本"派生两个只读子会话 → 等待 → 汇总。
// - maxConcurrent=1:两个子会话串行(运行区间不重叠);
// - 子会话超预算(划拨 1 次工具调用却调了 2 次)→ budget_exceeded,不影响父会话;
// - 父会话合计用量 = 自身 + 子会话;子会话事件按父 seq 镜像进父事件流,SSE 可见;
// - 白名单超父 / 只给方案派生执行 / 深度上限 / 开关关闭 → 拒绝;
// - 父会话中止 → 子会话 cancelled。
const hasPg = await pgAvailable();
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
type Any = any;
const PARENT_GOAL = "检查两部作品缺什么版本";
const ABORT_GOAL = "中止测试:派生后等待";
const DUNE = "子目标:检查《沙丘2》缺什么版本";
const OPPIE = "子目标:检查《奥本海默》缺什么版本";
const READONLY = ["inventory.query", "catalog.lookup"];
const lastOk = (results: FakeToolResult[], cap: string): Any =>
[...results].reverse().find((r) => r.capabilityId === cap && !r.isError)?.json;
const parentScript: FakeScript = {
name: "e11-parent",
match: PARENT_GOAL,
steps: [
{
text: "两部作品互不相关,各派一个只读子会话。",
toolCalls: [
{
capability: "agent.spawn",
input: { goal: DUNE, capabilityAllowlist: READONLY, mode: "plan_only", reason: "逐部检查" },
},
{
capability: "agent.spawn",
input: {
goal: OPPIE,
capabilityAllowlist: READONLY,
mode: "plan_only",
reason: "逐部检查(小预算)",
budget: { maxToolCalls: 1 },
},
},
],
},
{ toolCalls: [{ capability: "agent.await", input: { timeoutSec: 120 } }] },
],
finalText: (results) => {
const r = lastOk(results, "agent.await");
const lines = (r?.results ?? []).map((x: Any) => `${x.goal} → ${x.status}`);
return `汇总:${lines.join(";")}`;
},
};
const duneScript: FakeScript = {
name: "e11-dune",
match: DUNE,
steps: [{ toolCalls: [{ capability: "inventory.query", input: { query: "沙丘2", limit: 5 } }] }],
finalText: "《沙丘2》:库里没有 1080p 版本",
};
// 只划拨了 1 次工具调用:第二次调用超预算
const oppieScript: FakeScript = {
name: "e11-oppie",
match: OPPIE,
steps: [
{ toolCalls: [{ capability: "inventory.query", input: { query: "奥本海默", limit: 5 } }] },
{ toolCalls: [{ capability: "catalog.lookup", input: { query: "奥本海默" } }] },
],
finalText: "不应走到这里",
};
const abortParentScript: FakeScript = {
name: "e11-abort",
match: ABORT_GOAL,
steps: [
{
toolCalls: [
{ capability: "agent.spawn", input: { goal: "子目标:慢任务甲", capabilityAllowlist: READONLY } },
{ capability: "agent.spawn", input: { goal: "子目标:慢任务乙", capabilityAllowlist: READONLY } },
],
},
{ toolCalls: [{ capability: "agent.await", input: { timeoutSec: 600 } }] },
],
finalText: "不应走到这里",
};
const slowChild: FakeScript = {
name: "e11-slow",
match: /^子目标:慢任务/,
steps: [{ toolCalls: [{ capability: "inventory.query", input: { limit: 1 } }] }],
finalText: "done",
};
describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
let e: E2E;
beforeAll(async () => {
e = await createE2E("e11", { fakeScripts: [parentScript, duneScript, oppieScript, abortParentScript, slowChild] });
await e.settings({ pi: { subagents: { enabled: true, maxConcurrent: 1, maxDepth: 1 } } });
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
const settle = (runId: string, statuses: string[], ms = 90_000) =>
waitFor(
async () => {
const r = await runRow(e, runId);
return statuses.includes(r.status) ? r : null;
},
ms,
`run ${runId} → ${statuses.join("/")}`,
);
it("派生两个只读子会话并等待 → 汇总;串行、子超预算不影响父、预算合计、子事件进父事件流", async () => {
const seen: Any[] = [];
const off = e.bus.subscribe((ev) => {
if (ev.type === "agent.tool_call") seen.push(ev);
});
const r = await e.api("POST", "/agent/runs", {
goal: PARENT_GOAL,
mode: "plan_only",
capabilityAllowlist: [...READONLY, "agent.spawn", "agent.await"],
});
expect(r.status).toBe(201);
const parentId = r.body.data.id as string;
const parent = await settle(parentId, ["succeeded", "failed", "cancelled", "budget_exceeded"]);
off();
expect(parent.status).toBe("succeeded");
const kids = await e.db
.selectFrom("agent_run")
.selectAll()
.where("parent_run_id", "=", parentId)
.orderBy("created_at")
.execute();
expect(kids.map((k) => k.goal)).toEqual([DUNE, OPPIE]);
for (const k of kids) {
expect(k).toMatchObject({ trigger: "subagent", depth: 1, mode: "plan_only", created_by: parent.created_by });
expect((k.scope as Any).capabilities).toEqual(READONLY);
}
const [dune, oppie] = kids as [(typeof kids)[0], (typeof kids)[0]];
expect(dune.status).toBe("succeeded");
expect(dune.spawn_reason).toBe("逐部检查");
// 子超预算:budget_exceeded,不影响父会话
expect(oppie.status).toBe("budget_exceeded");
expect((oppie.budget as Any).maxToolCalls).toBe(1);
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 40,派生时父已用 1 次)
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(20);
// maxConcurrent=1:串行(谁先占到名额不定,但两个运行区间不重叠)
const [first, second] = [dune, oppie].sort((a, b) => a.started_at!.getTime() - b.started_at!.getTime()) as [
typeof dune,
typeof dune,
];
expect(first.finished_at).toBeTruthy();
expect(second.started_at!.getTime()).toBeGreaterThanOrEqual(first.finished_at!.getTime());
// 父会话汇总来自 agent.await 的结果
const events = await runEvents(e, parentId);
const done = events.find((x) => x.type === "run_completed");
expect((done!.payload as Any).finalText).toContain(`${DUNE} → succeeded`);
expect((done!.payload as Any).finalText).toContain(`${OPPIE} → budget_exceeded`);
// 子会话事件进父事件流(父 seq 单调、不冲突)
const types = events.map((x) => x.type);
expect(types.filter((t) => t === "subagent_spawned")).toHaveLength(2);
expect(types.filter((t) => t === "subagent_completed")).toHaveLength(2);
const mirrored = events.filter((x) => x.type === "subagent_event");
expect(
mirrored.some(
(x) => (x.payload as Any).childRunId === dune.id && (x.payload as Any).capabilityId === "inventory.query",
),
).toBe(true);
expect(events.map((x) => Number(x.seq))).toEqual(events.map((_, i) => i));
// 子会话结束记录先于 agent.await 的工具结果
const awaitEnd = events.find(
(x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "agent.await",
)!;
const lastCompleted = events.filter((x) => x.type === "subagent_completed").at(-1)!;
expect(Number(lastCompleted.seq)).toBeLessThan(Number(awaitEnd.seq));
// SSE:父会话 runId 上能看到子会话提示
expect(seen.some((x) => x.payload.runId === parentId && x.payload.capabilityId === "agent.spawn")).toBe(true);
// 预算合计:父会话合计 = 自身 + 子会话
const detail = await e.api("GET", `/agent/runs/${parentId}?afterSeq=-1&limit=500`);
const d = detail.body.data;
const ownTokens = (parent.usage as Any).totalTokens as number;
const kidTokens = kids.reduce((a, k) => a + ((k.usage as Any).totalTokens ?? 0), 0);
expect(kidTokens).toBeGreaterThan(0);
expect(d.familyUsage).toMatchObject({
runs: 3,
toolCalls: parent.tool_call_count + dune.tool_call_count + oppie.tool_call_count,
totalTokens: ownTokens + kidTokens,
});
expect(parent.tool_call_count).toBe(3);
expect(d.children.map((c: Any) => c.id)).toEqual([dune.id, oppie.id]);
expect(parent.summary).toContain("子会话 2 个");
// 子会话详情:父会话链接与继承信息
const child = await e.api("GET", `/agent/runs/${dune.id}`);
expect(child.body.data.parent).toMatchObject({ id: parentId, goal: PARENT_GOAL });
expect(child.body.data.run).toMatchObject({ parentRunId: parentId, depth: 1, spawnReason: "逐部检查" });
// 列表可按父会话过滤
const list = await e.api("GET", `/agent/runs?parentRunId=${parentId}`);
expect(list.body.data.items).toHaveLength(2);
}, 180_000);
it("拒绝:白名单超父、只给方案派生执行、深度上限、开关关闭", async () => {
const parent = await e.db
.insertInto("agent_run")
.values({
trigger: "user",
goal: "fixture parent",
status: "running",
mode: "plan_only",
scope: JSON.stringify({
capabilities: [...READONLY, "agent.spawn", "agent.await"],
allowedKinds: ["read", "preview", "propose"],
}),
budget: JSON.stringify({}),
created_by: "user:admin",
})
.returningAll()
.executeTakeFirstOrThrow();
const child = await e.db
.insertInto("agent_run")
.values({
trigger: "subagent",
goal: "fixture child",
status: "running",
mode: "plan_only",
scope: JSON.stringify({
capabilities: [...READONLY, "agent.spawn"],
allowedKinds: ["read", "preview", "propose"],
}),
budget: JSON.stringify({}),
created_by: "user:admin",
parent_run_id: parent.id,
depth: 1,
})
.returningAll()
.executeTakeFirstOrThrow();
const spawn = async (runId: string, input: Record<string, unknown>) => {
try {
await e.booted.ctx.capabilities.invoke("agent.spawn", input, {
actorId: "user:admin",
taskId: runId,
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
agentRunId: runId,
});
return null;
} catch (err) {
if (!isCapabilityError(err)) throw err;
return { code: err.code, message: err.message };
}
};
expect(await spawn(parent.id, { goal: "越权", capabilityAllowlist: ["release.search"] })).toMatchObject({
code: "unauthorized",
message: expect.stringContaining("超出本会话白名单"),
});
expect(await spawn(parent.id, { goal: "提权", mode: "execute" })).toMatchObject({
code: "unauthorized",
message: expect.stringContaining("只给方案"),
});
expect(await spawn(child.id, { goal: "再派生" })).toMatchObject({
code: "precondition",
message: expect.stringContaining("派生层数已达上限"),
});
await e.settings({ pi: { subagents: { enabled: false } } });
expect(await spawn(parent.id, { goal: "关闭后" })).toMatchObject({
code: "precondition",
message: expect.stringContaining("子代理未开启"),
});
await e.settings({ pi: { subagents: { enabled: true } } });
// 以上都没有建出子会话
const n = await e.db.selectFrom("agent_run").select("id").where("parent_run_id", "=", parent.id).execute();
expect(n.map((x) => x.id)).toEqual([child.id]);
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "in", [parent.id, child.id]).execute();
}, 60_000);
it("父会话中止 → 全部子会话 cancelled(排队等名额的也一样)", async () => {
// 先停 worker 建父会话,再塞一个占着唯一名额的子会话,保证真实子会话排队等名额
await e.worker?.stop();
const r = await e.api("POST", "/agent/runs", {
goal: ABORT_GOAL,
mode: "plan_only",
capabilityAllowlist: [...READONLY, "agent.spawn", "agent.await"],
});
const parentId = r.body.data.id as string;
const blocker = await e.db
.insertInto("agent_run")
.values({
trigger: "subagent",
goal: "占名额",
status: "running",
mode: "plan_only",
scope: JSON.stringify({ capabilities: READONLY }),
budget: JSON.stringify({}),
parent_run_id: parentId,
depth: 1,
started_at: new Date(),
})
.returning("id")
.executeTakeFirstOrThrow();
await e.startWorker();
const kids = await waitFor(
async () => {
const rows = await e.db
.selectFrom("agent_run")
.selectAll()
.where("parent_run_id", "=", parentId)
.where("id", "<>", blocker.id)
.execute();
const p = await runEvents(e, parentId);
const awaiting = p.some(
(x) => x.type === "tool_call_start" && (x.payload as Any).capabilityId === "agent.await",
);
return rows.length === 2 && awaiting ? rows : null;
},
60_000,
"父会话派生两个子会话并开始等待",
);
expect(kids.every((k) => k.status === "queued")).toBe(true);
const ab = await e.api("POST", `/agent/runs/${parentId}/abort`, { reason: "e11 中止" });
expect(ab.status).toBe(200);
const parent = await settle(parentId, ["cancelled", "succeeded", "failed"]);
expect(parent.status).toBe("cancelled");
for (const k of kids) {
const c = await settle(k.id, ["cancelled", "succeeded", "failed"]);
expect(c.status).toBe("cancelled");
expect(c.cancel_requested_at).not.toBeNull();
}
// 占名额的行也收到取消标志
const b = await runRow(e, blocker.id);
expect(b.cancel_requested_at).not.toBeNull();
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", blocker.id).execute();
}, 120_000);
});
@@ -42,8 +42,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
return r.body.data.id as string;
};
it("启动断言:15 个能力全部注册,并在能力目录里可见", async () => {
expect(e.booted.ctx.capabilities.list()).toHaveLength(15);
it("启动断言:17 个能力全部注册,并在能力目录里可见", async () => {
expect(e.booted.ctx.capabilities.list()).toHaveLength(17);
const r = await e.api("GET", "/capabilities");
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
e.booted.ctx.capabilities
+8 -1
View File
@@ -156,7 +156,14 @@ export function useAgentRun(id: string | undefined) {
params: { afterSeq: NO_EVENTS, limit: 1 },
signal,
});
return { run: d.run, messages: d.messages, report: d.report };
return {
run: d.run,
messages: d.messages,
report: d.report,
parent: d.parent ?? null,
children: d.children ?? [],
...(d.familyUsage ? { familyUsage: d.familyUsage } : {}),
};
},
refetchInterval: (q) => (isRunActive(q.state.data?.run.status) ? 3 * TTL_SECOND : false),
});
@@ -34,6 +34,7 @@ import {
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
@@ -181,11 +182,22 @@ export function AgentRunPanel({
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
const related = new Set([
runId,
...(run.data?.children ?? []).map((c) => c.id),
...(run.data?.parent ? [run.data.parent.id] : []),
]);
useSse(
[EventType.agentRunStatus],
[EventType.agentRunStatus, EventType.agentToolCall],
(e) => {
if ((e.payload as { runId?: string } | null)?.runId === runId)
void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
const p = e.payload as { runId?: string; capabilityId?: string } | null;
if (!p?.runId) return;
const hit =
e.type === EventType.agentRunStatus
? related.has(p.runId)
: p.runId === runId && p.capabilityId === "agent.spawn";
if (hit) void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
},
!agentMockEnabled(),
);
@@ -245,6 +257,8 @@ export function AgentRunPanel({
<BudgetBar run={r} live={live} />
</div>
<SubagentTree snapshot={run.data} />
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
{streaming && sseStatus === "reconnecting" ? (
@@ -104,6 +104,7 @@ export function AgentSessionList({
{(r.mode ?? r.scope.mode) === "plan_only" ? (
<span className="text-meta text-muted-foreground">只要方案</span>
) : null}
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
<time
dateTime={r.createdAt}
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
@@ -0,0 +1,136 @@
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`);
return parts.join(" · ");
}
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
function budgetText(b: Record<string, number | undefined>) {
const parts: string[] = [];
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
return parts.length ? parts.join(" · ") : "按设置缺省";
}
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
const kids = byParent.get(node.id) ?? [];
const [open, setOpen] = useState(true);
return (
<li>
<div className="flex min-w-0 items-start gap-1.5 rounded-lg px-1.5 py-1 hover:bg-foreground/[0.04]">
{kids.length ? (
<button
type="button"
aria-expanded={open}
aria-label={open ? "收起子会话" : "展开子会话"}
onClick={() => setOpen(!open)}
className="mt-0.5 cursor-pointer rounded-tight text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight className={cn("size-4 transition-transform duration-micro", open && "rotate-90")} />
</button>
) : (
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<Link
to={href(node.id)}
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{splitContextGoal(node.goal).text}
</Link>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
{node.mode === "plan_only" ? <span className="text-meta text-muted-foreground">只要方案</span> : null}
<span className="text-meta text-muted-foreground tabular-nums">
{usageLine({
toolCalls: node.toolCallCount,
totalTokens: node.usage.totalTokens,
costUsd: node.usage.costUsd,
})}
</span>
</div>
{node.spawnReason ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
) : null}
</div>
</div>
{kids.length && open ? (
<ul className={cn("ml-3 border-l border-border/70 pl-2", depth > 2 && "ml-1")}>
{kids.map((k) => (
<Node key={k.id} node={k} byParent={byParent} depth={depth + 1} />
))}
</ul>
) : null}
</li>
);
}
/** 子会话树 + 父会话链接;两者都没有时返回 null */
export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
const { run, parent, children = [], familyUsage } = snapshot;
if (!parent && children.length === 0) return null;
const byParent = new Map<string, AgentRunNode[]>();
for (const c of children) {
const k = c.parentRunId ?? "";
byParent.set(k, [...(byParent.get(k) ?? []), c]);
}
const direct = byParent.get(run.id) ?? [];
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
return (
<section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
{parent ? (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<CornerLeftUp aria-hidden className="size-3.5" />
父会话
</p>
<Link
to={href(parent.id)}
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{splitContextGoal(parent.goal).text}
</Link>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
</p>
</div>
) : null}
{direct.length ? (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5" />
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
</p>
<ul className="space-y-0.5">
{direct.map((c) => (
<Node key={c.id} node={c} byParent={byParent} depth={1} />
))}
</ul>
</div>
) : null}
</section>
);
}
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
return <span className="ml-auto tabular-nums">会话树合计 {usageLine(usage)}</span>;
}
+2
View File
@@ -153,6 +153,8 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
createdBy: "admin",
mode,
dedupeKey: null,
depth: 0,
spawnReason: null,
} satisfies AgentRunView;
}
+17 -2
View File
@@ -170,7 +170,10 @@ OpenRouter 换到的是永久 key,不刷新。
```jsonc
{
"pi": { "contextBudget": 128000, "compaction": { "enabled": true, "thresholdRatio": 0.8 }, "subagentsEnabled": false },
"pi": {
"contextBudget": 128000, "compaction": { "enabled": true, "thresholdRatio": 0.8 },
"subagents": { "enabled": false, "maxConcurrent": 2, "maxDepth": 1 } // subagentsEnabled 是 enabled 的旧别名
},
"agent": {
"maxToolCalls": 40, "maxDurationSec": 900, // 单次运行预算
"maxTokensPerRun": null, "maxCostUsdPerRun": null,
@@ -188,6 +191,18 @@ OpenRouter 换到的是永久 key,不刷新。
- 会话目录由环境变量 `AGENT_SESSION_DIR` 指定,缺省为 `<cwd>/data/agent-sessions`。api 进程和 worker 进程要能读到同一个目录,这是崩溃恢复的前提。
- TMDB 密钥放在 `metadata.tmdbApiKey`(只写,存为 secret `metadata_api_key`),地址放在 `metadata.tmdbBaseUrl`(可选)。设置里没有时,回退到环境变量 `TMDB_API_KEY` / `TMDB_ACCESS_TOKEN` / `TMDB_BASE_URL`。
## 子代理
- 开关:`pi.subagents.enabled`(设置页「允许子代理」,旧字段 `subagentsEnabled` 同步),关闭时模型拿不到 `agent.spawn`,服务端也拒绝派生。
- 用法:会话调用 `agent.spawn {goal, capabilityAllowlist?, budget?, mode?, upstreamId?, await?}` 派生子会话(独立 agent_run、同一 runAgent 任务、独立会话文件),再用 `agent.await` 取各子会话的状态与汇报摘要(已脱敏)。
- 权限:子会话能力白名单必须是父会话的子集,只给方案的会话只能派生只给方案的子会话,子会话继承父会话发起人,授权校验再按全部祖先会话的白名单与类别取交集。
- 限制:同一父会话下同时运行的子会话不超过 `maxConcurrent`(多出的排队等名额,不占 worker 也不调用模型),派生层数不超过 `maxDepth`,一个会话最多派生 10 个子会话。
- 预算:子会话预算从父会话剩余预算划拨(缺省剩余的一半,请求超过剩余直接拒绝),子会话超预算只结束它自己;父会话的工具调用 / token / 费用预算把子孙会话的实际用量算在内。
- 取消:中止父会话会递归取消全部未结束的子孙会话(排队中的也一样)。
- 事件:子会话的开始、工具调用、结束按父会话 seq 镜像为 `subagent_spawned` / `subagent_event` / `subagent_completed`,SSE 上以 `agent.tool_call`(能力 `agent.spawn`)提示;会话详情返回 `parent`、`children` 与 `familyUsage`(会话树合计)。
- `agent.await` 在父会话的工具调用里轮询业务表等待,不调用模型,但等待期间父会话占着一个 Agent worker 槽位,超时(缺省 600 秒)返回 pending。
- 成本:每个子会话都是一次完整的模型会话(独立上下文,有自己的系统提示与工具定义),token 成本大致随子会话数线性增加,也计入每日 Agent 运行次数。
## fake 模式演示
在设置页新建一个 provider = fake、authKind = none 的上游并设为默认(没有任何上游时,旧的 `PATCH /settings {"pi":{"provider":"fake"}}` 仍然生效)。
@@ -233,11 +248,11 @@ curl -X POST localhost:4380/api/v1/agent/runs -H "Authorization: Bearer umt-dev-
| `e4-crash-recovery` | worker 子进程在 download.submit 时 kill -9,新 worker resume,qB 只收到一次 add |
| `e5-auto-investigate` | 识别歧义 → 有界调查,去重 |
| `e6-auto-recover` | qB missingFiles → download_failed → 授权内恢复(task.inspect → download.recheck → 系统核实关闭事项);无授权时降级只调查;换源被拒;去重;同根因尝试上限;预算超限保持待处理 |
| `e11-subagents` | 父会话派生两个只读子会话并等待后汇总;maxConcurrent=1 时串行;子会话超预算不影响父;合计用量;子会话事件进父事件流;越权 / 提权 / 深度 / 关闭被拒;父中止后子会话 cancelled |
## 已知限制
- 编译后的二进制(`deno compile`)只授予 `--allow-read=/app`,没有写权限。Agent 会话目录和入库 Copy/Move 在容器里都需要写权限,需要在 W4 统一补 `--allow-write=<数据目录,库根>`,在此之前生产容器里的 runAgent 会失败。
- 子代理(`subagentsEnabled`)只做记录,没有实现。
- 41 个 provider 中只有请求形状与凭据装配经过测试(fake、自定义兼容端点真实走 HTTP);各家真实上游与 8 种订阅登录没有联网实测,首次接入请用「测试」按钮的 completion 模式确认。
- 登录会话在 api 进程内存,进程重启后进行中的登录丢失,需要重新开始。
- 追加消息与正在收尾的 run 存在很小的竞态窗口。`finalize` 会检查一次未消费的消息并补唤醒一次 run。
+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
+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,
+22 -1
View File
@@ -22,10 +22,28 @@ const samples: C.AgentRunEventBody[] = [
{ type: "compaction", phase: "end", reason: "threshold", status: "completed" },
{ type: "run_completed", finalText: "done", usage },
{ type: "run_failed", reason: "budget", message: "超预算", usage, budget: "tokens" },
{
type: "subagent_spawned",
childRunId: RUN,
goal: "子目标",
mode: "plan_only",
depth: 1,
capabilities: ["inventory.query"],
budget: { maxToolCalls: 5 },
},
{
type: "subagent_event",
childRunId: RUN,
childSeq: 3,
childType: "tool_call_end",
capabilityId: "inventory.query",
summary: "{}",
},
{ type: "subagent_completed", childRunId: RUN, status: "succeeded", summary: "ok", usage, toolCalls: 1 },
];
describe("agent 运行事件", () => {
it("样例覆盖全部 9 种事件类型", () => {
it("样例覆盖全部 12 种事件类型", () => {
expect(samples.map((s) => s.type).sort()).toEqual([...C.AgentRunEventType.options].sort());
});
@@ -60,6 +78,9 @@ describe("agent 运行事件", () => {
null,
"agent.run_status:succeeded",
"agent.run_status:budget_exceeded",
"agent.tool_call:started",
"agent.tool_call:started",
"agent.tool_call:succeeded",
]);
for (const b of bus) if (b) expect(C.parseEventPayload(b.type, b.payload), b.type).not.toBeNull();
});
@@ -0,0 +1,50 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// W7-B Pi 子代理(agent.spawn):agent_run 已有 parent_run_id(0010),这里补派生深度与派生理由;
// agent_run_event 增加 subagent_spawned / subagent_event / subagent_completed(子会话事件镜像进父会话事件流)。
const OLD_TYPES = [
"run_started",
"text_delta",
"thinking_delta",
"tool_call_start",
"tool_call_end",
"tool_call_rejected",
"compaction",
"run_completed",
"run_failed",
];
const NEW_TYPES = [...OLD_TYPES, "subagent_spawned", "subagent_event", "subagent_completed"];
const swap = (values: readonly string[]) => `
alter table agent_run_event drop constraint agent_run_event_type_check;
alter table agent_run_event add constraint agent_run_event_type_check ${oneOf("type", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table agent_run add column depth integer not null default 0 check (depth >= 0);
alter table agent_run add column spawn_reason text;
create index agent_run_parent_idx on agent_run (parent_run_id, created_at) where parent_run_id is not null;
${swap(NEW_TYPES)}
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
delete from agent_run_event where type in ('subagent_spawned', 'subagent_event', 'subagent_completed');
${swap(OLD_TYPES)}
drop index if exists agent_run_parent_idx;
alter table agent_run drop column spawn_reason;
alter table agent_run drop column depth;
`,
);
}
+6 -1
View File
@@ -47,7 +47,10 @@ export type AgentRunEventType =
| "tool_call_rejected"
| "compaction"
| "run_completed"
| "run_failed";
| "run_failed"
| "subagent_spawned"
| "subagent_event"
| "subagent_completed";
export type AgentRunMessageStatus = "pending" | "consumed";
export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed";
export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log";
@@ -344,6 +347,8 @@ export interface AgentRunTable {
mode: Generated<AgentRunMode>;
dedupe_key: string | null;
upstream_id: string | null;
depth: Generated<number>;
spawn_reason: string | null;
}
export interface AgentRunEventTable {