Skip to content

Compétences: guides en Markdown

源码版本v2026.6.11

Responsabilités

Une compétence (skill) dans OpenClaw n'est pas une fonction invocable; c'est un guide en Markdown — concrètement, un fichier SKILL.md. À la construction du system prompt, le système liste le name/description/location de toutes les compétences disponibles dans un bloc XML <available_skills> inséré dans le prompt; le modèle, voyant qu'une tâche correspond à la description d'une compétence, l'utilise lui-même via l'outil read pour charger le SKILL.md, puis suit les étapes qui y sont écrites.

Ce mécanisme paraît « léger », mais il diffère essentiellement du système d'outils: les outils sont des fonctions enregistrées par le système, le modèle ne peut qu'invoquer leur nom; les compétences sont des documents que le modèle décide lui-même de lire ou non. Le contenu d'une compétence peut être n'importe quel Markdown — étapes opératoires, checklist, glossaire, template de code — une fois lu, le modèle utilise les outils si besoin, écrit du code si besoin; la compétence elle-même ne participe pas à l'invocation runtime.

Le système de compétences a donc une responsabilité très pure: découverte (discovery) trouver tous les fichiers SKILL.md, chargement (loading) parser la frontmatter en un objet Skill, injection (injection) pousser le résumé dans le system prompt, cycle de vie (lifecycle) gérer installation/archivage/upload de la compétence. Le modèle gère lui-même le reste: « lire ou non, comment utiliser ».

Motivation de conception

Pourquoi ne pas faire des compétences aussi des outils? Parce que l'invocation d'un outil est déterministe — schéma de params, validation d'args, logique d'exécuteur doivent être fixes, comportement consistant d'un appel à l'autre. Mais beaucoup de guides de tâches sont naturellement narratifs: « face à un bug, d'abord regarder les logs, puis reproduire, puis localiser » — impossible à entasser dans un schéma JSON. Laisser le modèle comprendre le guide en langage naturel puis composer les appels d'outils en souplesse épouse mieux la forme qu'une fonction rigide.

L'autre motivation est le chargement différé (lazy loading). Le corps d'une compétence peut être long (un manuel opératoire de plusieurs dizaines de KB), mais le system prompt n'a besoin que d'un résumé. Le modèle ne read le corps que lorsque la tâche correspond vraiment; dans la plupart des conversations, le corps de la compétence n'entre jamais dans la fenêtre de contexte. formatSkillsForPrompt dans skill-contract.ts formatSkillsForPrompt:L34-L58 ne sort que les quatre champs name/description/location/version, précisément pour cette raison.

Le champ <version> est une design clé: c'est un marqueur stable du contenu de SKILL.md. Le system prompt dit explicitement au modèle: « If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it. » (src/skills/loading/skill-contract.ts:L41-L43) — l'auteur modifie le contenu, le modèle doit relire au tour suivant, pas se fier à sa mémoire d'une version périmée.

Le champ source porte l'identifiant de provenance (openclaw-bundled / workspace / plugin etc.); le champ frontmatter disableModelInvocation permet à l'auteur d'une compétence de déclarer « le modèle ne peut pas l'invoquer automatiquement, seul un utilisateur peut la déclencher explicitement » — un interstice pour les opérations à haut risque exigeant un consentement explicite.

Fichiers clés

Flux de données

Le chargement d'une compétence commence par le système de fichiers (local-loader.ts chargement d'un répertoire unique:L38-L89):

typescript
function loadSingleSkillDirectory(params: {
  skillDir: string;
  source: string;
  rootRealPath: string;
  maxBytes?: number;
}): LoadedLocalSkill | null {
  const skillFilePath = path.join(params.skillDir, "SKILL.md");
  const raw = readSkillFileSync({
    rootRealPath: params.rootRealPath,
    filePath: skillFilePath,
    maxBytes: params.maxBytes,
  });
  if (!raw) {
    return null;
  }

  let frontmatter: Record<string, string>;
  try {
    frontmatter = parseFrontmatter(raw);
  } catch {
    return null;
  }

  const fallbackName = path.basename(params.skillDir).trim();
  const name = frontmatter.name?.trim() || fallbackName;
  const description = frontmatter.description?.trim();
  if (!name || !description) {
    return null;
  }
  const invocation = resolveSkillInvocationPolicy(frontmatter);
  // ...
  return {
    skill: {
      name,
      description,
      filePath,
      baseDir,
      promptVersion: computeSkillPromptVersion(raw),
      source: params.source,
      sourceInfo: createSyntheticSourceInfo(filePath, { /* ... */ }),
      disableModelInvocation: invocation.disableModelInvocation,
    },
    frontmatter,
  };
}

Notez trois barrières: lecture via openRootFileSync (src/skills/loading/local-loader.ts:L16-L36) qui résout le chemin vers le real path root, empêchant un symlink de s'échapper du répertoire de compétence; frontmatter parse fail jette la compétence silencieusement — un seul mauvais fichier ne tue pas tout le chargement; name et description doivent tous deux exister, sinon skip. computeSkillPromptVersion calcule un marqueur stable du contenu, qui sera plus tard inséré dans le champ <version>.

loadSkillsFromDirSafe (src/skills/loading/local-loader.ts:L107-L151) est le wrapper supérieur; il tente d'abord de traiter le dir passé comme un répertoire de compétence unique, et en cas d'échec il enumère les sous-répertoires et charge en batch. Cette logique « suis-je une compétence ou un ensemble de compétences? » permet aux deux layouts skills/ (compétence unique à la racine) et skills/coding-agent/ (sous-compétences) de coexister.

Les objets Skill chargés sont finalement concaténés dans le prompt dans system-prompt.ts injection:L70-L74:

typescript
// Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
  prompt += formatSkillsForPrompt(skills);
}

Garde cruciale: le segment de compétences n'est injecté que si l'outil read est dans la liste active. Sinon le modèle verrait <available_skills> sans pouvoir lire SKILL.md, le mettant dans l'embarras « je sais qu'il existe mais je ne peux pas l'atteindre ». formatSkillsForPrompt (src/skills/loading/skill-contract.ts:L34-L58) produit un XML de cette forme:

typescript
const lines = [
  "\n\nThe following skills provide specialized instructions for specific tasks.",
  "Use the read tool to load a skill's file when the task matches its description.",
  "If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.",
  "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use the absolute path in tool commands.",
  "",
  "<available_skills>",
];
for (const skill of skills) {
  lines.push("  <skill>");
  lines.push(`    <name>${escapeXml(skill.name)}</name>`);
  lines.push(`    <description>${escapeXml(skill.description)}</description>`);
  lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);
  if (skill.promptVersion) {
    lines.push(`    <version>${escapeXml(skill.promptVersion)}</version>`);
  }
  lines.push("  </skill>");
}
lines.push("</available_skills>");

Notez la troisième ligne qui dit explicitement au modèle: « If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it. » — version changée ⇒ relire, ne pas se fier à la mémoire.

Quand la compétence est déclenchée explicitement par l'utilisateur (chat command) ou automatiquement par le modèle, on entre dans tool-dispatch.ts resolveSkillDispatchTools:L59-L249. Cette fonction ne fait pas « exécuter la compétence »; elle « filtre l'ensemble d'outils selon le contexte déclenché par la compétence » — par exemple, quand la compétence coding-agent se déclenche, la boucle principale doit décider quels outils fournir au sous-agent, en passant par une politique multi-niveaux profile/global/group/sender/sandbox/subagent/inherited:

typescript
const tools = createOpenClawTools({
  agentSessionKey: params.sessionKey,
  // ... plein de contexte
  pluginToolAllowlist: collectExplicitAllowlist(explicitPolicyList),
  pluginToolDenylist: explicitDenylist,
  // ...
});
const policyFiltered = applyToolPolicyPipeline({
  tools,
  toolMeta: (tool) => getPluginToolMeta(tool),
  warn: logVerbose,
  steps: [
    ...buildDefaultToolPolicyPipelineSteps({ /* profile/provider/global/agent/group/sender */ }),
    { policy: sandboxPolicy, label: "sandbox tools.allow" },
    { policy: subagentPolicy, label: "subagent tools.allow" },
    { policy: inheritedToolPolicy, label: "inherited tools" },
  ],
  declaredToolAllowlist: buildDeclaredToolAllowlistContext({ /* ... */ }),
});

