Description
WorkflowAgent's PrepareCallResult is Partial<Omit<PrepareCallOptions, 'tools'>>, and PrepareCallOptions extends Partial<GenerationSettings>, so returning any generation setting from prepareCall type-checks — including maxRetries and abortSignal. But unlike ToolLoopAgent, WorkflowAgent.stream() applies the prepareCall result field by field (packages/workflow/src/workflow-agent.ts), and the apply block copies every GenerationSettings key except those two:
if (prepared.seed !== undefined)
effectiveGenerationSettings.seed = prepared.seed;
// no prepared.maxRetries, no prepared.abortSignal
if (prepared.headers !== undefined)
effectiveGenerationSettings.headers = prepared.headers;
So both values are silently dropped:
maxRetries returned from prepareCall never lands in the merged settings; the call proceeds with the constructor/per-stream value or the default (mergedGenerationSettings.maxRetries ?? 2).
abortSignal returned from prepareCall never reaches the signal merge, which only reads options.abortSignal ?? effectiveGenerationSettings.abortSignal (per-stream and constructor values). The loop's abortSignal?.aborted checks and streamModelCall never see it.
The surrounding code suggests this is an oversight rather than a design choice: the same block applies the other 11 generation settings (maxOutputTokens through providerOptions), and prepareStep does honor maxRetries via the prepareStepGenerationSettingKeys allow-list in stream-text-iterator.ts — so the two callbacks currently disagree about whether a maxRetries override works.
Expected: either apply prepared.maxRetries and merge prepared.abortSignal into the effective signal, or Omit the unsupported keys from PrepareCallResult so the contract is visible at the type level. (If the latter, PrepareStepResult has the same question for abortSignal: it is typed via Partial<GenerationSettings> but excluded by the allow-list.)
Reproduction
Vitest test in packages/workflow, using the same mocked-streamTextIterator harness as the existing prepareCall tests in workflow-agent.test.ts. It inspects the generationSettings handed to the model-call layer; the temperature control shows the harness works.
import type {
LanguageModelV4,
LanguageModelV4Prompt,
LanguageModelV4ToolResultPart,
} from '@ai-sdk/provider';
import { describe, expect, it, vi } from 'vitest';
import type { StreamTextIteratorYieldValue } from './stream-text-iterator.js';
import type { PrepareCallResult } from './workflow-agent.js';
vi.mock('./stream-text-iterator.js', () => ({
streamTextIterator: vi.fn(),
}));
const { WorkflowAgent } = await import('./workflow-agent.js');
function createMockModel(): LanguageModelV4 {
return {
specificationVersion: 'v4' as const,
provider: 'test',
modelId: 'test-model',
doGenerate: vi.fn(),
doStream: vi.fn(),
supportedUrls: {},
};
}
type MockIterator = AsyncGenerator<
StreamTextIteratorYieldValue,
LanguageModelV4Prompt,
LanguageModelV4ToolResultPart[]
>;
describe('prepareCall generation settings', () => {
async function streamWithPrepareCall(prepared: PrepareCallResult) {
const { streamTextIterator } = await import('./stream-text-iterator.js');
vi.mocked(streamTextIterator).mockReturnValue({
next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }),
} as unknown as MockIterator);
const agent = new WorkflowAgent({
model: createMockModel(),
prepareCall: () => prepared,
});
await agent.stream({
messages: [{ role: 'user', content: 'test' }],
writable: new WritableStream({ write: vi.fn(), close: vi.fn() }),
});
return vi.mocked(streamTextIterator).mock.calls.at(-1)?.[0]
.generationSettings;
}
it('control: temperature returned from prepareCall reaches the model call', async () => {
const settings = await streamWithPrepareCall({ temperature: 0.9 });
expect(settings?.temperature).toBe(0.9); // passes
});
it('maxRetries returned from prepareCall reaches the model call', async () => {
const settings = await streamWithPrepareCall({ maxRetries: 5 });
expect(settings?.maxRetries).toBe(5); // fails: undefined
});
it('abortSignal returned from prepareCall reaches the model call', async () => {
const controller = new AbortController();
const settings = await streamWithPrepareCall({
abortSignal: controller.signal,
});
expect(settings?.abortSignal).toBeDefined(); // fails: undefined
});
});
Observed on current main: the control passes, the maxRetries and abortSignal tests fail with undefined. The file passes tsc --noEmit, confirming both keys are accepted by PrepareCallResult.
Happy to open a PR either way.
AI SDK Version
- @ai-sdk/workflow: 1.0.56 (same code on current main)
Description
WorkflowAgent'sPrepareCallResultisPartial<Omit<PrepareCallOptions, 'tools'>>, andPrepareCallOptions extends Partial<GenerationSettings>, so returning any generation setting fromprepareCalltype-checks — includingmaxRetriesandabortSignal. But unlikeToolLoopAgent,WorkflowAgent.stream()applies theprepareCallresult field by field (packages/workflow/src/workflow-agent.ts), and the apply block copies everyGenerationSettingskey except those two:So both values are silently dropped:
maxRetriesreturned fromprepareCallnever lands in the merged settings; the call proceeds with the constructor/per-stream value or the default (mergedGenerationSettings.maxRetries ?? 2).abortSignalreturned fromprepareCallnever reaches the signal merge, which only readsoptions.abortSignal ?? effectiveGenerationSettings.abortSignal(per-stream and constructor values). The loop'sabortSignal?.abortedchecks andstreamModelCallnever see it.The surrounding code suggests this is an oversight rather than a design choice: the same block applies the other 11 generation settings (
maxOutputTokensthroughproviderOptions), andprepareStepdoes honormaxRetriesvia theprepareStepGenerationSettingKeysallow-list instream-text-iterator.ts— so the two callbacks currently disagree about whether amaxRetriesoverride works.Expected: either apply
prepared.maxRetriesand mergeprepared.abortSignalinto the effective signal, orOmitthe unsupported keys fromPrepareCallResultso the contract is visible at the type level. (If the latter,PrepareStepResulthas the same question forabortSignal: it is typed viaPartial<GenerationSettings>but excluded by the allow-list.)Reproduction
Vitest test in
packages/workflow, using the same mocked-streamTextIteratorharness as the existingprepareCalltests inworkflow-agent.test.ts. It inspects thegenerationSettingshanded to the model-call layer; thetemperaturecontrol shows the harness works.Observed on current main: the control passes, the
maxRetriesandabortSignaltests fail withundefined. The file passestsc --noEmit, confirming both keys are accepted byPrepareCallResult.Happy to open a PR either way.
AI SDK Version