Skip to content
astorlm
← Map

Level 4

When to stop

An agent loop keeps going as long as the model asks for tools. There are three ways out: the model says it's done, a tool you marked closes the loop, or the turn budget runs out. Only the first two give you an answer.
1/42 Bandoneón folds:
  • user
  • assistant
  • tool_result
World 1-1. This level has no star at all, but the model will keep searching anyway. The clock is maxTurns: 3 turns for this run.

EventBus

The problem

The loop from level 2 has one normal exit: the model replies without asking for a tool. But a model that can't find what it's looking for rarely says so. It tries another search, then another spelling, then another. Each lap resends the whole history, so every lap costs more than the last one.

That's Loopboros, the loop that never ends. maxTurns stops it. But look at world 1-1 again: when the clock runs out, the loop doesn't fail. It stops and hands back its last message, and that message is a search request. No answer, no error.

The three exits

  • The flag

    end_turn

    World 1-2

    The model decides. It replies without asking for any tool. This is the normal way out, and most runs should end here.

    Returns: A message with the answer as text.

  • The warp pipe

    stopOnToolNames

    World 1-3

    Your code decides, when the model calls a tool you marked. The loop closes right after that tool runs without error. If it fails, the error goes back to the model and the loop continues.

    Returns: A message with the tool call. Its input is the answer, already checked against the schema.

  • The clock

    maxTurns

    World 1-1

    Nobody decides. The budget runs out. The loop stops after that many turns, whatever the model was doing. It’s a safety net, not an answer.

    Returns: Whatever the last message was. Often a tool call.

A terminal tool doesn't save a turn over end_turn. What it gives you is the answer as data, checked by a schema, ready for your app to use: here, a marker on the level map. Without stopOnToolNames, after mark_star ran, the loop would ask the model again just so it could say "done".

The code

With astorlm: maxTurns sets the clock and stopOnToolNames marks the warp pipes. run() always returns the last message, so read it to find out which exit the run took.

From scratch: The loop from level 2 with all three exits marked. Instead of a bare string, it returns how the run ended, so the caller can't mistake a timeout for an answer.

import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const searchBlocks = tool({
  name: 'search_blocks',
  description: 'Search the ? blocks in one area of the level. Returns what each one hides.',
  schema: z.object({ area: z.string() }),
  execute: async ({ area }) => level.search(area), // your code
})

// The answer as data. Its schema is checked before the loop is allowed to stop.
const markStar = tool({
  name: 'mark_star',
  description: 'Put a marker on the level map where the star is. Call it once, at the end.',
  schema: z.object({ item: z.literal('star'), block: z.number().int().positive() }),
  execute: async ({ block }) => map.addMarker(block), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [searchBlocks, markStar],
  appendSystemPrompt:
    'When you know where the star is, deliver it with mark_star. ' +
    "If a few searches come back empty, say you couldn't find it.",
  maxTurns: 10, // the clock. Without it, the default is 25.
  stopOnToolNames: ['mark_star'], // the warp pipe
})

const last = await agent.run('Is there a hidden star in this level?')

// The loop hands back its last message whichever way it ended. Read it to find out which.
const call = last.content.find((block) => block.type === 'tool_use')
if (!call) {
  console.log('end_turn:', last.content) // the flag: a plain text answer
} else if (call.name === 'mark_star') {
  console.log('answer:', call.input) // the warp pipe: { item, block }, schema-checked
} else {
  // The clock: maxTurns ran out while the model was still asking for tools.
  throw new Error(`No answer: the run stopped while asking for ${call.name}`)
}

What to watch

  • Always set maxTurns, and size it to the task. A lookup needs a handful of turns; a refactor may need dozens. The default in astorlm is 25.
  • Treat the clock as a failure. If the run ended with a tool call, tell the user you couldn't finish, or retry with a clearer prompt. Don't show them an empty answer.
  • Give the model a way to give up. Tell it in the system prompt to say "I couldn't find it" when a search keeps coming back empty. A model that is allowed to stop, stops sooner.
  • Turns aren't time. maxTurns doesn't help with a single turn that never ends. For that you need a timeout or an AbortSignal.