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

# Des skills à la demande

Une skill, c'est un manuel pour un type de travail. L'agent voit toujours la liste des manuels, et n'en lit un que quand une requête l'exige.

## Le problème

L'assistant de commandes d'une boulangerie doit connaître les règles de la maison. Quelle taille de gâteau pour 20 personnes. Que les gâteaux sans fruits à coque ne cuisent que le vendredi matin. Le montant de l'acompte. Comment chiffrer un plateau traiteur. Ce que contient chaque produit déjà en rayon. Le modèle n'en sait rien.

La solution évidente : tout écrire et le coller dans le system prompt. Ça marche le premier jour. Puis les manuels grossissent, et il y en a dix. Chaque requête transporte désormais tous les manuels, à chaque tour, que le client veuille une pièce montée ou seulement les horaires d'ouverture. Vous payez pour tout ça, les requêtes ralentissent, et la seule règle qui compte est enterrée sous celles qui ne comptent pas.

C'est Tomebloat, le system prompt boursouflé. Il nourrit Gulp, du niveau 7 : un prompt déjà plein laisse moins de place à la conversation elle-même.

## La solution

Coupez chaque manuel en deux. Une **description** d'une ou deux lignes dit quand la skill s'applique. Le **corps** dit comment faire le travail. Seules les descriptions vont dans le system prompt, sous forme de catalogue. Quand une requête correspond à l'une d'elles, le modèle demande son corps, et le corps rejoint la conversation à partir de là.

C'est la progressive disclosure (divulgation progressive) : montrer d'abord l'index, et le détail seulement quand il le faut. Le format habituel est un dossier par skill avec un fichier `SKILL.md`. Le frontmatter en tête (le bloc entre les lignes `---`) contient le nom et la description. Tout ce qui suit est le corps.

**skills/custom-cake-order/SKILL.md**

```md
---
name: custom-cake-order
description: Cakes made to order. Use when a customer wants a cake baked for them — size by guests, flavors, allergies, lead time, deposit.
---

# Custom cake orders

1. Size by guests: up to 12 → 18 cm, up to 24 → 24 cm, more → two tiers.
2. Flavors: chocolate, vanilla, dulce de leche. Nothing else.
3. Nut allergy → the nut-free line. It only bakes on Friday mornings.
4. Check the calendar before you promise a date. Never less than 72 hours.
5. Quote the 50% deposit, and ask before you book. Never book on your own.
```

Il y a trois façons courantes de donner des skills au modèle. astorlm prend en charge les trois avec `skillMode` :

- `all`
   Le corps complet de chaque skill va dans le system prompt, dès la première requête.
   Aucun appel en plus, et le modèle ne peut pas sauter un manuel. Très bien pour deux ou trois skills courtes dont presque chaque requête a besoin. Au-delà, c'est Tomebloat.
- `on-demand`
   Le system prompt liste le nom et la description de chaque skill. Un outil load_skill renvoie le corps complet quand le modèle le demande.
   Ça passe à l'échelle jusqu'à des dizaines de skills. Ça coûte un appel d'outil par skill chargée, et les petits modèles répondent parfois sans charger la skill dont ils avaient besoin.
- `filesystem`
   Le catalogue donne aussi le chemin de chaque SKILL.md, et le modèle l'ouvre avec son outil ordinaire de lecture de fichiers.
   La même idée, sans outil spécial. C'est ainsi que font Claude Code, Codex et Gemini CLI, donc le même dossier de skills fonctionne partout. Il faut un agent capable de lire des fichiers.

Une skill n'est pas un outil. Un outil, c'est quelque chose que la boucle exécute pour le modèle. Une skill, c'est quelque chose que le modèle lit, et elle peut lui dire quels outils appeler et dans quel ordre, comme la recette qui dit « vérifie le calendrier avant de promettre une date ».

## Les personnages

Les mêmes personnages que d'habitude, cette fois dans la cuisine d'une boulangerie.

