> Nivel 10 de Agent Harness Patterns, un recorrido de patrones sobre cómo funcionan los agentes de IA. Versión web: https://harnesspatterns.dev/es/patterns/fresh-laps · Todos los patrones (en inglés): https://harnesspatterns.dev/llms.txt

# Vueltas limpias

Algunos trabajos son demasiado largos para una sola sesión. Córrelos en vueltas: un agente nuevo en cada vuelta, y el progreso anotado donde el siguiente pueda encontrarlo.

## El problema

Algunos trabajos no entran en una sola ejecución: migrar 300 archivos, traducir un catálogo entero, arreglar cada test que falla en un repo. Cada paso suma una llamada a herramienta y un resultado al historial, y el bucle reenvía todo eso en cada turno.

Ese es Muddle, la sesión interminable. Por la tarde ya carga cada paso desde la mañana: los pedidos son enormes, los resultados viejos entierran a los nuevos, y el modelo empieza a rehacer trabajo que ya hizo o a saltarse trabajo que solo había planeado. Nada se cae. La calidad simplemente se escurre.

La compactación (nivel 7) frena a Muddle. No lo detiene: un trabajo lo bastante largo termina resumiendo sus propios resúmenes.

## La solución

No mantengas vivo un solo agente durante todo el trabajo. **Córrelo en vueltas**. En cada vuelta, tu código arranca un agente nuevo con el historial vacío y el mismo objetivo. Hace una porción del trabajo, anota cómo quedaron las cosas y termina. Después tu código revisa el trabajo en sí y, si no está terminado, arranca la siguiente vuelta.

- **Una sesión larga**
   Mantener el mismo agente y el mismo historial durante todo el trabajo.
   Cada turno reenvía todo desde el principio. Los pedidos se vuelven más pesados, al modelo le cuesta más encontrar lo que importa en ellos, y pasada la ventana se rompe.
- **Compactar sobre la marcha**
   La misma sesión, pero achicando los mensajes viejos cuando el historial se acerca al límite (nivel 7).
   Gana tiempo, no lo resuelve. Cada compactación pierde detalle, y un trabajo lo bastante largo termina compactando sus propios resúmenes.
- **Vueltas limpias**
   Dividir el trabajo en vueltas. Cada vuelta es un agente nuevo con el historial vacío. Lo que necesita saber, lo lee de archivos.
   Cada vuelta empieza chica y limpia. El precio: cada vuelta gasta un turno o dos en ubicarse, y los archivos tienen que decir todo lo que importa.

El truco es que nada importante vive en el historial. El trabajo está en disco (el puente), y también una nota corta que dice hasta dónde se llegó (`PROGRESS.md`). Un agente nuevo no necesita recordar la vuelta anterior. Solo necesita leer.

Al patrón suelen llamarlo **Ralph loop**, por un one-liner de shell que le pasaba a un agente de código el mismo prompt una y otra vez. Los agentes de código lo usan para refactors largos, con el árbol de git y un archivo TODO como estado.

## El elenco

El mismo elenco de siempre, esta vez en un cañón.

- **La escotilla** (tu código): Arranca un agente nuevo en cada vuelta (`createIterationAgent`) y recibe su respuesta. Es el bucle alrededor del bucle.
- **El Astor de una vuelta** (una ejecución del agente): El bucle del agente del nivel 2, con su propio bandoneón. Empieza vacío y se va flotando cuando termina la vuelta.
- **El puente** (el trabajo): Lo que las herramientas cambiaron en disco. Ninguna vuelta lo tira.
- **El cartel** (PROGRESS.md): Una nota corta de cada vuelta para la siguiente: qué está hecho, qué viene después.
- **DONE?** (isDone): Tu verificación, entre vueltas. Mide el puente, no lo que el modelo dice de él.
- **LAP 3/5** (maxIterations): El fusible. Si el trabajo nunca pasa la verificación, el bucle se detiene igual.

Mira las dos barras de arriba. *Esta vuelta* es lo que pesa de verdad cada pedido, y vuelve a empezar en cada vuelta. *1 sesión* es lo que pesarían los mismos pedidos si un solo agente hubiera hecho las tres vueltas: nunca baja.

## El código

**Con astorlm:** `runGoalLoop` recibe una fábrica que devuelve un agente nuevo, tu verificación `isDone` y un fusible `maxIterations`. Las herramientas escriben en archivos, así que cada vuelta encuentra el trabajo donde lo dejó la anterior.

**Desde cero:** El bucle del nivel 2, llamado dentro de un `for`. El historial es una variable local de cada llamada, así que cada vuelta empieza vacía sin costo.

**Con astorlm**

```ts
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: '…' }
```

**TypeScript**

```ts
// Fresh laps, from scratch. Plain fetch and node:fs, no SDK.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  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
}

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

type ToolFn = (args: Record<string, string | number>) => string
const tools: Record<string, ToolFn> = {
  read_progress: () => (existsSync('PROGRESS.md') ? readFileSync('PROGRESS.md', 'utf8') : 'Nothing built yet.'),
  lay_bricks: ({ from, count }) => {
    const to = Math.min(Number(from) + Math.min(Number(count), 12) - 1, GAP)
    writeFileSync('bridge.json', JSON.stringify({ length: Math.max(bridgeLength(), to) }))
    return `Laid bricks ${from}-${to}. The bridge is ${bridgeLength()} bricks long.`
  },
  write_progress: ({ text }) => {
    writeFileSync('PROGRESS.md', `# Progress\n${text}\n`)
    return 'Saved PROGRESS.md.'
  },
}
const toolSchemas = [/* one JSON Schema per tool: read_progress(), lay_bricks(from, count), write_progress(text) */]

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

