Продолжение первой статьи

Пару недель назад я выложил Доку — локального AI-агента для Windows и macOS. Статья попала в топ-5 Хабра за сутки, пришло 22 баг-репорта в первые 48 часов и 154 комментария за неделю.

Самый частый запрос в комментариях: «когда будет работа с файлами?». Это логично — агент который умеет искать в интернете, но не может взаимодействовать с твоим диском, это как браузер без закладок.

В этой статье — технические детали того что я сделал: файловый доступ для агента, permission gate, agent timeline, pipeline hardening. Плюс три баги которые я поймал по дороге и которые стоит знать если вы строите что-то похожее.

Баг №1: убийца KV-кэша которого я не замечал месяц

Начну с самого обидного.

После первого релиза я видел что многошаговые агентские задачи работают медленнее чем должны. Модель не прогрета? Железо? Просто медленная генерация?

Нет. Я сам всё ломал.

В системный промпт у меня была вставлена строка с текущим временем:

Session started: ${new Date().toISOString()}

Вставлена в начало промпта. И это убивало весь KV-кэш node-llama-cpp на каждой итерации агентного цикла.

Как работает KV-кэш в llama.cpp: модель кэширует обработанные токены промпта. Если промпт не изменился — платишь только за новые токены. Если изменился хоть один символ в начале — весь кэш невалиден, обрабатываешь заново.

Время меняется каждую секунду. Agent loop делает 6–12 итераций. На каждой итерации — полный пересчёт системного промпта. При системнике в 2000 токенов и 10 итерациях это 20 000 лишних токенов за один запрос.

Фикс — перенести timestamp в конец промпта или в user-тёрн. Это описано как критический паттерн в гайдах Anthropic для Claude, но я читал это применительно к облачным API и не подумал что это так же работает локально.

После фикса: многошаговые задачи ускорились в 2–3 раза на задачах где agent loop делал 8+ итераций.

Как я думал про файловый доступ

Дать агенту инструменты для чтения файлов — не сложно. Дать инструменты для записи — это уже ответственность.

Сначала я думал просто сделать file tools и добавить их в тулбокс агента. Но чем больше я думал, тем больше понимал что это неправильно.

Агент с файловым доступом без каких-либо ограничений — это программа которая может перезаписать твой рабочий документ, удалить что-то важное, или просто записать не туда. Не злонамеренно — просто потому что модель ошиблась в пути к файлу или неправильно интерпретировала инструкцию.

Я посмотрел как другие решают это. MCP-протокол предлагает разграничение через read / write permissions на уровне сервера. Claude Desktop показывает какие инструменты доступны агенту, но не показывает конкретные параметры вызова.

Мне нужно было что-то более явное. Не «пользователь включил write tools», а «прямо сейчас агент хочет записать вот этот конкретный файл — подтверждаешь?»

Tool Permission Gate: как это устроено

Каждый инструмент в тулбоксе помечен флагом dangerous: boolean.

const fileTools: Tool[] = [
  {
    name: 'read_file',
    dangerous: false,
    // ...
  },
  {
    name: 'write_file',
    dangerous: true,
    // ...
  },
  {
    name: 'edit_file',
    dangerous: true,
    // ...
  },
  {
    name: 'delete_file',
    dangerous: true,
    // ...
  },
];

Когда агент вызывает dangerous-инструмент, выполнение останавливается. Пользователю показывается диалог с точными аргументами вызова — не «агент хочет что-то записать», а конкретно:

Агент хочет выполнить: write_file
Путь: /Users/ilya/Documents/report.md
Содержимое: [показывает первые 500 символов]
[Разрешить]  [Отклонить]

Пользователь видит именно то что агент собирается сделать. Кнопку «Разрешить» я сделал не default — чтобы не кликнуть случайно.

Если пользователь нажимает «Отклонить» — инструмент возвращает агенту структурированную ошибку:

{
  "error": "User denied permission for write_file",
  "canRetry": false,
  "suggestion": "Ask the user to confirm the intended file path"
}

Агент может это обработать и сообщить пользователю что именно не получилось и почему.

Все шесть файловых инструментов

read_file(path: string): string
write_file(path: string, content: string): void          // dangerous
edit_file(path: string, instruction: string): void       // dangerous
list_dir(path: string): FileEntry[]
move_file(from: string, to: string): void                // dangerous
delete_file(path: string): void                          // dangerous

edit_file — принимает инструкцию, не весь файл. Агент говорит «добавь в конец файла такую-то секцию» — я применяю патч. Это важно для больших файлов: не гонять 10KB туда-обратно через контекст.

