diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index d81540246..07c2e5e72 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -69,6 +69,7 @@ export const AGENTS_RESULT_TYPE = "gentle-agents.result"; export const AGENTS_MESSAGE_TYPE = "gentle-agents.message"; export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message"; export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result"; +export const AGENTS_STALE_NOTICE_TYPE = "gentle-agents.stale-notice"; const RENDER_COALESCE_MS = 400; const CLOCK_TICK_MS = 1000; const TOOL_PREFIX = "subagent_"; @@ -696,8 +697,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // calling tools entirely, so in a long orchestrator run the notification // could land nearly an hour after the parent pulled the same result (#867). // Gentle Agents now owns the pending completions: they settle here, are - // flushed at the next turn boundary, and a stale one never re-enters the - // conversation. + // flushed at the next turn boundary, and a stale one never replays its + // report into the conversation (it gets a compact pull notice instead). const completions = createCompletionQueue(); const messages = createAgentMessageQueue(); let activeAgentRuns = 0; @@ -884,12 +885,27 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route); }; - // A stale completion must not re-enter the LLM conversation, so it is - // delivered as durable TUI-only content and the human still sees it. - const deliverStale = (task: TaskRecord, settledAt: number) => { + // A stale completion never replays its report into the LLM conversation + // (the parent may have used it already; consume() suppresses that case, so + // anything reaching here is unread). A single parent tool call can outlast + // the window, so the human gets the transcript entry and the model gets one + // compact notice, without the report, telling it to pull the result. The + // notice takes the same route as any child content (see the router above, + // #1528): an idle parent stores it with triggerTurn:false and gets one + // coalesced wake instead of a direct turn that skips before_agent_start. + const deliverStale = (task: TaskRecord, settledAt: number, route: Exclude) => { if (activeSessionId() !== task.parentSessionId) return; const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000)); pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds }); + const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " "); + sendToParent( + { + customType: AGENTS_STALE_NOTICE_TYPE, + content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome} ${ageSeconds}s ago and its result was not read yet. Call subagent_result with task_id ${task.id}.`, + display: false, + }, + route, + ); }; const deliverMessage = (msg: PendingAgentMessage, route: Exclude) => { @@ -928,7 +944,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = if (!route) return; for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) { try { - if (stale) deliverStale(task, settledAt); + if (stale) deliverStale(task, settledAt, route); else deliver(task, route); } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ } } diff --git a/lib/agents-completion-delivery.ts b/lib/agents-completion-delivery.ts index 90a8f4d99..fac5828ee 100644 --- a/lib/agents-completion-delivery.ts +++ b/lib/agents-completion-delivery.ts @@ -11,12 +11,13 @@ // behavior is unit-testable against a fake clock. /** - * A completion older than this at flush time is stale: the parent had many - * turns to pull the result with subagent_result, so replaying it into the - * model context would re-enter state the conversation may already have used. - * Ninety seconds comfortably covers one slow orchestrator turn (model latency - * plus a few tool calls) while staying orders of magnitude below the 50-58 - * minute followUp delays measured in issue #867. + * A completion older than this at flush time is stale: its full report is + * never replayed into model context. Consumed results are already suppressed + * by consume(), so this only bounds how late a full replay may land; an + * unread stale completion still gets a compact pull notice from the extension, + * because one long parent tool call can exceed the window before any flush. + * Ninety seconds stays orders of magnitude below the 50-58 minute followUp + * delays measured in issue #867. */ export const STALE_COMPLETION_MS = 90_000; diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index e1a0962a0..0c51d4073 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -5184,7 +5184,20 @@ test("a completion held past the stale window becomes transcript-only content an await tick(); clock += STALE_COMPLETION_MS + 1_000; await fire("turn_end", ctx); - assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never enters the model context"); + assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never replays the full result into the model context"); + // #1092: the model still learns the task finished, via one compact notice without the report. + const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice"); + assert.equal(notices.length, 1, "an unread stale completion yields exactly one model-facing notice"); + assert.deepEqual(notices[0]!.options, { deliverAs: "steer", triggerTurn: true }); + assert.equal(notices[0]!.message.display, false); + assert.match(String(notices[0]!.message.content), new RegExp(id)); + assert.match(String(notices[0]!.message.content), new RegExp(`Call subagent_result with task_id ${id}\\.$`)); + // subagent_status returns metadata, not the report, so following it would leave the result unread. + assert.doesNotMatch(String(notices[0]!.message.content), /subagent_status/, "the notice points only at the tool that returns the report"); + assert.doesNotMatch(String(notices[0]!.message.content), /Late answer\./, "the notice never carries the report"); + await fire("agent_end", ctx); + await fire("agent_settled", ctx); + assert.equal(sent.length, 1, "no later flush replays or re-notifies"); const stale = entries.filter((entry) => entry.customType === "gentle-agents.stale-result"); assert.equal(stale.length, 1, "the human still sees the stale completion as durable transcript content"); assert.match(JSON.stringify(stale[0]!.data), new RegExp(id), "the stale notice names the task"); @@ -5196,6 +5209,160 @@ test("a completion held past the stale window becomes transcript-only content an await fire("session_shutdown", ctx); }); +// A stale notice is child content like any other (#1528): it follows the +// idle/run/hold router, so an idle parent is woken through the prompt +// lifecycle instead of a direct turn that skips before_agent_start. +const finishBackground = (harness: ReturnType, index: number, text: string) => { + harness.children[index].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text }] }] }); + harness.children[index].emit({ type: "agent_settled" }); +}; + +test("issue #1092: a stale notice for an idle parent is stored without a direct turn and woken once", async () => { + const { pi, tools, fire, sent, entries, userMessages } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle stale", mode: "background" }, undefined, undefined, ctx); + const id = (started.details.gentleAgents as { taskId: string }).taskId; + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Idle answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + // agent_settled leaves the parent idle, so the held completion flushes as stale to an idle parent. + await fire("agent_settled", ctx); + await tick(); + const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice"); + assert.equal(notices.length, 1); + assert.deepEqual(notices[0]!.options, { triggerTurn: false }, "an idle parent never gets a direct triggerTurn message"); + assert.equal(sent.some((entry) => entry.options.triggerTurn === true), false); + assert.equal(userMessages.length, 1, "exactly one wake follows the stored notice"); + assert.doesNotMatch(String(userMessages[0]!.content), new RegExp(`${id}|Idle answer\\.|subagent_result`), "the wake repeats neither the notice nor the report"); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 1, "the transcript entry is still appended"); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale notice while the parent run is active is steered into that run without a wake", async () => { + const { pi, tools, fire, sent, userMessages } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Run stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Run answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { deliverAs: "steer", triggerTurn: true }]]); + assert.equal(userMessages.length, 0); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale completion held while the parent compacts outside a run is routed only at the boundary", async () => { + const { pi, tools, fire, sent, entries, userMessages, setIdle } = fakePi(); + const harness = deps(); + const timers = recordTimers(harness.deps); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Held stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + setIdle(false); + finishBackground(harness, 0, "Held answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + await fire("session_compact", ctx); + assert.equal(sent.length, 0, "nothing is sent while the parent is busy without a run"); + assert.equal(userMessages.length, 0); + assert.equal(entries.length, 0, "the stale entry waits for the boundary too"); + setIdle(true); + assert.equal(timers.run(0), 1); + await tick(); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { triggerTurn: false }]]); + assert.equal(userMessages.length, 1); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 1); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale and a fresh completion in one idle flush share a single wake", async () => { + const { pi, tools, fire, sent, userMessages, delivery } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Old", mode: "background" }, undefined, undefined, ctx); + await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Fresh", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Old answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + finishBackground(harness, 1, "Fresh answer."); + await tick(); + await fire("agent_settled", ctx); + await tick(); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { triggerTurn: false }], ["gentle-agents.result", { triggerTurn: false }]]); + assert.equal(userMessages.length, 1, "one coalesced wake covers both stored messages"); + assert.deepEqual(delivery.slice(-1), ["user"]); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a completion consumed before it went stale sends no notice and no entry", async () => { + const { pi, tools, fire, sent, entries } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled late", mode: "background" }, undefined, undefined, ctx); + const id = (started.details.gentleAgents as { taskId: string }).taskId; + await tick(); + await fire("agent_start", ctx); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx); + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.equal(sent.length, 0, "a consumed stale completion sends nothing"); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 0); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale completion owned by another session sends no notice and no entry", async () => { + const { pi, tools, fire, sent, entries } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Foreign stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Foreign answer." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2"; + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.equal(sent.length, 0); + assert.equal(entries.length, 0); + await fire("session_shutdown", ctx); +}); + test("a completion the parent already pulled is dropped silently at the next turn end", async () => { const { pi, tools, fire, sent } = fakePi(); const harness = deps();