Пишем кодинг‑агента на Swift с нуля: неочевидные сложности очевидной идеи

от автора

Я долго пользовался разными кодинг‑агентами, и на их фоне Claude Code для меня заметно выделялся: качеством решений, удобством работы и вниманием к деталям. В какой‑то момент мне захотелось не просто пользоваться таким инструментом, а понять, что на самом деле происходит у него под капотом. Так я сел писать собственного агента на Swift, с нуля, без использования готовых решений.

Довольно быстро стало понятно, что сложность не в том, чтобы вызвать модель и попросить ее сгенерировать код. Настоящая сложность начинается там, где система должна стабильно работать: удерживать контекст, пользоваться инструментами, справляться с ошибками и непредсказуемыми ответами модели. На обвязку вокруг модели и уходит почти все время.

Дальше я разберу места, где это проявляется: от устройства главного цикла до управления контекстом. Многие выводы задним числом кажутся очевидными, но на практике они становятся понятны, только тогда когда строишь агента сам и упираешься в каждую проблему руками.

Два цикла: REPL и Agent Loop

У любого кодинг‑агента на самом деле два цикла, и у каждого своя задача.

REPL — это внешний цикл. Он читает пользовательский ввод, передает его агенту и ждет следующего запроса.

Agent Loop — это внутренний цикл. Он вызывает LLM API, выполняет запрошенные инструменты и продолжает работу до тех пор, пока модель не решит, что задача выполнена. Один ваш запрос в REPL может развернуться в десяток итераций внутреннего цикла.

Внешний цикл REPL и внутренний Agent Loop

Внешний цикл REPL и внутренний Agent Loop

Дальше нас интересует именно внутренний цикл. Снаружи он выглядит как магия, внутри как небольшой while.

Agent Loop

Вся «магия» кроется в этом небольшом цикле. Сначала мы добавляем запрос пользователя в историю сообщений. Дальше на каждой итерации собираем контекст и отправляем модели. Модель думает над следующим шагом и возвращает ответ. Если это финальный текст, мы закончили и выходим из цикла. Если модель попросила вызвать инструмент, мы его выполняем, возвращаем результат обратно в историю и идем на следующую итерацию. И так до тех пор, пока модель не решит, что задача выполнена.

func run(query: String) async throws -> String {    messages.append(.user(query))    while true {        let request = APIRequest(            model: model, system: systemPrompt, messages: messages, tools: tools        )        let response = try await apiClient.createMessage(request)        messages.append(Message(role: .assistant, content: response.content))        guard response.stopReason == .toolUse else {            return response.content.textContent        }        var results = [ContentBlock]()        for block in response.content {            if case .toolUse(let id, let name, let input) = block {                let output = await executeTool(name: name, input: input)                results.append(.toolResult(toolUseId: id, content: output))            }        }        messages.append(Message(role: .user, content: results))    }}
Agent Loop по шагам

Agent Loop по шагам

Один проход цикла: модель запрашивает инструмент, получает tool_result и идет на следующую итерацию, пока не вернет финальный текст.

И еще важный момент: этот цикл не специфичен для кодинг‑агента. Это общий паттерн — agentic loop. Цикл вообще не зависит от домена. Кодинговым агента делает не цикл, а инструменты, которые в него вкладываешь: чтение и запись файлов, shell, редактирование. Плюс системный промпт и окружение. Сам цикл одинаков и для кодинг‑агента, и для агента поддержки. Если вы уже строили агентские системы, то вам это знакомо.

За все время развития агента цикл концептуально не менялся. Все новое добавлялось вокруг него. И это не случайность: цикл стабилен именно потому, что отделяет оркестрацию от конкретных инструментов. Модель только заявляет намерение, а решение и выполнение остаются за обвязкой, за harness.

Bash is all you need?

Если задуматься, то shell‑команды это универсальный инструмент для взаимодействия с операционной системой. Давая агенту такой единственный bash инструмент, он может выполнять любые операции с файлами, запускать программы и управлять системой. Закономерно возникает вопрос, так зачем нам что‑то еще?