Под капотом edit_file делает read → apply patch → write → verify (об этом ниже).

Баг №2: агент редактирует не тот файл

Первые тесты file tools вскрыли интересный класс ошибок.

Даёшь задачу: «добавь раздел в файл notes.md». Агент делает list_dir, находит notes.md, делает edit_file. Окей.

Но на третьем-четвёртом тесте агент взял notes.md из другой директории — та что была ближе к корню проекта, не та что просили. Путь был относительным, и агент выбрал «по смыслу» — что ему казалось правильным.

Это не баг модели, это моя недоработка. Я не указывал агенту работать в явном рабочем контексте.

Решение — рабочая директория по умолчанию, явно прописанная в системном промпте, и валидация что все пути находятся внутри неё. Если агент пытается пойти за её пределы — ошибка с объяснением.

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

Auto-verify после записи

После write_file или edit_file агент автоматически вызывает read_file чтобы убедиться что записалось правильно.

Зачем? Потому что без этого агент может «записать» файл, получить success, двигаться дальше — но файл при этом содержит неполные данные или вообще не обновился (мало ли, права, блокировка, диск заполнен).

Auto-verify добавляет один вызов инструмента на каждую запись, зато убирает целый класс молчаливых ошибок.

Это и есть одна из причин почему я поднял MAX_TOOL_STEPS с 6 до 12: типичная файловая задача — plan (1) + read (1–2) + write (1) + verify (1) — уже занимает 5 из 6 шагов без запаса на ошибки.

Agent Timeline: почему «чёрный ящик» — это проблема

Первая версия Доки показывала только статус: «Ищу в сети...» → «Читаю страницу...» → «Генерирую ответ...».

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

С файловым доступом это стало критичным. Если агент читает файлы, то пользователь должен видеть какие именно. Не потому что это красиво — а потому что нужно понимать что агент делает с твоими данными.

Я добавил боковую панель — Agent Timeline. Это лог с шагами. Видно полные аргументы инструмента, результат, время.

Технически это stream событий от агентного движка. Каждый вызов инструмента эмитит tool_call_start / tool_call_end события, которые рендерятся в React в реальном времени.

Баг №3: структурированные ошибки которых не было

Когда read_file падал с permission error или файл не существовал — агент получал голый стектрейс Node.js. Что он с ним делал? Обычно — ничего хорошего. Либо бесконечный retry, либо галлюцинировал ответ.

Теперь все ошибки инструментов форматируются в структуру:

{
  "error": "File not found: /path/to/file.md",
  "canRetry": false,
  "suggestion": "Check if the path is correct with list_dir first"
}

canRetry — подсказка агенту: есть ли смысл пробовать снова. Для «файл не найден» — нет, для «таймаут сети» — да. Агент использует это в логике принятия решений, и количество бессмысленных retry заметно упало.

Системный промпт: четыре секции вместо одной

Это не файловые инструменты, но важная часть того почему v1.5.0 стал стабильнее.

Я реструктурировал system.md по паттерну:

1. Роль и границы — кто агент, что он делает, чего не делает

2. Правила с приоритетами — явный порядок: безопасность > точность > скорость

3. Формат ответа — как структурировать разные типы ответов

4. Few-shot пример — один пример правильного reasoning на граничном кейсе

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

Структура с явными секциями убрала несколько классов галлюцинаций — особенно на кейсах вида «пользователь просит сделать X, но правильный ответ — отказать и объяснить почему».

Think → Plan → Act

Ещё один паттерн который стоит документировать.

Раньше агент сразу начинал вызывать инструменты. Это нормально для простых задач, но для многошаговых — агент иногда «застревал» в середине, потому что не строил план заранее.

Добавил в системный промпт обязательную фазу планирования перед выполнением:

Before using tools, write a short plan:
THINK: [what information do I need?]
PLAN: [step 1 → step 2 → step 3]
ACT: [execute plan]

На практике это снизило количество ситуаций «агент три раза вызвал один и тот же инструмент с одними и теми же аргументами» примерно в два раза. Планирование форсирует явное представление цели перед действием.

Что дальше?

Работа с форматами: Excel/CSV/DOCX не как «прочитать как текст», а нормальный парсинг с сохранением структуры. Плюс history compaction при переполнении контекста — сейчас при длинном чате модель просто начинает терять начало.

Cкачать можно здесь бесплатно и без VPN - https://dokaai.ru

Вопросы по архитектуре и баги — в комментарии.