Skip to content

Commit c54be80

Browse files
committed
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.
1 parent 4b543f4 commit c54be80

8 files changed

Lines changed: 469 additions & 13 deletions

File tree

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", () => {

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+
});

src/subagent/lifecycle-tools.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,105 @@ export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
145145
},
146146
});
147147
}
148+
149+
const InterruptAgentArgs = type({
150+
target: "string",
151+
});
152+
153+
export const interruptAgentToolDefinition: ToolDefinition = {
154+
name: "interrupt_agent",
155+
description:
156+
"Stop a worker session's current turn while keeping the session and its context intact and " +
157+
"reusable — distinct from close_agent, which is permanent. The worker's in-flight tool call or " +
158+
"inference keeps running in the background (there is no way to hard-stop it without tearing the " +
159+
"session down); this only stops the caller from waiting on it and marks the session " +
160+
"'interrupted' so followup_task or resume_agent can pick it back up with full prior context. " +
161+
"Fails on a session that is not currently running.",
162+
inputSchema: {
163+
type: "object",
164+
properties: {
165+
target: { type: "string", description: "agent_id of the session to interrupt." },
166+
},
167+
required: ["target"],
168+
},
169+
};
170+
171+
export function createInterruptAgentTool(deps: LifecycleToolDeps): AgentTool {
172+
return tool({
173+
definition: interruptAgentToolDefinition,
174+
handler: async (call, _signal): Promise<ToolResult> => {
175+
const parsed = InterruptAgentArgs(call.arguments);
176+
if (parsed instanceof type.errors) {
177+
return lifecycleResult(
178+
call.id,
179+
`Error: interrupt_agent arguments invalid: ${parsed.summary}`,
180+
);
181+
}
182+
const target = parsed.target.trim();
183+
const outcome = deps.sessions.interruptOne(target);
184+
if (!outcome.ok) {
185+
return lifecycleResult(
186+
call.id,
187+
`Error: cannot interrupt "${target}" (status: ${outcome.status}).`,
188+
);
189+
}
190+
return lifecycleResult(
191+
call.id,
192+
JSON.stringify({ agent_id: target, status: "interrupted" satisfies AgentLifecycleStatus }),
193+
);
194+
},
195+
});
196+
}
197+
198+
const FollowupTaskArgs = type({
199+
target: "string",
200+
message: "string",
201+
});
202+
203+
export const followupTaskToolDefinition: ToolDefinition = {
204+
name: "followup_task",
205+
description:
206+
"Send new work into an existing retained worker session (one that is 'completed' or " +
207+
"'interrupted'), reusing its prior context and tool outputs rather than starting a fresh worker. " +
208+
"Blocks until the worker replies to this new message, and returns its reply. Fails on a session " +
209+
"that was never retained, is still running, or was closed via close_agent (closing is permanent).",
210+
inputSchema: {
211+
type: "object",
212+
properties: {
213+
target: { type: "string", description: "agent_id of the retained session to resume." },
214+
message: { type: "string", description: "The new instruction/message for the worker." },
215+
},
216+
required: ["target", "message"],
217+
},
218+
};
219+
220+
export function createFollowupTaskTool(deps: LifecycleToolDeps): AgentTool {
221+
return tool({
222+
definition: followupTaskToolDefinition,
223+
handler: async (call, _signal): Promise<ToolResult> => {
224+
const parsed = FollowupTaskArgs(call.arguments);
225+
if (parsed instanceof type.errors) {
226+
return lifecycleResult(
227+
call.id,
228+
`Error: followup_task arguments invalid: ${parsed.summary}`,
229+
);
230+
}
231+
const target = parsed.target.trim();
232+
const message = parsed.message.trim();
233+
if (message.length === 0) {
234+
return lifecycleResult(call.id, "Error: followup_task requires a non-empty message.");
235+
}
236+
const outcome = await deps.sessions.followupOne(target, message);
237+
if (!outcome.ok) {
238+
return lifecycleResult(
239+
call.id,
240+
`Error: cannot send followup to "${target}" (status: ${outcome.status}).`,
241+
);
242+
}
243+
return lifecycleResult(
244+
call.id,
245+
JSON.stringify({ agent_id: target, status: "completed", reply: outcome.reply }),
246+
);
247+
},
248+
});
249+
}

0 commit comments

Comments
 (0)