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
5 changes: 5 additions & 0 deletions .changeset/dynamic-lifecycle-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Expose message snapshots and cancellation signals in hook contexts. Dynamic resolvers receive the latest durable message snapshot across lifecycle events, and durable tools fail closed when replay functions are unavailable.
8 changes: 8 additions & 0 deletions docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,19 @@ helpers documented in [Session context](./session-context):

```ts
interface HookContext extends SessionContext {
readonly abortSignal: AbortSignal;
readonly agent: { readonly name: string; readonly nodeId?: string };
readonly channel: { readonly kind?: string; readonly continuationToken?: string };
readonly messages: readonly ModelMessage[];
}
```

`messages` contains the latest durable model history available when the hook runs. Events that
do not produce a new snapshot reuse the previous one, so the history may lag the event and may
be empty for a new session. `abortSignal` tracks cancellation of the active turn. Hooks that run
while settling a cancelled turn receive an already-aborted signal; use an independent signal or
timeout for cleanup I/O that must continue after cancellation.

That means a hook can access the current sandbox and release its backing
compute at an application-defined boundary:

Expand Down
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/compatibility/hook/v11.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineHook } from "#public/hooks/index.js";

export default defineHook({
events: {
"approval.candidate"(event, ctx) {
console.info("approval candidate", {
candidateId: event.data.candidateId,
outcome: event.data.outcome,
sessionId: ctx.session.id,
});
},
},
});
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/reports/hook/v12.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 12,
"sha256": "8c1c6e56147f1af064a4bb9d946b0aaaec0c018573009e33f4a8b5d3c30c30b7",
"exports": ["defineHook"]
}
4 changes: 2 additions & 2 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ const EXTENSION_CAPABILITY_CONTRACTS = {
},
connection: { current: 5, supported: [1, 2, 3, 4, 5], dropped: {} },
hook: {
current: 11,
supported: [10, 11],
current: 12,
supported: [10, 11, 12],
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.",
Expand Down
38 changes: 27 additions & 11 deletions packages/eve/src/context/build-dynamic-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,27 @@ function lookupStepFunction(stepId: string): ((...args: unknown[]) => unknown) |
}
}

function replayTools(metadata: readonly DurableDynamicToolMetadata[]): HarnessToolDefinition[] {
export function replayDurableTools(
metadata: readonly DurableDynamicToolMetadata[],
): HarnessToolDefinition[] {
const tools: HarnessToolDefinition[] = [];

for (const m of metadata) {
if (!m.executeStepFnName || !m.closureVars) {
log.warn(
`Dynamic tool "${m.name}" has no registered step function — ` +
"skipping on this step. The bundler transform may not have processed this tool file.",
throw new Error(
`Dynamic tool "${m.name}" cannot be replayed because its durable execute metadata is incomplete.`,
);
continue;
}

const stepFn = lookupStepFunction(m.executeStepFnName);
if (!stepFn) {
log.warn(
`Dynamic tool "${m.name}" references step function "${m.executeStepFnName}" ` +
"which is not registered — skipping on this step.",
throw new Error(
`Dynamic tool "${m.name}" cannot be replayed because execute function "${m.executeStepFnName}" is not registered.`,
);
continue;
}

const toModelOutput = buildReplayedModelOutput(m);

tools.push({
description: m.description,
execute: createToolExecuteWithAuth({
Expand All @@ -63,12 +63,28 @@ function replayTools(metadata: readonly DurableDynamicToolMetadata[]): HarnessTo
name: m.name,
approval: buildReplayedApproval(m),
outputSchema: toOutputSchema(m.outputSchema),
toModelOutput,
});
}

return tools;
}

function buildReplayedModelOutput(
metadata: DurableDynamicToolMetadata,
): HarnessToolDefinition["toModelOutput"] | undefined {
if (metadata.toModelOutputStepFnName === undefined) return undefined;

const toModelOutputStepFn = lookupStepFunction(metadata.toModelOutputStepFnName);
if (toModelOutputStepFn === null) {
throw new Error(
`Dynamic tool "${metadata.name}" cannot be replayed because model-output function "${metadata.toModelOutputStepFnName}" is not registered.`,
);
Comment thread
AndrewBarba marked this conversation as resolved.
}

return (output: unknown) => toModelOutputStepFn(metadata.closureVars ?? {}, output);
}

function buildReplayedApproval(
metadata: DurableDynamicToolMetadata,
): HarnessToolDefinition["approval"] | undefined {
Expand Down Expand Up @@ -137,7 +153,7 @@ export function buildDynamicTools(ctx: {
get<T>(key: ContextKey<T>): T | undefined;
}): readonly HarnessToolDefinition[] {
const step = ctx.get(LiveStepToolsKey) ?? [];
const turn = replayTools(ctx.get(TurnDynamicToolMetadataKey) ?? []);
const session = replayTools(ctx.get(SessionDynamicToolMetadataKey) ?? []);
const turn = replayDurableTools(ctx.get(TurnDynamicToolMetadataKey) ?? []);
const session = replayDurableTools(ctx.get(SessionDynamicToolMetadataKey) ?? []);
return [...step, ...turn, ...session];
}
Loading
Loading