Inicio / Artículos

Cómo un agente de IA para corregir documentos se engañó a sí mismo: falsos positivos en un entorno aislado de Docker

Un agente de corrección de documentos de LangGraph envió una solución inoperante y luego causó problemas en los paquetes src-layout. El error del entorno aislado de Docker que lo provocó y la solución real se encontraron en click.

Editor: REACTAPP.TOP7 min de lectura1666 palabras

Fuentes: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**

Un agente de IA diseñado para reparar ejemplos de código dañados en la documentación produjo un resultado extraño en su primer repositorio real. “Reparó” un error que no existía, tomando una línea perfectamente válida, sin cambiar nada, y publicó el mismo texto como solución verificada.

- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)

Las dos líneas coinciden. El agente se convenció a sí mismo de que debía arreglar algo que nunca estuvo mal, y esa fue solo la primera de las tres formas en que se engañó a sí mismo antes de que apareciera el verdadero culpable.

Qué hace DocuPatch

DocuPatch es un agente impulsado por LangGraph. Recorre los archivos Markdown de un repositorio de GitHub, extrae los bloques de código en Python y los ejecuta en un contenedor Docker aislado. Las muestras que realmente fallan se envían a Gemini para su reparación; la solución se verifica nuevamente en el mismo entorno aislado, y solo un resultado verificado se convierte en una rama y una solicitud de integración.

