Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/eve/src/harness/input-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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),
Expand All @@ -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"
Expand All @@ -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,
Expand All @@ -303,6 +315,7 @@ function rejectWithoutCandidate(
): PendingApprovalAuthorizationResult {
return {
kind: "rejected",
requestId,
safeReason,
session: input.session,
stepInput: removeInputResponse(input.stepInput, requestId),
Expand All @@ -320,6 +333,7 @@ function failCandidate(input: {
return {
candidateId: input.candidateId,
kind: "failed",
requestId: input.requestId,
safeReason: input.safeReason,
session: {
...input.session,
Expand Down
39 changes: 39 additions & 0 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down
45 changes: 45 additions & 0 deletions packages/eve/src/protocol/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/eve/src/public/definitions/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ type ChannelSessionFailedHandler<TCtx> = (
* and the channel context, with no `ctx`.
*/
export interface ChannelEvents<TCtx = void> {
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>;
Expand Down Expand Up @@ -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<keyof ChannelEvents, null> = {
"approval.candidate": null,
"approval.settled": null,
"turn.started": null,
"actions.requested": null,
"action.result": null,
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/public/definitions/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type ProtocolEvent<TType extends HandleMessageStreamEvent["type"]> = 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">;
Expand Down