Octofs 0.9.0: la línea 42 es mentira
El agente pidió reemplazar las líneas 40 a 44. Obtuvo las líneas 40 a 44. No eran las líneas que había leído.
Nada falló. No apareció ningún error. En algún punto entre el view que produjo el plan y el batch_edit que lo ejecutó, se había ejecutado un formateador que desplazó el archivo tres líneas hacia abajo, y la edición aterrizó limpiamente sobre cinco líneas de código perfectamente inocentes. El modelo vio una respuesta de éxito, dio el refactor por terminado y siguió adelante. Lo encontramos en la revisión, veinte minutos después, leyendo un diff que no tenía ningún sentido.
Para ese modo de fallo existe esta versión.
0.9.0 hace que la dirección de cada línea se verifique por contenido: una línea es N:hh —su posición más un hash de lo que hay en ella— y toda herramienta de edición comprueba ese hash contra el archivo antes de escribir un solo byte. Un destino obsoleto ahora falla en voz alta, con el contenido actual dentro del error. No puede aterrizar en la línea equivocada, porque «la línea equivocada» ya no coincide.
Con ella salieron otros dos cambios que, al final, son la misma idea con otro traje. Vuelvo a eso al final.
El número de línea es la primitiva equivocada
Lo que pasa con un número de línea es esto: solo es cierto en el instante en que lo lees.
El bucle de edición de un agente es una secuencia de llamadas MCP separadas con huecos entre ellas, y en esos huecos el archivo no está congelado. Otra llamada lo edita. Un formateador se ejecuta al guardar. Un agente paralelo toca el mismo archivo. La persona que observa la sesión corrige una errata. Cuando la edición llega, «línea 42» apunta a lo que sea que esté ocupando el hueco 42, y un servidor de archivos que acepta un entero pelado no tiene forma de distinguir entre la línea que el modelo quería y la línea que está a punto de destruir.
La mitigación habitual es un candado global de obsolescencia: sellar el archivo cuando se lee y rechazar la edición si cambian el mtime o el hash. Teníamos una versión de eso. Es tosca en las dos direcciones. Rechaza una edición en la línea 900 porque alguien tocó la línea 3, y para recuperarse obliga a releer el archivo entero: caro, y la relectura queda obsoleta de inmediato. Peor aún, no dice nada útil. «El archivo cambió» le deja al modelo una sola jugada: leer el archivo completo otra vez y confiar en ganar la carrera esta vez.
La primitiva estaba mal elegida. Una referencia a una línea debería llevar suficiente información para verificarse a sí misma.
N:hh: posición más prueba
En 0.9.0, view muestra cada línea como N:hh|content:
1:a3|fn main() {
2:f1| println!("Hello");
3:0e|}
N es la posición, indexada desde uno. hh son dos caracteres hexadecimales: un hash FNV-1a del contenido de la línea, plegado de 32 bits a 8. Las herramientas de edición reciben de vuelta estos identificadores compuestos como destinos, y verify_line_id comprueba el hash contra el archivo en el momento de aplicar. Si coincide, la edición sigue. Si no coincide, no se escribe nada.
El hash cubre solo el contenido, nunca la posición. Cuando lo escribimos parecía un detalle y resultó ser el diseño entero. Como una línea conserva su hash cuando se mueve, una verificación fallida puede ir a buscar adónde fue a parar el contenido: recorre el archivo buscando líneas con el hash esperado y ya sabes que el destino no desapareció, sino que bajó tres líneas.
Que es exactamente lo que dice el error:
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.
En ese mensaje hay tres cosas, y cada una es deliberada. El contenido actual alrededor del destino, con identificadores frescos, para que el modelo pueda reapuntar de inmediato. Dónde vive ahora el contenido que coincide con el hash esperado, con los candidatos más cercanos primero, para que una línea que solo se movió se arregle en un paso. Y un rango concreto de view que ejecutar si prefiere confirmar en vez de adivinar.
El modelo se recupera solo con el error. Sin releer un archivo de 2.000 líneas, sin una segunda carrera, sin contexto quemado. El error es la instrucción de recuperación.
Y como los resultados de las ediciones vuelven en forma de diff con identificadores recién calculados, las ediciones se encadenan. Haz tres llamadas seguidas a batch_edit y los destinos de la segunda salen de la respuesta de la primera: el archivo no necesita volver a verse en ningún momento.
Hay una concesión honesta, y está escrita en un comentario del código fuente en lugar de estar enterrada: ocho bits significan que una línea modificada conserva su hash con probabilidad 1/256. La aceptamos. Los identificadores siguen siendo lo bastante cortos para resultar baratos en contexto y legibles en una transcripción, la comprobación de posición atrapa cualquier desplazamiento grueso, y la alternativa —hashes más largos en cada línea de cada lectura de archivo— costaría tokens literalmente en cada lectura para defenderse de un caso del 0,4 % que el bucle de diffs con identificadores frescos suele destapar de todos modos.
También eliminamos el interruptor de modos. Las versiones anteriores tenían una opción --line-mode que elegía entre direccionamiento por número y por hash. Esa opción ya no existe y el formato N:hh es obligatorio: este es el cambio incompatible de 0.9.0. Dos modos de direccionamiento significaban que cada descripción de herramienta tenía que explicar ambos, que cada modelo tenía que averiguar con cuál estaba hablando, y que el modo seguro era opcional. La seguridad que se entrega detrás de una opción es seguridad que casi nadie activa.
Los enteros pelados siguen funcionando allí donde una posición basta y no hay nada verificable por naturaleza: los rangos de view (los negativos cuentan desde el final) y los anclajes de inserción 0 para el inicio del archivo y -1 para añadir al final. Todo lo que apunta a contenido existente requiere un identificador.
La edición que tuvo éxito y no hizo nada
Ya que estábamos dentro, encontramos una versión más silenciosa del mismo error.
str_replace busca coincidencias de forma progresiva: primero exacta y después una pasada difusa con espacios en blanco normalizados, para cuando al modelo se le desvía la indentación. En un archivo con finales de línea CRLF, la pasada difusa encontraba la coincidencia sobre el texto normalizado y luego intentaba insertar el reemplazo de vuelta en el contenido crudo, donde cada línea seguía terminando en \r\n, así que no había nada donde insertarlo. Escribía el archivo idéntico byte a byte y devolvía éxito con un diff. La edición de alguien que trabaja en Windows recorría todo el camino sin cambiar nada.
Ahora todas las coincidencias se buscan en espacio LF y restore_endings devuelve los \r\n al escribir. Lo mismo en batch_edit. El archivo conserva sus finales de línea; al comparador dejan de importarle.
La escalera completa de coincidencias en 0.9.0 es: exacta → recuperación de literales escapados → difusa con espacios normalizados y ajuste de indentación → diagnóstico. Esa segunda etapa es nueva y es pura ingeniería sobre cómo fallan los modelos: cuando un modelo escapa dos veces su JSON y envía una barra invertida y una n literales en vez de un salto de línea, interpretamos los escapes y, si así la coincidencia es única, la aplicamos y adjuntamos una nota diciendo lo que hicimos. Es un error que los modelos cometen constantemente, es inequívoco cuando ocurre, y devolverles un error por ello costaba un viaje de ida y vuelta para arreglar algo que no existía.
replace_all también es nuevo: la edición estilo renombrado que antes exigía o bien suficiente contexto alrededor para que cada aparición fuera única, o bien un batch_edit con una operación por sitio. Y cuando una coincidencia exacta aparece varias veces sin replace_all, el error ahora enumera cada ubicación como identificador de línea:
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.
Tres salidas con nombre, y todas ejecutables sin otro view. El mismo patrón, otra vez.
Las sugerencias son consejos, y los modelos siguen los consejos de forma selectiva
El tercer cambio es el que va a molestar a alguien, así que déjame defenderlo.
Octofs detecta el mal uso del shell: cuando el modelo echa mano de cat, grep, find, ls, sed o awk donde una herramienta MCP dedicada hace mejor el trabajo. Hasta 0.9.0 esa detección era configurable con --hint-mode: avisar suavemente o rechazar. Desde 0.8.1, lo suave era lo predeterminado.
Las advertencias suaves no funcionan. Un aviso pegado a una respuesta exitosa es una sugerencia que compite contra un resultado que el modelo ya tiene en la mano, y gana el resultado. Vimos sesiones donde la misma advertencia saltó seis veces y el modelo siguió ejecutando grep, porque grep devolvía salida e ignorar el aviso no costaba nada.
En 0.9.0 el mal uso del shell siempre es un error tajante, y el interruptor de modos ya no está. La llamada falla, no se ejecuta nada, y el error nombra la herramienta que hay que usar con un ejemplo resuelto:
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
Esto no es pulcritud. view con content= devuelve identificadores de línea que las herramientas de edición aceptan, respeta .gitignore y funciona de forma transparente contra rutas ssh://. La salida cruda de grep le da al modelo un número de línea —es decir, según todo lo anterior, una mentira esperando su momento— y lo ahoga en silencio en node_modules. La herramienta dedicada es estrictamente más útil, así que la única pregunta era si elegirla debía ser opcional. Ya no lo es.
Lo que sigue funcionando: las tuberías. cargo build 2>&1 | grep error es una transformación de flujo, no una lectura de archivo, y el detector deliberadamente no divide por |. Divide por ;, &&, ||, saltos de línea, $( y comillas invertidas, y solo fuera de comillas, de modo que ssh host 'cd /path && ls' no genera un falso positivo sobre un comando remoto que al detector no le incumbe. Los prefijos de variables de entorno se saltan para llegar al programa real, y /bin/grep se reduce a grep para que anteponer una ruta no sea una vía de escape.
Qué tienen en común estos tres cambios
Míralos juntos y es un solo cambio hecho tres veces.
La interfaz real de un servidor MCP no es su esquema de herramientas: es cada cadena de texto que le devuelve a un modelo, y la mayoría de esas cadenas son errores. Para una persona, un error es una notificación; lo lees y vas a arreglar las cosas a mano. Para un agente, un error es un prompt. Es la entrada completa de la siguiente decisión, llega sin ningún otro contexto, y la calidad de lo que ocurra después está limitada por lo que contenga esa cadena.
Así que: no digas «el archivo cambió», di a qué cambió y adónde fue el contenido. No digas «se encontraron 3 coincidencias», enuméralas como destinos que la siguiente llamada pueda consumir. No insinúes que grep está desaconsejado: falla y entrega la llamada exacta a view que lo sustituye. Cada una de esas decisiones es el mismo movimiento: gastar unos cientos de caracteres en el error para ahorrar un viaje de ida y vuelta entero y el contexto que consume.
Esto es lo que queremos decir cuando hablamos de alinear el servidor con el modelo en lugar de con el sistema de archivos. No es ingeniería de prompts. Es diseño de interfaz para alguien que se recupera leyendo, y que solo lee lo que le das.
Actualizar
# Homebrew
brew upgrade muvon/tap/octofs
# Cargo
cargo install octofs --version 0.9.0
Los binarios precompilados para Linux, macOS y Windows (x86_64 y ARM64) están en la página de releases, y la canalización de publicación ahora publica en npm además de en crates.io y el registro MCP.
Un cambio incompatible que conviene conocer: si tienes --line-mode o --hint-mode en la configuración de tu cliente MCP, quítalos: ambas opciones ya no existen y el binario las rechazará. No hay nada con qué reemplazarlas; el comportamiento seguro es ahora el único comportamiento. Ningún otro cambio de configuración.
Después de actualizar, la diferencia aparece en la primera edición que compita con otra cosa. En lugar de un éxito silencioso sobre las líneas equivocadas, obtienes un error sobre el que tu agente puede actuar sin volver a leer el archivo.
Octofs es código abierto (Apache 2.0) en github.com/Muvon/octofs. Si te interesa el ángulo de seguridad —por qué a un agente conviene darle una herramienta de archivos acotada en vez de un shell pelado— eso está en otro artículo; este iba de asegurarnos de que la herramienta edite exactamente la línea a la que la apuntaron.



