Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ffe2980
DurableAgent: type helpers, prepareStep on constructor, supportedUrls…
VaguelySerious Mar 14, 2026
900b8e1
lock
VaguelySerious Mar 14, 2026
65947d9
Address telemetry review feedback: match AI SDK span conventions
VaguelySerious Mar 15, 2026
af59968
Disable agent tests until working
VaguelySerious Mar 16, 2026
862f1fe
Merge branch 'main' into peter/combined-durable-agent-fixes
VaguelySerious Mar 16, 2026
0daa0f9
Merge remote-tracking branch 'origin/main' into peter/combined-durabl…
VaguelySerious Mar 16, 2026
6f07ac5
Support multimodal tool results and improve sendStart docs
VaguelySerious Mar 16, 2026
bd32306
fix: globalThis singleton for step context storage (#839)
VaguelySerious Mar 16, 2026
c2b6984
Merge branch 'main' into peter/combined-durable-agent-fixes
VaguelySerious Mar 16, 2026
c1640b9
Apply suggestion from @VaguelySerious
VaguelySerious Mar 16, 2026
ec86847
Enable DurableAgent tests
VaguelySerious Mar 16, 2026
5671606
Skip agent e2e tests on Next.js canary builds
VaguelySerious Mar 16, 2026
c5c8e0f
Fix canary skip: pass NEXT_CANARY env var to all e2e test jobs
VaguelySerious Mar 16, 2026
5ba0975
Revert "fix: globalThis singleton for step context storage (#839)"
VaguelySerious Mar 17, 2026
241cf78
Merge branch 'peter/enable-tests' into peter/combined-durable-agent-f…
VaguelySerious Mar 17, 2026
ff7c4b2
Merge branch 'main' into peter/combined-durable-agent-fixes
VaguelySerious Mar 17, 2026
f34efdf
Add e2e tests for prepareStep on constructor and multimodal tool results
VaguelySerious Mar 17, 2026
094fe61
Add prepareStep compat tests for DurableAgent
VaguelySerious Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/durable-agent-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Add type helpers (`InferDurableAgentTools`, `InferDurableAgentUIMessage`), support `prepareStep` on `DurableAgent` constructor, fix `supportedUrls` causing `AI_DownloadError` for image URLs, and add telemetry span support for `experimental_telemetry`
6 changes: 6 additions & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,15 @@
"workflow": "workspace:*"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"ai": "^6",
"workflow": "workspace:^"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
},
"dependencies": {
"@ai-sdk/provider": "^3.0.0",
"@workflow/serde": "workspace:^",
Expand Down
35 changes: 34 additions & 1 deletion packages/ai/src/agent/do-stream-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
StreamTextTransform,
TelemetrySettings,
} from './durable-agent.js';
import { recordSpan } from './telemetry.js';
import type { CompatibleLanguageModel } from './types.js';

export type FinishPart = Extract<LanguageModelV3StreamPart, { type: 'finish' }>;
Expand Down Expand Up @@ -163,7 +164,39 @@ export async function doStreamStep(
}),
};

const result = await model.doStream(callOptions);
const result = await recordSpan({
name: 'ai.streamText.doStream',
telemetry: options?.experimental_telemetry,
attributes: {
'ai.model.provider': model.provider,
'ai.model.id': model.modelId,
// gen_ai semantic convention attributes
'gen_ai.system': model.provider,
'gen_ai.request.model': model.modelId,
...(options?.maxOutputTokens !== undefined && {
'gen_ai.request.max_tokens': options.maxOutputTokens,
}),
...(options?.temperature !== undefined && {
'gen_ai.request.temperature': options.temperature,
}),
...(options?.topP !== undefined && {
'gen_ai.request.top_p': options.topP,
}),
...(options?.topK !== undefined && {
'gen_ai.request.top_k': options.topK,
}),
...(options?.frequencyPenalty !== undefined && {
'gen_ai.request.frequency_penalty': options.frequencyPenalty,
}),
...(options?.presencePenalty !== undefined && {
'gen_ai.request.presence_penalty': options.presencePenalty,
}),
...(options?.stopSequences !== undefined && {
'gen_ai.request.stop_sequences': options.stopSequences,
}),
},
fn: () => model!.doStream(callOptions),
});

let finish: FinishPart | undefined;
const toolCalls: LanguageModelV3ToolCall[] = [];
Expand Down
40 changes: 40 additions & 0 deletions packages/ai/src/agent/durable-agent-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type InferUITools, tool, type UIMessage } from 'ai';
import { describe, expectTypeOf, it } from 'vitest';
import { z } from 'zod';
import type {
DurableAgent,
InferDurableAgentTools,
InferDurableAgentUIMessage,
} from './durable-agent.js';

const getWeather = tool({
description: 'Get weather for a location',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => `Weather in ${location}`,
});

type WeatherAgent = DurableAgent<{
getWeather: typeof getWeather;
}>;

describe('InferDurableAgentTools', () => {
it('infers the tools from a durable agent', () => {
expectTypeOf<InferDurableAgentTools<WeatherAgent>>().toEqualTypeOf<{
getWeather: typeof getWeather;
}>();
});
});

describe('InferDurableAgentUIMessage', () => {
it('infers the UI message type from a durable agent', () => {
expectTypeOf<
InferDurableAgentUIMessage<WeatherAgent, { threadId: string }>
>().toEqualTypeOf<
UIMessage<
{ threadId: string },
never,
InferUITools<InferDurableAgentTools<WeatherAgent>>
>
>();
});
});
219 changes: 218 additions & 1 deletion packages/ai/src/agent/durable-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ import type {
} from '@ai-sdk/provider';
import type { StepResult, ToolSet } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { FatalError } from 'workflow';
import { z } from 'zod';

