Skip to content

DurableAgent stops tool loop when step has tool calls but finishReason is "other"/"unknown" #1387

@craze3

Description

@craze3

Bug

DurableAgent stops instead of continuing the tool loop when a step contains tool calls but the provider/model reports finishReason: other (or raw unknown) instead of tool-calls.

This is reproducible with a mock model, so it does not appear to be provider-specific.

Versions

  • @workflow/ai: 4.0.1-beta.54
  • workflow: 4.2.0-beta.67
  • ai: 6.0.116

I also checked the published @workflow/ai@4.0.1-beta.56 tarball and the same loop logic still appears to be present.

Expected

If a step contains tool calls, DurableAgent should yield those tool calls, accept tool results, and continue the loop, regardless of whether the provider reports finishReason: tool-calls vs other/unknown.

ai core streamText already behaves this way.

Actual

DurableAgent stops after the first step and never executes/feeds back the tool result.

Minimal repro

import { streamText, simulateReadableStream, stepCountIs, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { DurableAgent } from '@workflow/ai/agent';
import z from 'zod/v3';

function makeMockModel() {
  let call = 0;

  return new MockLanguageModelV3({
    doStream: async () => {
      call += 1;

      if (call === 1) {
        return {
          stream: simulateReadableStream({
            chunks: [
              { type: 'stream-start', warnings: [] },
              {
                type: 'tool-call',
                toolCallId: 'call_1',
                toolName: 'readFile',
                input: JSON.stringify({ path: '.claude/plan.md' }),
              },
              {
                type: 'finish',
                finishReason: { unified: 'other', raw: 'unknown' },
                usage: {
                  inputTokens: {
                    total: 10,
                    noCache: 10,
                    cacheRead: undefined,
                    cacheWrite: undefined,
                  },
                  outputTokens: {
                    total: 5,
                    text: 0,
                    reasoning: 5,
                  },
                },
              },
            ],
          }),
        };
      }

      return {
        stream: simulateReadableStream({
          chunks: [
            { type: 'stream-start', warnings: [] },
            { type: 'text-start', id: 'text_1' },
            { type: 'text-delta', id: 'text_1', delta: 'continued after tool result' },
            { type: 'text-end', id: 'text_1' },
            {
              type: 'finish',
              finishReason: { unified: 'stop', raw: 'stop' },
              usage: {
                inputTokens: {
                  total: 20,
                  noCache: 20,
                  cacheRead: undefined,
                  cacheWrite: undefined,
                },
                outputTokens: {
                  total: 6,
                  text: 6,
                  reasoning: 0,
                },
              },
            },
          ],
        }),
      };
    },
  });
}

const tools = {
  readFile: tool({
    inputSchema: z.object({ path: z.string() }),
    execute: async ({ path }) => ({ path, content: 'plan contents' }),
  }),
};

// AI SDK core continues correctly
const aiResult = streamText({
  model: makeMockModel(),
  prompt: 'Read the plan',
  tools,
  stopWhen: stepCountIs(5),
});

console.log('streamText text:', await aiResult.text);
console.log(
  'streamText steps:',
  (await aiResult.steps).map(step => ({
    finishReason: step.finishReason,
    toolCalls: step.toolCalls.length,
    toolResults: step.toolResults.length,
  })),
);

// DurableAgent stops after step 1
const agent = new DurableAgent({
  model: async () => makeMockModel(),
  tools,
});

const writable = new WritableStream({ write() {} });

const agentResult = await agent.stream({
  messages: [{ role: 'user', content: [{ type: 'text', text: 'Read the plan' }] }],
  writable,
  maxSteps: 5,
  stopWhen: [({ steps }) => steps.length >= 5],
});

console.log(
  'DurableAgent steps:',
  agentResult.steps.map(step => ({
    finishReason: step.finishReason,
    toolCalls: step.toolCalls.length,
    toolResults: step.toolResults.length,
    text: step.text,
  })),
);

Try DurableAgent with the new Grok 4.20 and you will probably encounter it

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions