Herramientas: leer, buscar, editar, ejecutar

Herramientas: leer, buscar, editar, ejecutar

30 min read

Desde la lección del bucle con un modelo real, el harness del bloque anterior habla con un modelo que nadie ha guionizado, y lo hace sin una línea cambiada. Sus llamadas, en cambio, siguen cayendo en un entorno de mentira: rutas y contenidos guardados en la memoria del programa, que se pierden cuando termina. Esta lección lo cambia por una carpeta de tu disco, y le da al agente cinco herramientas para trabajar en ella.

Escribir cinco funciones que leen y escriben ficheros es la parte fácil. La difícil es decidir qué promete cada una, porque quien las va a llamar es un modelo pequeño que se equivoca, y ahora se equivocará sobre ficheros que existen. El harness no puede impedirlo. Lo que sí puede es que cada equivocación se vea: que una edición hecha de memoria falle en lugar de pisar el fichero, que un test que no pasa le llegue al modelo como un dato, que un comando que no termina no deje el bucle esperando para siempre. ¿Qué tiene que prometer cada herramienta para eso? Casi toda la respuesta está en una sola, editar, y en una regla de su contrato.

Esta es una ejecución del agente sobre un proyecto de dos ficheros, compra.py y su test_compra.py, cuyos dos tests fallan. Corre con las herramientas de esta lección salvo una: escribir es todavía la del bloque anterior, que sustituye un fichero entero, y su descripción añade un consejo, «para cambiar una parte, usa editar». El pedido es Los tests fallan: ejecuta python -m unittest y arregla compra.py hasta que pasen. He cortado las salidas de los tests (cada … es un trozo quitado) y los dos textos del modelo, y he quitado las preguntas de permiso, a las que contesté que sí. Los segundos son los de mi máquina:

turno 1  12 s  753 tokens de entrada
  ←  llamada      ejecutar {"comando": "python -m unittest"}
  →  observación  código de salida 1
                  --- stdout
                  --- stderr
                  FF
                  …
                  AssertionError: Lists differ: ['huevos', 'cebolla.'] != ['huevos', 'cebolla']
                  …
                  AssertionError: Lists differ: ['tomates', 'pepino', 'ajo.'] != ['tomates', 'pepino', 'ajo']
                  …
                  FAILED (failures=2)
turno 2  45 s  1196 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
  →  observación  """Lo que falta comprar para una receta."""
                  …
turno 3  244 s  1361 tokens de entrada
  ←  texto        I see the issue. In both failing tests, there's a trailing period (`.`) at the end of the ingredient list, …
  ←  llamada      escribir {"ruta": "compra.py", "contenido": "\"Lo que falta comprar para una receta.\"\n\n\ndef ingredientes(receta):\n    \"\\\"Tortilla: huevos, patatas, cebolla.\\\" -> [\\\"huevos\\\", \\\"patatas\\\", \\\"cebolla\\\"]\\\"\n    nombre, lista = receta.split(:)\n    # Split by comma and strip whitespace and period\n    return [i.strip().rstrip(.) for i in lista.split(,)]\n\n\ndef falta(lista, receta):\n    \"\\\"Los ingredientes de la receta que no están en la lista de la compra.\\\"\\\"\n    tengo = [i.strip() for i in lista.split(,)]\n    return [i for i in ingredientes(receta) if i not in tengo]"}
  →  observación  escritos 521 caracteres en compra.py
turno 4  98 s  1865 tokens de entrada
  ←  texto        The `compra.py` file has been successfully updated to fix the failing tests. …
  ←  llamada      ejecutar {"comando": "python -m unittest"}
  →  observación  código de salida 1
                  …
                    File "/tmp/tmpajoz5elq/compra.py", line 5
                      "\"Tortilla: huevos, patatas, cebolla.\" -> [\"huevos\", \"patatas\", \"cebolla\"]\"
                      ^
                  SyntaxError: unterminated string literal (detected at line 5); perhaps you escaped the end quote?
                  …

Los dos primeros turnos son lo que harías tú: correr los tests y leer el fichero. En el tercero el modelo acierta el diagnóstico (el punto que se queda pegado al último ingrediente), pasa a escribir en inglés y trae en su texto compra.py bien corregido, con una línea cambiada. Para escribirlo pide escribir con el fichero entero, y el contenido de la llamada no es el de su texto: han desaparecido comillas (receta.split(:), lista.split(,)) y las cadenas de documentación se han roto. El mismo código, dos veces en una respuesta: bien en el texto, roto en la llamada.

