
Самый цитируемый текст про кодинг-агентов, «How to Build an Agent» Торстена Балла, прекрасен и вреден одновременно. Прекрасен, потому что снимает мистику: агент это LLM, цикл и достаточно токенов, 400 строк, и модель уже правит ваши файлы. Вреден, потому что оставляет ощущение, что дело сделано. А это примерно одна десятая пути.
Я проверил на себе. Прошёл туториал за вечер, порадовался сгенерированному fizzbuzz.js, а через два дня выкинул агента и больше к нему не возвращался. Игрушка перезаписывала файлы без предпросмотра, не умела искать по коду и захлёбывалась контекстом на третьей серьёзной задаче. Однажды она предложила выполнить команду, после которой я на всякий случай проверил бэкапы. Доверять такому рабочий репозиторий не станет никто в здравом уме, и в этом честном признании нет ничего обидного для Балла: он и не обещал инструмент, он показывал принцип.
Меня же интересуют оставшиеся девять десятых: конкретные инженерные решения, которые отличают демо от Claude Code, Aider и Amp. В этой статье мы соберём агента на Python с нуля и по одному добавим их: поиск по коду, диффы с подтверждением, модель разрешений для shell, чекпоинты через git, управление контекстом и планирование. Получится чуть больше 400 строк, весь код рабочий, полная версия лежит в репозитории (ссылка в конце).
Сразу оговорка. Это не «убийца Claude Code» и не продукт. Это учебный проект, который честно показывает, из каких решений складывается настоящий агент. Каждое из них по отдельности скучное. Вместе они меняют всё.
Почему все агенты устроены одинаково
Если снять корпус с любого кодинг-агента, внутри окажется одно и то же. Модель получает историю диалога и список инструментов с JSON-схемами. Отвечает либо текстом, либо запросом «вызови мне вот этот инструмент с вот этими аргументами». Ваш код выполняет вызов, кладёт результат обратно в историю и снова дёргает модель. И так по кругу, пока модель не решит, что закончила.
Всё. Инструменты у неё простые: прочитать файл, поправить файл, выполнить команду. Способность «выпутываться из ошибок» и «пробовать разные стратегии», которая так впечатляет со стороны, живёт не в вашем коде, а в весах модели. Ваша задача скромнее: не мешать ей и вовремя подстраховывать.
Отсюда следует важный вывод, который я хочу зафиксировать до того, как мы напишем первую строчку. Качество агента определяется не хитростью цикла, а качеством обратной связи, которую получает модель. Понятные ошибки инструментов вместо падений процесса. Результаты поиска вместо чтения файлов наугад. Дифф вместо «файл перезаписан». Практически все улучшения в этой статье сводятся к одному: сделать среду, в которой модели удобно работать, а человеку не страшно.
Архитектура
Прежде чем нырять в код, посмотрим на систему целиком.

