diff --git a/e2e/subagent-orchestration.test.ts b/e2e/subagent-orchestration.test.ts index 3106374a2..4e8b0d3ec 100644 --- a/e2e/subagent-orchestration.test.ts +++ b/e2e/subagent-orchestration.test.ts @@ -15,6 +15,7 @@ import { openE2ESession, runUntilDone, } from "./harness.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js"; const RequestURL = type({ url: "string" }); @@ -69,7 +70,10 @@ describe("e2e — fleet orchestration", () => { toolCalls: [ { name: "ask_director", - args: { question: "which file should I edit?" }, + args: { + question: "which file should I edit?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + }, }, ], }); diff --git a/e2e/subagent-permission.test.ts b/e2e/subagent-permission.test.ts index a19c51ad7..341b1885f 100644 --- a/e2e/subagent-permission.test.ts +++ b/e2e/subagent-permission.test.ts @@ -23,6 +23,7 @@ import { createSubAgentSessionStore } from "../src/subagent/session-store.js"; import { workerPermissionGate } from "../src/permission/reactor-authorize.js"; import { getProcessWorkerGrantStore } from "../src/permission/worker-grant.js"; import { gateAgentTools } from "../src/plugins/permission-plugin.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js"; const report = "## Summary\nFinished.\n## Findings\nAttempted write.\n## Blockers\nNone.\n## Paths\nprobe.txt"; @@ -928,7 +929,13 @@ test.serial( }; harness.scenario.replyOnce("openai", { toolCalls: [ - { name: "ask_director", args: { question: "which file?" } }, + { + name: "ask_director", + args: { + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + }, + }, ], }); harness.scenario.replyOnce("openai", { text: report }); diff --git a/src/permission/worker-grant-flow.test.ts b/src/permission/worker-grant-flow.test.ts index 38a3eb67e..255a5d9df 100644 --- a/src/permission/worker-grant-flow.test.ts +++ b/src/permission/worker-grant-flow.test.ts @@ -20,6 +20,23 @@ const shellCall = (id: string, command: string): ToolCall => ({ arguments: { command }, }); +/** Valid assessed ask payload for the grant-flow fixture. */ +const assessedAsk = { + policyVersion: "1", + classification: "operator_decision_required", + blockedOutcome: "cannot produce the authenticated verification result", + unavailableDirectorPath: "the director has no credential authority", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "requires the missing credential", + comparableConfidence: false, + }, + ], + minimumAddition: "allow an authenticated read-only snapshot", + declineConsequence: "implementation is complete but verification is blocked", +} as const; + function makeParentGate(cwd: string, approve: boolean) { return createPermissionGate({ approvals: [], @@ -195,6 +212,7 @@ describe("worker grant-request flow: deny → parent replay grant → one retry" sessions.registerAsk(session.id, { question: `blocked; quote request ${envelope.requestId}`, questionId: "ask-1", + assessment: assessedAsk, resolve: (answer) => { resolved = answer; }, diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index b30d9ad6d..96f7aa5c7 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -150,6 +150,7 @@ async function spawnParkedAsk( askReply = params.askDirectorPort?.register({ question: "which file should I edit?", questionId: "ask-1", + assessment: assessedAsk, }); void askReply?.catch(() => undefined); return gate.promise; @@ -255,6 +256,31 @@ async function expectListShows( expect(defined(parsed.agents[0]).status).not.toBe("awaiting_director"); } +/** Valid assessed ask payload for the escalation projection tests. */ +const assessedAsk = { + policyVersion: "1", + classification: "operator_decision_required", + blockedOutcome: "cannot produce the authenticated verification result", + unavailableDirectorPath: "the director has no credential authority", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "requires the missing credential", + comparableConfidence: false, + }, + ], + minimumAddition: "allow an authenticated read-only snapshot", + declineConsequence: "implementation is complete but verification is blocked", + requestedMechanism: "broad web access", + minimumAuthority: "authenticated read-only snapshot", + verification: { + verificationOutcome: "integration verification", + rootCause: "missing test credential", + attemptedNarrowChecks: ["focused local test"], + reducedConfidence: "integration behavior remains unverified", + }, +} as const; + function parkAsk( sessions: ReturnType, mailbox: ReturnType, @@ -267,6 +293,7 @@ function parkAsk( sessions.registerAsk(id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => undefined, reject: () => undefined, }), @@ -1752,6 +1779,62 @@ describe("list_agents", () => { gate.resolve({ report: "done" }); }); + test("wait and list JSON emit assessment and terminal_outcome with stable field names (SF1)", async () => { + const { gate, deps, list, id } = await spawnParkedAsk(); + // list first: surfacing via wait would refuse a duplicate list (anti-poll). + const listed = await callFleetToolRaw(list, {}); + expect(listed.isError).not.toBe(true); + const parsed = parseFleetJson(listed.content) as { + agents: { + agent_id: string; + status: string; + assessment?: Record; + terminal_outcome?: Record; + }[]; + }; + expect(defined(parsed.agents[0]).assessment).toMatchObject({ + policyVersion: "1", + classification: "operator_decision_required", + minimumAuthority: "authenticated read-only snapshot", + }); + const wait = waitTool(deps); + const waited = await callFleetTool(wait, { + targets: [id], + timeout_ms: 2000, + }); + expect(waited.timed_out).toBe(false); + const waitRow = defined((waited.results as Record[])[0]); + expect(waitRow.assessment).toMatchObject({ + policyVersion: "1", + classification: "operator_decision_required", + minimumAuthority: "authenticated read-only snapshot", + }); + // Resolve through the public store API; the terminal outcome must then + // surface in list JSON. + expect( + deps.sessions.resolveEscalationAsk(id, { + kind: "declined", + answer: "No credential grant.", + questionId: "ask-1", + }), + ).toBe(true); + const after = await callFleetToolRaw(list, {}); + expect(after.isError).not.toBe(true); + const parsedAfter = parseFleetJson(after.content) as { + agents: { + agent_id: string; + status: string; + assessment?: Record; + terminal_outcome?: Record; + }[]; + }; + expect(defined(parsedAfter.agents[0]).terminal_outcome).toMatchObject({ + kind: "verification_blocked", + resolution: "declined", + }); + gate.resolve({ report: "done" }); + }); + test("errors after wait_agents surfaces awaiting_director with a question", async () => { const { gate, deps, list, id } = await spawnParkedAsk(); const wait = waitTool(deps); @@ -1839,6 +1922,7 @@ describe("list_agents", () => { .register({ question: "which test should I add?", questionId: "ask-2", + assessment: assessedAsk, }) .catch(() => undefined); await waitUntilAwaitingDirector(deps.fleetRecords, deps.sessions, id); @@ -2162,6 +2246,7 @@ describe("ask_director wait handshake", () => { ?.register({ question: "which file?", questionId: "ask-1", + assessment: assessedAsk, }) .catch(() => { // Session settlement rejects an unanswered ask; this test does not await it. @@ -2241,7 +2326,11 @@ describe("ask_director wait handshake", () => { process.on("unhandledRejection", onUnhandled); try { expect(() => - defined(port).register({ question: "late?", questionId: "ask-1" }), + defined(port).register({ + question: "late?", + questionId: "ask-1", + assessment: assessedAsk, + }), ).toThrow("could not register"); await new Promise((resolve) => setTimeout(resolve, 0)); expect(unhandled).toEqual([]); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 291f5ccd9..05c4eab66 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -77,6 +77,10 @@ import { projectWaitStatus, type WaitJSONStatus, } from "./lifecycle.js"; +import type { + EscalationAssessment, + TerminalOutcome, +} from "./escalation-policy.js"; import { getProcessAdmissionQueue, type AdmissionQueue } from "./admission.js"; import type { NestedDispatchDeps, @@ -141,6 +145,8 @@ interface FleetRecord { hint?: string; question?: string; questionId?: string; + assessment?: EscalationAssessment; + terminalOutcome?: TerminalOutcome; description?: string; } @@ -467,7 +473,16 @@ class FleetMailbox { ? { recoverableFailure: true } : {}), ...(ask !== undefined - ? { question: ask.question, questionId: ask.questionId } + ? { + question: ask.question, + questionId: ask.questionId, + ...(ask.assessment !== undefined + ? { assessment: ask.assessment } + : {}), + } + : {}), + ...(session?.terminalOutcome !== undefined + ? { terminalOutcome: session.terminalOutcome } : {}), ...(status === "awaiting_director" && session !== undefined ? { description: session.description } @@ -1548,7 +1563,12 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { : {}), persist: deps.persist !== false, askDirectorPort: { - register: ({ question, questionId, grantRequestId }) => { + register: ({ + question, + questionId, + assessment, + grantRequestId, + }) => { const hold: { resolve?: (answer: string) => void; reject?: (reason: unknown) => void; @@ -1568,6 +1588,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const ok = deps.sessions.registerAsk(session.id, { question, questionId, + assessment, ...(grantRequestId !== undefined ? { grantRequestId } : {}), resolve: hold.resolve, reject: hold.reject, @@ -1585,6 +1606,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { cancel: (reason) => { deps.sessions.cancelAsk(session.id, reason); }, + recordRoutineEvaluation: (assessment) => { + deps.sessions.recordRoutineEvaluation(session.id, assessment); + }, }, onAgentReady: ({ close, interrupt, followup, deliver }) => { deps.sessions.registerClose(session.id, async (deadlineMs) => { @@ -1862,6 +1886,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { ...(record.questionId !== undefined ? { question_id: record.questionId } : {}), + ...(record.assessment !== undefined + ? { assessment: record.assessment } + : {}), + ...(record.terminalOutcome !== undefined + ? { terminal_outcome: record.terminalOutcome } + : {}), ...(record.description !== undefined ? { description: record.description } : {}), @@ -1894,6 +1924,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { ...(record.questionId !== undefined ? { question_id: record.questionId } : {}), + ...(record.assessment !== undefined + ? { assessment: record.assessment } + : {}), + ...(record.terminalOutcome !== undefined + ? { terminal_outcome: record.terminalOutcome } + : {}), }; }); @@ -2003,6 +2039,12 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool { record.questionId !== undefined ? { question_id: record.questionId } : {}), + ...(record?.assessment !== undefined + ? { assessment: record.assessment } + : {}), + ...(record?.terminalOutcome !== undefined + ? { terminal_outcome: record.terminalOutcome } + : {}), }; }); if (parked.length > 0) { diff --git a/src/subagent/ask-director.test.ts b/src/subagent/ask-director.test.ts index 354eb01c8..069517987 100644 --- a/src/subagent/ask-director.test.ts +++ b/src/subagent/ask-director.test.ts @@ -11,6 +11,20 @@ import { releaseAskDirector, resetAskDirectorTurn, } from "./ask-director.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../../testkit/escalation.js"; + +/** Valid policy payload that resolves to continue_internal (routine). */ +const routine = { + ...DIRECTOR_RESOLVABLE_ESCALATION, + classification: "routine", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "passed", + comparableConfidence: true, + }, + ], +} as const; async function expectCancelledAsk( state: ReturnType, @@ -22,6 +36,7 @@ async function expectCancelledAsk( ): Promise { const message = await handleAskDirector({ question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, state, port, signal: controller.signal, @@ -35,8 +50,13 @@ async function expectCancelledAsk( describe("evaluateAskDirector", () => { test("rejects a missing or empty question without suspending", () => { const state = createAskDirectorState(); - expect(evaluateAskDirector({ question: undefined, state }).ok).toBe(false); - expect(evaluateAskDirector({ question: " ", state }).ok).toBe(false); + expect( + evaluateAskDirector({ question: undefined, escalation: undefined, state }) + .ok, + ).toBe(false); + expect( + evaluateAskDirector({ question: " ", escalation: undefined, state }).ok, + ).toBe(false); expect(state.questions).toBe(0); expect(state.pending).toBe(false); }); @@ -44,7 +64,11 @@ describe("evaluateAskDirector", () => { test("rejects an oversized question without suspending", () => { const state = createAskDirectorState(); const oversized = "a".repeat(ASK_DIRECTOR_MAX_BYTES + 1); - const outcome = evaluateAskDirector({ question: oversized, state }); + const outcome = evaluateAskDirector({ + question: oversized, + escalation: undefined, + state, + }); expect(outcome.ok).toBe(false); expect(outcome.message).toContain("exceeds"); expect(state.questions).toBe(0); @@ -55,6 +79,7 @@ describe("evaluateAskDirector", () => { const state = createAskDirectorState(); const outcome = evaluateAskDirector({ question: "a".repeat(ASK_DIRECTOR_MAX_BYTES), + escalation: DIRECTOR_RESOLVABLE_ESCALATION, state, }); expect(outcome.ok).toBe(true); @@ -66,12 +91,17 @@ describe("evaluateAskDirector", () => { test("a second parallel ask errors without incrementing or suspending", () => { const state = createAskDirectorState(); - expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe( - true, - ); + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); commitAskDirector(state); const second = evaluateAskDirector({ question: "and which function?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, state, }); expect(second.ok).toBe(false); @@ -83,11 +113,21 @@ describe("evaluateAskDirector", () => { test("question cap refuses a 4th ask after prior asks are released", () => { const state = createAskDirectorState(); for (let i = 0; i < ASK_DIRECTOR_MAX_QUESTIONS; i++) { - expect(evaluateAskDirector({ question: `q${i}`, state }).ok).toBe(true); + expect( + evaluateAskDirector({ + question: `q${i}`, + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); commitAskDirector(state); releaseAskDirector(state); } - const capped = evaluateAskDirector({ question: "one more", state }); + const capped = evaluateAskDirector({ + question: "one more", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }); expect(capped.ok).toBe(false); expect(capped.message).toContain("question cap"); expect(capped.message).toContain("this turn"); @@ -98,11 +138,22 @@ describe("evaluateAskDirector", () => { test("releaseAskDirector allows a later sequential ask under the cap", () => { const state = createAskDirectorState(); - expect(evaluateAskDirector({ question: "first", state }).ok).toBe(true); + expect( + evaluateAskDirector({ + question: "first", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); commitAskDirector(state); releaseAskDirector(state); - const second = evaluateAskDirector({ question: "second", state }); + const second = evaluateAskDirector({ + question: "second", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }); expect(second.ok).toBe(true); + if (!second.ok) return; expect(second.question).toBe("second"); commitAskDirector(state); expect(state.questions).toBe(2); @@ -127,6 +178,7 @@ describe("evaluateAskDirector", () => { controller.abort(); const message = await handleAskDirector({ question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, state, port, signal: controller.signal, @@ -198,6 +250,7 @@ describe("evaluateAskDirector", () => { }; const message = await handleAskDirector({ question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, state, port, signal: new AbortController().signal, @@ -219,9 +272,13 @@ describe("requestContinuation stall skip", () => { latch.request(state, deliver); expect(delivered).toBe(1); - expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe( - true, - ); + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); latch.request(state, deliver); expect(delivered).toBe(1); }); @@ -234,9 +291,13 @@ describe("requestContinuation stall skip", () => { delivered += 1; }; - expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe( - true, - ); + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); latch.request(state, deliver); expect(delivered).toBe(0); @@ -256,31 +317,153 @@ describe("resetAskDirectorTurn", () => { test("zeros questions and pending so a new turn gets a fresh cap", () => { const state = createAskDirectorState(); for (let i = 0; i < ASK_DIRECTOR_MAX_QUESTIONS; i++) { - expect(evaluateAskDirector({ question: `q${i}`, state }).ok).toBe(true); + expect( + evaluateAskDirector({ + question: `q${i}`, + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); commitAskDirector(state); releaseAskDirector(state); } - expect(evaluateAskDirector({ question: "one more", state }).ok).toBe(false); + expect( + evaluateAskDirector({ + question: "one more", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(false); resetAskDirectorTurn(state); expect(state.questions).toBe(0); expect(state.pending).toBe(false); - const next = evaluateAskDirector({ question: "fresh turn", state }); + const next = evaluateAskDirector({ + question: "fresh turn", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }); expect(next.ok).toBe(true); + if (!next.ok) return; expect(next.question).toBe("fresh turn"); }); test("clears a leftover pending lock without waiting for release", () => { const state = createAskDirectorState(); - expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe( - true, - ); + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); expect(state.pending).toBe(true); resetAskDirectorTurn(state); expect(state.questions).toBe(0); expect(state.pending).toBe(false); - expect(evaluateAskDirector({ question: "next turn", state }).ok).toBe(true); + expect( + evaluateAskDirector({ + question: "next turn", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state, + }).ok, + ).toBe(true); + }); +}); + +describe("ask_director policy boundary", () => { + test("a missing escalation is rejected and never parks", () => { + const state = createAskDirectorState(); + const outcome = evaluateAskDirector({ + question: "which file?", + escalation: undefined, + state, + }); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.message).toContain("escalation"); + expect(state.questions).toBe(0); + expect(state.pending).toBe(false); + }); + + test("a routine assessment is recorded for audit and never registers or wakes (SF7/SF8)", async () => { + const state = createAskDirectorState(); + const recorded: unknown[] = []; + let registered = 0; + const port = { + register: (): Promise => { + registered += 1; + return Promise.resolve("answer"); + }, + cancel: () => undefined, + recordRoutineEvaluation: (assessment: unknown) => { + recorded.push(assessment); + }, + }; + const message = await handleAskDirector({ + question: "which file?", + escalation: routine, + state, + port, + signal: new AbortController().signal, + }); + expect(message).toContain( + "Continue with the recorded permitted alternative", + ); + expect(registered).toBe(0); + expect(state.pending).toBe(false); + expect(state.questions).toBe(0); + expect(recorded).toHaveLength(1); + }); + + test("a routine ask continues while another ask is pending", () => { + const state = createAskDirectorState(); + state.pending = true; + const outcome = evaluateAskDirector({ + question: "which file?", + escalation: routine, + state, + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.continueInternal).toBe(true); + expect(state.pending).toBe(true); + }); + + test("a routine ask continues past the question cap without consuming it", () => { + const state = createAskDirectorState(); + state.questions = ASK_DIRECTOR_MAX_QUESTIONS; + const outcome = evaluateAskDirector({ + question: "which file?", + escalation: routine, + state, + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.continueInternal).toBe(true); + expect(state.questions).toBe(ASK_DIRECTOR_MAX_QUESTIONS); + }); + + test("a parking ask is still rejected while pending or past the cap", () => { + const pending = createAskDirectorState(); + pending.pending = true; + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state: pending, + }).ok, + ).toBe(false); + const capped = createAskDirectorState(); + capped.questions = ASK_DIRECTOR_MAX_QUESTIONS; + expect( + evaluateAskDirector({ + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + state: capped, + }).ok, + ).toBe(false); }); }); diff --git a/src/subagent/ask-director.ts b/src/subagent/ask-director.ts index 93790742c..217e4c334 100644 --- a/src/subagent/ask-director.ts +++ b/src/subagent/ask-director.ts @@ -1,5 +1,11 @@ /** ask_director evaluation: pure caps, unit-testable without an agent loop. */ +import { + decideEscalation, + parseEscalationAssessment, + type EscalationAssessment, +} from "./escalation-policy.js"; + export const ASK_DIRECTOR_MAX_BYTES = 4096; export const ASK_DIRECTOR_MAX_QUESTIONS = 3; @@ -14,6 +20,7 @@ export function createAskDirectorState(): AskDirectorState { export interface AskDirectorInput { question: unknown; + escalation: unknown; state: AskDirectorState; } @@ -21,17 +28,24 @@ export interface AskDirectorPort { register: (input: { question: string; questionId: string; + assessment: EscalationAssessment; grantRequestId?: string; }) => Promise; cancel: (reason: string) => void; + /** Routine evaluations are recorded for audit; they never register or wake. */ + recordRoutineEvaluation?: (assessment: EscalationAssessment) => void; } /** Over-cap / second-ask returns `ok: false` so the worker does not suspend. */ -export function evaluateAskDirector(input: AskDirectorInput): { - ok: boolean; - message: string; - question?: string; -} { +export function evaluateAskDirector(input: AskDirectorInput): + | { ok: false; message: string } + | { + ok: true; + message: string; + question: string; + assessment: EscalationAssessment; + continueInternal?: true; + } { if (typeof input.question !== "string") { return { ok: false, @@ -52,6 +66,34 @@ export function evaluateAskDirector(input: AskDirectorInput): { message: `Error: ask_director question exceeds ${ASK_DIRECTOR_MAX_BYTES} bytes (got ${bytes}).`, }; } + // The policy contract is mandatory at the tool boundary: a worker that + // omits `escalation` is rejected and must never park a prompt-prose-only + // question. The wire schema also lists these fields, but the local tool + // wrapper does no schema validation, so the runtime check is the gate. + if (input.escalation === undefined) { + return { + ok: false, + message: + "Error: ask_director requires escalation (object) with policyVersion, classification, blockedOutcome, unavailableDirectorPath, permittedAlternatives, minimumAddition, and declineConsequence.", + }; + } + const assessment = parseEscalationAssessment(input.escalation); + if (assessment instanceof Error) { + return { ok: false, message: `Error: ${assessment.message}` }; + } + const directive = decideEscalation(assessment); + if (directive.kind === "continue_internal") { + return { + ok: true, + message: + "Continue with the recorded permitted alternative; do not park or escalate. Report its result in the final outcome.", + question, + assessment, + continueInternal: true, + }; + } + // The lock and cap guard parking only; a routine ask never parks, so it + // must keep working while another ask is pending or after the cap. if (input.state.pending) { return { ok: false, @@ -67,7 +109,7 @@ export function evaluateAskDirector(input: AskDirectorInput): { } // Hold the one-at-a-time lock; count the cap only at commitAskDirector. input.state.pending = true; - return { ok: true, message: "ok", question }; + return { ok: true, message: "ok", question, assessment }; } /** Count the question against the per-turn cap once register will run. */ @@ -110,6 +152,7 @@ export function createDeferredContinuation(): { export async function handleAskDirector(args: { question: unknown; + escalation?: unknown; grantRequestId?: unknown; state: AskDirectorState; port: AskDirectorPort; @@ -117,9 +160,14 @@ export async function handleAskDirector(args: { }): Promise { const outcome = evaluateAskDirector({ question: args.question, + escalation: args.escalation, state: args.state, }); - if (!outcome.ok || outcome.question === undefined) return outcome.message; + if (!outcome.ok) return outcome.message; + if (outcome.continueInternal === true) { + args.port.recordRoutineEvaluation?.(outcome.assessment); + return outcome.message; + } try { const onAbort = (): void => { args.port.cancel("ask_director aborted"); @@ -137,6 +185,7 @@ export async function handleAskDirector(args: { const answerP = args.port.register({ question: outcome.question, questionId, + assessment: outcome.assessment, ...(grantRequestId !== undefined ? { grantRequestId } : {}), }); if (args.signal.aborted) { diff --git a/src/subagent/escalation-policy.test.ts b/src/subagent/escalation-policy.test.ts new file mode 100644 index 000000000..b94534a40 --- /dev/null +++ b/src/subagent/escalation-policy.test.ts @@ -0,0 +1,317 @@ +import { expect, test } from "bun:test"; + +import { + deriveTerminalOutcome, + deriveVerificationBlockedOutcome, + decideEscalation, + ESCALATION_TEXT_MAX_BYTES, + parseEscalationAssessment, + parseEscalationResolution, + renderEscalationDecision, +} from "./escalation-policy.js"; + +const assessment = { + policyVersion: "1", + classification: "operator_decision_required", + blockedOutcome: "The authenticated verification result cannot be produced.", + unavailableDirectorPath: "The director has no credential authority.", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "requires the missing credential", + comparableConfidence: false, + }, + ], + minimumAddition: "Allow an authenticated read-only snapshot.", + declineConsequence: "Implementation is complete but verification is blocked.", + recommendation: "Decline unless the snapshot is necessary.", + safeDefault: "Decline.", + requestedMechanism: "broad web access", + minimumAuthority: "authenticated read-only snapshot", + verification: { + verificationOutcome: "integration verification", + rootCause: "missing test credential", + attemptedNarrowChecks: ["focused local test"], + reducedConfidence: "integration behavior remains unverified", + }, +} as const; + +test("routine alternatives remain internal and never produce a parked directive", () => { + const parsed = parseEscalationAssessment({ + ...assessment, + classification: "routine", + permittedAlternatives: [ + { + attempted: "focused test", + result: "passed", + comparableConfidence: true, + }, + ], + }); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + expect(decideEscalation(parsed).kind).toBe("continue_internal"); +}); + +const comparableAlternative = { + attempted: "focused test", + result: "passed", + comparableConfidence: true, +}; + +for (const classification of [ + "operator_decision_required", + "irreversible_or_sensitive", + "outcome_blocked", +] as const) { + test(`${classification} parks even when an alternative claims comparable confidence`, () => { + const parsed = parseEscalationAssessment({ + ...assessment, + classification, + permittedAlternatives: [comparableAlternative], + }); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + expect(decideEscalation(parsed).kind).toBe("park_parent"); + }); +} + +test("comparable confidence short-circuits only director_resolvable asks", () => { + const withAlternative = (alternatives: readonly unknown[]) => + parseEscalationAssessment({ + ...assessment, + classification: "director_resolvable", + permittedAlternatives: alternatives, + }); + const comparable = withAlternative([comparableAlternative]); + const notComparable = withAlternative([ + { ...comparableAlternative, comparableConfidence: false }, + ]); + if (comparable instanceof Error || notComparable instanceof Error) + throw new Error("fixture must parse"); + expect(decideEscalation(comparable).kind).toBe("continue_internal"); + expect(decideEscalation(notComparable).kind).toBe("park_parent"); +}); + +test("the rendered recommendation falls back to safeDefault before none recorded", () => { + const parsed = parseEscalationAssessment({ + ...assessment, + recommendation: undefined, + safeDefault: "Decline the snapshot.", + }); + if (parsed instanceof Error) throw parsed; + const rendered = renderEscalationDecision({ + sessionId: "worker-1", + questionId: "ask-1", + assessment: parsed, + }); + expect(rendered).toContain( + "Safe default/recommendation: Decline the snapshot.", + ); +}); + +test("decision rendering distinguishes requested mechanism from minimum authority", () => { + const parsed = parseEscalationAssessment(assessment); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + const rendered = renderEscalationDecision({ + sessionId: "worker-1", + questionId: "ask-1", + assessment: parsed, + }); + expect(rendered).toContain("Requested mechanism: broad web access"); + expect(rendered).toContain( + "Minimum authority/decision: authenticated read-only snapshot", + ); + expect(rendered).toContain("Decline/proceed-without consequence:"); +}); + +test("declined verification produces a distinct durable blocked outcome", () => { + const parsed = parseEscalationAssessment(assessment); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + expect( + deriveVerificationBlockedOutcome(parsed, { + kind: "declined", + answer: "No credential grant.", + questionId: "ask-1", + }), + ).toMatchObject({ + kind: "verification_blocked", + implementationReviewComplete: true, + rootCause: "missing test credential", + resolution: "declined", + }); +}); + +test("incomplete material requests are rejected", () => { + expect( + parseEscalationAssessment({ + policyVersion: "1", + classification: "operator_decision_required", + }), + ).toBeInstanceOf(Error); +}); + +test("parked classifications require fact-3 alternative content (Nit 1)", () => { + const noAlternatives = parseEscalationAssessment({ + ...assessment, + permittedAlternatives: [], + }); + expect(noAlternatives).toBeInstanceOf(Error); + if (noAlternatives instanceof Error) { + expect(noAlternatives.message).toContain("permitted alternative"); + } + const directorResolvable = parseEscalationAssessment({ + ...assessment, + classification: "director_resolvable", + }); + expect(directorResolvable).not.toBeInstanceOf(Error); +}); + +test("recommendation and safeDefault are independent optional facts (Nit 1)", () => { + const withRecommendationOnly = parseEscalationAssessment({ + ...assessment, + safeDefault: undefined, + }); + expect(withRecommendationOnly).not.toBeInstanceOf(Error); + if (withRecommendationOnly instanceof Error) return; + expect(withRecommendationOnly.recommendation).toBe( + "Decline unless the snapshot is necessary.", + ); + expect(withRecommendationOnly.safeDefault).toBeUndefined(); + + const withSafeDefaultOnly = parseEscalationAssessment({ + ...assessment, + recommendation: undefined, + }); + expect(withSafeDefaultOnly).not.toBeInstanceOf(Error); +}); + +test("requestedMechanism must differ from minimumAuthority (Nit 2)", () => { + const conflated = parseEscalationAssessment({ + ...assessment, + requestedMechanism: "authenticated read-only snapshot", + minimumAuthority: "authenticated read-only snapshot", + }); + expect(conflated).toBeInstanceOf(Error); + if (conflated instanceof Error) { + expect(conflated.message).toContain("differ from minimumAuthority"); + } +}); + +test("a declined non-verification ask still records a consistent outcome (SF3)", () => { + const parsed = parseEscalationAssessment({ + ...assessment, + verification: undefined, + }); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + const outcome = deriveTerminalOutcome(parsed, { + kind: "declined", + answer: "No.", + questionId: "ask-1", + }); + expect(outcome).toMatchObject({ + kind: "declined", + resolution: "declined", + declineConsequence: + "Implementation is complete but verification is blocked.", + }); +}); + +test("director_resolvable is a parent-only directive, never operator wording (SF8)", () => { + const parsed = parseEscalationAssessment({ + ...assessment, + classification: "director_resolvable", + }); + expect(parsed).not.toBeInstanceOf(Error); + if (parsed instanceof Error) return; + expect(decideEscalation(parsed).kind).toBe("park_parent"); + const rendered = renderEscalationDecision({ + sessionId: "worker-1", + questionId: "ask-1", + assessment: parsed, + }); + expect(rendered).not.toMatch(/operator/i); + expect(rendered).toContain("send_input"); +}); + +test("long fields cannot drop the authority line or the send_input instruction (SF4)", () => { + const long = parseEscalationAssessment({ + ...assessment, + blockedOutcome: "x".repeat(4000), + unavailableDirectorPath: "y".repeat(4000), + }); + expect(long).not.toBeInstanceOf(Error); + if (long instanceof Error) return; + const rendered = renderEscalationDecision({ + sessionId: "worker-1", + questionId: "ask-1", + assessment: long, + }); + expect(rendered).toContain("Minimum authority/decision:"); + expect(rendered).toContain("authenticated read-only snapshot"); + expect(rendered).toContain("send_input"); + expect(rendered).toContain("x".repeat(4000)); + expect(rendered).not.toContain("\uFFFD"); +}); + +test("byte truncation never splits a multi-byte character (SF4)", () => { + const nearCap = parseEscalationAssessment({ + ...assessment, + blockedOutcome: "a".repeat(ESCALATION_TEXT_MAX_BYTES - 3) + "🎯", + }); + expect(nearCap).not.toBeInstanceOf(Error); + if (nearCap instanceof Error) return; + expect(nearCap.blockedOutcome).not.toContain("\uFFFD"); + const rendered = renderEscalationDecision({ + sessionId: "worker-1", + questionId: "ask-1", + assessment: nearCap, + }); + expect(rendered).not.toContain("\uFFFD"); + expect(rendered).toContain("Minimum authority/decision:"); +}); + +test("structured parent resolution is closed and carries no authority", () => { + expect( + parseEscalationResolution({ + kind: "minimum_grant_available", + answer: "The minimum decision is available; continue your next turn.", + questionId: "ask-1", + }), + ).toEqual({ + kind: "minimum_grant_available", + answer: "The minimum decision is available; continue your next turn.", + questionId: "ask-1", + }); + expect( + parseEscalationResolution({ + kind: "grant", + answer: "retry this command", + command: "unsafe", + questionId: "ask-1", + }), + ).toBeInstanceOf(Error); +}); + +test("structured resolution requires questionId and names the valid kinds", () => { + const missing = parseEscalationResolution({ + kind: "declined", + answer: "No.", + }); + expect(missing).toBeInstanceOf(Error); + const invalid = parseEscalationResolution({ + kind: "grant", + answer: "No.", + questionId: "ask-1", + }); + expect(invalid).toBeInstanceOf(Error); + if (invalid instanceof Error) { + expect(invalid.message).toContain("director_answer"); + expect(invalid.message).toContain("declined"); + expect(invalid.message).toContain("unavailable"); + expect(invalid.message).toContain("minimum_grant_available"); + } +}); diff --git a/src/subagent/escalation-policy.ts b/src/subagent/escalation-policy.ts new file mode 100644 index 000000000..e08bbeb77 --- /dev/null +++ b/src/subagent/escalation-policy.ts @@ -0,0 +1,409 @@ +export const ESCALATION_POLICY_VERSION = "1" as const; +export const ESCALATION_TEXT_MAX_BYTES = 4096; + +export const ESCALATION_CLASSIFICATIONS = [ + "routine", + "director_resolvable", + "operator_decision_required", + "outcome_blocked", + "irreversible_or_sensitive", +] as const; +export type EscalationClassification = + (typeof ESCALATION_CLASSIFICATIONS)[number]; + +export interface PermittedAlternative { + readonly attempted: string; + readonly result: string; + readonly comparableConfidence: boolean; +} + +export interface VerificationDetail { + readonly verificationOutcome: string; + readonly rootCause: string; + readonly attemptedNarrowChecks: readonly string[]; + readonly reducedConfidence: string; +} + +export interface EscalationAssessment { + readonly policyVersion: typeof ESCALATION_POLICY_VERSION; + readonly classification: EscalationClassification; + readonly blockedOutcome: string; + readonly unavailableDirectorPath: string; + readonly permittedAlternatives: readonly PermittedAlternative[]; + readonly minimumAddition: string; + readonly declineConsequence: string; + readonly recommendation?: string; + readonly safeDefault?: string; + readonly requestedMechanism?: string; + readonly minimumAuthority?: string; + readonly verification?: VerificationDetail; +} + +export type EscalationResolution = + | { + readonly kind: "director_answer"; + readonly answer: string; + readonly questionId: string; + } + | { + readonly kind: "declined"; + readonly answer: string; + readonly questionId: string; + } + | { + readonly kind: "unavailable"; + readonly answer: string; + readonly questionId: string; + } + | { + readonly kind: "minimum_grant_available"; + readonly answer: string; + readonly questionId: string; + }; + +export type EscalationDirective = + | { + readonly kind: "continue_internal"; + readonly assessment: EscalationAssessment; + } + | { readonly kind: "park_parent"; readonly assessment: EscalationAssessment }; + +export interface VerificationBlockedOutcome { + readonly kind: "verification_blocked"; + readonly implementationReviewComplete: true; + readonly verificationOutcome: string; + readonly rootCause: string; + readonly alternatives: readonly PermittedAlternative[]; + readonly resolution: "declined" | "unavailable"; + readonly reducedConfidence: string; + readonly recommendation?: string; +} + +/** A decline/unavailability on a non-verification ask still records an outcome. */ +export interface DeclinedOutcome { + readonly kind: "declined"; + readonly resolution: "declined" | "unavailable"; + readonly declineConsequence: string; + readonly recommendation?: string; +} + +/** Explicit worker-reported successful verification replaces a blocked outcome. */ +export interface VerifiedOutcome { + readonly kind: "verified"; + readonly verificationOutcome: string; +} + +export type TerminalOutcome = + | VerificationBlockedOutcome + | DeclinedOutcome + | VerifiedOutcome; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const nonEmpty = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; +/** Byte-bounded on a character boundary so a slice never splits a code point. */ +const bounded = (value: string): string => { + if (new TextEncoder().encode(value).byteLength <= ESCALATION_TEXT_MAX_BYTES) + return value; + let kept = ""; + let bytes = 0; + for (const ch of Array.from(value)) { + const size = new TextEncoder().encode(ch).byteLength; + if (bytes + size > ESCALATION_TEXT_MAX_BYTES) break; + kept += ch; + bytes += size; + } + return kept; +}; +const text = (value: unknown, field: string): string | Error => + nonEmpty(value) + ? bounded(value.trim()) + : new Error(`escalation.${field} must be a non-empty string.`); + +/** Validates the explicit parent decision; it never carries authority or a call. */ +export function parseEscalationResolution( + raw: unknown, +): EscalationResolution | Error { + if (!isRecord(raw)) return new Error("resolution must be an object."); + const { kind } = raw; + if ( + kind !== "director_answer" && + kind !== "declined" && + kind !== "unavailable" && + kind !== "minimum_grant_available" + ) { + return new Error( + "resolution.kind must be one of: director_answer, declined, unavailable, minimum_grant_available.", + ); + } + const questionId = text(raw.questionId, "resolution.questionId"); + if (questionId instanceof Error) return questionId; + const answer = text(raw.answer, "resolution.answer"); + if (answer instanceof Error) return answer; + return { kind, answer, questionId }; +} + +/** Validates tool-boundary data before it can create a parked parent ask. */ +export function parseEscalationAssessment( + raw: unknown, +): EscalationAssessment | Error { + if (!isRecord(raw)) + return new Error("ask_director requires escalation (object)."); + if (raw.policyVersion !== ESCALATION_POLICY_VERSION) + return new Error( + `escalation.policyVersion must be ${ESCALATION_POLICY_VERSION}.`, + ); + const classification = raw.classification; + if ( + typeof classification !== "string" || + !ESCALATION_CLASSIFICATIONS.includes( + classification as EscalationClassification, + ) + ) + return new Error("escalation.classification is not recognized."); + const fields = [ + "blockedOutcome", + "unavailableDirectorPath", + "minimumAddition", + "declineConsequence", + ] as const; + const parsed = fields.map((field) => text(raw[field], field)); + const invalid = parsed.find( + (value): value is Error => value instanceof Error, + ); + if (invalid !== undefined) return invalid; + if (!Array.isArray(raw.permittedAlternatives)) + return new Error("escalation.permittedAlternatives must be an array."); + const alternatives: PermittedAlternative[] = []; + for (const [index, alternative] of raw.permittedAlternatives.entries()) { + if (!isRecord(alternative)) + return new Error( + `escalation.permittedAlternatives[${index}] must be an object.`, + ); + const attempted = text( + alternative.attempted, + `permittedAlternatives[${index}].attempted`, + ); + const result = text( + alternative.result, + `permittedAlternatives[${index}].result`, + ); + if (attempted instanceof Error) return attempted; + if (result instanceof Error) return result; + if (typeof alternative.comparableConfidence !== "boolean") + return new Error( + `escalation.permittedAlternatives[${index}].comparableConfidence must be boolean.`, + ); + alternatives.push({ + attempted, + result, + comparableConfidence: alternative.comparableConfidence, + }); + } + // Fact 3 content is mandatory for every parked/surfaced classification: + // an empty alternatives list would let a worker park without showing the + // permitted paths already considered. `routine` instead requires a + // comparable alternative below. + if (classification !== "routine" && alternatives.length === 0) + return new Error( + "parked escalation requires at least one permitted alternative considered.", + ); + const optionalText = (field: string): string | Error | undefined => + raw[field] === undefined ? undefined : text(raw[field], field); + const requestedMechanism = optionalText("requestedMechanism"); + const minimumAuthority = optionalText("minimumAuthority"); + const recommendation = optionalText("recommendation"); + const safeDefault = optionalText("safeDefault"); + for (const value of [ + requestedMechanism, + minimumAuthority, + recommendation, + safeDefault, + ]) + if (value instanceof Error) return value; + const requestedMechanismValue = requestedMechanism as string | undefined; + const minimumAuthorityValue = minimumAuthority as string | undefined; + const recommendationValue = recommendation as string | undefined; + const safeDefaultValue = safeDefault as string | undefined; + if ( + requestedMechanismValue !== undefined && + minimumAuthorityValue !== undefined && + requestedMechanismValue === minimumAuthorityValue + ) + return new Error( + "escalation.requestedMechanism must differ from minimumAuthority.", + ); + if ( + (classification === "operator_decision_required" || + classification === "irreversible_or_sensitive") && + minimumAuthorityValue === undefined + ) + return new Error( + `escalation.minimumAuthority is required for ${classification}.`, + ); + if ( + classification === "routine" && + !alternatives.some((alternative) => alternative.comparableConfidence) + ) + return new Error( + "routine escalation requires a comparable permitted alternative.", + ); + let verification: VerificationDetail | undefined; + if (raw.verification !== undefined) { + if (!isRecord(raw.verification)) + return new Error("escalation.verification must be an object."); + const verificationOutcome = text( + raw.verification.verificationOutcome, + "verification.verificationOutcome", + ); + const rootCause = text( + raw.verification.rootCause, + "verification.rootCause", + ); + const reducedConfidence = text( + raw.verification.reducedConfidence, + "verification.reducedConfidence", + ); + if (verificationOutcome instanceof Error) return verificationOutcome; + if (rootCause instanceof Error) return rootCause; + if (reducedConfidence instanceof Error) return reducedConfidence; + if ( + !Array.isArray(raw.verification.attemptedNarrowChecks) || + !raw.verification.attemptedNarrowChecks.every(nonEmpty) + ) + return new Error( + "escalation.verification.attemptedNarrowChecks must contain non-empty strings.", + ); + verification = { + verificationOutcome, + rootCause, + reducedConfidence, + attemptedNarrowChecks: raw.verification.attemptedNarrowChecks.map( + (item) => bounded(item.trim()), + ), + }; + } + return { + policyVersion: ESCALATION_POLICY_VERSION, + classification: classification as EscalationClassification, + blockedOutcome: parsed[0] as string, + unavailableDirectorPath: parsed[1] as string, + minimumAddition: parsed[2] as string, + declineConsequence: parsed[3] as string, + permittedAlternatives: alternatives, + ...(requestedMechanismValue !== undefined + ? { requestedMechanism: requestedMechanismValue } + : {}), + ...(minimumAuthorityValue !== undefined + ? { minimumAuthority: minimumAuthorityValue } + : {}), + ...(recommendationValue !== undefined + ? { recommendation: recommendationValue } + : {}), + ...(safeDefaultValue !== undefined + ? { safeDefault: safeDefaultValue } + : {}), + ...(verification !== undefined ? { verification } : {}), + }; +} + +// Comparable confidence is the worker's own claim, so it may only settle asks +// the director could resolve anyway. Decision-mandated classes need a human or +// parent decision no matter how confident the worker is in an alternative. +export const decideEscalation = ( + assessment: EscalationAssessment, +): EscalationDirective => + assessment.classification === "routine" || + (assessment.classification === "director_resolvable" && + assessment.permittedAlternatives.some( + (alternative) => alternative.comparableConfidence, + )) + ? { kind: "continue_internal", assessment } + : { kind: "park_parent", assessment }; + +export const deriveVerificationBlockedOutcome = ( + assessment: EscalationAssessment, + resolution: EscalationResolution, +): VerificationBlockedOutcome | undefined => { + if ( + (resolution.kind !== "declined" && resolution.kind !== "unavailable") || + assessment.verification === undefined + ) + return undefined; + return { + kind: "verification_blocked", + implementationReviewComplete: true, + verificationOutcome: assessment.verification.verificationOutcome, + rootCause: assessment.verification.rootCause, + alternatives: assessment.permittedAlternatives, + resolution: resolution.kind, + reducedConfidence: assessment.verification.reducedConfidence, + ...(assessment.recommendation !== undefined + ? { recommendation: assessment.recommendation } + : {}), + }; +}; + +/** + * Records a consistent decline/unavailability outcome for every ask. A + * verification ask derives the typed `verification_blocked` outcome; any other + * parked ask records the decline consequence so a structured decline can never + * settle silently. + */ +export const deriveTerminalOutcome = ( + assessment: EscalationAssessment, + resolution: EscalationResolution, +): TerminalOutcome | undefined => { + if (resolution.kind !== "declined" && resolution.kind !== "unavailable") + return undefined; + if (assessment.verification !== undefined) + return deriveVerificationBlockedOutcome(assessment, resolution); + return { + kind: "declined", + resolution: resolution.kind, + declineConsequence: assessment.declineConsequence, + ...(assessment.recommendation !== undefined + ? { recommendation: assessment.recommendation } + : {}), + }; +}; + +/** Stable parent-facing text. It carries facts only; it cannot convey authority. */ +export const renderEscalationDecision = (input: { + sessionId: string; + questionId: string; + assessment: EscalationAssessment; +}): string => { + const a = input.assessment; + const alternatives = + a.permittedAlternatives.length === 0 + ? "none recorded" + : a.permittedAlternatives + .map( + (alternative) => + `${alternative.attempted}: ${alternative.result} (${alternative.comparableConfidence ? "comparable" : "not comparable"})`, + ) + .join("; "); + // Bound every field on its own (character-boundary) so a long field can + // never truncate the mechanism-vs-authority separation or the send_input + // instruction that follow it. + const field = (label: string, value: string): string => + `${label} ${bounded(value)}`; + return [ + `Worker decision required (${a.classification})`, + `Target: ${input.sessionId} / ${input.questionId}`, + field("Blocked outcome/verification:", a.blockedOutcome), + field("Unavailable director path:", a.unavailableDirectorPath), + field("Permitted alternatives considered:", alternatives), + field("Minimum addition:", a.minimumAddition), + field("Decline/proceed-without consequence:", a.declineConsequence), + field( + "Safe default/recommendation:", + a.recommendation ?? a.safeDefault ?? "none recorded", + ), + field("Requested mechanism:", a.requestedMechanism ?? "none recorded"), + field("Minimum authority/decision:", a.minimumAuthority ?? "none recorded"), + "Reply with send_input (plain text or a structured resolution); this does not grant or retry a tool.", + ].join("\n"); +}; diff --git a/src/subagent/fleet-dry-drive.test.ts b/src/subagent/fleet-dry-drive.test.ts index 76a4371a3..66b413b17 100644 --- a/src/subagent/fleet-dry-drive.test.ts +++ b/src/subagent/fleet-dry-drive.test.ts @@ -226,6 +226,21 @@ describe("collectUncollectedTerminals", () => { sessions.registerAsk("ask", { question: "which path?", questionId: "q1", + assessment: { + policyVersion: "1", + classification: "director_resolvable", + blockedOutcome: "cannot choose a path", + unavailableDirectorPath: "the director has no map", + permittedAlternatives: [ + { + attempted: "checked cached paths", + result: "ambiguous", + comparableConfidence: false, + }, + ], + minimumAddition: "a path decision", + declineConsequence: "the worker remains blocked", + }, resolve: () => undefined, reject: () => undefined, }), diff --git a/src/subagent/fleet-report.ask-wake.test.ts b/src/subagent/fleet-report.ask-wake.test.ts index fee35ea46..1a11d2fc7 100644 --- a/src/subagent/fleet-report.ask-wake.test.ts +++ b/src/subagent/fleet-report.ask-wake.test.ts @@ -99,4 +99,33 @@ describe("pendingAskWakeText", () => { expect(text).toContain("stalled"); expect(text).toContain("send_input"); }); + + test("an assessed wake keeps the worker's question plus the policy facts (SF5)", () => { + const text = pendingAskWakeText({ + sessionId: "a1", + agentId: "builder", + description: "Build the thing", + question: "Which branch should I target?", + questionId: "q1", + assessment: { + policyVersion: "1", + classification: "director_resolvable", + blockedOutcome: "cannot choose a target branch", + unavailableDirectorPath: "the director has no branch map", + permittedAlternatives: [ + { + attempted: "checked the branch list", + result: "ambiguous", + comparableConfidence: false, + }, + ], + minimumAddition: "a branch decision", + declineConsequence: "implementation stays on the current branch", + }, + }); + expect(text).toContain("Which branch should I target?"); + expect(text).toContain("Worker decision required (director_resolvable)"); + expect(text).toContain("Minimum authority/decision:"); + expect(text).toContain("send_input"); + }); }); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index d34dd9cf6..1505a0197 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -18,6 +18,10 @@ import type { AgentLifecycleStatus, SubAgentSessionStatus, } from "./session-store.js"; +import { + renderEscalationDecision, + type EscalationAssessment, +} from "./escalation-policy.js"; /** The lane fields a report is written from. `SubAgentSession` satisfies it. */ export interface FleetLane { @@ -120,14 +124,19 @@ export interface PendingAskWake { readonly description: string; readonly question: string; readonly questionId: string; + readonly assessment?: EscalationAssessment; } /** Nested orchestrators own their children's questions; only root workers wake the TUI. */ export function pendingAskSnapshot( lanes: readonly FleetLane[], - peekAsk: ( - sessionId: string, - ) => { question: string; questionId: string } | undefined, + peekAsk: (sessionId: string) => + | { + question: string; + questionId: string; + assessment?: EscalationAssessment; + } + | undefined, ): readonly PendingAskWake[] { const asks: PendingAskWake[] = []; for (const lane of lanes) { @@ -141,6 +150,7 @@ export function pendingAskSnapshot( description: lane.description, question: ask.question, questionId: ask.questionId, + ...(ask.assessment !== undefined ? { assessment: ask.assessment } : {}), }); } return asks; @@ -159,7 +169,17 @@ export function pendingAskWakeText( const lines = [ `${ASK_DIRECTOR_WAKE_PREFIX} — worker ${wake.agentId} (${wake.description}) parked question ${wake.questionId} while this session was not collecting:`, "", - wake.question, + wake.assessment === undefined + ? wake.question + : [ + `Question: ${wake.question}`, + "", + renderEscalationDecision({ + sessionId: wake.sessionId, + questionId: wake.questionId, + assessment: wake.assessment, + }), + ].join("\n"), "", ]; // A re-surfaced question: the earlier wake was aborted without an answer. diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index bec8b45e4..21c4c2a54 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -25,6 +25,31 @@ const callTool = callFleetTool; type SessionStore = ReturnType; +/** Valid assessed ask payload for the structured-resolution tests. */ +const assessedAsk = { + policyVersion: "1", + classification: "operator_decision_required", + blockedOutcome: "cannot produce the authenticated verification result", + unavailableDirectorPath: "the director has no credential authority", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "requires the missing credential", + comparableConfidence: false, + }, + ], + minimumAddition: "allow an authenticated read-only snapshot", + declineConsequence: "implementation is complete but verification is blocked", + requestedMechanism: "broad web access", + minimumAuthority: "authenticated read-only snapshot", + verification: { + verificationOutcome: "integration verification", + rootCause: "missing test credential", + attemptedNarrowChecks: ["focused local test"], + reducedConfidence: "integration behavior remains unverified", + }, +} as const; + // Every session in this suite uses the same agentId/brief scaffold; callers // pass only the fields that actually vary for the behavior under test. function startSession( @@ -339,6 +364,7 @@ describe("resume_agent", () => { sessions.registerAsk(worker.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => undefined, reject: () => undefined, }), @@ -1111,6 +1137,79 @@ describe("send_input", () => { expect(denied.isError).toBe(true); expect(String(denied.content)).toContain("no resolvable session"); }); + + test("structured escalation resolution requires the pending questionId and rejects a stale one (SF2)", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const worker = startSession(sessions, { description: "worker" }); + sessions.markRunning(worker.id); + sessions.registerAsk(worker.id, { + question: "which file?", + questionId: "ask-1", + assessment: assessedAsk, + resolve: () => undefined, + reject: () => undefined, + }); + + const noQuestion = await callFleetToolRaw(sendInput, { + target: worker.id, + message: "No.", + resolution: { kind: "declined", answer: "No." }, + }); + expect(noQuestion.isError).toBe(true); + expect(String(noQuestion.content)).toContain("questionId"); + + const mismatch = await callFleetToolRaw(sendInput, { + target: worker.id, + message: "No.", + resolution: { + kind: "declined", + answer: "No.", + questionId: "ask-0", + }, + }); + expect(mismatch.isError).toBe(true); + expect(String(mismatch.content)).toContain("questionId mismatch"); + expect(String(mismatch.content)).toContain('"ask-1"'); + expect(sessions.hasPendingAsk(worker.id)).toBe(true); + }); + + test("structured resolution delivers the message together with the answer (SF6)", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const worker = startSession(sessions, { description: "worker" }); + sessions.markRunning(worker.id); + let resolved: string | undefined; + sessions.registerAsk(worker.id, { + question: "which file?", + questionId: "ask-1", + assessment: assessedAsk, + resolve: (answer) => { + resolved = answer; + }, + reject: () => { + throw new Error("should not reject"); + }, + }); + + const both = await callFleetToolRaw(sendInput, { + target: worker.id, + message: "use branch X", + resolution: { + kind: "declined", + answer: "No credential grant.", + questionId: "ask-1", + }, + }); + expect(both.isError).not.toBe(true); + expect(resolved).toBe("use branch X\nNo credential grant."); + expect(sessions.get(worker.id)?.terminalOutcome).toMatchObject({ + kind: "verification_blocked", + resolution: "declined", + }); + }); }); describe("nested lifecycle authority", () => { diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts index 84dab01f1..3b6ea99af 100644 --- a/src/subagent/lifecycle-tools.ts +++ b/src/subagent/lifecycle-tools.ts @@ -26,6 +26,7 @@ import { type AgentLifecycleStatus, type SubAgentSessionStore, } from "./session-store.js"; +import { parseEscalationResolution } from "./escalation-policy.js"; import { assertCanTargetAgent, FleetAuthorityError, @@ -434,6 +435,7 @@ const SendInputArgs = type({ target: "string", message: "string", "interrupt?": "boolean", + "resolution?": "unknown", }); export const sendInputToolDefinition: ToolDefinition = { @@ -455,6 +457,33 @@ export const sendInputToolDefinition: ToolDefinition = { type: "boolean", description: "Interrupt first.", }, + resolution: { + type: "object", + description: + "Optional explicit pending-escalation resolution (questionId, kind, answer). It never grants or retries a tool. The message is delivered together with the structured answer.", + properties: { + questionId: { + type: "string", + description: "The pending question id this resolution answers.", + }, + kind: { + type: "string", + enum: [ + "director_answer", + "declined", + "unavailable", + "minimum_grant_available", + ], + description: + "director_answer (normal answer), declined, unavailable, or minimum_grant_available.", + }, + answer: { + type: "string", + description: "Text the worker receives.", + }, + }, + required: ["questionId", "kind", "answer"], + }, }, required: ["target", "message"], }, @@ -488,6 +517,50 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool { `(got ${message.length}).`, ); } + if (parsed.resolution !== undefined) { + if (parsed.interrupt === true) { + return lifecycleResult( + call.id, + "Error: structured escalation resolution cannot interrupt a worker.", + ); + } + const resolution = parseEscalationResolution(parsed.resolution); + if (resolution instanceof Error) { + return lifecycleResult( + call.id, + `Error: send_input resolution invalid: ${resolution.message}`, + ); + } + const pending = deps.sessions.peekAsk(target); + if (pending === undefined) { + return lifecycleResult( + call.id, + `Error: no pending escalation for "${target}".`, + ); + } + if (pending.questionId !== resolution.questionId) { + return lifecycleResult( + call.id, + `Error: send_input resolution questionId mismatch: pending ask is "${pending.questionId}", resolution names "${resolution.questionId}".`, + ); + } + // SF6: deliver the parent's message together with the structured + // answer so a resolution can never silently drop the message. + if (!deps.sessions.resolveEscalationAsk(target, resolution, message)) { + return lifecycleResult( + call.id, + `Error: no pending escalation for "${target}".`, + ); + } + return lifecycleResult( + call.id, + fleetJson({ + agent_id: target, + status: "running", + resolution: resolution.kind, + }), + ); + } const interrupt = parsed.interrupt === true; const outcome = deps.sessions.sendInputOne(target, message, { ...(interrupt ? { interrupt: true } : {}), diff --git a/src/subagent/run-ask-director-continue.test.ts b/src/subagent/run-ask-director-continue.test.ts index 6387cbf04..f942e9821 100644 --- a/src/subagent/run-ask-director-continue.test.ts +++ b/src/subagent/run-ask-director-continue.test.ts @@ -18,6 +18,7 @@ import type { RunSubAgentParams } from "./types.js"; import type { AskDirectorState } from "./ask-director.js"; import { SubAgentDirector } from "./nudge-director.js"; import { createTestCapabilities } from "./director-test-harness.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../../testkit/escalation.js"; const testPermissionGate = createPermissionGate({ approvals: [], @@ -217,7 +218,10 @@ describe("runSubAgent ask_director compact-continue wiring", () => { } const askPromise = capturedAskHandler( - { question: "which file?" }, + { + question: "which file?", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + }, new AbortController().signal, ); for (let i = 0; i < 500 && resolveAsk === undefined; i++) { diff --git a/src/subagent/run-ask-director-grant.test.ts b/src/subagent/run-ask-director-grant.test.ts index 8194eb4ad..08160aee3 100644 --- a/src/subagent/run-ask-director-grant.test.ts +++ b/src/subagent/run-ask-director-grant.test.ts @@ -10,6 +10,7 @@ import type { DirectorFactory } from "@intx/agent"; import { withMockedModuleDuring } from "../../testkit/mock-module.js"; import { createPermissionGate } from "../permission/gate.js"; import type { RunSubAgentParams } from "./types.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../../testkit/escalation.js"; const testPermissionGate = createPermissionGate({ approvals: [], @@ -56,12 +57,13 @@ function createHangingStubAgent() { } describe("runSubAgent ask_director grant_request_id threading", () => { - test("leaf forwards grant_request_id to the port; absent stays legacy", async () => { + test("leaf forwards grant_request_id to the port; an assessed ask without one stays unnamed", async () => { const cwd = await mkdtemp(join(tmpdir(), "cl9475-ask-grant-")); const registered: { question: string; questionId: string; grantRequestId?: string; + assessment?: unknown; }[] = []; let capturedAskHandler: | (( @@ -145,16 +147,23 @@ describe("runSubAgent ask_director grant_request_id threading", () => { }); const named = await capturedAskHandler( - { question: "may I retry?", grant_request_id: "grant-123" }, + { + question: "may I retry?", + grant_request_id: "grant-123", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + }, new AbortController().signal, ); expect(named).toBe("answer:1"); - const legacy = await capturedAskHandler( - { question: "plain question" }, + const unnamed = await capturedAskHandler( + { + question: "plain question", + escalation: DIRECTOR_RESOLVABLE_ESCALATION, + }, new AbortController().signal, ); - expect(legacy).toBe("answer:2"); + expect(unnamed).toBe("answer:2"); await handles.close().catch(() => undefined); await runPromise.catch(() => undefined); @@ -166,7 +175,15 @@ describe("runSubAgent ask_director grant_request_id threading", () => { expect(outcome.registered.length).toBe(2); expect(outcome.registered[0]?.question).toBe("may I retry?"); expect(outcome.registered[0]?.grantRequestId).toBe("grant-123"); + expect(outcome.registered[0]?.assessment).toMatchObject({ + policyVersion: "1", + classification: "director_resolvable", + }); expect(outcome.registered[1]?.question).toBe("plain question"); expect(outcome.registered[1]?.grantRequestId).toBeUndefined(); + expect(outcome.registered[1]?.assessment).toMatchObject({ + policyVersion: "1", + classification: "director_resolvable", + }); }); }); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 3dcd6c212..22ce0ea76 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -562,7 +562,8 @@ const askDirectorDefinition: ToolDefinition = { `${ASK_DIRECTOR_MAX_BYTES} byte cap. The director answers with send_input (soft). ` + "For a denied tool call, reference only the grant requestId from the deny " + "message — the harness-owned envelope carries the exact call, so repeating " + - "tool arguments here grants nothing.", + "tool arguments here grants nothing. Include the typed escalation assessment; " + + "an answer or wake never grants authority or retries a tool automatically.", inputSchema: { type: "object", properties: { @@ -575,8 +576,74 @@ const askDirectorDefinition: ToolDefinition = { description: "Grant request id quoted from the deny reason; binds this ask to its own denial.", }, + escalation: { + type: "object", + description: + "Outcome-aware policy assessment: policyVersion, classification, the seven required facts, alternatives, requested mechanism vs minimum authority, and optional verification detail. An answer or wake never grants authority or retries a tool.", + properties: { + policyVersion: { type: "string", const: "1" }, + classification: { + type: "string", + enum: [ + "routine", + "director_resolvable", + "operator_decision_required", + "outcome_blocked", + "irreversible_or_sensitive", + ], + }, + blockedOutcome: { type: "string" }, + unavailableDirectorPath: { type: "string" }, + permittedAlternatives: { + type: "array", + items: { + type: "object", + properties: { + attempted: { type: "string" }, + result: { type: "string" }, + comparableConfidence: { type: "boolean" }, + }, + required: ["attempted", "result", "comparableConfidence"], + }, + }, + minimumAddition: { type: "string" }, + declineConsequence: { type: "string" }, + recommendation: { type: "string" }, + safeDefault: { type: "string" }, + requestedMechanism: { type: "string" }, + minimumAuthority: { type: "string" }, + verification: { + type: "object", + properties: { + verificationOutcome: { type: "string" }, + rootCause: { type: "string" }, + attemptedNarrowChecks: { + type: "array", + items: { type: "string" }, + }, + reducedConfidence: { type: "string" }, + }, + required: [ + "verificationOutcome", + "rootCause", + "attemptedNarrowChecks", + "reducedConfidence", + ], + }, + }, + required: [ + "policyVersion", + "classification", + "blockedOutcome", + "unavailableDirectorPath", + "permittedAlternatives", + "minimumAddition", + "declineConsequence", + ], + additionalProperties: false, + }, }, - required: ["question"], + required: ["question", "escalation"], }, }; @@ -901,6 +968,7 @@ async function runSubAgentInner( try { return await handleAskDirector({ question: rawArgs.question, + escalation: rawArgs.escalation, grantRequestId: rawArgs.grant_request_id, state: askDirectorState, port, diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index f245369e5..c56f1bf52 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { createSubAgentSessionStore } from "./session-store.js"; +import { + createSubAgentSessionStore, + ROUTINE_EVALUATIONS_MAX, +} from "./session-store.js"; import { projectWaitStatus } from "./lifecycle.js"; import { createAdmissionQueue } from "./admission.js"; import { forcedStopReport } from "./stop-policy.js"; @@ -78,6 +81,35 @@ function completedRetained(store: SessionStore, opts: RetainedOpts = {}) { }); } +/** Valid assessed ask payload for the policy-boundary tests. */ +const assessedAsk = { + policyVersion: "1", + classification: "operator_decision_required", + blockedOutcome: "cannot produce the authenticated verification result", + unavailableDirectorPath: "the director has no credential authority", + permittedAlternatives: [ + { + attempted: "focused local test", + result: "requires the missing credential", + comparableConfidence: false, + }, + ], + minimumAddition: "allow an authenticated read-only snapshot", + declineConsequence: "implementation is complete but verification is blocked", + requestedMechanism: "broad web access", + minimumAuthority: "authenticated read-only snapshot", +} as const; + +const assessedVerificationAsk = { + ...assessedAsk, + verification: { + verificationOutcome: "integration verification", + rootCause: "missing test credential", + attemptedNarrowChecks: ["focused local test"], + reducedConfidence: "integration behavior remains unverified", + }, +} as const; + function fillRetained(store: SessionStore, n: number, prefix = "fill"): void { for (let i = 0; i < n; i++) { const s = store.start({ @@ -1296,6 +1328,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: (answer) => { resolved = answer; }, @@ -1308,6 +1341,7 @@ describe("pending ask_director", () => { expect(store.peekAsk(session.id)).toEqual({ question: "which file?", questionId: "ask-1", + assessment: assessedAsk, }); expect(store.sendInputOne(session.id, "src/foo.ts")).toEqual({ @@ -1329,6 +1363,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1361,6 +1396,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1406,6 +1442,7 @@ describe("pending ask_director", () => { store.registerAsk(child.id, { question: "q", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1448,6 +1485,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: (answer) => { resolved = answer; }, @@ -1476,6 +1514,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "late?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1492,6 +1531,7 @@ describe("pending ask_director", () => { const ask = { question: "late?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1540,6 +1580,7 @@ describe("pending ask_director", () => { store.registerAsk(session.id, { question: "which file?", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1573,6 +1614,7 @@ describe("pending ask_director", () => { store.registerAsk(completed.id, { question: "q", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1592,6 +1634,7 @@ describe("pending ask_director", () => { store.registerAsk(closed.id, { question: "q", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1623,6 +1666,7 @@ describe("pending ask_director", () => { store.registerAsk(child.id, { question: "q", questionId: "ask-1", + assessment: assessedAsk, resolve: () => { throw new Error("should not resolve"); }, @@ -1636,6 +1680,289 @@ describe("pending ask_director", () => { }); }); +describe("outcome-aware escalation policy", () => { + function assessedStore() { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + store.markRunning(session.id); + return { store, session }; + } + + function registerAssessed( + store: SessionStore, + id: string, + assessment: typeof assessedAsk, + ) { + let resolved: string | undefined; + expect( + store.registerAsk(id, { + question: "which file?", + questionId: "ask-1", + assessment, + resolve: (answer) => { + resolved = answer; + }, + reject: () => { + throw new Error("should not reject"); + }, + }), + ).toBe(true); + return { resolved: () => resolved }; + } + + test("declined and unavailable verification asks surface verification_blocked through get() and listForStrip()", () => { + for (const kind of ["declined", "unavailable"] as const) { + const { store, session } = assessedStore(); + registerAssessed(store, session.id, assessedVerificationAsk); + expect( + store.resolveEscalationAsk(session.id, { + kind, + answer: + kind === "declined" + ? "No credential grant." + : "Credential unavailable.", + questionId: "ask-1", + }), + ).toBe(true); + const viaGet = store.get(session.id); + expect(viaGet?.terminalOutcome).toMatchObject({ + kind: "verification_blocked", + implementationReviewComplete: true, + resolution: kind, + rootCause: "missing test credential", + }); + const viaStrip = store.listForStrip(); + expect(viaStrip[0]?.terminalOutcome).toMatchObject({ + kind: "verification_blocked", + resolution: kind, + }); + } + }); + + test("a structured decline on a non-verification ask records a consistent outcome (SF3)", () => { + const { store, session } = assessedStore(); + registerAssessed(store, session.id, assessedAsk); + expect( + store.resolveEscalationAsk(session.id, { + kind: "declined", + answer: "No.", + questionId: "ask-1", + }), + ).toBe(true); + expect(store.get(session.id)?.terminalOutcome).toMatchObject({ + kind: "declined", + resolution: "declined", + declineConsequence: assessedAsk.declineConsequence, + }); + }); + + test("a resolution for a stale question id is rejected and cannot settle the ask (SF2)", () => { + const { store, session } = assessedStore(); + registerAssessed(store, session.id, assessedAsk); + expect( + store.resolveEscalationAsk(session.id, { + kind: "declined", + answer: "No.", + questionId: "ask-0", + }), + ).toBe(false); + expect(store.hasPendingAsk(session.id)).toBe(true); + }); + + test("minimum_grant_available resumes by text only and records no terminal outcome (SF8)", () => { + const { store, session } = assessedStore(); + const registered = registerAssessed( + store, + session.id, + assessedVerificationAsk, + ); + expect( + store.resolveEscalationAsk(session.id, { + kind: "minimum_grant_available", + answer: "The minimum decision is available; continue your next turn.", + questionId: "ask-1", + }), + ).toBe(true); + expect(registered.resolved()).toBe( + "The minimum decision is available; continue your next turn.", + ); + expect(store.get(session.id)?.terminalOutcome).toBeUndefined(); + }); + + test("verification_blocked is retained across complete and a resumed follow-up turn (SF8)", async () => { + const store = createSubAgentSessionStore(); + const session = store.start({ + description: "d", + agentId: "a", + brief: "b", + retained: true, + }); + store.markRunning(session.id); + registerAssessed(store, session.id, assessedVerificationAsk); + store.registerDeliver(session.id, () => undefined); + store.registerInterrupt(session.id, () => undefined); + store.registerFollowup(session.id, async () => "followup reply"); + expect( + store.resolveEscalationAsk(session.id, { + kind: "declined", + answer: "No credential grant.", + questionId: "ask-1", + }), + ).toBe(true); + store.complete(session.id, "## Summary\nDone.", { agentRetained: true }); + expect(store.get(session.id)?.terminalOutcome).toMatchObject({ + kind: "verification_blocked", + }); + store.sendInputOne(session.id, "resume", { interrupt: true }); + expect(store.get(session.id)?.terminalOutcome).toMatchObject({ + kind: "verification_blocked", + }); + }); + + test("only an explicit worker report replaces verification_blocked", () => { + const { store, session } = assessedStore(); + registerAssessed(store, session.id, assessedVerificationAsk); + store.resolveEscalationAsk(session.id, { + kind: "unavailable", + answer: "No.", + questionId: "ask-1", + }); + expect( + store.reportVerifiedOutcome(session.id, { + kind: "verified", + verificationOutcome: "focused integration verification passed", + }), + ).toBe(true); + expect(store.get(session.id)?.terminalOutcome).toEqual({ + kind: "verified", + verificationOutcome: "focused integration verification passed", + }); + // A later decision grant on the same session must not silently clear the + // blocked outcome: it stays verification_blocked until the worker + // explicitly reports a successful verification. + const second = assessedStore(); + registerAssessed(second.store, second.session.id, assessedVerificationAsk); + second.store.resolveEscalationAsk(second.session.id, { + kind: "unavailable", + answer: "No.", + questionId: "ask-1", + }); + second.store.registerAsk(second.session.id, { + question: "which file?", + questionId: "ask-2", + assessment: assessedVerificationAsk, + resolve: () => undefined, + reject: () => undefined, + }); + second.store.resolveEscalationAsk(second.session.id, { + kind: "minimum_grant_available", + answer: "continue", + questionId: "ask-2", + }); + expect(second.store.get(second.session.id)?.terminalOutcome?.kind).toBe( + "verification_blocked", + ); + // Refuses when no blocked outcome exists. + const fresh = assessedStore(); + expect( + fresh.store.reportVerifiedOutcome(fresh.session.id, { + kind: "verified", + verificationOutcome: "n/a", + }), + ).toBe(false); + }); + + test("subscribers are notified exactly once per escalation resolution (Nit 3)", () => { + const { store, session } = assessedStore(); + registerAssessed(store, session.id, assessedVerificationAsk); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + try { + store.resolveEscalationAsk(session.id, { + kind: "declined", + answer: "No credential grant.", + questionId: "ask-1", + }); + expect(notifications).toBe(1); + } finally { + unsubscribe(); + } + }); + + test("routine evaluations are recorded for audit and visible through get() and listForStrip() (SF7)", () => { + const { store, session } = assessedStore(); + expect(store.recordRoutineEvaluation(session.id, assessedAsk)).toBe(true); + expect(store.recordRoutineEvaluation(session.id, assessedAsk)).toBe(true); + expect(store.get(session.id)?.routineEvaluations).toHaveLength(2); + expect(store.listForStrip()[0]?.routineEvaluations).toHaveLength(2); + }); + + test("routine evaluations keep only the most recent entries", () => { + const { store, session } = assessedStore(); + const total = ROUTINE_EVALUATIONS_MAX + 5; + for (let i = 0; i < total; i += 1) + store.recordRoutineEvaluation(session.id, { + ...assessedAsk, + blockedOutcome: `routine-${i}`, + }); + const kept = store.get(session.id)?.routineEvaluations ?? []; + expect(kept).toHaveLength(ROUTINE_EVALUATIONS_MAX); + expect(kept[0]?.blockedOutcome).toBe( + `routine-${total - ROUTINE_EVALUATIONS_MAX}`, + ); + expect(kept.at(-1)?.blockedOutcome).toBe(`routine-${total - 1}`); + }); + + test("a minimum_grant_available resolution leaves the denied-call envelope unconsumed (SF8)", async () => { + const { createDeniedCallEnvelope, getProcessWorkerGrantStore } = + await requireGrantStore(); + getProcessWorkerGrantStore().clear(); + const { store, session } = assessedStore(); + const envelope = getProcessWorkerGrantStore().register( + createDeniedCallEnvelope({ + callId: "c1", + tool: "run_shell", + subject: "npm test", + args: { command: "npm test" }, + cwd: process.cwd(), + workerSessionId: session.id, + }), + ); + store.registerAsk(session.id, { + question: "blocked", + questionId: "ask-1", + assessment: assessedAsk, + grantRequestId: envelope.requestId, + resolve: () => undefined, + reject: () => undefined, + }); + expect(store.peekAsk(session.id)?.deniedCall?.requestId).toBe( + envelope.requestId, + ); + expect( + store.resolveEscalationAsk(session.id, { + kind: "minimum_grant_available", + answer: "continue", + questionId: "ask-1", + }), + ).toBe(true); + // The envelope is still pending: the decision delivered text only and + // neither widened the grant nor retried the denied call. + expect(getProcessWorkerGrantStore().peek(envelope.requestId)?.status).toBe( + "pending", + ); + expect( + getProcessWorkerGrantStore().peek(envelope.requestId)?.questionId, + ).toBe("ask-1"); + }); +}); + +async function requireGrantStore() { + return await import("../permission/worker-grant.js"); +} + describe("CL-7344 follow-up stash", () => { test("CL-7989 run-completion wins with queued steers: all deliver in order and the original report survives", async () => { const store = createSubAgentSessionStore(); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index ab976b3f6..368eddd9a 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -22,6 +22,13 @@ import { type WorkerDeniedCallEnvelope, } from "../permission/worker-grant.js"; import type { AdmissionQueue, AdmissionStatus } from "./admission.js"; +import type { + EscalationAssessment, + EscalationResolution, + TerminalOutcome, + VerifiedOutcome, +} from "./escalation-policy.js"; +import { deriveTerminalOutcome } from "./escalation-policy.js"; const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "session-store"]); @@ -77,6 +84,11 @@ export interface OutstandingToolCall { preview: string | null; } +// Routine asks bypass the question cap, so a chatty worker could otherwise grow +// this audit log for the life of the session. Recent entries are enough to +// audit what the worker was told to continue with. +export const ROUTINE_EVALUATIONS_MAX = 50; + export interface SubAgentSession { id: string; description: string; @@ -88,6 +100,10 @@ export interface SubAgentSession { * independently. */ lifecycle: WorkerLifecycle; toolNames: string[]; + /** Durable policy outcome; never inferred from report Markdown. */ + terminalOutcome?: TerminalOutcome; + /** Routine escalation evaluations kept for audit, newest ROUTINE_EVALUATIONS_MAX only. */ + routineEvaluations?: readonly EscalationAssessment[]; // Oldest outstanding call; its start clock separates wedged from busy. // Derived from `outstandingTools`; never assign directly. currentToolName: string | null; @@ -257,6 +273,7 @@ export interface SubAgentSessionStore { ask: { question: string; questionId: string; + assessment: EscalationAssessment; /** Grant requestId quoted from the deny reason: binds this ask to * its own denial instead of the session's first pending envelope. */ grantRequestId?: string; @@ -265,12 +282,34 @@ export interface SubAgentSessionStore { }, ): boolean; resolveAsk(id: string, answer: string): boolean; + resolveEscalationAsk( + id: string, + resolution: EscalationResolution, + note?: string, + ): boolean; + /** Records a routine evaluation for audit without registering or waking. */ + recordRoutineEvaluation( + id: string, + assessment: EscalationAssessment, + ): boolean; + /** + * Replaces a `verification_blocked` terminal outcome only after the worker + * explicitly reports a successful verification through the typed contract. + * Refuses when no blocked outcome exists, so a decision grant can never + * silently clear a blocker. + * + * No production caller yet: the runtime has no typed "worker verified" + * signal to drive it, so a blocked outcome stays blocked until CL-10568 + * picks one. + */ + reportVerifiedOutcome(id: string, outcome: VerifiedOutcome): boolean; cancelAsk(id: string, reason?: string): boolean; hasPendingAsk(id: string): boolean; peekAsk(id: string): | { question: string; questionId: string; + assessment?: EscalationAssessment; deniedCall?: WorkerDeniedCallEnvelope; } | undefined; @@ -538,6 +577,7 @@ export function createSubAgentSessionStore( { question: string; questionId: string; + assessment: EscalationAssessment; resolve: (answer: string) => void; reject: (reason: unknown) => void; // Registration clock for the ask deadline: an ask parked past the bound @@ -1803,6 +1843,7 @@ export function createSubAgentSessionStore( ask: { question: string; questionId: string; + assessment: EscalationAssessment; /** Grant requestId quoted from the deny reason: binds this ask to * its own denial instead of the session's first pending envelope. */ grantRequestId?: string; @@ -1830,12 +1871,13 @@ export function createSubAgentSessionStore( } catch { // Envelope attach must not fail ask registration. } - pendingAsks.set( - id, - deniedCall !== undefined - ? { ...ask, askedAt: now(), deniedCall } - : { ...ask, askedAt: now() }, - ); + // Every ask carries its assessment, so peekAsk and the wake surface + // policy facts for all of them. + pendingAsks.set(id, { + ...ask, + askedAt: now(), + ...(deniedCall !== undefined ? { deniedCall } : {}), + }); mutate(id, () => undefined); return true; }, @@ -1849,6 +1891,64 @@ export function createSubAgentSessionStore( return true; }, + resolveEscalationAsk( + id: string, + resolution: EscalationResolution, + note?: string, + ): boolean { + const pending = pendingAsks.get(id); + if (pending === undefined) return false; + // CL-8016: asks expire on a deadline, so a late answer meant for an + // earlier question must never settle a newer one. + if (pending.questionId !== resolution.questionId) return false; + pendingAsks.delete(id); + const terminalOutcome = deriveTerminalOutcome( + pending.assessment, + resolution, + ); + let notified = false; + if (terminalOutcome !== undefined) { + // mutate() notifies; keep the settlement to exactly one subscriber wake. + mutate(id, (session) => { + session.terminalOutcome = terminalOutcome; + }); + notified = true; + } + pending.resolve( + note === undefined + ? resolution.answer + : `${note}\n${resolution.answer}`, + ); + if (!notified) notify(); + return true; + }, + + recordRoutineEvaluation( + id: string, + assessment: EscalationAssessment, + ): boolean { + const session = sessions.get(id); + if (session === undefined) return false; + mutate(id, (target) => { + target.routineEvaluations = [ + ...(target.routineEvaluations ?? []), + assessment, + ].slice(-ROUTINE_EVALUATIONS_MAX); + }); + return true; + }, + + reportVerifiedOutcome(id: string, outcome: VerifiedOutcome): boolean { + const session = sessions.get(id); + if (session === undefined) return false; + if (session.terminalOutcome?.kind !== "verification_blocked") + return false; + mutate(id, (target) => { + target.terminalOutcome = outcome; + }); + return true; + }, + cancelAsk(id: string, reason = "ask_director cancelled"): boolean { return cancelAskInternal(id, reason); }, @@ -1861,6 +1961,7 @@ export function createSubAgentSessionStore( | { question: string; questionId: string; + assessment?: EscalationAssessment; deniedCall?: WorkerDeniedCallEnvelope; } | undefined { @@ -1869,6 +1970,7 @@ export function createSubAgentSessionStore( return { question: pending.question, questionId: pending.questionId, + assessment: pending.assessment, ...(pending.deniedCall !== undefined ? { deniedCall: pending.deniedCall } : {}), @@ -2279,6 +2381,12 @@ function cloneSession( ...(session.stopReason !== undefined ? { stopReason: session.stopReason } : {}), + ...(session.terminalOutcome !== undefined + ? { terminalOutcome: session.terminalOutcome } + : {}), + ...(session.routineEvaluations !== undefined + ? { routineEvaluations: [...session.routineEvaluations] } + : {}), ...(session.parentSessionId !== undefined ? { parentSessionId: session.parentSessionId } : {}), diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 2c9d33ed0..d0baee42b 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -20,6 +20,7 @@ import type { TaskIntent } from "./report.js"; import type { SubagentTier } from "../agent/directors/types.js"; import type { ForcedStopReason } from "./stop-policy.js"; import type { AdmissionQueue } from "./admission.js"; +import type { EscalationAssessment } from "./escalation-policy.js"; import type { SkillSummary } from "../extensions/skills.js"; export interface SubAgentProvider { @@ -234,9 +235,12 @@ export type RunSubAgentParams = { register: (input: { question: string; questionId: string; + assessment: EscalationAssessment; grantRequestId?: string; }) => Promise; cancel: (reason: string) => void; + /** Routine evaluations are recorded for audit; they never register or wake. */ + recordRoutineEvaluation?: (assessment: EscalationAssessment) => void; }; /** * Fired once the agent object exists (before the prompt is sent), with diff --git a/src/tui/runner/wiring.ask-wake.test.ts b/src/tui/runner/wiring.ask-wake.test.ts index 1cf7d90da..22ec1b832 100644 --- a/src/tui/runner/wiring.ask-wake.test.ts +++ b/src/tui/runner/wiring.ask-wake.test.ts @@ -26,6 +26,7 @@ import { createLeftoverSend, createSessionOperationQueue, } from "../delivery-queue.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../../../testkit/escalation.js"; test("failed reset releases publication without flushing partially cancelled workers", () => { const store = createSubAgentSessionStore(); @@ -45,6 +46,7 @@ test("failed reset releases publication without flushing partially cancelled wor store.registerAsk("old", { question: "Old question?", questionId: "old-question", + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: () => undefined, reject: () => undefined, }); @@ -199,6 +201,7 @@ for (const phase of ["settled", "prequeued", "deferred"] as const) { store.registerAsk(id, { question: `question ${id}`, questionId: `question-${id}`, + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: () => undefined, reject: () => undefined, }); @@ -305,6 +308,7 @@ for (const removal of [ store.registerAsk(worker.id, { question: "Which port?", questionId: "q1", + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: () => undefined, reject: () => undefined, }); @@ -393,6 +397,7 @@ test("same catalog workers answer by session, reconcile one resolution and repla store.registerAsk(id, { question: questionId, questionId, + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: (answer) => { answers.push(`${id}:${answer}`); }, @@ -485,6 +490,7 @@ test("yield wait does not stamp; sending pendingAskWakeText gates list_agents", store.registerAsk(worker.id, { question: "Which port?", questionId: "q1", + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: () => undefined, reject: () => undefined, }); @@ -592,6 +598,7 @@ async function withStripRig(fn: (rig: StripRig) => Promise) { store.registerAsk(id, { question, questionId, + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: (answer) => { answers.push(`${id}:${answer}`); }, @@ -636,7 +643,9 @@ test("the strip follows agent-ask snapshots while the wake reaches the director park("worker-session", "q1", "Which destination path should I use?"); const parked = await stripText(h); expect(parked).toContain("WORKER WAITING"); - expect(parked).toContain("Which destination path should I use?"); + // The panel summary leads with the question; the strip may truncate the + // trailing facts (and the final "?") at narrow widths. + expect(parked).toContain("Which destination path should I use"); expect(toPrimary).toEqual([]); // The primary settles: the wake goes to the director, the strip stays. diff --git a/src/tui/runner/wiring.stall-bound.test.ts b/src/tui/runner/wiring.stall-bound.test.ts index 7aba95c97..71e296740 100644 --- a/src/tui/runner/wiring.stall-bound.test.ts +++ b/src/tui/runner/wiring.stall-bound.test.ts @@ -19,6 +19,7 @@ import { type SubAgentSessionStore, } from "../../subagent/session-store.js"; import { cancelWorkersForStop, createFleetStallPollTick } from "./wiring.js"; +import { DIRECTOR_RESOLVABLE_ESCALATION } from "../../../testkit/escalation.js"; // A silent primary turn (wake sent, inference never starts) must not freeze // the queue and parked questions forever: the stall poll aborts it past the @@ -59,6 +60,7 @@ function parkWorker(store: SubAgentSessionStore, tag: string): ParkedWorker { const registered = store.registerAsk(session.id, { question: worker.wake.question, questionId: worker.wake.questionId, + assessment: DIRECTOR_RESOLVABLE_ESCALATION, resolve: (answer: string) => { worker.resolved.push(answer); }, diff --git a/src/tui/worker-wait.test.ts b/src/tui/worker-wait.test.ts index 058a6b9eb..51a51a6a7 100644 --- a/src/tui/worker-wait.test.ts +++ b/src/tui/worker-wait.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { PendingAskWake } from "../subagent/fleet-report.js"; +import { ESCALATION_POLICY_VERSION } from "../subagent/escalation-policy.js"; import { stringWidth } from "./view/height.js"; import { composeWorkerWaitLine, @@ -8,6 +9,7 @@ import { reduceWorkerWait, selectedWorkerWait, workerWaitMoreCount, + workerWaitDecisionSummary, type WorkerWaitPart, type WorkerWaitState, } from "./worker-wait.js"; @@ -173,6 +175,82 @@ describe("worker wait strip copy", () => { expect(roles.get("routing")).toContain("director"); }); + test("an assessed decision shows the question plus actionable policy detail without changing its identity", () => { + const assessed: PendingAskWake = { + ...ask("sess-assessed", "q1", "Which branch should I target?"), + assessment: { + policyVersion: ESCALATION_POLICY_VERSION, + classification: "operator_decision_required", + blockedOutcome: "cannot verify authenticated release status", + unavailableDirectorPath: "director lacks authenticated read access", + permittedAlternatives: [ + { + attempted: "checked cached status", + result: "snapshot is stale", + comparableConfidence: false, + }, + ], + minimumAddition: "authenticated read access", + requestedMechanism: "broad web access", + minimumAuthority: "authenticated release-status read", + declineConsequence: "release verification remains blocked", + recommendation: "grant the narrow read", + safeDefault: "do not publish", + }, + }; + const state = fold([assessed]); + const item = selectedWorkerWait(state); + expect(item).not.toBeNull(); + if (item === null) throw new Error("expected assessed worker wait item"); + expect(workerWaitDecisionSummary(item)).toContain( + "Which branch should I target?", + ); + expect(workerWaitDecisionSummary(item)).toContain( + "classification: operator_decision_required", + ); + const line = text(composeWorkerWaitLine(state, 1_000)); + expect(line).toContain("Which branch should I target?"); + expect(line).toContain( + "outcome: cannot verify authenticated release status", + ); + expect(line).toContain("minimum: authenticated release-status read"); + expect(line).toContain("consequence: release verification remains blocked"); + expect(line).toContain("recommendation: grant the narrow read"); + expect(line).toContain("target: sess-assessed"); + expect(reduceWorkerWait(state, [assessed])).toBe(state); + }); + + test("a serialized copy of an unchanged assessment is not a change, an edited one is", () => { + const assessed: PendingAskWake = { + ...ask("sess-copy", "q1"), + assessment: { + policyVersion: ESCALATION_POLICY_VERSION, + classification: "director_resolvable", + blockedOutcome: "cannot choose a branch", + unavailableDirectorPath: "no branch map", + permittedAlternatives: [ + { + attempted: "list", + result: "ambiguous", + comparableConfidence: false, + }, + ], + minimumAddition: "a branch decision", + declineConsequence: "stay on current branch", + }, + }; + const state = fold([assessed]); + const copy = structuredClone(assessed); + expect(reduceWorkerWait(state, [copy])).toBe(state); + const edited = structuredClone(assessed); + if (edited.assessment === undefined) throw new Error("fixture"); + const changed = { + ...edited, + assessment: { ...edited.assessment, blockedOutcome: "different" }, + }; + expect(reduceWorkerWait(state, [changed])).not.toBe(state); + }); + test("every width fits the strip inside its row", () => { const many = fold([ ask("s1", "q1", "A long question ".repeat(12)), diff --git a/src/tui/worker-wait.ts b/src/tui/worker-wait.ts index 487960974..461b3b90d 100644 --- a/src/tui/worker-wait.ts +++ b/src/tui/worker-wait.ts @@ -14,7 +14,10 @@ * shell/chrome.ts; the row budget lives in geometry (zone `worker_wait`). */ +import { isDeepStrictEqual } from "node:util"; + import type { PendingAskWake } from "../subagent/fleet-report.js"; +import type { EscalationAssessment } from "../subagent/escalation-policy.js"; import { stripTerminalControlSequences } from "../util/control-char-strip.js"; import { sliceToWidth, stringWidth } from "./view/height.js"; @@ -26,6 +29,8 @@ export interface WorkerWaitItem { readonly agentId: string; readonly description: string; readonly question: string; + /** Immutable policy detail for this live question; display only. */ + readonly assessment?: EscalationAssessment; } export interface WorkerWaitState { @@ -42,11 +47,14 @@ function identityKey(sessionId: string, questionId: string): string { } function sameItem(a: WorkerWaitItem, b: WorkerWaitItem): boolean { + // Compared by value: a snapshot that round-trips serialization carries an + // equal but distinct object, and that must not repaint the strip. return ( a.key === b.key && a.agentId === b.agentId && a.description === b.description && - a.question === b.question + a.question === b.question && + isDeepStrictEqual(a.assessment, b.assessment) ); } @@ -74,6 +82,7 @@ export function reduceWorkerWait( agentId: ask.agentId, description: ask.description, question: ask.question, + ...(ask.assessment === undefined ? {} : { assessment: ask.assessment }), }); } const items = [...bySession.values()]; @@ -128,6 +137,24 @@ export function oneLinePreview(text: string): string { return stripTerminalControlSequences(text).replace(/\s+/g, " ").trim(); } +/** Concise assessed-decision detail for the live panel; it never routes or resolves. */ +export function workerWaitDecisionSummary(item: WorkerWaitItem): string { + const assessment = item.assessment; + if (assessment === undefined) return item.question; + const recommendation = assessment.recommendation ?? assessment.safeDefault; + return [ + item.question, + `classification: ${assessment.classification}`, + `outcome: ${assessment.blockedOutcome}`, + `minimum: ${assessment.minimumAuthority ?? assessment.minimumAddition}`, + `consequence: ${assessment.declineConsequence}`, + ...(recommendation === undefined + ? [] + : [`recommendation: ${recommendation}`]), + `target: ${item.sessionId}`, + ].join("; "); +} + const MARK = "◆"; const LABEL = "WORKER WAITING"; const LABEL_COMPACT = "WAITING"; @@ -201,7 +228,7 @@ export function composeWorkerWaitLine( if (item === null || width <= 0) return []; const agentId = oneLinePreview(item.agentId); const description = oneLinePreview(item.description); - const question = oneLinePreview(item.question); + const question = oneLinePreview(workerWaitDecisionSummary(item)); const requesterFull = description.length > 0 && description !== agentId ? agentId.length > 0 diff --git a/testkit/escalation.ts b/testkit/escalation.ts new file mode 100644 index 000000000..30769c175 --- /dev/null +++ b/testkit/escalation.ts @@ -0,0 +1,18 @@ +// ask_director rejects a call without an escalation payload, so every +// scripted ask that should park carries this director_resolvable one. + +export const DIRECTOR_RESOLVABLE_ESCALATION = { + policyVersion: "1", + classification: "director_resolvable", + blockedOutcome: "cannot choose a target branch", + unavailableDirectorPath: "the director has no branch map", + permittedAlternatives: [ + { + attempted: "check the branch list", + result: "ambiguous", + comparableConfidence: false, + }, + ], + minimumAddition: "a branch decision", + declineConsequence: "implementation stays on the current branch", +} as const;