El harness ha hecho lo que escribir prometía, y la observación es verdad: 521 caracteres. El consejo de la descripción no ha servido de nada. El destrozo sólo se descubre porque el pedido decía cómo ejecutar los tests, y en el cuarto turno el SyntaxError llega como observación; sin ellos, el fichero se habría quedado roto y el modelo, convencido de lo contrario. (El quinto turno no volvió: tardó más de los 300 segundos que espera el adaptador.)

Cinco contratos sobre una carpeta

El entorno es ahora una carpeta, la carpeta de trabajo, y cada ruta que pide el modelo es relativa a ella: compra.py, tests/test_compra.py, . para la carpeta entera. La función de cada herramienta recibe la carpeta como primer argumento, en el sitio donde antes recibía el diccionario, así que para permisos.ejecutar y para el bucle sigue siendo «el entorno». Una cosa no cambia todavía, y conviene decirla ya: nada impide que una ruta salga de la carpeta (../) ni que un comando haga cualquier cosa. Cerrar eso es la lección de permisos y sandbox, más adelante en el bloque.

Cada herramienta vive en su fichero, y el fichero tiene tres partes para tres lectores. HERRAMIENTA es lo que lee el modelo: nombre, descripción y esquema, lo único que sabe de ella. CLASE es lo que lee el permiso, escrita al lado de la función por quien la escribe, como pedía la lección de los permisos. Y la función es lo que ejecuta el harness. Estos son los cinco contratos:

  1. leer(ruta), de lectura. Si ruta es un fichero, devuelve su contenido entero. Si es una carpeta, las rutas de lo que hay dentro, una por línea, contadas desde la carpeta de trabajo y con una / al final de cada carpeta; ni .git ni las cachés de Python. Es la listar del bloque anterior, que desaparece. Si no existe, un error.
  2. buscar(patron, ruta), de lectura. Las líneas que contienen patron sin distinguir mayúsculas, en el fichero ruta o en todos los de la carpeta ruta y sus subcarpetas, como ruta:número: línea. Si no hay ninguna, lo dice con una frase, sin is_error: no encontrar nada no es un error, pero una observación vacía es fácil de no leer. Los dos cambios salen de la traza de la tortilla de la lección anterior.
  3. editar(ruta, antiguo, nuevo), de escritura. Si antiguo aparece en el fichero exactamente una vez, lo sustituye por nuevo y devuelve en qué línea. Si aparece cero veces, o dos o más, o está vacío, la llamada es un error que dice cuál de los casos es, y el fichero no se toca.
  4. escribir(ruta, contenido), de escritura. Crea un fichero nuevo, con las carpetas que hagan falta. Si el fichero ya existe, la llamada es un error que manda a editar, y el fichero no se toca. Es el cambio que pide la traza de arriba.
  5. ejecutar(comando), de ejecución. Corre el comando en la terminal, en la carpeta de trabajo, y devuelve su código de salida, su stdout y su stderr. Cualquier código de salida es una observación, no un error. Si no termina en 30 segundos, se detiene, y eso sí es un error, con el timeout en el texto.

Las funciones fallan como fallaban las del bloque anterior: lanzan una excepción, y permisos.ejecutar la convierte en un error que el modelo ve, como estableció la lección de los errores como observaciones. Hay una coincidencia de nombres que no es una relación: la herramienta ejecutar corre un comando, y la función ejecutar de permisos.py ejecuta cualquier llamada, la de esta herramienta incluida. En el código no se cruzan, porque la herramienta vive en herramientas/ejecutar.py.

Por qué antiguo tiene que aparecer una vez

La regla parece una molestia, y es lo que hace comprobable una edición. Hace tres cosas.

La primera es que antiguo es una cita del fichero, y el harness la comprueba contra el disco antes de escribir. Un modelo que edita de memoria, a partir de una observación que ya no es verdad o de un fichero que no ha leído nunca (el de la tortilla, que se inventó en la lección anterior), cita algo que no está, y la llamada falla sin escribir. Es lo que prometió la lección del estado y la memoria: una copia vieja no puede pisar un cambio, porque el cambio no está en la cita.

La segunda es que una cita que aparece dos veces no se resuelve eligiendo. El harness no sabe cuál de los dos sitios ha mirado el modelo, y cambiar los dos cambia seguro uno que nadie ha pedido. Lo que sabe hacer bien es negarse y decir cuántas veces aparece.