// 2. The loop from level 2, unchanged. `messages` is born and dies inside each call.
async function runAgent(prompt: string, maxTurns = 8): Promise<string> {
  const messages: Message[] = [{ role: 'user', content: prompt }]
  for (let turn = 1; turn <= maxTurns; turn++) {
    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''

    for (const call of reply.tool_calls ?? []) {
      const run = tools[call.function.name]
      let output = `Unknown tool: ${call.function.name}`
      try {
        if (run) output = run(JSON.parse(call.function.arguments))
      } catch (err) {
        output = `Error: ${err instanceof Error ? err.message : err}`
      }
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// 3. The goal loop: a fresh run per lap, then YOUR check of the work on disk.
const GOAL = 'Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md.'
const MAX_LAPS = 5 // the fuse

for (let lap = 1; lap <= MAX_LAPS; lap++) {
  console.log(`lap ${lap}:`, await runAgent(GOAL))
  if (bridgeLength() >= GAP) {
    console.log(`Done after ${lap} laps.`)
    break
  }
  if (lap === MAX_LAPS) throw new Error(`Bridge unfinished after ${MAX_LAPS} laps: ${bridgeLength()}/${GAP}`)
}
```

**Python**

```python
# Fresh laps, from scratch. Standard library only, no SDK.
import json
import urllib.request
from pathlib import Path

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

def post(path, payload):
    request = urllib.request.Request(
        f"{LLM['base_url']}{path}",
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)

# 1. The state lives on disk: the bridge, and a progress note for the next lap.
GAP = 36
BRIDGE = Path("bridge.json")
PROGRESS = Path("PROGRESS.md")

def bridge_length():
    return json.loads(BRIDGE.read_text())["length"] if BRIDGE.exists() else 0

def read_progress():
    return PROGRESS.read_text() if PROGRESS.exists() else "Nothing built yet."

def lay_bricks(start, count):
    end = min(start + min(count, 12) - 1, GAP)
    BRIDGE.write_text(json.dumps({"length": max(bridge_length(), end)}))
    return f"Laid bricks {start}-{end}. The bridge is {bridge_length()} bricks long."

def write_progress(text):
    PROGRESS.write_text(f"# Progress\n{text}\n")
    return "Saved PROGRESS.md."

TOOLS = {
    "read_progress": read_progress,
    "lay_bricks": lambda **args: lay_bricks(args["from"], args["count"]),  # "from" is a Python keyword
    "write_progress": write_progress,
}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool: read_progress(), lay_bricks(from, count), write_progress(text)

# 2. The loop from level 2, unchanged. `messages` is born and dies inside each call.
def run_agent(prompt, max_turns=8):
    messages = [{"role": "user", "content": prompt}]

    for _ in range(max_turns):
        choice = post("/chat/completions", {"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS})["choices"][0]
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            try:
                output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
            except Exception as err:
                output = f"Error: {err}"
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")

# 3. The goal loop: a fresh run per lap, then YOUR check of the work on disk.
GOAL = "Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md."
MAX_LAPS = 5  # the fuse

for lap in range(1, MAX_LAPS + 1):
    print(f"lap {lap}:", run_agent(GOAL))
    if bridge_length() >= GAP:
        print(f"Done after {lap} laps.")
        break
else:
    raise RuntimeError(f"Bridge unfinished after {MAX_LAPS} laps: {bridge_length()}/{GAP}")
```

## Qué vigilar

- **Verifica el trabajo, no la respuesta.** Un “¡Listo!” del modelo no prueba nada. `isDone` debería mirar el resultado en sí: correr los tests, contar las filas, medir el puente. Mantenlo barato y determinista, porque corre después de cada vuelta.
- **Pon siempre el fusible.** Una verificación que nunca puede pasar, o un agente que sigue deshaciendo su propio trabajo, da vueltas hasta que tu factura lo detiene. `maxIterations`, y una mirada a por qué se agotó.
- **El archivo de progreso es el único traspaso.** Lo que deje afuera, la siguiente vuelta no lo sabe. Dile al agente exactamente qué escribir ahí: qué está hecho, qué sigue, qué probó y falló.
- **Haz que cada paso sea seguro de repetir.** Una vuelta puede morir a la mitad, después del trabajo pero antes de la nota. La siguiente vuelta va a hacer esa porción otra vez, así que hacerla dos veces no debe romper nada.
- **Mantén las porciones chicas.** Una vuelta debería entrar en una ejecución corta. Si una sola porción ya necesita compactación, las porciones son demasiado grandes.

## Patrones relacionados

- [4 · Cuándo parar](https://harnesspatterns.dev/es/patterns/when-to-stop.md)
- [7 · La mochila se llena](https://harnesspatterns.dev/es/patterns/compaction.md)
- [9 · Memoria](https://harnesspatterns.dev/es/patterns/memory.md)
- [12 · Planificar y reflexionar](https://harnesspatterns.dev/es/patterns/plan-and-reflect.md)
- [16 · Agentes proactivos](https://harnesspatterns.dev/es/patterns/proactive-agents.md)
