Skip to content

Commit 6808f99

Browse files
Add send_input without breaking the wait mailbox (#682)
* 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. * Gate addressing fleet verbs with subtree authority Nested interrupt/close/resume/followup now share send_input's assertCanTargetAgent check and fail closed without an actorId. Soft-interrupt wait_agents collects so a later followup cannot resurrect an already-observed interrupt as done. * Format run.ts after send_input authority restack
1 parent 4b9954b commit 6808f99

13 files changed

Lines changed: 709 additions & 28 deletions

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
219219

220220
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
221221

222-
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved name (`send_input`) so a later mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
223-
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` and `close_agent` terminalize the wait mailbox immediately.
222+
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
223+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, and `followup_task`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
224224
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
225225

226226
#### Closed director fleet (`src/agent/directors/`)

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: 109 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
});
@@ -722,6 +800,35 @@ describe("interrupt_agent unblocks wait_agents", () => {
722800
expect(results[0]!.status).toBe("interrupted");
723801
expect(results[0]!.report).toContain("salvage");
724802
});
803+
804+
test("soft-interrupt wait collects so a later followup cannot resurrect done", async () => {
805+
const sessions = createSubAgentSessionStore();
806+
const fleetRecords = createFleetRecords();
807+
const worker = sessions.start({
808+
id: "soft-int",
809+
description: "looping",
810+
agentId: "explorer",
811+
brief: "b",
812+
retained: true,
813+
});
814+
sessions.markRunning(worker.id);
815+
// Running fleet record + soft-interrupted session (lifecycle only) —
816+
// the wait soft path must interrupt+take before returning.
817+
fleetRecords.register(worker.id);
818+
sessions.registerInterrupt(worker.id, () => {});
819+
sessions.interruptOne(worker.id);
820+
821+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
822+
const waited = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 });
823+
expect(waited.timed_out).toBe(false);
824+
const results = waited.results as { status: string }[];
825+
expect(results[0]!.status).toBe("interrupted");
826+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
827+
828+
fleetRecords.completeAfterInterrupt(worker.id, "resurrected reply");
829+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
830+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
831+
});
725832
});
726833

727834
describe("close_agent unblocks wait_agents", () => {
@@ -732,6 +839,7 @@ describe("close_agent unblocks wait_agents", () => {
732839
close: async () => {},
733840
interrupt: () => {},
734841
followup: async () => "",
842+
deliver: () => {},
735843
});
736844
return gate.promise;
737845
});

src/subagent/agent-fleet.ts

Lines changed: 19 additions & 3 deletions
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
};
@@ -706,8 +721,9 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
706721
}
707722
const session = deps.sessions.get(id);
708723
if (isSoftInterrupted(session)) {
709-
// Terminalize + collect so an omitted-targets re-wait does not keep
710-
// seeing this id as uncollected / re-deliver soft-interrupt.
724+
// Match the mailbox to what we report (include salvage report when
725+
// present), then collect so a later completeAfterInterrupt cannot
726+
// resurrect this wait as "done".
711727
deps.fleetRecords.interrupt(id, session.report);
712728
const taken = deps.fleetRecords.take(id);
713729
return {

src/subagent/authority.ts

Lines changed: 10 additions & 11 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",
@@ -108,15 +107,15 @@ function isDescendant(
108107
}
109108

110109
/**
111-
* Live gate for `read_agent_trace` (and any future verb that addresses an
112-
* existing session). Callers that only spawn (`task`, `spawn_agent`) never
113-
* reach this check.
114-
*
115110
* Authority rule (root owns its tree; a child manages only its own
116111
* descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id`
117112
* itself, or a descendant of `actor.id` in `nodes`. A Tier 3 leaf holds no
118113
* fleet verbs at all and can never reach this check with a real call, so it
119114
* always fails closed here too.
115+
*
116+
* Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`,
117+
* `close_agent`, `resume_agent`, and `followup_task` (nested mounts pass
118+
* authority from run.ts; Tier-1 primary omits it and stays unrestricted).
120119
*/
121120
export function assertCanTargetAgent(
122121
actor: { readonly id: string; readonly tier: SubagentTier },

0 commit comments

Comments
 (0)