Skip to content

Commit fbd8665

Browse files
committed
Add send_input without breaking the wait mailbox
Soft-deliver steers a running worker without completing wait_agents. interrupt:true uses the same mailbox flip as interrupt_agent so a parent wait unblocks once, then a later followup can become done only if that interrupt was never collected.
1 parent 4b9954b commit fbd8665

12 files changed

Lines changed: 471 additions & 16 deletions

src/agent/fleet-verbs-mount.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const FLEET_VERBS = [
1818
"resume_agent",
1919
"interrupt_agent",
2020
"followup_task",
21+
"send_input",
2122
] as const;
2223

2324
describe("primary fleet verb mount", () => {

src/agent/tool-search.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("createToolIndex", () => {
9393
"resume_agent",
9494
"interrupt_agent",
9595
"followup_task",
96+
"send_input",
9697
] as const) {
9798
expect(CORE_TOOL_NAMES).toContain(name);
9899
expect(advertised).toContain(name);
@@ -243,6 +244,7 @@ describe("advertisedTools", () => {
243244
"resume_agent",
244245
"interrupt_agent",
245246
"followup_task",
247+
"send_input",
246248
] as const) {
247249
expect(prefix).toContain(name);
248250
}

src/agent/tool-search.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const CORE_TOOL_NAMES: readonly string[] = [
5050
"resume_agent",
5151
"interrupt_agent",
5252
"followup_task",
53+
"send_input",
5354
];
5455

5556
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
@@ -62,6 +63,7 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
6263
"resume_agent",
6364
"interrupt_agent",
6465
"followup_task",
66+
"send_input",
6567
];
6668

6769
// Session-start facts that gate a core tool's advertisement. Each must be

src/agent/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
createResumeAgentTool,
5151
createInterruptAgentTool,
5252
createFollowupTaskTool,
53+
createSendInputTool,
5354
} from "../subagent/lifecycle-tools.js";
5455
import { parseManageTasksArgs } from "./tasks.js";
5556
import { createListDirTool } from "../util/list-dir.js";
@@ -350,6 +351,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
350351
createResumeAgentTool({ sessions: fleetSessions }),
351352
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
352353
createFollowupTaskTool({ sessions: fleetSessions }),
354+
createSendInputTool({ sessions: fleetSessions, fleetRecords }),
353355
);
354356
}
355357
}

src/subagent/agent-fleet.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
MAX_FLEET_RECORDS,
99
type AgentFleetDeps,
1010
} from "./agent-fleet.js";
11-
import { createInterruptAgentTool, createCloseAgentTool } from "./lifecycle-tools.js";
11+
import {
12+
createInterruptAgentTool,
13+
createCloseAgentTool,
14+
createSendInputTool,
15+
} from "./lifecycle-tools.js";
1216
import { createSubAgentSessionStore } from "./session-store.js";
1317
import { createPermissionGate } from "../permission/gate.js";
1418
import { forcedStopReport } from "./stop-policy.js";
@@ -471,6 +475,7 @@ describe("wait_agents caller scope", () => {
471475
close: async () => {},
472476
interrupt: () => {},
473477
followup: async () => "",
478+
deliver: () => {},
474479
});
475480
return gates[callIndex++]!.promise;
476481
});
@@ -568,6 +573,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
568573
close: async () => {},
569574
interrupt: () => {},
570575
followup: async () => "",
576+
deliver: () => {},
571577
});
572578
return gate.promise;
573579
});
@@ -631,13 +637,84 @@ describe("interrupt_agent unblocks wait_agents", () => {
631637
expect(results[0]!.report).toContain("partial");
632638
});
633639

