<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Pytest on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/pytest/</link><description>Recent content in Pytest on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 17 Sep 2026 04:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/pytest/index.xml" rel="self" type="application/rss+xml"/><item><title>Probar agentes de LangGraph sin modelo: tests de grafo con un modelo guionizado, pytest y el checkpointer en memoria</title><link>https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/</link><pubDate>Thu, 17 Sep 2026 04:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un agente de LangGraph es un grafo con nodos deterministas y uno que no lo es. Todo lo que no es el modelo se prueba sin modelo: la estructura, las herramientas, el contexto, los límites, la persistencia, las interrupciones y el abanico. Es la mayor parte del código y la parte que rompe en silencio.&lt;/p>
&lt;p>El modelo se sustituye por &lt;code>FakeMessagesListChatModel&lt;/code> de &lt;code>langchain-core&lt;/code>, que devuelve una lista de mensajes en orden. Le falta &lt;code>bind_tools&lt;/code>, y &lt;code>create_agent&lt;/code> lo llama; un subtipo con &lt;code>bind_tools&lt;/code> que devuelva &lt;code>self&lt;/code> y guarde las herramientas que vio lo arregla. Ese subtipo es un &lt;code>BaseModel&lt;/code> de pydantic, y guardar algo en un atributo no declarado lanza &lt;code>ValueError&lt;/code>: el atributo se declara en la clase.&lt;/p>
&lt;p>Los tests cuentan checkpoints. &lt;code>InMemorySaver.list(config)&lt;/code> devuelve uno por superstep, y el número es una propiedad del grafo que cambia cuando se añade middleware. Once por turno con una herramienta en el caso 1, trece con el resumen; si un cambio lo mueve, el test lo dice antes que la base de datos.&lt;/p>
&lt;p>Escribir los tests sacó tres cosas que no estaban en los artículos. Cuando &lt;code>ToolCallLimitMiddleware&lt;/code> corta con &lt;code>exit_behavior=&amp;quot;end&amp;quot;&lt;/code>, el último mensaje es un &lt;code>AIMessage&lt;/code> sintético en inglés, &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>, y va al usuario si la aplicación no lo intercepta. El corte se detecta en &lt;code>stream_mode=&amp;quot;updates&amp;quot;&lt;/code> porque la actualización del nodo del límite lleva &lt;code>jump_to: &amp;quot;end&amp;quot;&lt;/code>. Y los contadores por ejecución no quedan en el estado del hilo; solo los del hilo.&lt;/p>
&lt;p>Las interrupciones se prueban de principio a fin: se invoca, se comprueba &lt;code>__interrupt__&lt;/code>, se reanuda con &lt;code>Command(resume=...)&lt;/code> y se comprueba que la herramienta corrió una vez con los argumentos de la decisión. El fallo a medias también: una herramienta que lanza en la primera llamada y el estado que queda antes de reanudar.&lt;/p>
&lt;p>Lo que estos tests no prueban es si el modelo elige bien la herramienta o redacta bien. Eso es evaluación, no prueba, y es el siguiente artículo.&lt;/p>
&lt;h2 id="estás-aquí-después-de-construir-antes-de-medir">Estás aquí: después de construir, antes de medir&lt;/h2>
&lt;p>La &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a> construyó cuatro agentes y los sirvió. Los artículos verificaron cada afirmación ejecutando código contra un modelo falso, y ese código era una sesión de terminal. Este artículo lo convierte en una suite de pytest que corre en cada commit, sin GPU y en menos de un segundo.&lt;/p>
&lt;p>La distinción que ordena todo lo que sigue: un test comprueba que el grafo hace lo que el código dice; una evaluación comprueba que el modelo hace lo que el negocio quiere. Lo primero es determinista y se ejecuta en CI. Lo segundo es estadístico, cuesta tokens y va en el &lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">artículo de evaluación&lt;/a>.&lt;/p>
&lt;h2 id="la-analogía-el-simulacro-de-caja">La analogía: el simulacro de caja&lt;/h2>
&lt;p>Para formar a un dependiente nuevo no hace falta un cliente. Se le pone un compañero que hace de cliente con un guion: pide esto, luego pregunta aquello, luego se enfada. Lo que se comprueba no es si el compañero actúa bien; es si el dependiente sigue el procedimiento, si sabe qué botón del TPV tocar y cuándo llamar al encargado.&lt;/p>
&lt;p>El modelo falso es el compañero con guion. Los tests comprueban el procedimiento: las herramientas, el orden, el rol, la firma del encargado. El modelo real se evalúa después, con clientes reales grabados.&lt;/p>
&lt;h2 id="el-modelo-guionizado">El modelo guionizado&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_core.language_models.fake_chat_models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">FakeMessagesListChatModel&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_core.messages&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AIMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FakeMessagesListChatModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Devuelve los mensajes de `responses` en orden y acepta herramientas.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools_vistas&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">bind_tools&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tools_vistas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tools&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">self&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nombre&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tool_calls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">nombre&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;c-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">nombre&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">}])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres cosas de estas doce líneas.&lt;/p>
&lt;p>&lt;code>FakeMessagesListChatModel&lt;/code> viene en &lt;code>langchain-core&lt;/code> y devuelve &lt;code>responses[i]&lt;/code> en la llamada &lt;code>i&lt;/code>. Con un &lt;code>AIMessage&lt;/code> que lleva &lt;code>tool_calls&lt;/code>, el nodo &lt;code>model&lt;/code> del agente lo trata como una petición de herramienta y el grafo va a &lt;code>tools&lt;/code>. Con uno que lleva solo &lt;code>content&lt;/code>, el grafo termina. Un guion es una lista de esos mensajes en el orden en que el modelo &amp;ldquo;decidiría&amp;rdquo;.&lt;/p>
&lt;p>&lt;code>bind_tools&lt;/code> no existe en el falso y &lt;code>create_agent&lt;/code> lo llama en cada vuelta del modelo para pasarle las herramientas. Sin él, &lt;code>NotImplementedError&lt;/code> en la primera invocación. El subtipo devuelve &lt;code>self&lt;/code> y de paso guarda los nombres de las herramientas, que es un dato que los tests quieren: qué vio el modelo.&lt;/p>
&lt;p>&lt;code>tools_vistas&lt;/code> está declarado como campo de clase porque el modelo falso es un modelo de pydantic. Asignar &lt;code>self.tools_vistas = ...&lt;/code> sin declararlo lanza &lt;code>ValueError: &amp;quot;ModeloGuion&amp;quot; object has no field &amp;quot;tools_vistas&amp;quot;&lt;/code>. Fue el primer fallo de la suite.&lt;/p>
&lt;p>Para el resumen y para &lt;code>profile&lt;/code>, el falso admite &lt;code>profile={&amp;quot;max_input_tokens&amp;quot;: 32768}&lt;/code> en el constructor como cualquier &lt;code>BaseChatModel&lt;/code>; se pasa cuando el agente lleva &lt;code>SummarizationMiddleware&lt;/code> con disparador por fracción, o el constructor del middleware lanza.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-1">Lo que se prueba del caso 1&lt;/h2>
&lt;p>El agente del &lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">asistente de sala&lt;/a> se construye con una función &lt;code>build(modelo, checkpointer)&lt;/code>, y las herramientas leen de un diccionario de stock en el módulo de pruebas en vez de Postgres. Lo que sigue es la suite entera; corre en medio segundo.&lt;/p>
&lt;h3 id="la-estructura">La estructura&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_estructura_del_grafo&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">nodos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_graph&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">nodes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">nodos&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;__start__&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;__end__&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;ToolCallLimitMiddleware.after_model&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">nodos&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;ModelCallLimitMiddleware.before_model&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">nodos&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>get_graph()&lt;/code> no ejecuta nada. Devuelve el grafo compilado con los nodos que el middleware añadió. Un test así falla cuando alguien quita un límite &amp;ldquo;para probar&amp;rdquo; y lo deja quitado, y cuando una subida de &lt;code>langchain&lt;/code> cambia el nombre de un nodo, que es algo que la aplicación del artículo 5 usa para filtrar el flujo.&lt;/p>
&lt;p>&lt;code>get_graph().draw_mermaid()&lt;/code> devuelve el diagrama en texto. Guardado como fichero de referencia y comparado en un test, es un test de instantánea del grafo: cualquier cambio de topología aparece en el diff del commit.&lt;/p>
&lt;h3 id="las-herramientas-y-el-contexto">Las herramientas y el contexto&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@pytest.fixture&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">dependiente&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">empleado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;E017&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;dependiente&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_stock_usa_la_tienda_del_contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;No queda.&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;¿queda?&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msgs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;unidades&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cercanas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;T002&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">14&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">modelo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tools_vistas&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;procedimiento&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El guion pide &lt;code>stock_sku&lt;/code> sin decir tienda, y la herramienta responde con &lt;code>T001&lt;/code> porque lo leyó del contexto. Si alguien cambia la herramienta para que la tienda venga como argumento del modelo, el test falla porque el guion no lo pasa. Es la garantía de que el contexto no se puede suplantar desde el prompt.&lt;/p>
&lt;p>La última aserción fija la lista de herramientas que ve el modelo, en orden. Un test de contrato: si se añade una herramienta, el test la reclama.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_dependiente_no_registra_rotura&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-4471&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">unidades&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;caída&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Avisa al encargado.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registra&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;permiso_denegado&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_encargado_si_registra&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">encargado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">empleado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;E001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-4471&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">unidades&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;caída&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Hecho.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registra&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">encargado&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Son los dos tests que importan de todo el artículo. El guion hace que el modelo pida registrar la rotura en los dos casos; lo que cambia es el contexto. Si la comprobación de rol se moviera al prompt, los dos tests pasarían con el guion y el segundo fallaría con un modelo real que obedeciera al prompt, pero el primero pasaría también con un modelo real que no lo obedeciera. La comprobación tiene que estar donde el guion no puede saltarla.&lt;/p>
&lt;h3 id="los-límites">Los límites&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_limite_de_llamadas_corta_sin_excepcion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;bucle&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ejecutadas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ejecutadas&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;limit&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tool_msgs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Un guion de veinte llamadas seguidas es un modelo en bucle. Con &lt;code>run_limit=6&lt;/code>, la primera versión del test comprobaba que había como máximo seis &lt;code>ToolMessage&lt;/code>, y falló: hay siete. El middleware, al bloquear la séptima, añade un &lt;code>ToolMessage&lt;/code> sintético con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code> y el texto &lt;code>Tool call limit exceeded. Do not make additional tool calls.&lt;/code> para esa llamada, y después un &lt;code>AIMessage&lt;/code> sintético: &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>. El test correcto cuenta las ejecutadas, que son las que no tienen &lt;code>status=&amp;quot;error&amp;quot;&lt;/code>.&lt;/p>
&lt;p>Ese &lt;code>AIMessage&lt;/code> final es el que la aplicación del artículo 5 envía al usuario como respuesta del agente, y está en inglés y lo escribe el middleware. &lt;code>ModelCallLimitMiddleware&lt;/code> hace lo mismo: &lt;code>Model call limits exceeded: run limit (3/3)&lt;/code>. La aplicación tiene que detectar el corte y sustituir el texto. La forma fiable no es buscar ese texto, es mirar el flujo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_el_corte_se_ve_en_updates&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cortes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">update&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;bucle&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}},&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">nodo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cambio&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cambio&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">cambio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;jump_to&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;end&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cortes&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nodo&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">cortes&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ToolCallLimitMiddleware.after_model&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La actualización del nodo del límite lleva &lt;code>jump_to: &amp;quot;end&amp;quot;&lt;/code>, &lt;code>run_tool_call_count: {&amp;quot;__all__&amp;quot;: 7}&lt;/code> y los dos mensajes sintéticos. Verificado en langgraph 1.2.11 y langchain 1.4.1. Es el mismo bucle de &lt;code>updates&lt;/code> que el servicio ya recorre para detectar interrupciones, y añade una rama para los cortes.&lt;/p>
&lt;p>Un detalle más que salió del test: tras el corte, &lt;code>get_state(config).values&lt;/code> tiene &lt;code>thread_tool_call_count&lt;/code> y &lt;code>thread_model_call_count&lt;/code>, pero no los contadores por ejecución. &lt;code>run_tool_call_count&lt;/code> vive en la actualización y no se persiste. Un test que quiera comprobar el contador por ejecución lo lee del flujo, no del estado.&lt;/p>
&lt;h3 id="los-checkpoints">Los checkpoints&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_checkpoints_por_turno&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;q&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">_&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">cp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">11&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Un número fijo que documenta el coste del middleware. Cuando alguien añada &lt;code>SummarizationMiddleware&lt;/code>, el test dirá 13 y el commit tendrá que cambiar el número y, con él, la estimación de filas por día del artículo 5. Es un test que existe para obligar a esa conversación.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-2">Lo que se prueba del caso 2&lt;/h2>
&lt;p>La &lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">interrupción de aprobación&lt;/a> tiene un contrato de datos y una secuencia, y las dos cosas se prueban sin modelo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">efectos&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">tuple&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">reembolsar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">float&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Emite un reembolso.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reembolso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;R-1&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_reembolso_grande_para_y_no_ejecuta&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">pedido&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">120.0&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Reembolso emitido.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;h1&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;devuelve P1&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">intr&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">intr&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;action_requests&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">intr&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;review_configs&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;allowed_decisions&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reject&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r2&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edited_action&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;pedido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">90.0&lt;/span>&lt;span class="p">}}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]}),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">90.0&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">r2&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;Reembolso emitido.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_reembolso_pequeno_no_para&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">pedido&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;P2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">20.0&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Hecho.&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;devuelve P2&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;h2&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;P2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">20.0&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Los dos tests fijan la política (&lt;code>when&lt;/code> por importe), el contrato de la interrupción (&lt;code>action_requests&lt;/code> y &lt;code>review_configs&lt;/code>), la reanudación con &lt;code>edit&lt;/code>, y la propiedad central: la herramienta no corre antes de la decisión y corre una vez después, con los argumentos editados. La lista &lt;code>efectos&lt;/code> es el instrumento; en producción es la tabla &lt;code>reembolsos&lt;/code>.&lt;/p>
&lt;p>El tercer test del caso 2 es el de la re-ejecución, con un nodo propio que registra efectos antes y después de &lt;code>interrupt&lt;/code> y comprueba la secuencia &lt;code>antes, antes, después&lt;/code>. Existe para que nadie ponga una escritura delante de una interrupción sin que un test lo diga.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-3">Lo que se prueba del caso 3&lt;/h2>
&lt;p>Del &lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">abanico&lt;/a> se prueban cuatro propiedades, y ninguna necesita el modelo: el trabajador se sustituye por una función que devuelve un análisis fijo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_consolidar_espera_a_todos&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pasos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">keys&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">u&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">pasos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">count&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">pasos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pasos&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_orden_de_analisis_es_el_del_reparto&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">05d&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_fallo_de_un_lote_conserva_los_demas&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">monkeypatch&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fallos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">analizar_con_fallo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;referencias&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;SKU-00050&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">fallos&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fallos&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="ne">ConnectionError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gateway&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">analizar_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">analizar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">analizar_con_fallo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">retry_policy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pytest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">raises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="ne">ConnectionError&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">st&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_state&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">st&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">150&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">st&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">next&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">,)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">200&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_cache_evita_la_segunda_pasada&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamadas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">analizar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">lambda&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">llamadas&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">analizar_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">))[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">cache&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">InMemoryCache&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;b&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">llamadas&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">4&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El primero es el test de &lt;code>defer=True&lt;/code>: si alguien lo quita, &lt;code>consolidar&lt;/code> aparece antes del último lote. El segundo fija el orden determinista del reductor. El tercero es el más valioso: con un lote que falla y sin &lt;code>retry_policy&lt;/code>, el estado conserva los 150 análisis de los tres lotes que terminaron, &lt;code>next&lt;/code> apunta al trabajador, y &lt;code>invoke(None)&lt;/code> completa solo el que faltaba. El cuarto cuenta llamadas reales con y sin caché: cuatro lotes de 50 en la primera pasada y cero en la segunda.&lt;/p>
&lt;p>Lo que no se prueba aquí es la concurrencia. Se puede medir el pico de tareas en vuelo con un contador y un candado, como se hizo para el artículo, pero depende del número de CPU de la máquina de CI, y un test que pasa en el portátil y falla en el runner no es un test. La concurrencia se fija con &lt;code>max_concurrency&lt;/code> y se mide en el despliegue.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-4">Lo que se prueba del caso 4&lt;/h2>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">bucle de fichas&lt;/a> tiene dos agentes y un contador. Con el modelo guionizado los dos agentes se controlan por separado:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_tres_rechazos_van_a_revision_manual&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">generador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_generador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Ficha&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">titulo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Cafetera 1L&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">descripcion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">atributos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">evaluador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Veredicto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">aprobada&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">problemas&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;criterio&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;C04&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;detalle&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">}])&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">_&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">generador&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluador&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado_inicial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SKU-1&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">h&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;historial&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">ultimo_nodo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">g&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_titulo_largo_se_rechaza_aunque_el_modelo_apruebe&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">generador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_generador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Ficha&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">titulo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">90&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">descripcion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">atributos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">evaluador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Veredicto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">aprobada&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">problemas&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[])]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pytest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">raises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">StructuredOutputValidationError&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">generador&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluador&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado_inicial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SKU-1&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con &lt;code>ToolStrategy&lt;/code> en los tests, la salida estructurada es una llamada a herramienta con el nombre del esquema, y el guion la produce con &lt;code>llamada(&amp;quot;Ficha&amp;quot;, ...)&lt;/code>. En producción los agentes llevan &lt;code>ProviderStrategy&lt;/code>; en los tests se usa &lt;code>ToolStrategy(esquema, handle_errors=False)&lt;/code> a propósito, porque el modelo falso no genera JSON con esquema y porque con &lt;code>handle_errors=True&lt;/code>, el valor por defecto, un fallo de validación se convierte en un &lt;code>ToolMessage&lt;/code> de error y el agente vuelve a llamar al modelo, que en un guion es el siguiente mensaje de la lista y no un reintento. Con &lt;code>handle_errors=False&lt;/code> la excepción es &lt;code>StructuredOutputValidationError&lt;/code>, la misma que lanza &lt;code>ProviderStrategy&lt;/code> en producción; verificado con un título de 90 caracteres y &lt;code>max_length=10&lt;/code>. Es una de las pocas diferencias entre el agente probado y el desplegado, y se documenta en la fixture.&lt;/p>
&lt;p>El segundo test fija que la validación de pydantic manda por encima del evaluador: un título de 90 caracteres no llega a evaluarse.&lt;/p>
&lt;h2 id="dónde-corren-y-qué-no-cubren">Dónde corren y qué no cubren&lt;/h2>
&lt;p>La suite de los cuatro casos corre con &lt;code>InMemorySaver&lt;/code> e &lt;code>InMemoryCache&lt;/code>, sin Postgres, sin vLLM y sin red; los seis tests del caso 1 de arriba tardan medio segundo. Va en el pre-commit y en CI.&lt;/p>
&lt;p>Un segundo grupo, marcado con &lt;code>@pytest.mark.postgres&lt;/code>, repite los tests de checkpoint y de retención contra un Postgres real levantado en un contenedor: &lt;code>setup()&lt;/code> en una base limpia, el número de filas en &lt;code>checkpoints&lt;/code> y &lt;code>checkpoint_writes&lt;/code> tras un turno, &lt;code>delete_thread&lt;/code> y la consulta de retención por &lt;code>checkpoint-&amp;gt;&amp;gt;'ts'&lt;/code>. Corre en CI y no en el pre-commit. Es el grupo que detecta una subida de &lt;code>langgraph-checkpoint-postgres&lt;/code> que añade una migración o cambia una tabla.&lt;/p>
&lt;p>Lo que ninguno de los dos grupos cubre:&lt;/p>
&lt;ul>
&lt;li>Si el modelo elige &lt;code>stock_sku&lt;/code> cuando el dependiente pregunta por stock. El guion lo decide.&lt;/li>
&lt;li>Si la respuesta al cliente es correcta, está en castellano y no promete plazos.&lt;/li>
&lt;li>Si el evaluador de fichas aprueba lo que catálogo aprobaría.&lt;/li>
&lt;li>Si el planificador de compras ordena bien los proveedores.&lt;/li>
&lt;li>La latencia, los tokens y el coste.&lt;/li>
&lt;/ul>
&lt;p>Todo eso depende del modelo y se mide con datos, no se afirma con aserciones. Es el siguiente artículo.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Hay un &lt;code>ModeloGuion&lt;/code> con &lt;code>bind_tools&lt;/code> y un campo declarado para las herramientas vistas.&lt;/li>
&lt;li>Cada agente se construye con una función &lt;code>build(modelo, checkpointer)&lt;/code> que los tests pueden llamar.&lt;/li>
&lt;li>Las herramientas leen de un origen sustituible (diccionario en tests, Postgres en producción).&lt;/li>
&lt;li>Hay un test de estructura por agente y una instantánea Mermaid del grafo.&lt;/li>
&lt;li>Cada herramienta con rol tiene dos tests: uno que pasa por el rol y otro que no.&lt;/li>
&lt;li>Los tests de límite cuentan ejecuciones, no &lt;code>ToolMessage&lt;/code>, y comprueban &lt;code>jump_to&lt;/code> en &lt;code>updates&lt;/code>.&lt;/li>
&lt;li>Hay un test de checkpoints por turno con el número exacto.&lt;/li>
&lt;li>Las interrupciones se prueban con &lt;code>invoke&lt;/code>, &lt;code>__interrupt__&lt;/code>, &lt;code>Command(resume)&lt;/code> y una lista de efectos.&lt;/li>
&lt;li>El abanico tiene tests de &lt;code>defer&lt;/code>, de orden, de fallo parcial y de caché.&lt;/li>
&lt;li>Los agentes de salida estructurada se prueban con &lt;code>ToolStrategy&lt;/code> y la diferencia está documentada.&lt;/li>
&lt;li>Los tests que necesitan Postgres van en un grupo aparte que corre en CI.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El modelo falso no tiene &lt;code>bind_tools&lt;/code>.&lt;/strong> &lt;code>NotImplementedError&lt;/code> en la primera invocación de &lt;code>create_agent&lt;/code>. Un subtipo de tres líneas.&lt;/p>
&lt;p>&lt;strong>El subtipo es pydantic.&lt;/strong> Un atributo no declarado lanza &lt;code>ValueError&lt;/code>. Declararlo en la clase.&lt;/p>
&lt;p>&lt;strong>El corte por límite deja siete &lt;code>ToolMessage&lt;/code> con &lt;code>run_limit=6&lt;/code>.&lt;/strong> El séptimo es sintético, con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code>. Contar las ejecutadas.&lt;/p>
&lt;p>&lt;strong>El mensaje final del corte es inglés fijo del middleware.&lt;/strong> &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>. Detectarlo por &lt;code>jump_to&lt;/code> en &lt;code>updates&lt;/code>, no por el texto, y sustituirlo antes de enviarlo.&lt;/p>
&lt;p>&lt;strong>Los contadores por ejecución no se persisten.&lt;/strong> &lt;code>run_tool_call_count&lt;/code> está en la actualización del nodo, no en &lt;code>get_state&lt;/code>. Los del hilo sí.&lt;/p>
&lt;p>&lt;strong>Un test de concurrencia depende de la máquina.&lt;/strong> El pool de hilos por defecto es cpu+4. No se afirma en CI; se mide en el despliegue.&lt;/p>
&lt;p>&lt;strong>&lt;code>ToolStrategy&lt;/code> en los tests, &lt;code>ProviderStrategy&lt;/code> en producción.&lt;/strong> Es una diferencia real y hay que escribirla en la fixture, no descubrirla en un incidente.&lt;/p>
&lt;p>&lt;strong>Un guion que pasa no dice que el modelo pasaría.&lt;/strong> Los tests prueban el grafo con las decisiones fijadas. Las decisiones se evalúan aparte.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Cuatro agentes probados en segundos y sin modelo. Lo que se prueba es todo lo que un modelo no puede romper y un desarrollador sí: qué herramienta ve el modelo, qué hace cada una con cada rol, cuándo se para, cuántas filas deja, y qué queda cuando algo falla a medias. Lo que no se prueba es lo que solo el modelo decide, y para eso hacen falta datos reales, un evaluador y una forma de comparar dos versiones. Es lo que viene a continuación.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">Evaluar agentes de LangGraph con datasets de Langfuse&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Modelos falsos de &lt;code>langchain-core&lt;/code> (&lt;code>FakeMessagesListChatModel&lt;/code>, &lt;code>GenericFakeChatModel&lt;/code>): &lt;a href="https://reference.langchain.com/python/langchain_core/language_models/fake_chat_models/">https://reference.langchain.com/python/langchain_core/language_models/fake_chat_models/&lt;/a>&lt;/li>
&lt;li>Middleware de LangChain 1.x (&lt;code>ToolCallLimitMiddleware&lt;/code>, &lt;code>ModelCallLimitMiddleware&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langchain/middleware">https://docs.langchain.com/oss/python/langchain/middleware&lt;/a>&lt;/li>
&lt;li>Persistencia en LangGraph (&lt;code>InMemorySaver&lt;/code>, &lt;code>get_state&lt;/code>, &lt;code>list&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.middleware.tool_call_limit&lt;/code> (langchain 1.4.1), método &lt;code>after_model&lt;/code>&lt;/li>
&lt;li>pytest: &lt;a href="https://docs.pytest.org/en/stable/">https://docs.pytest.org/en/stable/&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>