La tercera es la que se nota al revisar. Cuando editar termina bien, ha cambiado un sitio y nada más, y la observación dice cuál. Con el escribir de antes, cambiar una línea era volver a escribir todas las demás, y lo que se perdiera por el camino (las comillas de la traza) se perdía sin que nada fallara. Una cita sin sus comillas, en cambio, no aparece en el fichero. Lo comprobable no es que el modelo acierte. Es que se sepa exactamente qué ha tocado.

Por eso escribir ya no sustituye. Su consejo era una petición al modelo, y la traza enseña lo que vale; una garantía tiene que estar en el contrato, como avisó la lección del estado y la memoria. Con un escribir que sólo crea, ningún fichero que exista cambia sin una cita comprobada.

ejecutar tiene dos decisiones del mismo tipo. Un test que no pasa termina con código de salida 1, y eso es lo que el modelo necesita saber, no un fallo de la llamada: la herramienta prometió correr el comando y contar qué pasó, y lo ha hecho. Y el error del límite de tiempo no dice «no se ha ejecutado», porque no es verdad. Un comando cortado puede haber hecho la mitad de su trabajo, y la lección de los errores ya avisó de lo que cuesta repetir esa mitad.

Las herramientas, en Python

El código parte del punto de control en que terminó la lección anterior:

CheckpointAl empezar esta lección

Todo lo que sigue corre contra el modelo local del aviso del principio. Las herramientas van en una carpeta nueva, herramientas/, una por fichero, más herramientas/comun.py con lo que comparten: el objeto que escribe un esquema, que se muda desde entorno.py, y qué rutas no se enseñan.

"""Lo que comparten las herramientas: el esquema de sus argumentos y qué rutas se ven."""
 
 
def objeto(**campos):                     # un esquema: estos campos, de texto y obligatorios
    return {"type": "object", "required": list(campos), "additionalProperties": False,
            "properties": {c: {"type": "string", "description": d} for c, d in campos.items()}}
 
 
def visible(ruta):                        # ni .git ni las cachés de Python
    return not any(p.startswith(".") or p == "__pycache__" for p in ruta.parts)

Las dos de lectura, herramientas/leer.py y herramientas/buscar.py, usan pathlib para que las rutas salgan con / también en Windows:

"""leer(ruta): un fichero entero, o lo que hay en una carpeta. Lección 34 del curso."""
from pathlib import Path
 
from herramientas.comun import objeto, visible
 
CLASE = "lectura"
HERRAMIENTA = {"name": "leer", "description": "Devuelve el contenido entero de un fichero. "
               "Si ruta es una carpeta, lo que hay dentro, una ruta por línea; las carpetas "
               "acaban en /. La carpeta de trabajo es .",
               "input_schema": objeto(ruta="la ruta del fichero o de la carpeta")}
 
 
def leer(entorno, ruta):
    p = Path(entorno, ruta)
    if p.is_dir():
        hijos = (h.relative_to(entorno) for h in sorted(p.iterdir()))
        return "\n".join(h.as_posix() + ("/" if Path(entorno, h).is_dir() else "")
                         for h in hijos if visible(h))
    if not p.is_file():
        raise FileNotFoundError("no existe el fichero %r" % ruta)
    return p.read_text(encoding="utf-8")
"""buscar(patron, ruta): las líneas que lo contienen, sin distinguir mayúsculas. Lección 34."""
from pathlib import Path
 
from herramientas.comun import objeto, visible
 
CLASE = "lectura"
HERRAMIENTA = {"name": "buscar", "description": "Las líneas que contienen patron, sin "
               "distinguir mayúsculas, en el fichero ruta o en todos los de la carpeta ruta, "
               "cada una como ruta:número: línea.",
               "input_schema": objeto(patron="el texto que buscar",
                                      ruta="un fichero o una carpeta; . es la de trabajo")}
 
 
def buscar(entorno, patron, ruta):
    p = Path(entorno, ruta)
    if not p.exists():
        raise FileNotFoundError("no existe %r" % ruta)
    ficheros = [p] if p.is_file() else sorted(f for f in p.rglob("*") if f.is_file())
    lineas = []
    for f in (f for f in ficheros if visible(f.relative_to(entorno))):
        try:
            texto = f.read_text(encoding="utf-8")
        except UnicodeDecodeError:                   # un fichero binario no tiene líneas
            continue
        for n, linea in enumerate(texto.split("\n"), 1):
            if patron.lower() in linea.lower():
                lineas.append("%s:%d: %s" % (f.relative_to(entorno).as_posix(), n, linea))
    return "\n".join(lineas) or "ninguna línea contiene %r en %s" % (patron, ruta)

