Skip to content

Commit fd6416e

Browse files
committed
feat(subagent): add outcome-aware escalation policy
1 parent 8638639 commit fd6416e

25 files changed

Lines changed: 2073 additions & 57 deletions

‎e2e/subagent-orchestration.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
openE2ESession,
1616
runUntilDone,
1717
} from "./harness.js";
18+
import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js";
1819

1920
const RequestURL = type({ url: "string" });
2021

@@ -69,7 +70,10 @@ describe("e2e — fleet orchestration", () => {
6970
toolCalls: [
7071
{
7172
name: "ask_director",
72-
args: { question: "which file should I edit?" },
73+
args: {
74+
question: "which file should I edit?",
75+
escalation: DIRECTOR_RESOLVABLE_ESCALATION,
76+
},
7377
},
7478
],
7579
});

‎e2e/subagent-permission.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { createSubAgentSessionStore } from "../src/subagent/session-store.js";
2323
import { workerPermissionGate } from "../src/permission/reactor-authorize.js";
2424
import { getProcessWorkerGrantStore } from "../src/permission/worker-grant.js";
2525
import { gateAgentTools } from "../src/plugins/permission-plugin.js";
26+
import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js";
2627

2728
const report =
2829
"## Summary\nFinished.\n## Findings\nAttempted write.\n## Blockers\nNone.\n## Paths\nprobe.txt";
@@ -928,7 +929,13 @@ test.serial(
928929
};
929930
harness.scenario.replyOnce("openai", {
930931
toolCalls: [
931-
{ name: "ask_director", args: { question: "which file?" } },
932+
{
933+
name: "ask_director",
934+
args: {
935+
question: "which file?",
936+
escalation: DIRECTOR_RESOLVABLE_ESCALATION,
937+
},
938+
},
932939
],
933940
});
934941
harness.scenario.replyOnce("openai", { text: report });

‎src/permission/worker-grant-flow.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ const shellCall = (id: string, command: string): ToolCall => ({
2020
arguments: { command },
2121
});
2222