Слева пользователь и терминальный REPL. В центре агентный цикл, который держит историю диалога и гоняет её в Claude API. К циклу прикручены два контура: Context Manager следит, чтобы история не разрослась до отказа, а системный промпт при старте впитывает память проекта из файла AGENT.md.
Внизу реестр инструментов. Их шесть: grep и glob_files для поиска, read_file для чтения, edit_file для правок, run_command для shell и update_plan для декомпозиции задач. Два из них, edit_file и run_command, проходят через Permission Gate: любое изменение файла показывается диффом и требует подтверждения, а команды вне белого списка спрашивают разрешения. Справа чекпоинты: перед каждой правкой агент делает git-коммит, и команда /undo откатывает всё одним движением.
Реестр инструментов: схема из аннотаций, а не JSON руками
В туториале Балла каждый инструмент описывается вручную: имя, описание, JSON-схема аргументов. Для трёх инструментов терпимо. Для шести уже больно, а когда вы начнёте экспериментировать (а вы начнёте), ручные схемы превратятся в источник глупых багов: переименовали аргумент в функции, забыли в схеме, и модель час зовёт инструмент со старым именем параметра.
Python позволяет решить это красиво. У нас уже есть всё для описания инструмента: сигнатура функции с аннотациями типов и docstring. Осталось попросить Pydantic собрать из этого JSON-схему:
import inspect from dataclasses import dataclass from typing import Callable from pydantic import create_model @dataclass class Tool: name: str description: str input_schema: dict handler: Callable REGISTRY: dict[str, Tool] = {} def tool(fn: Callable) -> Callable: """Декоратор: превращает обычную функцию в инструмент агента.""" sig = inspect.signature(fn) fields = {} for pname, param in sig.parameters.items(): annotation = param.annotation if annotation is inspect.Parameter.empty: annotation = str default = param.default if default is inspect.Parameter.empty: default = ... fields[pname] = (annotation, default) schema = create_model(f"{fn.__name__}_input", **fields).model_json_schema() schema.pop("title", None) REGISTRY[fn.__name__] = Tool( name=fn.__name__, description=inspect.getdoc(fn) or "", input_schema=schema, handler=fn, ) return fn
Теперь новый инструмент это обычная функция с декоратором:
@tool def glob_files(pattern: str) -> str: """Найти файлы по glob-шаблону, например '**/*.py'. Результат отсортирован по дате изменения, свежие сверху.""" hits = [ p for p in WORKDIR.glob(pattern) if p.is_file() and ".git" not in p.parts ] hits.sort(key=lambda p: p.stat().st_mtime, reverse=True) listing = "\n".join(str(p.relative_to(WORKDIR)) for p in hits[:200]) return listing or "ничего не найдено"
Обратите внимание на две мелочи, которые на самом деле не мелочи.
Первая: docstring здесь пишется не для коллег, а для модели. Это часть промпта. «Результат отсортирован по дате изменения, свежие сверху» это не документация, а подсказка: если модель ищет «тот файл, который недавно правили», она поймёт, что достаточно посмотреть на верх списка.
Вторая: сортировка по mtime. Я подсмотрел этот приём в поведении Claude Code и сначала не понял, зачем он. А потом заметил на своих сессиях: когда пользователь говорит «продолжи то, что мы делали», недавно изменённые файлы почти всегда и есть контекст задачи. Одна строчка с sort, а попаданий в нужный файл заметно больше.
Поиск: агент, который не умеет в grep, читает вслепую
Это, пожалуй, главное функциональное отличие от агента из туториала. Там модель могла только получить список файлов и читать их целиком. На проекте из пяти файлов работает. На проекте из пяти сотен файлов агент либо сдаётся, либо начинает читать всё подряд, сжигая контекст со скоростью полного бака на светофорах.
Настоящие агенты тратят большую часть вызовов инструментов на поиск. Дадим нашему то же самое:
import re @tool def grep(pattern: str, glob: str = "**/*") -> str: """Поиск регулярного выражения по содержимому файлов проекта. Возвращает строки в формате файл:номер:текст.""" rx = re.compile(pattern) out: list[str] = [] for p in WORKDIR.glob(glob): if not p.is_file() or ".git" in p.parts: continue try: text = p.read_text(encoding="utf-8", errors="ignore") except OSError: continue for n, line in enumerate(text.splitlines(), 1): if rx.search(line): out.append(f"{p.relative_to(WORKDIR)}:{n}: {line.strip()}") if len(out) >= 100: return "\n".join(out) + "\n... слишком много совпадений, уточни запрос" return "\n".join(out) or "совпадений нет"
Реализация нарочито наивная: построчный проход на чистом Python. В продакшне вы позовёте ripgrep через subprocess, он быстрее на порядки. Но интерфейс важнее реализации, и в интерфейсе спрятаны три решения.
Формат «файл:номер:текст» модель знает наизусть, потому что видела миллионы выводов grep в обучающих данных. Не изобретайте свой формат, используйте тот, на котором модель выросла.
Лимит в 100 совпадений с честным сообщением «уточни запрос» это защита контекста. Регулярка вида def на большом проекте вернёт тысячи строк, и без лимита один неудачный поиск съест десятки тысяч токенов. Заметьте: сообщение об обрезке адресовано модели, и она реально реагирует, сужая паттерн.
И третье: errors="ignore" при чтении. Бинарники и файлы в экзотических кодировках не должны ронять поиск. Ошибка инструмента, которая валит весь процесс, это худшее, что может случиться с агентом посреди длинной задачи.
Тот же принцип пагинации я применил и к read_file: аргументы offset и limit плюс приписка «в файле N строк, показано не всё». Модель прекрасно умеет листать, если ей сказать, что есть что листать.
Правки: дифф, подтверждение и правило одного вхождения
Механика редактирования у нас та же, что у Балла и у самого Claude Code: замена строки old_str на new_str. Модели любят этот формат, он надёжнее генерации патчей в формате unified diff (с ними модели путаются в номерах строк) и экономнее полной перезаписи файла.
Но вокруг этой механики мы построим то, чего в туториале не было: предпросмотр и явное согласие человека.
import difflib def show_diff(old: str, new: str, path: str) -> str: diff = difflib.unified_diff( old.splitlines(keepends=True), new.splitlines(keepends=True), fromfile=f"a/{path}", tofile=f"b/{path}", ) return "".join(diff) def approve(question: str) -> bool: answer = input(f"\n\033[91m{question}\033[0m [y/N] ").strip().lower() return answer in ("y", "yes", "д", "да") @tool def edit_file(path: str, old_str: str, new_str: str) -> str: """Заменить фрагмент old_str на new_str в файле. old_str обязан встречаться в файле ровно один раз, включая отступы. Пустой old_str создаёт новый файл с содержимым new_str.""" target = resolve(path) if old_str == "": if target.exists(): return "ошибка: файл уже существует, передай непустой old_str" print(show_diff("", new_str, path)) if not approve(f"Создать {path}?"): return "пользователь отклонил создание файла" checkpoint(f"создание {path}") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(new_str, encoding="utf-8") return "файл создан" text = target.read_text(encoding="utf-8") count = text.count(old_str) if count != 1: return (f"ошибка: old_str найден {count} раз(а), нужен ровно один. " "Возьми фрагмент побольше, чтобы он стал уникальным") new_text = text.replace(old_str, new_str, 1) print(show_diff(text, new_text, path)) if not approve(f"Применить правку к {path}?"): return "пользователь отклонил правку, спроси его, что не так" checkpoint(f"правка {path}") target.write_text(new_text, encoding="utf-8") return "правка применена"
Разберу решения, которые здесь зашиты.
Правило «ровно одно вхождение» защищает от самой коварной ошибки string replacement: old_str встречается в файле дважды, и замена уходит не туда. Причём сообщение об ошибке не просто констатирует факт, а подсказывает модели выход: возьми фрагмент побольше. Это работает безотказно, модель расширяет old_str на пару соседних строк и повторяет вызов.
Текст «пользователь отклонил правку, спроси его, что не так» тоже промпт-инженерия, спрятанная в возвращаемом значении. Без этой фразы модель, получив отказ, часто пыталась применить ту же правку ещё раз, слегка переформулировав. С фразой она поворачивается к пользователю и спрашивает, что исправить. Возвращаемые значения инструментов это канал управления поведением модели, пользуйтесь им.
И функция resolve, о которой я пока не рассказал:
def resolve(path: str) -> Path: """Не даём агенту выйти за пределы рабочей директории.""" p = (WORKDIR / path).resolve() if not p.is_relative_to(WORKDIR): raise ValueError(f"путь {path!r} выходит за пределы проекта") return p
Три строки, которые гарантируют, что ни ../../etc/hosts, ни абсолютный путь в домашнюю директорию через агента не пройдут. Модель почти никогда не пытается, но «почти» в вопросах записи на диск меня не устраивает.
Чекпоинты: git как машина времени за десять строк
Подтверждение диффа спасает от плохих правок, которые вы заметили. А от плохих правок, которые вы заметили через двадцать минут? Согласились на серию изменений, запустили тесты, посмотрели внимательнее и поняли, что агент увёл рефакторинг не туда. Руками откатывать восемь правок в пяти файлах удовольствие ниже среднего.
Решается смешно дёшево, если проект и так живёт в git:
import subprocess def git(*args: str) -> str: res = subprocess.run( ["git", *args], capture_output=True, text=True, cwd=WORKDIR ) return (res.stdout + res.stderr).strip() def checkpoint(label: str) -> None: """Снимок состояния проекта перед каждым изменением.""" if not (WORKDIR / ".git").exists(): return git("add", "-A") git("commit", "--no-verify", "--allow-empty", "-m", f"kodo: чекпоинт перед '{label}'")
Перед каждой правкой агент коммитит текущее состояние. В REPL живёт команда /undo, которая делает git reset --hard HEAD и возвращает рабочую директорию к последнему чекпоинту. История коммитов сессии заодно превращается в журнал действий агента, по которому потом интересно разбирать, что и в каком порядке он делал.
Да, это загрязняет историю ветки. Для учебного агента осознанный компромисс. В Claude Code, к слову, чекпоинты устроены аккуратнее и не трогают вашу историю коммитов, но идея ровно та же: дёшево снимать состояние перед каждым необратимым действием. Если захотите чистое решение, смотрите в сторону git stash create (он создаёт коммит-снимок, не двигая ветку) или отдельного теневого репозитория.
Психологический эффект от /undo я недооценивал, пока не пожил с ним неделю. Когда откат стоит одну команду, начинаешь позволять агенту больше: пусть попробует смелый вариант, откатить недолго. Доверие к инструменту строится не на его безошибочности, а на цене его ошибки.
Shell: самый мощный и самый опасный инструмент
Без выполнения команд агент не может запустить тесты, а значит не может проверить собственную работу. Агент, который не проверяет свою работу, это генератор правдоподобных диффов, не более. Так что run_command обязателен. И одновременно это единственный инструмент, через который агент способен натворить настоящих бед.
Мой подход: три уровня защиты.
SAFE_PREFIXES = ( "ls", "cat", "head", "tail", "wc", "file", "du", "git status", "git diff", "git log", "git show", "python", "pytest", "pip list", "pip show", "node", "npm test", "npm run", ) BLOCKED_MARKERS = ("sudo", "rm -rf /", "mkfs", "shutdown", "reboot", ":(){") @tool def run_command(command: str, timeout: int = 60) -> str: """Выполнить shell-команду в директории проекта. Запускай тесты и линтеры после правок. Не запускай интерактивные команды и долгие процессы вроде dev-серверов.""" if any(marker in command for marker in BLOCKED_MARKERS): return "команда заблокирована политикой безопасности" if not command.strip().startswith(SAFE_PREFIXES): if not approve(f"Агент хочет выполнить: {command}\nРазрешить?"): return "пользователь запретил выполнение этой команды" try: res = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=timeout, cwd=WORKDIR, ) except subprocess.TimeoutExpired: return f"команда не уложилась в {timeout} секунд и была остановлена" out = (res.stdout + res.stderr).strip() if len(out) > OUTPUT_LIMIT: half = OUTPUT_LIMIT // 2 out = out[:half] + "\n... середина вывода обрезана ...\n" + out[-half:] return f"код возврата: {res.returncode}\n{out or '(пустой вывод)'}"
Чёрный список ловит очевидно разрушительное. Белый список префиксов пропускает без вопросов то, что заведомо только читает или запускает тесты, иначе вы задолбаетесь жать y на каждый git status. Всё остальное идёт через подтверждение человеком. Это ровно та модель, которую вы видите в Claude Code: часть команд выполняется молча, часть спрашивает разрешения, и со временем allowlist можно расширять под свои привычки.
Буду честен: белый список по префиксам это защита от добросовестной модели, а не от атакующего. python -c "import shutil; ..." проходит по префиксу python и может сделать что угодно. Настоящая изоляция это контейнер или отдельная виртуалка, и для агента, который работает автономно без человека у терминала, она обязательна. Для интерактивного ассистента, где вы видите каждую команду на экране, компромисс «allowlist плюс подтверждение» практичен. Главное, понимать, от чего именно вы защищаетесь.
Ещё две детали, обе выстраданы. Таймаут: без него первая же команда npm run dev повесит агента навсегда, потому что dev-сервер не завершается. И обрезка вывода с середины, а не с конца: в выводе упавших тестов самое ценное живёт в начале (какие тесты упали) и в конце (итоговая сводка), а середину с трейсбеками часто можно пожертвовать. Возврат кода завершения отдельной строкой тоже важен: модель по нему мгновенно понимает, успех это был или провал, не вчитываясь в вывод.
План: инструмент, который ничего не делает, но всё меняет
А вот моё любимое. Инструмент, у которого нет никакого эффекта, кроме печати на экран:
@tool def update_plan(steps: list[str], done: list[int] = []) -> str: """Обновить план работы. steps: список шагов, done: индексы выполненных шагов, считая с нуля. Вызывай перед началом сложной задачи и после каждого завершённого шага.""" lines = [ ("[x] " if i in done else "[ ] ") + step for i, step in enumerate(steps) ] print("\n\033[95mплан\033[0m:\n" + "\n".join(lines)) return "план обновлён, продолжай"
Зачем он нужен, если состояние нигде не сохраняется? Затем, что сам акт вызова заставляет модель сформулировать план текстом, и этот текст остаётся в истории диалога. На многошаговых задачах это меняет поведение радикально. Без плана модель делает первые два шага, увлекается починкой попутного бага и забывает, зачем всё начиналось. С планом она возвращается к списку, видит незакрытые пункты и доводит дело до конца. По сути мы дали модели внешнюю рабочую память для намерений, а пользователю прогресс-бар в подарок.
Это тот же приём, что todo-список в Claude Code. Когда я впервые увидел его там, подумал: украшательство для UI. Теперь считаю одним из самых недооценённых механизмов надёжности агентов.
Системный промпт и память проекта
Системный промпт нашего агента короткий, и каждая строчка в нём появилась после конкретного фейла:
def build_system_prompt() -> str: base = ( "Ты кодинг-агент kodo, работаешь в терминале в директории проекта.\n" "Правила:\n" "1. Сначала изучи код: ищи через grep и glob_files, читай через " "read_file. Не правь то, чего не видел.\n" "2. Для задач из нескольких шагов сразу составь план через " "update_plan и отмечай прогресс после каждого шага.\n" "3. Правки делай минимальными. Стиль проекта важнее твоих привычек.\n" "4. После правок запускай тесты, если они есть в проекте.\n" "5. Если пользователь отклонил правку, уточни у него причину, " "а не повторяй то же самое.\n" "6. Отвечай кратко и по делу, без пересказа своих же действий." ) memory = WORKDIR / "AGENT.md" if memory.exists(): base += ("\n\nПамять проекта из AGENT.md, учитывай её:\n" + memory.read_text(encoding="utf-8")) return base
«Не правь то, чего не видел» лечит любимую болезнь моделей: уверенно редактировать файл по памяти из обучающих данных, хотя в вашем проекте он выглядит иначе. «Стиль проекта важнее твоих привычек» появился после того, как агент в кодовой базе с snake_case старательно написал новый модуль в camelCase, потому что «так принято в JavaScript».
Файл AGENT.md это прямой аналог CLAUDE.md: место, куда вы складываете знания о проекте, которые агент не должен переоткрывать каждую сессию. Как запускать тесты, какие директории не трогать, почему модуль legacy_billing нельзя рефакторить до конца квартала. Пять минут на написание такого файла окупаются в первой же сессии. Это самый дешёвый способ «дообучить» агента под проект: никакого файнтюнинга, просто текстовый файл в корне репозитория.
Управление контекстом: вопрос выживания, а не оптимизации
Теперь о том, обо что разбиваются все туториальные агенты без исключения. История диалога растёт с каждым ходом, и растёт быстро: один прочитанный файл это тысячи токенов, один вывод pytest ещё столько же. Контекстное окно у современных моделей большое, но конечное, а главное, задолго до жёсткого лимита начинает деградировать качество: модели хуже удерживают внимание на распухшем контексте, и это подтверждается как бенчмарками, так и моим личным опытом наблюдения за агентом, который на 150-й тысяче токенов начинает переспрашивать то, что сам выяснил час назад.
Первая линия обороны у нас уже построена, и вы её видели: лимиты везде. Обрезка вывода команд, потолок в 100 совпадений у grep, пагинация в read_file. Дешевле не пустить мусор в контекст, чем потом его вычищать.
Вторая линия это компактификация: когда история распухла, сжимаем её старую часть в конспект силами самой модели.
def compact(self) -> None: """Сжимает старую часть истории в конспект.""" if len(self.history) < 8: return head, tail = self.history[:-4], self.history[-4:] digest = self.client.messages.create( model=MODEL, max_tokens=2000, messages=head + [{ "role": "user", "content": ( "Сожми наш диалог в конспект для продолжения работы: " "цель, что уже сделано, какие файлы и как затронуты, " "что осталось, какие были договорённости. Только факты." ), }], ) summary = "".join(b.text for b in digest.content if b.type == "text") self.history = [ {"role": "user", "content": f"[конспект предыдущей части сессии]\n{summary}"}, {"role": "assistant", "content": "Принял конспект, продолжаю с этого места."}, ] + tail print("\033[90m[история сжата в конспект]\033[0m")
Хвост из последних сообщений сохраняем как есть: свежий контекст терять нельзя, модель прямо сейчас с ним работает. Всё, что старше, превращается в структурированный конспект. Именно так устроен autocompact в Claude Code, и когда вы видите там строчку про сжатие контекста, под капотом происходит ровно это.
Здесь закопаны грабли, на которые я наступил со всей дури, и хочу, чтобы вы через них перешагнули. Резать историю можно только на границе хода пользователя, когда последний ответ ассистента завершён. Если обрезать посреди цикла инструментов, в истории окажется блок tool_use без парного tool_result, и API отвергнет запрос. Звучит очевидно, когда знаешь, но отладка этого бага стоила мне вечера, потому что проявлялся он только на длинных сессиях. Поэтому в нашем агенте проверка на компактификацию стоит в начале turn, а не внутри цикла:
def turn(self, user_input: str) -> None: if self.last_input_tokens > CONTEXT_BUDGET: self.compact() self.history.append({"role": "user", "content": user_input}) ...
Порог мы узнаём бесплатно: каждый ответ API несёт usage.input_tokens, точный счётчик размера того, что мы отправили. Никаких эвристик с подсчётом символов.
Собираем ядро
Сам агентный цикл после всего сказанного выглядит почти скучно, и это правильно. Вся сложность вынесена в инструменты и контуры вокруг:
class Agent: def __init__(self, client: anthropic.Anthropic): self.client = client self.system = build_system_prompt() self.history: list[dict] = [] self.last_input_tokens = 0 def call_model(self): reply = self.client.messages.create( model=MODEL, max_tokens=MAX_TOKENS, system=self.system, messages=self.history, tools=self.tool_specs(), ) self.last_input_tokens = reply.usage.input_tokens return reply def run_tool(self, name: str, args: dict) -> str: spec = REGISTRY.get(name) if spec is None: return f"неизвестный инструмент: {name}" print(f"\033[92m→ {name}\033[0m " f"{json.dumps(args, ensure_ascii=False)[:200]}") try: return str(spec.handler(**args)) except Exception as exc: return f"ошибка инструмента {name}: {exc}" def turn(self, user_input: str) -> None: if self.last_input_tokens > CONTEXT_BUDGET: self.compact() self.history.append({"role": "user", "content": user_input}) while True: reply = self.call_model() self.history.append( {"role": "assistant", "content": reply.content} ) results = [] for block in reply.content: if block.type == "text" and block.text.strip(): print(f"\n\033[93mkodo\033[0m: {block.text}") elif block.type == "tool_use": results.append({ "type": "tool_result", "tool_use_id": block.id, "content": self.run_tool(block.name, block.input), }) if not results: return self.history.append({"role": "user", "content": results})
Обратите внимание на try/except в run_tool. Исключение инструмента не роняет процесс, а уходит модели текстом. Это принципиально: модель отлично читает сообщения об ошибках и меняет стратегию. Файл не найден? Поищет через glob_files. Регулярка не скомпилировалась? Перепишет. Упавший процесс агента, наоборот, не умеет ничего. За время тестов я не раз наблюдал, как агент, получив «ошибка инструмента: [Errno 2] No such file or directory», сам догадывался, что путь устарел после переименования, находил новый и продолжал как ни в чём не бывало.
И ещё одна деталь, легко ускользающая: цикл собирает все tool_use блоки из ответа, а не первый попавшийся. Модель может запросить несколько инструментов за один ход, например два grep по разным паттернам, и все результаты должны вернуться одним сообщением с парными tool_use_id. Перепутаете соответствие, получите ошибку API. Забудете один результат, тоже получите.
REPL дописывается за минуту, он есть в полной версии кода вместе с обработкой /undo.
Полевые испытания
Синтетический fizzbuzz нашему агенту неинтересен, поэтому я устроил проверку жёстче. Взял свой pet-проект, парсер банковских выписок примерно на 2500 строк, и руками внёс туда баг: в функции разбора дат сломал обработку декабря (классическая ошибка на единицу в индексе месяца). Задача агенту формулировалась одной строкой: «в проекте где-то неправильно парсятся некоторые даты, найди причину, почини и покрой регрессионным тестом».
Дальше привожу сокращённый лог сессии, полный лежит в репозитории:
вы: в проекте где-то неправильно парсятся некоторые даты, найди причину, почини и покрой регрессионным тестом план: [ ] найти код, отвечающий за парсинг дат [ ] запустить существующие тесты, посмотреть на падения [ ] локализовать и исправить ошибку [ ] написать регрессионный тест и убедиться, что он проходит → grep {"pattern": "date|Date", "glob": "**/*.py"} → read_file {"path": "parser/dates.py"} → run_command {"command": "pytest tests/ -x -q"} kodo: Существующие тесты проходят, значит кейс с багом ими не покрыт. В parse_stmt_date вижу подозрительное: индекс месяца вычисляется как MONTHS.index(token) + 1, но список MONTHS содержит 11 элементов, "dec" в нём отсутствует. Декабрьские даты должны падать в ValueError и уходить в ветку fallback с молчаливой подменой на None. → edit_file {"path": "parser/dates.py", "old_str": "MONTHS = [... --- a/parser/dates.py +++ b/parser/dates.py @@ -14,7 +14,7 @@ -MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", - "jul", "aug", "sep", "oct", "nov"] +MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", + "jul", "aug", "sep", "oct", "nov", "dec"] Применить правку к parser/dates.py? [y/N] y → edit_file {"path": "tests/test_dates.py", "old_str": "", ...} Создать tests/test_dates.py? [y/N] y → run_command {"command": "pytest tests/test_dates.py -q"} план: [x] найти код, отвечающий за парсинг дат [x] запустить существующие тесты, посмотреть на падения [x] локализовать и исправить ошибку [x] написать регрессионный тест и убедиться, что он проходит kodo: Причиной был неполный список MONTHS без декабря, из-за чего декабрьские даты молча превращались в None. Исправил список и добавил тест на все 12 месяцев плюс отдельный кейс на декабрь. Все тесты проходят.
Что здесь важно, помимо того, что баг найден и починен. Агент сначала искал, потом читал, потом запускал тесты, и только затем правил, ровно в том порядке, который заложен в системном промпте. Он сам заметил, что существующие тесты баг не ловят, и сделал из этого правильный вывод. Каждое изменение прошло через дифф у меня на экране. И когда на одной из предыдущих попыток я отклонил его тест (он проверял приватную функцию напрямую, а я хотел тест через публичный API), агент не стал упираться, а спросил, какой подход я предпочитаю. Та самая строчка из возвращаемого значения edit_file отработала.
Не буду рисовать идиллию: агент не безгрешен. На другой задаче он однажды зациклился на паре «правка, падающий тест, правка», пока я не остановил его и не подсказал, что проблема в фикстуре, а не в коде. Лимит итераций цикла с принудительной передачей слова пользователю это, пожалуй, первое, что стоит добавить сверх нашей версии.
Что осознанно осталось за кадром
Несколько вещей, без которых статья не была бы честной, но с которыми она распухла бы вдвое.
Субагенты. Когда задача требует прочитать половину проекта, выгоднее породить дочернего агента с чистым контекстом, дать ему исследовательскую подзадачу и получить назад только выжимку. Родительский контекст остаётся чистым. В нашей архитектуре это делается удивительно просто: субагент это ещё один инструмент, внутри которого живёт второй экземпляр класса Agent с урезанным набором инструментов (только чтение и поиск, без правок).
MCP, Model Context Protocol. Открытый стандарт подключения внешних инструментов: базы данных, браузеры, трекеры задач. Наш REGISTRY по духу и есть крошечный локальный MCP, так что миграция на стандарт при желании почти механическая.
Кеширование промптов. В агентных сессиях каждый запрос повторяет всю историю, и без prompt caching вы платите за одни и те же токены десятки раз. Включение кеша в Anthropic API снижает стоимость длинных сессий в разы, и для реального ежедневного использования это не опция, а необходимость.
Стриминг. Наш агент молчит, пока модель думает, и на длинных ответах это раздражает. Стриминговый API решает проблему, но заметно усложняет разбор tool_use блоков, поэтому в учебной версии я его сознательно выкинул.
Выводы
Возвращаюсь к тезису из начала. Балл был прав: секрета нет, агент это LLM, цикл и токены. Вредна не сама эта правда, а точка, поставленная после неё. Потому что у правды есть вторая половина, и она интереснее первой: разница между демо на 400 строк и инструментом, которому доверяешь рабочий репозиторий, состоит из десятка решений, каждое из которых по отдельности выглядит скучной санитарией. Лимит на вывод команды, правило одного вхождения, коммит перед правкой, конспект вместо распухшей истории, подсказка модели в тексте ошибки.
Ни одно из них не требует гениальности. Все вместе они требуют того самого elbow grease, о котором писал Балл, и понимания одной простой вещи: вы проектируете не программу, а среду обитания для модели. Чем честнее обратная связь в этой среде, тем умнее выглядит её обитатель.
Полный код (412 строк, Python 3.11+, из зависимостей только anthropic и pydantic) лежит в репозитории: https://github.com/olegshevt/article-code-agent. Клонируйте, ломайте, прикручивайте своё. И расскажите в комментариях, какой инструмент вы бы добавили следующим: у меня в очереди lint_on_save и субагент-ревьюер, который вычитывает диффы основного агента перед показом человеку.
