Niveau 8
Des skills à la demande
- user
- assistant
- tool_result
EventBus
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.
---
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 :
-
allLe 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-demandLe 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.
-
filesystemLe 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_skillrenvoie 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.
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)
// 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.'))
# 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-infomentionne 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.mdpilote votre agent. Ne chargez pas de skills depuis des dossiers ou des registres que vous ne contrôlez pas (niveau 14).