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

# Subagentes

Algunos encargos son pesados y autocontenidos. Delégalos a otro agente: empieza con un historial limpio, hace la búsqueda pesada y te devuelve solo lo que necesitas.

## El problema

Muchos pedidos esconden encargos adentro: buscar entre veinte publicaciones, leer una página larga, revisar cuarenta reseñas, recorrer una base de código para encontrar una función. El agente necesita la *respuesta* a cada encargo. No necesita la búsqueda pesada.

Ese es Hoarder, el agente que hace cada encargo él mismo. Cada resultado de búsqueda y cada página se suman a su historial, y el bucle reenvía todo eso en cada turno posterior. Para cuando vuelve a tu pregunta, la está leyendo debajo de una pila de publicaciones que solo necesitó un minuto. Los pedidos son pesados, el modelo se distrae, y un encargo más lo empuja más allá de la ventana.

La compactación (nivel 7) puede recortar la pila después. Mejor no armarla desde el principio.

## La solución

**Dale el encargo a un subagente.** Un subagente es un agente completo, con su propio bucle, sus propias llamadas al modelo y sus propias herramientas, que el agente padre ve como *una herramienta*. Cuando el padre la llama, arranca un agente nuevo con el historial vacío y un mensaje: el informe que escribió el padre. Hace la búsqueda pesada, responde y se descarta. El padre solo recibe esa respuesta, como un resultado de herramienta común.

- **Hacerlo uno mismo**
   Un agente, todas las herramientas. Ejecuta cada búsqueda y lee cada página él mismo.
   Cada resultado crudo queda en su historial, y cada turno posterior lo reenvía. Los encargos entierran la pregunta.
- **Subagente**
   Delegar el encargo a otro agente, expuesto como herramienta. Empieza vacío, recibe un informe y sus propias herramientas, y responde en pocas líneas.
   El agente padre se mantiene chico y enfocado. El precio: más llamadas al modelo en total, y el subagente solo sabe lo que dice el informe.
- **Workflow**
   Tu código llama a los agentes, en un orden que escribiste tú (nivel 1). Nadie decide delegar: lo decidiste tú, de antemano.
   Predecible y fácil de razonar. Solo funciona cuando conoces los pasos antes de que llegue el pedido.

Hay dos cosas que vienen gratis. Si el modelo pide dos subagentes en el mismo mensaje, el bucle los ejecuta **en paralelo**, como cualquier par de llamadas a herramientas. Y cada subagente puede tener un system prompt distinto, un conjunto más acotado de herramientas, incluso un modelo más chico: el explorador que lee reseñas no tiene por qué reservar nada.

Los agentes de código se apoyan en esto todo el tiempo: “explora el repo y dime dónde se maneja la autenticación” va a un subagente que hace grep en cincuenta archivos y vuelve con tres líneas.

## El elenco

El mismo elenco de siempre, esta vez en una agencia de detectives.

- **La central** (el agente padre): El bucle del nivel 2: Astor, el Oráculo y el bandoneón. Sus únicas herramientas son los dos exploradores.
- **El telégrafo** (herramientas de subagentes): Donde corren `milonga_scout` y `food_scout`. Un informe baja por el cable como entrada de la herramienta; un telegrama sube como su resultado.
- **Una ventana de campo** (una ejecución de subagente): Un agente completo: un explorador, su propio Oráculo, sus propias herramientas (las dos tiendas) y su propio bandoneón. El contador de la ventana es su contexto. Cuando responde, desaparece.
- **El grosor de los pliegues** (tokens): En este nivel, un pliegue es tan grueso como pesado es su mensaje. Un telegrama de tres líneas es una lámina. Una página de reseñas es un bloque.

Mira las dos barras de arriba. *Parent* es lo que pesa de verdad el pedido del padre. *All in 1* es lo que pesaría si el padre hubiera hecho los dos encargos él mismo, con cada página en su propio historial: termina por encima de la línea de compactación.

Las líneas `subagent` del registro de eventos son los eventos propios de los exploradores. El EventBus del padre nunca las ve: lo único que recibe es el inicio y el fin de cada herramienta de subagente.

## El código

**Con astorlm:** `createSubagentTool` envuelve un proveedor, un system prompt y un conjunto de herramientas en una sola herramienta que el padre puede llamar. Cada llamada arranca un agente hijo nuevo, ejecuta el informe hasta el final y devuelve su texto final. Cancelar el padre cancela al hijo.

**Desde cero:** El bucle del nivel 2, que recibe sus herramientas como argumento. Un subagente es una herramienta cuyo cuerpo vuelve a llamar a ese bucle, con mensajes nuevos y menos herramientas.

**Con astorlm**

```ts
import { OpenAIProvider, createLocalAgent, createSubagentTool, tool } from 'astorlm'
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
const provider = new OpenAIProvider({ ...LLM, model: 'your-model' }) // e.g. 'llama3.1', 'gpt-4o-mini'

// The heavy tools: each one returns whole listings, pages or reviews.
const searchEvents = tool({
  name: 'search_events',
  description: 'Search tango events by neighborhood and date. Returns every match with its blurb.',
  schema: z.object({ neighborhood: z.string(), date: z.string() }),
  execute: async ({ neighborhood, date }) => eventsApi.search(neighborhood, date), // your code
})
const readPage = tool({
  name: 'read_page',
  description: 'Read a web page and return its text.',
  schema: z.object({ url: z.string() }),
  execute: async ({ url }) => fetchText(url), // your code
})
const searchPlaces = tool({
  name: 'search_places',
  description: 'Search restaurants near a street, with their opening hours.',
  schema: z.object({ near: z.string() }),
  execute: async ({ near }) => placesApi.search(near), // your code
})
const readReviews = tool({
  name: 'read_reviews',
  description: 'Read the latest reviews of one restaurant.',
  schema: z.object({ place: z.string() }),
  execute: async ({ place }) => placesApi.reviews(place), // your code
})

// Each subagent is a whole agent, handed to the parent as ONE tool.
// It gets its own system prompt, only the tools it needs, and a fresh history on every call.
const milongaScout = createSubagentTool({
  name: 'milonga_scout',
  description: 'Finds tango events. Give it a full brief: it knows nothing else about the conversation.',
  provider, // could be a smaller, cheaper model
  systemPrompt: 'You find milongas in Buenos Aires. Reply in 3 lines: name, address, times. No lists, no links.',
  tools: [searchEvents, readPage],
  maxTurns: 6,
})
const foodScout = createSubagentTool({
  name: 'food_scout',
  description: 'Finds places to eat. Give it a full brief: it knows nothing else about the conversation.',
  provider,
  systemPrompt: 'You find restaurants in Buenos Aires. Reply in 3 lines: name, address, why.',
  tools: [searchPlaces, readReviews],
  maxTurns: 6,
})

// The parent only sees two tools. It never gets the listings, pages or reviews: just each scout's final text.
const agent = await createLocalAgent({
  provider,
  systemPrompt: 'You plan evenings out. Send the scouts out with a clear brief each, then put their answers together.',
  tools: [milongaScout, foodScout],
  maxTurns: 6,
})

const answer = await agent.run('I’m staying in San Telmo. Find me a milonga for Saturday night, and somewhere to eat nearby before it.')
console.log(answer.content)
// Both scouts were asked for in one message, so the loop ran them in parallel.
// Cancelling the parent (abortSignal) cancels any scout still out.
```

**TypeScript**

```ts
// Subagents, 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 Args = Record<string, string>
type ToolFn = (args: Args) => Promise<string>
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

// 1. The loop from level 2, with its tools passed in. `messages` is born and dies inside each call.
async function runAgent(system: string, prompt: string, tools: Record<string, ToolFn>, schemas: object[], maxTurns = 6): Promise<string> {
  const messages: Message[] = [
    { role: 'system', content: system },
    { 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: schemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''

    // Run every call of this message at once, and add the results in order.
    const calls = reply.tool_calls ?? []
    const outputs = await Promise.all(
      calls.map(async (call) => {
        try {
          const run = tools[call.function.name]
          return run ? await run(JSON.parse(call.function.arguments)) : `Unknown tool: ${call.function.name}`
        } catch (err) {
          return `Error: ${err instanceof Error ? err.message : err}`
        }
      }),
    )
    calls.forEach((call, i) => messages.push({ role: 'tool', tool_call_id: call.id, content: outputs[i]! }))
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// 2. The scouts' own tools: the heavy ones. Your code.
const scoutTools: Record<string, ToolFn> = {
  search_events: async ({ neighborhood, date }) => eventsApi.search(neighborhood, date),
  read_page: async ({ url }) => fetchText(url),
  search_places: async ({ near }) => placesApi.search(near),
  read_reviews: async ({ place }) => placesApi.reviews(place),
}
const pick = (...names: string[]) => Object.fromEntries(names.map((name) => [name, scoutTools[name]!]))

// 3. A subagent is a tool whose body is another runAgent call: new messages, fewer tools, its own prompt.
//    Only its final text comes back. Everything it read dies with its `messages`.
const parentTools: Record<string, ToolFn> = {
  milonga_scout: ({ task }) =>
    runAgent('You find milongas in Buenos Aires. Reply in 3 lines: name, address, times.', task, pick('search_events', 'read_page'), [/* their schemas */]),
  food_scout: ({ task }) =>
    runAgent('You find restaurants in Buenos Aires. Reply in 3 lines: name, address, why.', task, pick('search_places', 'read_reviews'), [/* their schemas */]),
}
// Both take one string, `task`. The description tells the parent to write a full brief.
const parentSchemas = [/* milonga_scout(task), food_scout(task) */]

// 4. The parent: the same loop, and all it ever sees of the scouts is two short answers.
const answer = await runAgent(
  'You plan evenings out. Send the scouts out with a clear brief each, then put their answers together.',
  'I’m staying in San Telmo. Find me a milonga for Saturday night, and somewhere to eat nearby before it.',
  parentTools,
  parentSchemas,
)
console.log(answer)
```

**Python**

```python
# Subagents, from scratch. Standard library only, no SDK.
import json
import urllib.request
from concurrent.futures import ThreadPoolExecutor

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

def call_tool(tools, call):
    try:
        return tools[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
    except Exception as err:
        return f"Error: {err}"

# 1. The loop from level 2, with its tools passed in. `messages` is born and dies inside each call.
def run_agent(system, prompt, tools, schemas, max_turns=6):
    messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]

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

        # Run every call of this message at once, and add the results in order.
        calls = reply.get("tool_calls", [])
        with ThreadPoolExecutor() as pool:
            outputs = list(pool.map(lambda call: call_tool(tools, call), calls))
        for call, output in zip(calls, outputs):
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

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

# 2. The scouts' own tools: the heavy ones. Your code.
SCOUT_TOOLS = {
    "search_events": lambda neighborhood, date: events_api.search(neighborhood, date),
    "read_page": lambda url: fetch_text(url),
    "search_places": lambda near: places_api.search(near),
    "read_reviews": lambda place: places_api.reviews(place),
}

def pick(*names):
    return {name: SCOUT_TOOLS[name] for name in names}

# 3. A subagent is a tool whose body is another run_agent call: new messages, fewer tools, its own prompt.
#    Only its final text comes back. Everything it read dies with its `messages`.
def milonga_scout(task):
    system = "You find milongas in Buenos Aires. Reply in 3 lines: name, address, times."
    return run_agent(system, task, pick("search_events", "read_page"), [...])  # their schemas

def food_scout(task):
    system = "You find restaurants in Buenos Aires. Reply in 3 lines: name, address, why."
    return run_agent(system, task, pick("search_places", "read_reviews"), [...])  # their schemas

# Both take one string, `task`. The description tells the parent to write a full brief.
PARENT_TOOLS = {"milonga_scout": milonga_scout, "food_scout": food_scout}
PARENT_SCHEMAS = [...]  # milonga_scout(task), food_scout(task)

# 4. The parent: the same loop, and all it ever sees of the scouts is two short answers.
answer = run_agent(
    "You plan evenings out. Send the scouts out with a clear brief each, then put their answers together.",
    "I'm staying in San Telmo. Find me a milonga for Saturday night, and somewhere to eat nearby before it.",
    PARENT_TOOLS,
    PARENT_SCHEMAS,
)
print(answer)
```

## Qué vigilar

- **El informe es todo lo que sabe.** El subagente nunca vio la conversación. “Busca el que mencionamos” no significa nada para él. Pídele al padre, en la descripción de la herramienta, que escriba un informe completo: el objetivo, las restricciones y cómo se ve una buena respuesta.
- **Pide una forma corta y fija.** Todo el punto es un resultado chico. Un system prompt como “responde en 3 líneas: nombre, dirección, horarios” evita que el explorador le pegue su pila de vuelta al padre.
- **Ahorra contexto, no dinero.** La búsqueda pesada igual ocurre, en los pedidos de otro agente. A menudo cuesta más en total. Usa subagentes cuando el foco del padre lo vale, y dales un modelo más barato cuando el encargo lo permite.
- **Divide solo lo que es independiente.** Dos exploradores pueden correr lado a lado porque ninguno necesita al otro. Si el segundo encargo necesita la respuesta del primero, llámalos uno después del otro, o mantenlo en un solo agente.
- **Acota sus herramientas, y limita la profundidad.** Dale a cada subagente solo las herramientas que necesita su encargo, y piénsalo dos veces antes de darle subagentes propios. Cada nivel multiplica las llamadas, y una falla en lo profundo llega como una sola línea confusa.

## Patrones relacionados

- [3 · Diseñar una herramienta](https://harnesspatterns.dev/es/patterns/designing-a-tool.md)
- [7 · La mochila se llena](https://harnesspatterns.dev/es/patterns/compaction.md)
- [8 · Skills bajo demanda](https://harnesspatterns.dev/es/patterns/skills.md)
- [10 · Vueltas limpias](https://harnesspatterns.dev/es/patterns/fresh-laps.md)
- [11 · Observabilidad y evaluaciones](https://harnesspatterns.dev/es/patterns/observability.md)
- [14 · Seguridad y sandboxing](https://harnesspatterns.dev/es/patterns/security.md)
