Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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`. Fix `LanguageModelV3ToolResultOutput` breaking response when not json compatible.
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
76 changes: 76 additions & 0 deletions packages/ai/src/agent/durable-agent-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,82 @@ describe('DurableAgent (ToolLoopAgent compat)', () => {
`);
});

it('should use prepareStep from constructor', async () => {
const stepNumbers: number[] = [];

const agent = new DurableAgent({
model: asModelFactory(mockModel),
prepareStep: ({ stepNumber }) => {
stepNumbers.push(stepNumber);
return {};
},
});

const { writable } = createMockWritable();

await agent.stream({
messages: [{ role: 'user' as const, content: 'Hello' }],
writable,
});

expect(stepNumbers).toEqual([0]);
});

it('should prefer stream prepareStep over constructor prepareStep', async () => {
const source: string[] = [];

const agent = new DurableAgent({
model: asModelFactory(mockModel),
prepareStep: () => {
source.push('constructor');
return {};
},
});

const { writable } = createMockWritable();

await agent.stream({
messages: [{ role: 'user' as const, content: 'Hello' }],
writable,
prepareStep: () => {
source.push('stream');
return {};
},
});

// Stream-level prepareStep should override constructor-level
expect(source).toEqual(['stream']);
});

it('should call constructor prepareStep on each step in multi-step', async () => {
const toolCallModel = createToolCallStreamMockModel();
const stepNumbers: number[] = [];

const agent = new DurableAgent({
model: asModelFactory(toolCallModel),
tools: {
testTool: tool({
inputSchema: z.object({ value: z.string() }),
execute: async ({ value }: { value: string }) => `result: ${value}`,
}),
},
prepareStep: ({ stepNumber }) => {
stepNumbers.push(stepNumber);
return {};
},
});

const { writable } = createMockWritable();

await agent.stream({
messages: [{ role: 'user' as const, content: 'Hello' }],
writable,
});

// prepareStep called for both the tool-call step and the final text step
expect(stepNumbers).toEqual([0, 1]);
});

it('should pass abortSignal to streamText', async () => {
const abortController = new AbortController();

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>>
>
>();
});
});
Loading
Loading