- **L'Oracle** (le modèle): Le chef derrière le passe. Il lit ce qu'il a devant lui, à chaque tour, et ne cuisine rien lui-même.
- **Les tickets** (le catalogue): Un par skill, sur le rail au-dessus du passe : un nom et une ligne sur quand l'utiliser. Ils font partie du system prompt, donc ils partent avec chaque requête.
- **L'étagère** (les corps des skills): Un livre de recettes par skill, fermé. Plus le livre est épais, plus il pèse de tokens. Rien de tout ça n'atteint le modèle tant que personne ne va le chercher.
- **Le livre ouvert** (une skill chargée): `load_skill` renvoie le corps comme résultat d'outil (le marque-page vert). Il entre dans le bandonéon comme n'importe quel résultat, donc il reste ouvert sur le passe pour le reste de l'exécution.
- **Le four** (check_calendar): Un outil ordinaire. C'est la recette qui dit au modèle de s'en servir.
- **La barre** (taille de la requête): Combien de tokens transporte la prochaine requête. Sa longueur totale correspond à ce qu'elle transporterait avec les trois livres collés dans le system prompt.

Dans le panneau EventBus, `load_skill` apparaît comme un `tool_execution_start` et un `tool_execution_end` ordinaires. La boucle n'a aucune idée que les skills existent : pour elle, charger un manuel n'est qu'un appel d'outil de plus.

## Le code

**Avec astorlm :** Pointez `skillSources` vers un dossier de skills. La valeur par défaut `skillMode: 'on-demand'` met le catalogue dans le system prompt et ajoute l'outil `load_skill` pour vous.

**À partir de zéro :** Lisez chaque `SKILL.md`, mettez une ligne par skill dans le system prompt, et ajoutez un outil `load_skill` qui renvoie un corps. La boucle du niveau 2 ne change pas du tout.

**Avec astorlm**

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

const checkCalendar = tool({
  name: 'check_calendar',
  description: 'Free baking slots and pickup times for a day, per production line.',
  schema: z.object({ day: z.string(), line: z.enum(['regular', 'nut-free']) }),
  execute: async ({ day, line }) => bakerySlots(day, line), // 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: [checkCalendar],
  // Every folder in ./skills with a SKILL.md is one skill:
  //   skills/custom-cake-order/SKILL.md, skills/catering-quote/SKILL.md, …
  skillSources: [createFileSystemSkillSource({ dir: './skills' })],
  // The default. Only each skill's name and description go in the system prompt,
  // and the agent gets a load_skill tool to fetch a full body when it needs one.
  skillMode: 'on-demand',
  maxTurns: 10,
})

agent.on('tool-end', ({ name, output }) => {
  if (name === 'load_skill') console.log('loaded:', output.slice(0, 60))
})

const last = await agent.run('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.')
console.log(last.content)
```

**TypeScript**

```ts
// On-demand skills, from scratch. Plain fetch and node:fs, no SDK.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

// 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: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

// 1. Read the skills: one folder per skill, each with a SKILL.md.
//    The frontmatter (the block between the --- lines) holds name and description.
type Skill = { name: string; description: string; body: string }

function readSkill(file: string): Skill {
  const [, front = '', body = ''] = readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) ?? []
  const field = (key: string) => front.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? ''
  return { name: field('name'), description: field('description'), body: body.trim() }
}

const SKILLS_DIR = './skills'
const skills = new Map(
  readdirSync(SKILLS_DIR, { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => readSkill(join(SKILLS_DIR, entry.name, 'SKILL.md')))
    .map((skill) => [skill.name, skill]),
)

// 2. The catalog goes in the system prompt: one line per skill, never the body.
const catalog = [...skills.values()].map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
const SYSTEM = `You are the order assistant of a bakery.

<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
${catalog}
</available-skills>`

// 3. load_skill is a tool like any other. Its result is the body.
const loadSkill = ({ name }: { name: string }): string => {
  const skill = skills.get(name)
  if (!skill) throw new Error(`Unknown skill "${name}". Available: ${[...skills.keys()].join(', ')}`)
  return `<skill name="${skill.name}">\n${skill.body}\n</skill>`
}

type ToolFn = (args: Record<string, string>) => string | Promise<string>
const tools: Record<string, ToolFn> = {
  load_skill: (args) => loadSkill({ name: args.name ?? '' }),
  check_calendar: checkCalendar, // your code
}
const toolSchemas = [
  {
    type: 'function',
    function: {
      name: 'load_skill',
      description: "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
      parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
    },
  },
  /* check_calendar's schema */
]

// 4. The loop from level 2. Nothing in it knows about skills.
export async function runAgent(prompt: string, maxTurns = 10): 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: 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}`
      }
      // A loaded body lands here, in the history, and every later turn resends it.
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

console.log(await runAgent('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.'))
```