herramientas/editar.py es el contrato, cláusula por cláusula, y el texto de cada error le dice al modelo qué hacer en la llamada siguiente:

"""editar(ruta, antiguo, nuevo): un cambio en un sitio, o ninguno. Lección 34 del curso."""
from pathlib import Path
 
from herramientas.comun import objeto
 
CLASE = "escritura"
HERRAMIENTA = {"name": "editar", "description": "Cambia en el fichero ruta el texto antiguo "
               "por nuevo. antiguo tiene que aparecer en el fichero exactamente una vez, "
               "copiado tal cual, con sus espacios y sus saltos de línea; si no, no cambia "
               "nada y dice por qué.",
               "input_schema": objeto(ruta="la ruta del fichero", antiguo="el texto que cambiar",
                                      nuevo="el texto que lo sustituye")}
 
 
def editar(entorno, ruta, antiguo, nuevo):
    p = Path(entorno, ruta)
    if not p.is_file():
        raise FileNotFoundError("no existe el fichero %r" % ruta)
    texto = p.read_text(encoding="utf-8")
    veces = texto.count(antiguo) if antiguo else 0     # "" aparecería en todas partes
    if veces == 0:
        raise ValueError("antiguo no aparece en %s: cópialo tal cual del fichero. "
                         "No se ha cambiado nada" % ruta)
    if veces > 1:
        raise ValueError("antiguo aparece %d veces en %s: añade líneas de alrededor para que "
                         "sea único. No se ha cambiado nada" % (veces, ruta))
    p.write_text(texto.replace(antiguo, nuevo), encoding="utf-8")
    return "editado %s en la línea %d" % (ruta, texto[:texto.index(antiguo)].count("\n") + 1)

Fíjate en que todas las comprobaciones van antes de la única línea que escribe: por eso el error puede decir «no se ha cambiado nada» y que sea verdad.

Quedan herramientas/escribir.py y herramientas/ejecutar.py, la primera herramienta de ejecución del curso. shell=True le pasa el comando a la terminal de tu sistema, la que lo entiende:

"""escribir(ruta, contenido): un fichero nuevo, nunca uno que ya existe. Lecciones 26 y 34."""
from pathlib import Path
 
from herramientas.comun import objeto
 
CLASE = "escritura"
HERRAMIENTA = {"name": "escribir", "description": "Crea un fichero nuevo con este contenido. "
               "Si el fichero ya existe, no lo toca: para cambiar un fichero que existe, usa "
               "editar.",
               "input_schema": objeto(ruta="la ruta del fichero nuevo",
                                      contenido="su contenido, entero")}
 
 
def escribir(entorno, ruta, contenido):
    p = Path(entorno, ruta)
    if p.exists():
        raise FileExistsError("ya existe %r: para cambiarlo, usa editar. No se ha escrito nada"
                              % ruta)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(contenido, encoding="utf-8")
    return "escritos %d caracteres en %s" % (len(contenido), ruta)
"""ejecutar(comando): un programa, con su código de salida y lo que escribe. Lección 34."""
import subprocess
 
from herramientas.comun import objeto
 
CLASE = "ejecución"
LIMITE = 30                                          # segundos
HERRAMIENTA = {"name": "ejecutar", "description": "Ejecuta un comando de la terminal en la "
               "carpeta de trabajo y devuelve su código de salida, su stdout y su stderr. "
               "Se detiene a los %d segundos." % LIMITE,
               "input_schema": objeto(comando="el comando, como lo escribirías en la terminal")}
 
 
def ejecutar(entorno, comando):
    try:
        r = subprocess.run(comando, shell=True, cwd=entorno, capture_output=True,
                           encoding="utf-8", errors="replace", timeout=LIMITE)
    except subprocess.TimeoutExpired:
        raise TimeoutError("no ha terminado en %d s y se ha detenido" % LIMITE)
    return "código de salida %d\n--- stdout\n%s--- stderr\n%s" % (r.returncode, r.stdout, r.stderr)

herramientas/__init__.py junta las cinco en lo que el resto del harness ya importaba:

"""Las cinco herramientas del agente, una por fichero. Lección 34 del curso.
 
Cada fichero tiene tres partes, para tres lectores: HERRAMIENTA, lo que lee el modelo; CLASE,
lo que lee el permiso; y la función, lo que ejecuta el harness sobre el entorno, que es una
carpeta de tu disco.
"""
from herramientas import buscar, editar, ejecutar, escribir, leer
 