La estructura del repositorio:

  • agent/ingest.py clona el repositorio y localiza los archivos Markdown.
  • agent/graph.py inicia un proceso LangGraph para cada archivo.
  • agent/parser.py extrae los bloques de código en Python.
  • agent/tester.py ejecuta cada fragmento dentro de un contenedor Docker.
  • agent/context_collector.py recopila los archivos y código en Python relevantes para un fragmento.
  • agent/fixer.py genera el fragmento reparado.
  • agent/github_client.py crea una rama fork, realiza el commit con la corrección y abre solicitudes de pull request.
  • tests/ contiene pruebas para cada componente.
  • La premisa es sencilla: el código de documentación se deteriora con el tiempo. Las APIs cambian, las funciones se renombran, los ejemplos dejan de funcionar, y nadie se da cuenta hasta que alguien pega un fragmento inútil extraído de un README. El agente automatiza la detección de estos problemas. Encuentra un fragmento sospechoso, demuestra que está roto ejecutándolo, solicita una corrección a un LLM, verifica que la corrección funciona ejecutándolo nuevamente y solo entonces abre una solicitud de pull request.

    El pipeline incluye una etapa de clonado y análisis que alimenta un bucle basado en pruebas, y la corrección solo se aplica después de dos verificaciones: el original falló y el sustituto funciona correctamente.

    La regla guía es el control basado en evidencias. Nada llega al LLM a menos que una ejecución en entorno aislado demuestre que tiene un error, y nada se publica a menos que una nueva ejecución en ese entorno demuestre que la solución funciona. Ese principio se mantuvo. El propio entorno aislado, por un buen tiempo, no lo hizo.

    Primer prueba: tqdm

    La ejecución inicial en entornos reales se centró en tqdm/tqdm:

    Found 9 Markdown file(s). Starting audit...
    
    Auditing: .github\ISSUE_TEMPLATE\bug.md
      Generating repair for snippet #1
      Verifying generated repair for snippet #1
      Publishing verified repair(s)...
      VERIFIED REPAIR: Branch published
    

    Se “arreglaron” y publicaron dos archivos, lo cual parece una victoria en teoría. Luego se examinó la diferencia real:

    - print(tqdm.__version__, sys.version, sys.platform)
    + print(tqdm.__version__, sys.version, sys.platform)
    

    De nuevo, las dos líneas son idénticas. Gemini no cambió nada y regeneró el código byte a byte. El sistema probó un fragmento de código, lo declaró defectuoso, lo “arregló” devolviendo el mismo código, lo probó de nuevo, vio que funcionaba y realizó un commit sin cambios en el fork.

    Esto no fue un error de corrección. Fue un falso positivo en la capa de pruebas, porque el fragmento original nunca estuvo defectuoso. Una línea que importa tqdm y sys y muestra información de la versión es algo completamente normal en el uso de tqdm, así que, ¿por qué la sandbox reportó un fallo? La cuestión se consideró sospechosa y se probó otro repositorio para obtener un segundo punto de datos antes de sacar conclusiones.

    Segunda prueba: pallets/click

    En pallets/click, el patrón era imposible de pasar por alto:

    Auditing: docs\advanced.md
      Generating repair for snippet #2
      Verifying generated repair for snippet #2
      [Reflection Loop 2/3] Revising repair for snippet #2
      ...
      [Reflection Loop 3/3] Revising repair for snippet #3
      NOT PUBLISHED: Generated repair failed verification:
        ModuleNotFoundError: No module named 'click'
    

    Un error, cada vez. Los seis archivos pasaron por tres ciclos completos de reflexión, y cada fallo fue el mismo ModuleNotFoundError: No module named 'click'.

    Esa repetición es la pista. Cuando seis archivos y seis intentos separados con LLM fallan todos por el mismo error de importación, el factor común no es el código, sino el entorno subyacente. Reescribir un fragmento de código no puede solucionar un módulo faltante si la sandbox no puede detectar el paquete, sin importar lo que se escriba.

    Al abrir tester.py se reveló el mecanismo:

    command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
    

    Dentro del contenedor, el repositorio clonado se monta y PYTHONPATH se establece en el nivel superior del repositorio. Eso funciona bien cuando el directorio del paquete se encuentra allí mismo, pero falla cuando el paquete está anidado un nivel más abajo. Click está anidado: su pyproject.toml guarda el código real dentro de src/click/ en lugar de en una carpeta click/ en la raíz, como lo hace el layout src que siguen muchos proyectos modernos. Con PYTHONPATH=/workspace, el intérprete buscaba en un directorio que en realidad no contenía ninguna carpeta click.

    Una verificación manual fuera del pipeline lo confirmó:

    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
    # ModuleNotFoundError: No module named 'click'
    
    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
    # import works... but:
    # importlib.metadata.PackageNotFoundError: No package metadata was found for click
    

    El segundo resultado fue el más instructivo. Apuntar PYTHONPATH a la carpeta correcta solucionó el problema de importación, pero click.__version__ seguía fallando, ya que las versiones actuales de click generan __version__ mediante importlib.metadata, el cual lee la metadata de los paquetes instalados y no los archivos fuente. Esa metadata solo existe después de una instalación real con pip, nunca tras un truco con rutas.

    Así que había dos errores acumulados: PYTHONPATH apuntaba a la carpeta incorrecta para los repositorios de src-layout. E incluso si apuntaba bien, un simple truco con PYTHONPATH no genera un paquete instalado correctamente, mientras que cada vez más paquetes esperan ser instalados de verdad y no simplemente importables.

    Hay un desvío que merece mención, ya que fue el tramo más instructivo. Se invirtió mucho tiempo en investigar una peculiaridad de Docker relacionada con el montaje exclusivo en Windows, mediante experimentos con comillas, estilos de rutas y listas de argumentos frente a cadenas de shell. La verdadera explicación era sencilla: la carpeta de pruebas estaba vacía porque un anterior git clone había fallado silenciosamente al intentar llenarla, y nadie lo comprobó. Un listado vacío de dir C:\temp\click_test no era “todavía sin respuesta”, sino la respuesta en sí, y pasó desapercibido tres veces antes de que alguien se detuviera a leerlo.

    La solución: instalar, no adivinar

    El verdadero remedio no es adivinar un camino más inteligente, sino no adivinar en absoluto. pip install -e . lee el propio pyproject.toml o setup.cfg del proyecto, determina la ubicación correcta del paquete independientemente de su estructura y escribe metadatos de instalación reales, de modo que las llamadas a importlib.metadata funcionen como lo harían para cualquier usuario real de la biblioteca.

    La nueva lógica de tester.py se ejecuta en este orden:

    • Si existe un pyproject.toml, setup.py o setup.cfg, el entorno aislado crea una imagen Docker y ejecuta pip install -e . dentro de ella. Este es el “modo de instalación”, donde se puede considerar que un fallo de importación es real.
    • Si no hay metadatos de empaquetado pero sí un requirements.txt, se recurre al método anterior basado en PYTHONPATH, el “modo pythonpath”.
  • En modo pythonpath, si un fragmento falla debido a una importación no resuelta de un paquete propio del proyecto, etiquételo como ENV_ERROR, es decir, “no se puede determinar”, en lugar de FAIL.
  • Después del cambio, el conjunto de pruebas existente mostró que 26 de 27 pruebas pasaban sin modificaciones. El único fallo fue correcto, ya que se basaba en la firma de la función antigua que se había modificado intencionadamente. Se actualizó la expectativa y tres pruebas nuevas cubrieron el caso límite, lo que elevó el total de pruebas aprobadas a 74.

    Un verdadero problema en la documentación de click

    Con la sandbox instalando el proyecto y ya sin hacer conjeturas, click se ejecutó nuevamente. Los seis archivos que anteriormente fallaban de manera idéntica ahora tuvieron una oportunidad justa, y uno reveló un error real e indescubierto en la propia documentación de click.

    docs/commands.md explica un flujo en cadena de la CLI, y una función de ejemplo muestra:

    @cli.result_callback()
    def process_pipeline(processors, fin):
    - iterator = (x.rstrip("\r\n") for x in input)
    + iterator = (x.rstrip("\r\n") for x in fin)
    

    En realidad, la función recibe fin, el identificador del archivo que proporciona click. Sin embargo, la documentación itera sobre input, que se refiere a la función incorporada de Python input() y no al archivo. Iterar sobre ella es inválido y falla en cuanto se ejecuta el código, pero un lector podría no darse cuenta porque el nombre parece plausible.

    DocuPatch lo detectó, confirmó una falla real en el entorno de pruebas, generó la corrección, verificó que la versión corregida funcionara sin problemas y la publicó. Fue el primer resultado de toda la investigación que era tanto un verdadero error en la documentación como una corrección efectiva, sin ruido generado por el entorno de pruebas ni falsos positivos. Para eso existe el proyecto.

    Lecciones de la depuración

    • Verifique el veredicto antes de confiar en el resultado. Etiquetas como “Aprobado” o “Publicado” son tan fiables como la prueba que las acompaña. El problema con tqdm se descubrió únicamente porque se abrió la diferencia entre versiones y no se tomó en serio la marca de verificación verde.
    • Fallas idénticas en muchos casos sugieren una causa común. Seis archivos, seis intentos independientes con LLM y un mensaje de error indican un problema a nivel del sistema.
    • Observe antes de teorizar. Se pasaron horas especulando sobre rutas en Docker y Windows antes de que alguien comprobara si la carpeta de pruebas contenía algún archivo.
    • Una prueba fallida puede confirmar algo sin causar daños. Cuando cambiar la firma de run_snippet rompió una prueba, la pregunta correcta era cuál era la afirmación real de esa prueba, no qué se había roto.

    Ahora el error del entorno de pruebas está corregido, cubierto por pruebas y verificado en un repositorio con el que anteriormente tenía problemas. La documentación de Click contenía un defecto real: un nombre de variable que causaba errores en cuanto alguien ejecutaba el ejemplo. El agente lo localizó, lo reparó correctamente y verificó la corrección antes de publicarla. Esa es la idea central del proyecto, demostrada en un caso real y no en uno sintético.