Глава 6: Инструменты — от определения до выполнения
Нервная система
В главе 5 мы показали цикл агента — тот самый while(true), который стримит ответы модели, собирает вызовы инструментов и возвращает результаты обратно. Цикл — это сердцебиение. Но сердцебиение бесполезно без нервной системы, которая переводит «модель хочет выполнить git status» в реальную шелл-команду с проверками разрешений, бюджетированием результата и обработкой ошибок.
Система инструментов — это эта нервная система. Она включает более 40 реализаций инструментов, централизованный реестр с feature-флагами, 14-шаговый конвейер выполнения, резолвер разрешений с семью режимами и стриминг-исполнитель, который запускает инструменты ещё до того, как модель завершит ответ.
Каждый вызов инструмента в Claude Code — каждое чтение файла, каждая шелл-команда, каждый grep, каждая отправка суб-агента — проходит через тот же самый конвейер. Единообразие — это и есть идея: будь то встроенный Bash-исполнитель или сторонний MCP-сервер, он получает ту же валидацию, те же проверки разрешений, тот же бюджет результатов и ту же классификацию ошибок.
Интерфейс Tool содержит примерно 45 членов. Это звучит пугающе, но для понимания системы важны только пять:
call()— выполнить инструментinputSchema— валидировать и распарсить входisConcurrencySafe()— можно ли запускать параллельно?checkPermissions()— разрешено ли это?validateInput()— имеет ли вход смысл семантически?
Всё остальное — 12 методов рендеринга, хуки аналитики, подсказки для поиска — существует для UI и телеметрии. Начните с этих пяти, и остальное встанет на свои места.
Интерфейс инструмента
Три параметра типа
Каждый инструмент параметризуется тремя типами:
Tool<Input extends AnyObject, Output, P extends ToolProgressData>
Input — Zod-объектная схема, которая выполняет двойную роль: генерирует JSON Schema, отправляемую в API (чтобы модель знала, какие параметры предоставить), и валидирует ответ модели в рантайме через safeParse. Output — TypeScript-тип результата инструмента. P — тип событий прогресса, который инструмент эмитит во время выполнения — BashTool эмитит чанки stdout, GrepTool эмитит количества совпадений, AgentTool эмитит транскрипты суб-агентов.
buildTool() и политики “fail-closed”
Ни одна реализация инструмента напрямую не конструирует объект Tool. Каждый инструмент проходит через buildTool(), фабрику, которая наносит объект дефолтов под определение конкретного инструмента:
// Псевдокод — иллюстрация паттерна безопасных (fail-closed) значений по умолчанию
const SAFE_DEFAULTS = {
isEnabled: () => true,
isParallelSafe: () => false, // Безопасное по умолчанию: новые инструменты исполняются последовательно
isReadOnly: () => false, // Безопасное по умолчанию: считаются операциями записи
isDestructive: () => false,
checkPermissions: (input) => ({ behavior: 'allow', updatedInput: input }),
}
function buildTool(definition) {
return { ...SAFE_DEFAULTS, ...definition } // Определение переопределяет дефолты
}
Дефолты намеренно “закрыты там, где это важно” (fail-closed). Новый инструмент, забывший реализовать isConcurrencySafe, по умолчанию получает false — он выполняется последовательно, никогда не параллельно. Инструмент, забывший isReadOnly, по умолчанию считается операцией записи. Инструмент, забывший toAutoClassifierInput, возвращает пустую строку — авто-классификатор безопасности пропускает его, и вместо автоматического обхода применяется общая система разрешений.
Единственный дефолт, который не является “fail-closed”, — это checkPermissions, возвращающий allow. Это кажется контринтуитивным до тех пор, пока вы не поймёте слоистую модель разрешений: checkPermissions — это специфичная для инструмента логика, которая выполняется после общей системы разрешений, которая уже оценила правила, хуки и политику режимов. Инструмент, возвращающий allow из checkPermissions, говорит «у меня нет инструмент-специфического возражения» — это не даёт безусловный доступ. Группировка в под-объекты (options, именованные поля типа readFileState) даёт ту же структуру, которую дали бы узконаправленные интерфейсы, но без церемонии объявления и проброса пяти отдельных интерфейсных типов через 40+ точек вызова.
Конкурентность зависит от входа
Сигнатура isConcurrencySafe(input: z.infer<Input>): boolean принимает распарсенный вход, потому что один и тот же инструмент может быть безопасен для одних входов и небезопасен для других. BashTool — канонический пример: ls -la — это только чтение и параллельно-безопасно, а rm -rf /tmp/build — нет. Инструмент парсит команду, классифицирует подкоманды по известным наборам безопасных операций и возвращает true только если каждая не-нейтральная часть — операция поиска или чтения.
Тип возврата ToolResult
Каждый call() возвращает ToolResult<T>:
type ToolResult<T> = {
data: T
newMessages?: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[]
contextModifier?: (context: ToolUseContext) => ToolUseContext
}
data — типизированный вывод, который сериализуется в блок tool_result API. newMessages позволяет инструменту вставлять дополнительные сообщения в беседу — AgentTool использует это для добавления транскриптов суб-агентов. contextModifier — функция, которая мутирует ToolUseContext для последующих инструментов — так EnterPlanMode переключает режим разрешений. Модификаторы контекста учитываются только для инструментов, не безопасных для параллельного выполнения; если ваш инструмент работает параллельно, его модификатор ставится в очередь до завершения батча.
ToolUseContext: объект-бог
ToolUseContext — это огромный мешок контекста, который пробрасывается через каждый вызов инструмента. В нём примерно 40 полей. По любому разумному определению, это god object. Он существует потому, что альтернатива хуже.
Такому инструменту, как BashTool, нужен abort controller, кэш состояния файлов, состояние приложения, история сообщений, набор инструментов, подключения MCP и полдюжины UI-колбеков. Пробрасывать их как отдельные параметры привело бы к сигнатурам функций с 15+ аргументами. Прагматичное решение — единый объект контекста, организованный по областям:
Конфигурация (под-объект options): набор инструментов, имя модели, MCP-подключения, флаги отладки. Задаётся один раз в начале запроса и в основном неизменяем.
Состояние выполнения: abortController для отмены, readFileState для LRU-кэша содержимого файлов, messages — полная история диалога. Эти поля изменяются в ходе выполнения.
UI-колбеки: setToolJSX, addNotification, requestPrompt. Подключены только в интерактивных (REPL) контекстах. SDK и headless-режимы оставляют их undefined.
Контекст агента: agentId, renderedSystemPrompt (замороженный родительский prompt для форкнутых суб-агентов — повторный рендер мог бы расходиться из-за прогрева feature-флагов и сломать кэш).
Версия ToolUseContext для суб-агента особенно информативна. Когда createSubagentContext() строит контекст для дочернего агента, оно делает сознательный выбор о том, какие поля шарить, а какие изолировать: setAppState становится no-op для асинхронных агентов, localDenialTracking получает новый объект, contentReplacementState клонируется от родителя. Каждый выбор кодирует урок, извлечённый из боевого бага.
Реестр
getAllBaseTools(): единый источник истины
Функция getAllBaseTools() возвращает исчерпывающий список всех инструментов, которые могут существовать в текущем процессе. Сначала идут всегда-присутствующие инструменты, затем условно включаемые, ограждённые feature-флагами:
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
Импорт feature() из bun:bundle разрешается во время бандлинга. Когда feature('AGENT_TRIGGERS') статически false, бандлер исключает весь require() — dead code elimination, что делает бинарник компактнее.
assembleToolPool(): слияние встроенных и MCP-инструментов
Окончательный набор инструментов, который видит модель, формируется в assembleToolPool():
- Получить встроенные инструменты (фильтрация deny-правил, скрытие в REPL-режиме и проверки
isEnabled()) - Отфильтровать MCP-инструменты по deny-правилам
- Отсортировать каждую партицию по алфавиту по имени
- Конкатенировать встроенные (префикс) + MCP-инструменты (суффикс)
Подход “сортировка-потом-конкатенация” — не эстетический выбор. API-сервер ставит точку прерывания кэширования prompt после последнего встроенного инструмента. Общая сортировка по всем инструментам перемешала бы MCP-инструменты со встроенными, и добавление/удаление MCP-инструмента сдвинуло бы позиции встроенных, инвалидируя кэш.
14-шаговый конвейер выполнения
Функция checkPermissionsAndCallTool() — это место, где намерение превращается в действие. Каждый вызов инструмента проходит через эти 14 шагов.
Шаги 1–4: валидация
Поиск инструмента делает fallback к getAllBaseTools() для соответствий по псевдонимам, обрабатывая транскрипты из старых сессий, где инструмент был переименован. Проверка отмены (Abort Check) предотвращает пустую трату ресурсов на вызовы инструментов, поставленные в очередь до распространения Ctrl+C. Zod-валидация ловит несовпадения типов; для deferred инструментов ошибка дополняется подсказкой вызвать сначала ToolSearch. Семантическая валидация выходит за рамки схемы — FileEditTool отклоняет no-op правки, BashTool блокирует standalone sleep, когда доступен MonitorTool.
Шаги 5–6: подготовка
Спекулятивный запуск классификатора стартует авто-режимный классификатор безопасности в параллели для Bash-команд, экономя сотни миллисекунд на критическом пути. Input Backfill клонирует распарсенный вход и добавляет производные поля (расширяя ~/foo.txt в абсолютные пути) для хук-ов и разрешений, сохраняя оригинал ради стабильности транскрипта.
Шаги 7–9: разрешения
PreToolUse Hooks — механизм расширения: они могут принимать решения по разрешению, модифицировать вход, инжектить контекст или полностью останавливать исполнение. Разрешение прав объединяет хуки и общую систему разрешений: если хук уже решил, это финально; иначе canUseTool() запускает матчинг правил, инструмент-специфические проверки, режимные дефолты и интерактивные подсказки. Обработка отказа в разрешении формирует сообщение об ошибке и триггерит хуки PermissionDenied.
Шаги 10–14: выполнение и очистка
Выполнение инструмента вызывает реальный call() с оригинальным входом. Бюджетирование результата сохраняет чрезмерный вывод на диск в ~/.claude/tool-results/{hash}.txt и заменяет его превью-обёрткой. PostToolUse Hooks могут модифицировать вывод MCP или блокировать продолжение. Новые сообщения добавляются в историю (транскрипты суб-агентов, системные напоминания). Обработка ошибок классифицирует ошибки для телеметрии, извлекает безопасные строки из потенциально искажённых имён и эмитит события OTel.
Система разрешений
Семь режимов
| Режим | Поведение |
|---|---|
default | Инструмент-специфические проверки; запрашивать у пользователя при нераспознанных операциях |
acceptEdits | Автоматически разрешать правки файлов; запрашивать для остальных операций |
plan | Только чтение — запрещать все операции записи |
dontAsk | Авто-отказ для всего, что обычно спровоцировало бы prompt (фоновые агенты) |
bypassPermissions | Разрешать всё без запросов |
auto | Использовать классификатор транскрипта для решения (через feature-флаг) |
bubble | Внутренний режим для суб-агентов, эскалирующим запросы к родителю |
Цепочка разрешения
Когда вызов инструмента доходит до разрешения:
- Решение хука: если PreToolUse хук уже вернул
allowилиdeny, это окончательно. - Матчинг правил: три набора правил —
alwaysAllowRules,alwaysDenyRules,alwaysAskRules— сопоставляются по имени инструмента и опциональным паттернам содержимого.Bash(git *)матчится на любую Bash-команду, начинающуюся сgit. - Инструмент-специфическая проверка: метод
checkPermissions()инструмента. Большинство возвращаютpassthrough. - Режимный дефолт:
bypassPermissionsразрешает всё.planзапрещает записи.dontAskзапрещает запросы. - Интерактивный prompt: в режимах
defaultиacceptEditsнеразрешённые решения показывают промпт. - Классификатор авто-режима: двухступенчатый классификатор (быстрая модель, затем углублённый анализ для неоднозначных случаев).
В варианте safetyCheck есть булево classifierApprovable: правки в .claude/ и .git/ помечены как classifierApprovable: true (редко, но иногда легитимно), тогда как попытки обойтись через Windows-пути получают classifierApprovable: false (почти всегда adversarial).
Правила разрешений и матчинг
Правила хранятся как объекты PermissionRule с тремя частями: source указывает происхождение (userSettings, projectSettings, localSettings, cliArg, policySettings, session и т.д.), ruleBehavior (allow, deny, ask) и ruleValue с именем инструмента и опциональным паттерном содержимого.
Поле ruleContent позволяет тонкий матчинг. Bash(git *) разрешает любую Bash-команду, начинающуюся с git. Edit(/src/**) разрешает правки только внутри /src. Fetch(domain:example.com) разрешает фетчи с конкретного домена. Правила без ruleContent совпадают со всеми вызовами данного инструмента.
Матчер BashTool парсит команду через parseForSecurity() (парсер bash AST) и разбивает составные команды на подкоманды. Если парсинг AST проваливается (сложный синтаксис с heredoc или вложенными subshell), матчер возвращает () => true — безопасное поведение: хук всегда выполняется. Предположение такое: если команда слишком сложна для парсинга, она слишком сложна для уверенного исключения из проверок безопасности.
Режим bubble для суб-агентов
Суб-агенты в паттернах координатор-воркер не могут показывать промпты — у них нет терминала. Режим bubble заставляет запросы разрешений всплывать к родительскому контексту. Координатор-агент, работающий в основном потоке с доступом к терминалу, обрабатывает prompt и отправляет решение обратно.
Отложенная загрузка инструментов (deferred loading)
Инструменты с shouldDefer: true отправляются в API с defer_loading: true — передаются имена и описания, но не полные схемы параметров. Это уменьшает начальный размер prompt. Чтобы использовать deferred-инструмент, модель должна сначала вызвать ToolSearchTool для подгрузки его схемы. Режим ошибки поучителен: вызов отложенного инструмента без его загрузки вызывает падение Zod-валидации (все типизированные параметры приходят как строки), и система добавляет целевую подсказку по восстановлению.
Отложенная загрузка также улучшает попадание в кэш: инструменты, отправленные с defer_loading: true, вносят в prompt только их имя, поэтому добавление или удаление отложенного MCP-инструмента меняет prompt на несколько токенов, а не на сотни.
Бюджетирование результатов
Ограничения по размеру для каждого инструмента
Каждый инструмент декларирует maxResultSizeChars:
| Инструмент | maxResultSizeChars | Обоснование |
|---|---|---|
| BashTool | 30,000 | Достаточно для большинства полезного вывода |
| FileEditTool | 100,000 | Дифы могут быть большими, но модель их нуждается |
| GrepTool | 100,000 | Результаты поиска с контекстными строками быстро растут |
| FileReadTool | Infinity | Самоограничивается через свои токен-лимиты; персистентность создала бы циклические петли чтения |
Когда результат превышает порог, полный контент сохраняется на диск и заменяется обёрткой <persisted-output> с превью и путём к файлу. Модель затем может использовать Read, чтобы получить полный вывод при необходимости.
Аггрегированный бюджет на разговор
Помимо ограничений на инструмент, ContentReplacementState отслеживает агрегированный бюджет в рамках всей беседы, предотвращая “смерть тысячую разрезов”: множество инструментов, каждый возвращающий 90% своего лимита, всё равно может переполнить контекстное окно.
Избранные инструменты
BashTool: самый сложный инструмент
BashTool — самый сложный инструмент в системе. Он парсит составные команды, классифицирует подкоманды как read-only или write, управляет фоновыми задачами, детектирует вывод изображений по magic bytes и реализует симуляцию sed для безопасных превью правок.
Разбор составных команд особенно интересен. splitCommandWithOperators() разбивает команду вроде cd /tmp && mkdir build && ls build на отдельные подкоманды. Каждая классифицируется по известным наборам безопасных команд (BASH_SEARCH_COMMANDS, BASH_READ_COMMANDS, BASH_LIST_COMMANDS). Составная команда считается read-only только если ВСЕ не-нейтральные части безопасны. Нейтральный набор (echo, printf) игнорируется — он не делает команду read-only, но и не делает её write-only.
Симуляция sed (_simulatedSedEdit) заслуживает особого внимания. Когда пользователь подтверждает sed-команду в диалоговом окне разрешений, система предварительно вычисляет результат, запуская sed в песочнице и захватывая вывод. Пред-вычисленный результат инжектируется в вход как _simulatedSedEdit. При выполнении call() он применяется напрямую, минуя шелл-исполнение. Это гарантирует, что то, что пользователь видел в превью, — это ровно то, что будет записано, а не повторный запуск команды, который мог бы дать иной результат, если файл изменился между превью и исполнением.
FileEditTool: детекция устаревания (staleness)
FileEditTool интегрируется с readFileState — LRU-кэшем содержимого файлов и таймстемпов, поддерживаемым в рамках беседы. Перед применением правки он проверяет, не был ли файл изменён с тех пор, как модель его в последний раз прочла. Если файл устарел — изменён фоновым процессом, другим инструментом или пользователем — правка отклоняется с сообщением, которое просит модель сначала перечитать файл.
Фаззи-матчинг в findActualString() обрабатывает распространённый случай, когда модель слегка ошибается с пробелами. Он нормализует пробельные символы и стиль кавычек перед матчингом, поэтому правка, целящаяся в old_string с завершающими пробелами, всё ещё найдёт реальный контент файла. Флаг replace_all включает массовые замены; без него неуникальные совпадения отклоняются, требуя от модели больше контекста для однозначной идентификации места.
FileReadTool: универсальный читатель
FileReadTool — единственный встроенный инструмент с maxResultSizeChars: Infinity. Если бы вывод Read сохранялся на диск, модель пришлось бы читать персистентный файл, который сам мог бы превысить лимит, создавая бесконечную петлю. Инструмент вместо этого самоограничивается оценкой токенов и усекает исходный контент.
Инструмент удивительно универсален: он читает текстовые файлы с нумерацией строк, изображения (возвращая base64 мультимодальные блоки), PDF (через extractPDFPages()), Jupyter-ноутбуки (через readNotebook()), и директории (фолбэк к ls). Он блокирует опасные device-пути (/dev/zero, /dev/random, /dev/stdin) и обрабатывает причуды имён macOS-скриншотов (U+202F узкий неразрывный пробел против обычного пробела в именах “Screen Shot”).
GrepTool: пагинация через head_limit
GrepTool оборачивает ripGrep() и добавляет механизм пагинации через head_limit. По умолчанию это 250 записей — достаточно для полезных результатов, но достаточно мало, чтобы не раздувать контекст. Когда происходит усечение, ответ включает appliedLimit: 250, сигнализируя модели использовать offset при следующем вызове для пагинации. Явный head_limit: 0 отключает лимит полностью.
GrepTool автоматически исключает шесть VCS-директорий (.git, .svn, .hg, .bzr, .jj, .sl). Поиск внутри .git/objects почти никогда не то, что хочет модель, и случайное включение бинарных pack-файлов разрушило бы бюджеты токенов.
AgentTool и модификаторы контекста
AgentTool порождает суб-агентов, которые запускают собственные циклы запросов. Его call() возвращает newMessages, содержащие транскрипт суб-агента, и опционально contextModifier, который переносит изменения состояния обратно к родителю. Поскольку AgentTool по умолчанию не безопасен для параллельного выполнения, несколько вызовов Agent в одном ответе выполняются последовательно — модификатор контекста каждого суб-агента применяется перед запуском следующего. В режиме координатора паттерн инвертируется: координатор диспетчеризует суб-агентов для независимых задач, и чек isAgentSwarmsEnabled() разблокирует параллельное исполнение агентов.
Как инструменты взаимодействуют с историей сообщений
Результаты инструментов не просто возвращают данные модели. Они участвуют в разговоре как структурированные сообщения.
API ожидает результаты инструментов в виде объектов ToolResultBlockParam, которые ссылаются на исходный блок tool_use по ID. Большинство инструментов сериализует в текст. FileReadTool может сериализовать в блоки изображений (base64) для мультимодальных ответов. BashTool детектирует изображение в stdout по magic bytes и переключает формат на image block.
ToolResult.newMessages — способ, с помощью которого инструменты расширяют диалог за пределы простого паттерна вызов→ответ. Транскрипты агентов: AgentTool инжектит историю сообщений суб-агента как attachment messages. Системные напоминания: инструменты памяти инжектят системные сообщения, которые появляются после результата инструмента — видимы модели на следующем ходу, но отброшены на границе normalizeMessagesForAPI. Attachment messages: результаты хук-ов, дополнительный контекст и детали ошибок несут структурированную метаинформацию, к которой модель может обращаться в последующих ходах.
Функция contextModifier — механизм для инструментов, которые меняют среду выполнения. Когда выполняется EnterPlanMode, он возвращает модификатор, который ставит режим разрешений в 'plan'. Когда выполняется ExitWorktree, он меняет рабочую директорию. Эти модификаторы — единственный способ, которым инструмент может повлиять на последующие инструменты — прямой мутации ToolUseContext не происходит, потому что контекст копируется spread-оператором перед каждым вызовом. Ограничение «только последовательные» поддерживается оркестрационным слоем: если два параллельных инструмента оба модифицируют рабочую директорию, кто победит?
Apply This: проектирование системы инструментов
-
Fail-closed дефолты. Новые инструменты должны быть консервативны, пока явно не помечены иначе. Разработчик, забывший установить флаг, получает безопасное поведение, а не опасное.
-
Безопасность зависит от входа.
isConcurrencySafe(input)иisReadOnly(input)принимают распарсенный вход, потому что один и тот же инструмент для разных входов имеет разные профили безопасности. Реестр, который помечает BashTool как «всегда последовательный», корректен, но неэффективен. -
Выстраивайте слои разрешений. Инструмент-специфические проверки, правило-основанный матчинг, режимные дефолты, интерактивные подсказки и автоматические классификаторы — каждый из механизмов решает разные случаи. Ни один механизм не покрывает всё.
-
Бюджетируйте результаты, а не только входы. Лимиты по токенам для входа — стандарт. Но результаты инструментов могут быть сколь угодно большими и накапливаться через ходы. Ограничения по инструменту предотвращают локальные взрывы. Агрегированные лимиты по беседе предотвращают накопление.
-
Делайте классификацию ошибок безопасной для телеметрии. В минифицированных билдах
error.constructor.nameискажается. ФункцияclassifyToolError()извлекает наиболее информативную безопасную строку — безопасные для аналитики сообщения, коды errno, стабильные имена ошибок — не логируя при этом сырое сообщение ошибки в аналитике.
Что дальше
Эта глава проследила путь одного вызова инструмента от определения через валидацию, разрешения, выполнение и бюджетирование результатов. Но модель редко просит только один инструмент за раз. О том, как инструменты оркеструются в конкурентные батчи, — в главе 7.