MODULOS = [leer, buscar, editar, escribir, ejecutar]
HERRAMIENTAS = [m.HERRAMIENTA for m in MODULOS]
CLASES = {m.HERRAMIENTA["name"]: m.CLASE for m in MODULOS}
FUNCIONES = {"leer": leer.leer, "buscar": buscar.buscar, "editar": editar.editar,
             "escribir": escribir.escribir, "ejecutar": ejecutar.ejecutar}

Lo que cambia en los ficheros que ya tienes

Tres ficheros cambian, y ninguno en su lógica. En entorno.py, borra las cuatro funciones del bloque anterior, FUNCIONES, objeto y HERRAMIENTAS, e importa las nuevas, para que bucle.py y permisos.py las sigan encontrando donde las buscaban. ENTORNO se queda, como ficheros de los tests. El principio queda así:

"""Los bloques del protocolo, el esquema de cada llamada y los ficheros de los tests.
 
Lecciones 26 y 28 del curso. Hasta la lección 34, también el entorno: un diccionario en
memoria con cuatro herramientas. Ahora el entorno es una carpeta de tu disco, las herramientas
están en herramientas/, y el diccionario es lo que los tests escriben en una carpeta temporal.
"""
import json
 
from herramientas import FUNCIONES, HERRAMIENTAS     # bucle y permisos las importan de aquí

En permisos.py, la línea de CLASES desaparece, porque cada herramienta dice la suya:

from entorno import FUNCIONES, error, incumple, resultado
from herramientas import CLASES                      # cada herramienta dice la suya

En tests/test_agente.py, cada test escribe su entorno en una carpeta temporal y compara lo que queda en ella. Añade import tempfile, from pathlib import Path y from herramientas.escribir import escribir, y una función que lee la carpeta:

def ficheros(carpeta):
    """Lo que hay en la carpeta, como el diccionario de un test: {ruta: contenido}."""
    return {f.relative_to(carpeta).as_posix(): f.read_text(encoding="utf-8")
            for f in Path(carpeta).rglob("*") if f.is_file()}

En probar, las líneas que copiaban el diccionario y corrían el agente pasan a ser estas, y la comprobación del entorno compara despues:

    with tempfile.TemporaryDirectory() as entorno:    # el entorno del test, en una carpeta
        for ruta, contenido in test["entorno"].items():
            escribir(entorno, ruta, contenido)
        try:
            motivo, final, M = agente(modelo, test["pedido"], entorno, test["tope"], usuario)
        except Exception as e:            # el modelo guionizado no reconoce M, o algo ha lanzado
            return "%s: %s" % (type(e).__name__, e)
        despues = ficheros(entorno)

Y los guiones. Donde la tortilla y el modelo terco piden listar(carpeta="recetas/"), pon leer(ruta="recetas"); la observación esperada es la misma. La tortilla añadía los ingredientes con un escribir sobre lista.txt, que ahora sería un error, así que su t6 pasa a ser llamada("t6", "editar", ruta="lista.txt", antiguo="pan, leche, patatas", nuevo=LISTA), con la observación resultado("t6", "editado lista.txt en la línea 1").

Un test por cláusula, y un proyecto donde probarlas

Los contratos se comprueban sin modelo, llamando a las funciones sobre una carpeta temporal. tests/test_herramientas.py tiene un test por cláusula que importa:

"""Los contratos de editar y escribir, y ejecutar con un comando que falla. Lección 34."""
import sys
import tempfile
import unittest
from pathlib import Path
 
from herramientas.editar import editar
from herramientas.ejecutar import ejecutar
from herramientas.escribir import escribir
 
CODIGO = "x = 1\ny = 1\n"
 
 
class TestHerramientas(unittest.TestCase):
    def setUp(self):
        self.entorno = self.enterContext(tempfile.TemporaryDirectory())
        self.fichero = Path(self.entorno, "a.py")
        self.fichero.write_text(CODIGO, encoding="utf-8")
 
    def test_editar_una_vez(self):
        r = editar(self.entorno, "a.py", "y = 1", "y = 2")
        self.assertEqual(r, "editado a.py en la línea 2")
        self.assertEqual(self.fichero.read_text(encoding="utf-8"), "x = 1\ny = 2\n")
 
    def test_editar_ninguna_o_dos_veces(self):
        for antiguo in ("z = 1", "= 1", ""):
            with self.assertRaises(ValueError):
                editar(self.entorno, "a.py", antiguo, "= 2")
            self.assertEqual(self.fichero.read_text(encoding="utf-8"), CODIGO)
 
    def test_escribir_no_pisa_un_fichero(self):
        with self.assertRaises(FileExistsError):
            escribir(self.entorno, "a.py", "")
        self.assertEqual(self.fichero.read_text(encoding="utf-8"), CODIGO)
 
    def test_ejecutar_un_comando_que_falla(self):
        r = ejecutar(self.entorno, '"%s" -c "import sys; sys.exit(3)"' % sys.executable)
        self.assertTrue(r.startswith("código de salida 3\n"))
 
 
