Skip to content

Commit a9f988b

Browse files
committed
fix(subagent): enforce outcome-aware escalation contract
- ask_director rejects a missing escalation instead of parking on prompt prose; the wire schema gains real properties and a classification enum - terminalOutcome is copied by cloneSession and replaceable only through an explicit worker verification report, never by a decision grant - wait/list JSON expose assessment and terminal_outcome; structured resolutions carry questionId and reject stale answers - declines record a consistent outcome; the renderer bounds each field on a character boundary and always keeps the authority and send_input lines - wake and panel keep the worker question; routine evaluations are recorded for audit - parked classifications require alternative content; recommendation and safeDefault are independent; requested mechanism differs from minimum authority
1 parent 5b18955 commit a9f988b

9 files changed

Lines changed: 362 additions & 85 deletions

File tree

‎src/subagent/agent-fleet.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import {
7979
} from "./lifecycle.js";
8080
import type {
8181
EscalationAssessment,
82-
VerificationBlockedOutcome,
82+
TerminalOutcome,
8383
} from "./escalation-policy.js";
8484
import { getProcessAdmissionQueue, type AdmissionQueue } from "./admission.js";
8585
import type {
@@ -146,7 +146,7 @@ interface FleetRecord {
146146
question?: string;
147147
questionId?: string;
148148
assessment?: EscalationAssessment;
149-
terminalOutcome?: VerificationBlockedOutcome;
149+
terminalOutcome?: TerminalOutcome;
150150
description?: string;
151151
}
152152

@@ -1588,7 +1588,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15881588
const ok = deps.sessions.registerAsk(session.id, {
15891589
question,
15901590
questionId,
1591-
...(assessment !== undefined ? { assessment } : {}),
1591+
assessment,
15921592
...(grantRequestId !== undefined ? { grantRequestId } : {}),
15931593
resolve: hold.resolve,
15941594
reject: hold.reject,
@@ -1606,6 +1606,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
16061606
cancel: (reason) => {
16071607
deps.sessions.cancelAsk(session.id, reason);
16081608
},
1609+
recordRoutineEvaluation: (assessment) => {
1610+
deps.sessions.recordRoutineEvaluation(session.id, assessment);
1611+
},
16091612
},
16101613
onAgentReady: ({ close, interrupt, followup, deliver }) => {
16111614
deps.sessions.registerClose(session.id, async (deadlineMs) => {
@@ -1883,6 +1886,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
18831886
...(record.questionId !== undefined
18841887
? { question_id: record.questionId }
18851888
: {}),
1889+
...(record.assessment !== undefined
1890+
? { assessment: record.assessment }
1891+
: {}),
1892+
...(record.terminalOutcome !== undefined
1893+
? { terminal_outcome: record.terminalOutcome }
1894+
: {}),
18861895
...(record.description !== undefined
18871896
? { description: record.description }
18881897
: {}),
@@ -1915,6 +1924,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
19151924
...(record.questionId !== undefined
19161925
? { question_id: record.questionId }
19171926
: {}),
1927+
...(record.assessment !== undefined
1928+
? { assessment: record.assessment }
1929+
: {}),
1930+
...(record.terminalOutcome !== undefined
1931+
? { terminal_outcome: record.terminalOutcome }
1932+
: {}),
19181933
};
19191934
});
19201935

@@ -2024,6 +2039,12 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
20242039
record.questionId !== undefined
20252040
? { question_id: record.questionId }
20262041
: {}),
2042+
...(record?.assessment !== undefined
2043+
? { assessment: record.assessment }
2044+
: {}),
2045+
...(record?.terminalOutcome !== undefined
2046+
? { terminal_outcome: record.terminalOutcome }
2047+
: {}),
20272048
};
20282049
});
20292050
if (parked.length > 0) {

‎src/subagent/ask-director.ts‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,32 @@ export function createAskDirectorState(): AskDirectorState {
2020

2121
export interface AskDirectorInput {
2222
question: unknown;
23-
escalation?: unknown;
23+
escalation: unknown;
2424
state: AskDirectorState;
2525
}
2626

2727
export interface AskDirectorPort {
2828
register: (input: {
2929
question: string;
3030
questionId: string;
31-
assessment?: EscalationAssessment;
31+
assessment: EscalationAssessment;
3232
grantRequestId?: string;
3333
}) => Promise<string>;
3434
cancel: (reason: string) => void;
35+
/** Routine evaluations are recorded for audit; they never register or wake. */
36+
recordRoutineEvaluation?: (assessment: EscalationAssessment) => void;
3537
}
3638

37-
/** Over-cap / second-ask returns `ok: false` so the worker does not suspend. */
38-
export function evaluateAskDirector(input: AskDirectorInput): {
39-
ok: boolean;
40-
message: string;
41-
question?: string;
42-
assessment?: EscalationAssessment;
43-
continueInternal?: true;
44-
} {
39+
/** Non-terminal by design: over-cap / second-ask returns `ok: false` so the worker does not suspend. */
40+
export function evaluateAskDirector(input: AskDirectorInput):
41+
| { ok: false; message: string }
42+
| {
43+
ok: true;
44+
message: string;
45+
question: string;
46+
assessment: EscalationAssessment;
47+
continueInternal?: true;
48+
} {
4549
if (typeof input.question !== "string") {
4650
return {
4751
ok: false,
@@ -75,11 +79,16 @@ export function evaluateAskDirector(input: AskDirectorInput): {
7579
message: `Error: ask_director question cap (${ASK_DIRECTOR_MAX_QUESTIONS}) reached for this turn. No further questions accepted — finish with the markdown report envelope instead.`,
7680
};
7781
}
78-
// Direct callers predate the tool schema. The wire schema in run.ts still
79-
// requires escalation; this preserves the legacy unit/integration port API.
82+
// The policy contract is mandatory at the tool boundary: a worker that
83+
// omits `escalation` is rejected and must never park a prompt-prose-only
84+
// question. The wire schema also lists these fields, but the local tool
85+
// wrapper does no schema validation, so the runtime check is the gate.
8086
if (input.escalation === undefined) {
81-
input.state.pending = true;
82-
return { ok: true, message: "ok", question };
87+
return {
88+
ok: false,
89+
message:
90+
"Error: ask_director requires escalation (object) with policyVersion, classification, blockedOutcome, unavailableDirectorPath, permittedAlternatives, minimumAddition, and declineConsequence.",
91+
};
8392
}
8493
const assessment = parseEscalationAssessment(input.escalation);
8594
if (assessment instanceof Error) {
@@ -90,6 +99,7 @@ export function evaluateAskDirector(input: AskDirectorInput): {
9099
ok: true,
91100
message:
92101
"Continue with the recorded permitted alternative; do not park or escalate. Report its result in the final outcome.",
102+
question,
93103
assessment,
94104
continueInternal: true,
95105
};
@@ -152,8 +162,10 @@ export async function handleAskDirector(args: {
152162
state: args.state,
153163
});
154164
if (!outcome.ok) return outcome.message;
155-
if (outcome.continueInternal === true) return outcome.message;
156-
if (outcome.question === undefined) return outcome.message;
165+
if (outcome.continueInternal === true) {
166+
args.port.recordRoutineEvaluation?.(outcome.assessment);
167+
return outcome.message;
168+
}
157169
try {
158170
const onAbort = (): void => {
159171
args.port.cancel("ask_director aborted");
@@ -171,9 +183,7 @@ export async function handleAskDirector(args: {
171183
const answerP = args.port.register({
172184
question: outcome.question,
173185
questionId,
174-
...(outcome.assessment !== undefined
175-
? { assessment: outcome.assessment }
176-
: {}),
186+
assessment: outcome.assessment,
177187
...(grantRequestId !== undefined ? { grantRequestId } : {}),
178188
});
179189
if (args.signal.aborted) {

‎src/subagent/escalation-policy.ts‎

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,26 @@ export interface EscalationAssessment {
4040
}
4141

4242
export type EscalationResolution =
43-
| { readonly kind: "director_answer"; readonly answer: string }
44-
| { readonly kind: "declined"; readonly answer: string }
45-
| { readonly kind: "unavailable"; readonly answer: string }
46-
| { readonly kind: "minimum_grant_available"; readonly answer: string };
43+
| {
44+
readonly kind: "director_answer";
45+
readonly answer: string;
46+
readonly questionId: string;
47+
}
48+
| {
49+
readonly kind: "declined";
50+
readonly answer: string;
51+
readonly questionId: string;
52+
}
53+
| {
54+
readonly kind: "unavailable";
55+
readonly answer: string;
56+
readonly questionId: string;
57+
}
58+
| {
59+
readonly kind: "minimum_grant_available";
60+
readonly answer: string;
61+
readonly questionId: string;
62+
};
4763

4864
export type EscalationDirective =
4965
| {
@@ -63,15 +79,42 @@ export interface VerificationBlockedOutcome {
6379
readonly recommendation?: string;
6480
}
6581

82+
/** A decline/unavailability on a non-verification ask still records an outcome. */
83+
export interface DeclinedOutcome {
84+
readonly kind: "declined";
85+
readonly resolution: "declined" | "unavailable";
86+
readonly declineConsequence: string;
87+
readonly recommendation?: string;
88+
}
89+
90+
/** Explicit worker-reported successful verification replaces a blocked outcome. */
91+
export interface VerifiedOutcome {
92+
readonly kind: "verified";
93+
readonly verificationOutcome: string;
94+
}
95+
96+
export type TerminalOutcome =
97+
| VerificationBlockedOutcome
98+
| DeclinedOutcome
99+
| VerifiedOutcome;
100+
66101
const isRecord = (value: unknown): value is Record<string, unknown> =>
67102
typeof value === "object" && value !== null && !Array.isArray(value);
68103
const nonEmpty = (value: unknown): value is string =>
69104
typeof value === "string" && value.trim().length > 0;
105+
/** Byte-bounded on a character boundary so a slice never splits a code point. */
70106
const bounded = (value: string): string => {
71-
const encoded = new TextEncoder().encode(value);
72-
return encoded.byteLength <= ESCALATION_TEXT_MAX_BYTES
73-
? value
74-
: new TextDecoder().decode(encoded.slice(0, ESCALATION_TEXT_MAX_BYTES));
107+
if (new TextEncoder().encode(value).byteLength <= ESCALATION_TEXT_MAX_BYTES)
108+
return value;
109+
let kept = "";
110+
let bytes = 0;
111+
for (const ch of Array.from(value)) {
112+
const size = new TextEncoder().encode(ch).byteLength;
113+
if (bytes + size > ESCALATION_TEXT_MAX_BYTES) break;
114+
kept += ch;
115+
bytes += size;
116+
}
117+
return kept;
75118
};
76119
const text = (value: unknown, field: string): string | Error =>
77120
nonEmpty(value)
@@ -90,10 +133,15 @@ export function parseEscalationResolution(
90133
kind !== "unavailable" &&
91134
kind !== "minimum_grant_available"
92135
) {
93-
return new Error("resolution.kind is invalid.");
136+
return new Error(
137+
"resolution.kind must be one of: director_answer, declined, unavailable, minimum_grant_available.",
138+
);
94139
}
140+
const questionId = text(raw.questionId, "resolution.questionId");
141+
if (questionId instanceof Error) return questionId;
95142
const answer = text(raw.answer, "resolution.answer");
96-
return answer instanceof Error ? answer : { kind, answer };
143+
if (answer instanceof Error) return answer;
144+
return { kind, answer, questionId };
97145
}
98146

99147
/** Validates tool-boundary data before it can create a parked parent ask. */
@@ -153,6 +201,14 @@ export function parseEscalationAssessment(
153201
comparableConfidence: alternative.comparableConfidence,
154202
});
155203
}
204+
// Fact 3 content is mandatory for every parked/surfaced classification:
205+
// an empty alternatives list would let a worker park without showing the
206+
// permitted paths already considered. `routine` instead requires a
207+
// comparable alternative below.
208+
if (classification !== "routine" && alternatives.length === 0)
209+
return new Error(
210+
"parked escalation requires at least one permitted alternative considered.",
211+
);
156212
const optionalText = (field: string): string | Error | undefined =>
157213
raw[field] === undefined ? undefined : text(raw[field], field);
158214
const requestedMechanism = optionalText("requestedMechanism");
@@ -170,9 +226,13 @@ export function parseEscalationAssessment(
170226
const minimumAuthorityValue = minimumAuthority as string | undefined;
171227
const recommendationValue = recommendation as string | undefined;
172228
const safeDefaultValue = safeDefault as string | undefined;
173-
if ((recommendation === undefined) !== (safeDefault === undefined))
229+
if (
230+
requestedMechanismValue !== undefined &&
231+
minimumAuthorityValue !== undefined &&
232+
requestedMechanismValue === minimumAuthorityValue
233+
)
174234
return new Error(
175-
"escalation recommendation and safeDefault must be provided together when asserted.",
235+
"escalation.requestedMechanism must differ from minimumAuthority.",
176236
);
177237
if (
178238
(classification === "operator_decision_required" ||
@@ -239,10 +299,10 @@ export function parseEscalationAssessment(
239299
? { minimumAuthority: minimumAuthorityValue }
240300
: {}),
241301
...(recommendationValue !== undefined
242-
? {
243-
recommendation: recommendationValue,
244-
safeDefault: safeDefaultValue as string,
245-
}
302+
? { recommendation: recommendationValue }
303+
: {}),
304+
...(safeDefaultValue !== undefined
305+
? { safeDefault: safeDefaultValue }
246306
: {}),
247307
...(verification !== undefined ? { verification } : {}),
248308
};
@@ -281,6 +341,30 @@ export const deriveVerificationBlockedOutcome = (
281341
};
282342
};
283343

344+
/**
345+
* Records a consistent decline/unavailability outcome for every ask. A
346+
* verification ask derives the typed `verification_blocked` outcome; any other
347+
* parked ask records the decline consequence so a structured decline can never
348+
* settle silently.
349+
*/
350+
export const deriveTerminalOutcome = (
351+
assessment: EscalationAssessment,
352+
resolution: EscalationResolution,
353+
): TerminalOutcome | undefined => {
354+
if (resolution.kind !== "declined" && resolution.kind !== "unavailable")
355+
return undefined;
356+
if (assessment.verification !== undefined)
357+
return deriveVerificationBlockedOutcome(assessment, resolution);
358+
return {
359+
kind: "declined",
360+
resolution: resolution.kind,
361+
declineConsequence: assessment.declineConsequence,
362+
...(assessment.recommendation !== undefined
363+
? { recommendation: assessment.recommendation }
364+
: {}),
365+
};
366+
};
367+
284368
/** Stable parent-facing text. It carries facts only; it cannot convey authority. */
285369
export const renderEscalationDecision = (input: {
286370
sessionId: string;
@@ -297,19 +381,22 @@ export const renderEscalationDecision = (input: {
297381
`${alternative.attempted}: ${alternative.result} (${alternative.comparableConfidence ? "comparable" : "not comparable"})`,
298382
)
299383
.join("; ");
300-
return bounded(
301-
[
302-
`Worker decision required (${a.classification})`,
303-
`Target: ${input.sessionId} / ${input.questionId}`,
304-
`Blocked outcome/verification: ${a.blockedOutcome}`,
305-
`Unavailable director path: ${a.unavailableDirectorPath}`,
306-
`Permitted alternatives considered: ${alternatives}`,
307-
`Minimum addition: ${a.minimumAddition}`,
308-
`Decline/proceed-without consequence: ${a.declineConsequence}`,
309-
`Safe default/recommendation: ${a.recommendation ?? "none recorded"}`,
310-
`Requested mechanism: ${a.requestedMechanism ?? "none recorded"}`,
311-
`Minimum authority/decision: ${a.minimumAuthority ?? "none recorded"}`,
312-
"Reply with send_input (plain text or a structured resolution); this does not grant or retry a tool.",
313-
].join("\n"),
314-
);
384+
// Bound every field on its own (character-boundary) so a long field can
385+
// never truncate the mechanism-vs-authority separation or the send_input
386+
// instruction that follow it.
387+
const field = (label: string, value: string): string =>
388+
`${label} ${bounded(value)}`;
389+
return [
390+
`Worker decision required (${a.classification})`,
391+
`Target: ${input.sessionId} / ${input.questionId}`,
392+
field("Blocked outcome/verification:", a.blockedOutcome),
393+
field("Unavailable director path:", a.unavailableDirectorPath),
394+
field("Permitted alternatives considered:", alternatives),
395+
field("Minimum addition:", a.minimumAddition),
396+
field("Decline/proceed-without consequence:", a.declineConsequence),
397+
field("Safe default/recommendation:", a.recommendation ?? "none recorded"),
398+
field("Requested mechanism:", a.requestedMechanism ?? "none recorded"),
399+
field("Minimum authority/decision:", a.minimumAuthority ?? "none recorded"),
400+
"Reply with send_input (plain text or a structured resolution); this does not grant or retry a tool.",
401+
].join("\n");
315402
};

0 commit comments

Comments
 (0)