> Niveau 15 de Agent Harness Patterns, un parcours de patterns sur le fonctionnement des agents d'IA. Version web : https://harnesspatterns.dev/fr/patterns/subagents · Tous les patterns (en anglais) : https://harnesspatterns.dev/llms.txt

# Les sous-agents

Certaines missions sont lourdes et autonomes. Confiez-les à un autre agent : il démarre avec un historique propre, fait les fouilles, et ne renvoie que ce dont vous avez besoin.

## Le problème

Beaucoup de demandes cachent des missions à l'intérieur : éplucher vingt annonces, lire une longue page, parcourir quarante avis, fouiller une base de code pour trouver une fonction. L'agent a besoin de la *réponse* à chaque mission. Il n'a pas besoin des fouilles.

C'est Hoarder, l'agent qui fait chaque course lui-même. Chaque résultat de recherche et chaque page rejoignent son historique, et la boucle renvoie tout cela à chaque tour suivant. Quand il revient enfin à votre question, il la lit sous une pile d'annonces dont il n'a eu besoin qu'une minute. Les requêtes sont lourdes, le modèle se disperse, et une mission de plus le pousse au-delà de la fenêtre.

La compaction (niveau 7) peut élaguer la pile après coup. Mieux vaut ne pas la construire du tout.

## La solution

**Confiez la mission à un sous-agent.** Un sous-agent est un agent complet, avec sa propre boucle, ses propres appels au modèle et ses propres outils, que le parent voit comme *un seul outil*. Quand le parent l'appelle, un agent neuf démarre avec un historique vide et un message : le brief rédigé par le parent. Il fait les fouilles, répond, et il est jeté. Le parent ne reçoit que cette réponse, comme un résultat d'outil ordinaire.

- **Tout faire soi-même**
   Un agent, tous les outils. Il lance chaque recherche et lit chaque page lui-même.
   Chaque résultat brut reste dans son historique, et chaque tour suivant le renvoie. Les missions enterrent la question.
- **Sous-agent**
   Confier la mission à un autre agent, exposé comme un outil. Il démarre vide, reçoit un brief et ses propres outils, et répond en quelques lignes.
   L'agent parent reste petit et concentré. Le prix : plus d'appels au modèle au total, et le sous-agent ne sait que ce que dit le brief.
- **Workflow**
   Votre code appelle les agents, dans un ordre que vous avez écrit (niveau 1). Personne ne décide de déléguer : vous l'avez décidé, à l'avance.
   Prévisible et facile à raisonner. Ça ne marche que si vous connaissez les étapes avant que la demande n'arrive.

Deux choses viennent gratuitement. Si le modèle demande deux sous-agents dans le même message, la boucle les exécute **en parallèle**, comme n'importe quels deux appels d'outils. Et chaque sous-agent peut avoir un system prompt différent, un jeu d'outils plus restreint, voire un modèle plus petit : l'éclaireur qui lit des avis n'a aucune raison de réserver quoi que ce soit.

Les agents de code s'en servent sans arrêt : « explore le dépôt et dis-moi où l'authentification est gérée » part chez un sous-agent qui fait un grep dans cinquante fichiers et revient avec trois lignes.

## Les personnages

Les mêmes personnages que d'habitude, cette fois dans une agence de détectives.

- **Le QG** (l'agent parent): La boucle du niveau 2 : Astor, l'Oracle et le bandonéon. Ses seuls outils, ce sont les deux éclaireurs.
- **Le télégraphe** (les outils sous-agents): Là où tournent `milonga_scout` et `food_scout`. Un brief descend le fil comme entrée de l'outil ; un télégramme remonte comme son résultat.
- **Une fenêtre de terrain** (une exécution de sous-agent): Un agent complet : un éclaireur, son propre Oracle, ses propres outils (les deux boutiques) et son propre bandonéon. Le compteur de la fenêtre, c'est son contexte. Quand il répond, il disparaît.
- **L'épaisseur des plis** (tokens): Dans ce niveau, un pli est aussi épais que son message est lourd. Un télégramme de trois lignes est une lamelle. Une page d'avis est un pavé.

Regardez les deux barres du haut. *Parent*, c'est ce que pèse vraiment la requête du parent. *All in 1*, c'est ce qu'elle pèserait si le parent avait fait les deux missions lui-même, avec chaque page dans son propre historique : elle finit au-delà de la ligne de compaction.

Les lignes `subagent` du journal d'événements sont les événements propres aux éclaireurs. L'EventBus du parent ne les voit jamais : il ne reçoit que le début et la fin de chaque outil sous-agent.

## Le code

**Avec astorlm :** `createSubagentTool` enveloppe un fournisseur, un system prompt et un jeu d'outils en un seul outil que le parent peut appeler. Chaque appel lance un nouvel agent enfant, exécute le brief jusqu'au bout et renvoie son texte final. Annuler le parent annule l'enfant.

**À partir de zéro :** La boucle du niveau 2, qui prend ses outils en argument. Un sous-agent est un outil dont le corps rappelle cette boucle, avec de nouveaux messages et moins d'outils.

**Avec 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)
```

## Points de vigilance

- **Le brief, c'est tout ce qu'il sait.** Le sous-agent n'a jamais vu la conversation. « Trouve celui dont on a parlé » ne veut rien dire pour lui. Demandez au parent, dans la description de l'outil, d'écrire un brief complet : l'objectif, les contraintes, et ce à quoi ressemble une bonne réponse.
- **Demandez une forme courte et fixe.** Tout l'intérêt, c'est un petit résultat. Un system prompt du genre « réponds en 3 lignes : nom, adresse, horaires » évite que l'éclaireur recolle sa pile chez le parent.
- **Ça économise du contexte, pas de l'argent.** Les fouilles ont quand même lieu, dans les requêtes d'un autre agent. Souvent, ça coûte plus cher au total. Utilisez des sous-agents quand la concentration du parent en vaut la peine, et donnez-leur un modèle moins cher quand la mission le permet.
- **Ne découpez que ce qui est indépendant.** Deux éclaireurs peuvent tourner côte à côte parce qu'aucun n'a besoin de l'autre. Si la deuxième mission a besoin de la réponse de la première, appelez-les l'un après l'autre, ou gardez tout dans un seul agent.
- **Restreignez ses outils, et plafonnez la profondeur.** Ne donnez à chaque sous-agent que les outils dont sa mission a besoin, et réfléchissez à deux fois avant de lui donner ses propres sous-agents. Chaque niveau multiplie les appels, et un échec tout au fond arrive sous la forme d'une seule ligne confuse.

## Patterns liés

- [3 · Concevoir un outil](https://harnesspatterns.dev/fr/patterns/designing-a-tool.md)
- [7 · Le sac à dos déborde](https://harnesspatterns.dev/fr/patterns/compaction.md)
- [8 · Des skills à la demande](https://harnesspatterns.dev/fr/patterns/skills.md)
- [10 · Des tours tout neufs](https://harnesspatterns.dev/fr/patterns/fresh-laps.md)
- [11 · Observabilité et évaluations](https://harnesspatterns.dev/fr/patterns/observability.md)
- [14 · Sécurité et sandboxing](https://harnesspatterns.dev/fr/patterns/security.md)
