diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 2bba37e6..35395daf 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -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; diff --git a/apps/server/src/modules/agent/capabilities.ts b/apps/server/src/modules/agent/capabilities.ts index d493b7d7..289e0f7c 100644 --- a/apps/server/src/modules/agent/capabilities.ts +++ b/apps/server/src/modules/agent/capabilities.ts @@ -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 diff --git a/apps/server/src/modules/agent/index.ts b/apps/server/src/modules/agent/index.ts index c266cdc4..9f62b147 100644 --- a/apps/server/src/modules/agent/index.ts +++ b/apps/server/src/modules/agent/index.ts @@ -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 }, + }, + }), + }); }, }; } diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 8c354a40..d531f45b 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -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, + summary: r.summary, + capabilities: scope.capabilities ?? [], + budget: r.budget as Record, + createdAt: r.created_at.toISOString(), + finishedAt: toIso(r.finished_at), + }; +} + /** snake_case 行 → camelCase(Date → ISO) */ function camel(row: object): Record { const out: Record = {}; @@ -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`(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), diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 4e913918..81eedca5 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -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(); } } diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index 63451737..530e5720 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -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; - 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` - 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, parentRunId: string): Promise { + 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") diff --git a/apps/server/src/modules/agent/subagents.ts b/apps/server/src/modules/agent/subagents.ts new file mode 100644 index 00000000..3ed84fde --- /dev/null +++ b/apps/server/src/modules/agent/subagents.ts @@ -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; + /** 子孙会话已用量(同步读缓存,pump 刷新) */ + externalUsage(): ExternalUsage; +} + +const live = new Map(); +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 }> { + 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(); + const completed = new Set(); + const mirrored = new Map(); + 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 = 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))) + 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; + const budget = Object.fromEntries( + Object.entries((c.budget ?? {}) as Record).filter(([, v]) => typeof v === "number"), + ) as Record; + 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): 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; + 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 { + const r = await sql` + 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 { + 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, runId: string): Promise { + const r = await sql` + 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 { + 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 { + 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 { + 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 { + 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: 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; + 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((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 { + 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; + 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, + runId: string, + capabilityId: string, + kind: CapabilityKind, +): Promise { + const chain = await ancestors(db, runId); + for (const a of chain) { + const sc = (a.scope ?? {}) as Partial; + 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; +} diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 969f22e9..0373ade1 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -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; diff --git a/apps/server/test/e2e/e11-subagents.test.ts b/apps/server/test/e2e/e11-subagents.test.ts new file mode 100644 index 00000000..b0d80ce7 --- /dev/null +++ b/apps/server/test/e2e/e11-subagents.test.ts @@ -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) => { + 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); +}); diff --git a/apps/server/test/e2e/e2-agent-delegate.test.ts b/apps/server/test/e2e/e2-agent-delegate.test.ts index 6296402c..e5b5d216 100644 --- a/apps/server/test/e2e/e2-agent-delegate.test.ts +++ b/apps/server/test/e2e/e2-agent-delegate.test.ts @@ -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 diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 3cbeed2e..c3ed3b08 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -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), }); diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index af08a187..2e03e5bd 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -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({ + + {mode === "plan_only" ? : null} {streaming && sseStatus === "reconnecting" ? ( diff --git a/apps/web/app/components/agent/agent-session-list.tsx b/apps/web/app/components/agent/agent-session-list.tsx index 87de7d23..2abb9396 100644 --- a/apps/web/app/components/agent/agent-session-list.tsx +++ b/apps/web/app/components/agent/agent-session-list.tsx @@ -104,6 +104,7 @@ export function AgentSessionList({ {(r.mode ?? r.scope.mode) === "plan_only" ? ( 只要方案 ) : null} + {r.parentRunId ? 子会话 : null}