640+
test("send_input soft-deliver does not complete wait_agents", async () => {
641+
const gate = deferred<RunSubAgentResult>();
642+
const deps = makeDeps(async (params) => {
643+
params.onAgentReady?.({
644+
close: async () => {},
645+
interrupt: () => {},
646+
followup: async () => "",
647+
deliver: () => {},
648+
});
649+
return gate.promise;
650+
});
651+
const spawn = createSpawnAgentTool(deps);
652+
const wait = createWaitAgentsTool({
653+
sessions: deps.sessions,
654+
fleetRecords: deps.fleetRecords,
655+
});
656+
const sendInput = createSendInputTool({
657+
sessions: deps.sessions,
658+
fleetRecords: deps.fleetRecords,
659+
});
660+
const spawned = await callTool(spawn, {
661+
description: "looping",
662+
prompt: "do it",
663+
intent: "explore",
664+
});
665+
const id = spawned.agent_id as string;
666+
await callTool(sendInput, { target: id, message: "keep going" });
667+
const waited = await callTool(wait, { targets: [id], timeout_ms: 50 });
668+
expect(waited.timed_out).toBe(true);
669+
const results = waited.results as { status: string }[];
670+
expect(results[0]!.status).toBe("running");
671+
gate.resolve({ report: "done" });
672+
});
673+
674+
test("send_input interrupt:true unblocks wait_agents as interrupted", async () => {
675+
const gate = deferred<RunSubAgentResult>();
676+
const followupGate = deferred<string>();
677+
const deps = makeDeps(async (params) => {
678+
params.onAgentReady?.({
679+
close: async () => {},
680+
interrupt: () => {},
681+
followup: async () => followupGate.promise,
682+
deliver: () => {},
683+
});
684+
return gate.promise;
685+
});
686+
const spawn = createSpawnAgentTool(deps);
687+
const wait = createWaitAgentsTool({
688+
sessions: deps.sessions,
689+
fleetRecords: deps.fleetRecords,
690+
});
691+
const sendInput = createSendInputTool({
692+
sessions: deps.sessions,
693+
fleetRecords: deps.fleetRecords,
694+
});
695+
const spawned = await callTool(spawn, {
696+
description: "looping",
697+
prompt: "do it",
698+
intent: "explore",
699+
});
700+
const id = spawned.agent_id as string;
701+
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
702+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
703+
const waited = await waiting;
704+
expect(waited.timed_out).toBe(false);
705+
const results = waited.results as { status: string }[];
706+
expect(results[0]!.status).toBe("interrupted");
707+
followupGate.resolve("later");
708+
});
709+
634710
test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => {
635711
const gate = deferred<RunSubAgentResult>();
636712
const deps = makeDeps(async (params) => {
637713
params.onAgentReady?.({
638714
close: async () => {},
639715
interrupt: () => {},
640716
followup: async () => "",
717+
deliver: () => {},
641718
});
642719
return gate.promise;
643720
});
@@ -678,6 +755,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
678755
close: async () => {},
679756
interrupt: () => {},
680757
followup: async () => "",
758+
deliver: () => {},
681759
});
682760
return settle.promise;
683761
});
@@ -732,6 +810,7 @@ describe("close_agent unblocks wait_agents", () => {
732810
close: async () => {},
733811
interrupt: () => {},
734812
followup: async () => "",
813+
deliver: () => {},
735814
});
736815
return gate.promise;
737816
});

src/subagent/agent-fleet.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,20 @@ class FleetRecords {
157157
this.notify();
158158
}
159159

