Skip to content

Commit 56dc961

Browse files
committed
fix(subagent): deliver send_input plainly when no escalation is pending
Models that fill every optional field attach a resolution to ordinary messages. With no ask pending the resolution has nothing to answer, yet it routed the call into the escalation path, which rejected it, so the parent could not message its worker at all.
1 parent 83c6eef commit 56dc961

3 files changed

Lines changed: 93 additions & 13 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ Workers ask the spawning parent with **`ask_director`** (not the human). That pa
255255

256256
The ask is never prose-only: `ask_director` requires a typed **`escalation`** object (`src/subagent/escalation-policy.ts`, `ask-director.ts`) with `policyVersion` plus the required facts — `classification`, `blockedOutcome`, `unavailableDirectorPath`, `permittedAlternatives`, `minimumAddition`, and `declineConsequence` (each a non-empty string, byte-bounded). It may also carry `recommendation`, `safeDefault`, `requestedMechanism` versus `minimumAuthority`, and an optional `verification` detail (`verificationOutcome`, `rootCause`, `attemptedNarrowChecks`, `reducedConfidence`). The five `classification` values decide where the ask settles (`decideEscalation`): `routine` never parks — the worker continues with one of its displayed permitted alternatives and the evaluation is recorded for audit (`routineEvaluations`, newest-only cap); it requires at least one alternative with `comparableConfidence: true`. `director_resolvable` also continues internally when such a comparable alternative exists and parks otherwise. `operator_decision_required`, `outcome_blocked`, and `irreversible_or_sensitive` always park — the first and last require an explicit `minimumAuthority`, which must differ from `requestedMechanism` when both are present — and every parked classification requires at least one permitted alternative to show what was already considered.
257257

258-
The parent resolves a parked ask with **`send_input`**'s optional structured `resolution` (`questionId`, `kind`, `answer`; `parseEscalationResolution` validates it at the tool boundary). The four kinds are `director_answer` (normal answer), `declined`, `unavailable`, and `minimum_grant_available` (resume by text only — it grants nothing and leaves a denied-call envelope unconsumed). A resolution is bound to the pending `questionId` — a stale or mismatched id is refused so a late answer cannot settle a newer question — and is mutually exclusive with `interrupt`. The resolution's message is delivered to the worker together with the structured answer; neither can silently drop the other. A resolution never grants authority or retries a tool — a denied call still needs the envelope-owned covering-grant retry path described above.
258+
The parent resolves a parked ask with **`send_input`**'s optional structured `resolution` (`questionId`, `kind`, `answer`; `parseEscalationResolution` validates it at the tool boundary). The four kinds are `director_answer` (normal answer), `declined`, `unavailable`, and `minimum_grant_available` (resume by text only — it grants nothing and leaves a denied-call envelope unconsumed). A resolution is bound to the pending `questionId` — a stale or mismatched id is refused so a late answer cannot settle a newer question — and is mutually exclusive with `interrupt`. With no ask pending there is nothing to resolve, so `send_input` ignores the resolution, delivers the message as plain input, and reports `resolution_ignored` in its result; models that fill every optional field would otherwise fail every plain message. The resolution's message is delivered to the worker together with the structured answer; neither can silently drop the other. A resolution never grants authority or retries a tool — a denied call still needs the envelope-owned covering-grant retry path described above.
259259

