Sugestão de habilidades
Escolhe no máximo uma habilidade para um turno de agente entre as 182 do catálogo Hermes da Nous Research, usando duas requisições ao TypeSafe para ordenar e reconferir os melhores candidatos.
Agentes escolhem habilidades truncando-as e carregando todas na mensagem de sistema, o que aumenta os custos, degrada o desempenho da seleção de habilidades e induz o apodrecimento do contexto pelo resto da sessão. Resolvemos isso usando duas requisições ao TypeSafe por turno, uma para ordenar as habilidades e outra para verificar a escolha, e reduzimos em mais da metade as cargas incorretas de habilidade.
Um agente com um catálogo grande de habilidades faz sua escolha com quase nenhuma
informação. O catálogo chega até ele como um índice: uma linha por habilidade, com a
descrição truncada para que o texto completo não ocupe espaço demais na conversa. O
Hermes, o harness de agente usado aqui, corta em 60 caracteres por padrão. Por exemplo,
nessa largura a habilidade que edita arquivos .pptx lê quase igual à que os cria.
Peça um pitch deck e o agente pode carregar a errada. Em um turno em que nenhuma
habilidade se encaixa, ele pode mesmo assim carregar uma, porque uma lista de nomes
convida a um palpite.
Este cookbook deixa as descrições em paz e usa divulgação progressiva: lê todas as 182 habilidades a um custo baixo e depois lê três delas em detalhe. Duas requisições ao TypeSafe entram na frente da decisão de qual habilidade carregar, se alguma. A primeira ordena cada habilidade do catálogo contra o turno do usuário e responde se o turno precisa mesmo de uma habilidade. A segunda relê apenas as três primeiras, agora com a descrição completa de cada habilidade e o início de suas instruções, e é livre para rejeitar todas elas.
O nome da vencedora vai em uma linha extra do prompt de sistema do agente naquele turno:
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user
actually asked for.
</skill_relevance>
O agente mantém seu índice completo e seu próprio julgamento, e aquela única linha só lhe
diz qual entrada olhar primeiro. O catálogo em si nunca muda, então qualquer cache de
prefixo sobre ele continua valendo. Em 488 requisições contra claude-haiku-4-5-20251001,
usando habilidades do catálogo Hermes:
| carrega a habilidade errada | carrega uma quando nada se encaixa | |
|---|---|---|
| agente sozinho, apenas com seu catálogo | 16.8% | 9.8% |
| agente com uma sugestão do TypeSafe | 7.3% | 4.0% |
| agente que recebe a resposta certa | 2.5% | 1.2% |
A terceira linha mostra que o piso de erros não é zero, porque um agente que recebe a habilidade certa ainda não a carrega sempre, e nenhum método de seleção, por melhor que seja, escapa disso.
No fim você tem uma função suggest() que retorna no máximo um nome de habilidade, uma
suggestion_block() que a envolve para o prompt de sistema, e o harness que produziu a
tabela acima, pronto para apontar para o seu próprio catálogo.
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"]
Configuração
- Instale o client do TypeSafe, o client da Anthropic e os helpers compartilhados do cookbook.
- Defina uma chave de API do TypeSafe e uma chave da Anthropic para o agente que será medido.
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
Nota: os blocos de código abaixo formam um único script, em ordem. Para acompanhar, coloque-os em um único arquivo na ordem mostrada.
Armazenar resultados em cache
JsonCache salva o resultado de cada chamada, indexado por suas entradas, então reexecutar
reproduz os números abaixo em vez de chamar qualquer uma das APIs. Apague json_cache.json
para rodar ao vivo. A execução publicada usou jev-1.12 e claude-haiku-4-5-20251001,
renderizada em 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"))
Passo 1: carregue o catálogo
hermes_roster.json contém as 182 habilidades do
NousResearch/hermes-agent (MIT) em um
commit fixado. Cada registro contém o nome e a categoria de uma habilidade, a descrição
como o índice a mostra, a descrição completa e o início de seu SKILL.md.
O índice abaixo, e as instruções acima dele no prompt, são copiados do 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.
Passo 2: pontue o agente sozinho
requests.json contém 488 requisições de turno único, 315 delas cobertas por exatamente
uma habilidade e as outras 173 não cobertas por nenhuma.
As requisições cobertas foram escritas pelo Claude Sonnet 5 a partir do SKILL.md de cada
habilidade, então os rótulos são confiáveis e as requisições são mais fáceis que as que os
usuários enviam.
As 173 não cobertas foram todas escritas para punir o palpite: 85 requisições do dia a dia, 42 perguntas técnicas que nenhuma habilidade atende (explique o que é uma mônada) e 46 que pedem algo específico para o qual o catálogo não tem habilidade, como poste isto no Mastodon em um catálogo que cobre X e nada mais.
A pontuação lê apenas a primeira resposta do agente. Os dois números são taxas de erro, então menor é melhor em cada um:
- carga errada: das requisições cobertas, a fração em que a primeira chamada a
skill_viewnão foi a habilidade que cobre. Um turno que não carregou nada conta como erro. - carga desnecessária: das requisições não cobertas, a fração em que o agente chamou
skill_viewde qualquer forma.
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.
A sugestão vai em seu próprio bloco do prompt de sistema, depois do catálogo e não dentro dele, para que o texto do catálogo seja idêntico em todo turno e mantenha o cache de prefixo.
O agente tem um conjunto mínimo de ferramentas, incluindo skill_view para carregar uma
habilidade usando um nome em texto livre. O nome precisa corresponder exatamente à
habilidade para uma carga correta.
# 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))
O agente roda primeiro sem nada além de seu catálogo, do jeito que funciona hoje. Suas duas taxas de erro são a linha de base contra a qual o resto do cookbook mede.
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
As cargas erradas caem na própria categoria da habilidade certa muito mais vezes do que o acaso as colocaria, então a parte difícil é distinguir alguns parecidos. O agente já está olhando mais ou menos no lugar certo.
Passo 3: ordene o catálogo inteiro
Uma requisição carrega dois tipos de pergunta:
whiché uma perguntaChoicesobre todos os 182 nomes de habilidade, com a descrição do índice como criteria de cada opção (o mesmo texto que o próprio agente recebe). Suas probabilidades são a ordenação.- três perguntas
Noulsobre a requisição, impressas abaixo, cada uma perguntando de uma forma diferente se o que se quer é uma ação tomada e não uma explicação dada.prose_sufficesconta ao contrário. A média delas decide se deve sugerir alguma coisa, e abaixo de 0.30 nada é sugerido.
Ambas saem em uma única requisição, então a ordenação e a verificação custam uma ida e volta.
Escreva essas três para perguntar se uma ação é desejada. Uma pergunta sobre o assunto não vai separar explique o que é uma mônada de uma requisição que precisa de uma habilidade, já que ambas são software.
Uma pergunta Choice acomoda com folga um catálogo deste tamanho. Algumas vezes maior e
você
o dividiria em blocos e ordenaria cada um, depois rodaria este mesmo passo de lista curta
sobre os vencedores.
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).
A requisição do Notes.app é inequívoca, e sua opção principal é a certa. Nada que uma ordenação faça vai salvar a do Mastodon: as três perguntas dizem que se quer uma habilidade, porque postar em uma conta é uma ação, e com uma habilidade para postar no X e nada para o Mastodon a habilidade mais próxima vence mesmo assim.
Isso deixa o deck. Os dois líderes são habilidades de .pptx, e em 60 caracteres a
pergunta Choice ampla coloca a habilidade de edição à frente da de criação, para uma
requisição sobre criar um deck.
Passo 4: reordene as três primeiras
Três opções dão espaço para a descrição completa mais o início do SKILL.md de cada
habilidade, então a segunda requisição faz a mesma pergunta com evidência melhor:
whiché uma perguntaChoicesobre a lista curta, com esse texto mais longo como criteria de cada opção.fits::{name}é uma perguntaNoulpor candidato: esta habilidade faz a coisa específica que a requisição pede? Cada uma é respondida por conta própria, então todas podem voltar baixas, e uma lista curta cujo maior valor fica abaixo de 0.30 é descartada por inteiro.
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
As duas habilidades de .pptx se separam quando cada uma traz seu próprio texto: a
requisição do deck vira para a habilidade de criação.
Os nouls de fits e o Choice discordam ali: os nouls pontuam a habilidade de edição mais
alto enquanto o Choice escolhe a de criação. Eles estão decidindo coisas diferentes. O
Choice decide qual habilidade, e os nouls decidem se devem dizer alguma coisa.
A requisição do Mastodon sobrevive às duas checagens: seu melhor noul de fits fica acima
de 0.30, então a receita sugere a habilidade do X para uma requisição sobre o Mastodon. A
maioria das requisições assim é capturada. A segunda passagem só pode rejeitar o que a
ordenação ampla lhe entrega, e aqui isso foram três quase-acertos.
A função abaixo é a receita inteira: duas requisições e dois limiares, com no máximo um nome de habilidade voltando.
Para apontá-la para o seu próprio catálogo, substitua hermes_roster.json. Toda pergunta
acima lê name, description, description_full e body desse arquivo, e nada mais
sabe sobre o 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>
Passo 5: meça a sugestão
Cada uma das 488 requisições vai ao agente três vezes, um turno medido cada. As execuções diferem apenas no que é dito ao agente:
| o que vai no prompt de sistema | |
|---|---|
| agente sozinho | nada |
| agente com uma sugestão | o que suggest() retornou |
| agente que recebe a resposta | o nome da habilidade que cobre, ou “nada se aplica” quando não há |
A terceira não é alcançável; é o teto contra o qual as outras duas são medidas.
A redação dessa sugestão faz dois trabalhos. Ela diz que a sugestão pode ser ignorada, porque forçar mais também ganha conformidade em sugestões erradas, e uma errada é pior que nenhuma. E um turno sem nada a sugerir ainda envia uma frase dizendo isso; não enviar nada deixaria a própria instrução do catálogo de “erre pelo lado de carregar” sem oposição.
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
A sugestão corrige muito mais requisições do que quebra, mas quebra algumas que o agente tinha acertado sozinho. Uma sugestão errada e confiante é mais persuasiva que nenhuma sugestão, que é o preço de colocar uma na frente do turno.
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)
O que os resultados mostram
- As cargas erradas caíram de 16.8% para 7.3% e as desnecessárias de 9.8% para 4.0%, que é a maior parte da diferença entre adivinhar a partir de um índice truncado e receber a resposta.
- Algumas requisições que o agente tinha acertado sozinho voltam erradas quando uma sugestão é anexada. As contagens estão acima.
Copie este formato quando um agente seu carrega um catálogo grande: uma ordenação barata sobre tudo, depois um olhar de perto em duas ou três. Qualquer um dos passos pode voltar de mãos vazias.
Abra no playground
Monte um link de playground para a requisição do deck do passo 4, usando a descrição completa e o trecho de body de cada candidato como criteria.
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})"
)
)
Abra a lista curta + as perguntas no playground do TypeSafe →
Próximos passos
O mesmo formato aparece em outros lugares: Roteamento de intenção para rotear a um handler em vez de a uma habilidade, Confiança para escolher os dois limiares, e Fan-out especulativo para colocar todas as perguntas em uma única requisição.