Skip to content

WorkflowAgent: maxRetries and abortSignal returned from prepareCall are silently ignored #18576

Description

@hyamero

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)

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions