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

# La mochila se llena

Cada turno vuelve a enviar todo el historial al modelo. Si un chat sigue lo suficiente, deja de entrar. La compactación achica las partes más viejas antes de que eso pase.

## El problema

Un modelo solo puede leer cierta cantidad de una vez. Ese límite es su **ventana de contexto**, contada en tokens (pedazos de palabras, de unos cuatro caracteres cada uno): 8.000 en muchos modelos locales chicos, unos cientos de miles en los grandes modelos alojados. Todo lo de un pedido tiene que entrar ahí: el system prompt, cada mensaje hasta ahora, cada resultado de herramienta y lugar para la respuesta.

El modelo no recuerda nada entre llamadas, así que el bucle envía todo el historial en cada turno. Un chat de soporte que consulta un pedido y busca en un catálogo acumula miles de tokens de resultados que el modelo ya usó. Cada turno es más lento y cuesta más que el anterior. Y un día el pedido no entra.

Ese es Gulp, el desborde de contexto. Entonces el proveedor rechaza el pedido con un error o, peor, algunos servidores cortan en silencio la parte más vieja para que entre. La parte más vieja es donde el cliente dijo de qué pedido hablaba.

## La solución

Antes de cada llamada al modelo, el bucle revisa qué tan grande es el pedido. Pasada una línea, puesta por debajo del límite real para que la respuesta todavía tenga lugar, achica el historial. Hay tres formas comunes de hacerlo:

- **Truncar resultados de herramientas viejos**
   Reemplazar un resultado grande que el modelo ya usó por una nota de una línea y una vista previa corta.
   Casi gratis, y los resultados de herramientas suelen ser la parte más pesada del historial. Si el modelo vuelve a necesitar los detalles, llama otra vez a la herramienta.
- **Descartar intercambios viejos**
   Quitar los pedidos más antiguos con todo lo que vino después de ellos, hasta el siguiente pedido.
   También es gratis, pero el modelo olvida por completo esa parte de la conversación. Conserva el primerísimo pedido, porque a menudo dice de qué trata todo el chat.
- **Resumir con el modelo**
   Enviar la parte vieja al modelo una vez, y poner su resumen en lugar de esos mensajes.
   Conserva el sentido, pero cuesta una llamada extra, y un resumen puede dejar afuera en silencio el único número que importaba.

Elijas la que elijas, se aplican las mismas reglas: empieza por los mensajes más viejos, no toques los últimos pedidos y detente apenas entre. astorlm hace las dos primeras, en ese orden. Primero trunca los resultados de herramientas viejos, y solo descarta mensajes si eso no alcanzó.

## El elenco

El mismo elenco de siempre, esta vez en un pozo de bloques que caen.

- **El pozo** (ventana de contexto): Todo lo que puede llevar un pedido. Si la pila llega arriba, el pedido no entra.
- **Los bloques** (mensajes): Uno por mensaje, del tamaño de sus tokens. El piso es el system prompt: va con cada pedido y nunca se compacta.
- **La línea roja** (umbral): El 80% de la ventana. Pasada esa línea, el bucle compacta antes de llamar al modelo.
- **El martillo** (el compactador): Reduce el resultado de herramienta más viejo a una nota de una línea, y todo lo de arriba se asienta.
- **KEEP** (keepRecentTurns): Los dos últimos pedidos y todo lo que vino después. El martillo nunca los toca.
- **SENT** (la cuenta): Los tokens enviados hasta ahora, sumando todas las llamadas. Mira cuánto crece por turno antes y después del martillo.

En el panel EventBus, la línea `compact` marca al optimizador en acción. astorlm no emite un evento para eso; escribe “Context optimized” en tu logger. Fíjate dónde cae: después de que el pedido entra en el historial, antes de `turn_start`.

## El código

**Con astorlm:** La compactación viene activada por defecto, dimensionada según el proveedor. Pasa `contextOptimizer` para fijar la ventana real, la línea y cuántos pedidos recientes conservar.

**Desde cero:** El bucle del nivel 2, con un historial que vive entre pedidos y una llamada a `compact()` antes de cada llamada al modelo. El nivel 1 trunca resultados de herramientas viejos, el nivel 2 descarta intercambios viejos enteros.

**Con astorlm**

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

const getOrder = tool({
  name: 'get_order',
  description: 'Everything about one order: items, shipping, invoice.',
  schema: z.object({ order: z.number().int() }),
  execute: async ({ order }) => loadOrder(order), // your code
})

const searchParts = tool({
  name: 'search_parts',
  description: 'Search the parts catalog, with stock and price for each match.',
  schema: z.object({ query: z.string() }),
  execute: async ({ query }) => searchCatalog(query), // your code
})

const createReturn = tool({
  name: 'create_return',
  description: 'Open a return for an order and ship a replacement part.',
  schema: z.object({ order: z.number().int(), part: z.string() }),
  execute: async ({ order, part }) => openReturn(order, part), // 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: [getOrder, searchParts, createReturn],
  maxTurns: 10,
  // Compaction is on by default, sized from the provider. OpenAIProvider assumes a
  // 128,000-token window, so on a small local model, say how big it really is.
  contextOptimizer: {
    maxTokens: 8000,
    compressThreshold: 0.8, // compact once the request passes 80% of the window
    keepRecentTurns: 2, // never touch the last two requests, or anything after them
  },
  // There's no event for compaction: astorlm logs "Context optimized…" when it happens.
  logger: console,
})

// One agent, one history: every run() adds to it, and the optimizer checks it before each model call.
await agent.run('Hi! My order #4471 came with a bent front wheel. Can you help?')
await agent.run('Is that same wheel in stock?')
const last = await agent.run('Great. Open a return for my order and ship me the new wheel.')
console.log(last.content)
```

**TypeScript**

```ts
// Compaction, from scratch. Plain fetch, no SDK.

// 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
}

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 }

type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = { get_order: getOrder, search_parts: searchParts, create_return: createReturn }
const toolSchemas = [/* one JSON Schema per tool */]

const SYSTEM = 'You are the support assistant of a bike shop.'
const WINDOW = { maxTokens: 8000, threshold: 0.8, keepRecentTurns: 2 }

// A rough count, about 4 characters per token. Good enough to decide when to compact.
function estimateTokens(messages: Message[]): number {
  const chars = messages.reduce((sum, m) => sum + JSON.stringify(m).length, SYSTEM.length)
  return Math.ceil(chars / 4)
}

// Where the protected part starts: the Nth user request from the end.
// With fewer requests than that, everything is recent and nothing can go.
function keepFrom(messages: Message[], keep: number): number {
  let seen = 0
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i]!.role === 'user' && ++seen === keep) return i
  }
  return 0
}

export function compact(messages: Message[]): Message[] {
  const limit = WINDOW.maxTokens * WINDOW.threshold
  if (estimateTokens(messages) <= limit) return messages
  const out = structuredClone(messages)

  // Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
  for (let i = 0; i < keepFrom(out, WINDOW.keepRecentTurns); i++) {
    const m = out[i]!
    if (m.role !== 'tool' || m.content.startsWith('[Truncated')) continue
    m.content = `[Truncated to save context: ${m.content.length} chars. Preview: ${m.content.slice(0, 150)}…]`
    if (estimateTokens(out) <= limit) return out
  }

  // Level 2: drop the oldest exchanges whole, from one request up to the next.
  // Keep the very first request, and never cut between a tool call and its result.
  while (estimateTokens(out) > limit) {
    const next = out.findIndex((m, i) => i > 1 && m.role === 'user')
    if (next === -1 || next > keepFrom(out, WINDOW.keepRecentTurns)) break
    out.splice(1, next - 1)
  }
  return out
}

// The history lives across requests: that's what fills up.
const messages: Message[] = []

export async function ask(prompt: string, maxTurns = 10): Promise<string> {
  messages.push({ role: 'user', content: prompt })

  for (let turn = 1; turn <= maxTurns; turn++) {
    // Before every model call: does it still fit? The compacted history replaces the old one.
    messages.splice(0, messages.length, ...compact(messages))

    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: [{ role: 'system', content: SYSTEM }, ...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 = await 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`)
}

// The chat from the animation: three requests, one growing history.
await ask('Hi! My order #4471 came with a bent front wheel. Can you help?')
await ask('Is that same wheel in stock?')
console.log(await ask('Great. Open a return for my order and ship me the new wheel.'))
```

**Python**

```python
# Compaction, from scratch. Standard library only, no SDK.
import copy
import json
import math
import urllib.request

# 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
}

TOOLS = {"get_order": get_order, "search_parts": search_parts, "create_return": create_return}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool

SYSTEM = "You are the support assistant of a bike shop."
WINDOW = {"max_tokens": 8000, "threshold": 0.8, "keep_recent_turns": 2}

def estimate_tokens(messages):
    """A rough count, about 4 characters per token. Good enough to decide when to compact."""
    chars = len(SYSTEM) + sum(len(json.dumps(m)) for m in messages)
    return math.ceil(chars / 4)

def keep_from(messages, keep):
    """Where the protected part starts: the Nth user request from the end (0 if there are fewer)."""
    seen = 0
    for i in range(len(messages) - 1, -1, -1):
        if messages[i]["role"] == "user":
            seen += 1
            if seen == keep:
                return i
    return 0

def compact(messages):
    limit = WINDOW["max_tokens"] * WINDOW["threshold"]
    if estimate_tokens(messages) <= limit:
        return messages
    out = copy.deepcopy(messages)

    # Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
    for i in range(keep_from(out, WINDOW["keep_recent_turns"])):
        m = out[i]
        if m["role"] != "tool" or m["content"].startswith("[Truncated"):
            continue
        m["content"] = f"[Truncated to save context: {len(m['content'])} chars. Preview: {m['content'][:150]}...]"
        if estimate_tokens(out) <= limit:
            return out

    # Level 2: drop the oldest exchanges whole, from one request up to the next.
    # Keep the very first request, and never cut between a tool call and its result.
    while estimate_tokens(out) > limit:
        following = [i for i, m in enumerate(out) if i > 1 and m["role"] == "user"]
        if not following or following[0] > keep_from(out, WINDOW["keep_recent_turns"]):
            break
        del out[1 : following[0]]
    return out

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({
            "model": LLM["model"],
            "messages": [{"role": "system", "content": SYSTEM}, *messages],
            "tools": TOOL_SCHEMAS,
        }).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)["choices"][0]

# The history lives across requests: that's what fills up.
messages = []

def ask(prompt, max_turns=10):
    messages.append({"role": "user", "content": prompt})

    for turn in range(1, max_turns + 1):
        # Before every model call: does it still fit? The compacted history replaces the old one.
        messages[:] = compact(messages)

        choice = chat(messages)
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            run = TOOLS.get(name)
            try:
                output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
            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")

# The chat from the animation: three requests, one growing history.
ask("Hi! My order #4471 came with a bent front wheel. Can you help?")
ask("Is that same wheel in stock?")
print(ask("Great. Open a return for my order and ship me the new wheel."))
```

## Qué vigilar

- **Dile la ventana real.** El OpenAIProvider de astorlm asume 128.000 tokens. Con un modelo local de 8.000 tokens, el optimizador esperaría una línea a la que el modelo nunca llega.
- **Nunca separes una llamada a herramienta de su resultado.** Un resultado de herramienta cuya llamada se descartó hace que la mayoría de las APIs rechacen todo el pedido. Descarta intercambios enteros, desde un pedido hasta el siguiente.
- **Truncar solo es seguro si la herramienta puede volver a ejecutarse.** Si un resultado no se puede obtener dos veces, como un comprobante de pago, conserva la parte que importa en la respuesta, o guárdalo fuera del historial.
- **Dile al que resume qué tiene que sobrevivir.** Números de pedido, números de pieza, decisiones. Un resumen que se lee bien igual puede perder el único dato que necesita el turno siguiente.
- **La compactación solo cuenta lo que envías.** Estimar cuatro caracteres por token alcanza para decidir cuándo actuar. Deja suficiente lugar debajo de la línea para la respuesta.

## Patrones relacionados

- [2 · El bucle del agente](https://harnesspatterns.dev/es/patterns/agent-loop.md)
- [6 · Hooks](https://harnesspatterns.dev/es/patterns/hooks.md)
- [8 · Skills bajo demanda](https://harnesspatterns.dev/es/patterns/skills.md)
- [9 · Memoria](https://harnesspatterns.dev/es/patterns/memory.md)
- [15 · Subagentes](https://harnesspatterns.dev/es/patterns/subagents.md)
