Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-turns-rollback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Hooks can inspect a completed conversation turn with `beforeResponseRelease` and restore model history to an earlier index before terminal channel delivery. Restoring history suppresses the pending response while preserving earlier events and external side effects.
5 changes: 5 additions & 0 deletions .changeset/history-restoration-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Sessions can now restore model history to an earlier index with `restoreHistory({ to })`. The control is serialized with turns and retains only the selected history prefix without retracting prior events or external side effects.
10 changes: 10 additions & 0 deletions docs/guides/client/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ const cleared = await session.clear();
console.log(cleared.status);
```

Use `session.restoreHistory({ to })` to retain the exact model-history prefix before an index. The
control is serialized with turns and other session controls. It does not retract stream events,
messages already delivered through a channel, tool effects, or other external work. An invalid index
fails the session operation instead of selecting a nearby boundary.

```ts
const restored = await session.restoreHistory({ to: historyIndex });
console.log(restored.status);
```

## Aggregate a turn

Use `result()` when you only need the final turn summary:
Expand Down
38 changes: 37 additions & 1 deletion docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,43 @@ 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) {
const questionIndex = candidate.history.messages.findLastIndex(
(message) => message.role === "user",
);
if (questionIndex < 0) return;
const attempt = candidate.history.messages.slice(questionIndex);

if (containsSensitiveData(attempt) && !containsRequiredApproval(attempt)) {
candidate.history.restoreTo(questionIndex);
}
},
});
```

The hook receives candidate model history, terminal output, and `turnId`. Calling
`candidate.history.restoreTo(index)` retains the exact history prefix before `index` and suppresses
the withheld terminal `message.completed` event. If several hooks request restoration, eve retains
the shortest requested prefix. If no hook requests restoration, eve keeps the candidate history and
releases the terminal event.

This is a logical history-restoration 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

Expand Down
3 changes: 3 additions & 0 deletions packages/eve/extension-contracts/compatibility/channel/v17.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableRoute } from "#public/channels/index.js";

export default disableRoute();
9 changes: 9 additions & 0 deletions packages/eve/extension-contracts/compatibility/hook/v20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineHook } from "#public/hooks/index.js";

export default defineHook({
events: {
"subagent.completed"(event, ctx) {
console.info(event.data.subagentName, ctx.session.id);
},
},
});
8 changes: 8 additions & 0 deletions packages/eve/extension-contracts/compatibility/schedule/v9.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineSchedule } from "#public/schedules/index.js";

