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/instrumentation-event-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Give instrumentation lifecycle events replay-stable idempotency keys so providers can update one record across retries and worker replays.
57 changes: 39 additions & 18 deletions packages/eve/src/harness/ai-sdk-hook-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ describe("createAiSdkHookBridge", () => {
return {
events: {
"model.call.started"(event) {
calls.push(`${name}:started:${event.id}`);
states.set(event.id, `${name}-state`);
calls.push(`${name}:started:${event.idempotencyKey}`);
states.set(event.idempotencyKey, `${name}-state`);
},
"model.call.completed"(event) {
calls.push(`${name}:completed:${event.id}:${String(states.get(event.id))}`);
calls.push(
`${name}:completed:${event.idempotencyKey}:${String(states.get(event.idempotencyKey))}`,
);
},
},
};
Expand All @@ -55,7 +57,7 @@ describe("createAiSdkHookBridge", () => {
},
]);

const id = `${scope.attemptId}:model:call-1:0`;
const id = `model:${scope.attemptId}:0`;
expect(calls).toEqual([
`a:started:${id}`,
`b:started:${id}`,
Expand Down Expand Up @@ -91,10 +93,10 @@ describe("createAiSdkHookBridge", () => {
it("passes the identity captured at model-call start to the context runner", async () => {
const ids: string[] = [];
const hooks = createInstrumentationHooks([
{ events: { "model.call.started": (event) => void ids.push(event.id) } },
{ events: { "model.call.started": (event) => void ids.push(event.idempotencyKey) } },
]);
const bridge = createAiSdkHookBridge(scope, hooks, (operation, execute) => {
ids.push(operation.id);
ids.push(operation.idempotencyKey);
return execute();
});
Reflect.apply(bridge.onStart!, bridge, [
Expand All @@ -108,7 +110,7 @@ describe("createAiSdkHookBridge", () => {

await bridge.executeLanguageModelCall!({ callId: "call-1", execute: async () => "result" });

const expected = `${scope.attemptId}:model:call-1:0`;
const expected = `model:${scope.attemptId}:0`;
expect(ids).toEqual([expected, expected]);
});

Expand All @@ -126,6 +128,23 @@ describe("createAiSdkHookBridge", () => {
expect(adapterCalls).toBe(0);
});

it("derives replay-stable model identity without the AI SDK call ID", async () => {
const keys: string[] = [];
const hooks = createInstrumentationHooks([
{ events: { "model.call.started": (event) => void keys.push(event.idempotencyKey) } },
]);

for (const callId of ["sdk-random-1", "sdk-random-2"]) {
const bridge = createAiSdkHookBridge(scope, hooks);
await Reflect.apply(bridge.onStepStart!, bridge, [{ callId, stepNumber: 2 }]);
await Reflect.apply(bridge.onLanguageModelCallStart!, bridge, [
{ callId, messages: [], modelId: "model", provider: "test", tools: undefined },
]);
}

expect(keys).toEqual([`model:${scope.attemptId}:2`, `model:${scope.attemptId}:2`]);
});

it("publishes step provider metadata as step.metadata, skipping steps without any", async () => {
const events: InstrumentationStepAttemptMetadataEvent[] = [];
const hooks = createInstrumentationHooks([
Expand All @@ -146,6 +165,7 @@ describe("createAiSdkHookBridge", () => {

expect(events).toEqual([
{
idempotencyKey: `step:${scope.attemptId}`,
providerMetadata: { gateway: { cost: "0.000082" } },
scope,
type: "step.attempt.metadata",
Expand Down Expand Up @@ -225,6 +245,7 @@ describe("createAiSdkHookBridge", () => {
await Reflect.apply(bridge.onStepStart!, bridge, [{ callId: "call-1", stepNumber: 0 }]);

const expected = {
idempotencyKey: `step:${scope.attemptId}`,
operation: { modelId: "model", operationId: "ai.streamText", provider: "test" },
scope,
type: "step.attempt.started",
Expand Down Expand Up @@ -286,7 +307,7 @@ describe("createAiSdkHookBridge", () => {
]);

expect(before).toHaveBeenCalledExactlyOnceWith({
id: `${scope.attemptId}:model:call-1:0`,
idempotencyKey: `model:${scope.attemptId}:0`,
input: { instructions: "be brief", messages: [{ content: "hi", role: "user" }] },
model: { modelId: "model", provider: "test" },
scope,
Expand All @@ -303,7 +324,7 @@ describe("createAiSdkHookBridge", () => {
{ error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" },
],
finishReason: "tool-calls",
id: `${scope.attemptId}:model:call-1:0`,
idempotencyKey: `model:${scope.attemptId}:0`,
scope,
type: "model.call.completed",
usage: {
Expand Down Expand Up @@ -347,15 +368,15 @@ describe("createAiSdkHookBridge", () => {

expect(before).toHaveBeenCalledExactlyOnceWith({
callId: "tool-1",
id: `${scope.attemptId}:tool:tool-1:0`,
idempotencyKey: `tool:${scope.attemptId}:tool-1:0`,
input: { q: "eve" },
kind: "tool-call",
scope,
toolName: "search",
type: "tool.call.started",
});
expect(after).toHaveBeenCalledExactlyOnceWith({
id: `${scope.attemptId}:tool:tool-1:0`,
idempotencyKey: `tool:${scope.attemptId}:tool-1:0`,
output: expected,
scope,
type: "tool.call.completed",
Expand Down Expand Up @@ -389,9 +410,9 @@ describe("createAiSdkHookBridge", () => {
return {
events: {
"model.call.completed": (event) => {
observed.set(name, own.get(event.id));
observed.set(name, own.get(event.idempotencyKey));
},
"model.call.started": (event) => void own.set(event.id, `${name}-state`),
"model.call.started": (event) => void own.set(event.idempotencyKey, `${name}-state`),
},
};
};
Expand Down Expand Up @@ -450,14 +471,14 @@ describe("createAiSdkHookBridge", () => {
events: {
async "tool.call.started"(event) {
started.set(
event.id,
event.idempotencyKey,
await new Promise<string>((resolve) => {
resolvers.set(event.id, () => resolve(`state:${event.id}`));
resolvers.set(event.idempotencyKey, () => resolve(`state:${event.idempotencyKey}`));
}),
);
},
"tool.call.completed"(event) {
terminalStates.set(event.id, started.get(event.id));
terminalStates.set(event.idempotencyKey, started.get(event.idempotencyKey));
},
},
},
Expand All @@ -474,8 +495,8 @@ describe("createAiSdkHookBridge", () => {
const second = start("tool-2");
await vi.waitFor(() => expect(resolvers.size).toBe(2));

const firstId = `${scope.attemptId}:tool:tool-1:0`;
const secondId = `${scope.attemptId}:tool:tool-2:0`;
const firstId = `tool:${scope.attemptId}:tool-1:0`;
const secondId = `tool:${scope.attemptId}:tool-2:0`;
resolvers.get(secondId)!();
resolvers.get(firstId)!();
await Promise.all([first, second]);
Expand Down
99 changes: 54 additions & 45 deletions packages/eve/src/harness/ai-sdk-hook-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import type {
InstrumentationToolOutput,
InstrumentationUsage,
} from "#harness/instrumentation-lifecycle.js";
import {
attemptIdempotencyKey,
modelCallIdempotencyKey,
toolCallIdempotencyKey,
} from "#harness/instrumentation-lifecycle.js";

type TelemetryEvent<TKey extends keyof Telemetry> = Parameters<NonNullable<Telemetry[TKey]>>[0];

Expand All @@ -25,10 +30,10 @@ type TelemetryEvent<TKey extends keyof Telemetry> = Parameters<NonNullable<Telem
export type ActionKindResolver = (toolName: string) => InstrumentationActionKind;

interface AttemptState {
readonly modelIds: Map<string, string>;
readonly modelKeys: Map<string, string>;
readonly resolveActionKind: ActionKindResolver;
readonly scope: InstrumentationAttemptScope;
readonly toolIds: Map<string, string>;
readonly toolKeys: Map<string, string>;
operation?: InstrumentationOperationRef;
// Only the number is kept: it disambiguates call identities within an attempt.
stepNumber?: number;
Expand All @@ -42,10 +47,10 @@ export function createAiSdkHookBridge(
resolveActionKind: ActionKindResolver = defaultResolveActionKind,
): Telemetry {
const state: AttemptState = {
modelIds: new Map(),
modelKeys: new Map(),
resolveActionKind,
scope,
toolIds: new Map(),
toolKeys: new Map(),
};

return {
Expand All @@ -62,22 +67,22 @@ export function createAiSdkHookBridge(
if (started !== undefined) await hooks.publish(started);
},
async onLanguageModelCallStart(event) {
const id = createModelCallIdentity(state, event.callId);
state.modelIds.set(event.callId, id);
const started = toModelCallStarted(state, id, event);
const key = modelCallIdempotencyKey(state.scope, state.stepNumber ?? 0);
state.modelKeys.set(event.callId, key);
const started = toModelCallStarted(state, key, event);
await hooks.publish(started);
},
executeLanguageModelCall({ callId, execute }) {
const id = state.modelIds.get(callId);
return id === undefined
const key = state.modelKeys.get(callId);
return key === undefined
? execute()
: runInContext({ id, scope, type: "model.call" }, execute);
: runInContext({ idempotencyKey: key, scope, type: "model.call" }, execute);
},
async onLanguageModelCallEnd(event) {
const id = state.modelIds.get(event.callId);
if (id === undefined) return;
state.modelIds.delete(event.callId);
const completed = toModelCallCompleted(state, id, event);
const key = state.modelKeys.get(event.callId);
if (key === undefined) return;
state.modelKeys.delete(event.callId);
const completed = toModelCallCompleted(state, key, event);
await hooks.publish(completed);
},
async onStepEnd(event) {
Expand All @@ -87,28 +92,35 @@ export function createAiSdkHookBridge(
if (event.providerMetadata === undefined) return;
await hooks.publish(
Object.freeze({
idempotencyKey: attemptIdempotencyKey(state.scope),
providerMetadata: event.providerMetadata,
scope: state.scope,
type: "step.attempt.metadata",
}),
);
},
async onToolExecutionStart(event) {
const id = createToolCallIdentity(state, event.toolCall.toolCallId);
state.toolIds.set(event.toolCall.toolCallId, id);
const started = toToolCallStarted(state, id, event);
const key = toolCallIdempotencyKey(
state.scope,
event.toolCall.toolCallId,
state.stepNumber ?? 0,
);
state.toolKeys.set(event.toolCall.toolCallId, key);
const started = toToolCallStarted(state, key, event);
await hooks.publish(started);
},
executeTool({ toolCallId, execute }) {
const id = state.toolIds.get(toolCallId);
return id === undefined ? execute() : runInContext({ id, scope, type: "tool.call" }, execute);
const key = state.toolKeys.get(toolCallId);
return key === undefined
? execute()
: runInContext({ idempotencyKey: key, scope, type: "tool.call" }, execute);
},
async onToolExecutionEnd(event) {
const toolCallId = event.toolCall.toolCallId;
const id = state.toolIds.get(toolCallId);
if (id === undefined) return;
state.toolIds.delete(toolCallId);
const completed = toToolCallCompleted(state, id, event);
const key = state.toolKeys.get(toolCallId);
if (key === undefined) return;
state.toolKeys.delete(toolCallId);
const completed = toToolCallCompleted(state, key, event);
await hooks.publish(completed);
},
async onAbort(event) {
Expand All @@ -121,14 +133,18 @@ export function createAiSdkHookBridge(

async function failOpenOperations(error: unknown): Promise<void> {
const pending: Promise<void>[] = [];
for (const id of state.modelIds.values()) {
pending.push(hooks.publish(Object.freeze({ error, id, scope, type: "model.call.failed" })));
for (const idempotencyKey of state.modelKeys.values()) {
pending.push(
hooks.publish(Object.freeze({ error, idempotencyKey, scope, type: "model.call.failed" })),
);
}
for (const id of state.toolIds.values()) {
pending.push(hooks.publish(Object.freeze({ error, id, scope, type: "tool.call.failed" })));
for (const idempotencyKey of state.toolKeys.values()) {
pending.push(
hooks.publish(Object.freeze({ error, idempotencyKey, scope, type: "tool.call.failed" })),
);
}
state.modelIds.clear();
state.toolIds.clear();
state.modelKeys.clear();
state.toolKeys.clear();
await Promise.all(pending);
}
}
Expand All @@ -142,23 +158,20 @@ function toStepAttemptStarted(
): InstrumentationStepAttemptStartedEvent | undefined {
if (state.operation === undefined || state.stepNumber === undefined) return undefined;
return Object.freeze({
idempotencyKey: attemptIdempotencyKey(state.scope),
operation: state.operation,
scope: state.scope,
type: "step.attempt.started",
});
}

function createModelCallIdentity(state: AttemptState, callId: string): string {
return `${state.scope.attemptId}:model:${callId}:${state.stepNumber ?? 0}`;
}

function toModelCallStarted(
state: AttemptState,
id: string,
idempotencyKey: string,
source: TelemetryEvent<"onLanguageModelCallStart">,
): InstrumentationModelCallStartedEvent {
return Object.freeze({
id,
idempotencyKey,
input: Object.freeze({
instructions: source.instructions,
messages: Object.freeze([...source.messages]),
Expand All @@ -171,13 +184,13 @@ function toModelCallStarted(

function toModelCallCompleted(
state: AttemptState,
id: string,
idempotencyKey: string,
source: TelemetryEvent<"onLanguageModelCallEnd">,
): InstrumentationModelCallCompletedEvent {
return Object.freeze({
content: toContentParts(source.content),
finishReason: source.finishReason,
id,
idempotencyKey,
scope: state.scope,
type: "model.call.completed",
usage: toUsage(source.usage),
Expand Down Expand Up @@ -238,18 +251,14 @@ function toContentParts(
return Object.freeze(parts);
}

function createToolCallIdentity(state: AttemptState, toolCallId: string): string {
return `${state.scope.attemptId}:tool:${toolCallId}:${state.stepNumber ?? 0}`;
}

function toToolCallStarted(
state: AttemptState,
id: string,
idempotencyKey: string,
source: TelemetryEvent<"onToolExecutionStart">,
): InstrumentationToolCallStartedEvent {
return Object.freeze({
callId: source.toolCall.toolCallId,
id,
idempotencyKey,
input: source.toolCall.input,
kind: state.resolveActionKind(source.toolCall.toolName),
scope: state.scope,
Expand All @@ -260,11 +269,11 @@ function toToolCallStarted(

function toToolCallCompleted(
state: AttemptState,
id: string,
idempotencyKey: string,
source: TelemetryEvent<"onToolExecutionEnd">,
): InstrumentationToolCallCompletedEvent {
return Object.freeze({
id,
idempotencyKey,
output: toToolOutput(source.toolOutput),
scope: state.scope,
type: "tool.call.completed",
Expand Down
Loading