if __name__ == "__main__":
    unittest.main()

Para el modelo hace falta un repositorio, aunque sea diminuto. ejemplo/compra.py saca los ingredientes de una receta y dice cuáles faltan en la lista:

"""Lo que falta comprar para una receta."""
 
 
def ingredientes(receta):
    """«Tortilla: huevos, patatas, cebolla.» -> ["huevos", "patatas", "cebolla"]"""
    nombre, lista = receta.split(":")
    return [i.strip() for i in lista.split(",")]
 
 
def falta(lista, receta):
    """Los ingredientes de la receta que no están en la lista de la compra."""
    tengo = [i.strip() for i in lista.split(",")]
    return [i for i in ingredientes(receta) if i not in tengo]

Y ejemplo/test_compra.py las prueba con recetas que acaban en punto, como las del bloque anterior:

import unittest
 
from compra import falta, ingredientes
 
 
class TestCompra(unittest.TestCase):
    def test_ingredientes(self):
        self.assertEqual(ingredientes("Gazpacho: tomates, pepino, ajo."), ["tomates", "pepino", "ajo"])
 
    def test_falta(self):
        self.assertEqual(falta("pan, leche, patatas", "Tortilla: huevos, patatas, cebolla."),
                         ["huevos", "cebolla"])

Los dos tests fallan, por el mismo motivo: el punto final se queda pegado al último ingrediente, ajo. y cebolla.. He escrito el fallo a propósito, y también la línea repetida: [i.strip() for i in lista.split(",")] aparece dos veces en compra.py, y la que hay que cambiar es sólo una.

Correr el agente sobre una copia

Queda correr.py, que hasta ahora no tenía a quién preguntar y denegaba toda escritura. Ahora copia la carpeta que le das a una temporal y corre el agente sobre la copia, así que ejemplo/ conserva su fallo para la próxima vez. Y hace de usuario: antes de cada escritura y cada comando, te pregunta a ti. Mientras no lleguen los permisos de verdad, esa pregunta es lo único que separa un comando del modelo de tu máquina, así que léela antes de contestar.

Cambia la cabecera y los imports:

"""Un pedido al agente, sobre una copia de una carpeta, contra el modelo local, turno a turno.
 
Lecciones 33 y 34 del curso.
 
    python correr.py ejemplo "Los tests fallan: ejecuta python -m unittest y arregla compra.py."
"""
import json
import shutil
import sys
import tempfile
import time
from pathlib import Path
 
from agente import agente
from api import ModeloAPI
from herramientas.comun import visible

En linea, una observación conserva sus saltos de línea, porque la salida de un test en una sola línea no hay quien la lea:

    return (("error        " if b["is_error"] else "observación  ")
            + b["content"].rstrip("\n").replace("\n", "\n" + " " * 18))

Y el final del fichero, después de Traza, es el usuario, la copia y lo que ha cambiado en ella:

def usuario(ll):
    """Tú, en la terminal: cada escritura y cada comando, antes de que corran."""
    return input("  ?  ¿permites %s? [s/N] " % linea(ll)[13:]).strip().lower() == "s"
 
 
carpeta, pedido = sys.argv[1], sys.argv[2]
entorno = tempfile.mkdtemp()                        # una copia: la carpeta no se toca
shutil.copytree(carpeta, entorno, dirs_exist_ok=True)
motivo, texto, M = agente(Traza(ModeloAPI()), pedido, entorno, usuario=usuario)
print(motivo, "·", "%d mensajes en M" % len(M), "· la copia está en", entorno)
for f in sorted(Path(entorno).rglob("*")):
    ruta = f.relative_to(entorno)
    antes = Path(carpeta, ruta)
    igual = antes.is_file() and antes.read_bytes() == f.read_bytes()
    if f.is_file() and visible(ruta) and not igual:
        print("cambia", ruta.as_posix())

El bucle, el agente y el adaptador no se han tocado, y git lo puede comprobar. Esta orden no tiene que imprimir nada:

