Skip to content

Commit 751f7f4

Browse files
committed
fix(subagent): narrow late send_input redirect by terminal status
Completed names summary plus report_uri and resume, interrupted resumes without a false report claim, shutdown points at trace or spawn.
1 parent f82308d commit 751f7f4

2 files changed

Lines changed: 178 additions & 2 deletions

File tree

‎src/subagent/lifecycle-tools.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,98 @@ describe("send_input", () => {
955955
expect(sessions.get(closed.id)?.lifecycleStatus).toBe("shutdown");
956956
});
957957

958+
test("completed send_input error redirects to resume_agent and mailbox mail", async () => {
959+
const sessions = createSubAgentSessionStore();
960+
const fleetRecords = createFleetMailbox(sessions);
961+
const sendInput = createSendInputTool({ sessions, fleetRecords });
962+
963+
const completed = startSession(sessions, {
964+
description: "done",
965+
retained: true,
966+
});
967+
sessions.markRunning(completed.id);
968+
sessions.registerDeliver(completed.id, () => {
969+
throw new Error("must not deliver to a completed session");
970+
});
971+
sessions.complete(completed.id, "## Summary\nDone.");
972+
const err = await callFleetToolRaw(sendInput, {
973+
target: completed.id,
974+
message: "late steer",
975+
});
976+
expect(err.isError).toBe(true);
977+
expect(err.content).toContain(`cannot send_input to "${completed.id}"`);
978+
expect(err.content).toContain("status: completed");
979+
expect(err.content).toContain("resume_agent");
980+
expect(err.content).toContain("mailbox mail");
981+
expect(err.content).toContain("do not retry send_input");
982+
expect(err.content).toContain("Summary: Done.");
983+
expect(err.content).toContain(
984+
`report_uri: tool-output:///fleet-dry:${completed.id}:report`,
985+
);
986+
expect(err.content).toContain("read_file");
987+
});
988+
989+
test("interrupted send_input error resumes without a false report-delivered claim", async () => {
990+
const sessions = createSubAgentSessionStore();
991+
const fleetRecords = createFleetMailbox(sessions);
992+
const sendInput = createSendInputTool({ sessions, fleetRecords });
993+
994+
const worker = startSession(sessions, {
995+
description: "paused",
996+
retained: true,
997+
});
998+
sessions.markRunning(worker.id);
999+
sessions.registerInterrupt(worker.id, () => undefined);
1000+
sessions.registerDeliver(worker.id, () => {
1001+
throw new Error("must not deliver to an interrupted session");
1002+
});
1003+
await callTool(createInterruptAgentTool({ sessions, fleetRecords }), {
1004+
target: worker.id,
1005+
});
1006+
const err = await callFleetToolRaw(sendInput, {
1007+
target: worker.id,
1008+
message: "late steer",
1009+
});
1010+
expect(err.isError).toBe(true);
1011+
expect(err.content).toContain(`cannot send_input to "${worker.id}"`);
1012+
expect(err.content).toContain("status: interrupted");
1013+
expect(err.content).toContain("resume_agent");
1014+
expect(err.content).toContain("do not retry send_input");
1015+
expect(err.content).toContain("no report delivered");
1016+
expect(err.content).not.toContain("report was delivered");
1017+
expect(err.content).not.toContain("mailbox mail");
1018+
});
1019+
1020+
test("shutdown send_input error points at trace/spawn, never resume_agent", async () => {
1021+
const sessions = createSubAgentSessionStore();
1022+
const fleetRecords = createFleetMailbox(sessions);
1023+
const sendInput = createSendInputTool({ sessions, fleetRecords });
1024+
1025+
const worker = startSession(sessions, {
1026+
description: "closed",
1027+
retained: true,
1028+
});
1029+
sessions.markRunning(worker.id);
1030+
sessions.registerClose(worker.id, async () => undefined);
1031+
sessions.registerDeliver(worker.id, () => {
1032+
throw new Error("must not deliver to a closed session");
1033+
});
1034+
await callTool(createCloseAgentTool({ sessions, fleetRecords }), {
1035+
target: worker.id,
1036+
});
1037+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("shutdown");
1038+
const err = await callFleetToolRaw(sendInput, {
1039+
target: worker.id,
1040+
message: "late steer",
1041+
});
1042+
expect(err.isError).toBe(true);
1043+
expect(err.content).toContain(`cannot send_input to "${worker.id}"`);
1044+
expect(err.content).toContain("status: shutdown");
1045+
expect(err.content).toContain("read_agent_trace");
1046+
expect(err.content).not.toContain("use resume_agent for another turn");
1047+
expect(err.content).not.toContain("report was delivered");
1048+
});
1049+
9581050
test("enforces nested orchestrator descendant authority", async () => {
9591051
const sessions = createSubAgentSessionStore();
9601052
const nested = startSession(sessions, {

‎src/subagent/lifecycle-tools.ts‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import type { ToolDefinition, ToolResult } from "@intx/types/runtime";
1616
import { DEFAULT_CLOSE_DEADLINE_MS } from "./dispose.js";
1717
import { errorMessage } from "../agent/error-message.js";
1818
import type { FleetMailboxHandle } from "./agent-fleet.js";
19+
import {
20+
fleetDrySpillKey,
21+
MAILBOX_DIGEST_SECTION_CHARS,
22+
UNSTRUCTURED_DIGEST_CHARS,
23+
} from "./fleet-dry-drive.js";
24+
import { parseSubAgentReport } from "./report.js";
1925
import {
2026
DEFAULT_MAX_ENTRY_CHARS,
2127
type AgentLifecycleStatus,
@@ -136,6 +142,73 @@ export type ResumeAgentToolDeps = LifecycleToolDeps & {
136142
fleetRecords: FleetMailboxHandle;
137143
};
138144

145+
/**
146+
* Clip a late-send summary the same way digestCollectedReport clips its inline
147+
* digest section: structured reports keep up to MAILBOX_DIGEST_SECTION_CHARS,
148+
* unstructured (no envelope sections) keep a short teaser — the blob holds
149+
* the rest.
150+
*/
151+
function clipLateSendSummary(text: string, unstructured: boolean): string {
152+
const max = unstructured
153+
? UNSTRUCTURED_DIGEST_CHARS
154+
: MAILBOX_DIGEST_SECTION_CHARS;
155+
if (text.length <= max) return text;
156+
return `${text.slice(0, max - 1).trimEnd()}…`;
157+
}
158+
159+
/**
160+
* Late-send_input redirect per terminal status. Only `completed` delivered a
161+
* report (via mailbox mail); `interrupted` never did, and `shutdown` sessions
162+
* are gone for good (resumeOne after closeOne fails), so only `completed`
163+
* names resume_agent. Shutdown/evicted point at read_agent_trace / a fresh
164+
* spawn instead.
165+
*/
166+
function lateSendRedirect(
167+
target: string,
168+
status: AgentLifecycleStatus,
169+
gone: boolean,
170+
report: string | undefined,
171+
): string {
172+
if (status === "completed" && !gone) {
173+
let digest = "";
174+
if (report !== undefined && report.length > 0) {
175+
const parsed = parseSubAgentReport(report);
176+
const unstructured =
177+
parsed.findings.length === 0 &&
178+
parsed.blockers.length === 0 &&
179+
parsed.paths.length === 0;
180+
const summary =
181+
parsed.summary.length > 0
182+
? clipLateSendSummary(parsed.summary, unstructured)
183+
: "";
184+
const reportUri = `tool-output:///${fleetDrySpillKey(target, "report")}`;
185+
digest =
186+
summary.length > 0
187+
? ` Summary: ${summary} report_uri: ${reportUri} — use read_file with that URI (offset/limit supported) to see the rest.`
188+
: ` report_uri: ${reportUri} — use read_file with that URI (offset/limit supported) to see the rest.`;
189+
}
190+
return (
191+
" Worker already finished (completed) — summary below; full report via " +
192+
"report_uri / mailbox mail; use resume_agent for another turn, do not retry send_input." +
193+
digest
194+
);
195+
}
196+
if (status === "interrupted" && !gone) {
197+
return (
198+
" Worker is interrupted (no report delivered) — " +
199+
"use resume_agent for another turn, do not retry send_input."
200+
);
201+
}
202+
if (status === "shutdown" || gone) {
203+
return (
204+
" Worker is shut down — the session is gone and cannot be resumed; " +
205+
"inspect via read_agent_trace or spawn a new worker, " +
206+
"do not use resume_agent or retry send_input."
207+
);
208+
}
209+
return "";
210+
}
211+
139212
function gateTarget(
140213
deps: LifecycleToolDeps,
141214
toolName: string,
@@ -366,7 +439,7 @@ const SendInputArgs = type({
366439
export const sendInputToolDefinition: ToolDefinition = {
367440
name: "send_input",
368441
description:
369-
"Message a running worker, or answer its pending ask_director. interrupt:true stops the current turn first and queues message as the next turn.",
442+
"Message a running worker only (check list_agents for status) — completed or interrupted workers need resume_agent instead; shutdown workers are gone (see read_agent_trace or spawn anew). Answers a pending ask_director; interrupt:true stops the current turn first and queues message as the next turn.",
370443
inputSchema: {
371444
type: "object",
372445
properties: {
@@ -435,10 +508,21 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
435508
if (!outcome.ok) {
436509
// CL-8016: name the teardown when one is recorded — after a stop the
437510
// session is gone, and a bare status would read as "never existed".
511+
// Late-send redirect is scoped per terminal status: only `completed`
512+
// delivered a report (summary + report_uri below, resume for more);
513+
// `interrupted` never delivered one; `shutdown`/evicted sessions are
514+
// gone for good, so they point at read_agent_trace / a fresh spawn.
515+
const session = deps.sessions.get(target);
516+
const redirect = lateSendRedirect(
517+
target,
518+
outcome.status,
519+
session === undefined && outcome.status !== "not_found",
520+
session?.report,
521+
);
438522
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
439523
return lifecycleResult(
440524
call.id,
441-
`Error: cannot send_input to "${target}" (status: ${outcome.status}).${hint}`,
525+
`Error: cannot send_input to "${target}" (status: ${outcome.status}).${hint}${redirect}`,
442526
);
443527
}
444528
// CL-7331: an interrupt-with-followup is transitional, not terminal.

0 commit comments

Comments
 (0)