feat(agent): Pi subagents via agent.spawn / agent.await
- agent_run gains depth / spawn_reason (0029); agent_run_event types
subagent_spawned / subagent_event / subagent_completed
- agent.spawn (propose, medium, replay never): allowlist ⊆ parent, mode not
above parent, budget carved from parent's remainder, idempotent per run+input;
settings.pi.subagents {enabled, maxConcurrent, maxDepth} (subagentsEnabled kept as alias)
- child runs claim a concurrency slot under an advisory lock (waitForFact while
queued); agent.await polls business tables without calling the model
- parent abort cascades to all descendants; parent runtime budget counts
descendant usage; child events mirrored into the parent stream by parent seq
- authorize caps every child call by all ancestors' allowlists/kinds
- session page shows parent link, inherited info and the subagent tree
- e2e E11; runbook section on subagents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
daa85a8bb4
commit
459991421e
27 files changed
+1720
-32
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user