feat(agent): dedupe agent.spawn by normalized goal within a parent session (AG-4)

A spawn whose goal (NFKC, case, whitespace and punctuation folded) matches a
child of the same parent that is still queued/running, or succeeded/waiting
within the last 10 minutes, returns that child instead of creating a new one:
output gets reused=true, a message ("已有同目标子会话…") and the child's
current result summary. Failed/cancelled/over-budget children are not reused.
Spawn decisions are serialized per parent in-process so parallel duplicate
calls in one batch create a single child. Replays keyed by idempotency still
hit the original child as before. The subagent constraint in the system prompt
asks for Chinese sub-goals and no duplicate spawns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 17:58:08 +08:00
1 parent 0508f3418f
commit 01653b0427
5 files changed
+178 -4

No files matched your search

+1 -1
View File
@@ -271,7 +271,7 @@ function systemPromptFor(
constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;budget 的每一项都必须小于本会话剩余预算(超过会被拒绝,不给则按剩余的一半划拨);" +
"派生后用 agent.await 等结果再汇总,不要为小事派生。",
"派生后用 agent.await 等结果再汇总,不要为小事派生;子目标用中文写;同一目标已派出过的子会话不要重复派生(会直接返回已有子会话)。",
);
if (scope.capabilities.includes("web.search"))
constraints.push(
+87 -2
View File
@@ -49,6 +49,44 @@ const ALL_KINDS: CapabilityKind[] = ["read", "preview", "propose", "approve", "e
const PLAN_ONLY_KINDS: CapabilityKind[] = ["read", "preview", "propose"];
/** 一个会话最多派生的子会话数(预算之外的兜底) */
export const MAX_CHILDREN_PER_RUN = 10;
/** 同目标去重窗口(AG-4):已完成的子会话在这段时间内按同目标复用 */
export const SPAWN_DEDUPE_WINDOW_MS = 10 * 60_000;
/** 目标归一化:全半角统一、忽略大小写、空白与标点("查一下 观众 站点。" ≡ "查一下观众站点") */
export function normalizeGoal(goal: string): string {
return goal
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}\p{S}]+/gu, "");
}
/**
* 同一父会话里同目标、可复用的子会话:还没落定(排队 / 运行中),或 10 分钟内成功完成 / 已在等下载入库。
* 失败、取消、超预算的不复用(父会话可以换个预算或说法重派)。
*/
export async function findSameGoalChild(
db: Kysely<DB>,
parentId: string,
goal: string,
now = Date.now(),
): Promise<AgentRunRow | null> {
const key = normalizeGoal(goal);
if (!key) return null;
const rows = await db
.selectFrom("agent_run")
.selectAll()
.where("parent_run_id", "=", parentId)
.orderBy("created_at", "desc")
.execute();
for (const r of rows) {
if (normalizeGoal(r.goal) !== key) continue;
if (!SETTLED.includes(r.status)) return r;
if (r.status !== "succeeded" && r.status !== "waiting") continue;
const at = (r.finished_at ?? r.updated_at)?.getTime() ?? 0;
if (now - at <= SPAWN_DEDUPE_WINDOW_MS) return r;
}
return null;
}
const MIRRORED_TYPES = [
"run_started",
"tool_call_start",
@@ -690,6 +728,23 @@ export async function suspendRun(live: LiveRun, sus: AwaitSuspension): Promise<v
// ── 能力处理器 ──
const spawnLocks = new Map<string, Promise<void>>();
/** 同一父会话的派生判定串行(进程内);返回释放函数 */
async function spawnLock(parentId: string): Promise<() => void> {
const prev = spawnLocks.get(parentId) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((r) => {
release = r;
});
const tail = prev.then(() => mine);
spawnLocks.set(parentId, tail);
await prev;
return () => {
release();
if (spawnLocks.get(parentId) === tail) spawnLocks.delete(parentId);
};
}
export interface SpawnDeps {
/** 建子 agent_run + 任务行并入队 runAgent(service.startRun) */
start(p: {
@@ -728,8 +783,19 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
.selectAll()
.where("dedupe_key", "=", dedupeKey)
.executeTakeFirst();
// AG-4 去重:不是重放(幂等键没命中)时,同目标的子会话还在跑或刚完成就直接复用。
// 同一父会话的派生串行(同一批并行的两次同目标调用也只建一个);父会话只在一个进程里运行
const release = await spawnLock(parent.id);
let child = existing;
if (!child) {
let reused: AgentRunRow | null = null;
try {
reused = !child ? await findSameGoalChild(ctx.db, parent.id, input.goal) : null;
if (reused) child = reused;
if (!child) child = await create();
} finally {
release();
}
async function create(): Promise<AgentRunRow> {
if (parent.depth + 1 > cfg.maxDepth)
throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`);
const mode: AgentRunMode = input.mode ?? parent.mode;
@@ -753,7 +819,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
reason: input.reason ?? null,
dedupeKey,
});
child = r.run;
return r.run;
}
await liveRun(parent.id)?.pump();
let result: SubagentResult | null = null;
@@ -774,6 +840,25 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
}
}
const cs = (child.scope ?? {}) as Partial<RunScope>;
if (reused) {
// 没要求等待时也带上已有子会话的当前结果(还在跑的 summary 为 null、pending = true)
if (!result) {
const row = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", child.id).executeTakeFirst();
result = resultOf(row ?? child);
}
return {
reused: true,
message: result.pending
? "已有同目标子会话,仍在运行,未新建;需要结果时用 agent.await 等它"
: "已有同目标子会话(10 分钟内已完成),未新建;结果见 result",
runId: child.id,
depth: child.depth,
mode: child.mode,
capabilities: cs.capabilities ?? [],
budget: (child.budget ?? {}) as SubagentBudget,
result,
};
}
return {
runId: child.id,
depth: child.depth,