**Python**

```python
# On-demand skills, from scratch. Standard library only, no SDK.
import json
import re
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
}

# 1. Read the skills: one folder per skill, each with a SKILL.md.
#    The frontmatter (the block between the --- lines) holds name and description.
def read_skill(path):
    match = re.match(r"^---\n(.*?)\n---\n?(.*)$", path.read_text(encoding="utf-8"), re.S)
    front, body = match.groups() if match else ("", "")

    def field(key):
        found = re.search(rf"^{key}:\s*(.+)$", front, re.M)
        return found.group(1).strip() if found else ""

    return {"name": field("name"), "description": field("description"), "body": body.strip()}

SKILLS = {
    skill["name"]: skill
    for skill in (read_skill(folder / "SKILL.md") for folder in Path("skills").iterdir() if folder.is_dir())
}

# 2. The catalog goes in the system prompt: one line per skill, never the body.
CATALOG = "\n".join(f"- `{s['name']}` — {s['description']}" for s in SKILLS.values())
SYSTEM = f"""You are the order assistant of a bakery.

<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
{CATALOG}
</available-skills>"""

# 3. load_skill is a tool like any other. Its result is the body.
def load_skill(name):
    if name not in SKILLS:
        raise ValueError(f'Unknown skill "{name}". Available: {", ".join(SKILLS)}')
    return f'<skill name="{name}">\n{SKILLS[name]["body"]}\n</skill>'

TOOLS = {"load_skill": load_skill, "check_calendar": check_calendar}  # check_calendar: your code
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "load_skill",
            "description": "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
            "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
        },
    },
    # check_calendar's schema
]

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({"model": LLM["model"], "messages": 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]

# 4. The loop from level 2. Nothing in it knows about skills.
def run_agent(prompt, max_turns=10):
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}]

    for _ in range(max_turns):
        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"]
            try:
                output = TOOLS[name](**json.loads(call["function"]["arguments"]))
            except Exception as err:
                output = f"Error: {err}"
            # A loaded body lands here, in the history, and every later turn resends it.
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

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

print(run_agent("I need a cake for 20 people this Saturday. Chocolate, and one guest can't have nuts."))
```

## Points de vigilance

- **La description est le déclencheur.** Le modèle choisit une skill sur sa seule description, comme il choisit un outil (niveau 3). Dites quand l'utiliser, pas ce qu'elle contient : « À utiliser quand un client veut un gâteau fait sur commande » vaut mieux que « Informations sur les gâteaux ». Dans l'animation, `allergen-info` mentionne aussi les allergies ; seules les descriptions évitent au modèle de charger le mauvais livre.
- **Les petits modèles sautent l'étape.** Certains répondent tout de suite sans charger la skill dont ils avaient besoin. Dites-le clairement dans le system prompt (« charge la skill correspondante avant de répondre »), essayez le mode filesystem ou, pour une skill dont chaque requête a besoin, gardez-la en `all`.
- **Une skill chargée reste chargée.** Son corps est dans l'historique, donc chaque tour suivant le paie, et la compaction (niveau 7) peut le tronquer plus tard. Gardez des corps courts et coupez les grosses skills en deux.
- **Les skills sont des instructions, donc du code.** Quiconque écrit un `SKILL.md` pilote votre agent. Ne chargez pas de skills depuis des dossiers ou des registres que vous ne contrôlez pas (niveau 14).

## Patterns liés

- [0 · Votre boîte à outils](https://harnesspatterns.dev/fr/patterns/your-toolkit.md)
- [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)
- [9 · La mémoire](https://harnesspatterns.dev/fr/patterns/memory.md)
- [14 · Sécurité et sandboxing](https://harnesspatterns.dev/fr/patterns/security.md)
