Skip to main content
Vercel AI SDK: Why Your Agent Stops at Step 2

Vercel AI SDK: Why Your Agent Stops at Step 2

AI Integration
••5 min read•By Daily Miranda Pardo

The agent starts fine. It receives the query, calls its first tool, gets the data… and stops. No second call. No final answer. The log shows finish_reason: "stop" right after the first tool call, as if the agent decided it was already done.

This isn't a model bug or a Vercel issue. The agent just never got permission to continue.

The problem: generateText only runs one cycle

Most Vercel AI SDK tutorials start with generateText. It works perfectly for simple text generation. The problem surfaces when you add tools and expect the agent to use them sequentially.

Without an explicit maxSteps, generateText runs exactly one full cycle: it calls the LLM, the LLM decides to use a tool, the tool executes, the result comes back, and the cycle ends. The LLM never gets a chance to reason about the result and take the next step.

// This agent ALWAYS stops after the first tool call
const result = await generateText({
  model: anthropic('claude-sonnet-5-5'),
  tools: {
    fetchOrder: tool({
      description: 'Fetch an order by ID',
      parameters: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => getOrder(orderId),
    }),
    updateStatus: tool({
      description: 'Update an order status',
      parameters: z.object({ orderId: z.string(), status: z.string() }),
      execute: async ({ orderId, status }) => setOrderStatus(orderId, status),
    }),
  },
  messages,
});
// updateStatus never runs even if the model wants to call it.

The agent fetches the order, receives the data, and the cycle ends there. updateStatus is never called even though that was the whole point.

The fix: streamText with maxSteps

For agents that need to reason across multiple steps — fetching data, processing it, acting on it — you need streamText with maxSteps configured:

// app/api/agent/route.ts
import { streamText, tool } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = await streamText({
    model: anthropic('claude-sonnet-5-5'),
    maxSteps: 5, // up to 5 LLM ↔ tool cycles
    tools: {
      fetchOrder: tool({
        description: 'Fetch an order by ID',
        parameters: z.object({ orderId: z.string() }),
        execute: async ({ orderId }) => {
          const order = await getOrder(orderId);
          if (!order) return { error: 'Order not found', orderId };
          return { data: order };
        },
      }),
      updateStatus: tool({
        description: 'Update an order status',
        parameters: z.object({ orderId: z.string(), status: z.string() }),
        execute: async ({ orderId, status }) => {
          await setOrderStatus(orderId, status);
          return { success: true, orderId, newStatus: status };
        },
      }),
    },
    messages,
  });

  return result.toDataStreamResponse();
}

maxSteps: 5 allows up to 5 full cycles: LLM decides → tool executes → result returns to LLM → LLM decides again. Exactly what you need for tasks that require combining multiple data sources before giving a final answer.

Three more patterns that break the loop

1. Tool execute that throws exceptions

When a tool throws an error, the stream dies and the agent loop ends abruptly. The LLM receives no context about what went wrong and can't take an alternative path.

// BAD: an exception kills the entire stream
execute: async ({ orderId }) => {
  const order = await getOrder(orderId);
  if (!order) throw new Error('Not found'); // stream dead
  return order;
}

// GOOD: return an error object the LLM can reason about
execute: async ({ orderId }) => {
  const order = await getOrder(orderId);
  if (!order) return { error: 'Order not found', orderId };
  return order;
}

Returning an error object instead of throwing gives the model a chance to adapt: try with a different identifier, ask the user for clarification, or report the issue gracefully.

2. maxSteps out of sync between server and client

If you use useChat on the client, maxSteps must match between server and client. The client needs to know how many intermediate responses to expect before the final text arrives.

// app/components/AgentChat.tsx
'use client';
import { useChat } from 'ai/react';

export function AgentChat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } =
    useChat({
      api: '/api/agent',
      maxSteps: 5, // must match the server
    });

  return (
    <form onSubmit={handleSubmit}>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      <input value={input} onChange={handleInputChange} disabled={isLoading} />
      <button type="submit" disabled={isLoading}>Send</button>
    </form>
  );
}

3. Reading result.text before the stream finishes

If you use streamText in a non-streaming context (a webhook, a cron job, a background task), you must await the stream before reading the result:

const result = await streamText({ ... });

// Wait for the stream to complete
const text = await result.text;
const toolResults = await result.toolResults;

Without the await, you access the stream before it completes and get undefined or a partial value.

The full production pattern

For a support agent that looks up information and updates records, the complete setup is:

Server (app/api/agent/route.ts): streamText with maxSteps: 5, tools with execute functions that return objects — never throw — and result.toDataStreamResponse() to stream the response to the client.

Client (useChat with api: '/api/agent' and maxSteps: 5): the hook handles tool_result messages automatically and waits for the final text before updating the UI.

This pattern works for support agents, order management, data analysis, or any flow that combines multiple tools before giving a final answer.

If you're building this kind of integration and want it done right the first time, at DAILYMP we implement production-ready multi-step agents on Next.js — with error handling, observability, and architecture that holds up under real load. See how we work in AI Integration or through the AI Driven Development approach for teams that want to integrate agents into existing workflows.


Got an agent that keeps stalling? Tell me how it's set up and we'll figure it out together.

Message me on WhatsApp →

Share article

Repetitive processes in your business?

Download the free AI Automation Map — the 5 most time-consuming processes and how to fix them.

Controller: DailyMP (Daily Miranda Pardo). We use your email only to send you the PDF, unless you tick the box below. More info in the privacy policy.

Written by Daily Miranda Pardo

I help businesses automate processes, build AI agents and connect intelligent systems.