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
@@ -358,6 +358,88 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "in", [parent.id, child.id]).execute();
|
||||
}, 60_000);
|
||||
|
||||
it("同目标去重(AG-4):子会话还在运行或 10 分钟内已完成时返回已有子会话,不新建", async () => {
|
||||
const parent = await e.db
|
||||
.insertInto("agent_run")
|
||||
.values({
|
||||
trigger: "user",
|
||||
goal: "fixture dedupe 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: "子目标:查《沙丘》 缺什么版本",
|
||||
status: "running",
|
||||
mode: "plan_only",
|
||||
scope: JSON.stringify({ capabilities: READONLY, allowedKinds: ["read", "preview", "propose"] }),
|
||||
budget: JSON.stringify({ maxToolCalls: 5 }),
|
||||
created_by: "user:admin",
|
||||
parent_run_id: parent.id,
|
||||
depth: 1,
|
||||
started_at: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const spawn = async (input: Record<string, unknown>, key: string) =>
|
||||
(await e.booted.ctx.capabilities.invoke("agent.spawn", input, {
|
||||
actorId: "user:admin",
|
||||
taskId: parent.id,
|
||||
scopeIds: [],
|
||||
signal: new AbortController().signal,
|
||||
source: "agent",
|
||||
agentRunId: parent.id,
|
||||
invocationId: key,
|
||||
})) as Any;
|
||||
const children = async () =>
|
||||
(await e.db.selectFrom("agent_run").select("id").where("parent_run_id", "=", parent.id).execute()).map(
|
||||
(x) => x.id,
|
||||
);
|
||||
|
||||
// 仍在运行:归一化后同目标(全半角标点、空白不同)→ 复用,结果摘要 pending
|
||||
const a = await spawn({ goal: "子目标:查《沙丘》缺什么版本。", capabilityAllowlist: READONLY }, "dedupe-1");
|
||||
expect(a).toMatchObject({ reused: true, runId: child.id, result: { pending: true, summary: null } });
|
||||
expect(a.message).toContain("已有同目标子会话");
|
||||
expect(Object.keys(a)[0]).toBe("reused");
|
||||
|
||||
// 5 分钟前已完成:复用并带回汇报摘要
|
||||
await e.db
|
||||
.updateTable("agent_run")
|
||||
.set({ status: "succeeded", summary: "《沙丘》缺 4K 版本", finished_at: new Date(Date.now() - 5 * 60_000) })
|
||||
.where("id", "=", child.id)
|
||||
.execute();
|
||||
const b = await spawn({ goal: "子目标:查《沙丘》缺什么版本" }, "dedupe-2");
|
||||
expect(b).toMatchObject({
|
||||
reused: true,
|
||||
runId: child.id,
|
||||
result: { status: "succeeded", pending: false, summary: expect.stringContaining("缺 4K") },
|
||||
});
|
||||
expect(b.message).toContain("10 分钟内已完成");
|
||||
// 结果行的子会话计数(会话树)不变
|
||||
expect(await children()).toEqual([child.id]);
|
||||
|
||||
// 超过 10 分钟 / 失败的不复用;不同目标不复用
|
||||
const { findSameGoalChild, normalizeGoal } = await import("../../src/modules/agent/subagents.ts");
|
||||
expect(normalizeGoal("子目标:查《沙丘》 缺什么版本。")).toBe(normalizeGoal("子目标:查《沙丘》缺什么版本"));
|
||||
const later = Date.now() + 6 * 60_000;
|
||||
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本", later)).toBeNull();
|
||||
expect((await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本"))?.id).toBe(child.id);
|
||||
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《奥本海默》缺什么版本")).toBeNull();
|
||||
await e.db.updateTable("agent_run").set({ status: "failed" }).where("id", "=", child.id).execute();
|
||||
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本")).toBeNull();
|
||||
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", parent.id).execute();
|
||||
}, 60_000);
|
||||
|
||||
it("父会话中止 → 全部子会话 cancelled(排队等名额的也一样)", async () => {
|
||||
// 先停 worker 建父会话,再塞一个占着唯一名额的子会话,保证真实子会话排队等名额
|
||||
await e.worker?.stop();
|
||||
|
||||
Reference in new issue
Block a user