Выбор навыков для ИИ-агентов
Picks at most one skill for an agent turn out of the 182 in Nous Research's Hermes catalog: one TypeSafe request ranks every skill and asks whether the turn needs one at all, a second reads the top three properly and can reject all of them. The winner's name goes into a single line of the agent's sy…
Выбирает не более одного навыка для шага агента из 182 доступных в каталоге Hermes от Nous Research: один запрос TypeSafe ранжирует все навыки и определяет, требуется ли навык вообще, второй запрос подробно анализирует топ-3 и может отклонить их все. Имя победителя добавляется в единственную строку системного промпта агента, снижая более чем вдвое как число ошибочно загруженных навыков, так и загрузки в ситуациях, когда ни один навык не подходит.
Агенты обычно выбирают навыки, усекая их описания и загружая весь список в системный промпт, что увеличивает затраты, снижает точность выбора навыка и приводит к деградации контекста (context rot) на всю оставшуюся сессию. Мы решаем эту проблему, используя два запроса TypeSafe на каждый шаг агента — один для ранжирования навыков и второй для проверки выбора, сокращая количество некорректных загрузок навыков более чем вдвое.
Агент с большим каталогом навыков принимает решение практически вслепую. Каталог поступает к нему в виде индекса: одна строка на навык, при этом описание обрезается, чтобы полный текст не вытеснял саму беседу. Фреймворк Hermes, используемый здесь в качестве агентной среды, по умолчанию обрезает описание до 60 символов. Например, при такой длине навык, который редактирует файлы .pptx, выглядит почти так же, как навык, который создает их с нуля. Попросите подготовить питч-дек, и агент легко может загрузить не тот инструмент. А на шаге, где ни один навык вообще не нужен, он все равно может что-то загрузить, поскольку список названий провоцирует на угадывание.
В этом руководстве описания не сокращаются: вместо этого применяется принцип поэтапного раскрытия (progressive disclosure) — сначала недорогой обзор всех 182 навыков, а затем детальное изучение трех кандидатов. Перед принятием решения о загрузке навыка выполняются два запроса TypeSafe. Первый ранжирует все навыки каталога относительно запроса пользователя и определяет, нужен ли навык вообще. Второй заново оценивает только трех лидеров — теперь уже с их полными описаниями и началом инструкций — и имеет возможность отклонить их все.
Имя победителя подставляется в одну дополнительную строку системного промпта агента на текущем шаге:
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user
actually asked for.
</skill_relevance>
Агент сохраняет свой полный индекс и свободу собственного суждения, а эта строка лишь подсказывает ему, на какую запись обратить внимание в первую очередь. Сам каталог в промпте никогда не меняется, благодаря чему кэширование префикса (prefix caching) сохраняется в полной мере. Результаты на 488 запросах к модели claude-haiku-4-5-20251001 с использованием навыков из каталога Hermes:
| загружает неверный навык | загружает навык, когда ничего не подходит | |
|---|---|---|
| агент сам по себе, только со своим каталогом | 16.8% | 9.8% |
| агент с подсказкой TypeSafe | 7.3% | 4.0% |
| агенту передан правильный ответ (oracle) | 2.5% | 1.2% |
Третья строка показывает, что базовый уровень ошибок не равен нулю: даже получив абсолютно точный навык, агент не всегда загружает его, и никакой метод подбора, сколь бы совершенен он ни был, не может преодолеть этот порог.
В итоге вы получаете функцию suggest(), возвращающую не более одного имени навыка, функцию suggestion_block(), форматирующую подсказку для системного промпта, и тестовый стенд, на котором получена таблица выше, готовый к работе с вашим собственным каталогом.
flowchart LR
subgraph C1["Вызов 1 — беглый обзор всех 182 навыков"]
direction TB
Q1["<b>Choice:</b> какой навык подходит?<br/><i>все 182, по одной строке на каждый</i>"]
N1["<b>Nouls:</b> нужен ли навык вообще?<br/>· воздействовать на данные пользователя?<br/>· следовать инструкциям?<br/>· или просто пообщаться?"]
%% 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["Вызов 2 — детальный анализ этих 3"]
direction TB
Q2["<b>Choice:</b> какой из 3?<br/><i>с подробными описаниями</i>"]
N2["<b>Nouls:</b> действительно ли каждый<br/>решает задачу?"]
Q2 ~~~ N2
end
REQ["запрос пользователя"] --> C1
C1 -->|"топ-3"| C2
C1 -->|"ничего<br/>не подходит"| STOP["ничего не<br/>предлагать"]
C2 -->|"никто не подошел"| STOP
C2 -->|"есть победитель"| OUT["предложить<br/>победителя"]
Настройка
- Установите клиент TypeSafe, клиент Anthropic и вспомогательные модули руководства.
- Задайте ключ API TypeSafe и ключ Anthropic для тестируемого агента.
pip install anthropic matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
export TYPESAFE_API_KEY=your-key-here
export ANTHROPIC_API_KEY=your-key-here
Примечание: блоки кода ниже представляют собой единый скрипт. Чтобы выполнить его самостоятельно, сохраните их в один файл в указанном порядке.
Кэширование результатов
JsonCache сохраняет результаты каждого вызова по ключу входных данных, поэтому повторный запуск воспроизводит приведенные ниже цифры без обращения к API. Удалите json_cache.json, чтобы выполнить прогон в реальном времени. Опубликованный прогон использовал jev-1.12 и claude-haiku-4-5-20251001, выборка от 31.07.2026.
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"))
Шаг 1: загрузка каталога
Файл hermes_roster.json содержит 182 навыка из
NousResearch/hermes-agent (MIT) на определенном
коммите. Каждая запись содержит имя и категорию навыка, краткое описание для индекса,
полное описание и начало файла SKILL.md.
Приведенный ниже индекс и инструкции промпта над ним скопированы непосредственно из 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.
Шаг 2: оценка базового агента без подсказок
requests.json содержит 488 одношаговых запросов: 315 из них покрываются ровно одним навыком,
а остальные 173 не покрываются ни одним.
Покрытые запросы были сгенерированы Claude Sonnet 5 на основе файлов SKILL.md каждого навыка,
поэтому разметка надежна, а сами запросы проще реальных пользовательских.
Все 173 непокрытых запроса составлены так, чтобы выявлять ложные срабатывания: 85 повседневных
запросов, 42 технических вопроса, для которых нет навыков (объясни, что такое монада),
и 46 запросов на специфические действия, отсутствующие в каталоге (например, опубликуй это в Mastodon
в каталоге, где есть только интеграция с X).
Оценка учитывает только первый ответ агента. Оба показателя являются долями ошибок, поэтому меньшее
значение лучше:
- wrong load (ошибочная загрузка): доля покрытых запросов, в которых первый вызов
skill_view
загрузил не тот навык. Шаг, на котором ничего не загружено, считается промахом. - needless load (избыточная загрузка): доля непокрытых запросов, в которых агент вообще вызвал
skill_view.
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.
Подсказка помещается в отдельный блок системного промпта после каталога, а не внутри него,
благодаря чему текст каталога остается побайтово идентичным на каждом шаге, сохраняя кэширование префикса.
Агент располагает минимальным набором инструментов, включая skill_view для загрузки навыка по
текстовому имени. Для успешной загрузки имя должно точно совпадать с названием навыка.
# 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))
Сначала агент запускается только со своим каталогом — так, как он работает сегодня. Полученные
две доли ошибок служат базовой линией (baseline), с которой сравниваются остальные результаты.
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
Ошибочные загрузки попадают в ту же категорию, к которой относится правильный навык, несоизмеримо
чаще, чем это происходило бы при случайном выборе: основная сложность заключается в различении
похожих навыков внутри одной темы. Агент уже ищет примерно в правильном месте.
Шаг 3: ранжирование всего каталога
Один запрос объединяет два типа вопросов:
which— вопросChoice
по всем 182 именам навыков с кратким описанием из индекса в качестве критерия каждого
варианта (тот же текст, который видит сам агент). Его вероятности образуют итоговый рейтинг.- три вопроса
Noulо запросе пользователя
(приведены ниже), каждый из которых под своим углом проверяет, требуется ли выполнение конкретного
действия, а не просто разъяснение. Вопросprose_suffices
инвертирован. Их среднее значение определяет, стоит ли вообще предлагать навык: при значении
ниже 0.30 подсказка не формируется.
Оба типа вопросов отправляются в одном запросе, поэтому ранжирование и проверка обходятся в одно
обращение к API.
Формулируйте эти три вопроса так, чтобы они спрашивали о потребности в действии. Вопрос о тематике
текста не отличит задачу «объясни, что такое монада» от запроса, требующего навыка разработки,
поскольку обе темы относятся к программированию.
Один вопрос Choice без труда вмещает каталог такого размера. Для каталогов в разы большего
объема его можно разбить на части, проранжировать каждую и затем применить тот же этап отбора
к лидерам.
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).
Запрос к Notes.app однозначен, и его главный кандидат выбран верно. При ранжировании запроса
про Mastodon ситуация сложнее: три вопроса показывают, что навык требуется (так как публикация
в соцсеть — это действие), а при наличии навыка для X и отсутствии для Mastodon побеждает ближайший
доступный вариант.
Остается запрос про презентацию. Оба лидера — навыки для .pptx, и на основе 60-символьного описания
широкий вопрос Choice ошибочно ставит навык редактирования выше навыка создания новой презентации.
Шаг 4: переранжирование трех лучших кандидатов
Три варианта оставляют достаточно места для полного описания и начала файла SKILL.md каждого
навыка, поэтому второй запрос оценивает кандидатов на основе гораздо более подробных данных:
which— вопросChoiceпо шортлисту, где в качестве критериев вариантов выступает этот
расширенный текст.fits::{name}— по одному вопросуNoulна каждого кандидата: выполняет ли данный навык
именно то действие, о котором просит пользователь? Каждый вопрос оценивается независимо,
поэтому все они могут получить низкие баллы; шортлист, у которого наивысший балл ниже 0.30,
отклоняется полностью.
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
Два навыка для .pptx четко разделяются, как только к оценке подключаются полные тексты:
запрос на презентацию переключается на навык создания (pptx-author).
Оценки fits (noul) и выбор Choice здесь расходятся: noul выше оценивает навык редактирования,
тогда как Choice выбирает создание. Они решают разные задачи: Choice определяет, какой именно
навык лучше, а noul — стоит ли вообще что-либо предлагать.
Запрос про Mastodon проходит обе проверки: его лучший noul fits оказывается выше 0.30, поэтому
алгоритм предлагает навык для X на запрос о Mastodon. Большинство подобных запросов отсекаются;
второй проход может отфильтровать только то, что передало ему широкое ранжирование, а здесь в
шортлист попали три близких варианта.
Приведенная ниже функция представляет собой весь рецепт: два запроса и два порога, возвращающие
не более одного имени навыка.
Чтобы применить ее к собственному каталогу, замените hermes_roster.json. Все вопросы выше читают
поля name, description, description_full и body из этого файла, и больше ничто в коде
не привязано к 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>
Шаг 5: оценка эффективности подсказок
Каждый из 488 запросов отправляется агенту трижды — по одному тестовому шагу на вариант.
Запуски отличаются только тем, какая информация передается агенту:
| что передается в системный промпт | |
|---|---|
| агент без подсказок (baseline) | ничего |
| агент с подсказкой TypeSafe | результат, возвращенный suggest() |
| агент с идеальным ответом (oracle) | точное имя нужного навыка или «ничего не подходит» |
Третий вариант недостижим на практике — он служит теоретическим потолком для сравнения первых двух.
Формулировка подсказки решает две задачи. Она прямо указывает, что подсказку можно проигнорировать:
излишне настойчивая инструкция заставила бы агента соглашаться и с ошибочными подсказками, а неверный
навык хуже, чем отсутствие подсказки. Кроме того, если подсказывать нечего, агенту все равно
передается фраза о том, что подходящих навыков нет; пустое сообщение оставило бы без противовеса
собственную инструкцию каталога «в сомнительных случаях обязательно загружайте навык».
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
Подсказка исправляет гораздо больше запросов, чем ломает, однако в единичных случаях она сбивает
агента там, где он сам выбрал бы правильный вариант. Уверенная ошибочная подсказка обладает
внушающей силой, и это неизбежная плата за предварительное направление агента.
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)

Что показывают результаты
- Доля ошибочных загрузок снизилась с 16.8% до 7.3%, а избыточных — с 9.8% до 4.0%, что покрывает
большую часть разрыва между угадыванием по усеченному индексу и идеальным знанием правильного ответа. - Некоторые запросы, которые агент решал верно самостоятельно, оказываются ошибочными при наличии
подсказки. Точные цифры приведены выше.
Используйте эту архитектуру, если ваш агент работает с обширным каталогом инструментов: недорогое
общее ранжирование всего списка, затем детальный анализ двух-трех лидеров. Любой из этапов может
закончиться решением ничего не загружать.
Открыть в песочнице
Сформируйте ссылку на песочницу для запроса о презентации из шага 4 с использованием полных
описаний кандидатов и фрагментов документации в качестве критериев.
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})"
)
)
Открыть шортлист + вопросы в песочнице TypeSafe →
Что дальше
Та же архитектурная схема применяется и в других руководствах:
Маршрутизация намерений (Intent Routing) для направления к
обработчику вместо навыка, Уверенность (Confidence) для
подбора пороговых значений, и
Speculative Fan-Out для объединения всех
вопросов в одном запросе.