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

Instrumentation lifecycle events now carry eve-owned payloads instead of the AI SDK's callback types. Internal groundwork for a public provider surface; no change to the spans or attributes eve records.
144 changes: 125 additions & 19 deletions packages/eve/src/harness/ai-sdk-hook-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,30 +199,136 @@ describe("createAiSdkHookBridge", () => {
);
});

it("publishes frozen callback snapshots", async () => {
it("projects the operation callback onto eve fields only", async () => {
const started = vi.fn();
const hooks = createInstrumentationHooks([{ events: { "attempt.started": started } }]);
const bridge = createAiSdkHookBridge(scope, hooks);
const operation = {
callId: "call-1",
modelId: "model",
operationId: "ai.streamText",
provider: "test",
};
const step = { callId: "call-1", stepNumber: 0 };

Reflect.apply(bridge.onStart!, bridge, [operation]);
await Reflect.apply(bridge.onStepStart!, bridge, [step]);

expect(started).toHaveBeenCalledOnce();
const event = started.mock.calls[0]?.[0];
expect(event).toEqual({ operation, scope, step, type: "attempt.started" });
expect(event.operation).not.toBe(operation);
expect(event.step).not.toBe(step);
expect(Object.isFrozen(event.operation)).toBe(true);
expect(Object.isFrozen(event.step)).toBe(true);

Reflect.apply(bridge.onStart!, bridge, [
{ callId: "call-1", modelId: "model", operationId: "ai.streamText", provider: "test" },
]);
await Reflect.apply(bridge.onStepStart!, bridge, [{ callId: "call-1", stepNumber: 0 }]);

expect(started).toHaveBeenCalledExactlyOnceWith({
operation: { modelId: "model", operationId: "ai.streamText", provider: "test" },
scope,
type: "attempt.started",
});
});

it("projects the model call callbacks onto eve fields only", async () => {
const before = vi.fn(() => "state");
const after = vi.fn();
const hooks = createInstrumentationHooks([{ events: { "model.call": { after, before } } }]);
const bridge = createAiSdkHookBridge(scope, hooks);

await Reflect.apply(bridge.onLanguageModelCallStart!, bridge, [
{
callId: "call-1",
instructions: "be brief",
messages: [{ content: "hi", role: "user" }],
modelId: "model",
provider: "test",
tools: undefined,
},
]);
await Reflect.apply(bridge.onLanguageModelCallEnd!, bridge, [
{
callId: "call-1",
content: [
{ text: "thinking", type: "reasoning" },
{ text: "hello", type: "text" },
{ input: { a: 1 }, toolName: "search", type: "tool-call" },
{ input: { a: 1 }, output: "ok", toolName: "search", type: "tool-result" },
{ error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" },
{ type: "some-future-kind" },
],
finishReason: "tool-calls",
performance: { responseTimeMs: 1 },
responseId: "response-1",
usage: {
inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 4 },
inputTokens: 1,
outputTokens: 2,
},
},
]);

expect(before).toHaveBeenCalledExactlyOnceWith({
id: `${scope.attemptId}:model:call-1:0`,
input: { instructions: "be brief", messages: [{ content: "hi", role: "user" }] },
model: { modelId: "model", provider: "test" },
scope,
type: "model.call.started",
});
// An unrecognized part kind is dropped rather than forwarded, so widening
// InstrumentationContentPart is what makes a new kind reachable.
expect(after).toHaveBeenCalledExactlyOnceWith(
{
content: [
{ text: "thinking", type: "reasoning" },
{ text: "hello", type: "text" },
{ input: { a: 1 }, toolName: "search", type: "tool-call" },
{ input: { a: 1 }, output: "ok", toolName: "search", type: "tool-result" },
{ error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" },
],
finishReason: "tool-calls",
id: `${scope.attemptId}:model:call-1:0`,
scope,
type: "model.call.completed",
usage: {
inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 4 },
inputTokens: 1,
outputTokens: 2,
},
},
"state",
);
});

it.each([
{
expected: { output: "ok", type: "result" },
toolOutput: { output: "ok", type: "tool-result" },
},
{
expected: { error: "boom", type: "error" },
toolOutput: { error: "boom", type: "tool-error" },
},
])(
"collapses tool output $toolOutput.type onto $expected.type",
async ({ expected, toolOutput }) => {
const before = vi.fn(() => "state");
const after = vi.fn();
const hooks = createInstrumentationHooks([{ events: { "tool.call": { after, before } } }]);
const bridge = createAiSdkHookBridge(scope, hooks);
const toolCall = { input: { q: "eve" }, toolCallId: "tool-1", toolName: "search" };

await Reflect.apply(bridge.onToolExecutionStart!, bridge, [{ callId: "call-1", toolCall }]);
await Reflect.apply(bridge.onToolExecutionEnd!, bridge, [
{ callId: "call-1", toolCall, toolExecutionMs: 1, toolOutput },
]);

expect(before).toHaveBeenCalledExactlyOnceWith({
callId: "tool-1",
id: `${scope.attemptId}:tool:tool-1:0`,
input: { q: "eve" },
scope,
toolName: "search",
type: "tool.call.started",
});
expect(after).toHaveBeenCalledExactlyOnceWith(
{
id: `${scope.attemptId}:tool:tool-1:0`,
output: expected,
scope,
type: "tool.call.completed",
},
"state",
);
},
);

it("retains state for parallel tool starts", async () => {
const resolvers = new Map<string, () => void>();
const terminalStates = new Map<string, unknown>();
Expand Down
99 changes: 82 additions & 17 deletions packages/eve/src/harness/ai-sdk-hook-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import type { Telemetry } from "ai";
import type {
InstrumentationAttemptScope,
InstrumentationAttemptStartedEvent,
InstrumentationContentPart,
InstrumentationContextRunner,
InstrumentationHooks,
InstrumentationModelCallCompletedEvent,
InstrumentationModelCallStartedEvent,
InstrumentationOperationRef,
InstrumentationToolCallCompletedEvent,
InstrumentationToolCallStartedEvent,
InstrumentationToolOutput,
InstrumentationUsage,
} from "#harness/instrumentation-lifecycle.js";

type TelemetryEvent<TKey extends keyof Telemetry> = Parameters<NonNullable<Telemetry[TKey]>>[0];
Expand All @@ -17,8 +21,9 @@ interface AttemptState {
readonly modelIds: Map<string, string>;
readonly scope: InstrumentationAttemptScope;
readonly toolIds: Map<string, string>;
operationStart?: Readonly<TelemetryEvent<"onStart">>;
stepStart?: Readonly<TelemetryEvent<"onStepStart">>;
operation?: InstrumentationOperationRef;
// Only the number is kept: it disambiguates call identities within an attempt.
stepNumber?: number;
}

/** Creates one provider-neutral AI SDK bridge for one actual model attempt. */
Expand All @@ -35,10 +40,14 @@ export function createAiSdkHookBridge(

return {
onStart(event) {
state.operationStart = snapshot(event);
state.operation = {
modelId: event.modelId,
operationId: event.operationId,
provider: event.provider,
};
},
async onStepStart(event) {
state.stepStart = snapshot(event);
state.stepNumber = event.stepNumber;
const started = toAttemptStarted(state);
if (started !== undefined) await hooks.publish(started);
},
Expand Down Expand Up @@ -114,22 +123,17 @@ export function createAiSdkHookBridge(

const directRunInContext: InstrumentationContextRunner = (_operation, execute) => execute();

function snapshot<T extends object>(event: T): Readonly<T> {
return Object.freeze({ ...event });
}

function toAttemptStarted(state: AttemptState): InstrumentationAttemptStartedEvent | undefined {
if (state.operationStart === undefined || state.stepStart === undefined) return undefined;
if (state.operation === undefined || state.stepNumber === undefined) return undefined;
return {
operation: state.operationStart,
operation: state.operation,
scope: state.scope,
step: state.stepStart,
type: "attempt.started",
};
}

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

function toModelCallStarted(
Expand All @@ -139,8 +143,9 @@ function toModelCallStarted(
): InstrumentationModelCallStartedEvent {
return {
id,
input: { instructions: source.instructions, messages: source.messages },
model: { modelId: source.modelId, provider: source.provider },
scope: state.scope,
source: snapshot(source),
type: "model.call.started",
};
}
Expand All @@ -151,15 +156,65 @@ function toModelCallCompleted(
source: TelemetryEvent<"onLanguageModelCallEnd">,
): InstrumentationModelCallCompletedEvent {
return {
content: toContentParts(source.content),
finishReason: source.finishReason,
id,
scope: state.scope,
source: snapshot(source),
type: "model.call.completed",
usage: toUsage(source.usage),
};
}

function toUsage(usage: TelemetryEvent<"onLanguageModelCallEnd">["usage"]): InstrumentationUsage {
return {
inputTokenDetails: {
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens,
},
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
};
}

/** Drops kinds eve does not record; see {@link InstrumentationContentPart}. */
function toContentParts(
content: TelemetryEvent<"onLanguageModelCallEnd">["content"],
): readonly InstrumentationContentPart[] {
const parts: InstrumentationContentPart[] = [];
for (const part of content) {
switch (part.type) {
case "text":
case "reasoning":
parts.push({ text: part.text, type: part.type });
break;
case "tool-call":
parts.push({ input: part.input, toolName: part.toolName, type: "tool-call" });
break;
case "tool-result":
parts.push({
input: part.input,
output: part.output,
toolName: part.toolName,
type: "tool-result",
});
break;
case "tool-error":
parts.push({
error: part.error,
input: part.input,
toolName: part.toolName,
type: "tool-error",
});
break;
default:
break;
}
}
return parts;
}

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

function toToolCallStarted(
Expand All @@ -168,9 +223,11 @@ function toToolCallStarted(
source: TelemetryEvent<"onToolExecutionStart">,
): InstrumentationToolCallStartedEvent {
return {
callId: source.toolCall.toolCallId,
id,
input: source.toolCall.input,
scope: state.scope,
source: snapshot(source),
toolName: source.toolCall.toolName,
type: "tool.call.started",
};
}
Expand All @@ -182,8 +239,16 @@ function toToolCallCompleted(
): InstrumentationToolCallCompletedEvent {
return {
id,
output: toToolOutput(source.toolOutput),
scope: state.scope,
source: snapshot(source),
type: "tool.call.completed",
};
}

function toToolOutput(
toolOutput: TelemetryEvent<"onToolExecutionEnd">["toolOutput"],
): InstrumentationToolOutput {
return toolOutput.type === "tool-result"
? { output: toolOutput.output, type: "result" }
: { error: toolOutput.error, type: "error" };
}
Loading