Octofs 0.9.0: строка 42 — это ложь
Агент попросил заменить строки с 40 по 44. Он получил строки с 40 по 44. Это были не те строки, которые он читал.
Ничего не упало. Никакой ошибки. Где-то между view, который дал план, и batch_edit, который его выполнил, отработал форматтер и сдвинул файл на три строки вниз — и правка аккуратно легла на пять ни в чём не повинных строк кода. Модель увидела успешный ответ, отчиталась о рефакторинге и пошла дальше. Мы нашли это на ревью через двадцать минут, читая диф, в котором не было никакого смысла.
Ради этого сценария отказа релиз и существует.
0.9.0 делает адрес каждой строки проверяемым по содержимому: строка — это N:hh, её позиция плюс хеш того, что на ней написано, и каждый инструмент редактирования сверяет хеш с файлом, прежде чем записать хоть байт. Устаревшая цель теперь падает с явной ошибкой, в которой лежит актуальное содержимое. Попасть не в ту строку невозможно, потому что «не та строка» больше не совпадает.
Вместе с этим вышли ещё два изменения — и, как выясняется, это одна и та же идея в разных костюмах. Об этом в конце.
Номер строки — неправильный примитив
Вот в чём беда номера строки: он верен только в тот момент, когда вы его прочитали.
Цикл правок агента — это последовательность отдельных вызовов MCP с паузами между ними, и в этих паузах файл не заморожен. Его правит другой вызов инструмента. Форматтер срабатывает при сохранении. Параллельный агент трогает тот же файл. Человек, наблюдающий за сессией, исправляет опечатку. К моменту, когда правка доезжает, «строка 42» указывает на то, что случайно оказалось в 42-м слоте, — и файловый сервер, принимающий голое целое число, никак не отличит строку, которую имела в виду модель, от строки, которую он сейчас уничтожит.
Обычное решение — глобальная проверка устаревания файла: поставить отметку при чтении и отклонить правку, если mtime или хеш изменились. У нас была такая. Она груба в обе стороны. Она отклоняет правку строки 900, потому что кто-то тронул строку 3, и чтобы восстановиться, требует перечитать файл целиком — дорого, причём перечитанное тут же снова устаревает. Хуже того, она не говорит ничего полезного. «Файл изменился» оставляет модели ровно один ход: прочитать весь файл заново и надеяться, что в этот раз она выиграет гонку.
Примитив был неправильный. Ссылка на строку должна нести достаточно информации, чтобы проверить саму себя.
N:hh — позиция плюс доказательство
В 0.9.0 view выводит каждую строку как N:hh|content:
1:a3|fn main() {
2:f1| println!("Hello");
3:0e|}
N — позиция с нумерацией от единицы. hh — два шестнадцатеричных символа, хеш FNV-1a от содержимого строки, свёрнутый с 32 бит до 8. Инструменты редактирования принимают эти составные идентификаторы обратно как цели, а verify_line_id сверяет хеш с файлом в момент применения. Совпало — правка идёт. Не совпало — не записывается ничего.
Хеш считается только по содержимому, никогда по позиции. Когда мы это писали, это выглядело деталью, а оказалось сутью всей конструкции. Раз строка сохраняет свой хеш при переезде, неудачная проверка может пойти и поискать, куда делось содержимое: просканировать файл на строки с ожидаемым хешем — и вы знаете, что цель не исчезла, а сдвинулась на три строки вниз.
Ровно это ошибка и сообщает:
Stale line id "42:c7" — the file changed since you viewed it. Current content around line 42:
40:1b| let config = load_config()?;
41:9f| let client = Client::new(&config);
42:2e| tracing::info!("client ready");
43:0a|
44:5d| run(client).await
Content matching hash c7 is now at: 45:c7 (your target may have moved).
Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.
В этом сообщении три вещи, и каждая осознанная. Актуальное содержимое вокруг цели со свежими идентификаторами — чтобы модель могла сразу перенацелиться. Где сейчас живёт содержимое с ожидаемым хешем, ближайшие кандидаты первыми — чтобы переехавшая, но не изменившаяся строка чинилась одним шагом. И конкретный диапазон для view, если модель хочет убедиться, а не гадать.
Модель восстанавливается по одной только ошибке. Никакого перечитывания файла на 2000 строк, никакой второй гонки, никакого сожжённого контекста. Ошибка и есть инструкция по восстановлению.
А поскольку результаты правок возвращаются в виде дифов со свежевычисленными идентификаторами, правки выстраиваются в цепочку. Сделайте три вызова batch_edit подряд — и цели второго берутся из ответа первого: файл между ними не нужно перечитывать вообще.
Есть один честный компромисс, и он записан комментарием в исходниках, а не спрятан: восемь бит означают, что изменившаяся строка сохранит свой хеш с вероятностью 1/256. Мы на это пошли. Идентификаторы остаются достаточно короткими, чтобы быть дешёвыми в контексте и читаемыми в транскрипте, проверка позиции ловит любой заметный сдвиг, а альтернатива — более длинные хеши на каждой строке каждого просмотра файла — стоила бы токенов буквально при каждом чтении ради защиты от случая с вероятностью 0,4%, который цикл «диф со свежими идентификаторами» и так обычно вскрывает.
Ещё мы убрали переключатель режимов. В предыдущих версиях был флаг --line-mode, выбиравший между адресацией по номерам и по хешам. Этого флага больше нет, формат N:hh обязателен — это ломающее изменение в 0.9.0. Два режима адресации означали, что каждое описание инструмента должно объяснять оба, каждая модель должна догадаться, с каким из них она разговаривает, а безопасный режим включался вручную. Безопасность, спрятанная за флагом, — это безопасность, которую большинство никогда не включит.
Голые целые числа по-прежнему работают там, где одной позиции действительно достаточно и проверять нечего по определению: диапазоны в view (отрицательные значения отсчитываются с конца) и якоря вставки 0 — начало файла и -1 — в конец. Всё, что нацелено на существующее содержимое, требует идентификатор.
Правка, которая прошла успешно и ничего не сделала
Пока мы были внутри, нашлась более тихая версия того же бага.
str_replace подбирает совпадение поэтапно: сначала точное, затем нечёткое с нормализацией пробелов — на случай, когда у модели поехали отступы. В файле с переводами строк CRLF нечёткий этап находил совпадение на нормализованном тексте, а затем пытался вставить замену обратно в сырое содержимое, где каждая строка по-прежнему заканчивалась на \r\n, — и вставлять было некуда. Файл записывался байт в байт таким же, а в ответ приходил успех с дифом. Правка разработчика на Windows проходила весь путь и не меняла ничего.
Теперь всё сопоставление идёт в пространстве LF, а restore_endings возвращает \r\n при записи. То же самое в batch_edit. Файл сохраняет свои переводы строк; сопоставлению до них больше нет дела.
Полная лестница совпадений в 0.9.0 выглядит так: точное → восстановление экранированных литералов → нечёткое с нормализацией пробелов и подгонкой отступов → диагностика. Второй этап новый и целиком построен вокруг того, как ошибаются модели: когда модель дважды экранирует свой JSON и присылает литеральные обратный слеш и n вместо перевода строки, мы интерпретируем экранирование, и если так совпадение оказывается единственным — применяем его и добавляем подсказку о том, что сделали. Модели ошибаются так постоянно, случай однозначный, а возврат ошибки на него стоил лишнего круга ради исправления того, чего нет.
replace_all тоже новый — та самая правка в стиле переименования, которая раньше требовала либо достаточного окружающего контекста для уникальности каждого вхождения, либо batch_edit с отдельной операцией на каждое место. А когда точное совпадение срабатывает несколько раз без replace_all, ошибка теперь перечисляет каждое место в виде идентификатора строки:
Found 3 matches for replacement text at:
1. 12:a3
2. 88:a3
3. 140:a3
Add more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.
Три названных выхода, и каждый из них исполним без ещё одного view. Снова тот же приём.
Подсказка — это совет, а советам модели следуют выборочно
Третье изменение кого-то разозлит, так что позвольте его обосновать.
Octofs распознаёт злоупотребление shell — когда модель тянется за cat, grep, find, ls, sed или awk там, где специализированный MCP-инструмент справится лучше. До 0.9.0 это поведение настраивалось флагом --hint-mode: мягко предупредить или отклонить. С 0.8.1 мягкий режим был по умолчанию.
Мягкие подсказки не работают. Предупреждение, приклеенное к успешному ответу, — это рекомендация, конкурирующая с результатом, который у модели уже в руках, и результат побеждает. Мы видели сессии, где одна и та же подсказка срабатывала шесть раз, а модель продолжала запускать grep, потому что grep возвращал вывод, а игнорировать подсказку ничего не стоило.
В 0.9.0 злоупотребление shell — всегда жёсткая ошибка, а переключателя режимов больше нет. Вызов падает, ничего не выполняется, а в ошибке названо, чем пользоваться, с разобранным примером:
Searching file text with this command is forbidden — use `view` with content= instead
(gitignore-aware, context lines, line numbers, works on remote hosts).
Example:
view path="src/main.rs" content="fulfill_input_requests"
view path="src/" content="TODO" regex=true
view path="ssh://user@host/dir" content="TODO" # remote search — no `ssh grep` needed
Дело не в аккуратности. view с content= возвращает идентификаторы строк, которые принимают инструменты редактирования, уважает .gitignore и прозрачно работает с путями ssh://. Сырой вывод grep даёт модели номер строки — то есть, по всему сказанному выше, ложь, которая ждёт своего часа, — и тихо топит его в node_modules. Специализированный инструмент строго полезнее, так что вопрос был только один: делать ли этот выбор необязательным. Больше нет.
Что по-прежнему работает: конвейеры. cargo build 2>&1 | grep error — это преобразование потока, а не чтение файла, и детектор намеренно не разбивает команду по |. Он разбивает по ;, &&, ||, переводам строк, $( и обратным кавычкам — и только вне кавычек, так что ssh host 'cd /path && ls' не даёт ложного срабатывания на удалённой команде, в которую детектору лезть незачем. Префиксы переменных окружения пропускаются, чтобы добраться до настоящей программы, а /bin/grep сводится к grep, чтобы путь к бинарнику не стал лазейкой.
Что общего у этих трёх изменений
Посмотрите на них вместе — и это одно изменение, сделанное трижды.
Настоящий интерфейс MCP-сервера — не схема его инструментов, а каждая строка, которую он отдаёт модели, и большинство этих строк — ошибки. Для человека ошибка — это уведомление: прочитал и пошёл чинить руками. Для агента ошибка — это промпт. Это весь вход для следующего решения, приходящий без какого-либо другого контекста, и качество того, что случится дальше, ограничено тем, что лежит в этой строке.
Поэтому: не говорите «файл изменился» — скажите, на что он изменился и куда уехало содержимое. Не говорите «найдено 3 совпадения» — перечислите их как цели, которые примет следующий вызов. Не намекайте, что grep не приветствуется, — падайте и отдавайте точный вызов view, который его заменяет. Каждый из этих шагов — один и тот же приём: потратить пару сотен символов в ошибке, чтобы сэкономить целый круг общения и сожжённый на него контекст.
Вот что мы имеем в виду, когда говорим, что выравниваем сервер под модель, а не под файловую систему. Это не промпт-инжиниринг. Это проектирование интерфейса для вызывающей стороны, которая восстанавливается через чтение — и читает только то, что вы ей дали.
Обновление
# Homebrew
brew upgrade muvon/tap/octofs
# Cargo
cargo install octofs --version 0.9.0
Готовые сборки для Linux, macOS и Windows (x86_64 и ARM64) лежат на странице релизов, а конвейер релиза теперь публикует пакет в npm вместе с crates.io и реестром MCP.
Одно ломающее изменение, о котором стоит знать: если в конфигурации вашего MCP-клиента заданы --line-mode или --hint-mode, уберите их — обоих флагов больше нет, и бинарник их отвергнет. Заменять их нечем: безопасное поведение теперь единственное. Других изменений в конфигурации не требуется.
После обновления разница проявится на первой же правке, которая с чем-нибудь столкнётся. Вместо тихого успеха не по тем строкам вы получите ошибку, по которой ваш агент сможет действовать, не перечитывая файл.
Octofs — открытый исходный код (Apache 2.0), github.com/Muvon/octofs. Если вам нужен взгляд с точки зрения безопасности — почему агенту вообще стоит давать ограниченный файловый инструмент вместо голого shell, — это отдельная статья; эта была о том, чтобы инструмент правил ровно ту строку, на которую его навели.



