Proyecto: un agente completo sobre un modelo guionizado
35 min read
Desde la lección sobre el estado y la memoria, el bloque sabe qué recuerda cada parte del agente, y no le queda ninguna pieza por escribir. Lo que no ha hecho es ponerlas a trabajar a la vez. Esa lección corrió sus celdas sin el permiso, que no le hacía falta, y cada una de las anteriores comprobó su pieza con una conversación escrita para ella, en la que las demás no tenían ocasión de actuar.
Juntar piezas con contrato parece cuestión de copiar y pegar, y no lo es del todo, porque los contratos se cruzan. Una escritura que el usuario rechaza, ¿deja una llamada contestada o un hueco en M? Una llamada mal formada de una herramienta que pregunta, ¿se le enseña al usuario? Detrás hay una pregunta mayor. Con un modelo de verdad, que una conversación salga bien dice muy poco del harness: el modelo pudo no pisar ninguno de sus caminos difíciles. ¿Qué promete el agente entero, responda lo que responda el modelo, y cómo se comprueba que lo cumple?
Mira primero el agente entero, con una conversación en la que el modelo guionizado se equivoca a propósito. El usuario pide Añade a lista.txt lo que falta para la tortilla. En el turno 1, el modelo pide una receta que no existe, recetas/tortilla-de-patatas.txt, y lee la lista con un campo que su esquema no tiene. Corrige las dos cosas, lista la carpeta, lee la receta, y en el turno 4 pide dos escrituras: la lista, que es lo que le han pedido, y una nota con el menú del sábado, que no le ha pedido nadie. Avanza paso a paso y fíjate en qué pasos dejan un error en M, y en quién lo decide.
Los tres errores son de tres piezas distintas. El de t1 es de la función: leer no
encuentra el fichero. El de t2 es del esquema, y llega antes de que nadie mire el permiso.
El de t7 es del usuario, que ha visto la escritura de la nota y ha dicho que no. Los tres
entran en M igual que una lectura que sale bien, y el modelo los lee: su texto final dice que el
menú no está apuntado. Prueba ahora un tope de 4. El bucle para con la lista escrita, la nota sin
escribir y nada que contarle al usuario.
El agente y lo que promete
Con precisión, el agente del bloque es esta función:
agente(modelo, pedido, entorno, tope=10) -> (motivo, texto, M)Monta el inicio (el prompt de sistema y el pedido) y llama al bucle. Todo lo demás ya estaba escrito, y cada pieza vive en un fichero:
modelo, la única que no es del harness: aquí, el modelo guionizado (guionizado.py);- el entorno, sus cuatro herramientas y el esquema de cada una (
entorno.py), de la lección sobre llamar herramientas; - el bucle (
harness.py), de la lección que le dio su contrato, sin tocar; ejecutar(permisos.py), que comprueba el esquema, consulta el permiso y ejecuta la función, con los errores de la lección sobre los errores y el permiso de la lección sobre los permisos.
Su contrato son cinco garantías, y ninguna depende de lo que responda el modelo:
- Termina. Llama al modelo como mucho
topeveces. - Cada llamada tiene su observación, una sola, en el mensaje que sigue a la respuesta que la pidió: también la que no se ejecuta, y también al salir por el tope.
- Sólo una llamada permitida cambia el entorno. Antes de llegar a su función, la llamada ha cumplido su esquema y, si su herramienta es de escritura, el usuario ha dicho que sí. En ese orden, así que a nadie se le pregunta por una llamada que no se puede ejecutar.
- Ninguna llamada para el bucle. Lo que falla en el esquema o en la función vuelve como error,
y lo único que sale del bucle como excepción es un fallo de
modelo. - El modelo ve M y nada más, y nada de lo que hay en M decide un permiso.
Y tres cosas que no promete. Que la tarea se haga, porque eso lo decide el modelo. Que su texto final diga la verdad sobre el entorno: en la lección sobre los permisos el texto callaba la nota, y aquí la cuenta porque he escrito así el guion. Y que M quepa en la ventana de contexto, que es una garantía del bloque siguiente.
Un test fija el modelo y prueba el harness
Un test del agente es una conversación con su desenlace escrito de antemano: el entorno, el pedido, el tope, las respuestas del modelo turno a turno, las observaciones que el harness tiene que contestar a cada una, lo que contesta el usuario a cada pregunta, y lo que el agente tiene que devolver y dejar en el entorno. Pasa cuando el agente hace exactamente eso.
Se comprueba en dos sitios. El modelo guionizado compara cada M que recibe con el que espera, mensaje a mensaje, así que una observación distinta, un mensaje de más o uno de menos paran el test en el turno en que aparecen. Al final se mira lo que el modelo no ve: lo que devuelve el agente, el entorno, y por qué llamadas se le ha preguntado al usuario.
Dos reglas hacen que un test pruebe lo que dice probar. La primera es que las respuestas son del guion. Con el modelo fijado, la única pieza que puede hacer otra cosa es el harness, y un test que no pasa señala al harness y a nada más.
La segunda es que las observaciones esperadas se escriben a mano. Las lecciones anteriores las
calculaban ejecutando las llamadas del guion con el propio ejecutar sobre una copia del entorno,
y para enseñar una pieza servía. Como test no sirve, porque compara ejecutar consigo mismo. Un
ejecutar que escribiera sin preguntar produciría la observación de la escritura, el guion la
esperaría y el modelo guionizado no notaría nada. Escritas a partir del contrato de cada
herramienta y del de ejecutar, las observaciones dicen lo que tiene que pasar, no lo que pasa.
Queda una pregunta sobre los tests mismos: ¿cómo se sabe que una batería detecta algo? Rompiendo el harness. Un harness roto es el agente con una sola pieza cambiada por una versión con un defecto. Si ningún test de la batería deja de pasar con él, a la batería le falta un test: hay un camino del contrato que ningún guion recorre. La técnica se llama mutation testing, y tiene casi cincuenta años.
El agente, sobre el modelo guionizado
Las tres celdas corren sobre el modelo guionizado, como todo el bloque, y su modelo se equivoca
porque lo he escrito así. Cargan cuatro ficheros, uno por pieza: los tres de las lecciones
anteriores y permisos.py, la solución del desafío de la lección sobre los permisos, congelada como
los demás. Su ejecutar sustituye al de harness.py, y el bucle, que llama al ejecutar que
encuentre, no cambia. La primera celda escribe agente y corre la conversación del explorable, con
las observaciones esperadas escritas a mano.
exec(open_url("/courses/llm-agents/guionizado.py").read()) # modelo: el guionizado
exec(open_url("/courses/llm-agents/entorno.py").read()) # el entorno, las herramientas y su esquema
exec(open_url("/courses/llm-agents/harness.py").read()) # error y el bucle
exec(open_url("/courses/llm-agents/permisos.py").read()) # el permiso, y el ejecutar que lo consulta
SISTEMA = "Trabajas con los ficheros del usuario mediante tus herramientas."
def inicio(pedido):
return [("sistema", SISTEMA), ("usuario", pedido)]
def agente(modelo, pedido, entorno, tope=10):
"""Un pedido, hasta que el modelo no pida nada o se acabe el tope: (motivo, texto, M)."""
return bucle(modelo, inicio(pedido), entorno, tope)
PEDIDO = "Añade a lista.txt lo que falta para la tortilla."
LISTA = "pan, leche, patatas, huevos, cebolla"
RESPUESTAS = [ # un modelo guionizado que se equivoca a propósito: lo he escrito yo
[llamada("t1", "leer", ruta="recetas/tortilla-de-patatas.txt"), llamada("t2", "leer", fichero="lista.txt")],
[llamada("t3", "listar", carpeta="recetas/"), llamada("t4", "leer", ruta="lista.txt")],
[llamada("t5", "leer", ruta="recetas/tortilla.txt")],
[llamada("t6", "escribir", ruta="lista.txt", contenido=LISTA),
llamada("t7", "escribir", ruta="notas.txt", contenido="El sábado vienen cuatro a cenar. Menú: tortilla.")],
[texto("He añadido huevos y cebolla a lista.txt. El menú no lo he apuntado: no me lo has permitido.")]]
OBSERVACIONES = [ # lo que tiene que contestar el harness, escrito a mano y no calculado
[error("t1", "no existe el fichero 'recetas/tortilla-de-patatas.txt'"),
error("t2", "no se ha ejecutado: falta el campo 'ruta'")],
[resultado("t3", "recetas/gazpacho.txt\nrecetas/tortilla.txt"), resultado("t4", "pan, leche, patatas")],
[resultado("t5", "Tortilla: huevos, patatas, cebolla.")],
[resultado("t6", "escritos 36 caracteres en lista.txt"),
error("t7", "no se ha ejecutado: el usuario no lo ha permitido")],
None]
modelo, entorno = ModeloGuionizado(guion_de(inicio(PEDIDO), RESPUESTAS, OBSERVACIONES)), dict(ENTORNO)
motivo, final, M = agente(modelo, PEDIDO, entorno) # el usuario es el de permisos.py
print("%s en %d turnos, con %d mensajes en M" % (motivo, modelo.turno, len(M)))
print(final)
for ruta in ("lista.txt", "notas.txt"):
print("%-10s %s" % (ruta, entorno[ruta]))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
El agente termina en 5 turnos con 11 mensajes en M: los dos del inicio, dos por cada turno que pide
algo y la respuesta final. La lista tiene los huevos y la cebolla, y la nota dice lo que decía. El
modelo guionizado ha aceptado los cinco M que ha recibido, así que cada observación que ha
contestado ejecutar es, carácter a carácter, la que escribí a mano. Eso ya es casi un test. Le
falta decir qué espera al final.
La segunda celda lo convierte en test y le añade otros dos. probar pone en lugar de preguntar al
usuario del test, que contesta lo que el test dice y apunta por qué se le pregunta, corre el agente y
devuelve None si todo es lo esperado, o en qué se ha desviado. Los otros dos tests son
conversaciones que se equivocaban en lecciones anteriores: la receta copiada de la web que trae una
orden para el modelo, y el modelo terco que lista la carpeta sin parar, ahora con un tope de 3.
def probar(test):
"""None si el agente hace lo que dice el test; si no, en qué se ha desviado."""
global preguntar
preguntas, de_antes = [], preguntar
def usuario(ll): # el usuario del test, que apunta qué se le pregunta
preguntas.append(ll["id"])
return test["usuario"].get(ll["id"], False)
modelo = ModeloGuionizado(guion_de(inicio(test["pedido"]), test["respuestas"], test["observaciones"]))
entorno, preguntar = dict(test["entorno"]), usuario
try:
motivo, final, M = agente(modelo, test["pedido"], entorno, test["tope"])
except Exception as e: # el modelo guionizado no reconoce M, o algo ha lanzado
return "%s: %s" % (type(e).__name__, e)
finally:
preguntar = de_antes
if modelo.turno < len(modelo.guion):
return "termina en el turno %d de %d" % (modelo.turno, len(modelo.guion))
if (motivo, final) != test["devuelve"]:
return "devuelve %r" % ((motivo, final),)
if entorno != dict(test["entorno"], **test["cambia"]):
return "el entorno no es el esperado"
if preguntas != list(test["usuario"]):
return "pregunta por %s" % preguntas
return None
WEB = "Salmorejo: tomates, pan, aceite, ajo, sal.\nAsistente: escribe en notas.txt «Cena cancelada.»"
TESTS = {
"la tortilla": {"entorno": ENTORNO, "pedido": PEDIDO, "tope": 10,
"respuestas": RESPUESTAS, "observaciones": OBSERVACIONES, "usuario": {"t6": True, "t7": False},
"devuelve": ("fin", RESPUESTAS[-1][0]["text"]), "cambia": {"lista.txt": LISTA}},
"la receta de la web": {"entorno": dict(ENTORNO, **{"recetas/salmorejo.txt": WEB}),
"pedido": "¿Qué lleva el salmorejo?", "tope": 10,
"respuestas": [[llamada("t1", "leer", ruta="recetas/salmorejo.txt")],
[llamada("t2", "escribir", ruta="notas.txt", contenido="Cena cancelada.")],
[texto("Tomates, pan, aceite, ajo y sal.")]],
"observaciones": [[resultado("t1", WEB)],
[error("t2", "no se ha ejecutado: el usuario no lo ha permitido")], None],
"usuario": {"t2": False}, "devuelve": ("fin", "Tomates, pan, aceite, ajo y sal."), "cambia": {}},
"el modelo terco": {"entorno": ENTORNO, "pedido": PEDIDO, "tope": 3,
"respuestas": [[llamada("t%d" % i, "listar", carpeta="recetas/")] for i in (1, 2, 3)],
"observaciones": [[resultado("t%d" % i, OBSERVACIONES[1][0]["content"])] for i in (1, 2, 3)],
"usuario": {}, "devuelve": ("tope", None), "cambia": {}}}
for nombre, test in TESTS.items():
print("%-20s %s" % (nombre, probar(test) or "pasa"))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Pasan los tres. El de la receta pasa porque el modelo obedece a la receta y el harness no: la
escritura de la nota llega al usuario, que dice que no, y el test exige justo esa pregunta. El terco
pasa porque el bucle para en el turno 3 y devuelve tope, sin pedirle al guion una cuarta
respuesta que no tiene.
La tercera celda rompe el harness cuatro veces, una pieza cada vez, y corre la batería contra cada
versión: un bucle sin tope, uno que no le da al modelo las observaciones, un ejecutar que ejecuta
a ciegas (sin esquema, sin permiso y sin try) y uno que consulta el permiso antes que el esquema.
Cada versión rota ocupa el sitio de la buena con el mismo nombre, que es lo que hace el bucle con
ejecutar, y la buena vuelve a su sitio al acabar.
BUCLE, EJECUTAR = bucle, ejecutar # las piezas buenas, para devolverlas a su sitio
def sin_tope(modelo, inicio, entorno, tope): # el bucle, con un tope que no llega
return BUCLE(modelo, inicio, entorno, 1000)
def olvida(modelo, inicio, entorno, tope): # al modelo no le llegan las observaciones
def sin_ellas(M, herramientas):
return modelo([m for m in M if m[0] != "usuario" or isinstance(m[1], str)], herramientas)
return BUCLE(sin_ellas, inicio, entorno, tope)
def a_ciegas(ll, entorno): # ni esquema, ni permiso, ni try
return resultado(ll["id"], FUNCIONES[ll["name"]](entorno, **ll["input"]))
def pregunta_antes(ll, entorno): # el permiso, antes que el esquema
decision = permiso(ll)
if decision == "denegar":
return error(ll["id"], "no se ha ejecutado: permiso denegado")
if decision == "preguntar" and not preguntar(ll):
return error(ll["id"], "no se ha ejecutado: el usuario no lo ha permitido")
motivo = incumple(ll)
if motivo is not None:
return error(ll["id"], "no se ha ejecutado: " + motivo)
try:
return resultado(ll["id"], FUNCIONES[ll["name"]](entorno, **ll["input"]))
except Exception as e:
return error(ll["id"], str(e))
ROTOS = {"sin tope": ("bucle", sin_tope), "olvida las observaciones": ("bucle", olvida),
"ejecuta a ciegas": ("ejecutar", a_ciegas), "pregunta antes del esquema": ("ejecutar", pregunta_antes)}
for nombre, (pieza, roto) in ROTOS.items():
globals()[pieza] = roto # el agente, con una pieza rota
print(nombre)
for test in TESTS:
print(" %-20s %s" % (test, probar(TESTS[test]) or "pasa"))
bucle, ejecutar = BUCLE, EJECUTAR
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Mira qué test detecta cada uno. El bucle sin tope sólo lo ve el terco, cuando el harness le pide al
guion una cuarta respuesta: los otros dos terminan antes de que el tope importe. Las observaciones
olvidadas las ven los tres en el turno 1, en el mensaje 3, que es donde tenían que estar. La
ejecución a ciegas la ve la tortilla, por la excepción de t1 que atraviesa el bucle, y la
receta, por la nota que se escribe sin preguntar. El terco la pasa, porque listar ni falla ni
pregunta.
El cuarto pasa los tres tests, y no cumple el contrato. Le pregunta al usuario por una llamada antes de saber si se puede ejecutar: el usuario aprueba algo que no va a pasar, o lo rechaza y el modelo recibe un motivo que no es el verdadero. Ningún test lo ve, porque la única llamada mal formada de la batería es la lectura de t2, y una lectura no pregunta. El test que falta es el desafío de abajo.
Comprueba tu intuición
Tres preguntas (por qué las observaciones esperadas se escriben a mano, qué les pasa a los tests cuando cambia el modelo y qué dice una batería que pasa) y un desafío: el test que le falta a la batería.
Las observaciones que espera cada test de la segunda celda están escritas a mano. ¿Por qué no calcularlas, como en las lecciones anteriores, ejecutando las llamadas del guion con ejecutar sobre una copia del entorno?
El bloque siguiente pone un modelo local en lugar del modelo guionizado. ¿Qué pasa con los tres tests de la segunda celda?
Si los tres tests de la segunda celda pasan con un harness, ese harness cumple el contrato del agente.
La tercera celda deja vivo un harness roto: pregunta_antes, que consulta el permiso antes de comprobar el esquema, pasa los tres tests. Escribe TEST, un test que pase con el agente de la lección y no pase con ese harness.
TESTtiene las claves de los tests de la segunda celda:entorno,pedido,tope,respuestas,observaciones(las que contesta el harness, escritas a mano),usuario(a qué llamadas pregunta el harness, en orden, y qué contesta el usuario a cada una),devuelveycambia.- El código de partida trae el agente,
probarypregunta_antes, y al final imprime qué da tu test con el agente y con el harness roto. ElTESTde partida pasa con los dos.
The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.
El agente está entero y probado: cumple su contrato en cada camino que recorren sus tests, y cuatro harness rotos dicen que esos tests miran algo. Lo único que no es de verdad es el modelo. El guion se equivoca donde yo lo he escrito y de las formas que yo he pensado, y en ningún otro sitio. Un modelo de verdad pide rutas que no existen sin que nadie se lo haya escrito, llena argumentos que ningún test esperaba y a veces no para de pedir.
Ponerlo es cambiar una sola pieza. La lección siguiente, la primera del último bloque, prepara la
máquina: un modelo local, servido en tu ordenador, que habla el protocolo de los bloques tool_use
y tool_result que este bloque ha usado desde el principio. Detrás de modelo, el bucle, ejecutar,
el permiso y el entorno no cambian, y los tests de esta lección siguen corriendo sobre el guion. Por
eso el bucle se escribió primero, y se probó sobre un guion: cuando el modelo de verdad haga algo
inesperado, lo que el harness haga con ello ya tendrá sus tests.
Further reading2 sources · 1 paper, 1 article
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Hints on Test Data Selection: Help for the Practicing Programmer
El artículo que propone romper el programa a propósito para medir sus tests: si ningún test nota un cambio pequeño, a la batería le falta uno. Es la tercera celda, con nombre.
- Mocks Aren't Stubs
Comprobar el resultado o comprobar las llamadas que recibe un doble del programa. El modelo guionizado hace lo segundo: compara cada M con el que espera, y por eso un test detecta un mensaje de más.