Skip to content
astorlm
← Map

Level 10

Fresh laps

Some jobs are too long for one session. Run them in laps instead: a fresh agent every lap, and the progress written down where the next one can find it.
1/65 Bandoneón folds:
  • user
  • assistant
  • tool_result
A canyon 36 bricks wide, and a crowd that needs to get to the exit. Too much for one agent’s run: this job goes in laps of 12 bricks.

EventBus

The problem

Some jobs don’t fit in one run: migrate 300 files, translate a whole catalog, fix every failing test in a repo. Each step adds a tool call and a tool result to the history, and the loop resends all of it every turn.

That’s Muddle, the endless session. By the afternoon it’s carrying every step since the morning: the requests are huge, the old results bury the new ones, and the model starts redoing work it already did or skipping work it only planned. Nothing crashes. The quality just drains away.

Compaction (level 7) slows Muddle down. It doesn’t stop it: a long enough job ends up summarizing its own summaries.

The solution

Don’t keep one agent alive for the whole job. Run it in laps. Each lap, your code starts a new agent with an empty history and the same goal. It does one slice of the work, writes down where things stand, and ends. Then your code checks the work itself and, if it isn’t done, starts the next lap.

  • One long session

    Keep the same agent and the same history for the whole job.

    Every turn resends everything since the start. The requests get heavier, the model gets worse at finding what matters in them, and past the window it breaks.

  • Compact as you go

    Same session, but shrink the old messages when the history gets close to the limit (level 7).

    Buys time, not a fix. Every compaction loses detail, and a long enough job compacts its own summaries.

  • Fresh laps

    Split the job into laps. Each lap is a new agent with an empty history. What it needs to know, it reads from files.

    Every lap starts small and clean. The price: each lap spends a turn or two finding its bearings, and the files have to say everything that matters.

The trick is that nothing important lives in the history. The work is on disk (the bridge), and so is a short note saying how far it got (PROGRESS.md). A new agent doesn’t need to remember the last lap. It only needs to read.

The pattern is often called the Ralph loop, after a shell one-liner that fed a coding agent the same prompt over and over. Coding agents use it for long refactors, with the git tree and a TODO file as the state.

The cast

Same cast as always, in a canyon this time.

The hatch your code
Starts a new agent every lap (createIterationAgent) and gets its answer back. It’s the loop around the loop.
A lap’s Astor one agent run
The agent loop from level 2, with its own bandoneón. It starts empty and floats away when the lap ends.
The bridge the work
What the tools changed on disk. No lap ever throws it away.
The sign PROGRESS.md
A short note from each lap to the next: what’s done, what comes next.
DONE? isDone
Your check, between laps. It measures the bridge, not what the model says about it.
LAP 3/5 maxIterations
The fuse. If the job never checks out, the loop stops anyway.

Watch the two bars at the top. This lap is what each request really weighs, and it starts over every lap. 1 session is what the same requests would weigh if one agent had done all three laps: it never goes down.

The code

With astorlm: runGoalLoop takes a factory that returns a new agent, your isDone check and a maxIterations fuse. The tools write to files, so every lap finds the work where the last one left it.

From scratch: The loop from level 2, called inside a for. The history is a local variable of each call, so every lap starts empty for free.

import { OpenAIProvider, createLocalAgent, runGoalLoop, tool } from 'astorlm'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { z } from 'zod'

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = { baseURL: 'http://localhost:11434/v1', apiKey: 'YOUR_API_KEY' } // local servers usually ignore the key

// The state lives on disk, not in any history: the bridge, and a progress note.
const GAP = 36
const bridgeLength = (): number => (existsSync('bridge.json') ? JSON.parse(readFileSync('bridge.json', 'utf8')).length : 0)

const readProgress = tool({
  name: 'read_progress',
  description: 'Read PROGRESS.md: what earlier laps built, and where to start.',
  schema: z.object({}),
  execute: async () => (existsSync('PROGRESS.md') ? readFileSync('PROGRESS.md', 'utf8') : 'Nothing built yet.'),
})

const layBricks = tool({
  name: 'lay_bricks',
  description: 'Lay up to 12 bricks of the bridge, starting at brick number "from".',
  schema: z.object({ from: z.number().int().min(1), count: z.number().int().min(1).max(12) }),
  execute: async ({ from, count }) => {
    const to = Math.min(from + count - 1, GAP)
    writeFileSync('bridge.json', JSON.stringify({ length: Math.max(bridgeLength(), to) }))
    return `Laid bricks ${from}-${to}. The bridge is ${bridgeLength()} bricks long.`
  },
})

const writeProgress = tool({
  name: 'write_progress',
  description: 'Overwrite PROGRESS.md with where the bridge stands now, for whoever comes next.',
  schema: z.object({ text: z.string() }),
  execute: async ({ text }) => {
    writeFileSync('PROGRESS.md', `# Progress\n${text}\n`)
    return 'Saved PROGRESS.md.'
  },
})

const result = await runGoalLoop({
  goal: 'Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md.',
  // A NEW agent every lap: empty history, fresh context window. Same tools, same folder.
  createIterationAgent: () =>
    createLocalAgent({
      provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
      tools: [readProgress, layBricks, writeProgress],
      maxTurns: 8,
    }),
  // Your code decides when the job is done, by checking the work itself. Not the model's word.
  isDone: () => bridgeLength() >= GAP,
  onIteration: ({ iteration, lastText }) => console.log(`lap ${iteration}: ${lastText}`),
  maxIterations: 5, // the fuse: a goal that never checks out can't run forever
})

console.log(result) // { iterations: 3, done: true, stopReason: 'done', lastText: '…' }

What to watch

  • Check the work, not the answer. “Done!” from the model proves nothing. isDone should look at the result itself: run the tests, count the rows, measure the bridge. Keep it cheap and deterministic, because it runs after every lap.
  • Always set the fuse. A check that can never pass, or an agent that keeps undoing its own work, loops until your bill stops it. maxIterations, and a look at why it ran out.
  • The progress file is the only handover. Whatever it leaves out, the next lap doesn’t know. Tell the agent exactly what to write there: what’s done, what’s next, what it tried that failed.
  • Make each step safe to repeat. A lap can die halfway, after the work but before the note. The next lap will do that slice again, so doing it twice must not break anything.
  • Keep the slices small. A lap should fit in one short run. If a single slice already needs compaction, the slices are too big.