Le commentaire mentionne « Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 stays closed across allow/deny, group, sandbox, and subagent policy layers » — c'est un seam de sécurité; les appels d'outils déclenchés par une compétence doivent passer par le même pipeline de politique que les appels d'outils normaux, sans court-circuiter l'allowlist sous prétexte « déclenché par compétence ».

Limites et modes d'échec

  • Name/description manquants ⇒ drop: loadSingleSkillDirectory a if (!name || !description) return null; (src/skills/loading/local-loader.ts:L64-L66). Sans description, le modèle ne sait pas quand l'utiliser; la compétence est inutile, on la jette au chargement.
  • Frontmatter parse fail ⇒ drop: try { parseFrontmatter(raw) } catch { return null; } — un frontmatter cassé ne tue pas tout le chargement, mais cette compétence devient invisible. loadSkillsFromDirSafe convertit toutes les failures en « compétence absente » plutôt que de throw, pour préserver la disponibilité globale.
  • Défense contre l'évasion par symlink: readSkillFileSync via openRootFileSync n'ouvre un fichier qu'à l'intérieur du real path root de la compétence (src/skills/loading/local-loader.ts:L16-L36). Cela empêche un répertoire de compétence malveillant de poser un symlink vers /etc/passwd qui serait lu par l'outil read.
  • Pas d'injection si read inactif: hasRead && skills.length > 0 est la condition dure d'injection (src/agents/sessions/system-prompt.ts:L71-L74). Si l'allowlist d'outils de l'utilisateur supprime read, le système ne dit pas au modèle qu'il y a des compétences — pour éviter le « visible mais inaccessible ».
  • disableModelInvocation: resolveSkillInvocationPolicy(frontmatter) parse ce champ. Pour certaines compétences à haut risque (par exemple exécuter un shell arbitraire), l'auteur peut déclarer « modèle ne peut pas invoquer automatiquement, doit passer par chat command explicite »; le système omet alors cette compétence de <available_skills>.
  • Détection de drift promptVersion: computeSkillPromptVersion calcule un marqueur stable du contenu SKILL.md; si la version change, le modèle doit relire au tour suivant. C'est pensé pour le hot reload: un développeur modifie le contenu, et au tour suivant le modèle ne se fie pas à sa mémoire périmée.
  • La compétence n'est pas l'exécuteur de l'outil: resolveSkillDispatchTools ressemble à « exécuter la compétence », mais ne fait que filtrer l'ensemble d'outils; « faire le travail selon la compétence » reste l'affaire du modèle + des outils. Le système de compétences n'appelle jamais d'outils directement; c'est la frontière la plus marquée avec le système d'outils.

Résumé

Les compétences sont des guides Markdown injectés dans le system prompt, pas des fonctions invocables. Au chargement, lecture bornée empêche l'évasion par symlink, le fail de parsing frontmatter est toléré, promptVersion détecte la dérive de contenu; à l'injection, le segment <available_skills> n'est poussé que si l'outil read est disponible; le corps est lu à la demande par le modèle. Les appels d'outils déclenchés par compétence passent par le pipeline multi-niveaux resolveSkillDispatchTools, partageant les mêmes règles allow/deny que les appels normaux — ce seam ferme GHSA-mhm4-93fw-4qr2. Différence avec outils: les outils sont des fonctions dans une Map, les compétences sont des guides Markdown; différence avec plugins: les plugins sont des packs d'extension découverts par convention de répertoire, les compétences sont une forme de contenu qu'un plugin peut porter (champ skills/SKILL.md du plugin).

Pour comparer avec la documentation officielle: Skills docs · README.