Skip to content

Commit bee7629

Browse files
Add interrupt_agent / followup_task (CL-6997) (#619)
* Add interrupt_agent / followup_task (CL-6997) Second half of reusable worker sessions: interrupt_agent stops a retained worker's current turn while keeping the session and its context reusable, and followup_task sends new work into a retained session's existing agent, reusing its prior context and tool outputs. interrupt_agent fires a signal scoped only to the in-flight agent.send() call, never close() — it cannot hit the close()-ordering workdir-lock issue tracked separately (CL-6984). There is no lower- level stop primitive in the vendored agent for the reactor cycle itself, so this is an approximation: it stops the caller from waiting, not the worker's compute, which keeps running in the background until it finishes naturally. Both verbs are gated to orchestrator tiers via the existing FLEET_VERBS / assertTierMayMountFleetVerb mechanism, denied to leaves. * Add live-agent regression guard for followup_task (CL-6997) lifecycle-tools.test.ts only exercised interrupt_agent/followup_task against fake registered closures at the tool/store layer, not run.ts's real onAgentReady wiring where followup calls agent!.send() on the same live agent object. Add a test that drives runSubAgent end to end with createAgentWithLiveToolDispatch replaced by a stub Agent, proving the followup send after an interrupt lands on the same agent instance (one construction, one shared message log) rather than a rebuilt one.
1 parent 05360d6 commit bee7629

9 files changed

Lines changed: 629 additions & 13 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3737
operator interrupts it (`interrupt_agent`) rather than the harness enforcing
3838
a count.
3939

40+
- Added `interrupt_agent({ target })` and `followup_task({ target, message })`,
41+
the second half of reusable worker sessions: `interrupt_agent` stops a
42+
retained worker's current turn while keeping it and its context alive
43+
(distinct from the permanent `close_agent`), and `followup_task` sends new
44+
work into a retained worker's existing session, reusing its prior context
45+
and tool outputs rather than starting fresh. Both are gated to orchestrator
46+
tiers via the existing fleet-verb mechanism, denied to leaves. `interrupt_agent`
47+
fires a signal scoped only to the in-flight `agent.send()` call, never
48+
`close()`, so it cannot hit the close()-ordering workdir-lock issue tracked
49+
separately — the underlying reactor cycle keeps running in the background
50+
(there is no lower-level stop primitive for that in the vendored agent), so
51+
this is an approximation: it stops the caller from waiting, not the
52+
worker's compute.
4053
- `evaluateSubAgentStop` now always requires the final assistant text; the
4154
omitted-text branch that unconditionally completed a tool-less turn is
4255
removed, so every call path gets the `incomplete-report` nudge and salvage

src/subagent/agent-fleet.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
449449
// CL-6943: keep the session open after a clean completion, and hand
450450
// the store a bounded close for close_agent to call later.
451451
persist: true,
452-
onAgentReady: (close) => {
452+
onAgentReady: ({ close, interrupt, followup }) => {
453453
deps.sessions.registerClose(session.id, close);
454+
deps.sessions.registerInterrupt(session.id, interrupt);
455+
deps.sessions.registerFollowup(session.id, followup);
454456
deps.sessions.markRunning(session.id);
455457
},
456458
};
@@ -466,6 +468,11 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
466468
.run(params)
467469
.then((result) => {
468470
if (childCtl.signal.aborted) return;
471+
// CL-6997: interrupt_agent already flipped this session to
472+
// "interrupted" synchronously (session-store.interruptOne) — do
473+
// not let the settling promise's normal bookkeeping overwrite
474+
// that with a "completed" status.
475+
if (result.interrupted === true) return;
469476
deps.fleetRecords.resolve(session.id, result.report);
470477
// CL-7001: result.agentRetained is only true on run.ts's clean-
471478
// completion path when persist actually skipped teardown — a

src/subagent/authority.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ describe("assertTierMayMountFleetVerb", () => {
1414
// CL-6943: the reusable-session verbs are gated the same way.
1515
expect(() => assertTierMayMountFleetVerb("leaf", "close_agent")).toThrow(FleetAuthorityError);
1616
expect(() => assertTierMayMountFleetVerb("leaf", "resume_agent")).toThrow(FleetAuthorityError);
17+
// CL-6997: interrupt_agent / followup_task are gated the same way.
18+
expect(() => assertTierMayMountFleetVerb("leaf", "interrupt_agent")).toThrow(
19+
FleetAuthorityError,
20+
);
21+
expect(() => assertTierMayMountFleetVerb("leaf", "followup_task")).toThrow(FleetAuthorityError);
1722
});
1823

1924
test("leaves may still mount non-fleet tools", () => {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* CL-6997 regression guard: lifecycle-tools.test.ts proves interrupt_agent /
3+
* followup_task behave correctly against *fake registered closures* at the
4+
* tool/store layer — it never exercises run.ts's real wiring, where
5+
* `followup` calls `agent!.send()` on the same live agent object created by
6+
* `createAgentWithLiveToolDispatch`. A future refactor could make
7+
* `followup_task` rebuild the agent instead of reusing it (exactly the
8+
* regression this feature exists to prevent — a rebuilt agent means the
9+
* worker re-reads the codebase from scratch) without failing any existing
10+
* test.
11+
*
12+
* This test drives the real `runSubAgent` (run.ts) end to end with the one
13+
* real dependency that would require live inference credentials —
14+
* `createAgentWithLiveToolDispatch` — replaced by a stub `Agent`. Everything
15+
* else (tool assembly, environment gathering, the dispatch brief, the
16+
* onAgentReady wiring, the interrupt/followup closures themselves) is the
17+
* genuine run.ts code path.
18+
*/
19+
import { describe, expect, test } from "bun:test";
20+
import { mkdtemp } from "node:fs/promises";
21+
import { tmpdir } from "node:os";
22+
import { join } from "node:path";
23+
24+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
25+
import { createPermissionGate } from "../permission/gate.js";
26+
import type { RunSubAgentParams } from "./types.js";
27+
28+
const testPermissionGate = createPermissionGate({
29+
approvals: [],
30+
interactive: false,
31+
skipPermissions: true,
32+
});
33+
34+
async function tmpCwd(): Promise<string> {
35+
return mkdtemp(join(tmpdir(), "cl6997-live-agent-"));
36+
}
37+
38+
/** Minimal stand-in for the vendored `Agent` (dist/agent.d.ts), instrumented
39+
* to prove reuse: `sendLog` accumulates every message across BOTH the
40+
* original send and the later followup send, and rejects like the real
41+
* `Agent.send`'s documented `signal` option when its signal fires. */
42+
function createStubAgent() {
43+
const sendLog: string[] = [];
44+
return {
45+
sendLog,
46+
async send(content: string, opts?: { signal?: AbortSignal }) {
47+
sendLog.push(content);
48+
return await new Promise((resolve, reject) => {
49+
if (opts?.signal?.aborted === true) {
50+
reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error("aborted"));
51+
return;
52+
}
53+
const timer = setTimeout(
54+
() =>
55+
resolve({
56+
reply: `reply #${sendLog.length}`,
57+
turn: { role: "assistant", content: [] },
58+
}),
59+
20,
60+
);
61+
opts?.signal?.addEventListener(
62+
"abort",
63+
() => {
64+
clearTimeout(timer);
65+
reject(
66+
opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error("aborted"),
67+
);
68+
},
69+
{ once: true },
70+
);
71+
});
72+
},
73+
stream: () => (async function* () {})(),
74+
deliver: () => {},
75+
close: async () => {},
76+
setSource: () => {},
77+
setSources: () => {},
78+
history: async () => [],
79+
checkpoints: async () => [],
80+
readAt: async () => [],
81+
blobReader: {},
82+
};
83+
}
84+
85+
describe("interrupt_agent / followup_task reuse the same live agent (CL-6997)", () => {
86+
test("followup after interrupt sends into the SAME agent instance — not a rebuilt one", async () => {
87+
const cwd = await tmpCwd();
88+
let constructions = 0;
89+
let capturedAgent: ReturnType<typeof createStubAgent> | undefined;
90+
91+
const outcome = await withMockedModuleDuring(
92+
import.meta.resolve("../agent/live-tool-dispatch.js"),
93+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
94+
...real,
95+
createAgentWithLiveToolDispatch: async () => {
96+
constructions++;
97+
const stub = createStubAgent();
98+
capturedAgent = stub;
99+
return stub as unknown as Awaited<
100+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
101+
>;
102+
},
103+
}),
104+
async () => {
105+
const { runSubAgent } = await import("./run.js");
106+
107+
let handles:
108+
| {
109+
close: (ms?: number) => Promise<void>;
110+
interrupt: () => void;
111+
followup: (message: string) => Promise<string>;
112+
}
113+
| undefined;
114+
115+
const params: RunSubAgentParams = {
116+
cwd,
117+
workdirBase: join(cwd, ".ctx"),
118+
permissionGate: testPermissionGate,
119+
provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" },
120+
description: "live-agent reuse probe",
121+
prompt: "explore the codebase for the bug",
122+
persist: true,
123+
onAgentReady: (h) => {
124+
handles = h;
125+
},
126+
};
127+
128+
const runPromise = runSubAgent(params);
129+
130+
// onAgentReady fires before agent.send() is awaited; poll briefly
131+
// rather than assume a fixed number of ticks.
132+
for (let i = 0; i < 500 && handles === undefined; i++) {
133+
await new Promise((resolve) => setTimeout(resolve, 1));
134+
}
135+
if (handles === undefined) throw new Error("onAgentReady never fired");
136+
137+
handles.interrupt();
138+
const interruptedResult = await runPromise;
139+
140+
const reply = await handles.followup("do X instead, not what the original prompt said");
141+
return { interruptedResult, reply };
142+
},
143+
);
144+
145+
expect(outcome.interruptedResult.interrupted).toBe(true);
146+
// Exactly one agent was ever constructed across the interrupted turn and
147+
// the followup — a rebuild would show up here as constructions === 2.
148+
expect(constructions).toBe(1);
149+
expect(capturedAgent).toBeDefined();
150+
151+
// The load-bearing assertion: the SAME agent's message log holds both
152+
// the original turn's prompt and the followup message, proving the
153+
// followup was sent into the same live object rather than a fresh one
154+
// with empty history.
155+
expect(capturedAgent!.sendLog.length).toBe(2);
156+
expect(capturedAgent!.sendLog[0]).toContain("explore the codebase for the bug");
157+
expect(capturedAgent!.sendLog[1]).toBe("do X instead, not what the original prompt said");
158+
expect(outcome.reply).toBe("reply #2");
159+
});
160+
});

src/subagent/lifecycle-tools.test.ts

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { describe, expect, test } from "bun:test";
22

3-
import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js";
3+
import {
4+
createCloseAgentTool,
5+
createResumeAgentTool,
6+
createInterruptAgentTool,
7+
createFollowupTaskTool,
8+
} from "./lifecycle-tools.js";
49
import { createSubAgentSessionStore } from "./session-store.js";
510

611
async function callTool(
7-
tool: ReturnType<typeof createCloseAgentTool> | ReturnType<typeof createResumeAgentTool>,
12+
tool:
13+
| ReturnType<typeof createCloseAgentTool>
14+
| ReturnType<typeof createResumeAgentTool>
15+
| ReturnType<typeof createInterruptAgentTool>
16+
| ReturnType<typeof createFollowupTaskTool>,
817
args: Record<string, unknown>,
918
): Promise<Record<string, unknown>> {
1019
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
@@ -101,3 +110,151 @@ describe("resume_agent", () => {
101110
expect(rawResult.isError).toBe(true);
102111
});
103112
});
113+
114+
describe("interrupt_agent / followup_task", () => {
115+
test("interrupt then followup keeps prior context — the worker does not re-read from scratch", async () => {
116+
const sessions = createSubAgentSessionStore();
117+
const worker = sessions.start({
118+
description: "worker",
119+
agentId: "a",
120+
brief: "b",
121+
retained: true,
122+
});
123+
sessions.markRunning(worker.id);
124+
125+
// Simulates the live agent's own message history (what run.ts's
126+
// `followup`/`interrupt` closures actually close over) — a shared array,
127+
// not something recreated per call.
128+
const history: string[] = ["read src/index.ts", "found the bug on line 12"];
129+
let interruptFired = false;
130+
sessions.registerInterrupt(worker.id, () => {
131+
interruptFired = true;
132+
});
133+
sessions.registerFollowup(worker.id, async (message: string) => {
134+
history.push(message);
135+
return `Applying fix given ${history.length} prior turns of context.`;
136+
});
137+
138+
const interruptAgent = createInterruptAgentTool({ sessions });
139+
const followupTask = createFollowupTaskTool({ sessions });
140+
141+
const interruptResult = await callTool(interruptAgent, { target: worker.id });
142+
expect(interruptResult.status).toBe("interrupted");
143+
expect(interruptFired).toBe(true);
144+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted");
145+
146+
const followupResult = await callTool(followupTask, {
147+
target: worker.id,
148+
message: "actually fix line 12 directly, not line 20",
149+
});
150+
expect(followupResult.status).toBe("completed");
151+
152+
// The load-bearing assertion: the worker's own history object still
153+
// holds the turns that predate the interrupt, plus the new one appended
154+
// in place — not a fresh array the followup started from empty.
155+
expect(history).toEqual([
156+
"read src/index.ts",
157+
"found the bug on line 12",
158+
"actually fix line 12 directly, not line 20",
159+
]);
160+
expect(history.length).toBe(3);
161+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed");
162+
expect(sessions.get(worker.id)?.report).toBe(followupResult.reply as string);
163+
});
164+
165+
test("followup_task on a completed retained worker reuses its existing session, not a fresh one", async () => {
166+
const sessions = createSubAgentSessionStore();
167+
const worker = sessions.start({
168+
description: "worker",
169+
agentId: "a",
170+
brief: "b",
171+
retained: true,
172+
});
173+
const history: string[] = ["did the first task"];
174+
sessions.registerFollowup(worker.id, async (message: string) => {
175+
history.push(message);
176+
return `done, history now ${history.length} turns`;
177+
});
178+
sessions.complete(worker.id, "## Summary\nFirst task done.");
179+
180+
const followupTask = createFollowupTaskTool({ sessions });
181+
const result = await callTool(followupTask, { target: worker.id, message: "now do task two" });
182+
183+
expect(result.status).toBe("completed");
184+
// Same session id throughout — never re-created — and its underlying
185+
// history object grew rather than being replaced.
186+
expect(sessions.get(worker.id)?.id).toBe(worker.id);
187+
expect(history).toEqual(["did the first task", "now do task two"]);
188+
189+
const nonRetained = sessions.start({ description: "d2", agentId: "a", brief: "b" });
190+
sessions.complete(nonRetained.id, "## Summary\nDone.");
191+
if (followupTask.kind !== "full") throw new Error("expected full tool");
192+
const rejected = await followupTask.handler(
193+
{
194+
id: "c3",
195+
name: "followup_task",
196+
arguments: { target: nonRetained.id, message: "more work" },
197+
},
198+
new AbortController().signal,
199+
);
200+
expect(rejected.isError).toBe(true);
201+
});
202+
203+
test("an interrupted session is resumable via followup_task and interrupt never touches close()", async () => {
204+
const sessions = createSubAgentSessionStore();
205+
const worker = sessions.start({
206+
description: "worker",
207+
agentId: "a",
208+
brief: "b",
209+
retained: true,
210+
});
211+
sessions.markRunning(worker.id);
212+
213+
let closeCalls = 0;
214+
sessions.registerClose(worker.id, async () => {
215+
closeCalls++;
216+
});
217+
sessions.registerInterrupt(worker.id, () => {
218+
// Real interrupt handle: fires a dedicated signal, never close().
219+
});
220+
sessions.registerFollowup(worker.id, async () => "resumed cleanly");
221+
222+
const interruptAgent = createInterruptAgentTool({ sessions });
223+
const followupTask = createFollowupTaskTool({ sessions });
224+
225+
await callTool(interruptAgent, { target: worker.id });
226+
expect(closeCalls).toBe(0);
227+
228+
const followupResult = await callTool(followupTask, { target: worker.id, message: "continue" });
229+
expect(followupResult.status).toBe("completed");
230+
expect(closeCalls).toBe(0);
231+
// No lock-strand risk from this path: close() was never invoked, so the
232+
// workdir lock close_agent's bounded teardown would otherwise release
233+
// was never at risk of being held by a wedged close in the first place.
234+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed");
235+
});
236+
237+
test("interrupt_agent and followup_task fail closed on a non-running / non-retained target", async () => {
238+
const sessions = createSubAgentSessionStore();
239+
const notRunning = sessions.start({ description: "d", agentId: "a", brief: "b" });
240+
sessions.complete(notRunning.id, "## Summary\nDone.");
241+
242+
const interruptAgent = createInterruptAgentTool({ sessions });
243+
const followupTask = createFollowupTaskTool({ sessions });
244+
245+
if (interruptAgent.kind !== "full") throw new Error("expected full tool");
246+
const interruptErr = await interruptAgent.handler(
247+
{ id: "c1", name: "interrupt_agent", arguments: { target: notRunning.id } },
248+
new AbortController().signal,
249+
);
250+
expect(interruptErr.isError).toBe(true);
251+
252+
if (followupTask.kind !== "full") throw new Error("expected full tool");
253+
const followupErr = await followupTask.handler(
254+
{ id: "c2", name: "followup_task", arguments: { target: notRunning.id, message: "x" } },
255+
new AbortController().signal,
256+
);
257+
// Not retained, so followup_task must reject even though it is "completed".
258+
expect(followupErr.isError).toBe(true);
259+
});
260+
});

0 commit comments

Comments
 (0)