23+
/** Valid assessed ask payload for the grant-flow fixture. */
24+
const assessedAsk = {
25+
policyVersion: "1",
26+
classification: "operator_decision_required",
27+
blockedOutcome: "cannot produce the authenticated verification result",
28+
unavailableDirectorPath: "the director has no credential authority",
29+
permittedAlternatives: [
30+
{
31+
attempted: "focused local test",
32+
result: "requires the missing credential",
33+
comparableConfidence: false,
34+
},
35+
],
36+
minimumAddition: "allow an authenticated read-only snapshot",
37+
declineConsequence: "implementation is complete but verification is blocked",
38+
} as const;
39+
2340
function makeParentGate(cwd: string, approve: boolean) {
2441
return createPermissionGate({
2542
approvals: [],
@@ -195,6 +212,7 @@ describe("worker grant-request flow: deny → parent replay grant → one retry"
195212
sessions.registerAsk(session.id, {
196213
question: `blocked; quote request ${envelope.requestId}`,
197214
questionId: "ask-1",
215+
assessment: assessedAsk,
198216
resolve: (answer) => {
199217
resolved = answer;
200218
},

‎src/subagent/agent-fleet.test.ts‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ async function spawnParkedAsk(
150150
askReply = params.askDirectorPort?.register({
151151
question: "which file should I edit?",
152152
questionId: "ask-1",
153+
assessment: assessedAsk,
153154
});
154155
void askReply?.catch(() => undefined);
155156
return gate.promise;
@@ -255,6 +256,31 @@ async function expectListShows(
255256
expect(defined(parsed.agents[0]).status).not.toBe("awaiting_director");
256257
}
257258

259+
/** Valid assessed ask payload for the escalation projection tests. */
260+
const assessedAsk = {
261+
policyVersion: "1",
262+
classification: "operator_decision_required",
263+
blockedOutcome: "cannot produce the authenticated verification result",
264+
unavailableDirectorPath: "the director has no credential authority",
265+
permittedAlternatives: [
266+
{
267+
attempted: "focused local test",
268+
result: "requires the missing credential",
269+
comparableConfidence: false,
270+
},
271+
],
272+
minimumAddition: "allow an authenticated read-only snapshot",
273+
declineConsequence: "implementation is complete but verification is blocked",
274+
requestedMechanism: "broad web access",
275+
minimumAuthority: "authenticated read-only snapshot",
276+
verification: {
277+
verificationOutcome: "integration verification",
278+
rootCause: "missing test credential",
279+
attemptedNarrowChecks: ["focused local test"],
280+
reducedConfidence: "integration behavior remains unverified",
281+
},
282+
} as const;
283+
258284
function parkAsk(
259285
sessions: ReturnType<typeof createSubAgentSessionStore>,
260286
mailbox: ReturnType<typeof createFleetMailbox>,
@@ -267,6 +293,7 @@ function parkAsk(
267293
sessions.registerAsk(id, {
268294
question: "which file?",
269295
questionId: "ask-1",
296+
assessment: assessedAsk,
270297
resolve: () => undefined,
271298
reject: () => undefined,
272299
}),
@@ -1752,6 +1779,62 @@ describe("list_agents", () => {
17521779
gate.resolve({ report: "done" });
17531780
});
17541781

1782+
test("wait and list JSON emit assessment and terminal_outcome with stable field names (SF1)", async () => {
1783+
const { gate, deps, list, id } = await spawnParkedAsk();
1784+
// list first: surfacing via wait would refuse a duplicate list (anti-poll).
1785+
const listed = await callFleetToolRaw(list, {});
1786+
expect(listed.isError).not.toBe(true);
1787+
const parsed = parseFleetJson(listed.content) as {
1788+
agents: {
1789+
agent_id: string;
1790+
status: string;
1791+
assessment?: Record<string, unknown>;
1792+
terminal_outcome?: Record<string, unknown>;
1793+
}[];
1794+
};
1795+
expect(defined(parsed.agents[0]).assessment).toMatchObject({
1796+
policyVersion: "1",
1797+
classification: "operator_decision_required",
1798+
minimumAuthority: "authenticated read-only snapshot",
1799+
});
1800+
const wait = waitTool(deps);
1801+
const waited = await callFleetTool(wait, {
1802+
targets: [id],
1803+
timeout_ms: 2000,
1804+
});
1805+
expect(waited.timed_out).toBe(false);
1806+
const waitRow = defined((waited.results as Record<string, unknown>[])[0]);
1807+
expect(waitRow.assessment).toMatchObject({
1808+
policyVersion: "1",
1809+
classification: "operator_decision_required",
1810+
minimumAuthority: "authenticated read-only snapshot",
1811+
});
1812+
// Resolve through the public store API; the terminal outcome must then
1813+
// surface in list JSON.
1814+
expect(
1815+
deps.sessions.resolveEscalationAsk(id, {
1816+
kind: "declined",
1817+
answer: "No credential grant.",
1818+
questionId: "ask-1",
1819+
}),
1820+
).toBe(true);
1821+
const after = await callFleetToolRaw(list, {});
1822+
expect(after.isError).not.toBe(true);
1823+
const parsedAfter = parseFleetJson(after.content) as {
1824+
agents: {
1825+
agent_id: string;
1826+
status: string;
1827+
assessment?: Record<string, unknown>;
1828+
terminal_outcome?: Record<string, unknown>;
1829+
}[];
1830+
};
1831+
expect(defined(parsedAfter.agents[0]).terminal_outcome).toMatchObject({
1832+
kind: "verification_blocked",
1833+
resolution: "declined",
1834+
});
1835+
gate.resolve({ report: "done" });
1836+
});
1837+
17551838
test("errors after wait_agents surfaces awaiting_director with a question", async () => {
17561839
const { gate, deps, list, id } = await spawnParkedAsk();
17571840
const wait = waitTool(deps);
@@ -1839,6 +1922,7 @@ describe("list_agents", () => {
18391922
.register({
18401923
question: "which test should I add?",
18411924
questionId: "ask-2",
1925+
assessment: assessedAsk,
18421926
})
18431927
.catch(() => undefined);
18441928
await waitUntilAwaitingDirector(deps.fleetRecords, deps.sessions, id);
@@ -2162,6 +2246,7 @@ describe("ask_director wait handshake", () => {
21622246
?.register({
21632247
question: "which file?",
21642248
questionId: "ask-1",
2249+
assessment: assessedAsk,
21652250
})
21662251
.catch(() => {
21672252
// Session settlement rejects an unanswered ask; this test does not await it.
@@ -2241,7 +2326,11 @@ describe("ask_director wait handshake", () => {
22412326
process.on("unhandledRejection", onUnhandled);
22422327
try {
22432328
expect(() =>
2244-
defined(port).register({ question: "late?", questionId: "ask-1" }),
2329+
defined(port).register({
2330+
question: "late?",
2331+
questionId: "ask-1",
2332+
assessment: assessedAsk,
2333+
}),
22452334
).toThrow("could not register");
22462335
await new Promise((resolve) => setTimeout(resolve, 0));
22472336
expect(unhandled).toEqual([]);

‎src/subagent/agent-fleet.ts‎

Lines changed: 44 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+
TerminalOutcome,
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?: TerminalOutcome;
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,
15711592
...(grantRequestId !== undefined ? { grantRequestId } : {}),
15721593
resolve: hold.resolve,
15731594
reject: hold.reject,
@@ -1585,6 +1606,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15851606
cancel: (reason) => {
15861607
deps.sessions.cancelAsk(session.id, reason);
15871608
},
1609+
recordRoutineEvaluation: (assessment) => {
1610+
deps.sessions.recordRoutineEvaluation(session.id, assessment);
1611+
},
15881612
},
15891613
onAgentReady: ({ close, interrupt, followup, deliver }) => {
15901614
deps.sessions.registerClose(session.id, async (deadlineMs) => {
@@ -1862,6 +1886,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
18621886
...(record.questionId !== undefined
18631887
? { question_id: record.questionId }
18641888
: {}),
1889+
...(record.assessment !== undefined
1890+
? { assessment: record.assessment }
1891+
: {}),
1892+
...(record.terminalOutcome !== undefined
1893+
? { terminal_outcome: record.terminalOutcome }
1894+
: {}),
18651895
...(record.description !== undefined
18661896
? { description: record.description }
18671897
: {}),
@@ -1894,6 +1924,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
18941924
...(record.questionId !== undefined
18951925
? { question_id: record.questionId }
18961926
: {}),
1927+
...(record.assessment !== undefined
1928+
? { assessment: record.assessment }
1929+
: {}),
1930+
...(record.terminalOutcome !== undefined
1931+
? { terminal_outcome: record.terminalOutcome }
1932+
: {}),
18971933
};
18981934
});
18991935

@@ -2003,6 +2039,12 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
20032039
record.questionId !== undefined
20042040
? { question_id: record.questionId }
20052041
: {}),
2042+
...(record?.assessment !== undefined
2043+
? { assessment: record.assessment }
2044+
: {}),
2045+
...(record?.terminalOutcome !== undefined
2046+
? { terminal_outcome: record.terminalOutcome }
2047+
: {}),
20062048
};
20072049
});
20082050
if (parked.length > 0) {

0 commit comments

Comments
 (0)