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:
1 parent
0508f3418f
commit
01653b0427
5 files changed
+178
-4
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user