160+
/**
161+
* send_input interrupt:true queued a followup that has now finished.
162+
* Upgrade an uncollected interrupted record to done. No-op if wait_agents
163+
* already collected the interrupt, so a later reply cannot resurrect it.
164+
*/
165+
completeAfterInterrupt(id: string, report: string): void {
166+
const existing = this.records.get(id);
167+
if (existing === undefined || existing.collected === true) return;
168+
if (existing.status !== "interrupted") return;
169+
this.records.set(id, { status: "done", report });
170+
this.enforceCap();
171+
this.notify();
172+
}
173+
160174
ids(): string[] {
161175
return [...this.records.keys()];
162176
}
@@ -522,10 +536,11 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
522536
// Keep the session open after a clean completion, and hand the
523537
// store a bounded close for close_agent to call later.
524538
persist: true,
525-
onAgentReady: ({ close, interrupt, followup }) => {
539+
onAgentReady: ({ close, interrupt, followup, deliver }) => {
526540
deps.sessions.registerClose(session.id, close);
527541
deps.sessions.registerInterrupt(session.id, interrupt);
528542
deps.sessions.registerFollowup(session.id, followup);
543+
deps.sessions.registerDeliver(session.id, deliver);
529544
deps.sessions.markRunning(session.id);
530545
},
531546
};

src/subagent/authority.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
*
77
* - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb
88
* (task, spawn_agent, wait_agents, list_agents, interrupt_agent, close_agent,
9-
* resume_agent, followup_task, read_agent_trace, search_agents; reserved:
10-
* send_input). Fleet *discovery* of the director catalog
11-
* (search_agents) is Tier 1 only (CL-7051). list_agents is not catalog
12-
* discovery — it lists this install's own spawn_agent workers, the same
13-
* scoped mailbox wait_agents uses, so nested orchestrators may mount it.
9+
* resume_agent, followup_task, send_input, read_agent_trace, search_agents).
10+
* Fleet *discovery* of the director catalog (search_agents) is Tier 1 only
11+
* (CL-7051). list_agents is not catalog discovery — it lists this install's
12+
* own spawn_agent workers, the same scoped mailbox wait_agents uses, so
13+
* nested orchestrators may mount it.
1414
* - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its
1515
* own descendants, never a sibling or anything above it in the tree.
1616
* Tier 1 (the primary orchestrator) may target anyone. Callers pass the
@@ -25,8 +25,7 @@ export type { SubagentTier } from "../agent/directors/types.js";
2525

