Агент — это цикл: инструменты, правила, скилы и субагенты
Модель умеет только писать текст. Читать файлы, запускать тесты и не трогать прод её учит среда вокруг. Разберём, из чего эта среда состоит и как её настроить.
После урока вы сможете
- объяснить цикл агента: кто думает, а кто выполняет;
- написать короткий и полезный файл правил проекта (CLAUDE.md или AGENTS.md);
- отличить мягкое правило от жёсткого запрета и настроить разрешения;
- понять, когда нужен скил, а когда субагент, и как они экономят контекст.
Сильный новичок в команде
К вам пришёл очень умный новичок. Он знает все языки, но не знает ваш проект. Чтобы он приносил пользу, ему дают онбординг, доступы, рабочие инструменты, инструкции и помощников.
Что нужно новичку, чтобы работать
анимацияНажмите «Дальше», чтобы собрать рабочее место по шагам.
Всё это вместе — harness («упряжь»). Одна и та же модель в хорошем и плохом harness работает как два разных инженера.
От простого к сложному
Агент — это модель в цикле с инструментами
Модель ничего не запускает сама. Она только пишет запрос: «вызови инструмент run_tests». Такой запрос называют tool_use. Программа вокруг модели (harness) выполняет его, дописывает результат в историю и снова зовёт модель. Цикл идёт, пока модель не скажет «я закончила».
messages = [задача]
while (true) {
ответ = model(messages, tools) // модель думает
if (ответ.stop_reason === "end_turn") break
результат = выполнить(ответ.tool_use) // harness делает
messages.push(ответ, результат) // история растёт
}
Из чего состоит harness
Возьмём Claude Code (агент Anthropic для терминала). У Codex, Cursor и других агентов те же части, только файлы называются иначе.
- Системный промпт — базовые инструкции агента. Его пишет разработчик инструмента, вы его обычно не трогаете.
- Файл правил проекта —
CLAUDE.mdилиAGENTS.mdв корне репозитория. Агент читает его в начале каждой сессии. Там команды сборки и тестов, структура, договорённости, запреты. - Инструменты (tools) — чтение и правка файлов, поиск, запуск команд. Каждый описан именем, описанием и схемой аргументов.
- Разрешения (permissions) — в
.claude/settings.json. Списокallow: можно без вопросов. Списокdeny: нельзя никогда. Остальное агент спрашивает у вас. - Скилы (skills) — папка
.claude/skills/<имя>/с файломSKILL.md. Это инструкция под типовую задачу: «как выпустить релиз», «как писать миграцию». - Субагенты (subagents) — файлы
.claude/agents/<имя>.md. Отдельный агент со своим окном контекста и своим набором инструментов. - Управление контекстом — сжатие старой истории, кэш, вынос тяжёлой работы в субагентов.
deny в settings.json или хук (урок 4) — это код. Его выполняет harness, и модель не может его обойти. Важное — делайте жёстким.Пример CLAUDE.md, settings.json, скила и субагента — файлы целиком
CLAUDE.md — коротко и по делу. Модель читает его каждый раз, лишние строки стоят токенов и размывают внимание.
# Проект: сервис заказов (Symfony 7, PHP 8.3, Postgres) ## Команды - Тесты: `make test` (один файл: `make test F=tests/Unit/PriceTest.php`) - Статанализ: `make phpstan` - Не запускай `composer update` без просьбы. ## Структура - src/Domain — бизнес-логика без Symfony - src/Infrastructure — Doctrine, HTTP-клиенты - tests/Unit, tests/Functional ## Правила - Деньги — только через Money, никаких float. - Миграции не редактируем, только новые. - После правки кода запускай тесты и phpstan.
.claude/settings.json — жёсткие разрешения. Шаблон :* означает «команда, начинающаяся с этого».
{
"permissions": {
"allow": [
"Bash(make test:*)",
"Bash(make phpstan)",
"Bash(git status)",
"Bash(git diff:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(git push:*)",
"Bash(rm -rf:*)"
]
}
}
.claude/skills/new-migration/SKILL.md — в контекст сразу попадают только name и description. Тело агент читает, когда задача подходит под описание.
--- name: new-migration description: Как создать миграцию базы данных Doctrine в этом проекте. Используй, когда задача требует новой таблицы, колонки или индекса. --- 1. Сгенерируй файл: `make migration`. 2. Проверь SQL в up() и down(): down() обязан откатывать up(). 3. Индексы на больших таблицах — только CONCURRENTLY. 4. Прогони: `make migrate && make test`.
.claude/agents/reviewer.md — субагент-ревьюер. У него свой контекст и только инструменты чтения.
--- name: reviewer description: Проверяет изменения в ветке на ошибки и нарушения правил проекта. Ничего не правит. tools: Read, Grep, Glob --- Ты ревьюер. Прочитай diff ветки и правила из CLAUDE.md. Верни список находок: файл, строка, проблема, почему это важно. Не больше 10 пунктов. Если проблем нет — так и скажи.
Контекст — главный бюджет harness
Всё, что вы добавляете в harness, конкурирует за одно окно контекста (урок 1). Поэтому хороший harness устроен по принципу прогрессивного раскрытия: в окне лежит минимум, подробности подгружаются по требованию.
- Скил стоит в контексте ~30–100 токенов (имя и описание), пока не нужен. Если бы все инструкции лежали в CLAUDE.md, каждая сессия платила бы за все сразу.
- Субагент читает 30 файлов в своём окне, а в основное возвращает отчёт на полстраницы. Основной агент остаётся «свежим».
- Описание скила или субагента — это то, по чему агент решает, звать ли его. Плохое описание — скил никогда не сработает.
Типичные ошибки конфигурации:
- CLAUDE.md на 1000 строк: важное тонет, токены горят на каждой сессии.
- Правила противоречат друг другу: «всегда пиши тесты» и «не трогай папку tests».
- Слишком широкие права:
Bash(*)в allow — агент может удалить что угодно без вопроса. - Важные запреты только словами в CLAUDE.md вместо deny-правил и хуков.
- Субагенту дали право писать файлы, хотя ему нужно только читать.
Цикл tool use в коде — JS, TS, PHP, Python, сырой HTTP
Один инструмент run_tests и цикл. Все результаты инструментов одного шага возвращаем одним сообщением user. Команду запускаем без shell (execFile, массив аргументов): модель не должна уметь дописать ; rm -rf.
import { execFileSync } from "node:child_process";
const tools = [{
name: "run_tests",
description: "Запускает PHPUnit и возвращает вывод. Вызывай после каждой правки кода.",
input_schema: {
type: "object",
properties: { path: { type: "string", description: "Файл или папка с тестами, по умолчанию tests" } },
},
}];
const handlers = {
run_tests: ({ path = "tests" }) => {
if (!/^tests[\w\/.-]*$/.test(path)) throw new Error("path должен быть внутри tests/");
try { return execFileSync("vendor/bin/phpunit", [path], { encoding: "utf8" }); }
catch (e) { return e.stdout + e.stderr; } // упавшие тесты — тоже полезный результат
},
};
async function callModel(messages) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": process.env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01" },
body: JSON.stringify({ model: "claude-opus-5-5", max_tokens: 4096, tools, messages }),
});
return res.json();
}
const messages = [{ role: "user", content: "Тест PriceTest падает. Найди причину и почини." }];
for (let step = 0; step < 20; step++) { // лимит шагов — защита от петли
const res = await callModel(messages);
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
console.log(res.content.filter(b => b.type === "text").map(b => b.text).join("\n"));
break;
}
const results = [];
for (const block of res.content.filter(b => b.type === "tool_use")) {
try {
results.push({ type: "tool_result", tool_use_id: block.id, content: await handlers[block.name](block.input) });
} catch (e) {
results.push({ type: "tool_result", tool_use_id: block.id, content: String(e.message), is_error: true });
}
}
messages.push({ role: "user", content: results }); // все результаты шага — одним сообщением
}
import { execFileSync } from "node:child_process";
type Block =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> };
interface ModelResponse { stop_reason: "end_turn" | "tool_use" | "max_tokens"; content: Block[] }
type Handler = (input: Record<string, unknown>) => string;
const handlers: Record<string, Handler> = {
run_tests: (input) => {
const path = typeof input.path === "string" ? input.path : "tests";
if (!path.startsWith("tests")) throw new Error("path должен быть внутри tests/");
return execFileSync("vendor/bin/phpunit", [path], { encoding: "utf8" });
},
};
async function runAgent(task: string, call: (m: unknown[]) => Promise<ModelResponse>): Promise<string> {
const messages: unknown[] = [{ role: "user", content: task }];
for (let step = 0; step < 20; step++) {
const res = await call(messages);
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
return res.content.flatMap(b => (b.type === "text" ? [b.text] : [])).join("\n");
}
const results = res.content.flatMap(b => {
if (b.type !== "tool_use") return [];
const handler = handlers[b.name];
try {
if (!handler) throw new Error(`Неизвестный инструмент ${b.name}`);
return [{ type: "tool_result", tool_use_id: b.id, content: handler(b.input) }];
} catch (e) {
return [{ type: "tool_result", tool_use_id: b.id, content: String(e), is_error: true }];
}
});
messages.push({ role: "user", content: results });
}
return "Остановлено: лимит шагов";
}
<?php
$tools = [[
'name' => 'run_tests',
'description' => 'Запускает PHPUnit и возвращает вывод. Вызывай после каждой правки кода.',
'input_schema' => [
'type' => 'object',
'properties' => ['path' => ['type' => 'string', 'description' => 'Файл или папка с тестами']],
],
]];
$handlers = [
'run_tests' => function (array $input): string {
$path = $input['path'] ?? 'tests';
if (!preg_match('~^tests[\w/.-]*$~', $path)) {
throw new InvalidArgumentException('path должен быть внутри tests/');
}
// escapeshellarg: модель не допишет в команду ничего своего
return (string) shell_exec('vendor/bin/phpunit ' . escapeshellarg($path) . ' 2>&1');
},
];
function callModel(array $messages, array $tools): array {
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['content-type: application/json', 'x-api-key: ' . getenv('ANTHROPIC_API_KEY'), 'anthropic-version: 2023-06-01'],
CURLOPT_POSTFIELDS => json_encode(['model' => 'claude-opus-5-5', 'max_tokens' => 4096, 'tools' => $tools, 'messages' => $messages]),
]);
return json_decode(curl_exec($ch), true, flags: JSON_THROW_ON_ERROR);
}
$messages = [['role' => 'user', 'content' => 'Тест PriceTest падает. Найди причину и почини.']];
for ($step = 0; $step < 20; $step++) {
$res = callModel($messages, $tools);
$messages[] = ['role' => 'assistant', 'content' => $res['content']];
if ($res['stop_reason'] !== 'tool_use') {
break;
}
$results = [];
foreach ($res['content'] as $block) {
if ($block['type'] !== 'tool_use') continue;
try {
$out = ($handlers[$block['name']] ?? throw new RuntimeException('Неизвестный инструмент'))($block['input']);
$results[] = ['type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => $out];
} catch (Throwable $e) {
$results[] = ['type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => $e->getMessage(), 'is_error' => true];
}
}
$messages[] = ['role' => 'user', 'content' => $results];
}
import os
import re
import subprocess
import requests
TOOLS = [{
"name": "run_tests",
"description": "Запускает pytest и возвращает вывод. Вызывай после каждой правки кода.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Файл или папка с тестами"}},
},
}]
def run_tests(path: str = "tests") -> str:
if not re.fullmatch(r"tests[\w/.-]*", path):
raise ValueError("path должен быть внутри tests/")
# список аргументов, без shell=True
proc = subprocess.run(["pytest", path, "-q"], capture_output=True, text=True)
return proc.stdout + proc.stderr
HANDLERS = {"run_tests": run_tests}
def call_model(messages):
res = requests.post(
"https://api.anthropic.com/v1/messages",
headers={"content-type": "application/json", "x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01"},
json={"model": "claude-opus-5-5", "max_tokens": 4096, "tools": TOOLS, "messages": messages},
timeout=120,
)
res.raise_for_status()
return res.json()
messages = [{"role": "user", "content": "Тест test_price падает. Найди причину и почини."}]
for step in range(20):
res = call_model(messages)
messages.append({"role": "assistant", "content": res["content"]})
if res["stop_reason"] != "tool_use":
break
results = []
for block in res["content"]:
if block["type"] != "tool_use":
continue
try:
out = HANDLERS[block["name"]](**block["input"])
results.append({"type": "tool_result", "tool_use_id": block["id"], "content": out})
except Exception as e:
results.append({"type": "tool_result", "tool_use_id": block["id"], "content": str(e), "is_error": True})
messages.append({"role": "user", "content": results})
Посмотрите на harness изнутри
Сценарии учебные, но устроены как в настоящих агентах.
Цикл агента: «почини падающий тест»
интерактив 1 из 4Нажмите «Дальше». Следите, кто действует на каждом шаге и как растёт история.
Конструктор конфигурации
интерактив 2 из 4Включайте правила и смотрите, что агент сделает с пятью просьбами. Обратите внимание, где спасает строчка в CLAUDE.md, а где только жёсткий запрет.
Какой скил загрузится
интерактив 3 из 4Агент видит только описания скилов. Напишите задачу, и посмотрите, чьё описание совпало. Это упрощённый поиск по словам, настоящая модель понимает смысл, но принцип тот же: плохое описание — скил не найдут.
Бюджет контекста: с субагентом и без
интерактив 4 из 4Задача: «найди, где в проекте отправляются письма». Нужно прочитать 14 файлов по ~3 000 токенов. Красная линия — порог, после которого модель начинает хуже держать детали.
Основной агент
Субагент (своё окно)
Песочницы с автопроверкой
Код выполняется в браузере. Ctrl+Enter — запуск. Под выводом — проверки, как в юнит-тестах.
Песочница 1. Напишите цикл агента
JSМодель здесь фейковая (fakeModel), инструменты тоже. Напишите runAgent: спросить модель, выполнить инструмент, дописать результат, повторить. Добавьте лимит шагов и обработку неизвестного инструмента.
Что уже есть в песочнице — формат сообщений
await model(messages) возвращает одно из двух:
{ stop_reason: "tool_use", tool: { name: "run_tests", input: {} } }
{ stop_reason: "end_turn", text: "Готово: ..." }
tools — объект «имя → async функция(input) → строка». Сообщения складывайте так:
{ role: "user", content: "задача" }
{ role: "assistant", tool: { name, input } } // что попросила модель
{ role: "tool", name: "run_tests", content: "..." } // что вернул инструмент
Доступны: fakeModel, loopModel (вечно просит одно и то же), badToolModel (зовёт несуществующий инструмент), makeTools().
Песочница 2. Проверка разрешений
JSНапишите упрощённую проверку правил в стиле settings.json. Правило Bash(npm test) — точное совпадение. Bash(git status:*) — команда начинается с git status (дальше пробел или конец строки). deny сильнее allow. Ничего не подошло — 'ask'.
Песочница 3. Подбор скилов по описанию
JSНапишите matchSkills(task, skills, minScore). Разбейте текст на слова, выбросьте короткие (меньше 4 букв), обрежьте каждое до первых 5 букв (грубый «корень»). Счёт скила — сколько разных корней задачи есть в его описании. Верните имена со счётом не меньше minScore, по убыванию счёта, при равенстве — по имени.
Проверьте себя
Агент «запустил тесты». Кто на самом деле выполнил команду?
Модель выдаёт tool_use — запрос с именем инструмента и аргументами. Выполняет его программа вокруг модели. Поэтому именно там проверяют права и пишут логи.
Куда записать «тесты запускаем через make test, а не через phpunit напрямую»?
Это знание о проекте, оно нужно в каждой сессии. Файл правил читается в начале каждой сессии автоматически.
Как надёжно запретить агенту читать .env с секретами?
Текстовое правило — просьба к модели, её можно нарушить. Deny-правило и хук выполняет harness, модель не может их обойти.
Зачем отдавать поиск по 30 файлам субагенту?
Токены субагента тоже стоят денег. Выигрыш в другом: основной контекст не забивается сырыми файлами, и модель лучше держит главную задачу.
Задание на своём проекте — 30 минут
- Создайте в рабочем репозитории CLAUDE.md или AGENTS.md на 20–40 строк: команды сборки и тестов, структура папок, 3–5 правил. Без воды.
- Добавьте в
.claude/settings.jsondeny-правила для.env,git pushиrm -rf. Попросите агента прочитать .env и убедитесь, что его остановили. - Найдите задачу, которую вы делаете чаще раза в неделю (новая миграция, новый эндпоинт). Опишите её как скил. Проверьте, что агент находит скил по своей формулировке задачи.
- Опишите субагента-ревьюера только с инструментами чтения. Попросите его проверить вашу последнюю ветку.
Отметьте, что усвоили
- Могу нарисовать цикл агента: модель просит, harness выполняет, результат возвращается в историю.
- Знаю, что лежит в CLAUDE.md или AGENTS.md и почему он должен быть коротким.
- Отличаю мягкое правило (текст для модели) от жёсткого (deny, хук).
- Понимаю, как работают allow, deny и «спросить человека».
- Понимаю прогрессивное раскрытие: у скила в контексте только описание.
- Знаю, когда звать субагента и какие права ему давать.
- Решил три песочницы: цикл агента, разрешения, подбор скилов.
Что почитать и где попробовать
Материалы на русском отмечены зелёной меткой. Все ссылки проверены 18 октября 2026 года.
Почитать
- Расширение Claude Codeофициальный обзор: CLAUDE.md, skills, subagents, hooks, MCP — что когда грузится в контекст и сколько стоит. рус., офиц.
- Использование инструментов с Claudeкак устроен tool use (вызов функций): описание инструмента, JSON-схема, цикл «запрос → вызов → результат». рус., офиц.
- Что такое Harness и с какими моделями его едят?что такое обвязка (harness) вокруг модели и почему она важна не меньше самой модели. рус.
- Как написать хороший CLAUDE.md, чтобы не было мучительно больнопочему агент игнорирует правила и как писать короткий рабочий CLAUDE.md. рус.
- Claude Code: субагентывводный разбор субагентов: зачем отдельный контекст и как их настраивать. рус.
Песочницы по теме
- JSON Schema Validatorописать параметры инструмента JSON-схемой и проверить, пройдут ли аргументы, которые «придумала» модель.
- Tiktokenizerвставить свой CLAUDE.md и посмотреть, сколько токенов он съедает в каждой сессии.
Запустить код из урока
- TypeScript PlaygroundJS и TS из примеров урока: вставить и запустить кнопкой Run.
- 3v4l.orgPHP-примеры урока на любой версии PHP.
- JupyterLitePython-примеры урока прямо в браузере, без установки.