From 31cc744eea86065d09cac3744d6a0195cd8d4031 Mon Sep 17 00:00:00 2001 From: benpankow Date: Fri, 4 Sep 2026 12:05:00 -0700 Subject: [PATCH] feat(eve): add response release hooks Signed-off-by: benpankow --- .changeset/fuzzy-turns-rollback.md | 5 ++ docs/guides/hooks.md | 28 ++++++- .../compatibility/hook/v22.ts | 9 +++ .../extension-contracts/reports/hook/v23.json | 7 ++ .../src/compiler/extension-compatibility.ts | 4 +- packages/eve/src/compiler/normalize-hook.ts | 7 ++ .../hook-lifecycle.integration.test.ts | 28 ++++++- packages/eve/src/context/hook-lifecycle.ts | 21 ++++- packages/eve/src/execution/node-step.ts | 4 + .../response-release-event-gate.test.ts | 78 +++++++++++++++++++ .../execution/response-release-event-gate.ts | 70 +++++++++++++++++ packages/eve/src/execution/workflow-steps.ts | 11 ++- packages/eve/src/harness/tool-loop.test.ts | 45 ++++++++++- packages/eve/src/harness/tool-loop.ts | 58 +++++++++++--- packages/eve/src/harness/types.ts | 6 ++ packages/eve/src/public/definitions/hook.ts | 19 +++++ packages/eve/src/public/hooks/index.ts | 2 + .../eve/src/runtime/hooks/registry.test.ts | 10 ++- packages/eve/src/runtime/hooks/registry.ts | 14 +++- packages/eve/src/runtime/resolve-hook.test.ts | 11 +++ packages/eve/src/runtime/resolve-hook.ts | 11 ++- packages/eve/src/runtime/types.ts | 1 + 22 files changed, 427 insertions(+), 22 deletions(-) create mode 100644 .changeset/fuzzy-turns-rollback.md create mode 100644 packages/eve/extension-contracts/compatibility/hook/v22.ts create mode 100644 packages/eve/extension-contracts/reports/hook/v23.json create mode 100644 packages/eve/src/execution/response-release-event-gate.test.ts create mode 100644 packages/eve/src/execution/response-release-event-gate.ts diff --git a/.changeset/fuzzy-turns-rollback.md b/.changeset/fuzzy-turns-rollback.md new file mode 100644 index 0000000000..71e3f29e49 --- /dev/null +++ b/.changeset/fuzzy-turns-rollback.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Hooks can inspect a completed conversation response with `beforeResponseRelease` and return `"skip"` to suppress terminal channel delivery. Earlier events, model history, and external side effects remain unchanged. diff --git a/docs/guides/hooks.md b/docs/guides/hooks.md index a4523477a2..3b2f35007d 100644 --- a/docs/guides/hooks.md +++ b/docs/guides/hooks.md @@ -26,7 +26,33 @@ The slug is the path-relative basename. `agent/hooks/audit.ts` becomes `"audit"` `defineHook`, `HookDefinition`, and `HookContext` live on `eve/hooks`. -A hook file declares stream-event subscribers under the `events` map, keyed by event type, with `*` matching every event. Subscribe to any event in the runtime stream vocabulary documented in [Sessions, runs and streaming](../concepts/sessions-runs-and-streaming), including the lifecycle events `session.started`, `turn.completed`, `message.completed`, `action.partial`, and `action.result`. Handlers are observe-only. They cannot inject model context. To contribute runtime model messages, use `defineDynamic` and `defineInstructions` in `agent/instructions/`. +A hook file declares stream-event subscribers under the `events` map, keyed by event type, with `*` matching every event. Subscribe to any event in the runtime stream vocabulary documented in [Sessions, runs and streaming](../concepts/sessions-runs-and-streaming), including the lifecycle events `session.started`, `turn.completed`, `message.completed`, `action.partial`, and `action.result`. Stream-event handlers are observe-only. They cannot inject model context. To contribute runtime model messages, use `defineDynamic` and `defineInstructions` in `agent/instructions/`. + +## Gate terminal response release + +Use `beforeResponseRelease` when application policy must inspect a completed response before eve +releases its terminal completion to the channel: + +```ts title="agent/hooks/review.ts" +import { defineHook } from "eve/hooks"; + +export default defineHook({ + beforeResponseRelease(candidate) { + return shouldSuppress(candidate.history, candidate.output) ? "skip" : undefined; + }, +}); +``` + +The hook receives candidate model history, terminal output, and `turnId`. Returning `"skip"` +suppresses the withheld terminal `message.completed` event. Returning `undefined` releases it. If +several hooks are present, eve stops at the first `"skip"` decision. + +This is a response-release boundary, not a private execution environment. Earlier events +have already run through channel handlers, the durable stream, memory, instrumentation, and ordinary +hooks. Model providers, tools, external systems, sandboxes, subagents, and background tasks may also +retain or continue acting on candidate content. The hook itself cannot durably pause for HITL; use a +tool or workflow for durable human input, then inspect its model-visible record before response +release. ## Scope side effects to a channel diff --git a/packages/eve/extension-contracts/compatibility/hook/v22.ts b/packages/eve/extension-contracts/compatibility/hook/v22.ts new file mode 100644 index 0000000000..0c142b202e --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/hook/v22.ts @@ -0,0 +1,9 @@ +import { defineHook } from "#public/hooks/index.js"; + +export default defineHook({ + events: { + "turn.started"(event, ctx) { + console.info(event.meta.id, event.data.turnId, ctx.session.id); + }, + }, +}); diff --git a/packages/eve/extension-contracts/reports/hook/v23.json b/packages/eve/extension-contracts/reports/hook/v23.json new file mode 100644 index 0000000000..63ff7d8907 --- /dev/null +++ b/packages/eve/extension-contracts/reports/hook/v23.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "hook", + "epoch": 23, + "sha256": "bdc1bf4bf929bdd09c72c2863c2ff9ab4655338b779f117832fc6de9c8400f91", + "exports": ["defineHook"] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 4922160fa8..cd423b752b 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -87,8 +87,8 @@ const EXTENSION_CAPABILITY_CONTRACTS = { }, }, hook: { - current: 22, - supported: [10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22], + current: 23, + supported: [10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 23], dropped: { 1: "Model identity moved from session.started runtime metadata to step.started call attribution.", 2: "Model identity moved from session.started runtime metadata to step.started call attribution.", diff --git a/packages/eve/src/compiler/normalize-hook.ts b/packages/eve/src/compiler/normalize-hook.ts index 07bb80fe85..43c8c0049b 100644 --- a/packages/eve/src/compiler/normalize-hook.ts +++ b/packages/eve/src/compiler/normalize-hook.ts @@ -21,6 +21,13 @@ export async function compileHookEntry( }), `Expected the hook export "${source.exportName ?? "default"}" from "${source.logicalPath}" to return an object.`, ); + const beforeResponseRelease = loaded.beforeResponseRelease; + if (beforeResponseRelease !== undefined) { + expectFunction( + beforeResponseRelease, + `Expected the hook export "${source.exportName ?? "default"}" from "${source.logicalPath}" to provide a function for beforeResponseRelease.`, + ); + } const events = loaded.events === undefined ? {} diff --git a/packages/eve/src/context/hook-lifecycle.integration.test.ts b/packages/eve/src/context/hook-lifecycle.integration.test.ts index 4825de3aa8..19076b0712 100644 --- a/packages/eve/src/context/hook-lifecycle.integration.test.ts +++ b/packages/eve/src/context/hook-lifecycle.integration.test.ts @@ -10,7 +10,7 @@ import { import { stampTestEvent } from "#internal/testing/events.js"; import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; import { ContextContainer, contextStorage } from "./container.js"; -import { dispatchStreamEventHooks } from "./hook-lifecycle.js"; +import { dispatchBeforeResponseReleaseHooks, dispatchStreamEventHooks } from "./hook-lifecycle.js"; import { BundleKey, ChannelKey, @@ -55,6 +55,7 @@ function buildCtx(): ContextContainer { function hook(slug: string, hooks: Partial): ResolvedHookDefinition { return { + beforeResponseRelease: hooks.beforeResponseRelease, events: hooks.events ?? {}, exportName: undefined, logicalPath: `hooks/${slug}.ts`, @@ -64,6 +65,31 @@ function hook(slug: string, hooks: Partial): ResolvedHoo }; } +describe("dispatchBeforeResponseReleaseHooks", () => { + it("runs every pre-release hook in order", async () => { + const calls: string[] = []; + const registry = createRuntimeHookRegistry([ + hook("first", { beforeResponseRelease: async () => void calls.push("first") }), + hook("second", { beforeResponseRelease: async () => void calls.push("second") }), + ]); + const ctx = buildCtx(); + + await contextStorage.run(ctx, () => + dispatchBeforeResponseReleaseHooks({ + candidate: { + history: [], + output: "candidate", + turnId: "turn_0", + }, + ctx, + registry, + }), + ); + + expect(calls).toEqual(["first", "second"]); + }); +}); + describe("dispatchStreamEventHooks", () => { it("invokes typed then wildcard subscribers and propagates errors", async () => { const calls: string[] = []; diff --git a/packages/eve/src/context/hook-lifecycle.ts b/packages/eve/src/context/hook-lifecycle.ts index 553d382891..0c75a8183d 100644 --- a/packages/eve/src/context/hook-lifecycle.ts +++ b/packages/eve/src/context/hook-lifecycle.ts @@ -1,7 +1,7 @@ import { BoundaryHookError } from "#shared/boundary-hook-error.js"; import { getAdapterKind } from "#channel/adapter.js"; import type { MessageStreamEvent } from "#protocol/message.js"; -import type { HookContext } from "#public/definitions/hook.js"; +import type { HookContext, ResponseReleaseCandidate } from "#public/definitions/hook.js"; import type { RuntimeHookRegistry } from "#runtime/hooks/registry.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; import type { ContextContainer } from "./container.js"; @@ -42,6 +42,25 @@ export async function dispatchStreamEventHooks(input: { } } +/** Runs ordered pre-release hooks. */ +export async function dispatchBeforeResponseReleaseHooks(input: { + readonly candidate: ResponseReleaseCandidate; + readonly ctx: ContextContainer; + readonly registry: RuntimeHookRegistry; +}): Promise<"release" | "skip"> { + const hookCtx = buildHookContext(input.ctx); + for (const entry of input.registry.beforeResponseRelease) { + const decision: unknown = await entry.handler(input.candidate, hookCtx); + if (decision !== undefined && decision !== "skip") { + throw new Error( + `Hook "${entry.slug}" returned ${JSON.stringify(decision)} from beforeResponseRelease; expected undefined or "skip".`, + ); + } + if (decision === "skip") return "skip"; + } + return "release"; +} + /** Builds the {@link HookContext} surfaced to one handler. */ function buildHookContext(ctx: ContextContainer): HookContext { const bundle = ctx.require(BundleKey); diff --git a/packages/eve/src/execution/node-step.ts b/packages/eve/src/execution/node-step.ts index 766e8cabc5..b0641f38a0 100644 --- a/packages/eve/src/execution/node-step.ts +++ b/packages/eve/src/execution/node-step.ts @@ -72,6 +72,9 @@ export interface CreateExecutionNodeStepInput { */ readonly createRuntime: CreateRuntime; readonly handleEvent?: HandleEventFn; + readonly beforeResponseRelease?: Parameters< + typeof createToolLoopHarness + >[0]["beforeResponseRelease"]; readonly historyProjector?: HistoryViewProjector; readonly historyView?: PreparedHistoryView; readonly instrumentation: ExecutionInstrumentation | undefined; @@ -108,6 +111,7 @@ export function createExecutionNodeStep(input: CreateExecutionNodeStepInput): St compactOnly: input.compactOnly, workflow: input.node.agent.workflowTool !== undefined, workflowMaxSubagents: input.workflowMaxSubagents, + beforeResponseRelease: input.beforeResponseRelease, handleEvent: input.handleEvent, historyProjector: input.historyProjector, historyView: input.historyView, diff --git a/packages/eve/src/execution/response-release-event-gate.test.ts b/packages/eve/src/execution/response-release-event-gate.test.ts new file mode 100644 index 0000000000..7d56166ed6 --- /dev/null +++ b/packages/eve/src/execution/response-release-event-gate.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ContextContainer } from "#context/container.js"; +import { ResponseReleaseEventGate } from "#execution/response-release-event-gate.js"; +import type { RuntimeHookRegistry } from "#runtime/hooks/registry.js"; + +const { dispatchBeforeResponseReleaseHooks } = vi.hoisted(() => ({ + dispatchBeforeResponseReleaseHooks: vi.fn(), +})); + +vi.mock("#context/hook-lifecycle.js", () => ({ dispatchBeforeResponseReleaseHooks })); + +const terminal = { + data: { + finishReason: "stop" as const, + message: "candidate", + sequence: 0, + stepIndex: 0, + turnId: "turn_0", + }, + type: "message.completed" as const, +}; + +const registry: RuntimeHookRegistry = { + beforeResponseRelease: [{ handler: vi.fn(), slug: "review" }], + streamEventsByType: new Map(), + streamEventsWildcard: [], +}; + +describe("ResponseReleaseEventGate", () => { + it("withholds then releases a terminal completion when history is retained", async () => { + const gate = new ResponseReleaseEventGate(new ContextContainer(), registry); + const release = vi.fn().mockResolvedValue(undefined); + + expect(gate.intercept(terminal)).toBe(true); + await expect( + gate.beforeRelease(release)!({ history: [], output: "candidate", turnId: "turn_0" }), + ).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledWith(terminal); + }); + + it("does not intercept task-mode terminal completions", () => { + const gate = new ResponseReleaseEventGate(new ContextContainer(), registry, false); + + expect(gate.intercept(terminal)).toBe(false); + expect(gate.beforeRelease(vi.fn())).toBeUndefined(); + }); + + it("does not intercept a response that parks on tool calls", () => { + const gate = new ResponseReleaseEventGate(new ContextContainer(), registry); + + expect( + gate.intercept({ + ...terminal, + data: { ...terminal.data, finishReason: "tool-calls" }, + }), + ).toBe(false); + }); + + it("drops a terminal completion when a hook skips release", async () => { + dispatchBeforeResponseReleaseHooks.mockResolvedValueOnce("skip"); + const gate = new ResponseReleaseEventGate(new ContextContainer(), registry); + const release = vi.fn().mockResolvedValue(undefined); + + expect(gate.intercept(terminal)).toBe(true); + await expect( + gate.beforeRelease(release)!({ + history: [ + { content: "keep", role: "user" }, + { content: "remove", role: "assistant" }, + ], + output: "candidate", + turnId: "turn_0", + }), + ).resolves.toBe("skip"); + expect(release).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/execution/response-release-event-gate.ts b/packages/eve/src/execution/response-release-event-gate.ts new file mode 100644 index 0000000000..57466398cd --- /dev/null +++ b/packages/eve/src/execution/response-release-event-gate.ts @@ -0,0 +1,70 @@ +import type { ContextContainer } from "#context/container.js"; +import { dispatchBeforeResponseReleaseHooks } from "#context/hook-lifecycle.js"; +import type { ToolLoopHarnessConfig } from "#harness/types.js"; +import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; +import type { RuntimeHookRegistry } from "#runtime/hooks/registry.js"; + +/** Holds terminal content while authored hooks inspect the settling turn. */ +export class ResponseReleaseEventGate { + private readonly ctx: ContextContainer; + private readonly registry: RuntimeHookRegistry; + private readonly supported: boolean; + private releasing = false; + private terminalEvent: UnstampedMessageStreamEvent | undefined; + + constructor(ctx: ContextContainer, registry: RuntimeHookRegistry, supported = true) { + this.ctx = ctx; + this.registry = registry; + this.supported = supported; + } + + get enabled(): boolean { + return this.supported && this.registry.beforeResponseRelease.length > 0; + } + + /** Returns true when the terminal event was withheld from ordinary delivery. */ + intercept(event: UnstampedMessageStreamEvent): boolean { + if ( + this.releasing || + !this.enabled || + event.type !== "message.completed" || + event.data.finishReason === "tool-calls" + ) { + return false; + } + this.terminalEvent = event; + return true; + } + + beforeRelease( + release: (event: UnstampedMessageStreamEvent) => Promise, + ): NonNullable | undefined { + if (!this.enabled) return undefined; + return async (candidate) => { + const decision = await dispatchBeforeResponseReleaseHooks({ + candidate: { + history: candidate.history, + output: candidate.output, + turnId: candidate.turnId, + }, + ctx: this.ctx, + registry: this.registry, + }); + if (decision === "skip") { + this.terminalEvent = undefined; + return "skip"; + } + if (this.terminalEvent !== undefined) { + const terminalEvent = this.terminalEvent; + this.terminalEvent = undefined; + this.releasing = true; + try { + await release(terminalEvent); + } finally { + this.releasing = false; + } + } + return undefined; + }; + } +} diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index 865d0c57f9..f2288ecf7d 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -93,6 +93,7 @@ import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config. import { reconcileSessionContinuationToken } from "#execution/reconcile-session-continuation-token.js"; import { hydrateDurableSession, refreshSessionFromTurnAgent } from "#execution/session.js"; import { createExecutionHistoryView } from "#execution/history-view.js"; +import { ResponseReleaseEventGate } from "#execution/response-release-event-gate.js"; import { resolveRuntimeCompiledArtifactsVersionedCacheKey } from "#runtime/cache-key.js"; import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; import { bindDynamicConnections } from "#execution/dynamic-connections.js"; @@ -381,6 +382,12 @@ export async function turnStep(rawInput: TurnStepInput): Promise => { @@ -391,6 +398,7 @@ export async function turnStep(rawInput: TurnStepInput): Promise => { + if (responseReleaseGate.intercept(event)) return; // A remote task's parent owns its HITL. Forward blocking events over // the task callback and keep them out of the child's local channel; // otherwise two TUIs can present and answer the same request. @@ -448,8 +456,6 @@ export async function turnStep(rawInput: TurnStepInput): Promise { ]); }); + it("suppresses a skipped response before a turn settles", async () => { + setupMockAgent({ + finishReason: "stop", + response: { messages: [{ content: "Sensitive draft", role: "assistant" }] }, + text: "Sensitive draft", + toolCalls: [], + toolResults: [], + }); + + const beforeResponseRelease = vi.fn().mockResolvedValue("skip"); + const runStep = createToolLoopHarness( + createTestConfig("conversation", undefined, { + beforeResponseRelease, + }), + ); + const previous = { content: "Earlier context", role: "user" as const }; + + const result = await runStep(createTestSession({ history: [previous] }), { + message: "Create a response", + }); + + expect(beforeResponseRelease).toHaveBeenCalledWith({ + history: [ + previous, + { content: "Create a response", role: "user" }, + { content: "Sensitive draft", role: "assistant" }, + ], + output: "Sensitive draft", + turnId: "", + }); + expect(result.session.history).toEqual([ + previous, + { content: "Create a response", role: "user" }, + { content: "Sensitive draft", role: "assistant" }, + ]); + expect(result.settledTurn).toEqual({ output: "" }); + }); + it("omits user messages with no model-visible content", async () => { setupMockAgent({ finishReason: "stop", @@ -2663,7 +2701,10 @@ describe("createToolLoopHarness", () => { }); const { emit, events } = createEventCollector(); - const runStep = createToolLoopHarness(createTestConfig("conversation", emit)); + const beforeResponseRelease = vi.fn().mockResolvedValue(undefined); + const runStep = createToolLoopHarness( + createTestConfig("conversation", emit, { beforeResponseRelease }), + ); const session = createTestSession({ outputSchema: { type: "object" } }); const result = await runStep(session, { message: "Hi" }); @@ -2691,6 +2732,7 @@ describe("createToolLoopHarness", () => { }), ); expect(result.session.outputSchema).toBeUndefined(); + expect(beforeResponseRelease).not.toHaveBeenCalled(); }); it("returns only the final assistant reply when a completed task step includes tool work", async () => { @@ -9601,7 +9643,6 @@ describe("createToolLoopHarness", () => { outputSchema, state, }); - const result = await runStep(session); expect(result.next).toBeNull(); diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index f4508b7c4f..6fdb471733 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -2764,6 +2764,7 @@ async function handleStepResult(input: { } return finishConversationTurn({ + beforeResponseRelease: config.beforeResponseRelease, emissionState, emit, history: promptMessages, @@ -2887,6 +2888,7 @@ async function finishTaskTurn(input: { * ends the turn and the session waits for the next message. */ async function finishConversationTurn(input: { + readonly beforeResponseRelease?: ToolLoopHarnessConfig["beforeResponseRelease"]; readonly emissionState: ReturnType; readonly emit?: ToolLoopHarnessConfig["handleEvent"]; readonly history: readonly ModelMessage[]; @@ -2900,12 +2902,13 @@ async function finishConversationTurn(input: { session = clearTurnClientContextState(session); if (schema === undefined) { - if (emit) { - emissionState = await emitTurnEpilogue(emit, emissionState, "conversation"); - session = setHarnessEmissionState(session, emissionState); - } - const settledTurn = { output: stepOutput ?? "" } satisfies SettledTurn; - return { next: null, session, settledTurn }; + return settleConversationCandidate({ + beforeResponseRelease: input.beforeResponseRelease, + emissionState, + emit, + output: stepOutput ?? "", + session, + }); } const structured = extractFinalOutput(result); @@ -2928,12 +2931,47 @@ async function finishConversationTurn(input: { } session = persistStructuredAssistantTurn(session, history, structured); - if (emit) { - emissionState = await emitStructuredResult(emit, emissionState, structured, "conversation"); + return settleConversationCandidate({ + beforeResponseRelease: input.beforeResponseRelease, + emissionState, + emit, + emitAccepted: (emitFn, state) => + emitStructuredResult(emitFn, state, structured, "conversation"), + output: structured, + session, + }); +} + +async function settleConversationCandidate(input: { + readonly beforeResponseRelease?: ToolLoopHarnessConfig["beforeResponseRelease"]; + readonly emissionState: ReturnType; + readonly emit?: ToolLoopHarnessConfig["handleEvent"]; + readonly emitAccepted?: ( + emit: NonNullable, + state: ReturnType, + ) => Promise>; + readonly output: unknown; + readonly session: HarnessSession; +}): Promise { + let { emissionState, session } = input; + const skipped = + (await input.beforeResponseRelease?.({ + history: session.history, + output: input.output, + turnId: emissionState.turnId, + })) === "skip"; + if (input.emit) { + emissionState = skipped + ? await emitTurnEpilogue(input.emit, emissionState, "conversation") + : await (input.emitAccepted?.(input.emit, emissionState) ?? + emitTurnEpilogue(input.emit, emissionState, "conversation")); session = setHarnessEmissionState(session, emissionState); } - const settledTurn = { output: structured } satisfies SettledTurn; - return { next: null, session, settledTurn }; + return { + next: null, + session, + settledTurn: { output: skipped ? "" : input.output }, + }; } /** Replays a parked dynamic workflow with completed child-agent results. */ diff --git a/packages/eve/src/harness/types.ts b/packages/eve/src/harness/types.ts index e4fde11576..05e358dce0 100644 --- a/packages/eve/src/harness/types.ts +++ b/packages/eve/src/harness/types.ts @@ -305,6 +305,12 @@ export interface ToolLoopHarnessConfig { */ readonly workflowMaxSubagents?: number; readonly handleEvent?: HandleEventFn; + /** Optional suppression requested before terminal response release. */ + readonly beforeResponseRelease?: (input: { + readonly history: readonly ModelMessage[]; + readonly output: unknown; + readonly turnId: string; + }) => Promise<"skip" | undefined>; /** Projects raw durable history before it crosses a message-bearing boundary. */ readonly historyProjector?: HistoryViewProjector; /** Execution-prepared view of the history supplied to the first harness step. */ diff --git a/packages/eve/src/public/definitions/hook.ts b/packages/eve/src/public/definitions/hook.ts index 48e2bc3b5a..b3493ee17d 100644 --- a/packages/eve/src/public/definitions/hook.ts +++ b/packages/eve/src/public/definitions/hook.ts @@ -1,3 +1,4 @@ +import type { ModelMessage } from "ai"; import type { HandleMessageStreamEvent } from "../../protocol/message.js"; import type { SessionContext } from "./callback-context.js"; import type { ExactDefinition } from "./exact.js"; @@ -98,6 +99,23 @@ export type StreamEventHooks = { readonly [TKey_ in TKey]?: StreamEventHook>; }; +/** Candidate conversation turn presented immediately before response release. */ +export interface ResponseReleaseCandidate { + readonly history: readonly ModelMessage[]; + readonly output: unknown; + readonly turnId: string; +} + +/** + * Runs after final synthesis but before terminal content reaches the channel. + * Returning `"skip"` suppresses the pending terminal completion. Earlier + * stream events and side effects are not retracted. + */ +export type BeforeResponseReleaseHook = ( + candidate: ResponseReleaseCandidate, + ctx: HookContext, +) => void | "skip" | Promise; + /** * Public hook definition authored in `agent/hooks/*.ts`. * @@ -108,6 +126,7 @@ export type StreamEventHooks = { * `defineInstructions` in `agent/instructions/`. */ export interface HookDefinition { + readonly beforeResponseRelease?: BeforeResponseReleaseHook; readonly events?: StreamEventHooks; } diff --git a/packages/eve/src/public/hooks/index.ts b/packages/eve/src/public/hooks/index.ts index aeeb55dce2..e411a02011 100644 --- a/packages/eve/src/public/hooks/index.ts +++ b/packages/eve/src/public/hooks/index.ts @@ -7,6 +7,7 @@ */ export { + type BeforeResponseReleaseHook, type HookContext, type HookDefinition, type HookEvent, @@ -15,5 +16,6 @@ export { type HookEventType, type StreamEventHook, type StreamEventHooks, + type ResponseReleaseCandidate, defineHook, } from "#public/definitions/hook.js"; diff --git a/packages/eve/src/runtime/hooks/registry.test.ts b/packages/eve/src/runtime/hooks/registry.test.ts index a81e2fe25d..c6f72a6d48 100644 --- a/packages/eve/src/runtime/hooks/registry.test.ts +++ b/packages/eve/src/runtime/hooks/registry.test.ts @@ -4,17 +4,22 @@ import type { ResolvedHookDefinition } from "../types.js"; import { createEmptyHookRegistry, createRuntimeHookRegistry } from "./registry.js"; describe("createRuntimeHookRegistry", () => { - it("splits typed and wildcard stream-event subscribers", () => { + it("splits settlement and stream-event hooks", () => { + const beforeResponseRelease = async () => undefined; const typed = async () => {}; const wildcard = async () => {}; const registry = createRuntimeHookRegistry([ makeHook({ + beforeResponseRelease, slug: "audit", events: { "message.completed": typed, "*": wildcard }, }), ]); + expect(registry.beforeResponseRelease).toEqual([ + { handler: beforeResponseRelease, slug: "audit" }, + ]); expect( (registry.streamEventsByType.get("message.completed") ?? []).map((e) => e.eventType), ).toEqual(["message.completed"]); @@ -25,16 +30,19 @@ describe("createRuntimeHookRegistry", () => { describe("createEmptyHookRegistry", () => { it("returns flat empty buckets", () => { const registry = createEmptyHookRegistry(); + expect(registry.beforeResponseRelease).toEqual([]); expect(registry.streamEventsByType.size).toBe(0); expect(registry.streamEventsWildcard).toEqual([]); }); }); function makeHook(partial: { + readonly beforeResponseRelease?: ResolvedHookDefinition["beforeResponseRelease"]; readonly slug: string; readonly events?: ResolvedHookDefinition["events"]; }): ResolvedHookDefinition { return { + beforeResponseRelease: partial.beforeResponseRelease, events: partial.events ?? {}, exportName: undefined, logicalPath: `hooks/${partial.slug}.ts`, diff --git a/packages/eve/src/runtime/hooks/registry.ts b/packages/eve/src/runtime/hooks/registry.ts index 99db43c211..fa60290bba 100644 --- a/packages/eve/src/runtime/hooks/registry.ts +++ b/packages/eve/src/runtime/hooks/registry.ts @@ -1,5 +1,5 @@ import type { MessageStreamEvent } from "#protocol/message.js"; -import type { StreamEventHook } from "../../public/definitions/hook.js"; +import type { BeforeResponseReleaseHook, StreamEventHook } from "../../public/definitions/hook.js"; import type { ResolvedHookDefinition } from "../types.js"; /** @@ -8,6 +8,11 @@ import type { ResolvedHookDefinition } from "../types.js"; * `eventType` is `"*"` for wildcard subscribers, otherwise the typed * event name. */ +interface RuntimeBeforeResponseReleaseHookEntry { + readonly handler: BeforeResponseReleaseHook; + readonly slug: string; +} + interface RuntimeStreamEventHookEntry { readonly slug: string; readonly handler: StreamEventHook; @@ -21,6 +26,7 @@ interface RuntimeStreamEventHookEntry { * without scanning every entry. */ export interface RuntimeHookRegistry { + readonly beforeResponseRelease: readonly RuntimeBeforeResponseReleaseHookEntry[]; readonly streamEventsByType: ReadonlyMap; readonly streamEventsWildcard: readonly RuntimeStreamEventHookEntry[]; } @@ -33,6 +39,7 @@ export interface RuntimeHookRegistry { */ export function createEmptyHookRegistry(): RuntimeHookRegistry { return { + beforeResponseRelease: [], streamEventsByType: new Map(), streamEventsWildcard: [], }; @@ -49,10 +56,14 @@ export function createEmptyHookRegistry(): RuntimeHookRegistry { export function createRuntimeHookRegistry( resolvedHooks: readonly ResolvedHookDefinition[], ): RuntimeHookRegistry { + const beforeResponseRelease: RuntimeBeforeResponseReleaseHookEntry[] = []; const streamEventsByType = new Map(); const streamEventsWildcard: RuntimeStreamEventHookEntry[] = []; for (const hook of resolvedHooks) { + if (hook.beforeResponseRelease !== undefined) { + beforeResponseRelease.push({ handler: hook.beforeResponseRelease, slug: hook.slug }); + } for (const [eventType, handler] of Object.entries(hook.events)) { const entry: RuntimeStreamEventHookEntry = { slug: hook.slug, handler, eventType }; if (eventType === "*") { @@ -66,6 +77,7 @@ export function createRuntimeHookRegistry( } return { + beforeResponseRelease, streamEventsByType, streamEventsWildcard, }; diff --git a/packages/eve/src/runtime/resolve-hook.test.ts b/packages/eve/src/runtime/resolve-hook.test.ts index 8abf050e58..b860e92d71 100644 --- a/packages/eve/src/runtime/resolve-hook.test.ts +++ b/packages/eve/src/runtime/resolve-hook.test.ts @@ -48,6 +48,17 @@ describe("resolveHookDefinition", () => { expect(Object.keys(resolved.events).sort()).toEqual(["*", "message.completed"]); }); + it("resolves a pre-settlement turn hook", async () => { + const definition = buildDefinition({ slug: "review" }); + const beforeResponseRelease = () => undefined; + const moduleMap = buildModuleMap(definition.sourceId, { + default: { beforeResponseRelease }, + }); + + const resolved = await resolveHookDefinition(definition, moduleMap, undefined); + expect(resolved.beforeResponseRelease).toBe(beforeResponseRelease); + }); + it("accepts a hook with only `events` declared", async () => { const definition = buildDefinition({ slug: "audit" }); const moduleMap = buildModuleMap(definition.sourceId, { diff --git a/packages/eve/src/runtime/resolve-hook.ts b/packages/eve/src/runtime/resolve-hook.ts index 2fffa508fc..8afc098db7 100644 --- a/packages/eve/src/runtime/resolve-hook.ts +++ b/packages/eve/src/runtime/resolve-hook.ts @@ -2,7 +2,7 @@ import type { CompiledHookDefinition } from "../compiler/manifest.js"; import type { CompiledModuleMap } from "../compiler/module-map.js"; import { expectFunction, expectObjectRecord } from "../internal/authored-module.js"; import type { MessageStreamEvent } from "../protocol/message.js"; -import type { StreamEventHook } from "../public/definitions/hook.js"; +import type { BeforeResponseReleaseHook, StreamEventHook } from "../public/definitions/hook.js"; import { toErrorMessage } from "../shared/errors.js"; import { loadResolvedModuleExport, ResolveAgentError } from "./resolve-helpers.js"; import type { ResolvedHookDefinition } from "./types.js"; @@ -33,6 +33,14 @@ export async function resolveHookDefinition( describe(definition, "to return an object"), ); + const beforeResponseReleaseRaw = resolvedRecord.beforeResponseRelease; + const beforeResponseRelease = + beforeResponseReleaseRaw === undefined + ? undefined + : (expectFunction( + beforeResponseReleaseRaw, + describe(definition, "to provide a function for beforeResponseRelease"), + ) as BeforeResponseReleaseHook); const events: Record> = {}; const eventsRaw = resolvedRecord.events; @@ -52,6 +60,7 @@ export async function resolveHookDefinition( } return { + beforeResponseRelease, events, exportName: definition.exportName, logicalPath: definition.logicalPath, diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index a0b45d6840..94253adf2a 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -213,6 +213,7 @@ export type ResolvedToolDefinition = Readonly< * the resolved maps. */ export interface ResolvedHookDefinition extends ResolvedModuleSourceRef { + readonly beforeResponseRelease?: import("#public/definitions/hook.js").BeforeResponseReleaseHook; /** * Path-relative slug used for diagnostics and ordering. */