From bfd380dd55dfa067d00dd478ca95cc938d001ec1 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Tue, 4 Aug 2026 14:06:30 -0500 Subject: [PATCH] refactor(eve): eve-owned instrumentation event payloads The lifecycle contract derived five event fields from the AI SDK's callback types via `Parameters>[0]`, so any provider reading them was coupled to the model SDK's shapes. Publishing that bus would make an SDK upgrade a breaking change for every integration rather than for eve's own mapping code. Replace those fields with eve types derived from what the OTel provider actually reads, and confine the AI SDK types to `ai-sdk-hook-bridge.ts`, which is the mapping boundary. Guard rule 37 keeps `instrumentation-lifecycle.ts` from importing `ai` again. `attempt.started` loses its `step` field, which had no consumer. Spans and attributes are unchanged. Signed-off-by: Chad Hietala --- .changeset/lucky-donkeys-listen.md | 5 + .../src/harness/ai-sdk-hook-bridge.test.ts | 144 +++++++++++++++--- .../eve/src/harness/ai-sdk-hook-bridge.ts | 99 +++++++++--- .../src/harness/instrumentation-lifecycle.ts | 75 +++++++-- .../eve/src/tracing/agent-otel-provider.ts | 57 +++---- scripts/guard-invariants.mjs | 37 +++++ 6 files changed, 338 insertions(+), 79 deletions(-) create mode 100644 .changeset/lucky-donkeys-listen.md diff --git a/.changeset/lucky-donkeys-listen.md b/.changeset/lucky-donkeys-listen.md new file mode 100644 index 000000000..4d12482f8 --- /dev/null +++ b/.changeset/lucky-donkeys-listen.md @@ -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. diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts index 4093b51ca..d3897946e 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts @@ -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 void>(); const terminalStates = new Map(); diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.ts index 80945ae65..d72162e0f 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.ts @@ -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 = Parameters>[0]; @@ -17,8 +21,9 @@ interface AttemptState { readonly modelIds: Map; readonly scope: InstrumentationAttemptScope; readonly toolIds: Map; - operationStart?: Readonly>; - stepStart?: Readonly>; + 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. */ @@ -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); }, @@ -114,22 +123,17 @@ export function createAiSdkHookBridge( const directRunInContext: InstrumentationContextRunner = (_operation, execute) => execute(); -function snapshot(event: T): Readonly { - 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( @@ -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", }; } @@ -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( @@ -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", }; } @@ -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" }; +} diff --git a/packages/eve/src/harness/instrumentation-lifecycle.ts b/packages/eve/src/harness/instrumentation-lifecycle.ts index b352b3b22..055f38903 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.ts @@ -1,9 +1,5 @@ -import type { Telemetry } from "ai"; - import { createLogger, formatError } from "#internal/logging.js"; -type TelemetryEvent = Parameters>[0]; - /** Stable eve identity for one actual model attempt. */ export interface InstrumentationAttemptScope { readonly attemptId: string; @@ -15,11 +11,65 @@ export interface InstrumentationAttemptScope { readonly turnId: string; } +/** The model SDK operation an attempt runs through. */ +export interface InstrumentationOperationRef { + readonly modelId: string; + readonly operationId: string; + readonly provider: string; +} + +export interface InstrumentationModelRef { + readonly modelId: string; + readonly provider: string; +} + +/** Token usage for one model call. A field is absent when the provider omits it. */ +export interface InstrumentationUsage { + readonly inputTokenDetails?: { + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; + }; + readonly inputTokens?: number; + readonly outputTokens?: number; +} + +/** Final model input for one call. Message shape stays opaque to this layer. */ +export interface InstrumentationModelInput { + readonly instructions?: unknown; + readonly messages: readonly unknown[]; +} + +/** + * The model response parts eve records. A kind outside this union is dropped + * when the bridge maps a response, so widening the union is what makes a new + * kind reachable by a provider. + */ +export type InstrumentationContentPart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "reasoning"; readonly text: string } + | { readonly type: "tool-call"; readonly input: unknown; readonly toolName: string } + | { + readonly type: "tool-result"; + readonly input: unknown; + readonly output: unknown; + readonly toolName: string; + } + | { + readonly type: "tool-error"; + readonly error: unknown; + readonly input: unknown; + readonly toolName: string; + }; + +/** How one tool execution ended. */ +export type InstrumentationToolOutput = + | { readonly type: "result"; readonly output: unknown } + | { readonly type: "error"; readonly error: unknown }; + export interface InstrumentationAttemptStartedEvent { readonly type: "attempt.started"; + readonly operation: InstrumentationOperationRef; readonly scope: InstrumentationAttemptScope; - readonly operation: TelemetryEvent<"onStart">; - readonly step: TelemetryEvent<"onStepStart">; } export interface InstrumentationSessionStartedEvent { @@ -92,15 +142,18 @@ export interface InstrumentationAttemptMetadataEvent { export interface InstrumentationModelCallStartedEvent { readonly type: "model.call.started"; readonly id: string; + readonly input: InstrumentationModelInput; + readonly model: InstrumentationModelRef; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onLanguageModelCallStart">; } export interface InstrumentationModelCallCompletedEvent { readonly type: "model.call.completed"; + readonly content: readonly InstrumentationContentPart[]; + readonly finishReason: string; readonly id: string; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onLanguageModelCallEnd">; + readonly usage: InstrumentationUsage; } export interface InstrumentationModelCallFailedEvent { @@ -116,16 +169,18 @@ export type InstrumentationModelCallTerminalEvent = export interface InstrumentationToolCallStartedEvent { readonly type: "tool.call.started"; + readonly callId: string; readonly id: string; + readonly input: unknown; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onToolExecutionStart">; + readonly toolName: string; } export interface InstrumentationToolCallCompletedEvent { readonly type: "tool.call.completed"; readonly id: string; + readonly output: InstrumentationToolOutput; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onToolExecutionEnd">; } export interface InstrumentationToolCallFailedEvent { diff --git a/packages/eve/src/tracing/agent-otel-provider.ts b/packages/eve/src/tracing/agent-otel-provider.ts index 73ac55810..9f479b58c 100644 --- a/packages/eve/src/tracing/agent-otel-provider.ts +++ b/packages/eve/src/tracing/agent-otel-provider.ts @@ -33,6 +33,7 @@ import type { InstrumentationToolCallTerminalEvent, InstrumentationTurnStartedEvent, InstrumentationTurnTerminalEvent, + InstrumentationUsage, } from "#harness/instrumentation-lifecycle.js"; interface SpanState { @@ -303,23 +304,23 @@ export function createAgentOtelInstrumentation( const beforeModelCall = (event: InstrumentationModelCallStartedEvent): SpanState | undefined => { const attempt = steps.get(event.scope); if (attempt === undefined) return undefined; - attempt.step.span.setAttribute("agent.model.id", event.source.modelId); - attempt.step.span.setAttribute("agent.model.provider", event.source.provider); + attempt.step.span.setAttribute("agent.model.id", event.model.modelId); + attempt.step.span.setAttribute("agent.model.provider", event.model.provider); const span = input.tracer.startSpan( modelSpanName(attempt.operation.name), { attributes: { "gen_ai.operation.name": attempt.operation.name, - "gen_ai.provider.name": event.source.provider, - "gen_ai.request.model": event.source.modelId, + "gen_ai.provider.name": event.model.provider, + "gen_ai.request.model": event.model.modelId, }, }, attempt.operation.context, ); if (captureContent) { - const messages = messagesContentAttribute(event.source.messages); + const messages = messagesContentAttribute(event.input.messages); if (messages !== undefined) span.setAttribute("ai.prompt.messages", messages); - const system = systemPromptAttribute(event.source.instructions); + const system = systemPromptAttribute(event.input.instructions); if (system !== undefined) span.setAttribute("ai.prompt.system", system); } const state = { context: trace.setSpan(attempt.operation.context, span), span }; @@ -333,13 +334,13 @@ export function createAgentOtelInstrumentation( if (event.type === "model.call.failed") { recordError(state.span, event.error); } else { - setUsage(state.span, event.source.usage); + setUsage(state.span, event.usage); const attempt = steps.get(event.scope); - if (attempt !== undefined) setUsage(attempt.step.span, event.source.usage); + if (attempt !== undefined) setUsage(attempt.step.span, event.usage); if (captureContent) { - state.span.setAttribute("ai.response.finish_reason", event.source.finishReason); + state.span.setAttribute("ai.response.finish_reason", event.finishReason); const reasoning = textContentAttribute( - event.source.content + event.content .filter((part) => part.type === "reasoning") .map((part) => part.text) .filter((part) => part.trim().length > 0) @@ -347,13 +348,13 @@ export function createAgentOtelInstrumentation( ); if (reasoning !== undefined) state.span.setAttribute("ai.response.reasoning", reasoning); const text = textContentAttribute( - event.source.content + event.content .filter((part) => part.type === "text") .map((part) => part.text) .join(""), ); if (text !== undefined) state.span.setAttribute("ai.response.text", text); - const toolCalls = event.source.content + const toolCalls = event.content .filter((part) => part.type === "tool-call") .map((part) => ({ input: part.input, toolName: part.toolName })); if (toolCalls.length > 0) { @@ -363,7 +364,7 @@ export function createAgentOtelInstrumentation( // Provider-executed tools (e.g. web_search) run inside the model call, // never reach eve's tool loop, and so never get an ai.toolCall span. // Their results only exist as content parts on the model response. - const toolResults = event.source.content + const toolResults = event.content .filter((part) => part.type === "tool-result" || part.type === "tool-error") .map((part) => part.type === "tool-result" @@ -388,9 +389,9 @@ export function createAgentOtelInstrumentation( "agent.action", { attributes: { - "agent.action.call_id": event.source.toolCall.toolCallId, + "agent.action.call_id": event.callId, "agent.action.kind": "tool", - "agent.action.name": event.source.toolCall.toolName, + "agent.action.name": event.toolName, "agent.framework.name": "eve", "agent.framework.version": input.frameworkVersion, "agent.root.session.id": event.scope.rootSessionId ?? event.scope.sessionId, @@ -408,14 +409,14 @@ export function createAgentOtelInstrumentation( { attributes: { "gen_ai.operation.name": "execute_tool", - "gen_ai.tool.call.id": event.source.toolCall.toolCallId, - "gen_ai.tool.name": event.source.toolCall.toolName, + "gen_ai.tool.call.id": event.callId, + "gen_ai.tool.name": event.toolName, }, }, actionContext, ); if (captureContent) { - const args = contentAttribute(event.source.toolCall.input, false); + const args = contentAttribute(event.input, false); if (args !== undefined) toolSpan.setAttribute("gen_ai.tool.call.arguments", args); } const state: ToolSpanState = { @@ -433,11 +434,11 @@ export function createAgentOtelInstrumentation( if (event.type === "tool.call.failed") { recordError(state.toolSpan, event.error); recordError(state.span, event.error); - } else if (event.source.toolOutput.type !== "tool-result") { - recordError(state.toolSpan, event.source.toolOutput.error); - recordError(state.span, event.source.toolOutput.error); + } else if (event.output.type === "error") { + recordError(state.toolSpan, event.output.error); + recordError(state.span, event.output.error); } else if (captureContent) { - const result = contentAttribute(event.source.toolOutput.output, false); + const result = contentAttribute(event.output.output, false); if (result !== undefined) state.toolSpan.setAttribute("gen_ai.tool.call.result", result); } state.toolSpan.end(); @@ -658,17 +659,7 @@ function modelSpanName(operationName: string): string { : "ai.streamText.doStream"; } -function setUsage( - span: Span, - usage: { - readonly inputTokenDetails?: { - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; - }; - readonly inputTokens?: number; - readonly outputTokens?: number; - }, -): void { +function setUsage(span: Span, usage: InstrumentationUsage): void { if (usage.inputTokens !== undefined) { span.setAttribute("agent.usage.input_tokens", usage.inputTokens); } diff --git a/scripts/guard-invariants.mjs b/scripts/guard-invariants.mjs index 9405c2835..655063123 100644 --- a/scripts/guard-invariants.mjs +++ b/scripts/guard-invariants.mjs @@ -93,6 +93,12 @@ * dropped, every retained epoch needs a compiling fixture, and * every public authoring value must belong to a capability. * + * rule 37 — The instrumentation lifecycle contract stays provider-neutral. + * `harness/instrumentation-lifecycle.ts` must not import from + * `ai`: its event payloads are eve's published shape, so deriving + * them from the model SDK's callback types would make an SDK + * upgrade a breaking change for every provider. Map at the bridge. + * * Baselines for rules with pre-existing violations live in * `guard-invariants-baseline.json`. Counts and allowlists in that file * may only shrink (as offenders are removed) — they may never grow. @@ -182,6 +188,7 @@ function isTsLike(relPath) { * rule28: Violation[]; * rule33: Violation[]; * rule35: Violation[]; + * rule37: Violation[]; * symlinks: string[]; * }} state */ @@ -210,6 +217,7 @@ async function scanRepo(state) { checkRule28(posix, lines, state.rule28); checkRule33(posix, lines, state.rule33); checkRule35(posix, lines, state.rule35); + checkRule37(posix, lines, state.rule37); } } @@ -332,6 +340,33 @@ function checkRule35(posix, lines, violations) { }); } +// ---------- Rule 37: provider-neutral lifecycle contract ---------- + +const LIFECYCLE_CONTRACT = "packages/eve/src/harness/instrumentation-lifecycle.ts"; +const AI_SPECIFIER_RE = /["']ai(?:\/[^"']+)?["']/; + +/** + * @param {string} posix + * @param {string[]} lines + * @param {Violation[]} violations + */ +function checkRule37(posix, lines, violations) { + if (posix !== LIFECYCLE_CONTRACT) return; + lines.forEach((line, idx) => { + const isImport = + /^(?:import|export)\b|^}\s*from\b|\b(?:import|require)\s*\(/.test(line.trimStart()) && + AI_SPECIFIER_RE.test(line); + if (isImport) { + violations.push({ + rule: 37, + file: posix, + line: idx + 1, + message: `imports from "ai". Lifecycle event payloads are eve's own shape, so an AI SDK type reaching them makes an SDK upgrade a breaking change for every provider. Add an eve type here and map to it in ai-sdk-hook-bridge.ts.`, + }); + } + }); +} + // ---------- Rule 19: AsyncLocalStorage instances ---------- const NEW_ALS_RE = /new\s+AsyncLocalStorage\s*[<(]/; @@ -1066,6 +1101,7 @@ async function main() { rule28: /** @type {Violation[]} */ ([]), rule33: /** @type {Violation[]} */ ([]), rule35: /** @type {Violation[]} */ ([]), + rule37: /** @type {Violation[]} */ ([]), symlinks: /** @type {string[]} */ ([]), }; @@ -1156,6 +1192,7 @@ async function main() { // Rule 35 violations.push(...state.rule35); + violations.push(...state.rule37); // Rule 36 for (const issue of await checkExtensionCapabilityContracts()) {