Skip to content

Commit b87ba8d

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 55a590b commit b87ba8d

12 files changed

Lines changed: 463 additions & 16 deletions

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

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

2223
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
@@ -92,6 +92,7 @@ describe("createToolIndex", () => {
9292
"resume_agent",
9393
"interrupt_agent",
9494
"followup_task",
95+
"send_input",
9596
] as const) {
9697
expect(CORE_TOOL_NAMES).toContain(name);
9798
expect(advertised).toContain(name);
@@ -241,6 +242,7 @@ describe("advertisedTools", () => {
241242
"resume_agent",
242243
"interrupt_agent",
243244
"followup_task",
245+
"send_input",
244246
] as const) {
245247
expect(prefix).toContain(name);
246248
}

src/agent/tool-search.ts

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

5455
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
@@ -60,6 +61,7 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
6061
"resume_agent",
6162
"interrupt_agent",
6263
"followup_task",
64+
"send_input",
6365
];
6466

6567
// 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
@@ -49,6 +49,7 @@ import {
4949
createResumeAgentTool,
5050
createInterruptAgentTool,
5151
createFollowupTaskTool,
52+
createSendInputTool,
5253
} from "../subagent/lifecycle-tools.js";
5354
import { parseManageTasksArgs } from "./tasks.js";
5455
import { createListDirTool } from "../util/list-dir.js";
@@ -348,6 +349,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
348349
createResumeAgentTool({ sessions: fleetSessions }),
349350
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
350351
createFollowupTaskTool({ sessions: fleetSessions }),
352+
createSendInputTool({ sessions: fleetSessions, fleetRecords }),
351353
);
352354
}
353355
}

src/subagent/agent-fleet.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
MAX_FLEET_RECORDS,
88
type AgentFleetDeps,
99
} from "./agent-fleet.js";
10-
import { createInterruptAgentTool } from "./lifecycle-tools.js";
10+
import { createInterruptAgentTool, createSendInputTool } from "./lifecycle-tools.js";
1111
import { createSubAgentSessionStore } from "./session-store.js";
1212
import { createPermissionGate } from "../permission/gate.js";
1313
import { forcedStopReport } from "./stop-policy.js";
@@ -509,6 +509,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
509509
close: async () => {},
510510
interrupt: () => {},
511511
followup: async () => "",
512+
deliver: () => {},
512513
});
513514
return gate.promise;
514515
});
@@ -571,4 +572,74 @@ describe("interrupt_agent unblocks wait_agents", () => {
571572
expect(results[0]!.status).toBe("interrupted");
572573
expect(results[0]!.report).toContain("partial");
573574
});
575+
576+
test("send_input soft-deliver does not complete wait_agents", async () => {
577+
const gate = deferred<RunSubAgentResult>();
578+
const deps = makeDeps(async (params) => {
579+
params.onAgentReady?.({
580+
close: async () => {},
581+
interrupt: () => {},
582+
followup: async () => "",
583+
deliver: () => {},
584+
});
585+
return gate.promise;
586+
});
587+
const spawn = createSpawnAgentTool(deps);
588+
const wait = createWaitAgentsTool({
589+
sessions: deps.sessions,
590+
fleetRecords: deps.fleetRecords,
591+
});
592+
const sendInput = createSendInputTool({
593+
sessions: deps.sessions,
594+
fleetRecords: deps.fleetRecords,
595+
});
596+
const spawned = await callTool(spawn, {
597+
description: "looping",
598+
prompt: "do it",
599+
intent: "explore",
600+
});
601+
const id = spawned.agent_id as string;
602+
await callTool(sendInput, { target: id, message: "keep going" });
603+
const waited = await callTool(wait, { targets: [id], timeout_ms: 50 });
604+
expect(waited.timed_out).toBe(true);
605+
const results = waited.results as { status: string }[];
606+
expect(results[0]!.status).toBe("running");
607+
gate.resolve({ report: "done" });
608+
});
609+
610+
test("send_input interrupt:true unblocks wait_agents as interrupted", async () => {
611+
const gate = deferred<RunSubAgentResult>();
612+
const followupGate = deferred<string>();
613+
const deps = makeDeps(async (params) => {
614+
params.onAgentReady?.({
615+
close: async () => {},
616+
interrupt: () => {},
617+
followup: async () => followupGate.promise,
618+
deliver: () => {},
619+
});
620+
return gate.promise;
621+
});
622+
const spawn = createSpawnAgentTool(deps);
623+
const wait = createWaitAgentsTool({
624+
sessions: deps.sessions,
625+
fleetRecords: deps.fleetRecords,
626+
});
627+
const sendInput = createSendInputTool({
628+
sessions: deps.sessions,
629+
fleetRecords: deps.fleetRecords,
630+
});
631+
const spawned = await callTool(spawn, {
632+
description: "looping",
633+
prompt: "do it",
634+
intent: "explore",
635+
});
636+
const id = spawned.agent_id as string;
637+
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
638+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
639+
const waited = await waiting;
640+
expect(waited.timed_out).toBe(false);
641+
const results = waited.results as { status: string }[];
642+
expect(results[0]!.status).toBe("interrupted");
643+
followupGate.resolve("later");
644+
});
574645
});

