diff --git a/packages/eve/src/harness/approval-candidates.ts b/packages/eve/src/harness/approval-candidates.ts index 78768b970..218595531 100644 --- a/packages/eve/src/harness/approval-candidates.ts +++ b/packages/eve/src/harness/approval-candidates.ts @@ -38,6 +38,7 @@ export interface ApprovalSettlementAuditRecord { readonly requestId: string; readonly settledAt: number; readonly candidateId?: string; + readonly eventEmitted?: boolean; } interface ActiveApprovalCandidate { @@ -47,6 +48,7 @@ interface ActiveApprovalCandidate { readonly status: "pending" | "authorization-required"; readonly authorizationName?: string; readonly createdAt: number; + readonly pendingEventEmitted?: boolean; readonly expiresAt: number; readonly provider?: string; readonly runtimeRevision?: string; @@ -116,6 +118,40 @@ export function createApprovalCandidate(input: { }; } +/** Marks the pending candidate event as emitted. */ +export function markApprovalCandidatePendingEventEmitted(input: { + readonly candidateId: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined || candidate.pendingEventEmitted === true) return input.state; + return writeApprovalState(input.state, { + ...approvalState, + activeCandidates: { + ...approvalState.activeCandidates, + [input.candidateId]: { ...candidate, pendingEventEmitted: true }, + }, + }); +} + +/** Marks a terminal settlement event as emitted. */ +export function markApprovalSettlementEventEmitted(input: { + readonly requestId: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const settlement = approvalState.settlements[input.requestId]; + if (settlement === undefined || settlement.eventEmitted === true) return input.state; + return writeApprovalState(input.state, { + ...approvalState, + settlements: { + ...approvalState.settlements, + [input.requestId]: { ...settlement, eventEmitted: true }, + }, + }); +} + /** Marks a candidate as waiting on a private authorization challenge. */ export function markApprovalCandidateAuthorizationRequired(input: { readonly authorizationName: string; diff --git a/packages/eve/src/harness/authorize-approval-response.ts b/packages/eve/src/harness/authorize-approval-response.ts index 936686fd5..7947841d3 100644 --- a/packages/eve/src/harness/authorize-approval-response.ts +++ b/packages/eve/src/harness/authorize-approval-response.ts @@ -33,6 +33,12 @@ const APPROVAL_CANDIDATE_TTL_MS = 10 * 60_000; export type PendingApprovalAuthorizationResult = | { readonly kind: "continue"; readonly session: HarnessSession; readonly stepInput?: StepInput } + | { + readonly candidateId: string; + readonly kind: "candidate-created"; + readonly requestId: string; + readonly session: HarnessSession; + } | { readonly authorization: AuthorizationSignal; readonly candidateId: string; @@ -68,9 +74,10 @@ export async function authorizePendingApprovalResponse(input: { const batch = getPendingInputBatch(input.session.state); const activeCandidate = getApprovalAuditState(input.session.state).activeCandidates.find( (candidate) => - candidate.status === "authorization-required" && - candidate.authorizationName !== undefined && - getAuthorizationResult(candidate.authorizationName) !== undefined, + (candidate.status === "pending" && candidate.pendingEventEmitted === true) || + (candidate.status === "authorization-required" && + candidate.authorizationName !== undefined && + getAuthorizationResult(candidate.authorizationName) !== undefined), ); const response = input.stepInput?.inputResponses?.find((entry) => @@ -147,6 +154,14 @@ export async function authorizePendingApprovalResponse(input: { state: input.session.state, }); let session = { ...input.session, state: created.state }; + if (created.result.kind === "created") { + return { + candidateId, + kind: "candidate-created", + requestId: response.requestId, + session, + }; + } if ( activeCandidate === undefined && (created.result.kind === "duplicate" || created.result.kind === "stale") diff --git a/packages/eve/src/harness/input-requests.test.ts b/packages/eve/src/harness/input-requests.test.ts index 7b5c46d6c..55225e86a 100644 --- a/packages/eve/src/harness/input-requests.test.ts +++ b/packages/eve/src/harness/input-requests.test.ts @@ -912,8 +912,37 @@ describe("authorizePendingApprovalResponse", () => { return contextStorage.run(ctx, run); } + async function continueCreatedCandidate( + result: Awaited>, + tools: HarnessToolMap, + ) { + if (result.kind !== "candidate-created") return result; + const state = result.session.state?.["eve.runtime.hitl.approvalState"] as { + activeCandidates: Record>; + }; + const activeCandidates = Object.fromEntries( + Object.entries(state.activeCandidates).map(([id, candidate]) => [ + id, + { ...candidate, pendingEventEmitted: true }, + ]), + ); + return await approvalContext(() => + authorizePendingApprovalResponse({ + now: 101, + session: { + ...result.session, + state: { + ...result.session.state, + "eve.runtime.hitl.approvalState": { ...state, activeCandidates }, + }, + }, + tools, + }), + ); + } + it("fails closed when a required authorizer is missing", async () => { - const result = await approvalContext(() => + let result = await approvalContext(() => authorizePendingApprovalResponse({ now: 100, session: pendingSession(), @@ -923,11 +952,11 @@ describe("authorizePendingApprovalResponse", () => { tools: new Map(), }), ); + result = await continueCreatedCandidate(result, new Map()); expect(result).toMatchObject({ kind: "failed", safeReason: "Approval authorization is temporarily unavailable. Please try again.", - stepInput: { inputResponses: [] }, }); expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved"); }); @@ -950,7 +979,7 @@ describe("authorizePendingApprovalResponse", () => { }, ], ]); - const result = await approvalContext(() => + let result = await approvalContext(() => authorizePendingApprovalResponse({ now: 100, session: pendingSession(), @@ -960,11 +989,11 @@ describe("authorizePendingApprovalResponse", () => { tools, }), ); + result = await continueCreatedCandidate(result, tools); expect(result).toMatchObject({ kind: "rejected", safeReason: "U1 lacks repository write access.", - stepInput: { inputResponses: [] }, }); expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved"); }); @@ -984,7 +1013,7 @@ describe("authorizePendingApprovalResponse", () => { }, ], ]); - const result = await approvalContext(() => + let result = await approvalContext(() => authorizePendingApprovalResponse({ now: 100, session: pendingSession(), @@ -994,6 +1023,7 @@ describe("authorizePendingApprovalResponse", () => { tools, }), ); + result = await continueCreatedCandidate(result, tools); expect(result.kind).toBe("continue"); if (result.kind !== "continue") throw new Error("Expected allowed candidate."); @@ -1058,7 +1088,7 @@ describe("authorizePendingApprovalResponse", () => { }, ], ]); - const result = await approvalContext(() => + let result = await approvalContext(() => authorizePendingApprovalResponse({ now: 100, session, @@ -1071,6 +1101,7 @@ describe("authorizePendingApprovalResponse", () => { tools, }), ); + result = await continueCreatedCandidate(result, tools); expect(result.kind).toBe("continue"); if (result.kind !== "continue") throw new Error("Expected first candidate to settle."); diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index c47136ccd..f0884537f 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -104,7 +104,11 @@ import { } from "#harness/input-extraction.js"; import { createToolResultMessagePartFromToolError } from "#harness/action-result-helpers.js"; import { buildTelemetryRuntimeContext } from "#harness/instrumentation-runtime-context.js"; -import { getApprovalAuditState } from "#harness/approval-candidates.js"; +import { + getApprovalAuditState, + markApprovalCandidatePendingEventEmitted, + markApprovalSettlementEventEmitted, +} from "#harness/approval-candidates.js"; import { authorizePendingApprovalResponse } from "#harness/authorize-approval-response.js"; import { consumeDeferredStepInput, @@ -574,11 +578,44 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { tools: responseAuthorizationTools, }); session = authorized.session; - if (emit && authorized.kind !== "continue" && authorized.kind !== "duplicate") { + if (authorized.kind === "candidate-created") { + return { next: runStep, session }; + } + + const pendingCandidateEvent = getApprovalAuditState(session.state).activeCandidates.find( + (candidate) => candidate.pendingEventEmitted !== true, + ); + if (emit && pendingCandidateEvent !== undefined) { + await emit( + createApprovalCandidateEvent({ + candidateId: pendingCandidateEvent.candidateId, + outcome: "pending", + requestId: pendingCandidateEvent.requestId, + responderPrincipalId: pendingCandidateEvent.responder.principalId, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + session = { + ...session, + state: markApprovalCandidatePendingEventEmitted({ + candidateId: pendingCandidateEvent.candidateId, + state: session.state, + }), + }; + } + + if ( + emit && + authorized.kind !== "continue" && + authorized.kind !== "duplicate" && + authorized.kind !== "authorization-required" + ) { await emit( createApprovalCandidateEvent({ candidateId: authorized.candidateId ?? `stale:${authorized.requestId}`, - outcome: authorized.kind === "authorization-required" ? "pending" : authorized.kind, + outcome: authorized.kind, requestId: authorized.requestId, responderPrincipalId: [ @@ -621,27 +658,28 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { }; } - if (emit && authorized.kind === "continue") { - const response = authorized.stepInput?.inputResponses?.find( - (entry) => entry.optionId === "approve" || entry.optionId === "cancel", + const pendingSettlementEvent = getApprovalAuditState(session.state).settlements.find( + (settlement) => settlement.eventEmitted !== true, + ); + if (emit && pendingSettlementEvent !== undefined) { + await emit( + createApprovalSettledEvent({ + outcome: pendingSettlementEvent.outcome === "allowed" ? "approved" : "cancelled", + requestId: pendingSettlementEvent.requestId, + responderPrincipalId: pendingSettlementEvent.actor.principalId, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), ); - if (response !== undefined) { - const settlement = getApprovalAuditState(session.state).settlements.find( - (entry) => entry.requestId === response.requestId, - ); - if (settlement !== undefined) { - await emit( - createApprovalSettledEvent({ - outcome: settlement.outcome === "allowed" ? "approved" : "cancelled", - requestId: settlement.requestId, - responderPrincipalId: settlement.actor.principalId, - sequence: emissionState.sequence, - stepIndex: emissionState.stepIndex, - turnId: emissionState.turnId, - }), - ); - } - } + session = { + ...session, + state: markApprovalSettlementEventEmitted({ + requestId: pendingSettlementEvent.requestId, + state: session.state, + }), + }; + return { next: runStep, session }; } const pending = resolvePendingInput({