class FatalError extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalError';
}
}

// Mock the streamTextIterator
vi.mock('./stream-text-iterator.js', () => ({
streamTextIterator: vi.fn(),
Expand Down Expand Up @@ -277,6 +283,143 @@ describe('DurableAgent', () => {
});
});

it('should pass through LanguageModelV3ToolResultOutput directly', async () => {
// Tool returns a pre-formatted content output (e.g., multimodal with images)
const contentOutput = {
type: 'content',
value: [
{ type: 'text', text: 'Here is the image' },
{
type: 'file-data',
data: 'base64data',
mediaType: 'image/jpeg',
},
],
};
const tools: ToolSet = {
visionTool: {
description: 'Returns multimodal content',
inputSchema: z.object({}),
execute: vi.fn().mockResolvedValue(contentOutput),
},
};

const mockModel = createMockModel();

const agent = new DurableAgent({
model: async () => mockModel,
tools,
});

const mockWritable = new WritableStream({
write: vi.fn(),
close: vi.fn(),
});

const mockMessages: LanguageModelV3Prompt = [
{ role: 'user', content: [{ type: 'text', text: 'test' }] },
];

const { streamTextIterator } = await import('./stream-text-iterator.js');
const mockIterator = {
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
toolCalls: [
{
toolCallId: 'vision-call-id',
toolName: 'visionTool',
input: '{}',
} as LanguageModelV3ToolCall,
],
messages: mockMessages,
},
})
.mockResolvedValueOnce({ done: true, value: [] }),
};
vi.mocked(streamTextIterator).mockReturnValue(
mockIterator as unknown as MockIterator
);

await agent.stream({
messages: [{ role: 'user', content: 'test' }],
writable: mockWritable,
});

const toolResultsCall = mockIterator.next.mock.calls[1][0];
expect(toolResultsCall).toHaveLength(1);
expect(toolResultsCall[0]).toMatchObject({
type: 'tool-result',
toolCallId: 'vision-call-id',
toolName: 'visionTool',
output: contentOutput, // Passed through as-is, not wrapped in json
});
});

it('should pass through pre-formatted text output directly', async () => {
// Tool returns an already-formatted text output
const textOutput = { type: 'text', value: 'pre-formatted result' };
const tools: ToolSet = {
textTool: {
description: 'Returns pre-formatted text',
inputSchema: z.object({}),
execute: vi.fn().mockResolvedValue(textOutput),
},
};

const mockModel = createMockModel();

const agent = new DurableAgent({
model: async () => mockModel,
tools,
});

const mockWritable = new WritableStream({
write: vi.fn(),
close: vi.fn(),
});

const mockMessages: LanguageModelV3Prompt = [
{ role: 'user', content: [{ type: 'text', text: 'test' }] },
];

const { streamTextIterator } = await import('./stream-text-iterator.js');
const mockIterator = {
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
toolCalls: [
{
toolCallId: 'text-call-id',
toolName: 'textTool',
input: '{}',
} as LanguageModelV3ToolCall,
],
messages: mockMessages,
},
})
.mockResolvedValueOnce({ done: true, value: [] }),
};
vi.mocked(streamTextIterator).mockReturnValue(
mockIterator as unknown as MockIterator
);

await agent.stream({
messages: [{ role: 'user', content: 'test' }],
writable: mockWritable,
});

const toolResultsCall = mockIterator.next.mock.calls[1][0];
expect(toolResultsCall[0]).toMatchObject({
type: 'tool-result',
output: textOutput, // Passed through, not re-wrapped
});
});

it('should skip local execution for provider-executed tools', async () => {
// This tool should NOT be called because the tool call is provider-executed
const executeFn = vi.fn();
Expand Down Expand Up @@ -982,6 +1125,80 @@ describe('DurableAgent', () => {
);
});

it('should use prepareStep from the agent definition by default', async () => {
const mockModel = createMockModel();
const prepareStep: PrepareStepCallback = vi.fn().mockReturnValue({});

const agent = new DurableAgent({
model: async () => mockModel,
tools: {},
prepareStep,
});

const mockWritable = new WritableStream({
write: vi.fn(),
close: vi.fn(),
});

const { streamTextIterator } = await import('./stream-text-iterator.js');
const mockIterator = {
next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }),
};
vi.mocked(streamTextIterator).mockReturnValue(
mockIterator as unknown as MockIterator
);

await agent.stream({
messages: [{ role: 'user', content: 'test' }],
writable: mockWritable,
});

expect(streamTextIterator).toHaveBeenCalledWith(
expect.objectContaining({
prepareStep,
})
);
});

it('should prefer a stream prepareStep over the agent definition', async () => {
const mockModel = createMockModel();
const agentPrepareStep: PrepareStepCallback = vi.fn().mockReturnValue({});
const streamPrepareStep: PrepareStepCallback = vi
.fn()
.mockReturnValue({});

const agent = new DurableAgent({
model: async () => mockModel,
tools: {},
prepareStep: agentPrepareStep,
});

const mockWritable = new WritableStream({
write: vi.fn(),
close: vi.fn(),
});

const { streamTextIterator } = await import('./stream-text-iterator.js');
const mockIterator = {
next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }),
};
vi.mocked(streamTextIterator).mockReturnValue(
mockIterator as unknown as MockIterator
);

await agent.stream({
messages: [{ role: 'user', content: 'test' }],
writable: mockWritable,
prepareStep: streamPrepareStep,
});

expect(streamTextIterator).toHaveBeenCalledWith(
expect.objectContaining({
prepareStep: streamPrepareStep,
})
);
});

it('should allow prepareStep to modify messages', async () => {
const mockModel = createMockModel();

Expand Down
Loading
Loading