src/subagent/agent-fleet.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,20 @@ class FleetRecords {
151151
this.notify();
152152
}
153153

154+
/**
155+
* send_input interrupt:true queued a followup that has now finished.
156+
* Upgrade an uncollected interrupted record to done. No-op if wait_agents
157+
* already collected the interrupt, so a later reply cannot resurrect it.
158+
*/
159+
completeAfterInterrupt(id: string, report: string): void {
160+
const existing = this.records.get(id);
161+
if (existing === undefined || existing.collected === true) return;
162+
if (existing.status !== "interrupted") return;
163+
this.records.set(id, { status: "done", report });
164+
this.enforceCap();
165+
this.notify();
166+
}
167+
154168
ids(): string[] {
155169
return [...this.records.keys()];
156170
}
@@ -516,10 +530,11 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
516530
// Keep the session open after a clean completion, and hand the
517531
// store a bounded close for close_agent to call later.
518532
persist: true,
519-
onAgentReady: ({ close, interrupt, followup }) => {
533+
onAgentReady: ({ close, interrupt, followup, deliver }) => {
520534
deps.sessions.registerClose(session.id, close);
521535
deps.sessions.registerInterrupt(session.id, interrupt);
522536
deps.sessions.registerFollowup(session.id, followup);
537+
deps.sessions.registerDeliver(session.id, deliver);
523538
deps.sessions.markRunning(session.id);
524539
},
525540
};

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, interrupt_agent, close_agent,
9-
* resume_agent, followup_task, read_agent_trace, search_agents; reserved:
10-
* list_agents, send_input). Fleet *discovery* verbs (search_agents,
11-
* list_agents) are further restricted to Tier 1 only (CL-7051) — nested
12-
* orchestrators keep task/spawn allowlists but must not discover the
13-
* full fleet.
9+
* resume_agent, followup_task, send_input, read_agent_trace,
10+
* search_agents; reserved: list_agents). Fleet *discovery* verbs
11+
* (search_agents, list_agents) are further restricted to Tier 1 only
12+
* (CL-7051) — nested orchestrators keep task/spawn allowlists but must
13+
* not discover the full fleet.
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
@@ -26,8 +26,7 @@ export type { SubagentTier } from "../agent/directors/types.js";
2626
/**
2727
* Every tool that grants control over other agents (spawn, list, steer,
2828
* observe). Tier 3 leaves may mount none of these — ever. Reserved names
29-
* (`list_agents`, `send_input`) stay in the set so a later mount site
30-
* inherits the gate instead of needing a second allowlist.
29+
* `list_agents` stays reserved so a later mount site inherits the gate.
3130
*/
3231
export const FLEET_VERBS = new Set([
3332
"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 { createSubAgentSessionStore } from "./session-store.js";
1011

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

0 commit comments

Comments
 (0)