Klepa AI
Установить
Часть 2 · Агент в вашем проекте · Урок 2

Агент — это цикл: инструменты, правила, скилы и субагенты

Модель умеет только писать текст. Читать файлы, запускать тесты и не трогать прод её учит среда вокруг. Разберём, из чего эта среда состоит и как её настроить.

~50 минут 4 интерактива 3 песочницы с проверками Примеры: JS · TS · PHP · Python

После урока вы сможете

  • объяснить цикл агента: кто думает, а кто выполняет;
  • написать короткий и полезный файл правил проекта (CLAUDE.md или AGENTS.md);
  • отличить мягкое правило от жёсткого запрета и настроить разрешения;
  • понять, когда нужен скил, а когда субагент, и как они экономят контекст.
1 Аналогия

Сильный новичок в команде

Представьте

К вам пришёл очень умный новичок. Он знает все языки, но не знает ваш проект. Чтобы он приносил пользу, ему дают онбординг, доступы, рабочие инструменты, инструкции и помощников.

Что нужно новичку, чтобы работать

анимация
Умный новичокмодель
+
Онбординг-докCLAUDE.md, AGENTS.md
+
Доступыpermissions
+
Инструментыtools
+
Инструкцииskills
+
Помощникиsubagents

Нажмите «Дальше», чтобы собрать рабочее место по шагам.

Шаг 0 из 6
Онбординг-документ= файл правил проекта: команды, структура, запреты
Доступы и пропуск= разрешения: что можно без спроса, что нельзя никогда
Рабочие инструменты= tools: читать файл, запустить тесты, поиск
Инструкция «как делаем релиз»= skill: подробности, которые читают только когда нужно
Помощник-стажёр= subagent: делает часть работы и приносит короткий отчёт

Всё это вместе — harness («упряжь»). Одна и та же модель в хорошем и плохом harness работает как два разных инженера.

2 Объяснение

От простого к сложному

Уровень 1 · Суть

Агент — это модель в цикле с инструментами

Модель ничего не запускает сама. Она только пишет запрос: «вызови инструмент run_tests». Такой запрос называют tool_use. Программа вокруг модели (harness) выполняет его, дописывает результат в историю и снова зовёт модель. Цикл идёт, пока модель не скажет «я закончила».

messages = [задача]
while (true) {
  ответ = model(messages, tools)          // модель думает
  if (ответ.stop_reason === "end_turn") break
  результат = выполнить(ответ.tool_use)   // harness делает
  messages.push(ответ, результат)         // история растёт
}
ГлавноеМодель решает, что сделать. Harness решает, можно ли это делать, и делает. Вся безопасность и качество живут во втором.
Уровень 2 · Детали

Из чего состоит 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. Отдельный агент со своим окном контекста и своим набором инструментов.
  • Управление контекстом — сжатие старой истории, кэш, вынос тяжёлой работы в субагентов.
Мягкое и жёсткое правилоСтрока в CLAUDE.md «не читай .env» — это просьба к модели. Обычно её выполняют, но гарантии нет. Правило 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 пунктов. Если проблем нет — так и скажи.
Уровень 3 · Усложнение

Контекст — главный бюджет 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})
3 Интерактив

Посмотрите на harness изнутри

Сценарии учебные, но устроены как в настоящих агентах.

Цикл агента: «почини падающий тест»

интерактив 1 из 4
Выставите задачу
→
Модельрешает, что делать
⇄
Harnessпроверяет права и выполняет
⇄
Инструментфайлы, тесты, git

Нажмите «Дальше». Следите, кто действует на каждом шаге и как растёт история.

Шаг 0 из 10
История: 0 токенов · вызовов модели: 0

    Конструктор конфигурации

    интерактив 2 из 4

    Включайте правила и смотрите, что агент сделает с пятью просьбами. Обратите внимание, где спасает строчка в CLAUDE.md, а где только жёсткий запрет.

    CLAUDE.md (просьбы)
    allow (без вопросов) deny (никогда)
    Режим для остального

    Режим без вопросов удобен, но всё, что не запрещено явно, агент сделает сам.

    Какой скил загрузится

    интерактив 3 из 4

    Агент видит только описания скилов. Напишите задачу, и посмотрите, чьё описание совпало. Это упрощённый поиск по словам, настоящая модель понимает смысл, но принцип тот же: плохое описание — скил не найдут.

    Бюджет контекста: с субагентом и без

    интерактив 4 из 4

    Задача: «найди, где в проекте отправляются письма». Нужно прочитать 14 файлов по ~3 000 токенов. Красная линия — порог, после которого модель начинает хуже держать детали.

    Основной агент

    порог
    0 токенов

    Субагент (своё окно)

    порог
    не используется

    4 Практика

    Песочницы с автопроверкой

    Код выполняется в браузере. 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 минут
    1. Создайте в рабочем репозитории CLAUDE.md или AGENTS.md на 20–40 строк: команды сборки и тестов, структура папок, 3–5 правил. Без воды.
    2. Добавьте в .claude/settings.json deny-правила для .env, git push и rm -rf. Попросите агента прочитать .env и убедитесь, что его остановили.
    3. Найдите задачу, которую вы делаете чаще раза в неделю (новая миграция, новый эндпоинт). Опишите её как скил. Проверьте, что агент находит скил по своей формулировке задачи.
    4. Опишите субагента-ревьюера только с инструментами чтения. Попросите его проверить вашу последнюю ветку.
    5 Чек-лист

    Отметьте, что усвоили

    • Могу нарисовать цикл агента: модель просит, harness выполняет, результат возвращается в историю.
    • Знаю, что лежит в CLAUDE.md или AGENTS.md и почему он должен быть коротким.
    • Отличаю мягкое правило (текст для модели) от жёсткого (deny, хук).
    • Понимаю, как работают allow, deny и «спросить человека».
    • Понимаю прогрессивное раскрытие: у скила в контексте только описание.
    • Знаю, когда звать субагента и какие права ему давать.
    • Решил три песочницы: цикл агента, разрешения, подбор скилов.
    Урок пройденТеперь у агента есть рабочее место. Дальше дадим ему память о проекте: что знать заранее и как это хранить.