export default defineSchedule({
cron: "0 0 * * *",
run({ waitUntil }) {
waitUntil(Promise.resolve());
},
});
21 changes: 21 additions & 0 deletions packages/eve/extension-contracts/reports/channel/v18.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"kind": "eve-extension-capability-contract",
"capability": "channel",
"epoch": 18,
"sha256": "aa09c65ec5aa52fa7366a3ee56995d9d03e6612e4bb671438a648c5b035b474f",
"exports": [
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"WS",
"createWebSocketUpgradeServer",
"defineChannel",
"disableRoute",
"isChannel",
"isDisabledRouteSentinel"
]
}
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/reports/hook/v21.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 21,
"sha256": "15d70241a9cb0f3d8e87b6309ce24e7c62b8bf620f065fb6a94bc95db1bf0a8f",
"exports": ["defineHook"]
}
14 changes: 14 additions & 0 deletions packages/eve/extension-contracts/reports/schedule/v10.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"kind": "eve-extension-capability-contract",
"capability": "schedule",
"epoch": 10,
"sha256": "3a7852055fdd915739b6e08c2d09eba68be4bb76e23fab329c44f7348f0953f3",
"exports": [
"ScheduleDefinition",
"ScheduleHandlerArgs",
"ScheduleRunHandler",
"ScheduleToFn",
"TypedReceiveTarget",
"defineSchedule"
]
}
7 changes: 7 additions & 0 deletions packages/eve/src/channel/channel-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface ChannelAddress<TState = undefined> {
cancel(options?: { readonly turnId?: string }): Promise<CancelTurnResult>;
compact(): Promise<CompactSessionResult>;
clear(): Promise<ClearSessionResult>;
restoreHistory(input: { readonly to: number }): Promise<ClearSessionResult>;
reset(options?: { readonly reason?: string }): Promise<ResetSessionResult>;
resolveSession(): Promise<Session | undefined>;
}
Expand Down Expand Up @@ -190,6 +191,12 @@ export function createChannelAddress<TState = undefined>(input: {
continuationToken: namespacedToken,
});
},
async restoreHistory(restoreInput) {
return await input.runtime.dispatchContinuation({
command: { kind: "restore-history", to: restoreInput.to },
continuationToken: namespacedToken,
});
},
async reset(options) {
return await input.runtime.dispatchContinuation({
command: { kind: "reset", reason: options?.reason },
Expand Down
5 changes: 5 additions & 0 deletions packages/eve/src/channel/channel-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface ChannelSource<TState = undefined> {
compact(): Promise<CompactSessionResult>;
/** Clears model-message history without creating a session. */
clear(): Promise<ClearSessionResult>;
/** Restores model-message history without creating a session. */
restoreHistory(input: { readonly to: number }): Promise<ClearSessionResult>;
/** Retires the current owner without creating a replacement. */
reset(options?: { readonly reason?: string }): Promise<ResetSessionResult>;
}
Expand Down Expand Up @@ -141,6 +143,9 @@ export function createChannelOperations<TState = undefined>(input: {
async clear() {
return await bound.clear();
},
async restoreHistory(restoreInput) {
return await bound.restoreHistory(restoreInput);
},
async reset(options) {
return await bound.reset(options);
},
Expand Down
3 changes: 3 additions & 0 deletions packages/eve/src/channel/cross-channel-receive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ function makeSession(): Session {
async clear() {
return { status: "no_active_session" };
},
async restoreHistory() {
return { status: "no_active_session" };
},
async reset() {
return { status: "no_active_session" };
},
Expand Down
5 changes: 5 additions & 0 deletions packages/eve/src/channel/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe("fixed session operations", () => {
await session.respond([{ optionId: "approve", requestId: "request_1" }], { auth: null });
await session.compact();
await session.clear();
await session.restoreHistory({ to: 2 });
await session.reset({ reason: "fresh start" });

expect(runtime.dispatchSession).toHaveBeenNthCalledWith(1, {
Expand Down Expand Up @@ -173,6 +174,10 @@ describe("fixed session operations", () => {
sessionId: "sess_1",
});
expect(runtime.dispatchSession).toHaveBeenNthCalledWith(5, {
command: { kind: "restore-history", to: 2 },
sessionId: "sess_1",
});
expect(runtime.dispatchSession).toHaveBeenNthCalledWith(6, {
command: { kind: "reset", reason: "fresh start" },
sessionId: "sess_1",
});
Expand Down
9 changes: 9 additions & 0 deletions packages/eve/src/channel/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ClearSessionResult,
CompactSessionResult,
ResetSessionResult,
RestoreHistorySessionResult,
Runtime,
SessionAuthContext,
SessionCallback,
Expand Down Expand Up @@ -54,6 +55,8 @@ export interface Session {
compact(): Promise<CompactSessionResult>;
/** Queues a context clear on this exact session ID. */
clear(): Promise<ClearSessionResult>;
/** Queues restoration of an observed model-history snapshot. */
restoreHistory(input: { readonly to: number }): Promise<RestoreHistorySessionResult>;
/** Terminally retires this exact session ID. */
reset(options?: { reason?: string }): Promise<ResetSessionResult>;
getEventStream(options?: { startIndex?: number }): Promise<ReadableStream<MessageStreamEvent>>;
Expand Down Expand Up @@ -162,6 +165,12 @@ export function createSession(
async clear() {
return await runtime.dispatchSession({ command: { kind: "clear" }, sessionId: id });
},
async restoreHistory(input) {
return await runtime.dispatchSession({
command: { kind: "restore-history", to: input.to },
sessionId: id,
});
},
async reset(options) {
return await runtime.dispatchSession({
command: { kind: "reset", reason: options?.reason },
Expand Down
23 changes: 22 additions & 1 deletion packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export type ClearSessionResult =
| { readonly status: "accepted"; readonly sessionId: string }
| { readonly status: "no_active_session" };

/** Result of queueing model-history restoration for a session. */
export type RestoreHistorySessionResult = ClearSessionResult;

// ---------------------------------------------------------------------------
// Lineage
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -225,6 +228,10 @@ export type SessionCommand =
}
| { readonly kind: "compact" }
| { readonly kind: "clear" }
| {
readonly kind: "restore-history";
readonly to: number;
}
| { readonly kind: "reset"; readonly reason?: string };

export type SessionSendCommandResult =
Expand All @@ -236,6 +243,11 @@ export type ResetSessionResult =
| { readonly status: "reset"; readonly previousSessionId: string }
| { readonly status: "no_active_session" };

export type SessionControlCommand = Extract<
SessionCommand,
{ readonly kind: "clear" | "compact" | "restore-history" | "reset" }
>;

export type SessionCommandResult<TCommand extends SessionCommand = SessionCommand> =
TCommand extends { readonly kind: "send" }
? SessionSendCommandResult
Expand All @@ -245,7 +257,9 @@ export type SessionCommandResult<TCommand extends SessionCommand = SessionComman
? CompactSessionResult
: TCommand extends { readonly kind: "clear" }
? ClearSessionResult
: ResetSessionResult;
: TCommand extends { readonly kind: "restore-history" }
? RestoreHistorySessionResult
: ResetSessionResult;

export interface DispatchContinuationInput<TCommand extends SessionCommand = SessionCommand> {
readonly command: TCommand;
Expand Down Expand Up @@ -302,6 +316,12 @@ export interface ClearSessionHookPayload {
readonly kind: "clear";
}

/** Requests restoration of an observed model-history snapshot to an earlier index. */
export interface RestoreHistorySessionHookPayload {
readonly kind: "restore-history";
readonly to: number;
}

/**
* Results resumed back into a parked parent workflow by the work it
* dispatched: child-produced subagent results and authored workflow tool
Expand Down Expand Up @@ -382,6 +402,7 @@ export type HookPayload =
| CompactSessionHookPayload
| DeliverHookPayload
| RuntimeActionResultHookPayload
| RestoreHistorySessionHookPayload
| SessionTimeoutHookPayload
| SubagentAuthorizationEventHookPayload
| SubagentInputRequestHookPayload;
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type {
MessageResult,
RespondTurnOptions,
ResetResult,
RestoreHistoryResult,
ResolvedStreamReconnectPolicy,
SendTurnInput,
SendTurnOptions,
Expand Down
26 changes: 26 additions & 0 deletions packages/eve/src/client/session-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import type {
ClientRedirectPolicy,
CompactResult,
ResetResult,
RestoreHistoryResult,
} from "#client/types.js";
import { createClientUrl } from "#client/url.js";
import { CancelTurnResponseSchema } from "#protocol/cancel-turn.js";
import { ClearResponseSchema } from "#protocol/clear-session.js";
import { CompactResponseSchema } from "#protocol/compact-session.js";
import { ResetResponseSchema } from "#protocol/reset-session.js";
import { RestoreHistoryResponseSchema } from "#protocol/restore-history.js";
import {
createEveSessionCancelRoutePath,
createEveSessionClearRoutePath,
createEveSessionCompactRoutePath,
createEveSessionResetRoutePath,
createEveSessionRestoreHistoryRoutePath,
} from "#protocol/routes.js";

interface SessionControlContext {
Expand Down Expand Up @@ -95,6 +98,29 @@ export async function compactClientSession(input: {
: { status: "no_active_session" };
}

export async function restoreClientSessionHistory(input: {
readonly context: SessionControlContext;
readonly sessionId: string;
readonly to: number;
}): Promise<RestoreHistoryResult> {
const { payload } = await postJson({
body: { to: input.to },
context: input.context,
operation: "Restore history",
path: createEveSessionRestoreHistoryRoutePath(input.sessionId),
});
const result = RestoreHistoryResponseSchema.safeParse(payload);
if (
!result.success ||
(result.data.status === "accepted" && result.data.sessionId !== input.sessionId)
) {
throw new Error("History restoration route returned an invalid response.");
}
return result.data.status === "accepted"
? { sessionId: result.data.sessionId, status: "accepted" }
: { status: "no_active_session" };
}

export async function resetClientSession(input: {
readonly context: SessionControlContext;
readonly options?: { readonly reason?: string; readonly signal?: AbortSignal };
Expand Down
Loading
Loading