build(core): Upgrade agents to AI SDK 7 (#35072)

This commit is contained in:
Michael Drury
2026-07-28 17:13:00 +01:00
committed by GitHub
parent 587b23d886
commit 0ef049d12a
46 changed files with 1796 additions and 1224 deletions
+2 -2
View File
@@ -117,7 +117,7 @@
"@lezer/common": "^1.2.0",
"@mistralai/mistralai": "^1.10.0",
"@n8n/typeorm>@sentry/node": "catalog:sentry",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "2.8.0",
"@types/node": "^20.17.50",
"chokidar": "4.0.3",
@@ -141,7 +141,7 @@
"tmp": "0.2.7",
"nodemailer": "8.0.10",
"validator": "13.15.26",
"zod": "3.25.67",
"zod": "3.25.76",
"js-yaml": "4.3.0",
"body-parser": "2.3.0",
"glob@10": "10.5.0",
+5 -1
View File
@@ -2,6 +2,9 @@
"name": "@n8n/agents",
"version": "0.18.0",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=22.22"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
@@ -99,6 +102,7 @@
"@ai-sdk/mistral": "catalog:",
"@ai-sdk/openai": "catalog:",
"@ai-sdk/openai-compatible": "catalog:",
"@ai-sdk/otel": "catalog:",
"@ai-sdk/provider-utils": "catalog:",
"@ai-sdk/xai": "catalog:",
"@daytona/sdk": "catalog:",
@@ -153,7 +157,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
@@ -127,7 +127,7 @@
{
"type": "tool_result",
"tool_use_id": "toolu_01J4prxB28Ksk13jkhDsHkMd",
"content": "Invalid input for tool find_user: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"content": "AI_InvalidToolInputError: Invalid input for tool find_user: AI_TypeValidationError: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"is_error": true
}
]
@@ -237,7 +237,7 @@
{
"type": "tool_result",
"tool_use_id": "toolu_01J4prxB28Ksk13jkhDsHkMd",
"content": "Invalid input for tool find_user: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"content": "AI_InvalidToolInputError: Invalid input for tool find_user: AI_TypeValidationError: Type validation failed: Value: {\"age\":150}.\nError message: [\n {\n \"code\": \"too_big\",\n \"maximum\": 99,\n \"type\": \"number\",\n \"inclusive\": true,\n \"exact\": false,\n \"message\": \"age must be at most 99\",\n \"path\": [\n \"age\"\n ]\n }\n]",
"is_error": true
}
]
@@ -6,7 +6,7 @@
* LangSmith, and verifies the full pipeline works end-to-end.
*
* Pipeline under test:
* Agent.generate() → AI SDK (generateText with experimental_telemetry)
* Agent.generate() → AI SDK (generateText with telemetry)
* → OTel spans with ai.operationId → LangSmithOTLPSpanProcessor
* → LangSmithOTLPTraceExporter → HTTP POST → captured by local server
*/
@@ -176,7 +176,7 @@ describe('Telemetry → LangSmith integration', () => {
onStart: () => {
hookEvents.push('start');
},
onFinish: () => {
onEnd: () => {
hookEvents.push('finish');
},
})
@@ -42,8 +42,8 @@ describe('Zod validation errors surface to LLM and allow self-correction', () =>
'Find a user aged 150. If that age is invalid, use 25 instead and retry. You MUST find a user aged 150, and only then use 25',
);
expect(result.finishReason).toBe('stop');
expect(result.error).toBeUndefined();
expect(result.finishReason).toBe('stop');
// At least two tool-call messages: one rejected, one resolved
const allMessages = filterLlmMessages(result.messages);
@@ -16,7 +16,7 @@ const mockBatchProcessorInstances: Array<{
}> = [];
const mockProviderConfigs: unknown[] = [];
const mockAwaitPendingTraceBatches = vi.fn(async () => await Promise.resolve());
const mockTracer = { startSpan: vi.fn() };
const mockTracer = { startSpan: vi.fn(), startActiveSpan: vi.fn() };
const mockProvider = {
getTracer: vi.fn(() => mockTracer),
register: vi.fn(),
+2
View File
@@ -207,9 +207,11 @@ export { verify } from './sdk/verify';
export type { VerifyResult } from './sdk/verify';
export type {
ContentCitation,
ContentCustom,
ContentFile,
ContentMetadata,
ContentReasoning,
ContentReasoningFile,
ContentText,
ContentToolCall,
Message,
@@ -12,6 +12,7 @@
* The tool-result is inserted right after its tool-call, regardless of what
* messages follow it in the n8n list.
*/
import type { ModelMessage } from 'ai';
import { describe, it, expect } from 'vitest';
import type { Message } from '../../types/sdk/message';
@@ -154,6 +155,70 @@ describe('toAiMessages + fromAiMessages — round-trip', () => {
});
});
it('round-trips SDK 7 reasoning files with provider replay state', () => {
const aiMessage: ModelMessage = {
role: 'assistant',
content: [
{
type: 'reasoning-file',
data: 'base64-reasoning-data',
mediaType: 'application/octet-stream',
providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
},
],
};
const agentMessages = fromAiMessages([aiMessage]);
expect(agentMessages).toEqual([
{
role: 'assistant',
content: [
{
type: 'reasoning-file',
data: 'base64-reasoning-data',
mediaType: 'application/octet-stream',
providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
},
],
},
]);
expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
});
it('round-trips SDK 7 custom provider state', () => {
const aiMessage: ModelMessage = {
role: 'assistant',
content: [
{
type: 'custom',
kind: 'openai.compaction',
providerOptions: {
openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
},
},
],
};
const agentMessages = fromAiMessages([aiMessage]);
expect(agentMessages).toEqual([
{
role: 'assistant',
content: [
{
type: 'custom',
kind: 'openai.compaction',
providerOptions: {
openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
},
},
],
},
]);
expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
});
it.each([
{ name: 'no provider metadata', content: {} },
{
@@ -144,7 +144,7 @@ async function collectChunks(stream: ReadableStream<unknown>): Promise<StreamChu
/** Minimal successful streamText response. */
function makeStreamSuccess(text = 'Hello') {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -188,7 +188,7 @@ function makeStreamWithProviderTool(opts: {
};
const text = opts.text ?? 'done';
return {
fullStream: makeChunkStream([
stream: makeChunkStream([
{
type: 'tool-call',
toolCallId: opts.toolCallId,
@@ -197,7 +197,7 @@ function makeStreamWithProviderTool(opts: {
providerExecuted: true,
},
terminal,
{ type: 'text-delta', textDelta: text },
{ type: 'text-delta', id: 'text-1', text },
]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
@@ -349,29 +349,50 @@ describe('AgentRuntime — execution counters', () => {
expect(counter.incrementTokenCount).toHaveBeenCalledWith(15);
});
it('forwards onStepStart and onStepFinish to generateText and streamText', async () => {
it('forwards onStepStart and onStepEnd to generateText and streamText', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
streamText.mockReturnValue(makeStreamSuccess());
const onStepStart = vi.fn();
const onStepFinish = vi.fn();
const onStepEnd = vi.fn();
const { runtime } = createRuntime();
await runtime.generate('hi', { onStepStart, onStepFinish });
const streamResult = await runtime.stream('hi', { onStepStart, onStepFinish });
await runtime.generate('hi', { onStepStart, onStepEnd });
const streamResult = await runtime.stream('hi', { onStepStart, onStepEnd });
await collectChunks(streamResult.stream);
for (const call of generateText.mock.calls) {
const args = call[0] as Record<string, unknown>;
expect(args.experimental_onStepStart).toBe(onStepStart);
expect(args.onStepFinish).toBe(onStepFinish);
expect(args.onStepStart).toBe(onStepStart);
expect(args.onStepEnd).toBe(onStepEnd);
}
for (const call of streamText.mock.calls) {
const args = call[0] as Record<string, unknown>;
expect(args.experimental_onStepStart).toBe(onStepStart);
expect(args.onStepFinish).toBe(onStepFinish);
expect(args.onStepStart).toBe(onStepStart);
expect(args.onStepEnd).toBe(onStepEnd);
}
});
it('allows system-role messages in generateText and streamText history', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
streamText.mockReturnValue(makeStreamSuccess());
const input: Message[] = [
{ role: 'system', content: [{ type: 'text', text: 'Historical instruction' }] },
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
];
const { runtime } = createRuntime();
await runtime.generate(input);
const streamResult = await runtime.stream(input);
await collectChunks(streamResult.stream);
expect(generateText.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ allowSystemInMessages: true }),
);
expect(streamText.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ allowSystemInMessages: true }),
);
});
it('counts provider-executed tool calls when surfaced by the model', async () => {
generateText
.mockResolvedValueOnce({
@@ -545,7 +566,7 @@ describe('AgentRuntime — execution counters', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -949,7 +970,7 @@ describe('AgentRuntime — eager input persistence', () => {
// Same shape as the generate case, on the streaming path (the one Instance AI uses):
// the abort is handled by the StreamSession, which must persist the turn-so-far.
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'on it...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'on it...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -985,7 +1006,7 @@ describe('AgentRuntime — eager input persistence', () => {
// The model streams two text deltas, then the stop lands — before the turn
// completes, so its `newMessages` are never built (finishReason/response reject).
streamText.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield { type: 'text-delta', id: 't1', text: 'Here is my ' };
yield { type: 'text-delta', id: 't1', text: 'partial answer' };
await Promise.resolve();
@@ -1020,7 +1041,7 @@ describe('AgentRuntime — eager input persistence', () => {
// fires as the stream closes — before the loop folds the turn into the list. The
// completed text must still be recovered (the clear is deferred until the fold).
streamText.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield { type: 'text-delta', id: 't1', text: 'Complete answer' };
await Promise.resolve();
controller.abort();
@@ -1131,7 +1152,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// provider's `message_start` raw event already carried the input/cache +
// initial output tokens. The run must bill those.
streamText.mockReturnValue({
fullStream: (async function* () {
stream: (async function* () {
yield {
type: 'raw',
rawValue: {
@@ -1187,7 +1208,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
streamText
// Turn 1 completes with a tool call; its usage is folded into the total.
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -1207,7 +1228,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// Turn 2 aborts mid-stream after the provider reported its raw usage; the
// SDK promises reject so the only signal is the captured raw event.
.mockReturnValueOnce({
fullStream: (async function* () {
stream: (async function* () {
yield {
type: 'raw',
rawValue: {
@@ -1258,7 +1279,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
// Single turn: emits a raw usage event (captured) AND resolves its final
// usage. The raw capture must not be re-added on top of the folded total.
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([
stream: makeChunkStream([
{
type: 'raw',
rawValue: {
@@ -1310,7 +1331,7 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
streamText.mockReturnValue({
// Sync generator: `for await` consumes it fine and there's nothing to await.
fullStream: (function* () {
stream: (function* () {
yield {
type: 'raw',
rawValue: {
@@ -1339,19 +1360,19 @@ describe('AgentRuntime.stream() — usage billing on abort', () => {
expect(runtime.getState().status).toBe('cancelled');
});
it('requests includeRawChunks only when recoverUsageOnAbort is set', async () => {
it('requests raw chunks only when recoverUsageOnAbort is set', async () => {
streamText.mockReturnValue(makeStreamSuccess('ok'));
const off = createRuntime(undefined, 'anthropic/claude-sonnet-4-6');
await collectChunks((await off.runtime.stream('hello')).stream);
expect(streamText.mock.calls.at(-1)?.[0]).not.toHaveProperty('includeRawChunks');
expect(streamText.mock.calls.at(-1)?.[0]).not.toHaveProperty('include.rawChunks');
streamText.mockClear();
streamText.mockReturnValue(makeStreamSuccess('ok'));
const on = createRuntime(undefined, 'anthropic/claude-sonnet-4-6');
await collectChunks((await on.runtime.stream('hello', { recoverUsageOnAbort: true })).stream);
expect(streamText.mock.calls.at(-1)?.[0]).toMatchObject({ includeRawChunks: true });
expect(streamText.mock.calls.at(-1)?.[0]).toHaveProperty('include.rawChunks', true);
});
});
@@ -1367,7 +1388,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('resolves (never rejects) when the LLM stream throws', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -1381,7 +1402,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('yields an error chunk when the LLM stream throws', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -1399,7 +1420,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('closes the stream cleanly (with a finish chunk) after an error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -1423,7 +1444,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('sets state to "failed" after a stream error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -1456,7 +1477,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
const streamError = new Error('transient');
streamText
.mockReturnValueOnce({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -1483,7 +1504,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
it('never throws when consuming the stream after an error', async () => {
const streamError = new Error('stream broke');
streamText.mockReturnValue({
fullStream: makeErrorStream(streamError),
stream: makeErrorStream(streamError),
finishReason: silentReject(streamError),
usage: Promise.resolve(undefined),
response: silentReject(streamError),
@@ -2700,7 +2721,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], 1);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -2763,7 +2784,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], Infinity);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -2812,7 +2833,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
const { runtime } = createRuntimeWithTools([suspendTool], 1);
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -2887,7 +2908,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -3225,7 +3246,7 @@ describe('AgentRuntime.stream() — structured output', () => {
const expected = { answer: '5', score: 1 };
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([
stream: makeChunkStream([
{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'add', args: { a: 2, b: 3 } },
]),
finishReason: Promise.resolve('tool-calls'),
@@ -4049,7 +4070,7 @@ describe('AgentRuntime — tool approval (HITL wrapper)', () => {
.build();
streamText.mockReturnValue({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -4119,7 +4140,7 @@ describe('AgentRuntime — tool approval (HITL wrapper)', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -4295,7 +4316,7 @@ describe('external abort signal', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'checking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'checking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -4405,7 +4426,7 @@ describe('AgentRuntime — abort during a tool batch', () => {
toolCalls: Array<{ toolCallId: string; toolName: string; args: Record<string, unknown> }>,
) {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'calling tools...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'calling tools...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -4686,7 +4707,7 @@ describe('tool systemInstruction merging', () => {
function getSystemMessageText(): string {
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system;
const systemMsg = callArgs.instructions;
if (Array.isArray(systemMsg)) {
return systemMsg.map((entry) => String((entry as { content: string }).content)).join('');
}
@@ -4819,10 +4840,10 @@ describe('tool systemInstruction merging', () => {
await runtime.generate('load the deferred capability');
const calls = generateText.mock.calls as Array<
[{ system: Array<{ content: string }> | { content: string } }]
[{ instructions: Array<{ content: string }> | { content: string } }]
>;
const beforeLoadSystem = calls[0][0].system;
const afterLoadSystem = calls[1][0].system;
const beforeLoadSystem = calls[0][0].instructions;
const afterLoadSystem = calls[1][0].instructions;
const beforeFirst = Array.isArray(beforeLoadSystem)
? beforeLoadSystem[0].content
@@ -4866,7 +4887,7 @@ describe('instruction providerOptions', () => {
});
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system as Record<string, unknown>;
const systemMsg = callArgs.instructions as Record<string, unknown>;
expect(systemMsg.role).toBe('system');
expect(systemMsg.providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral' } },
@@ -4896,7 +4917,7 @@ describe('promptCaching', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const systemMsg = callArgs.system as Record<string, unknown>;
const systemMsg = callArgs.instructions as Record<string, unknown>;
expect(systemMsg.providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
});
@@ -5297,10 +5318,10 @@ describe('AgentRuntime — observation log jobs', () => {
});
const callArgs = (generateText.mock.calls[0] as [unknown])[0] as {
system: { content: string };
instructions: { content: string };
tools: Record<string, unknown>;
};
const systemPrompt = callArgs.system?.content ?? '';
const systemPrompt = callArgs.instructions?.content ?? '';
expect(systemPrompt).not.toContain('<episodic_memory>');
expect(systemPrompt).not.toContain('Postgres');
expect(systemPrompt).not.toContain('SQLite');
@@ -5420,17 +5441,17 @@ describe('AgentRuntime — observation log jobs', () => {
const generateTextMock = generateText as MockedFunction<
(input: {
system: { content: string } | Array<{ content: string }>;
instructions: { content: string } | Array<{ content: string }>;
messages: Array<{
role: string;
content: unknown;
}>;
}) => unknown
>;
const [{ system, messages }] = generateTextMock.mock.calls[0];
const systemText = Array.isArray(system)
? system.map((entry) => entry.content).join('')
: system.content;
const [{ instructions, messages }] = generateTextMock.mock.calls[0];
const systemText = Array.isArray(instructions)
? instructions.map((entry) => entry.content).join('')
: instructions.content;
expect(systemText).toContain('Resource one memory.');
expect(systemText).toContain('Resource two memory.');
expect(JSON.stringify(messages)).not.toContain('remember resource-one preference');
@@ -5637,7 +5658,7 @@ describe('AgentRuntime — telemetry propagation', () => {
tracer: { startSpan: vi.fn() },
};
it('passes telemetry config into generateText as experimental_telemetry', async () => {
it('passes telemetry config into generateText as telemetry', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
@@ -5651,13 +5672,12 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
expect(expTelemetry.functionId).toBe('test-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
expect(expTelemetry.recordInputs).toBe(true);
expect(expTelemetry.recordOutputs).toBe(false);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
expect(telemetry.functionId).toBe('test-agent');
expect(telemetry.recordInputs).toBe(true);
expect(telemetry.recordOutputs).toBe(false);
});
it('uses updated telemetry config for later runs', async () => {
@@ -5680,9 +5700,8 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry.functionId).toBe('updated-agent');
expect(expTelemetry.metadata).toEqual({ env: 'updated' });
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry.functionId).toBe('updated-agent');
});
it('wraps generate calls in a telemetry root span when the tracer supports active spans', async () => {
@@ -5741,6 +5760,7 @@ describe('AgentRuntime — telemetry propagation', () => {
const telemetry: BuiltTelemetry = {
...baseTelemetry,
runtimeRootSpanEnabled: false,
integrations: [{ onStart: vi.fn() }],
tracer,
};
@@ -5757,11 +5777,11 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(tracer.startActiveSpan).not.toHaveBeenCalled();
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.experimental_telemetry).toEqual(
expect(callArgs.telemetry).toEqual(
expect.objectContaining({
isEnabled: true,
functionId: 'test-agent',
tracer,
integrations: telemetry.integrations,
}),
);
});
@@ -5827,7 +5847,7 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(attributes['gen_ai.prompt']).toEqual(expect.stringContaining('"input_schema"'));
});
it('passes telemetry config into streamText as experimental_telemetry', async () => {
it('passes telemetry config into streamText as telemetry', async () => {
streamText.mockReturnValue(makeStreamSuccess());
const runtime = new AgentRuntime({
@@ -5842,11 +5862,10 @@ describe('AgentRuntime — telemetry propagation', () => {
await collectChunks(stream);
const callArgs = streamText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
expect(expTelemetry.functionId).toBe('test-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
expect(telemetry.functionId).toBe('test-agent');
});
it('enables smoothStream by default on streamText', async () => {
@@ -5926,12 +5945,11 @@ describe('AgentRuntime — telemetry propagation', () => {
});
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
expect(expTelemetry).toBeDefined();
expect(expTelemetry.isEnabled).toBe(true);
const telemetry = callArgs.telemetry as Record<string, unknown>;
expect(telemetry).toBeDefined();
expect(telemetry.isEnabled).toBe(true);
// Inherited telemetry uses the child agent's name as functionId
expect(expTelemetry.functionId).toBe('child-agent');
expect(expTelemetry.tracer).toBe(baseTelemetry.tracer);
expect(telemetry.functionId).toBe('child-agent');
});
it('passes resolved telemetry to tool handlers via parentTelemetry', async () => {
@@ -6088,7 +6106,7 @@ describe('AgentRuntime — telemetry propagation', () => {
expect(capturedTelemetry!.tracer).toBe(baseTelemetry.tracer);
});
it('does not include experimental_telemetry when telemetry is disabled', async () => {
it('does not include telemetry when telemetry is disabled', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
const runtime = new AgentRuntime({
@@ -6102,7 +6120,7 @@ describe('AgentRuntime — telemetry propagation', () => {
await runtime.generate('hello');
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.experimental_telemetry).toBeUndefined();
expect(callArgs.telemetry).toBeUndefined();
});
});
@@ -6211,7 +6229,7 @@ describe('AgentRuntime.resume() with createCancellation() — auto-bypass', () =
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -6397,7 +6415,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => {
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -6473,7 +6491,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => {
// First stream: agent calls the tool and suspends
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -6544,7 +6562,7 @@ describe('AgentRuntime — empty model responses', () => {
function makeEmptyStream(finishReason: string, extraChunks: Array<Record<string, unknown>> = []) {
return {
fullStream: makeChunkStream(extraChunks),
stream: makeChunkStream(extraChunks),
finishReason: Promise.resolve(finishReason),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0, totalTokens: 10 }),
response: Promise.resolve({ messages: [] }),
@@ -6605,7 +6623,7 @@ describe('AgentRuntime — empty model responses', () => {
await collectChunks(readableStream);
const args = streamText.mock.calls[0][0] as Record<string, unknown>;
expect(args.includeRawChunks).toBe(true);
expect(args).toHaveProperty('include.rawChunks', true);
});
it('stream: an empty response finishing with "stop" is not treated as an error', async () => {
@@ -6704,7 +6722,7 @@ describe('AgentRuntime — MCP connection failure warnings', () => {
await collectChunks(readableStream);
const callArgs = streamText.mock.calls.at(-1)![0] as Record<string, unknown>;
const system = callArgs.system;
const system = callArgs.instructions;
const systemText = Array.isArray(system)
? system.map((e) => String((e as { content: string }).content)).join('')
: String((system as { content: string }).content);
@@ -6723,7 +6741,7 @@ describe('AgentRuntime — MCP connection failure warnings', () => {
await collectChunks(readableStream);
const callArgs = streamText.mock.calls.at(-1)![0] as Record<string, unknown>;
const system = callArgs.system;
const system = callArgs.instructions;
const systemText = Array.isArray(system)
? system.map((e) => String((e as { content: string }).content)).join('')
: String((system as { content: string }).content);
@@ -65,7 +65,7 @@ function* makeChunkStream(
function makeStreamSuccess(text = 'Hello') {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -77,7 +77,7 @@ function makeStreamSuccess(text = 'Hello') {
function makeStreamWithToolCall(toolCallId: string, args: Record<string, unknown>) {
return {
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'working...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'working...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -251,7 +251,7 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
const runtime2 = createRuntime(store, [lookupTool, approveTool]);
streamText.mockReset();
streamText.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'asking...' }]),
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'asking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
@@ -58,7 +58,7 @@ vi.mock('@ai-sdk/openai', () => ({
}));
vi.mock('@ai-sdk/google', () => ({
createGoogleGenerativeAI: (opts?: ProviderOpts) => (model: string) => ({
createGoogle: (opts?: ProviderOpts) => (model: string) => ({
provider: 'google',
modelId: model,
apiKey: opts?.apiKey,
@@ -68,12 +68,14 @@ vi.mock('@ai-sdk/google', () => ({
}));
vi.mock('@ai-sdk/xai', () => ({
createXai: (opts?: ProviderOpts) => (model: string) => ({
provider: 'xai',
modelId: model,
apiKey: opts?.apiKey,
fetch: opts?.fetch,
specificationVersion: 'v3',
createXai: (opts?: ProviderOpts) => ({
chat: (model: string) => ({
provider: 'xai',
modelId: model,
apiKey: opts?.apiKey,
fetch: opts?.fetch,
specificationVersion: 'v3',
}),
}),
}));
@@ -149,16 +149,15 @@ describe('observation-log observer defaults', () => {
await observe({ ...baseInput, telemetry: { ...telemetry, enabled: false } });
expect(mockGenerateText.mock.calls[0][0]).toMatchObject({
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId: 'my-agent.memory-observer',
metadata: { thread_id: 't1' },
recordInputs: true,
recordOutputs: false,
},
});
expect(mockGenerateText.mock.calls[1][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[1][0].telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].telemetry).toBeUndefined();
});
it('reports normalized, cache-aware usage through an async onUsage before the observer promise settles', async () => {
@@ -132,16 +132,15 @@ describe('observation-log reflector defaults', () => {
await reflect({ ...baseInput, telemetry: { ...telemetry, enabled: false } });
expect(mockGenerateText.mock.calls[0][0]).toMatchObject({
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId: 'my-agent.memory-reflector',
metadata: { thread_id: 't1' },
recordInputs: true,
recordOutputs: false,
},
});
expect(mockGenerateText.mock.calls[1][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].experimental_telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[1][0].telemetry).toBeUndefined();
expect(mockGenerateText.mock.calls[2][0].telemetry).toBeUndefined();
});
it('reports usage with task="reflector" and the configured model through onUsage', async () => {
@@ -63,7 +63,7 @@ describe('thinkingToProviderOptions', () => {
it('openai: defaults reasoningEffort to medium', () => {
expect(getProviderQuirks('openai').thinkingToProviderOptions?.({})).toEqual({
openai: { reasoningEffort: 'medium' },
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
});
@@ -8,9 +8,9 @@ import {
} from '../memory/title-generation';
type GenerateTextCall = {
system?: string;
instructions?: string;
messages: Array<{ role: string; content: string }>;
experimental_telemetry?: Record<string, unknown>;
telemetry?: Record<string, unknown>;
};
type GenerateTextResult = { text: string; usage?: { totalTokens?: number } };
@@ -117,8 +117,8 @@ describe('generateTitleFromMessage', () => {
mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' });
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow');
const call = mockGenerateText.mock.calls[0][0];
expect(call.system).toContain('markdown');
expect(call.system).toContain('sentence case');
expect(call.instructions).toContain('markdown');
expect(call.instructions).toContain('sentence case');
});
it('accepts custom instructions', async () => {
@@ -127,7 +127,7 @@ describe('generateTitleFromMessage', () => {
instructions: 'Custom system prompt',
});
const call = mockGenerateText.mock.calls[0][0];
expect(call.system).toBe('Custom system prompt');
expect(call.instructions).toBe('Custom system prompt');
});
it('passes generic telemetry to the title LLM call', async () => {
@@ -147,13 +147,11 @@ describe('generateTitleFromMessage', () => {
});
const call = mockGenerateText.mock.calls[0][0];
expect(call.experimental_telemetry).toEqual({
expect(call.telemetry).toEqual({
isEnabled: true,
functionId: 'instance-ai.thread-title',
metadata: { thread_id: 'thread-1' },
recordInputs: true,
recordOutputs: false,
tracer: undefined,
integrations: undefined,
});
});
@@ -1,5 +1,5 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { TelemetrySettings, ToolCallRepairFunction, ToolSet } from 'ai';
import type { TelemetryOptions, ToolCallRepairFunction, ToolSet } from 'ai';
import type { JSONSchema7 } from 'json-schema';
import type { z } from 'zod';
@@ -618,16 +618,18 @@ export class AgentRuntime {
toolMap: Map<string, BuiltTool>,
options?: ExecutionOptions,
): {
experimental_telemetry?: TelemetrySettings;
experimental_repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
experimental_onStepStart?: ExecutionOptions['onStepStart'];
onStepFinish?: ExecutionOptions['onStepFinish'];
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
onStepStart?: ExecutionOptions['onStepStart'];
onStepEnd?: ExecutionOptions['onStepEnd'];
} {
return {
...this.telemetry.buildTelemetryOptions(options),
...(options?.onStepStart ? { experimental_onStepStart: options.onStepStart } : {}),
...(options?.onStepFinish ? { onStepFinish: options.onStepFinish } : {}),
experimental_repairToolCall: async (options) => {
...(options?.onStepStart ? { onStepStart: options.onStepStart } : {}),
...(options?.onStepEnd || options?.onStepFinish
? { onStepEnd: options.onStepEnd ?? options.onStepFinish }
: {}),
repairToolCall: async (options) => {
return await fixToolCall(
{
toolCall: options.toolCall,
@@ -31,8 +31,9 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
const { generateText } = loadAi();
const result = await generateText({
model: ctx.model,
system: ctx.system,
instructions: ctx.system,
messages: ctx.messages,
allowSystemInMessages: true,
abortSignal: ctx.abortSignal,
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
@@ -3,7 +3,7 @@ import type {
ModelMessage,
Output,
SystemModelMessage,
TelemetrySettings,
TelemetryOptions,
ToolCallRepairFunction,
ToolSet,
} from 'ai';
@@ -68,8 +68,8 @@ export interface ModelCallContext {
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
aiSdkOptions: {
experimental_telemetry?: TelemetrySettings;
experimental_repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
};
}
@@ -113,13 +113,14 @@ export class StreamSink implements RunOutputSink<void> {
const { streamText } = loadAi();
const result = streamText({
model: ctx.model,
system: ctx.system,
instructions: ctx.system,
messages: ctx.messages,
allowSystemInMessages: true,
abortSignal: ctx.abortSignal,
// Surface the provider's raw message_start/message_delta events so an
// aborted run can recover its usage — the SDK reports none on abort.
...(this.rawUsageReader !== undefined || this.rawErrorReader !== undefined
? { includeRawChunks: true }
? { include: { rawChunks: true } }
: {}),
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
@@ -131,7 +132,7 @@ export class StreamSink implements RunOutputSink<void> {
// Consume the stream. When the AbortSignal fires mid-stream the AI SDK
// cancels the underlying fetch and the async iterator throws; the error
// propagates to the StreamSession which closes the consumer stream.
for await (const chunk of result.fullStream) {
for await (const chunk of result.stream) {
// Track usage from raw provider events so an aborted turn (which never
// reaches the post-loop awaits) can still be billed via getAbortFinish.
if (chunk.type === 'raw') {
@@ -538,7 +538,7 @@ export function createEpisodicMemoryExtractFn(
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.extractionPrompt ?? DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
instructions: options.extractionPrompt ?? DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
prompt: buildEpisodicMemoryExtractorPrompt(input),
output: Output.object({ schema: EpisodicMemoryExtractionSchema }),
});
@@ -564,7 +564,7 @@ export function createEpisodicMemoryReflectFn(
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.reflectionPrompt ?? DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
instructions: options.reflectionPrompt ?? DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
prompt: buildEpisodicMemoryReflectorPrompt(input),
output: Output.object({ schema: EpisodicMemoryReflectionSchema }),
});
@@ -13,7 +13,7 @@ import { getModelIdString } from '../loop/runtime-context';
import { loadAi } from '../model/lazy-ai';
import { createModel } from '../model/model-factory';
import { toTokenUsage } from '../streaming/stream';
import { buildExperimentalTelemetry } from '../telemetry/telemetry-options';
import { buildAiSdkTelemetry } from '../telemetry/telemetry-options';
// The observer's fixed prompt is a few thousand tokens, so firing per tiny delta
// is majority overhead. 8k keeps that overhead ratio acceptable while still firing
@@ -328,9 +328,9 @@ export function createObservationLogObserveFn(
return async (input) => {
const { text, usage, providerMetadata } = await loadAi().generateText({
model: createModel(model),
system: options.observerPrompt ?? DEFAULT_OBSERVATION_LOG_OBSERVER_PROMPT,
instructions: options.observerPrompt ?? DEFAULT_OBSERVATION_LOG_OBSERVER_PROMPT,
prompt: buildObservationLogObserverPrompt(input),
...buildExperimentalTelemetry(input.telemetry, { functionSuffix: 'memory-observer' }),
...buildAiSdkTelemetry(input.telemetry, { functionSuffix: 'memory-observer' }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);
@@ -582,9 +582,9 @@ export function createObservationLogReflectFn(
return async (input) => {
const { text, usage, providerMetadata } = await loadAi().generateText({
model: createModel(model),
system: options.reflectorPrompt ?? DEFAULT_OBSERVATION_LOG_REFLECTOR_PROMPT,
instructions: options.reflectorPrompt ?? DEFAULT_OBSERVATION_LOG_REFLECTOR_PROMPT,
prompt: buildObservationLogReflectorPrompt(input),
...buildExperimentalTelemetry(input.telemetry, { functionSuffix: 'memory-reflector' }),
...buildAiSdkTelemetry(input.telemetry, { functionSuffix: 'memory-reflector' }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);
@@ -7,6 +7,7 @@ import { createFilteredLogger } from '../logger';
import { incrementTokenCountFromUsage } from '../loop/execution-counter';
import { loadAi } from '../model/lazy-ai';
import { createModel, type FetchFn } from '../model/model-factory';
import { buildAiSdkTelemetry } from '../telemetry/telemetry-options';
const logger = createFilteredLogger();
@@ -55,22 +56,6 @@ interface GenerateTitleFromMessageOptions {
executionCounter?: AgentExecutionCounter;
}
function buildTelemetryOptions(telemetry: BuiltTelemetry | undefined): Record<string, unknown> {
if (!telemetry?.enabled) return {};
return {
experimental_telemetry: {
isEnabled: true,
functionId: telemetry.functionId ?? 'title-generation',
metadata: telemetry.metadata,
recordInputs: telemetry.recordInputs,
recordOutputs: telemetry.recordOutputs,
tracer: telemetry.tracer,
integrations: telemetry.integrations.length > 0 ? telemetry.integrations : undefined,
},
};
}
/**
* Whether a user message has too little substance to title a conversation
* (e.g. "hey", "hello"). For these, the LLM tends to hallucinate an
@@ -134,14 +119,14 @@ export async function generateTitleFromMessage(
const result = await loadAi().generateText({
model,
system: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS,
instructions: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS,
messages: [
{
role: 'user',
content: `Generate a title for the following first message of a conversation. Do not answer the message — only produce the title.\n\n<message>\n${trimmed}\n</message>`,
},
],
...buildTelemetryOptions(opts?.telemetry),
...buildAiSdkTelemetry(opts?.telemetry, { fallbackFunctionId: 'title-generation' }),
});
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);
@@ -181,7 +166,7 @@ ${trimmed}
const result = await loadAi().generateText({
model,
system: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS,
instructions: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS,
messages: [{ role: 'user', content: wrappedMessage }],
});
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);
@@ -6,9 +6,6 @@ import type {
TextPart,
ToolCallPart,
ToolResultPart,
ImagePart,
ToolApprovalRequest,
ToolApprovalResponse,
FinishReason as AiFinishReason,
} from 'ai';
@@ -16,8 +13,10 @@ import { getProviderQuirks, PROVIDER_QUIRKS } from './provider-quirks';
import type { FinishReason } from '../../types';
import type {
AgentMessage,
ContentCustom,
ContentFile,
ContentReasoning,
ContentReasoningFile,
ContentText,
ContentToolCall,
Message,
@@ -25,18 +24,9 @@ import type {
} from '../../types/sdk/message';
import type { JSONObject, JSONValue } from '../../types/utils/json';
/** Reasoning content part — mirrors @ai-sdk/provider-utils ReasoningPart (not re-exported by 'ai'). */
type ReasoningPart = { type: 'reasoning'; text: string };
type AiContentPart =
| TextPart
| FilePart
| ImagePart
| ReasoningPart
| ToolCallPart
| ToolResultPart
| ToolApprovalRequest
| ToolApprovalResponse;
// Used across all message roles; AssistantContent omits user images and tool approval responses.
type AiContentPart = Exclude<ModelMessage['content'], string>[number];
type AiAssistantContent = Exclude<Extract<ModelMessage, { role: 'assistant' }>['content'], string>;
// --- Type guards for MessageContent blocks ---
@@ -48,10 +38,18 @@ function isReasoning(block: MessageContent): block is ContentReasoning {
return block.type === 'reasoning';
}
function isReasoningFile(block: MessageContent): block is ContentReasoningFile {
return block.type === 'reasoning-file';
}
function isFile(block: MessageContent): block is ContentFile {
return block.type === 'file';
}
function isCustom(block: MessageContent): block is ContentCustom {
return block.type === 'custom';
}
function isToolCall(block: MessageContent): block is ContentToolCall {
return block.type === 'tool-call';
}
@@ -125,10 +123,19 @@ function hasReplayableReasoningProviderOptions(
type ContentToolResultOutput = Extract<ToolResultPart['output'], { type: 'content' }>;
function isContentToolResultOutput(value: JSONValue): value is ContentToolResultOutput {
function isContentToolResultOutput(value: unknown): value is ContentToolResultOutput {
return isRecord(value) && value.type === 'content' && Array.isArray(value.value);
}
function normalizeReasoningFileData(
data: Extract<AiContentPart, { type: 'reasoning-file' }>['data'],
): ContentReasoningFile['data'] {
if (data instanceof URL) return data.toString();
if (typeof data !== 'object' || data === null || !('type' in data)) return data;
if (data.type === 'data') return data.data;
return data.url.toString();
}
/**
* Providers replay reasoning from `providerOptions`, but the AI SDK exposes the
* replay data in `providerMetadata` (see `PROVIDER_QUIRKS[provider].reasoningReplayKeys`).
@@ -168,6 +175,14 @@ function toAiContent(block: MessageContent): AiContentPart | undefined {
data: block.data,
mediaType: block.mediaType ?? 'application/octet-stream',
};
} else if (isReasoningFile(block)) {
base = {
type: 'reasoning-file',
data: block.data,
mediaType: block.mediaType,
};
} else if (isCustom(block)) {
base = { type: 'custom', kind: block.kind };
} else if (isToolCall(block)) {
base = {
type: 'tool-call',
@@ -259,6 +274,17 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined {
case 'reasoning':
base = { type: 'reasoning', text: part.text };
break;
case 'reasoning-file': {
base = {
type: 'reasoning-file',
data: normalizeReasoningFileData(part.data),
mediaType: part.mediaType,
};
break;
}
case 'custom':
base = { type: 'custom', kind: part.kind };
break;
case 'tool-call': {
const normalizedInput = normalizeToolInputForModel(part.input);
base = {
@@ -364,9 +390,7 @@ function toAiMessageList(msg: Message): ModelMessage[] {
if (assistantParts.length > 0) {
const assistantBase: ModelMessage = {
role: 'assistant',
content: assistantParts as Array<
TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart
>,
content: assistantParts as AiAssistantContent,
};
const assistantMsg: ModelMessage = msg.providerOptions
? { ...assistantBase, providerOptions: msg.providerOptions }
@@ -115,15 +115,14 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
},
google: {
build: (creds, model, fetch) => {
const { createGoogleGenerativeAI } =
require('@ai-sdk/google') as typeof import('@ai-sdk/google');
return createGoogleGenerativeAI({ ...creds, fetch })(model);
const { createGoogle } = require('@ai-sdk/google') as typeof import('@ai-sdk/google');
return createGoogle({ ...creds, fetch })(model);
},
},
xai: {
build: (creds, model, fetch) => {
const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai');
return createXai({ ...creds, fetch })(model);
return createXai({ ...creds, fetch }).chat(model);
},
},
groq: {
@@ -283,7 +282,7 @@ export function createModel(config: ModelConfig, fetch?: FetchFn): LanguageModel
*/
const EMBEDDING_PROVIDERS = {
openai: { pkg: '@ai-sdk/openai', factory: 'createOpenAI' },
google: { pkg: '@ai-sdk/google', factory: 'createGoogleGenerativeAI' },
google: { pkg: '@ai-sdk/google', factory: 'createGoogle' },
mistral: { pkg: '@ai-sdk/mistral', factory: 'createMistral' },
cohere: { pkg: '@ai-sdk/cohere', factory: 'createCohere' },
amazon: { pkg: '@ai-sdk/amazon-bedrock', factory: 'createAmazonBedrock' },
@@ -68,7 +68,12 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
relaxStrictJsonSchemaForRawOutput: true,
thinkingToProviderOptions: (thinking) => {
const cfg = thinking as OpenAIThinkingConfig;
return { openai: { reasoningEffort: cfg.reasoningEffort ?? 'medium' } };
return {
openai: {
reasoningEffort: cfg.reasoningEffort ?? 'medium',
reasoningSummary: null,
},
};
},
},
groq: {
@@ -69,7 +69,7 @@ export function toTokenUsage(
}
/**
* Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk
* Convert a single AI SDK stream chunk to an n8n StreamChunk
*/
export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefined {
switch (c.type) {
@@ -125,7 +125,7 @@ export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefine
}
case 'tool-result':
// The fullStream emits the raw tool output here, not the
// The stream emits the raw tool output here, not the
// `{ type, value }` ToolResultOutput wrapper used on the message
// side — so pass it through verbatim. Only provider-executed tools
// (e.g. native web search) reach this branch; local tool results are
@@ -1,5 +1,7 @@
import { Telemetry } from '../../../sdk/telemetry';
import type { BuiltTelemetry } from '../../../types/telemetry';
import { deriveSubAgentTelemetry } from '../sub-agent-telemetry';
import { buildAiSdkTelemetry } from '../telemetry-options';
function builtTelemetry(overrides: Partial<BuiltTelemetry> = {}): BuiltTelemetry {
return {
@@ -42,4 +44,45 @@ describe('deriveSubAgentTelemetry()', () => {
expect(derived?.metadata).toEqual({ source: 'sub-agent' });
});
it('uses derived metadata for AI SDK OpenTelemetry spans', async () => {
const tracer = {
startSpan: vi.fn(() => ({ end: vi.fn() })),
startActiveSpan: vi.fn(),
};
const parentTelemetry = await new Telemetry()
.tracer(tracer)
.metadata({ source: 'workflow' })
.build();
const derived = deriveSubAgentTelemetry(parentTelemetry);
const options = buildAiSdkTelemetry(derived).telemetry;
const integrations = Array.isArray(options?.integrations)
? options.integrations
: options?.integrations
? [options.integrations]
: [];
integrations[0]?.onStart?.({
operationId: 'ai.generateText',
callId: 'call-1',
provider: 'openai.responses',
modelId: 'gpt-5',
instructions: 'test',
messages: [],
maxRetries: 2,
functionId: 'sub-agent',
recordInputs: true,
recordOutputs: true,
} as never);
expect(tracer.startSpan).toHaveBeenCalledWith(
'ai.generateText',
expect.objectContaining({
attributes: expect.objectContaining({
'ai.telemetry.metadata.source': 'sub-agent',
}),
}),
undefined,
);
});
});
@@ -1,7 +1,7 @@
import type { TelemetrySettings } from 'ai';
import type { TelemetryOptions } from 'ai';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { buildExperimentalTelemetry } from './telemetry-options';
import { buildAiSdkTelemetry } from './telemetry-options';
import { Telemetry } from '../../sdk/telemetry';
import type { AttributeValue, BuiltProviderTool, BuiltTelemetry, BuiltTool } from '../../types';
import type { ExecutionOptions } from '../../types/sdk/agent';
@@ -126,7 +126,7 @@ function buildGenAiRootAttributes(
/**
* Owns all telemetry concerns for a single agent runtime: resolving the
* effective telemetry config, mapping it to the AI SDK's
* `experimental_telemetry` shape, building LangSmith/AI-SDK span attributes,
* `telemetry` shape, building LangSmith/AI-SDK span attributes,
* and wrapping the generate/stream loops and tool calls in active spans.
*
* Keeps provider-specific attribute formatting out of the core loop. Holds a
@@ -156,11 +156,11 @@ export class RuntimeTelemetry {
await Telemetry.forceFlush(this.resolve(options));
}
/** Map resolved telemetry to AI SDK's experimental_telemetry shape. */
/** Map resolved telemetry to the AI SDK's telemetry shape. */
buildTelemetryOptions(options?: ExecutionOptions): {
experimental_telemetry?: TelemetrySettings;
telemetry?: TelemetryOptions;
} {
return buildExperimentalTelemetry(this.resolve(options), {
return buildAiSdkTelemetry(this.resolve(options), {
fallbackFunctionId: this.config.name,
});
}
@@ -1,34 +1,33 @@
import type { TelemetrySettings } from 'ai';
import type { TelemetryOptions } from 'ai';
import type { BuiltTelemetry } from '../../types/telemetry';
/**
* Map a resolved BuiltTelemetry to the AI SDK's `experimental_telemetry`
* Map a resolved BuiltTelemetry to the AI SDK's `telemetry`
* option. Single source of truth for all runtime LLM calls (loop, memory
* tasks). `functionSuffix` namespaces auxiliary calls (e.g. 'memory-observer')
* under the parent functionId.
*/
export function buildExperimentalTelemetry(
export function buildAiSdkTelemetry(
telemetry: BuiltTelemetry | undefined,
options: { fallbackFunctionId?: string; functionSuffix?: string } = {},
): { experimental_telemetry?: TelemetrySettings } {
): { telemetry?: TelemetryOptions } {
if (!telemetry?.enabled) return {};
const baseFunctionId = telemetry.functionId ?? options.fallbackFunctionId ?? 'agent';
const functionId = options.functionSuffix
? `${baseFunctionId}.${options.functionSuffix}`
: baseFunctionId;
const integrations =
telemetry.resolveIntegrations?.(telemetry.metadata) ?? telemetry.integrations;
return {
experimental_telemetry: {
telemetry: {
isEnabled: true,
functionId,
metadata: telemetry.metadata,
recordInputs: telemetry.recordInputs,
recordOutputs: telemetry.recordOutputs,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
tracer: telemetry.tracer as any,
integrations: telemetry.integrations.length > 0 ? telemetry.integrations : undefined,
integrations: integrations.length > 0 ? integrations : undefined,
},
};
}
@@ -54,6 +54,7 @@ export function toAiSdkProviderTools(tools?: BuiltProviderTool[]): Record<string
id: t.name,
args: t.args,
inputSchema: t.inputSchema ?? z.any(),
isProviderExecuted: true,
};
result[t.name] = providerTool;
}
@@ -71,11 +71,10 @@ describe('Agent telemetry', () => {
const firstCall = generateText.mock.calls[0][0] as Record<string, unknown>;
const secondCall = generateText.mock.calls[1][0] as Record<string, unknown>;
const firstTelemetry = firstCall.experimental_telemetry as Record<string, unknown>;
const secondTelemetry = secondCall.experimental_telemetry as Record<string, unknown>;
const firstTelemetry = firstCall.telemetry as Record<string, unknown>;
const secondTelemetry = secondCall.telemetry as Record<string, unknown>;
expect(firstTelemetry.functionId).toBe('initial-agent');
expect(secondTelemetry.functionId).toBe('updated-agent');
expect(secondTelemetry.metadata).toEqual({ functionId: 'updated-agent' });
});
});
@@ -1,4 +1,4 @@
import type { TelemetryIntegration } from 'ai';
import type { Telemetry as AiSdkTelemetry } from 'ai';
import { Telemetry } from '../telemetry';
@@ -35,9 +35,47 @@ describe('Telemetry builder', () => {
});
it('accepts a pre-built tracer', async () => {
const fakeTracer = { startSpan: vi.fn() };
const fakeTracer = { startSpan: vi.fn(), startActiveSpan: vi.fn() };
const built = await new Telemetry().tracer(fakeTracer).build();
expect(built.tracer).toBe(fakeTracer);
expect(built.integrations).toHaveLength(1);
expect(built.integrations[0]).toEqual(
expect.objectContaining({
onStart: expect.any(Function),
executeLanguageModelCall: expect.any(Function),
}),
);
});
it('preserves metadata on AI SDK 7 OpenTelemetry spans', async () => {
const fakeTracer = {
startSpan: vi.fn(() => ({ end: vi.fn() })),
startActiveSpan: vi.fn(),
};
const built = await new Telemetry().metadata({ team: 'platform' }).tracer(fakeTracer).build();
built.integrations[0].onStart?.({
operationId: 'ai.generateText',
callId: 'call-1',
provider: 'openai.responses',
modelId: 'gpt-5',
instructions: 'test',
messages: [],
maxRetries: 2,
functionId: 'agent',
recordInputs: true,
recordOutputs: true,
} as never);
expect(fakeTracer.startSpan).toHaveBeenCalledWith(
'ai.generateText',
expect.objectContaining({
attributes: expect.objectContaining({
'ai.telemetry.metadata.team': 'platform',
}),
}),
undefined,
);
});
it('throws when both .tracer() and .otlpEndpoint() are set', async () => {
@@ -47,21 +85,161 @@ describe('Telemetry builder', () => {
});
it('collects multiple integrations', async () => {
const int1: TelemetryIntegration = { onStart: vi.fn() };
const int2: TelemetryIntegration = { onFinish: vi.fn() };
const int1: AiSdkTelemetry = { onStart: vi.fn() };
const int2: AiSdkTelemetry = { onEnd: vi.fn() };
const built = await new Telemetry().integration(int1).integration(int2).build();
expect(built.integrations).toHaveLength(2);
});
});
describe('Telemetry — redaction wrapping', () => {
it('redacts hooks on frozen integrations', async () => {
const onStart = vi.fn();
const integration: AiSdkTelemetry = Object.freeze({ onStart });
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return filtered;
})
.integration(integration)
.build();
built.integrations[0].onStart?.({ secret: 'hidden', safe: 'ok' } as never);
expect(onStart).toHaveBeenCalledWith({ safe: 'ok' });
});
it('accepts immutable redactor results', async () => {
const onStart = vi.fn();
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return Object.freeze(filtered);
})
.integration({ onStart })
.build();
built.integrations[0].onStart?.({ secret: 'hidden', safe: 'ok' } as never);
expect(onStart).toHaveBeenCalledWith({ safe: 'ok' });
});
it('redacts future event hooks without requiring an explicit wrapper', async () => {
type FutureTelemetry = AiSdkTelemetry & {
onFutureEvent: (event: Record<string, unknown>) => void;
readonly receivedEvents: Array<Record<string, unknown>>;
};
class FutureIntegration {
readonly #receivedEvents: Array<Record<string, unknown>> = [];
onStart() {}
onFutureEvent(event: Record<string, unknown>) {
this.#receivedEvents.push(event);
}
get receivedEvents() {
return this.#receivedEvents;
}
}
const integration: FutureTelemetry = new FutureIntegration();
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.secret;
return filtered;
})
.integration(integration)
.build();
const wrapped = built.integrations[0] as FutureTelemetry;
wrapped.onFutureEvent({ secret: 'hidden', safe: 'ok' });
expect(integration.receivedEvents).toEqual([{ safe: 'ok' }]);
});
it('preserves executor control fields while redacting their event data', async () => {
let modelOptions: Record<string, unknown> | undefined;
let toolOptions: Record<string, unknown> | undefined;
const integration: AiSdkTelemetry = {
executeLanguageModelCall: (options) => {
modelOptions = options;
return options.execute();
},
executeTool: (options) => {
toolOptions = options;
return options.execute();
},
};
const built = await new Telemetry()
.redact((data) => {
const filtered = { ...data };
delete filtered.callId;
delete filtered.toolCallId;
delete filtered.execute;
delete filtered.secret;
return Object.freeze(filtered);
})
.integration(integration)
.build();
const modelExecute = vi.fn().mockResolvedValue('model-result');
await expect(
built.integrations[0].executeLanguageModelCall?.({
callId: 'model-call',
execute: modelExecute,
secret: 'hidden',
} as never),
).resolves.toBe('model-result');
expect(modelOptions).toEqual({ callId: 'model-call', execute: modelExecute });
const toolExecute = vi.fn().mockResolvedValue('tool-result');
await expect(
built.integrations[0].executeTool?.({
callId: 'model-call',
toolCallId: 'tool-call',
execute: toolExecute,
secret: 'hidden',
} as never),
).resolves.toBe('tool-result');
expect(toolOptions).toEqual({
callId: 'model-call',
toolCallId: 'tool-call',
execute: toolExecute,
});
});
it('preserves the receiver for stateful integration methods', async () => {
class StatefulIntegration {
readonly receivedEvents: unknown[] = [];
onStart(event: Parameters<NonNullable<AiSdkTelemetry['onStart']>>[0]) {
this.receivedEvents.push(event);
}
}
const integration = new StatefulIntegration();
const built = await new Telemetry()
.redact((data) => data)
.integration(integration)
.build();
built.integrations[0].onStart!({ operationId: 'ai.generateText' } as never);
expect(integration.receivedEvents).toHaveLength(1);
});
it('wraps integrations with redaction when .redact() is set', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},
onFinish: (event) => {
onEnd: (event) => {
receivedEvents.push(event);
},
};
@@ -86,7 +264,7 @@ describe('Telemetry — redaction wrapping', () => {
});
it('does not wrap integrations when .redact() is not set', async () => {
const integration: TelemetryIntegration = { onStart: vi.fn() };
const integration: AiSdkTelemetry = { onStart: vi.fn() };
const built = await new Telemetry().integration(integration).build();
// The integration should be a copy (not the same reference due to spread) but functionally identical
expect(built.integrations[0].onStart).toBe(integration.onStart);
@@ -94,7 +272,7 @@ describe('Telemetry — redaction wrapping', () => {
it('redacts top-level scalar fields via the redact callback', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},
@@ -118,7 +296,7 @@ describe('Telemetry — redaction wrapping', () => {
it('redacts objects inside arrays', async () => {
const receivedEvents: unknown[] = [];
const integration: TelemetryIntegration = {
const integration: AiSdkTelemetry = {
onStart: (event) => {
receivedEvents.push(event);
},
@@ -0,0 +1,69 @@
import type { Attributes, Context, Span, SpanOptions, Tracer } from '@opentelemetry/api';
import type { AttributeValue } from '../types/telemetry';
class MetadataEnrichedTracer implements Tracer {
constructor(
private readonly delegate: Tracer,
private readonly attributes: Attributes,
) {}
startSpan(name: string, options?: SpanOptions, context?: Context): Span {
return this.delegate.startSpan(
name,
{
...options,
attributes: { ...this.attributes, ...options?.attributes },
},
context,
);
}
startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
fn: F,
): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
context: Context,
fn: F,
): ReturnType<F>;
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
optionsOrFn: SpanOptions | F,
contextOrFn?: Context | F,
fn?: F,
): ReturnType<F> {
if (typeof optionsOrFn === 'function') {
return this.delegate.startActiveSpan(name, optionsOrFn);
}
const options = {
...optionsOrFn,
attributes: { ...this.attributes, ...optionsOrFn.attributes },
};
if (typeof contextOrFn === 'function') {
return this.delegate.startActiveSpan(name, options, contextOrFn);
}
if (contextOrFn === undefined || fn === undefined) {
throw new Error('OpenTelemetry active span callback is required.');
}
return this.delegate.startActiveSpan(name, options, contextOrFn, fn);
}
}
export function createMetadataEnrichedTracer(
tracer: Tracer,
metadata: Record<string, AttributeValue> | undefined,
): Tracer {
if (!metadata || Object.keys(metadata).length === 0) return tracer;
const attributes = Object.fromEntries(
Object.entries(metadata).map(([key, value]) => [`ai.telemetry.metadata.${key}`, value]),
);
return new MetadataEnrichedTracer(tracer, attributes);
}
+107 -36
View File
@@ -1,5 +1,8 @@
import type { TelemetryIntegration } from 'ai';
import { isRecord } from '@n8n/utils/is-record';
import type { Tracer } from '@opentelemetry/api';
import type { Telemetry as AiSdkTelemetry } from 'ai';
import { createMetadataEnrichedTracer } from './metadata-enriched-tracer';
import type {
AttributeValue,
BuiltTelemetry,
@@ -8,6 +11,16 @@ import type {
} from '../types/telemetry';
type RedactFn = (data: Record<string, unknown>) => Record<string, unknown>;
type ExecuteHookKey = 'executeLanguageModelCall' | 'executeTool';
// Future on* hooks are wrapped automatically; other SDK members must be classified explicitly.
type RedactableTelemetry = AiSdkTelemetry &
Record<Exclude<keyof AiSdkTelemetry, `on${string}` | ExecuteHookKey>, never>;
type UnknownMethod = (this: unknown, ...args: unknown[]) => unknown;
const EXECUTION_CONTROL_FIELDS: Record<ExecuteHookKey, string[]> = {
executeLanguageModelCall: ['callId', 'execute'],
executeTool: ['callId', 'toolCallId', 'execute'],
};
/**
* Recursively apply the redact function to plain objects found anywhere
@@ -22,7 +35,7 @@ function redactValue(value: unknown, redact: RedactFn): unknown {
value !== null &&
Object.getPrototypeOf(value) === Object.prototype
) {
const redacted = redact(value as Record<string, unknown>);
const redacted = { ...redact(value as Record<string, unknown>) };
// Recurse into the redacted result so deeply nested objects are also processed.
for (const key of Object.keys(redacted)) {
redacted[key] = redactValue(redacted[key], redact);
@@ -41,7 +54,7 @@ function redactValue(value: unknown, redact: RedactFn): unknown {
function redactEvent<T extends object>(event: T, redact: RedactFn): T {
const cloned = { ...event };
// Redact the cloned event itself (it is a plain object).
const redacted = redact(cloned as unknown as Record<string, unknown>);
const redacted = { ...redact(cloned as unknown as Record<string, unknown>) };
// Then recurse into each value to handle arrays and nested objects.
for (const key of Object.keys(redacted)) {
const value = redacted[key];
@@ -50,42 +63,82 @@ function redactEvent<T extends object>(event: T, redact: RedactFn): T {
return redacted as T;
}
function isExecuteHookKey(property: PropertyKey): property is ExecuteHookKey {
return property === 'executeLanguageModelCall' || property === 'executeTool';
}
function isTelemetryHook(property: PropertyKey): boolean {
return (typeof property === 'string' && property.startsWith('on')) || isExecuteHookKey(property);
}
function isMethod(value: unknown): value is UnknownMethod {
return typeof value === 'function';
}
function redactHookArgument(property: PropertyKey, argument: unknown, redact: RedactFn): unknown {
if (property === 'onError' || !isRecord(argument)) return redactValue(argument, redact);
const redacted = redactEvent(argument, redact);
if (isExecuteHookKey(property)) {
const controlFields = Object.fromEntries(
EXECUTION_CONTROL_FIELDS[property].map((field) => [field, argument[field]]),
);
return { ...redacted, ...controlFields };
}
return redacted;
}
/**
* Wrap a TelemetryIntegration so every hook passes event data through
* Wrap an AI SDK telemetry integration so every hook passes event data through
* the redact callback before forwarding to the original hook.
*/
function wrapIntegrationWithRedaction(
integration: TelemetryIntegration,
integration: RedactableTelemetry,
redact: RedactFn,
): TelemetryIntegration {
const wrapped: TelemetryIntegration = {};
): AiSdkTelemetry {
const methodCache = new Map<PropertyKey, { original: UnknownMethod; wrapped: UnknownMethod }>();
const facade: AiSdkTelemetry = {};
if (integration.onStart) {
const orig = integration.onStart;
wrapped.onStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onStepStart) {
const orig = integration.onStepStart;
wrapped.onStepStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onToolCallStart) {
const orig = integration.onToolCallStart;
wrapped.onToolCallStart = (event) => orig(redactEvent(event, redact));
}
if (integration.onToolCallFinish) {
const orig = integration.onToolCallFinish;
wrapped.onToolCallFinish = (event) => orig(redactEvent(event, redact));
}
if (integration.onStepFinish) {
const orig = integration.onStepFinish;
wrapped.onStepFinish = (event) => orig(redactEvent(event, redact));
}
if (integration.onFinish) {
const orig = integration.onFinish;
wrapped.onFinish = (event) => orig(redactEvent(event, redact));
return new Proxy(facade, {
get(_target, property) {
const original: unknown = Reflect.get(integration, property, integration);
if (!isTelemetryHook(property) || !isMethod(original)) return original;
const cached = methodCache.get(property);
if (cached?.original === original) return cached.wrapped;
const wrapped: UnknownMethod = function (...args) {
const [argument, ...rest] = args;
return Reflect.apply(original, integration, [
redactHookArgument(property, argument, redact),
...rest,
]);
};
methodCache.set(property, { original, wrapped });
return wrapped;
},
});
}
function isOpenTelemetryTracer(value: unknown): value is Tracer {
return (
value !== null &&
typeof value === 'object' &&
typeof Reflect.get(value, 'startSpan') === 'function' &&
typeof Reflect.get(value, 'startActiveSpan') === 'function'
);
}
async function createAiSdkOpenTelemetryIntegrationFactory(
tracer: OpaqueTracer,
): Promise<(metadata: Record<string, AttributeValue> | undefined) => AiSdkTelemetry> {
if (!isOpenTelemetryTracer(tracer)) {
throw new Error('Telemetry tracer must implement startSpan() and startActiveSpan().');
}
return wrapped;
const { LegacyOpenTelemetry } = await import('@ai-sdk/otel');
return (metadata) =>
new LegacyOpenTelemetry({ tracer: createMetadataEnrichedTracer(tracer, metadata) });
}
/**
@@ -128,7 +181,7 @@ async function createOtlpTracer(endpoint: string): Promise<{
*
* Use `.tracer()` with a pre-built integration (e.g. `LangSmithTelemetry`,
* `integrations.langsmith()`) or `.otlpEndpoint()` for a generic OTLP
* collector. Add AI SDK `TelemetryIntegration` hooks via `.integration()`.
* collector. Add AI SDK `Telemetry` hooks via `.integration()`.
*
* @example
* ```typescript
@@ -157,7 +210,7 @@ export class Telemetry {
protected redactFn?: RedactFn;
protected integrationsList: TelemetryIntegration[] = [];
protected integrationsList: AiSdkTelemetry[] = [];
protected tracerValue?: OpaqueTracer;
@@ -242,7 +295,7 @@ export class Telemetry {
}
/** Add a telemetry integration (e.g. for observability platforms). */
integration(value: TelemetryIntegration): this {
integration(value: AiSdkTelemetry): this {
this.integrationsList.push(value);
return this;
}
@@ -285,9 +338,26 @@ export class Telemetry {
provider = otlp.provider;
}
const integrations = this.redactFn
? this.integrationsList.map((i) => wrapIntegrationWithRedaction(i, this.redactFn!))
const redactFn = this.redactFn;
const customIntegrations = redactFn
? this.integrationsList.map((integration) =>
wrapIntegrationWithRedaction(integration, redactFn),
)
: [...this.integrationsList];
let resolveIntegrations: BuiltTelemetry['resolveIntegrations'];
let integrations = customIntegrations;
if (tracer !== undefined) {
const createOpenTelemetryIntegration =
await createAiSdkOpenTelemetryIntegrationFactory(tracer);
resolveIntegrations = (metadata) => {
const integration = createOpenTelemetryIntegration(metadata);
return [
redactFn ? wrapIntegrationWithRedaction(integration, redactFn) : integration,
...customIntegrations,
];
};
integrations = resolveIntegrations(this.metadataValue);
}
return {
enabled: this.enabledValue,
@@ -297,6 +367,7 @@ export class Telemetry {
recordOutputs: this.recordOutputsValue,
runtimeRootSpanEnabled: this.runtimeRootSpanEnabledValue,
integrations,
...(resolveIntegrations && { resolveIntegrations }),
tracer,
provider,
credentialName: this.credentialNameValue,
+2
View File
@@ -7,7 +7,9 @@ export type {
ContentCitation,
ContentText,
ContentReasoning,
ContentReasoningFile,
ContentFile,
ContentCustom,
ContentToolCall,
ContentInvalidToolCall,
ContentProvider,
+11 -4
View File
@@ -1,5 +1,10 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { LanguageModel, OnStepFinishEvent, OnStepStartEvent, smoothStream } from 'ai';
import type {
GenerateTextStepEndEvent,
GenerateTextStepStartEvent,
LanguageModel,
smoothStream,
} from 'ai';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { AgentMessage, ContentMetadata } from './message';
@@ -91,7 +96,7 @@ export type StreamChunk = ContentMetadata &
| {
/**
* Emitted just before a tool handler starts executing. Bridged from
* the runtime event bus (not part of the AI SDK fullStream). Pairs
* the runtime event bus (not part of the AI SDK stream). Pairs
* with the subsequent `tool-result` to let consumers show a
* mid-flight indicator between "LLM picked a tool" and "result arrived".
*/
@@ -185,8 +190,10 @@ export interface ExecutionOptions {
telemetry?: BuiltTelemetry;
/** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */
executionCounter?: AgentExecutionCounter;
onStepStart?: (event: OnStepStartEvent) => void | Promise<void>;
onStepFinish?: (event: OnStepFinishEvent) => void | Promise<void>;
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise<void>;
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/** @deprecated Use `onStepEnd` instead. */
onStepFinish?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/**
* Durable-log RFC (resilience phase), opt-in: persist a `running`-status
* checkpoint at every step boundary (after a tool batch settles, before the
@@ -9,8 +9,10 @@ export type MessageContent =
| ContentToolCall
| ContentInvalidToolCall
| ContentReasoning
| ContentReasoningFile
| ContentFile
| ContentCitation
| ContentCustom
| ContentProvider;
export interface ContentMetadata {
@@ -83,6 +85,17 @@ export type ContentFile = ContentMetadata & {
data: Uint8Array | ArrayBuffer | Buffer | string;
};
export type ContentReasoningFile = ContentMetadata & {
type: 'reasoning-file';
data: ContentFile['data'];
mediaType: string;
};
export type ContentCustom = ContentMetadata & {
type: 'custom';
kind: `${string}.${string}`;
};
export type ContentToolCall = ContentMetadata & {
type: 'tool-call';
+6 -2
View File
@@ -1,4 +1,4 @@
import type { TelemetryIntegration } from 'ai';
import type { Telemetry as AiSdkTelemetry } from 'ai';
/** OTel-compatible attribute values (no undefined). */
export type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
@@ -43,7 +43,11 @@ export interface BuiltTelemetry {
*/
readonly rootAnchored?: boolean;
/** Integrations are pre-wrapped with redaction if .redact() was set at build time. */
readonly integrations: TelemetryIntegration[];
readonly integrations: AiSdkTelemetry[];
/** @internal Rebuild metadata-sensitive integrations for the effective run metadata. */
readonly resolveIntegrations?: (
metadata: Record<string, AttributeValue> | undefined,
) => AiSdkTelemetry[];
readonly tracer?: OpaqueTracer;
/** @internal Provider reference for flush/shutdown. Only set when .otlpEndpoint() is used. */
readonly provider?: OpaqueTracerProvider;
+1 -1
View File
@@ -64,7 +64,7 @@
"@n8n/mcp-browser": "workspace:*",
"@n8n/utils": "workspace:*",
"@n8n/workflow-sdk": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "catalog:",
"@thednp/dommatrix": "^2.0.12",
"ai": "catalog:",
"csv-parse": "catalog:",
@@ -1,4 +1,4 @@
import type { OnStepFinishEvent, OnStepStartEvent } from 'ai';
import type { GenerateTextStepEndEvent, GenerateTextStepStartEvent } from 'ai';
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
@@ -12,7 +12,7 @@ import {
} from '../run-debug-buffer';
import { sanitizeDebugSnapshotValue } from '../sanitize-debug-snapshot';
function makeFinishEvent(text: string): OnStepFinishEvent {
function makeStepEndEvent(text: string): GenerateTextStepEndEvent {
return {
stepNumber: 0,
text,
@@ -24,10 +24,10 @@ function makeFinishEvent(text: string): OnStepFinishEvent {
timestamp: new Date('2026-01-01T00:00:00.000Z'),
messages: [{ role: 'assistant', content: text }],
},
} as unknown as OnStepFinishEvent;
} as unknown as GenerateTextStepEndEvent;
}
function makeStartEvent(): OnStepStartEvent {
function makeStepStartEvent(): GenerateTextStepStartEvent {
return {
stepNumber: 0,
system: 'You are helpful',
@@ -36,7 +36,7 @@ function makeStartEvent(): OnStepStartEvent {
toolChoice: 'auto',
activeTools: ['search'],
abortSignal: new AbortController().signal,
} as unknown as OnStepStartEvent;
} as unknown as GenerateTextStepStartEvent;
}
describe('RunDebugBuffer', () => {
@@ -44,8 +44,8 @@ describe('RunDebugBuffer', () => {
const buffer = new RunDebugBuffer();
buffer.ensure('run-1', 'thread-1');
buffer.recordStepStart('run-1', 0, makeStartEvent());
buffer.recordStepFinish('run-1', 0, makeFinishEvent('done'));
buffer.recordStepStart('run-1', 0, makeStepStartEvent());
buffer.recordStepFinish('run-1', 0, makeStepEndEvent('done'));
const record = buffer.get('run-1');
expect(record?.steps).toHaveLength(1);
@@ -60,8 +60,8 @@ describe('RunDebugBuffer', () => {
const hooks = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
for (const label of ['first', 'second', 'third']) {
hooks.onStepStart(makeStartEvent());
hooks.onStepFinish(makeFinishEvent(label));
hooks.onStepStart(makeStepStartEvent());
hooks.onStepEnd(makeStepEndEvent(label));
}
const record = buffer.get('run-1');
@@ -75,12 +75,12 @@ describe('RunDebugBuffer', () => {
buffer.ensure('run-1', 'thread-1');
const firstPass = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
firstPass.onStepStart(makeStartEvent());
firstPass.onStepFinish(makeFinishEvent('before suspend'));
firstPass.onStepStart(makeStepStartEvent());
firstPass.onStepEnd(makeStepEndEvent('before suspend'));
const resumePass = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
resumePass.onStepStart(makeStartEvent());
resumePass.onStepFinish(makeFinishEvent('after resume'));
resumePass.onStepStart(makeStepStartEvent());
resumePass.onStepEnd(makeStepEndEvent('after resume'));
const record = buffer.get('run-1');
expect(record?.steps).toHaveLength(2);
@@ -100,7 +100,7 @@ describe('RunDebugBuffer', () => {
toolChoice: 'auto',
activeTools: ['search'],
abortSignal: new AbortController().signal,
} as unknown as OnStepStartEvent,
} as unknown as GenerateTextStepStartEvent,
4,
);
@@ -146,7 +146,7 @@ describe('RunDebugBuffer', () => {
messages: [{ role: 'assistant', content: '' }],
body: { secret: 'raw-provider-body' },
},
} as unknown as OnStepFinishEvent,
} as unknown as GenerateTextStepEndEvent,
0,
);
@@ -177,7 +177,7 @@ describe('RunDebugBuffer', () => {
buffer.recordStepStart(runId, 0, {
stepNumber: 0,
messages: [],
} as unknown as OnStepStartEvent);
} as unknown as GenerateTextStepStartEvent);
}
expect(buffer.get('run-0')).toBeUndefined();
@@ -193,12 +193,20 @@ describe('RunDebugBuffer', () => {
hooks.onStepStart({
stepNumber: 2,
messages: [{ role: 'user', content: 'ping' }],
} as unknown as OnStepStartEvent);
} as unknown as GenerateTextStepStartEvent);
expect(buffer.get('run-1')?.steps[0]?.stepNumber).toBe(0);
expect(buffer.get('run-1')?.steps[0]?.input?.sdkStepNumber).toBe(2);
});
it('exposes onStepEnd and keeps onStepFinish as a compatibility alias', () => {
const buffer = new RunDebugBuffer();
const hooks = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
expect(typeof hooks.onStepEnd).toBe('function');
expect(hooks.onStepFinish).toBe(hooks.onStepEnd);
});
it('stores a run label on first ensure', () => {
const buffer = new RunDebugBuffer();
buffer.ensure('run-1', 'thread-1', 'build a weather workflow');
@@ -1,5 +1,5 @@
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import type { OnStepFinishEvent, OnStepStartEvent } from 'ai';
import type { GenerateTextStepEndEvent, GenerateTextStepStartEvent } from 'ai';
import type { Logger } from '../logger';
import { sanitizeDebugSnapshotRecord, sanitizeDebugSnapshotValue } from './sanitize-debug-snapshot';
@@ -53,16 +53,18 @@ export interface RunDebugStepHookOptions {
threadId: string;
}
function captureStepStartPayload(event: OnStepStartEvent): Record<string, unknown> {
const { abortSignal: _abortSignal, ...capturable } = event;
return sanitizeDebugSnapshotRecord(capturable);
}
function captureStepFinishPayload(event: OnStepFinishEvent): Record<string, unknown> {
function captureStepStartPayload(event: GenerateTextStepStartEvent): Record<string, unknown> {
return sanitizeDebugSnapshotRecord(event);
}
export function sanitizeStepStart(event: OnStepStartEvent, stepNumber: number): SanitizedStepStart {
function captureStepFinishPayload(event: GenerateTextStepEndEvent): Record<string, unknown> {
return sanitizeDebugSnapshotRecord(event);
}
export function sanitizeStepStart(
event: GenerateTextStepStartEvent,
stepNumber: number,
): SanitizedStepStart {
return {
...captureStepStartPayload(event),
stepNumber,
@@ -71,7 +73,7 @@ export function sanitizeStepStart(event: OnStepStartEvent, stepNumber: number):
}
export function sanitizeStepFinish(
event: OnStepFinishEvent,
event: GenerateTextStepEndEvent,
stepNumber: number,
): SanitizedStepFinish {
return {
@@ -85,21 +87,26 @@ export function createRunDebugStepHooks(
buffer: RunDebugBuffer,
options: RunDebugStepHookOptions,
): {
onStepStart: (event: OnStepStartEvent) => void;
onStepFinish: (event: OnStepFinishEvent) => void;
onStepStart: (event: GenerateTextStepStartEvent) => void;
onStepEnd: (event: GenerateTextStepEndEvent) => void;
/** @deprecated Use `onStepEnd` instead. */
onStepFinish: (event: GenerateTextStepEndEvent) => void;
} {
// The agent runtime calls streamText/generateText once per loop iteration. The AI SDK
// resets stepNumber to 0 on each call, so we allocate a run-scoped sequence instead.
let stepIndex = buffer.getNextStepIndex(options.runId);
const onStepEnd = (event: GenerateTextStepEndEvent) => {
buffer.recordStepFinish(options.runId, stepIndex, event);
stepIndex++;
};
return {
onStepStart: (event) => {
buffer.recordStepStart(options.runId, stepIndex, event);
},
onStepFinish: (event) => {
buffer.recordStepFinish(options.runId, stepIndex, event);
stepIndex++;
},
onStepEnd,
onStepFinish: onStepEnd,
};
}
@@ -153,7 +160,7 @@ export class RunDebugBuffer {
return this.records.get(runId)?.nextStepIndex ?? 0;
}
recordStepStart(runId: string, stepIndex: number, event: OnStepStartEvent): void {
recordStepStart(runId: string, stepIndex: number, event: GenerateTextStepStartEvent): void {
const record = this.records.get(runId);
if (!record) return;
@@ -171,7 +178,7 @@ export class RunDebugBuffer {
record.steps.sort((a, b) => a.stepNumber - b.stepNumber);
}
recordStepFinish(runId: string, stepIndex: number, event: OnStepFinishEvent): void {
recordStepFinish(runId: string, stepIndex: number, event: GenerateTextStepEndEvent): void {
const record = this.records.get(runId);
if (!record) return;
+1 -1
View File
@@ -173,7 +173,7 @@
"@n8n/workflow-sdk": "workspace:*",
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "3.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
"@opentelemetry/instrumentation": "^0.217.0",
@@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended';
import { AgentRunTracingService, modelIdFromSnapshot } from '../agent-run-tracing.service';
vi.mock('@opentelemetry/api', () => ({
trace: { getTracer: vi.fn(() => ({ startActiveSpan: vi.fn() })) },
trace: { getTracer: vi.fn(() => ({ startSpan: vi.fn(), startActiveSpan: vi.fn() })) },
}));
describe('AgentRunTracingService', () => {
@@ -53,8 +53,10 @@ describe('InstanceAiService run debug gating', () => {
);
expect(streamOptions.onStepStart).toBeUndefined();
expect(streamOptions.onStepEnd).toBeUndefined();
expect(streamOptions.onStepFinish).toBeUndefined();
expect(resumeOptions.onStepStart).toBeUndefined();
expect(resumeOptions.onStepEnd).toBeUndefined();
expect(resumeOptions.onStepFinish).toBeUndefined();
expect(service.getRunDebug(runId)).toBeUndefined();
// Both terminal paths opt into raw-usage recovery so stopped/errored runs bill.
@@ -85,8 +87,10 @@ describe('InstanceAiService run debug gating', () => {
);
expect(typeof streamOptions.onStepStart).toBe('function');
expect(typeof streamOptions.onStepEnd).toBe('function');
expect(typeof streamOptions.onStepFinish).toBe('function');
expect(typeof resumeOptions.onStepStart).toBe('function');
expect(typeof resumeOptions.onStepEnd).toBe('function');
expect(typeof resumeOptions.onStepFinish).toBe('function');
expect(service.getRunDebug(runId)).toEqual(
expect.objectContaining({
+975 -914
View File
File diff suppressed because it is too large Load Diff
+18 -16
View File
@@ -6,19 +6,20 @@ packages:
- packages/testing/**
catalog:
'@ai-sdk/amazon-bedrock': ^4.0.113
'@ai-sdk/anthropic': ^3.0.81
'@ai-sdk/azure': ^3.0.70
'@ai-sdk/cohere': ^3.0.36
'@ai-sdk/deepseek': ^2.0.35
'@ai-sdk/gateway': ^3.0.125
'@ai-sdk/google': ^3.0.80
'@ai-sdk/groq': ^3.0.39
'@ai-sdk/mistral': ^3.0.37
'@ai-sdk/openai': ^3.0.68
'@ai-sdk/openai-compatible': ^2.0.48
'@ai-sdk/provider-utils': ^4.0.27
'@ai-sdk/xai': ^3.0.93
'@ai-sdk/amazon-bedrock': ^5.0.31
'@ai-sdk/anthropic': ^4.0.20
'@ai-sdk/azure': ^4.0.21
'@ai-sdk/cohere': ^4.0.12
'@ai-sdk/deepseek': ^3.0.13
'@ai-sdk/gateway': ^4.0.28
'@ai-sdk/google': ^4.0.24
'@ai-sdk/groq': ^4.0.13
'@ai-sdk/mistral': ^4.0.14
'@ai-sdk/openai': ^4.0.20
'@ai-sdk/openai-compatible': ^3.0.14
'@ai-sdk/otel': ^1.0.37
'@ai-sdk/provider-utils': ^5.0.12
'@ai-sdk/xai': ^4.0.18
'@azure/identity': 4.13.0
'@azure/storage-blob': ^12.32.0
'@chat-adapter/linear': ^4.28.1
@@ -57,7 +58,8 @@ catalog:
'@modelcontextprotocol/sdk': 1.26.0
'@mozilla/readability': ^0.6.0
'@n8n_io/ai-assistant-sdk': 1.25.0
'@openrouter/ai-sdk-provider': ^2.8.0
'@openrouter/ai-sdk-provider': ^3.0.0
'@opentelemetry/api': 1.9.1
'@pinecone-database/pinecone': ^5.0.2
'@qdrant/js-client-rest': ^1.16.2
'@rudderstack/rudder-sdk-node': 3.0.5
@@ -113,7 +115,7 @@ catalog:
'@vitest/coverage-v8': 4.1.9
'@e965/xlsx': 0.20.3
agent-browser: 0.26.0
ai: ^6.0.197
ai: ^7.0.37
axios: 1.18.0
basic-auth: 2.0.1
cache-manager: 5.2.3
@@ -209,7 +211,7 @@ catalog:
xml2js: 0.6.2
xss: 1.0.15
yaml: 2.8.3
zod: 3.25.67
zod: 3.25.76
zod-to-json-schema: 3.23.3
catalogMode: strict