From c3a20448f590809fbb4ab97f28fbe4343a291453 Mon Sep 17 00:00:00 2001 From: benpankow Date: Wed, 29 Jul 2026 12:43:25 -0700 Subject: [PATCH] feat(eve): emit approval candidate lifecycle events Signed-off-by: benpankow --- packages/eve/src/harness/input-requests.ts | 16 ++++++- packages/eve/src/harness/tool-loop.ts | 39 ++++++++++++++++ packages/eve/src/protocol/message.ts | 45 +++++++++++++++++++ .../eve/src/public/definitions/channel.ts | 4 ++ packages/eve/src/public/definitions/hook.ts | 2 + 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/packages/eve/src/harness/input-requests.ts b/packages/eve/src/harness/input-requests.ts index 2779a019f..95135da7e 100644 --- a/packages/eve/src/harness/input-requests.ts +++ b/packages/eve/src/harness/input-requests.ts @@ -86,11 +86,13 @@ export type PendingApprovalAuthorizationResult = readonly authorization: AuthorizationSignal; readonly candidateId: string; readonly kind: "authorization-required"; + readonly requestId: string; readonly session: HarnessSession; } | { readonly candidateId?: string; readonly kind: "rejected" | "duplicate" | "stale" | "failed"; + readonly requestId: string; readonly safeReason?: string; readonly session: HarnessSession; readonly stepInput?: StepInput; @@ -152,6 +154,7 @@ async function authorizePendingApprovalResponseInternal(input: { }); return { kind: settled.result.kind === "settled" ? "continue" : "stale", + requestId: response.requestId, session: { ...input.session, state: settled.state }, stepInput: settled.result.kind === "settled" @@ -196,6 +199,7 @@ async function authorizePendingApprovalResponseInternal(input: { return { candidateId: created.result.kind === "duplicate" ? candidateId : undefined, kind: created.result.kind, + requestId: response.requestId, session, stepInput: removeInputResponse(input.stepInput, response.requestId), }; @@ -251,6 +255,7 @@ async function authorizePendingApprovalResponseInternal(input: { return { candidateId, kind: "rejected", + requestId: response.requestId, safeReason: outcome.safeReason, session, stepInput: removeInputResponse(input.stepInput, response.requestId), @@ -259,6 +264,7 @@ async function authorizePendingApprovalResponseInternal(input: { const settled = settleAllowedCandidate({ candidateId, settledAt: now, state: session.state }); return { kind: settled.result.kind === "settled" ? "continue" : "stale", + requestId: response.requestId, session: { ...session, state: settled.state }, stepInput: settled.result.kind === "settled" @@ -283,7 +289,13 @@ async function authorizePendingApprovalResponseInternal(input: { state: session.state, }), }; - return { authorization, candidateId, kind: "authorization-required", session }; + return { + authorization, + candidateId, + kind: "authorization-required", + requestId: response.requestId, + session, + }; } return failCandidate({ candidateId, @@ -303,6 +315,7 @@ function rejectWithoutCandidate( ): PendingApprovalAuthorizationResult { return { kind: "rejected", + requestId, safeReason, session: input.session, stepInput: removeInputResponse(input.stepInput, requestId), @@ -320,6 +333,7 @@ function failCandidate(input: { return { candidateId: input.candidateId, kind: "failed", + requestId: input.requestId, safeReason: input.safeReason, session: { ...input.session, diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index f7c99b0a4..d3e23e2ac 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -37,6 +37,8 @@ import { PendingSkillAnnouncementKey } from "#context/dynamic-skill-lifecycle.js import { toErrorMessage } from "#shared/errors.js"; import { createActionResultEvent, + createApprovalCandidateEvent, + createApprovalSettledEvent, createCompactionCompletedEvent, createCompactionRequestedEvent, createInputRequestedEvent, @@ -102,6 +104,7 @@ 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 { authorizePendingApprovalResponse, consumeDeferredStepInput, @@ -571,6 +574,19 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { tools: responseAuthorizationTools, }); session = authorized.session; + if (emit && authorized.kind !== "continue" && authorized.kind !== "duplicate") { + await emit( + createApprovalCandidateEvent({ + candidateId: authorized.candidateId ?? `stale:${authorized.requestId}`, + outcome: authorized.kind === "authorization-required" ? "pending" : authorized.kind, + requestId: authorized.requestId, + safeReason: "safeReason" in authorized ? authorized.safeReason : undefined, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + } if (authorized.kind === "authorization-required") { const { challenges } = authorized.authorization; if (emit) { @@ -598,6 +614,29 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { }; } + if (emit && authorized.kind === "continue") { + const response = authorized.stepInput?.inputResponses?.find( + (entry) => entry.optionId === "approve" || entry.optionId === "cancel", + ); + 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, + }), + ); + } + } + } + const pending = resolvePendingInput({ history: resolvedRuntimeActions.messages, resolveApprovalKey: resolveApprovalKeyFromTools(config.tools), diff --git a/packages/eve/src/protocol/message.ts b/packages/eve/src/protocol/message.ts index d782554f9..9cfaa4d31 100644 --- a/packages/eve/src/protocol/message.ts +++ b/packages/eve/src/protocol/message.ts @@ -213,6 +213,35 @@ export interface ActionsRequestedStreamEvent { type: "actions.requested"; } +export type ApprovalCandidateOutcome = "pending" | "rejected" | "failed" | "timed-out" | "stale"; + +/** Safe lifecycle event for one responder-bound approval candidate. */ +export interface ApprovalCandidateStreamEvent { + data: { + candidateId: string; + outcome: ApprovalCandidateOutcome; + requestId: string; + safeReason?: string; + sequence: number; + stepIndex: number; + turnId: string; + }; + type: "approval.candidate"; +} + +/** Terminal durable settlement for one approval request. */ +export interface ApprovalSettledStreamEvent { + data: { + outcome: "approved" | "cancelled"; + requestId: string; + responderPrincipalId: string; + sequence: number; + stepIndex: number; + turnId: string; + }; + type: "approval.settled"; +} + /** * Stream event emitted when the harness needs human input before it can * continue the run. @@ -584,6 +613,8 @@ export interface SessionCompletedStreamEvent { * Serializable stream event union for the durable message session flow. */ export type HandleMessageStreamEvent = ( + | ApprovalCandidateStreamEvent + | ApprovalSettledStreamEvent | CompactionCompletedStreamEvent | CompactionRequestedStreamEvent | AuthorizationCompletedStreamEvent @@ -998,6 +1029,20 @@ export function createAuthorizationCompletedEvent(input: { }; } +/** Creates a safe candidate lifecycle event. */ +export function createApprovalCandidateEvent( + input: ApprovalCandidateStreamEvent["data"], +): ApprovalCandidateStreamEvent { + return { data: input, type: "approval.candidate" }; +} + +/** Creates a terminal approval settlement event. */ +export function createApprovalSettledEvent( + input: ApprovalSettledStreamEvent["data"], +): ApprovalSettledStreamEvent { + return { data: input, type: "approval.settled" }; +} + /** * Creates the `input.requested` event for one pending HITL batch. */ diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index a96d8d01d..f42d255a0 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -236,6 +236,8 @@ type ChannelSessionFailedHandler = ( * and the channel context, with no `ctx`. */ export interface ChannelEvents { + readonly "approval.candidate"?: ChannelEventHandler<"approval.candidate", TCtx>; + readonly "approval.settled"?: ChannelEventHandler<"approval.settled", TCtx>; readonly "turn.started"?: ChannelEventHandler<"turn.started", TCtx>; readonly "actions.requested"?: ChannelEventHandler<"actions.requested", TCtx>; readonly "action.result"?: ChannelEventHandler<"action.result", TCtx>; @@ -339,6 +341,8 @@ export function defineChannel< // The Record type fails to compile if this map drifts from the ChannelEvents // keys in either direction. const channelEventTypes: Record = { + "approval.candidate": null, + "approval.settled": null, "turn.started": null, "actions.requested": null, "action.result": null, diff --git a/packages/eve/src/public/definitions/hook.ts b/packages/eve/src/public/definitions/hook.ts index 2a9abb4dd..3c4725096 100644 --- a/packages/eve/src/public/definitions/hook.ts +++ b/packages/eve/src/public/definitions/hook.ts @@ -16,6 +16,8 @@ type ProtocolEvent = Extract< */ export interface HookEventMap { readonly "action.result": ProtocolEvent<"action.result">; + readonly "approval.candidate": ProtocolEvent<"approval.candidate">; + readonly "approval.settled": ProtocolEvent<"approval.settled">; readonly "actions.requested": ProtocolEvent<"actions.requested">; readonly "authorization.completed": ProtocolEvent<"authorization.completed">; readonly "authorization.required": ProtocolEvent<"authorization.required">;