Skip to content

Commit 5b18955

Browse files
committed
feat(subagent): add outcome-aware escalation policy
1 parent b72ebc9 commit 5b18955

12 files changed

Lines changed: 1022 additions & 21 deletions

‎BUILD_PLAN.md‎

Lines changed: 333 additions & 0 deletions
Large diffs are not rendered by default.

‎src/subagent/agent-fleet.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ import {
7777
projectWaitStatus,
7878
type WaitJSONStatus,
7979
} from "./lifecycle.js";
80+
import type {
81+
EscalationAssessment,
82+
VerificationBlockedOutcome,
83+
} from "./escalation-policy.js";
8084
import { getProcessAdmissionQueue, type AdmissionQueue } from "./admission.js";
8185
import type {
8286
NestedDispatchDeps,
@@ -141,6 +145,8 @@ interface FleetRecord {
141145
hint?: string;
142146
question?: string;
143147
questionId?: string;
148+
assessment?: EscalationAssessment;
149+
terminalOutcome?: VerificationBlockedOutcome;
144150
description?: string;
145151
}
146152

@@ -467,7 +473,16 @@ class FleetMailbox {
467473
? { recoverableFailure: true }
468474
: {}),
469475
...(ask !== undefined
470-
? { question: ask.question, questionId: ask.questionId }
476+
? {
477+
question: ask.question,
478+
questionId: ask.questionId,
479+
...(ask.assessment !== undefined
480+
? { assessment: ask.assessment }
481+
: {}),
482+
}
483+
: {}),
484+
...(session?.terminalOutcome !== undefined
485+
? { terminalOutcome: session.terminalOutcome }
471486
: {}),
472487
...(status === "awaiting_director" && session !== undefined
473488
? { description: session.description }
@@ -1548,7 +1563,12 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15481563
: {}),
15491564
persist: deps.persist !== false,
15501565
askDirectorPort: {
1551-
register: ({ question, questionId, grantRequestId }) => {
1566+
register: ({
1567+
question,
1568+
questionId,
1569+
assessment,
1570+
grantRequestId,
1571+
}) => {
15521572
const hold: {
15531573
resolve?: (answer: string) => void;
15541574
reject?: (reason: unknown) => void;
@@ -1568,6 +1588,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15681588
const ok = deps.sessions.registerAsk(session.id, {
15691589
question,
15701590
questionId,
1591+
...(assessment !== undefined ? { assessment } : {}),
15711592
...(grantRequestId !== undefined ? { grantRequestId } : {}),
15721593
resolve: hold.resolve,
15731594
reject: hold.reject,

‎src/subagent/ask-director.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/** ask_director evaluation: pure caps, unit-testable without an agent loop. */
22

3+
import {
4+
decideEscalation,
5+
parseEscalationAssessment,
6+
type EscalationAssessment,
7+
} from "./escalation-policy.js";
8+
39
export const ASK_DIRECTOR_MAX_BYTES = 4096;
410
export const ASK_DIRECTOR_MAX_QUESTIONS = 3;
511

@@ -14,13 +20,15 @@ export function createAskDirectorState(): AskDirectorState {
1420

1521
export interface AskDirectorInput {
1622
question: unknown;
23+
escalation?: unknown;
1724
state: AskDirectorState;
1825
}
1926

2027
export interface AskDirectorPort {
2128
register: (input: {
2229
question: string;
2330
questionId: string;
31+
assessment?: EscalationAssessment;
2432
grantRequestId?: string;
2533
}) => Promise<string>;
2634
cancel: (reason: string) => void;
@@ -31,6 +39,8 @@ export function evaluateAskDirector(input: AskDirectorInput): {
3139
ok: boolean;
3240
message: string;
3341
question?: string;
42+
assessment?: EscalationAssessment;
43+
continueInternal?: true;
3444
} {
3545
if (typeof input.question !== "string") {
3646
return {
@@ -65,9 +75,29 @@ export function evaluateAskDirector(input: AskDirectorInput): {
6575
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.`,
6676
};
6777
}
68-
// Hold the one-at-a-time lock; count the cap only at commitAskDirector.
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.
80+
if (input.escalation === undefined) {
81+
input.state.pending = true;
82+
return { ok: true, message: "ok", question };
83+
}
84+
const assessment = parseEscalationAssessment(input.escalation);
85+
if (assessment instanceof Error) {
86+
return { ok: false, message: `Error: ${assessment.message}` };
87+
}
88+
if (decideEscalation(assessment).kind === "continue_internal") {
89+
return {
90+
ok: true,
91+
message:
92+
"Continue with the recorded permitted alternative; do not park or escalate. Report its result in the final outcome.",
93+
assessment,
94+
continueInternal: true,
95+
};
96+
}
97+
// Reserve the one-at-a-time lock so a parallel ask errors, but do not
98+
// consume a cap slot until commitAskDirector (register reached the director).
6999
input.state.pending = true;
70-
return { ok: true, message: "ok", question };
100+
return { ok: true, message: "ok", question, assessment };
71101
}
72102

73103
/** Count the question against the per-turn cap once register will run. */
@@ -110,16 +140,20 @@ export function createDeferredContinuation(): {
110140

111141
export async function handleAskDirector(args: {
112142
question: unknown;
143+
escalation?: unknown;
113144
grantRequestId?: unknown;
114145
state: AskDirectorState;
115146
port: AskDirectorPort;
116147
signal: AbortSignal;
117148
}): Promise<string> {
118149
const outcome = evaluateAskDirector({
119150
question: args.question,
151+
escalation: args.escalation,
120152
state: args.state,
121153
});
122-
if (!outcome.ok || outcome.question === undefined) return outcome.message;
154+
if (!outcome.ok) return outcome.message;
155+
if (outcome.continueInternal === true) return outcome.message;
156+
if (outcome.question === undefined) return outcome.message;
123157
try {
124158
const onAbort = (): void => {
125159
args.port.cancel("ask_director aborted");
@@ -137,6 +171,9 @@ export async function handleAskDirector(args: {
137171
const answerP = args.port.register({
138172
question: outcome.question,
139173
questionId,
174+
...(outcome.assessment !== undefined
175+
? { assessment: outcome.assessment }
176+
: {}),
140177
...(grantRequestId !== undefined ? { grantRequestId } : {}),
141178
});
142179
if (args.signal.aborted) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { expect, test } from "bun:test";
2+
3+
import {
4+
deriveVerificationBlockedOutcome,
5+
decideEscalation,
6+
parseEscalationAssessment,
7+
parseEscalationResolution,
8+
renderEscalationDecision,
9+
} from "./escalation-policy.js";
10+
11+
const assessment = {
12+
policyVersion: "1",
13+
classification: "operator_decision_required",
14+
blockedOutcome: "The authenticated verification result cannot be produced.",
15+
unavailableDirectorPath: "The director has no credential authority.",
16+
permittedAlternatives: [
17+
{
18+
attempted: "focused local test",
19+
result: "requires the missing credential",
20+
comparableConfidence: false,
21+
},
22+
],
23+
minimumAddition: "Allow an authenticated read-only snapshot.",
24+
declineConsequence: "Implementation is complete but verification is blocked.",
25+
recommendation: "Decline unless the snapshot is necessary.",
26+
safeDefault: "Decline.",
27+
requestedMechanism: "broad web access",
28+
minimumAuthority: "authenticated read-only snapshot",
29+
verification: {
30+
verificationOutcome: "integration verification",
31+
rootCause: "missing test credential",
32+
attemptedNarrowChecks: ["focused local test"],
33+
reducedConfidence: "integration behavior remains unverified",
34+
},
35+
} as const;
36+
37+
test("routine alternatives remain internal and never produce a parked directive", () => {
38+
const parsed = parseEscalationAssessment({
39+
...assessment,
40+
classification: "routine",
41+
permittedAlternatives: [
42+
{
43+
attempted: "focused test",
44+
result: "passed",
45+
comparableConfidence: true,
46+
},
47+
],
48+
});
49+
expect(parsed).not.toBeInstanceOf(Error);
50+
if (parsed instanceof Error) return;
51+
expect(decideEscalation(parsed).kind).toBe("continue_internal");
52+
});
53+
54+
test("decision rendering distinguishes requested mechanism from minimum authority", () => {
55+
const parsed = parseEscalationAssessment(assessment);
56+
expect(parsed).not.toBeInstanceOf(Error);
57+
if (parsed instanceof Error) return;
58+
const rendered = renderEscalationDecision({
59+
sessionId: "worker-1",
60+
questionId: "ask-1",
61+
assessment: parsed,
62+
});
63+
expect(rendered).toContain("Requested mechanism: broad web access");
64+
expect(rendered).toContain(
65+
"Minimum authority/decision: authenticated read-only snapshot",
66+
);
67+
expect(rendered).toContain("Decline/proceed-without consequence:");
68+
});
69+
70+
test("declined verification produces a distinct durable blocked outcome", () => {
71+
const parsed = parseEscalationAssessment(assessment);
72+
expect(parsed).not.toBeInstanceOf(Error);
73+
if (parsed instanceof Error) return;
74+
expect(
75+
deriveVerificationBlockedOutcome(parsed, {
76+
kind: "declined",
77+
answer: "No credential grant.",
78+
}),
79+
).toMatchObject({
80+
kind: "verification_blocked",
81+
implementationReviewComplete: true,
82+
rootCause: "missing test credential",
83+
resolution: "declined",
84+
});
85+
});
86+
87+
test("incomplete material requests are rejected", () => {
88+
expect(
89+
parseEscalationAssessment({
90+
policyVersion: "1",
91+
classification: "operator_decision_required",
92+
}),
93+
).toBeInstanceOf(Error);
94+
});
95+
96+
test("structured parent resolution is closed and carries no authority", () => {
97+
expect(
98+
parseEscalationResolution({
99+
kind: "minimum_grant_available",
100+
answer: "The minimum decision is available; continue your next turn.",
101+
}),
102+
).toEqual({
103+
kind: "minimum_grant_available",
104+
answer: "The minimum decision is available; continue your next turn.",
105+
});
106+
expect(
107+
parseEscalationResolution({
108+
kind: "grant",
109+
answer: "retry this command",
110+
command: "unsafe",
111+
}),
112+
).toBeInstanceOf(Error);
113+
});

0 commit comments

Comments
 (0)