git diff --stat b5-l2 -- bucle.py agente.py api.py guionizado.py

Si te has perdido, este es el punto de control al final de la lección, con las cinco herramientas en herramientas/ y el proyecto en ejemplo/:

CheckpointAl terminar esta lección

El agente, sobre un repositorio

Primero, los tests. Son los seis de antes, los del agente ahora sobre carpetas de verdad, y los cuatro de las herramientas. Ninguno necesita el servidor:

python -m unittest -v
test_el_modelo_terco (tests.test_agente.TestAgente.test_el_modelo_terco) ... ok
test_la_escritura_sin_contenido (tests.test_agente.TestAgente.test_la_escritura_sin_contenido) ... ok
test_la_receta_de_la_web (tests.test_agente.TestAgente.test_la_receta_de_la_web) ... ok
test_la_tortilla (tests.test_agente.TestAgente.test_la_tortilla) ... ok
test_la_respuesta_cortada (tests.test_api.TestAdaptador.test_la_respuesta_cortada) ... ok
test_la_traduccion (tests.test_api.TestAdaptador.test_la_traduccion) ... ok
test_editar_ninguna_o_dos_veces (tests.test_herramientas.TestHerramientas.test_editar_ninguna_o_dos_veces) ... ok
test_editar_una_vez (tests.test_herramientas.TestHerramientas.test_editar_una_vez) ... ok
test_ejecutar_un_comando_que_falla (tests.test_herramientas.TestHerramientas.test_ejecutar_un_comando_que_falla) ... ok
test_escribir_no_pisa_un_fichero (tests.test_herramientas.TestHerramientas.test_escribir_no_pisa_un_fichero) ... ok
 
----------------------------------------------------------------------
Ran 10 tests in 0.054s
 
OK

Después, el pedido de la traza del principio, ahora con las herramientas definitivas. Con el servidor en marcha (y sin una ANTHROPIC_BASE_URL que apunte a otro sitio), ejecuta:

python correr.py ejemplo "Los tests fallan: ejecuta python -m unittest y arregla compra.py hasta que pasen."

Si tu Python se llama python3 o py, escríbelo así en el pedido, porque ejecutar corre el comando tal cual. Contesta s a las preguntas que reconozcas (los tests, un editar de compra.py) y n a cualquier otra. Esta es la mía, recortada como la de arriba (de la primera lectura dejo la línea que importa):

turno 1  29 s  749 tokens de entrada
  ←  llamada      ejecutar {"comando": "python -m unittest"}
  →  observación  código de salida 1
                  …
                  FAILED (failures=2)
turno 2  44 s  1194 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
  →  observación  """Lo que falta comprar para una receta."""
                  …
                      return [i.strip() for i in lista.split(",")]
                  …
turno 3  102 s  1359 tokens de entrada
  ←  llamada      editar {"ruta": "compra.py", "antiguo": "    return [i.strip() for i in lista.split(',')]", "nuevo": "    return [i.strip().rstrip('.') for i in lista.split(',')]"}
  →  error        antiguo no aparece en compra.py: cópialo tal cual del fichero. No se ha cambiado nada
turno 4  59 s  1590 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
  →  observación  """Lo que falta comprar para una receta."""
                  …
turno 5  116 s  1835 tokens de entrada
  ←  llamada      editar {"ruta": "compra.py", "antiguo": "    return [i.strip() for i in lista.split(',')]", "nuevo": "    return [i.strip().rstrip('.') for i in lista.split(',')]"}
  →  error        antiguo no aparece en compra.py: cópialo tal cual del fichero. No se ha cambiado nada
turno 6  76 s  2064 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
  →  observación  """Lo que falta comprar para una receta."""
                  …
turno 7  161 s  2322 tokens de entrada
  ←  llamada      editar {"ruta": "compra.py", "antiguo": "    return [i.strip() for i in lista.split(',')]", "nuevo": "    return [i.strip().rstrip('.') for i in lista.split(',')]"}
  →  error        antiguo no aparece en compra.py: cópialo tal cual del fichero. No se ha cambiado nada
turno 8  111 s  2587 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
  →  observación  """Lo que falta comprar para una receta."""
                  …
turno 9  146 s  2878 tokens de entrada
  ←  llamada      editar {"ruta": "compra.py", "antiguo": "    return [i.strip() for i in lista.split(',')]", "nuevo": "    return [i.strip().rstrip('.') for i in lista.split(',')]"}
  →  error        antiguo no aparece en compra.py: cópialo tal cual del fichero. No se ha cambiado nada
