mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
build(core): Upgrade agents to AI SDK 7 (#35072)
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,9 @@ export type {
|
||||
ContentCitation,
|
||||
ContentText,
|
||||
ContentReasoning,
|
||||
ContentReasoningFile,
|
||||
ContentFile,
|
||||
ContentCustom,
|
||||
ContentToolCall,
|
||||
ContentInvalidToolCall,
|
||||
ContentProvider,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
+975
-914
File diff suppressed because it is too large
Load Diff
+18
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user