Простой пример. Модель собирает многострочный sed, чтобы поправить исходник, и одного лишнего бэкслеша хватает, чтобы испортить файл. По сути каждую файловую операцию модель пишет shell‑командой заново, без всяких гарантий, и проверить результат удается не всегда.

Агент правит файл многострочным sed через bash

Агент правит файл многострочным sed через bash и один лишний бэкслеш ломает файл

Поэтому появляются отдельные инструменты: read_file, write_file, edit_file. С известным форматом ввода и вывода, с лимитами на размер, с атомарной записью. Модель больше не изобретает каждый раз команду, она вызывает инструмент с понятными параметрами и получает предсказуемый ответ.

Отдельные инструменты — это не про больше возможностей, bash и так умеет все. Это про ограничения, которые можно встроить в сам инструмент, вместо того чтобы надеяться, что сгенерированная команда от модели окажется корректной.

Sandbox и guardrails

Специализированные инструменты есть, модель умная. Кажется, она не полезет читать или удалять что‑нибудь за пределами текущей рабочей директории. На это хочется положиться, но на практике ответ модели непредсказуем. Она вполне может попросить абсолютный путь или выйти из проекта через ../../.

private static let dangerousPatterns = [  "rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]if let matched = dangerousPatterns.first(where: { command.contains($0) }) {  throw ShellExecutorError.blockedCommand(matched)}let fullURL = relativePath.hasPrefix("/")  ? URL(fileURLWithPath: relativePath).standardized  : workDirURL.appendingPathComponent(relativePath).standardizedguard fullURL.path.hasPrefix(resolvedWorkDir.path + "/") ||      fullURL.path == resolvedWorkDir.path else {  return .failure(.executionFailed("Path escapes workspace"))}guard allowedTools.contains(name) else {  results.append(.toolResult(    toolUseId: id, content: "Tool '\(name)' is not allowed", isError: true  ))  continue}

Сложную систему с permissions и подтверждениями у пользователя я сознательно делать не стал, для учебного проекта это избыточно. Хватает простых валидаций.

Path sandbox: резолвим путь и проверяем, что он остается внутри рабочей директории. Блокировка опасных команд: маленький список вроде rm -rf /, sudo, shutdown, который bash инструмент просто откажется выполнять. И то же самое с вызовами инструментов: даже если инструмента нет в конфиге, модель может нагаллюцинировать вызов с таким именем. Поэтому allowlist для инструментов тоже проверяется принудительно.

Песочница блокирует выход за пределы рабочей директории

Sandbox и guardrails в действии

Теперь агент огорожен и ему можно доверить длинную задачу. И тут всплывает проблема уже не безопасности, а внимания.

Todo tool, или instruction‑following decay

Кажется, что модель достаточно умная и план держит в голове сама, хватит одной строчки в системном промпте «следуй плану». На коротких задачах так и есть.

На длинной задаче происходит интересное. Первые шаги модель делает уверенно, к середине начинает дрейфовать, к концу уже импровизирует. План растворяется в массе вызовов инструментов и их результатов. Модель не забывает в человеческом смысле, она просто перестает смотреть на старое: чем больше контекст, тем сильнее внимание уезжает к свежему вводу. У этого даже есть название — instruction‑following decay.

Дрейф плана без todo-инструмента и удержание с ним

Дрейф плана без todo‑инструмента и удержание с ним

Вывод простой: план не должен жить только в рассуждении модели. Todo tool — это список задач, который модель ведет сама для себя. Каждый вызов возвращает план как tool_result, то есть он физически попадает в конец контекста, туда, где внимание максимальное.

Управление вниманием оказалось важнее, чем формулировки в промпте. И тут всплывает следующая проблема: даже с планом контекст быстро забивается промежуточным мусором.

Сабагенты и загрязнение контекста

Один агент, единая история сообщений. Кажется естественным держать всё в одном контексте.

Простой пример на задаче поиска: «какой testing framework используется в проекте?». Чтобы ответить одним словом «XCTest», модель читает десяток файлов, грепает директории, вызывает различные команды. И все эти результаты остаются в контексте навсегда, хотя нам нужен был только вывод. Это и есть проблема загрязнения контекста: на длинной сессии массив сообщений набивается промежуточной информацией, которая вытесняет важное и усиливает тот самый дрейф внимания.

// Тот же самый agentLoop, другие настройкиlet result = try await agentLoop(  initialMessages: [Message.user(prompt)],  config: .subagent)

Решение — делегировать задачу с изолированным контекстом. Субагент получает чистый массив сообщений, делает работу и возвращает только финальный текстовый ответ. Вся его промежуточная история в контекст родителя не попадает. Цикл при этом тот же самый agentLoop, меняется только LoopConfig.

Главный агент и изолированный контекст субагента

Главный агент и изолированный контекст субагента

Главный агент получает только итог («XCTest»). Все 12 промежуточных сообщений и ~22k токенов остаются в изолированном контексте субагента.

Логичный вопрос: как система решает, что делегировать субагенту, а что выполнить в основном контексте? Никакого роутера в коде нет, решение принимает сама модель по описанию инструмента agent. Логика в описании простая: если задача создаст много промежуточного мусора и может быть изолирована, делегируем.

Субагент — это не про concurrency, а про делегирование с изоляцией контекста. Но даже с субагентами основной контекст все равно растет.

Context management

Если запустить агента в таком виде на долгую задачу, рано или поздно случится одно из двух. Либо ответы начнут деградировать, либо запрос к API упадет с ошибкой про переполнение контекстного окна. Деградация наступает раньше, и это хуже: агент продолжает работать, но делает это плохо.

Главный источник мусора видно невооруженным глазом — это результаты вызовов инструментов. Один read_file на тысячу строк это тысячи токенов, и таких в длинной сессии десятки.

Отсюда базовый алгоритм. Micro‑compact работает в фоне перед каждым запросом: все tool_result, кроме N последних, заменяются на placeholder. Факт вызова остается, а громоздкий вывод удаляется. Работает это потому, что старые результаты модель уже прочитала и учла в своих ответах. В худшем случае будет один лишний вызов инструмента, а не потеря корректности.

// micro-compact, в фоне перед каждым запросом:// старые tool_results → placeholderlet oldResults = toolResultLocations.dropLast(keepRecent)// → .toolResult(toolUseId: id, content: "[Previous: used \(toolName)]")// auto-compact при превышении порогаif estimateTokens(from: messages) > tokenThreshold {  let path = try saveTranscript(messages)  let summary = try await apiClient.createMessage(request: ...)  messages = [.user(summary), .assistant("Continuing.")]}
Накопление контекста, micro-compact и auto-compact

Накопление контекста, micro‑compact и auto‑compact

Контекст наполняется результатами вызовов. Micro‑compact сворачивает старые tool_result, а при переходе порога срабатывает auto‑compact.

Когда размер контекста все равно переходит порог, срабатывает auto‑compact: transcript сессии сохраняется на диск, модель делает summary, и заменяет им всю историю.

Теперь агенту можно доверить длинную задачу и он автономно доведёт её до конца.

Что я вынес для себя

Главный вывод. Хороший агент — это не развесистая обвязка из подключенных MCP, плагинов и фреймворков. Это маленькое ядро, которое ты понимаешь: агентский цикл и небольшой набор хороших инструментов под задачу.

А вот как поменялся мой собственный подход к работе с кодинг‑агентами:

  1. Когда агент делает что‑то не так, чаще всего проблема не в модели, а в обвязке: контекст, инструменты, промпт, архитектура, — и нужно искать проблему здесь.

  2. Я начинаю новую сессию раньше и чаще. Длинный контекст деградирует всегда, и чистая сессия выигрывает у длинной.

  3. Я держу инструкции компактными. Короткий CLAUDE.md или AGENTS.md с progressive disclosure работает лучше, чем простыня на все случаи жизни.

  4. И я не верю self‑review модели. Чтобы закрыть цикл обратной связи, агенту нужно дать возможность проверить себя объективно: тесты, линтер, компилятор.

Где взять код

Весь код и серия статей в формате code‑along лежат в репозитории на Гитхабе, если захотите построить своего агента или копнуть тему глубже.

Если строили своих агентов и встречали подводные камни, расскажите в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1048946/