Suggestion de compétence
Choisit au plus une compétence pour un tour d’agent parmi les 182 du catalogue Hermes de Nous Research, à l’aide de deux requêtes TypeSafe pour classer puis revérifier les meilleurs candidats.
Les agents choisissent leurs compétences en les tronquant et en les chargeant toutes dans le message système, ce qui augmente les coûts, dégrade la sélection des compétences et corrompt le contexte pour le reste de la session. Nous y remédions en utilisant deux requêtes TypeSafe par tour, une pour classer les compétences et une pour vérifier le choix, et réduisons de plus de moitié les chargements de compétences incorrects.
Un agent doté d’un large répertoire de compétences fait son choix sur presque aucune
information. Le répertoire lui parvient sous forme d’index : une ligne par compétence, la
description étant tronquée pour que le texte complet ne vienne pas écraser la conversation.
Hermes, le harness d’agent utilisé ici, la coupe à 60 caractères par défaut. Par exemple, à
cette largeur, la compétence qui modifie des fichiers .pptx se lit presque comme celle
qui les crée. Demande un pitch deck et l’agent risque de charger la mauvaise. Lors d’un
tour où aucune compétence ne convient du tout, il peut quand même en charger une, car une
liste de noms invite à deviner.
Ce cookbook laisse les descriptions telles quelles et utilise à la place la divulgation progressive, en lisant les 182 compétences à peu de frais puis trois d’entre elles en détail. Deux requêtes TypeSafe précèdent la décision de charger une compétence, s’il y en a une. La première classe chaque compétence du répertoire face au tour de l’utilisateur et répond si le tour a besoin d’une compétence. La seconde relit seulement les trois premières, désormais avec la description complète de chaque compétence et le début de ses instructions, et est libre de les rejeter toutes.
Le nom du gagnant est ajouté sur une ligne supplémentaire du prompt système de l’agent pour ce tour :
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user
actually asked for.
</skill_relevance>
L’agent garde son index complet et son propre jugement, et cette unique ligne lui indique
seulement quelle entrée regarder en premier. Le répertoire lui-même ne change jamais, donc
tout cache de préfixe le concernant tient toujours. Sur 488 requêtes contre
claude-haiku-4-5-20251001, avec des compétences du répertoire Hermes :
| charge la mauvaise compétence | en charge une alors que rien ne convient | |
|---|---|---|
| agent seul, avec juste son répertoire | 16,8% | 9,8% |
| agent avec une suggestion TypeSafe | 7,3% | 4,0% |
| agent à qui on donne la bonne réponse | 2,5% | 1,2% |
La troisième ligne montre que le plancher des erreurs n’est pas zéro, car un agent à qui on donne la bonne compétence ne la charge pas toujours, et aucune méthode de sélection, aussi bonne soit-elle, ne va au-delà.
Tu obtiens au final une fonction suggest() qui renvoie au plus un nom de compétence, une
suggestion_block() qui l’enveloppe pour le prompt système, et le harness qui a produit le
tableau ci-dessus, prêt à être pointé vers ton propre répertoire.
flowchart LR
subgraph C1["Call 1 - skim all 182 skills"]
direction TB
Q1["<b>Choice:</b> which skill fits?<br/><i>all 182, one line each</i>"]
N1["<b>Nouls:</b> need a skill at all?<br/>· act on their stuff?<br/>· follow written steps?<br/>· or just talk?"]
%% invisible link: without an edge these two share a rank, which in a TB
%% subgraph puts them side by side instead of stacked
Q1 ~~~ N1
end
subgraph C2["Call 2 - read those 3 properly"]
direction TB
Q2["<b>Choice:</b> which of the 3?<br/><i>with real detail now</i>"]
N2["<b>Nouls:</b> does each one<br/>really do it?"]
Q2 ~~~ N2
end
REQ["the request"] --> C1
C1 -->|"top 3"| C2
C1 -->|"nothing<br/>applies"| STOP["suggest<br/>nothing"]
C2 -->|"none fit"| STOP
C2 -->|"a winner"| OUT["suggest<br/>the winner"]
Configuration
- Installe le client TypeSafe, le client Anthropic et les helpers partagés du cookbook.
- Définis une clé d’API TypeSafe, et une clé Anthropic pour l’agent mesuré.
pip install anthropic matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
export TYPESAFE_API_KEY=your-key-here
export ANTHROPIC_API_KEY=your-key-here
Note : les blocs de code ci-dessous forment un seul script, dans l’ordre. Pour suivre, mets-les dans un seul fichier dans l’ordre indiqué.
Mise en cache des résultats
JsonCache enregistre le résultat de chaque appel, indexé sur ses entrées, donc une
réexécution rejoue les chiffres ci-dessous au lieu d’appeler l’une ou l’autre API. Supprime
json_cache.json pour exécuter en direct. L’exécution publiée a utilisé jev-1.12 et
claude-haiku-4-5-20251001, rendue le 2026-07-31.
import json
import os
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from time import perf_counter
import anthropic
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, TypeSafeClient
matplotlib.use("Agg") # headless render
TYPESAFE_MODEL = "jev-1.12"
AGENT_MODEL = (
"claude-haiku-4-5-20251001" # the agent under test, pinned so scores are stable
)
SHORTLIST = 3 # candidates carried from the first request into the second
EXCERPT_CHARS = (
700 # SKILL.md characters each candidate brings; the roster file stores 1600
)
GATE_THRESHOLD = (
0.30 # mean of the three request nouls, below which nothing is suggested
)
FITS_THRESHOLD = (
0.30 # a shortlist whose best "does this fit" noul is under this is dropped
)
WORKERS = 8 # small pool: enough to keep a live run to minutes, gentle on rate limits
assert EXCERPT_CHARS <= 1600, (
"the shipped roster file stores 1600 body characters per skill"
)
client = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # keyless kernels replay the cache
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
agent = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", "cache-only"))
json_cache = JsonCache(Path("json_cache.json"))
Étape 1 : charger le répertoire
hermes_roster.json contient les 182 compétences de
NousResearch/hermes-agent (MIT) à un commit
épinglé. Chaque enregistrement contient le nom et la catégorie d’une compétence, la
description telle que l’index la montre, la description complète, et le début de son
SKILL.md.
L’index ci-dessous, et les instructions au-dessus dans le prompt, sont copiés de Hermes.
ROSTER = json.loads(Path("hermes_roster.json").read_text(encoding="utf-8"))
BY_NAME = {skill["name"]: skill for skill in ROSTER}
# Verbatim from hermes-agent agent/prompt_builder.py:build_skills_system_prompt.
PREAMBLE = (
"## Skills (mandatory)\n"
"Before replying, scan the skills below. If a skill matches or is even partially relevant "
"to your task, you MUST load it with skill_view(name) and follow its instructions. "
"Err on the side of loading — it is always better to have context you don't need "
"than to miss critical steps, pitfalls, or established workflows. "
"Skills contain specialized knowledge — API endpoints, tool-specific commands, "
"and proven workflows that outperform general-purpose approaches. Load the skill "
"even if you think you could handle the task with basic tools like web_search or terminal. "
"Skills also encode the user's preferred approach, conventions, and quality standards "
"for tasks like code review, planning, and testing — load them even for tasks you "
"already know how to do, because the skill defines how it should be done here.\n"
"Whenever the user asks you to configure, set up, install, enable, disable, modify, "
"or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
"skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
"first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
"`hermes setup`) so you don't have to guess or invent workarounds.\n"
"If a skill has issues, fix it with skill_manage(action='patch').\n"
"After difficult/iterative tasks, offer to save as a skill. "
"If a skill you loaded was missing steps, had wrong commands, or needed "
"pitfalls you discovered, update it before finishing.\n"
"\n"
)
FOOTER = "\n\nOnly proceed without loading a skill if genuinely none are relevant to the task."
IDENTITY = (
"You are Hermes, a capable AI assistant with access to tools and a library "
"of skills. You help the user with coding, research, and everyday tasks.\n\n"
)
def render_index() -> str:
"""The body of <available_skills>: skills grouped by category, both sorted by name."""
by_category = defaultdict(list)
for skill in ROSTER:
by_category[skill["category"]].append(skill)
lines = []
for category in sorted(by_category):
lines.append(f" {category}:")
for skill in sorted(by_category[category], key=lambda s: s["name"]):
lines.append(f" - {skill['name']}: {skill['description']}")
return "\n".join(lines)
CATALOG_PROMPT = (
IDENTITY
+ PREAMBLE
+ "<available_skills>\n"
+ render_index()
+ "\n</available_skills>"
+ FOOTER
)
widths = [len(skill["description"]) for skill in ROSTER]
print(f"{len(ROSTER)} skills in {len({s['category'] for s in ROSTER})} categories")
print(f"roster prompt: {len(CATALOG_PROMPT):,} characters")
print(
f"index description: {sum(widths) / len(widths):.0f} characters on average, "
f"{max(widths)} at most"
)
print("\none category, as the agent reads it:")
index_lines = render_index().splitlines()
start = index_lines.index(" apple:")
end = next(
i
for i in range(start + 1, len(index_lines))
if not index_lines[i].startswith(" ")
)
print("\n".join(index_lines[start:end]))
182 skills in 33 categories
roster prompt: 16,089 characters
index description: 54 characters on average, 60 at most
one category, as the agent reads it:
apple:
- apple-notes: Manage Apple Notes via memo CLI: create, search, edit.
- apple-reminders: Apple Reminders via remindctl: add, list, complete.
- findmy: Track Apple devices/AirTags via FindMy.app on macOS.
- imessage: Send and receive iMessages/SMS via the imsg CLI on macOS.
Étape 2 : noter l’agent tout seul
requests.json contient 488 requêtes à un seul tour, dont 315 sont couvertes par exactement
une compétence et les 173 autres par aucune.
Les requêtes couvertes ont été écrites par Claude Sonnet 5 à partir du SKILL.md de chaque
compétence, donc les étiquettes sont fiables et les requêtes sont plus faciles que celles que
les utilisateurs envoient.
Les 173 non couvertes ont toutes été écrites pour punir la devinette : 85 requêtes du quotidien, 42 questions techniques qu’aucune compétence ne sert (explique ce qu’est une monade), et 46 qui demandent quelque chose de précis pour quoi le répertoire n’a pas de compétence, comme publie ceci sur Mastodon sur un répertoire qui couvre X et rien d’autre.
La notation ne lit que la première réponse de l’agent. Les deux nombres sont des taux d’erreur, donc plus bas est mieux pour chacun :
- chargement erroné : parmi les requêtes couvertes, la part où le premier appel
skill_viewn’était pas la compétence couvrante. Un tour qui n’a rien chargé du tout compte comme un échec. - chargement inutile : parmi les requêtes non couvertes, la part où l’agent a appelé
skill_view, ne serait-ce qu’une fois.
REQUESTS = json.loads(Path("requests.json").read_text(encoding="utf-8"))
POSITIVES = [p for p in REQUESTS if p["gold"]]
NEGATIVES = [p for p in REQUESTS if not p["gold"]]
print(
f"{len(REQUESTS)} requests: {len(POSITIVES)} covered by a skill "
f"({len({p['gold'] for p in POSITIVES})} distinct skills), {len(NEGATIVES)} covered by none"
)
print(f"\ncovered [{POSITIVES[0]['gold']}] {POSITIVES[0]['text']}")
print(f"uncovered {NEGATIVES[0]['text']}")
488 requests: 315 covered by a skill (171 distinct skills), 173 covered by none
covered [1password] I've got a config.yaml with `{{ op://app-prod/db/password }}` placeholders in it — can you set up my project to pull the real values in at runtime instead of hardcoding them?
uncovered Add these three cards to our Trello backlog.
La suggestion va dans son propre bloc du prompt système, après le répertoire plutôt qu’à l’intérieur, afin que le texte du répertoire soit identique à chaque tour pour préserver le cache de préfixe.
L’agent dispose d’un ensemble minimal d’outils, dont skill_view pour charger une compétence
via un nom en texte libre. Le nom doit correspondre exactement à la compétence pour un
chargement correct.
# Verbatim from hermes-agent tools/skills_tool.py:SKILL_VIEW_SCHEMA.
SKILL_VIEW_DESCRIPTION = (
"Skills allow for loading information about specific tasks and workflows, as "
"well as scripts and templates. Load a skill's full content or access its "
"linked files (references, templates, scripts). First call returns SKILL.md "
"content plus a 'linked_files' dict showing available references/templates/"
"scripts. To access those, call again with file_path parameter."
)
TOOLS = [
{
"name": "skill_view",
"description": SKILL_VIEW_DESCRIPTION,
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The skill name."}
},
"required": ["name"],
},
},
{
"name": "terminal",
"description": "Run a shell command on the user's machine and return its output.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
{
"name": "read_file",
"description": "Read a file from the user's filesystem.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "web_search",
"description": "Search the web and return result snippets.",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
]
@json_cache
def run_turn(model: str, arm: str, request: str, suggestion: str) -> dict:
"""One measured turn. ``arm`` is in the key so each arm samples independently."""
system = [
{"type": "text", "text": CATALOG_PROMPT, "cache_control": {"type": "ephemeral"}}
]
if suggestion:
system.append({"type": "text", "text": suggestion}) # after the breakpoint
response = agent.messages.create(
model=model,
max_tokens=1024,
system=system,
tools=TOOLS,
messages=[{"role": "user", "content": request}],
)
usage = response.usage
return {
"loaded": [
str(block.input.get("name", ""))
for block in response.content
if block.type == "tool_use" and block.name == "skill_view"
],
"input_tokens": usage.input_tokens or 0,
"output_tokens": usage.output_tokens or 0,
}
def summarise(turns: dict[str, dict]) -> dict[str, float]:
"""Two failure rates: wrong loads on covered requests, needless ones on uncovered."""
hits = [turns[p["text"]]["loaded"][:1] == [p["gold"]] for p in POSITIVES]
over = [bool(turns[p["text"]]["loaded"]) for p in NEGATIVES]
return {
# both metrics are errors, so the two columns read the same direction
"wrong_load": 1 - sum(hits) / len(hits),
"needless_load": sum(over) / len(over),
}
def run_arm(arm: str, suggestions: dict[str, str]) -> dict[str, dict]:
"""One measured turn per request, in a small pool. 488 calls."""
texts = [request["text"] for request in REQUESTS]
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
turns = pool.map(
lambda t: run_turn(AGENT_MODEL, arm, t, suggestions.get(t, "")), texts
)
return dict(zip(texts, turns))
L’agent tourne d’abord avec rien d’autre que son répertoire, comme il fonctionne aujourd’hui. Ses deux taux d’erreur sont la référence à laquelle le reste du cookbook se mesure.
baseline = run_arm("baseline", {})
base_scores = summarise(baseline)
print(
f"wrong loads {base_scores['wrong_load']:.1%} ({len(POSITIVES)} covered requests)"
)
print(
f"needless loads {base_scores['needless_load']:.1%} ({len(NEGATIVES)} uncovered requests)"
)
# where the wrong loads land: a neighbour of the right skill, or somewhere unrelated?
misses = [
(p["gold"], baseline[p["text"]]["loaded"][0])
for p in POSITIVES
if baseline[p["text"]]["loaded"] and baseline[p["text"]]["loaded"][0] != p["gold"]
]
same_category = sum(
1
for gold, got in misses
if got in BY_NAME and BY_NAME[got]["category"] == BY_NAME[gold]["category"]
)
print(
f"\nof {len(misses)} wrong first picks, {same_category} came from the right skill's own "
f"category"
)
wrong loads 16.8% (315 covered requests)
needless loads 9.8% (173 uncovered requests)
of 36 wrong first picks, 10 came from the right skill's own category
Les chargements erronés tombent dans la catégorie de la bonne compétence bien plus souvent que le hasard ne le voudrait, donc le plus dur est de distinguer quelques sosies. L’agent regarde déjà au bon endroit, à peu près.
Étape 3 : classer tout le répertoire
Une requête porte deux types de question :
whichest une questionChoicesur les 182 noms de compétence, avec la description d’index comme critères de chaque option (le même texte que reçoit l’agent lui-même). Ses probabilités sont le classement.- trois questions
Noulsur la requête, imprimées ci-dessous, chacune demandant d’une autre façon si elle veut une action plutôt qu’une explication.prose_sufficescompte dans l’autre sens. Leur moyenne décide s’il faut suggérer quoi que ce soit, et sous 0,30 rien n’est suggéré.
Les deux partent dans une seule requête, donc le classement et la vérification coûtent un seul aller-retour.
Écris ces trois-là pour demander si une action est voulue. Une question sur le sujet ne séparera pas explique ce qu’est une monade d’une requête qui a besoin d’une compétence, puisque les deux relèvent du logiciel.
Une question Choice porte confortablement un répertoire de cette taille. Quelques fois plus
grand et tu le découperais en morceaux pour classer chacun, puis tu lancerais cette même
étape de présélection sur les gagnants.
CHOICE_INSTRUCTIONS = (
"Which of these skills, if any, is the right one to load to help with the "
"user's latest request?"
)
GATE_QUESTIONS = {
"acts_on_user_system": (
"Is the assistant being asked to act on the user's files, accounts, devices, "
"or online services, rather than only to explain or advise?"
),
"would_follow_documented_procedure": (
"Would a careful expert answering this consult a specific documented procedure "
"or set of commands, rather than answering from general understanding?"
),
"prose_suffices": (
"Could a knowledgeable generalist fully satisfy this request in prose, with "
"no tools, no documentation, and no access to the user's files or accounts?"
),
}
INVERTED = {"prose_suffices"} # a yes here points away from needing a skill
def build_state(request: str) -> dict:
return {"request": request, "recent_context": ""}
@json_cache
def rank_wide(request: str) -> dict:
"""Request 1: rank all 182 skills, and score the request for whether a skill applies."""
questions = {
"which": Choice(
instructions=CHOICE_INSTRUCTIONS,
criteria={skill["name"]: skill["description"] for skill in ROSTER},
)
}
for key, text in GATE_QUESTIONS.items():
questions[f"gate::{key}"] = Noul(instructions=text)
started = perf_counter()
response = client.system_one(
state=build_state(request), questions=questions, model=TYPESAFE_MODEL
)
ranked = sorted(
response.answers["which"].probabilities.items(), key=lambda kv: -kv[1]
)
values = {
key.removeprefix("gate::"): answer.noul
for key, answer in response.answers.items()
if key.startswith("gate::")
}
oriented = [(1.0 - v) if k in INVERTED else v for k, v in values.items()]
return {
"ranked": ranked[
:12
], # more than any shortlist needs, and keeps the cache small
"gate": sum(oriented) / len(oriented),
"values": values,
"seconds": round(perf_counter() - started, 2),
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
DEMO = [
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so it syncs"
" to my phone? Just write it up in whatever editor pops up.",
"Can you put together a pitch deck skeleton (cover, situation overview, comps, precedent"
" transactions, DCF, LBO) as a .pptx, using our firm-template.pptx for branding and"
" footnoting each valuation number back to the cell it came from in the model?",
"Post this announcement to my Mastodon account.",
]
for request in DEMO:
wide = rank_wide(request)
verdict = "suggest" if wide["gate"] >= GATE_THRESHOLD else "stay quiet"
print(f'"{request[:78]}"')
print(f" needs a skill {wide['gate']:.2f} -> {verdict} ({wide['seconds']}s)")
for name, probability in wide["ranked"][:SHORTLIST]:
print(f" {probability:.3f} {name:<38}{BY_NAME[name]['description']}")
print()
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
needs a skill 0.75 -> suggest (0.31s)
0.990 apple-notes Manage Apple Notes via memo CLI: create, search, edit.
0.010 computer-use Drive the user's desktop in the background — clicking, ty...
0.000 concept-diagrams Generate flat, minimal educational SVG visuals as HTML.
"Can you put together a pitch deck skeleton (cover, situation overview, comps, "
needs a skill 0.76 -> suggest (0.16s)
0.700 powerpoint Create, read, edit .pptx decks, slides, notes, templates.
0.300 pptx-author Build PowerPoint decks headless with python-pptx.
0.000 chroma Embedding database for RAG and semantic search.
"Post this announcement to my Mastodon account."
needs a skill 0.78 -> suggest (0.16s)
0.550 xurl X/Twitter via xurl CLI: raw post search, posting, DM, media.
0.140 computer-use Drive the user's desktop in the background — clicking, ty...
0.080 openhands Delegate coding to OpenHands CLI (model-agnostic, LiteLLM).
La requête Notes.app est sans ambiguïté, et son option de tête est la bonne. Aucun classement ne sauvera celle de Mastodon : les trois questions disent qu’une compétence est voulue, parce que publier sur un compte est une action, et comme il existe une compétence pour publier sur X et aucune pour Mastodon, la compétence la plus proche l’emporte quand même.
Il reste le deck. Les deux meneurs sont des compétences .pptx, et sur 60 caractères la
question Choice large place la compétence d’édition devant celle de création, pour une requête
qui porte sur la création d’un deck.
Étape 4 : reclasser les trois premières
Trois options laissent la place à la description complète plus le début du SKILL.md de
chaque compétence, donc la seconde requête pose la même question sur de meilleures preuves :
whichest une questionChoicesur la présélection, avec ce texte plus long comme critères de chaque option.fits::{name}est une questionNoulpar candidat : cette compétence fait-elle la chose précise que la requête demande ? Chacune est répondue indépendamment, donc elles peuvent toutes revenir basses, et une présélection dont la plus haute passe sous 0,30 est écartée entièrement.
RERANK_INSTRUCTIONS = (
"Exactly one of these skills is the right one to load for the user's latest "
"request. Which one? Read what each actually does, not just its name."
)
def rerank_criteria(names: tuple[str, ...], excerpt: int) -> dict[str, str]:
return {
name: f"{BY_NAME[name]['description_full']} — {BY_NAME[name]['body'][:excerpt]}"
for name in names
}
def rerank_questions(names: tuple[str, ...], excerpt: int) -> dict:
questions = {
"which": Choice(
instructions=RERANK_INSTRUCTIONS, criteria=rerank_criteria(names, excerpt)
)
}
for name in names:
questions[f"fits::{name}"] = Noul(
instructions=(
f"Does the skill '{name}' do the specific thing the user's request asks "
f"for? It is described as: {BY_NAME[name]['description_full']}"
)
)
return questions
@json_cache
def rerank(request: str, names: tuple[str, ...], excerpt: int) -> dict:
"""Request 2: the same Choice over a shortlist, plus one absolute noul per candidate."""
started = perf_counter()
response = client.system_one(
state=build_state(request),
questions=rerank_questions(names, excerpt),
model=TYPESAFE_MODEL,
)
return {
"winner": response.answers["which"].choice,
"fits": {
key.removeprefix("fits::"): answer.noul
for key, answer in response.answers.items()
if key.startswith("fits::")
},
"seconds": round(perf_counter() - started, 2),
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
for request in DEMO:
wide = rank_wide(request)
if wide["gate"] < GATE_THRESHOLD:
print(f'"{request[:78]}"\n scored too low, nothing suggested\n')
continue
shortlist = tuple(name for name, _ in wide["ranked"][:SHORTLIST])
result = rerank(request, shortlist, EXCERPT_CHARS)
best = max(result["fits"].values())
verdict = result["winner"] if best >= FITS_THRESHOLD else "nothing fits"
print(f'"{request[:78]}"')
print(f" was {shortlist[0]} -> {verdict} ({result['seconds']}s)")
for name in shortlist:
print(f" fits {result['fits'][name]:.2f} {name}")
print()
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
was apple-notes -> apple-notes (0.12s)
fits 0.60 apple-notes
fits 0.54 computer-use
fits 0.01 concept-diagrams
"Can you put together a pitch deck skeleton (cover, situation overview, comps, "
was powerpoint -> pptx-author (0.09s)
fits 0.73 powerpoint
fits 0.38 pptx-author
fits 0.02 chroma
"Post this announcement to my Mastodon account."
was xurl -> xurl (0.09s)
fits 0.56 xurl
fits 0.38 computer-use
fits 0.05 openhands
Les deux compétences .pptx se séparent une fois que chacune apporte son propre texte : la
requête sur le deck bascule vers la compétence de création.
Les nouls fits et le Choice sont en désaccord ici : les nouls notent la compétence d’édition
plus haut tandis que le Choice choisit celle de création. Ils décident de choses différentes.
Le Choice tranche quelle compétence, et les nouls tranchent s’il faut dire quoi que ce
soit.
La requête Mastodon survit aux deux vérifications : son meilleur noul fits passe au-dessus
de 0,30, donc la recette suggère la compétence X pour une requête sur Mastodon. La plupart des
requêtes de ce genre sont attrapées. La seconde passe ne peut rejeter que ce que le classement
large lui tend, et ici c’était trois quasi-manques.
La fonction ci-dessous est toute la recette : deux requêtes et deux seuils, avec au plus un nom de compétence en retour.
Pour la pointer vers ton propre répertoire, remplace hermes_roster.json. Chaque question
ci-dessus lit name, description, description_full et body dans ce fichier, et rien
d’autre ne connaît Hermes.
def suggest(request: str) -> tuple[str, ...]:
"""At most one skill name for a request, or () for "nothing here applies"."""
wide = rank_wide(request)
if wide["gate"] < GATE_THRESHOLD:
return ()
shortlist = tuple(name for name, _ in wide["ranked"][:SHORTLIST])
result = rerank(request, shortlist, EXCERPT_CHARS)
if max(result["fits"].values()) < FITS_THRESHOLD:
return ()
return (result["winner"],)
def suggestion_block(names: tuple[str, ...]) -> str:
"""What gets appended after the roster, in the suggestion.
This string is a measured input rather than prose: it goes to the agent, so it is part
of every graded turn's cache key. Editing a word here silently invalidates the shipped
results and costs a live re-run to restore them.
"""
body = (
f"Relevant to the current request: {', '.join(names)}. Ignore this if it does not "
"fit what the user actually asked for."
if names
else "No skill in the roster appears relevant to this request."
)
return f"\n\n<skill_relevance>\n{body}\n</skill_relevance>"
print(suggestion_block(suggest(DEMO[1])))
print(suggestion_block(suggest(DEMO[2])))
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
<skill_relevance>
Relevant to the current request: xurl. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
Étape 5 : mesurer la suggestion
Chacune des 488 requêtes va trois fois à l’agent, à raison d’un tour mesuré à chaque fois. Les exécutions ne diffèrent que par ce qu’on dit à l’agent :
| ce qui va dans le prompt système | |
|---|---|
| agent seul | rien |
| agent avec une suggestion | ce que suggest() a renvoyé |
| agent à qui on donne la réponse | le nom de la compétence couvrante, ou « rien ne s’applique » quand il n’y en a pas |
La troisième n’est pas atteignable ; c’est le plafond auquel les deux autres se mesurent.
Le libellé de cette suggestion fait deux choses. Il dit que la suggestion peut être ignorée, parce qu’insister davantage gagne l’obéissance aussi sur les mauvaises suggestions, et qu’une mauvaise vaut moins que rien. Et un tour sans rien à suggérer envoie quand même une phrase qui le dit ; n’envoyer rien du tout laisserait l’instruction « charge en cas de doute » du répertoire sans contradicteur.
texts = [request["text"] for request in REQUESTS]
with ThreadPoolExecutor(max_workers=WORKERS) as pool: # up to 488 x 2 TypeSafe requests
suggested = dict(zip(texts, pool.map(suggest, texts)))
WIDE = {text: rank_wide(text) for text in texts} # all cache hits now; reused below
arms = {
"baseline": {},
"TypeSafe": {
request["text"]: suggestion_block(suggested[request["text"]])
for request in REQUESTS
},
"oracle": {
request["text"]: suggestion_block((request["gold"],) if request["gold"] else ())
for request in REQUESTS
},
}
scores = {
arm: summarise(run_arm(arm, suggestions)) for arm, suggestions in arms.items()
}
print(f"{'run':<10}{'wrong loads':>13}{'needless loads':>16}")
for arm, row in scores.items():
print(f"{arm:<10}{row['wrong_load']:>13.1%}{row['needless_load']:>16.1%}")
def fewer(metric: str) -> str:
"""The plain ratio between the two arms' error rates."""
return f"{scores['baseline'][metric] / scores['TypeSafe'][metric]:.1f}x fewer"
print(
f"\nbaseline -> TypeSafe: {fewer('wrong_load')} wrong loads, "
f"{fewer('needless_load')} needless ones"
)
run wrong loads needless loads
baseline 16.8% 9.8%
TypeSafe 7.3% 4.0%
oracle 2.5% 1.2%
baseline -> TypeSafe: 2.3x fewer wrong loads, 2.4x fewer needless ones
moved = [
(
baseline[p["text"]]["loaded"][:1] == [p["gold"]],
run_turn(AGENT_MODEL, "TypeSafe", p["text"], arms["TypeSafe"][p["text"]])[
"loaded"
][:1]
== [p["gold"]],
)
for p in POSITIVES
]
print(
f"of {len(POSITIVES)} covered requests: {sum(not b and a for b, a in moved)} the suggestion "
f"fixed, {sum(b and not a for b, a in moved)} it broke"
)
of 315 covered requests: 37 the suggestion fixed, 7 it broke
La suggestion répare beaucoup plus de requêtes qu’elle n’en casse, mais elle en casse quelques-unes que l’agent avait bonnes tout seul. Une suggestion fausse et confiante est plus persuasive que pas de suggestion du tout, c’est le prix à payer pour en mettre une devant le tour.
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
GRID, AXIS, BLUE, ORANGE = "#e1e0d9", "#c3c2b7", "#2a78d6", "#eb6834"
ARM_COLOR = {"baseline": BLUE, "TypeSafe": ORANGE, "oracle": MUTED}
def style(ax):
ax.set_facecolor(SURFACE)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(AXIS)
ax.tick_params(colors=MUTED, labelcolor=INK2, labelsize=9)
ax.set_axisbelow(True)
panels = [
("wrong_load", f"wrong loads\n{len(POSITIVES)} covered requests"),
("needless_load", f"needless loads\n{len(NEGATIVES)} uncovered requests"),
]
names = list(scores)
fig, axes = plt.subplots(1, 2, figsize=(8.4, 3.6), facecolor=SURFACE)
for ax, (metric, title) in zip(axes, panels):
style(ax)
ax.grid(axis="y", color=GRID, linewidth=0.8)
values = [scores[arm][metric] for arm in names]
bars = ax.bar(
names,
values,
0.58,
color=[ARM_COLOR[arm] for arm in names],
# the oracle is a ceiling, not a competitor: gray, and hatched so it never depends
# on colour alone
hatch=["", "", "///"],
edgecolor=SURFACE,
linewidth=1.2,
)
ax.bar_label(
bars,
labels=[f"{v:.1%}" for v in values],
padding=3,
color=INK2,
fontsize=9,
)
ax.set_title(title, loc="left", color=INK2, fontsize=9.5)
ax.set_ylim(0, max(values) * 1.28)
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set_ylabel("% of those requests - lower is better", color=INK2, fontsize=9)
fig.suptitle(
f"Hermes' {len(ROSTER)}-skill roster, {len(REQUESTS)} requests, {AGENT_MODEL}",
x=0.02,
ha="left",
color=INK,
fontsize=11,
)
fig.tight_layout()
display(fig)
plt.close(fig)
Ce que montrent les résultats
- Les chargements erronés tombent de 16,8% à 7,3% et les inutiles de 9,8% à 4,0%, soit l’essentiel de l’écart entre deviner depuis un index tronqué et recevoir la réponse.
- Certaines requêtes que l’agent avait bonnes tout seul reviennent fausses une fois une suggestion attachée. Les comptes sont ci-dessus.
Copie cette forme quand un de tes agents porte un large répertoire : un classement peu coûteux sur tout, puis un examen attentif de deux ou trois. Chaque étape peut revenir les mains vides.
L’ouvrir dans le playground
Construis un lien de playground pour la requête deck de l’étape 4, en utilisant la description complète et l’extrait de corps de chaque candidat comme critères.
demo_shortlist = tuple(name for name, _ in rank_wide(DEMO[1])["ranked"][:SHORTLIST])
playground_link = make_playground_link(
build_state(DEMO[1]),
rerank_questions(demo_shortlist, EXCERPT_CHARS),
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open the shortlist + questions in the TypeSafe playground]({playground_link})"
)
)
Ouvrir la shortlist + les questions dans le playground TypeSafe →
Et ensuite
La même forme apparaît ailleurs : Routage d’intention pour router vers un gestionnaire plutôt que vers une compétence, Confiance pour choisir les deux seuils, et Fan-out spéculatif pour mettre chaque question dans une seule requête.