turno 10  167 s  3087 tokens de entrada
  ←  llamada      leer {"ruta": "compra.py"}
tope · 22 mensajes en M · la copia está en /tmp/tmp1a7otp6m

El agente empieza como en la traza del principio, y en el tercer turno pide editar con la línea que hay que cambiar. La cita es casi buena: el fichero dice split(","), con comillas dobles, y el modelo cita split(','). antiguo no aparece, y el fichero no se toca. Lo que sigue son cuatro vueltas iguales: el modelo vuelve a leer el fichero y vuelve a pedir la misma llamada, con las mismas comillas simples. Dos veces, en el texto que escribe justo antes, copia la línea bien, con las dobles. En el noveno turno culpa al sistema («a temporary or system-level discrepancy»), y en el décimo el bucle llega a su tope.

El pedido no se ha hecho, y compra.py está como estaba, byte a byte: correr.py no imprime ninguna línea cambia. En la traza del principio, el mismo modelo con el mismo defecto dejó un fichero que no se podía importar, con una observación que decía que todo había ido bien. Aquí cada intento ha fallado en voz alta y sin escribir. Y el defecto es siempre el mismo: lo que el modelo escribe en su texto y lo que llega en su llamada difieren justo en las comillas dobles. No es cosa del adaptador ni del protocolo, porque la API nativa del servidor devuelve la misma llamada rota. Es del modelo del curso, y el banco de pruebas del bloque tendrá que contar con él.

Tu ejecución puede ser otra, porque el modelo sortea: puede acertar la cita a la primera, o volver a escribir y encontrarse con que el fichero ya existe. Lo que tiene que coincidir es lo que promete el harness: ninguna llamada que falla cambia un byte de la copia, y cada error dice qué ha pasado.

En ejemplo/compra.py, el texto [i.strip() for i in lista.split(",")] aparece en dos líneas. El modelo llama a editar con ese texto como antiguo. ¿Qué pasa?

El agente lee lista.txt, que dice pan, leche. Antes del turno siguiente, tú añades , sal a mano. Ahora el modelo quiere añadir huevos y pide editar con antiguo pan, leche y nuevo pan, leche, huevos. ¿Qué queda en el fichero?

ejecutar("python -m unittest") termina con código de salida 1 porque un test no pasa. La observación vuelve con is_error verdadero.

Un comando se pasa de los 30 segundos y la observación dice «no ha terminado en 30 s y se ha detenido». ¿Por qué no dice «no se ha ejecutado», como un error del esquema?


El agente ya trabaja sobre ficheros de verdad, y sus herramientas no dejan que un error del modelo estropee nada sin que se note. Lo que el modelo sabe del sitio donde trabaja, en cambio, sigue siendo casi nada. Su prompt de sistema es la frase del bloque anterior, y no le dice que conteste en español, que no explique cada paso en un párrafo ni que, cuando una edición falla, copie la línea de la observación en vez de repetir la llamada. Y del proyecto no sabe ni cómo se ejecutan sus tests: el pedido de esta lección tuvo que decírselo.

Eso son dos voces que le faltan al agente, y no son la misma. Una es la del harness, que dice qué es el agente, qué herramientas tiene y qué reglas sigue. La otra es la del repositorio, que dice cómo se trabaja en él. La lección siguiente, sobre el prompt de sistema y el fichero de proyecto, escribe las dos y cuenta lo que cuestan en una ventana de 8192 tokens.

Was this helpful?
Further reading3 sources · 1 paper, 2 articles

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.

  • SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering
    paperYang, Jimenez, Wettig, Lieret, Yao, Narasimhan y Press, 2024arXiv:2405.15793EN

    La tesis de esta lección, medida: con el mismo modelo, cambiar cómo son las herramientas para leer, editar y ejecutar cambia cuántos fallos de un repositorio arregla el agente.

  • Edit formats
    articleaiderDocumentación de aiderEN

    Las dos formas de editar de esta lección, con sus nombres en otro agente: el fichero entero, que es `escribir`, y un bloque de buscar y sustituir, que es `editar`. Cuenta por qué la segunda sale más barata.

  • subprocess — Subprocess management
    articlePython Software FoundationDocumentación de PythonEN

    Todo `ejecutar` sale de `subprocess.run`: `capture_output`, `timeout` y `TimeoutExpired`. Lee también sus consideraciones de seguridad sobre `shell=True`, que es lo que la lección de permisos tiene que cerrar.