Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_";
Expand Down Expand Up @@ -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<TaskRecord>();
const messages = createAgentMessageQueue();
let activeAgentRuns = 0;
Expand Down Expand Up @@ -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<ParentRoute, "hold">) => {
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<ParentRoute, "hold">) => {
Expand Down Expand Up @@ -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. */ }
}
Expand Down
13 changes: 7 additions & 6 deletions lib/agents-completion-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
169 changes: 168 additions & 1 deletion tests/gentle-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<typeof deps>, 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();
Expand Down