260260
Projections keep the policy facts visible: wait JSON and `list_agents` carry the pending ask's `question`, `questionId`, and `assessment`, and the session's `terminal_outcome` (`verification_blocked` when a `declined`/`unavailable` resolution lands on an ask that carried a `verification` detail, `declined` for a plain decline/unavailability, or `verified` when the worker reports a successful verification in place of a blocked one) is projected through wait JSON, `list_agents`, and mailbox mail (`src/subagent/agent-fleet.ts`, `fleet-report.ts`).
261261

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,6 +1287,81 @@ describe("send_input", () => {
12871287
resolution: "declined",
12881288
});
12891289
});
1290+
1291+
test("a resolution with no pending escalation is ignored and the message is delivered as plain input", async () => {
1292+
const sessions = createSubAgentSessionStore();
1293+
const fleetRecords = createFleetMailbox(sessions);
1294+
const sendInput = createSendInputTool({ sessions, fleetRecords });
1295+
const worker = startSession(sessions, { description: "worker" });
1296+
sessions.markRunning(worker.id);
1297+
const delivered: string[] = [];
1298+
sessions.registerDeliver(worker.id, (message) => {
1299+
delivered.push(message);
1300+
});
1301+
1302+
// Filler a field-filling model sent after approve_worker_call had
1303+
// already answered the worker's only ask.
1304+
const fillers = [
1305+
{ questionId: "", kind: "director_answer", answer: "" },
1306+
{
1307+
questionId: "ask-2",
1308+
kind: "director_answer",
1309+
answer: "Additional requirement applies after rebase.",
1310+
},
1311+
{
1312+
questionId: "none",
1313+
kind: "unavailable",
1314+
answer: "No escalation; normal task update only.",
1315+
},
1316+
];
1317+
for (const resolution of fillers) {
1318+
const result = await callTool(sendInput, {
1319+
target: worker.id,
1320+
message: "also require the PR checklist",
1321+
interrupt: false,
1322+
resolution,
1323+
});
1324+
expect(result).toEqual({
1325+
agent_id: worker.id,
1326+
status: "running",
1327+
resolution_ignored: "no pending escalation",
1328+
});
1329+
}
1330+
expect(delivered).toEqual(
1331+
fillers.map(() => "also require the PR checklist"),
1332+
);
1333+
});
1334+
1335+
test("an unusable resolution against a pending ask names the plain-message alternative", async () => {
1336+
const sessions = createSubAgentSessionStore();
1337+
const fleetRecords = createFleetMailbox(sessions);
1338+
const sendInput = createSendInputTool({ sessions, fleetRecords });
1339+
const worker = startSession(sessions, { description: "worker" });
1340+
sessions.markRunning(worker.id);
1341+
sessions.registerAsk(worker.id, {
1342+
question: "which file?",
1343+
questionId: "ask-1",
1344+
assessment: assessedAsk,
1345+
resolve: () => undefined,
1346+
reject: () => undefined,
1347+
});
1348+
1349+
for (const resolution of [
1350+
{ questionId: "", kind: "director_answer", answer: "" },
1351+
{ questionId: "ask-0", kind: "declined", answer: "No." },
1352+
]) {
1353+
const result = await callFleetToolRaw(sendInput, {
1354+
target: worker.id,
1355+
message: "No.",
1356+
resolution,
1357+
});
1358+
expect(result.isError).toBe(true);
1359+
expect(result.content).toContain(
1360+
"Omit resolution to answer with a plain message.",
1361+
);
1362+
}
1363+
expect(sessions.hasPendingAsk(worker.id)).toBe(true);
1364+
});
12901365
});
12911366

12921367
describe("nested lifecycle authority", () => {

‎src/subagent/lifecycle-tools.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ export function createInterruptAgentTool(
448448
});
449449
}
450450

451+
const OMIT_RESOLUTION_HINT = "Omit resolution to answer with a plain message.";
452+
451453
const SendInputArgs = type({
452454
target: "string",
453455
message: "string",
@@ -477,7 +479,7 @@ export const sendInputToolDefinition: ToolDefinition = {
477479
resolution: {
478480
type: "object",
479481
description:
480-
"Optional explicit pending-escalation resolution (questionId, kind, answer). It never grants or retries a tool. The message is delivered together with the structured answer.",
482+
"Optional explicit pending-escalation resolution (questionId, kind, answer). Omit it unless answering a pending ask_director; with no ask pending it is ignored and the message is delivered as plain input. It never grants or retries a tool. The message is delivered together with the structured answer.",
481483
properties: {
482484
questionId: {
483485
type: "string",
@@ -534,7 +536,11 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
534536
`(got ${message.length}).`,
535537
);
536538
}
537-
if (parsed.resolution !== undefined) {
539+
// Field-filling models send a resolution on every call. With no ask
540+
// pending it has nothing to answer, so the message goes out as plain
541+
// input instead of failing on filler.
542+
const pending = deps.sessions.peekAsk(target);
543+
if (parsed.resolution !== undefined && pending !== undefined) {
538544
if (parsed.interrupt === true) {
539545
return lifecycleResult(
540546
call.id,
@@ -545,20 +551,13 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
545551
if (resolution instanceof Error) {
546552
return lifecycleResult(
547553
call.id,
548-
`Error: send_input resolution invalid: ${resolution.message}`,
549-
);
550-
}
551-
const pending = deps.sessions.peekAsk(target);
552-
if (pending === undefined) {
553-
return lifecycleResult(
554-
call.id,
555-
`Error: no pending escalation for "${target}".`,
554+
`Error: send_input resolution invalid: ${resolution.message} ${OMIT_RESOLUTION_HINT}`,
556555
);
557556
}
558557
if (pending.questionId !== resolution.questionId) {
559558
return lifecycleResult(
560559
call.id,
561-
`Error: send_input resolution questionId mismatch: pending ask is "${pending.questionId}", resolution names "${resolution.questionId}".`,
560+
`Error: send_input resolution questionId mismatch: pending ask is "${pending.questionId}", resolution names "${resolution.questionId}". ${OMIT_RESOLUTION_HINT}`,
562561
);
563562
}
564563
// SF6: deliver the parent's message together with the structured
@@ -629,7 +628,13 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
629628
}
630629
return lifecycleResult(
631630
call.id,
632-
fleetJson({ agent_id: target, status: outcome.status }),
631+
fleetJson({
632+
agent_id: target,
633+
status: outcome.status,
634+
...(parsed.resolution !== undefined
635+
? { resolution_ignored: "no pending escalation" }
636+
: {}),
637+
}),
633638
);
634639
},
635640
});

0 commit comments

Comments
 (0)