2626
/**
2727
* Every tool that grants control over other agents (spawn, list, steer,
28-
* observe). Tier 3 leaves may mount none of these — ever. Reserved names
29-
* `send_input` stays reserved so a later mount site inherits the gate.
28+
* observe). Tier 3 leaves may mount none of these — ever.
3029
*/
3130
export const FLEET_VERBS = new Set([
3231
"task",

src/subagent/lifecycle-tools.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createResumeAgentTool,
66
createInterruptAgentTool,
77
createFollowupTaskTool,
8+
createSendInputTool,
89
} from "./lifecycle-tools.js";
910
import { createFleetRecords } from "./agent-fleet.js";
1011
import { createSubAgentSessionStore } from "./session-store.js";
@@ -14,7 +15,8 @@ async function callTool(
1415
| ReturnType<typeof createCloseAgentTool>
1516
| ReturnType<typeof createResumeAgentTool>
1617
| ReturnType<typeof createInterruptAgentTool>
17-
| ReturnType<typeof createFollowupTaskTool>,
18+
| ReturnType<typeof createFollowupTaskTool>
19+
| ReturnType<typeof createSendInputTool>,
1820
args: Record<string, unknown>,
1921
): Promise<Record<string, unknown>> {
2022
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
@@ -268,3 +270,131 @@ describe("interrupt_agent / followup_task", () => {
268270
expect(followupErr.isError).toBe(true);
269271
});
270272
});
273+
274+
describe("send_input", () => {
275+
test("soft-delivers without flipping lifecycle or awaiting a reply", async () => {
276+
const sessions = createSubAgentSessionStore();
277+
const worker = sessions.start({
278+
description: "worker",
279+
agentId: "a",
280+
brief: "b",
281+
retained: true,
282+
});
283+
sessions.markRunning(worker.id);
284+
const delivered: string[] = [];
285+
sessions.registerDeliver(worker.id, (message) => {
286+
delivered.push(message);
287+
});
288+
289+
const sendInput = createSendInputTool({ sessions });
290+
const result = await callTool(sendInput, {
291+
target: worker.id,
292+
message: "stop and inspect line 4",
293+
});
294+
295+
expect(result).toEqual({ agent_id: worker.id, status: "running" });
296+
expect(delivered).toEqual(["stop and inspect line 4"]);
297+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
298+
});
299+
300+
test("interrupt:true queues followup without awaiting and refuses when followup is missing", async () => {
301+
const sessions = createSubAgentSessionStore();
302+
const worker = sessions.start({
303+
description: "worker",
304+
agentId: "a",
305+
brief: "b",
306+
retained: true,
307+
});
308+
sessions.markRunning(worker.id);
309+
let interrupted = false;
310+
let followupStarted = false;
311+
sessions.registerInterrupt(worker.id, () => {
312+
interrupted = true;
313+
});
314+
sessions.registerFollowup(worker.id, async (message) => {
315+
followupStarted = true;
316+
expect(message).toBe("patch only the test");
317+
await new Promise((resolve) => setTimeout(resolve, 20));
318+
return "queued turn finished";
319+
});
320+
sessions.registerDeliver(worker.id, () => {
321+
throw new Error("interrupt:true should not soft-deliver");
322+
});
323+
324+
const sendInput = createSendInputTool({ sessions });
325+
const result = await callTool(sendInput, {
326+
target: worker.id,
327+
message: "patch only the test",
328+
interrupt: true,
329+
});
330+
expect(result).toEqual({ agent_id: worker.id, status: "interrupted" });
331+
expect(interrupted).toBe(true);
332+
expect(followupStarted).toBe(true);
333+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted");
334+
335+
const missing = sessions.start({
336+
description: "no-followup",
337+
agentId: "a",
338+
brief: "b",
339+
retained: true,
340+
});
341+
sessions.markRunning(missing.id);
342+
sessions.registerInterrupt(missing.id, () => {});
343+
if (sendInput.kind !== "full") throw new Error("expected full tool");
344+
const denied = await sendInput.handler(
345+
{
346+
id: "missing-followup",
347+
name: "send_input",
348+
arguments: { target: missing.id, message: "steer", interrupt: true },
349+
},
350+
new AbortController().signal,
351+
);
352+
expect(denied.isError).toBe(true);
353+
expect(sessions.get(missing.id)?.lifecycleStatus).toBe("running");
354+
});
355+
356+
test("enforces nested orchestrator descendant authority", async () => {
357+
const sessions = createSubAgentSessionStore();
358+
const nested = sessions.start({
359+
id: "nested",
360+
description: "nested",
361+
agentId: "a",
362+
brief: "b",
363+
});
364+
const child = sessions.start({
365+
id: "child",
366+
description: "child",
367+
agentId: "a",
368+
brief: "b",
369+
parentSessionId: nested.id,
370+
});
371+
const sibling = sessions.start({
372+
id: "sibling",
373+
description: "sibling",
374+
agentId: "a",
375+
brief: "b",
376+
});
377+
for (const session of [nested, child, sibling]) {
378+
sessions.markRunning(session.id);
379+
sessions.registerDeliver(session.id, () => {});
380+
}
381+
const sendInput = createSendInputTool({
382+
sessions,
383+
authority: {
384+
actorId: nested.id,
385+
tier: "nested-orchestrator",
386+
getNodes: () => sessions.list(),
387+
},
388+
});
389+
390+
const ok = await callTool(sendInput, { target: child.id, message: "continue" });
391+
expect(ok.status).toBe("running");
392+
393+
if (sendInput.kind !== "full") throw new Error("expected full tool");
394+
const denied = await sendInput.handler(
395+
{ id: "denied", name: "send_input", arguments: { target: sibling.id, message: "continue" } },
396+
new AbortController().signal,
397+
);
398+
expect(denied.isError).toBe(true);
399+
});
400+
});

0 commit comments

Comments
 (0)