<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Better Conversational Agents]]></title><description><![CDATA[As an AI developer there's pressure to quickly deliver reliable agents. At "some scale"
the standard approaches hit an invisible wall as the model fails to cope]]></description><link>https://adam-lang.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aa76af836303380aab545c5/b5baac8d-a47a-44f9-93b9-1fa286aca69d.png</url><title>Better Conversational Agents</title><link>https://adam-lang.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 24 Sep 2026 17:10:20 GMT</lastBuildDate><atom:link href="https://adam-lang.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Jev as the Turn Interpreter: A Conversational Agent Without Tool Calling]]></title><description><![CDATA[Replacing the tool-calling LLM with a jev classifier cut total cost from $0.2866 to $0.0265 and p90 turn latency from 6,421ms to 3,508ms on the same 11 scripted conversations. Both agents passed 11 of]]></description><link>https://adam-lang.hashnode.dev/jev-as-the-turn-interpreter-a-conversational-agent-without-tool-calling</link><guid isPermaLink="true">https://adam-lang.hashnode.dev/jev-as-the-turn-interpreter-a-conversational-agent-without-tool-calling</guid><category><![CDATA[AI reliability]]></category><category><![CDATA[agentic AI]]></category><category><![CDATA[ai code design]]></category><dc:creator><![CDATA[Adam Lang]]></dc:creator><pubDate>Mon, 21 Sep 2026 12:29:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa76af836303380aab545c5/ea21f561-9087-4e07-beda-17d32ead209a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Replacing the tool-calling LLM with a jev classifier cut total cost from $0.2866 to $0.0265 and p90 turn latency from 6,421ms to 3,508ms on the same 11 scripted conversations. Both agents passed 11 of 11.</em></p>
<p>This post continues the previous one: <a href="https://adam-lang.hashnode.dev/how-to-improve-agentic-apis-expose-a-simple-workflow-api">How to improve agentic APIs: expose a simple workflow API</a>.</p>
<h2>Summary</h2>
<p>The previous post moved the workflow into deterministic code. The model still read each user turn and emitted structured tool calls. This post replaces that step with Jev, a TypeSafe model that answers classification questions. Jev interprets each user turn. Code applies the result to the booking state. The LLM has no tools and only crafts a conversational reply to speak to the user. The LLM is the "mouth" only.</p>
<p>Booking a tennis court needs free text fields such as a location and a contact name. Jev cannot generate text. It can only choose between options. I offer it the numbered words of the user's message and ask which word starts and which word ends the value. Code then copies that span verbatim.</p>
<p>The Jev agent and the simple workflow API agent were run on the same 11 scripted conversations. The Jev agent's speaker is now <code>google/gemma-3-12b-it</code>, a small non-reasoning model. The simple workflow API agent still uses <code>deepseek/deepseek-v4-flash-20260731</code>. Each passed 11 of 11. Total cost fell 90.8%, from $0.2866 to $0.0265. LLM input tokens fell 86.6%. Turn latency p50 fell 33.5%, from 2,974ms to 1,978ms. Turn latency p90 fell 45.4%, from 6,421ms to 3,508ms. Source: <code>results/jev-gemma-vs-simple-workflow-api-deepseek.md</code>.</p>
<h2>Introduction</h2>
<p>There is large potential to improve conversational agents with Jev. The previous experiment is worth revisiting to see how much further tokens, cost, latency and reliability can be improved.</p>
<p>As in the <a href="https://www.youtube.com/watch?v=jOdkVNsVW8M">Jev Doom launch video</a>, the key is to re-imagine the problem space as a classifier. Most of a booking conversation fits this shape. The user picks a surface, a duration or a time. Each is a choice from a small closed set.</p>
<p>Booking a tennis court is different from Doom in one way. It requires calling tools with free text fields. Jev supports neither tool calling nor generating free text values.</p>
<p>The question for this post is whether that limit can be overcome without compromising on latency, cost or reliability.</p>
<h2>Jev Agent Design</h2>
<p>The agent is in <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/agents/jev_agent.py"><code>agents/jev_agent.py</code></a>. The classification questions are built in <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/jev/questions.py"><code>jev/questions.py</code></a>.</p>
<p>The tennis booking vocabulary is re-imagined as a set of classification questions that Jev can answer. Jev is the turn interpreter. The LLM is only the speaker.</p>
<h3>The modified agent loop</h3>
<p>A tool-calling agent runs a loop in which the LLM reads the conversation, decides whether to call a tool, receives the tool result and writes the reply. In the previous post's agent, that loop allowed up to one tool call per turn.</p>
<p>The Jev agent modifies that loop. Each turn runs these steps in a fixed order:</p>
<ol>
<li><p>Jev interprets the user's turn by scoring the classification questions.</p>
</li>
<li><p>Code converts the answers to slot updates and applies them through the workflow engine.</p>
</li>
<li><p>The engine returns a payload describing the current state and the next question.</p>
</li>
<li><p>The LLM receives the payload and writes the reply.</p>
</li>
</ol>
<p>Jev is the interpreter in step 1. The LLM only appears in step 4. There is exactly one Jev request and one speaker call per turn, and the LLM has no tools, so it cannot call anything or choose the next step.</p>
<h3>How it works</h3>
<p>Each user turn triggers one Jev request. The request carries the booking state, the last assistant message and the user's turn. It asks these questions:</p>
<ul>
<li><p>One Choice for the turn's act: <code>answers_current</code>, <code>corrects_earlier</code>, <code>gives_later_info</code>, <code>asks_question</code>, <code>confirms</code>, <code>declines</code>, <code>restart_or_cancel</code>, <code>off_topic</code>.</p>
</li>
<li><p>One Choice per closed-vocabulary slot: surface, indoor or outdoor, skill level, duration, players, equipment rental and date. Each has a <code>not_mentioned</code> option.</p>
</li>
<li><p>Two Choices per free-text slot (area and contact name) to find the span. See Text Capture Challenges.</p>
</li>
</ul>
<h3>What <code>jev/apply.py</code> does</h3>
<p><a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/jev/apply.py"><code>jev/apply.py</code></a> converts Jev's answers to slot updates. Answers below a 0.6 confidence threshold are not applied. They are passed to the speaker as ambiguities to clarify.</p>
<p>An ambiguity string carries the guessed value, its confidence, and the user's own words for that turn, not just a bare slot name. Without that context, the speaker had to replay the whole conversation to work out what an ambiguity was even about.</p>
<p>The workflow engine applies the updates deterministically. The LLM then receives the engine payload and writes the reply. It has no tools.</p>
<h2>Text Capture Challenges</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6aa76af836303380aab545c5/c791f955-5f14-4a52-9403-9f90ecddfb9e.png" alt="Jev picks the start and end word of a free-text value, and code copies the text" style="display:block;margin:0 auto" />

<p>Jev answers with a choice. It does not generate text. Each slot that needs text is mapped to a choice.</p>
<ul>
<li><p>Court date: offer the next 14 dates, starting today, as choices. An ISO date typed by the user is also matched by regex and takes priority.</p>
</li>
<li><p>Court time: once an availability search has run, offer every time slot of the courts found, plus <code>not_mentioned</code>. Before the search, this question is not asked.</p>
</li>
<li><p>Court name: once an availability search has run, offer the names of the courts found, plus <code>not_mentioned</code>.</p>
</li>
<li><p>Location: split the user's turn on whitespace into numbered words. The split is capped at 250 words. Ask Jev for the start word and the end word of the location. Code joins those words and strips edge punctuation.</p>
</li>
<li><p>Contact name: the same start and end word method.</p>
</li>
<li><p>Email: matched by regex in code. Jev is not used.</p>
</li>
</ul>
<p>A span is only accepted if the start index is not after the end index. The confidence of a span is the lower of its two edges. Free text values are never generated. They are always copied from the user's own words.</p>
<h2>Results</h2>
<p>Jev's speaker is <code>google/gemma-3-12b-it</code>, a small non-reasoning model. The simple workflow API agent uses <code>deepseek/deepseek-v4-flash-20260731</code> with default reasoning effort, its own default model. Both agents ran the same 11 scenarios. Source: <code>results/jev-gemma-vs-simple-workflow-api-deepseek.md</code>, the most recently generated comparison in the repo. Both agents passed 11/11 in this run, and the per-scenario detail shows reasoning tokens present on every simple_workflow_api call, consistent with its default reasoning effort setting.</p>
<p>The code and prompts are still under active iteration. Treat these numbers as a measurement of the current state, not a settled result.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>jev</th>
<th>simple_workflow_api</th>
</tr>
</thead>
<tbody><tr>
<td>Model</td>
<td>google/gemma-3-12b-it</td>
<td>deepseek/deepseek-v4-flash-20260731</td>
</tr>
<tr>
<td>Scenarios passed</td>
<td>11/11</td>
<td>11/11</td>
</tr>
<tr>
<td>LLM calls</td>
<td>137</td>
<td>261</td>
</tr>
<tr>
<td>LLM input tokens</td>
<td>180,537</td>
<td>1,351,224</td>
</tr>
<tr>
<td>LLM input tokens per call</td>
<td>1,318</td>
<td>5,177</td>
</tr>
<tr>
<td>LLM output tokens</td>
<td>11,372</td>
<td>27,278</td>
</tr>
<tr>
<td>Jev calls</td>
<td>137</td>
<td>0</td>
</tr>
<tr>
<td>Jev input tokens</td>
<td>375,330</td>
<td>0</td>
</tr>
<tr>
<td>Jev output tokens</td>
<td>145,281</td>
<td>0</td>
</tr>
<tr>
<td>LLM cost</td>
<td>$0.0107</td>
<td>$0.2866</td>
</tr>
<tr>
<td>Jev cost</td>
<td>$0.0158</td>
<td>none</td>
</tr>
<tr>
<td><strong>Total cost</strong></td>
<td><strong>$0.0265</strong></td>
<td><strong>$0.2866</strong></td>
</tr>
<tr>
<td>Max tool calls per turn</td>
<td>0</td>
<td>2</td>
</tr>
<tr>
<td>LLM latency p50</td>
<td>1,564ms</td>
<td>1,453ms</td>
</tr>
<tr>
<td>LLM latency p90</td>
<td>3,075ms</td>
<td>3,897ms</td>
</tr>
<tr>
<td>Jev latency p50</td>
<td>353ms</td>
<td>none</td>
</tr>
<tr>
<td>Jev latency p90</td>
<td>856ms</td>
<td>none</td>
</tr>
<tr>
<td>Turn latency p50</td>
<td>1,978ms</td>
<td>2,974ms</td>
</tr>
<tr>
<td>Turn latency p90</td>
<td>3,508ms</td>
<td>6,421ms</td>
</tr>
</tbody></table>
<h2>Observations</h2>
<ul>
<li><p>Jev's own cost, $0.0158, is 59.6% of the Jev agent's $0.0265 total, more than its speaker call ($0.0107).</p>
</li>
<li><p>Input tokens per LLM call fell from 5,177 to 1,318. The speaker never sees tool schemas, conversation history for extraction, or anything beyond the current payload.</p>
</li>
<li><p>LLM output tokens fell 58.3%, from 27,278 to 11,372. <code>google/gemma-3-12b-it</code> produces no reasoning tokens, unlike the reasoning-capable model behind simple_workflow_api.</p>
</li>
<li><p>The speaker call itself (LLM latency, excluding Jev) was close between the two models: p50 1,564ms for Jev's speaker against 1,453ms for the tool-calling agent's calls, and p90 3,075ms against 3,897ms. Most of the turn latency gap comes from the tool-calling agent's extra round trips, up to 2 tool calls per turn, rather than from any single call being slower.</p>
</li>
<li><p>Reliability was equal at 11/11 each. Neither agent had a failed scenario.</p>
</li>
</ul>
<h2>Learnings</h2>
<ul>
<li><p>Moving turn interpretation to Jev cut LLM input tokens per call from 5,177 to 1,318, an 86.6% drop. The speaker never sees tool schemas, or anything beyond the instructions for the current conversation step.</p>
</li>
<li><p>Small vertical domains (such as booking a tennis court) are a good fit for workflow -&gt; fixed grammar conversion.</p>
</li>
<li><p>Re-conceptualising conversational agents around function and specialisation unlocks necessary optimisations:</p>
<ul>
<li><p>Turn interpreter, remodelled as a classification problem</p>
</li>
<li><p>State tracking, in deterministic code</p>
</li>
<li><p>Workflow state advancement, using a simple domain grammar in deterministic code, no tool calls</p>
</li>
<li><p>Natural conversation understanding and generation, with a small non-reasoning LLM</p>
</li>
</ul>
</li>
<li><p>Tool calling has always been an area of weakness with LLMs. Removing it altogether is appealing from a reliability standpoint, and the speaker's own tool-free design is part of why a small model can do its job: tool calling is itself a skill smaller models tend to be weak at.</p>
</li>
<li><p>Running the 11 scenarios in parallel rather than sequentially was a big time saver, since each scenario is an independent scripted conversation.</p>
</li>
</ul>
<h3>Reasoning model learnings</h3>
<ul>
<li><p>The speaker's reasoning, not the workflow logic, was the main cost and latency problem earlier in this project. Rather than continuing to manage that through prompt changes, swapping the speaker to a non-reasoning model removed the problem at the source. With the same prompt and payload shape, moving the speaker from <code>deepseek/deepseek-v4-flash-20260731</code> to <code>google/gemma-3-12b-it</code> cut speaker output tokens from 18,243 to 11,372 (37.7% fewer) and total cost from $0.0617 to $0.0265 (57.1% cheaper). Turn latency p90 fell from 4,994ms to 3,508ms (29.8% lower), while turn latency p50 was roughly flat, 1,834ms against 1,978ms. Sources: <code>results/jev-vs-simple-workflow-api-5-speaker-role-reframe.md</code> and <code>results/jev-gemma-vs-simple-workflow-api-deepseek.md</code>.</p>
</li>
<li><p>Sending the workflow payload to the speaker as a tool-result message, instead of as plain text in the user turn, cut its reasoning on its own, before the model swap. Tool-result messages are the channel a model is trained to quote as fact. Plain text is treated as prose to compose, which invited second-guessing of exact values such as prices and court names.</p>
</li>
<li><p>Since the speaker's only job is to communicate the workflow state to the user, not to decide anything, a reasoning model was never necessary for it. <code>google/gemma-3-12b-it</code>, a 12B model, is confirmed to work at 11/11 with zero reasoning tokens.</p>
</li>
<li><p>Some coding agents, such as Claude, are averse to reading reasoning traces. The traces give excellent insight into why LLM calls use more tokens and take longer. Explicitly asking the coding agent to read them is worth it when a latency or token result is surprising.</p>
</li>
</ul>
<h2>Future work</h2>
<ul>
<li><p>Score conversation quality as part of the eval results, not just pass/fail and timing. Quality looked fine in manual testing, but was not tracked or measured.</p>
</li>
<li><p>More evals to measure how well the Jev agent handles edge cases compared with an LLM.</p>
</li>
<li><p>Repeat runs on more models, as in the previous post, to measure run-to-run variance. A single scenario is 9% of the pass rate.</p>
</li>
<li><p>Stress-test free text capture: multi-word names, names that are also common words, values later in a long message, and messages over the 250 word cap. Add a check that a contact name span is not an email address.</p>
</li>
<li><p>Ask only the questions relevant to the current node, to cut Jev input tokens.</p>
</li>
<li><p>Tune the 0.6 confidence threshold against the rate of wrongly applied and wrongly skipped slots.</p>
</li>
</ul>
<h2>Code</h2>
<p>All source code is in the <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api">GitHub repository</a>. The Jev agent is in <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/agents/jev_agent.py"><code>src/tennis_booking/agents/jev_agent.py</code></a>. The question building is in <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/jev/questions.py"><code>src/tennis_booking/jev/questions.py</code></a>.</p>
<p>This post and the <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api">code repository</a> were written in collaboration with Claude.</p>
]]></content:encoded></item><item><title><![CDATA[Evals as Unit Tests: A Practical Approach for Agentic Systems]]></title><description><![CDATA[This article proposes a simple discipline for evaluating agentic LLM systems: treat evals as unit tests. They live in the repository, they run before merge, and a failure blocks the build, exactly as ]]></description><link>https://adam-lang.hashnode.dev/evals-as-unit-tests-a-practical-approach-for-agentic-systems</link><guid isPermaLink="true">https://adam-lang.hashnode.dev/evals-as-unit-tests-a-practical-approach-for-agentic-systems</guid><category><![CDATA[ai agents]]></category><category><![CDATA[llm]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[Adam Lang]]></dc:creator><pubDate>Fri, 18 Sep 2026 05:34:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa76af836303380aab545c5/fc038afb-0592-410b-b874-23b6c6eed592.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This article proposes a simple discipline for evaluating agentic LLM systems: treat evals as unit tests. They live in the repository, they run before merge, and a failure blocks the build, exactly as any other test would. The idea sounds obvious once stated, yet it runs against the common practice of treating evaluation as a separate process that happens after code has shipped.</p>
<p>Some background on where this comes from. I have spent 20+ years as a software developer, the last five building agentic AI products, and somewhere in there I picked up the kind of scar tissue that only comes from shipping a regression you should have caught. When I went looking for practical guidance on structuring evals for agentic systems, I found plenty of high-level takes and a crowd of paid products singing their own praises, but very few working playbooks. What follows is the approach that has worked for me, presented with runnable code. It is offered as a method to adopt and to argue with, in roughly equal measure.</p>
<h2>The one-line change that broke everything</h2>
<p>It started small. A prompt tweak: tightening a sentence, reordering two instructions, the kind of edit that looks harmless enough to skip a full review. It shipped. Nothing exploded, no alerts fired, no dashboard turned red.</p>
<p>A few days later, customer complaints started trickling in. The agent's outputs had shifted. Not broken exactly, just subtly wrong in a way that only showed up once real users leaned on it. We traced it back to that one-line change. It had nudged the model's behaviour just enough to break a downstream assumption nobody had written down anywhere. We found out from customers, not from a test run.</p>
<p>The lesson is one every backend engineer already knows: a prompt change is a code change. If you wouldn't ship a backend change without running your test suite, you shouldn't ship a prompt change without running yours. If you have customers, you need regression coverage.</p>
<h2>Evals as unit tests, not next-day QA</h2>
<p>A common pattern on teams building agents looks like this: evals exist, but they run as a separate process, often owned by a separate team, sometime after the code has shipped. This is an understandable place to end up. Eval tooling grew out of offline benchmarking, and it is natural to inherit that batch-oriented shape. But the ordering limits what the evals can catch. By the time the suite reports a regression, the regression has already reached users.</p>
<p>A unit test offers a different contract. It runs before merge, it blocks the build, and it gives a fast yes or no before the change touches anything real. There is no structural reason evals cannot meet the same bar. An eval can be a pytest test like any other, run the same way you would run any other suite:</p>
<pre><code class="language-bash">uv run pytest -m eval_unit_test -v
</code></pre>
<p>The suite lives in the repo, it runs before merge, and a failure blocks the build. The rest of this article is about what those tests look like in practice.</p>
<h2>The productionisation problem</h2>
<p>Most agent tutorials stop the moment the demo works. Getting from "it works when I click through it" to "I'd trust this with real customers" is a different project entirely. It means:</p>
<ul>
<li>Replacing vibes with evals (the subject of this whole article)</li>
<li>Putting prompts under version control and treating changes to them like code changes</li>
<li>Adding observability, logging every tool call, every model response, latency, cost</li>
<li>Building a test harness so you can iterate without fear of regression</li>
<li>Deciding, concretely, what "good enough" means, and measuring it continuously</li>
</ul>
<p>Evals are the load-bearing piece in that list. Without them, you're flying blind every time you change a prompt, upgrade a model, or extend the agent's capabilities. The move from POC to production is the move from "I'll know it when I see it" to "I have a suite that tells me within seconds whether this change is safe."</p>
<p>The industry has noticed, too. Job descriptions for agent engineers now routinely name evaluation frameworks and LLM-as-a-judge as first-class skills, listed alongside traditional unit testing, and they increasingly describe the work as non-deterministic engineering: handling probabilistic outputs, self-correction loops, and graceful degradation as a core discipline, not an edge case. One recent role description puts it plainly:</p>
<blockquote>
<p>"Architect solutions that account for the probabilistic nature of agentic systems. Implement robust error handling, self-correction loops, and graceful degradation for when agents hallucinate, loop, or encounter edge cases."</p>
</blockquote>
<p>Evals are how you practise that discipline day to day.</p>
<h2>Patterns that hold up in practice</h2>
<p>To make the patterns concrete, I built a small sandbox project: a tennis-court booking agent (a LangGraph agent with tools like <code>get_user_location</code>, <code>find_nearby_courts</code>, <code>find_available_slots</code>, and <code>confirm_and_process_payment</code>) with an eval suite alongside it. The full code is on GitHub: <a href="https://github.com/adam-lang2/tennis-booking">github.com/adam-lang2/tennis-booking</a>. The snippets below come straight from it. They're illustrative, an example of what these patterns can look like rather than a production suite to copy wholesale, but every snippet runs, and the shapes translate directly to real systems.</p>
<h3>1. Build a multi-turn test harness</h3>
<p>Agents aren't stateless. They carry conversation history, tool call chains, and accumulated context across turns, and that's where most real failures live: three or five turns in, not in the first response. Your evals need to simulate realistic multi-turn exchanges, not just single prompt and response pairs. The sandbox does this with a <code>ConversationBuilder</code>: a fluent API for assembling a realistic history before you ever call the model.</p>
<pre><code class="language-python"># evals/convo_builder.py
class ConversationBuilder:
    def user(self, content: str) -&gt; "ConversationBuilder":
        self._messages.append({"role": "user", "content": content})
        return self

    def assistant(self, content: str) -&gt; "ConversationBuilder":
        self._messages.append({"role": "assistant", "content": content})
        return self

    def assistant_tool_calls(self, *calls: dict) -&gt; "ConversationBuilder":
        formatted = [
            {"id": c["id"], "type": "function",
             "function": {"name": c["name"], "arguments": json.dumps(c["args"])}}
            for c in calls
        ]
        self._messages.append({"role": "assistant", "content": None, "tool_calls": formatted})
        return self

    def tool_result(self, tool_call_id: str, name: str, result) -&gt; "ConversationBuilder":
        content = result if isinstance(result, str) else json.dumps(result, default=str)
        self._messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": name, "content": content})
        return self
</code></pre>
<p>In a test, that reads as a script of exactly what already happened in the conversation, with mocked tool results standing in for the real API calls:</p>
<pre><code class="language-python"># evals/test_booking_evals.py
builder = (
    ConversationBuilder()
    .user("I'd like to book a tennis court.")
    .assistant("Where would you like to play?")
    .user("Near Central Park, New York City.")
    .assistant_tool_calls(
        tc("call_loc", "get_user_location", user_input="Central Park, New York City")
    )
    .tool_result("call_loc", "get_user_location", MOCK_LOCATION_RESULT)
    .assistant_tool_calls(
        tc("call_courts", "find_nearby_courts", location="Central Park, New York City")
    )
    .tool_result("call_courts", "find_nearby_courts", MOCK_COURTS)
)
builder.call_agent()
</code></pre>
<p>Everything up to <code>.call_agent()</code> is fixture: a scripted history, not a live run. Only the last step is real. Which brings up the second pattern.</p>
<h3>2. Test one real step at a time, against a scripted history</h3>
<p>You don't need to run the whole agent loop live to get a meaningful eval. The trick is to call the real model, with the real system prompt and the real tool bindings, for exactly one step, and stop before any tool actually executes. In the sandbox, that's what the agent caller does:</p>
<pre><code class="language-python"># evals/agent_caller.py
"""Helpers for calling the tennis booking agent LLM directly during eval tests.

Uses the production agent's model construction, system prompt, and tool
bindings (from tennis_booking.agent), so evals exercise one LLM step of the
real agent stack -- without the LangGraph tool-execution loop, which would
run real tools mid-eval and break the single-step eval design.
"""
from tennis_booking.agent import AGENT_TOOLS, SYSTEM_PROMPT, create_chat_model


def call_agent(messages: list[dict]) -&gt; AgentResponse:
    """Call the booking agent model with the given conversation history.

    Prepends the production system prompt and applies the production tool
    bindings, then runs a single LLM step (no tool execution).
    """
    response = _get_bound_model().invoke(_to_langchain_messages(messages))
    return AgentResponse(response)
</code></pre>
<p>Note what's imported: <code>AGENT_TOOLS</code>, <code>SYSTEM_PROMPT</code>, and <code>create_chat_model</code> come from the agent package itself, not from a test double. The eval exercises the same prompt and tool bindings the agent actually uses, but no tool runs, so there are no side effects and no flakiness from live integrations. This is what makes it possible to test turn 4 of a conversation without replaying turns 1 to 3 through a live agent every time: you script the history you want as a fixture, and the eval answers exactly one question. What does the agent do right now, given this exact context?</p>
<h3>3. Grade with an LLM judge when wording varies</h3>
<p>Some agent behaviours can be checked with a plain <code>assert</code>: a routing decision, a required field in structured output. But a lot of what agents do has more than one correct answer. "Which tool did the agent call, and did the arguments make sense?" sounds deterministic until you realise the user's location might arrive as "Central Park," "near the park," or "Central Park, NYC," all correct, none an exact string match.</p>
<p>That's where an LLM judge comes in: a second model that reads the conversation and grades the agent's response against a written rubric. If you haven't used one before, here's what it looks like in the sandbox, using the deepeval library:</p>
<pre><code class="language-python"># evals/test_booking_evals.py
metric = ConversationalGEval(
    name="Calls location tool",
    criteria=(
        "PASS: The agent calls get_user_location with an argument referencing the user's stated location "
        "('Central Park', 'New York City', or similar).\n"
        "FAIL: The agent does not call get_user_location, or calls it without a location argument "
        "matching what the user mentioned."
    ),
    evaluation_params=[MultiTurnParams.TOOLS_CALLED],
    model=judge_model,
    threshold=0.7,
    async_mode=False,
)
</code></pre>
<p>Reading it piece by piece: <code>criteria</code> is the rubric, written as an explicit PASS/FAIL binary rather than "is this response good?", so the judge has almost no room for interpretation. <code>evaluation_params</code> tells the judge what evidence to look at (here, the tools the agent called; other tests also include the message content). <code>model</code> is the judge itself, a separate model from the agent, more on that below. And <code>threshold</code> converts the judge's score into a pass/fail that pytest understands, so a failing rubric fails the test like any other assertion.</p>
<p>The rule of thumb: write the tightest check the actual variability of your input allows. If there's exactly one correct answer, use a plain <code>assert</code>. It's faster, cheaper, and can't be wrong. If correct answers legitimately vary in wording, use a judge with a strict binary rubric. What you want to avoid is the vague middle: a fuzzy rubric ("did the agent respond helpfully?") produces noisy, inconsistent scores and catches nothing.</p>
<h3>4. Rerun for reliability, and know which kind of rerun you need</h3>
<p>LLMs are non-deterministic, so how much a single run tells you depends on what you're testing. There are two distinct questions:</p>
<ul>
<li><strong>Can the agent do this at all?</strong> (capability: the agent might phrase things differently across runs, and passing once proves the capability exists)</li>
<li><strong>Does the agent do this every single time?</strong> (reliability: guardrails and critical behaviours, where a single failure is a real bug)</li>
</ul>
<p>Mixing them up gives you the wrong signal in both directions. Treating a safety guardrail as "only needs to pass once" lets a real regression through, while demanding "must pass every time" from ordinary phrasing variance makes your suite flaky for no reason.</p>
<p>Both are expressible with plain pytest markers, the same tools you already use for flaky integration tests:</p>
<pre><code class="language-python"># evals/test_booking_evals.py
# For invariant tests, re-run them N times to guard against false negatives.
# All have to pass.
INVARIANT_REPEATS = 3

# For trajectory tests, re-run them N times to allow for non-deterministic behaviours.
# Only one has to pass.
FLAKY_RERUNS = 3
</code></pre>
<p>A guardrail like <code>test_agent_refuses_to_book_without_email</code> gets <code>@pytest.mark.repeat(INVARIANT_REPEATS)</code>, meaning all three runs must pass. A capability check like <code>test_agent_calls_location_tool_after_user_gives_location</code> gets <code>@pytest.mark.flaky(reruns=FLAKY_RERUNS)</code>, where one success is enough. The flaky treatment is particularly useful when the LLM has multiple correct ways to reach a result: the agent might call the location tool immediately, or ask a clarifying question first and call it next turn. Both are legitimate paths to the same outcome, and a test that demands one specific path on every run will fail on behaviour that isn't actually wrong. The practice worth adopting is the distinction itself: decide per-test whether you're measuring capability or reliability, and rerun accordingly.</p>
<p>One caveat that saves a lot of API spend: reruns are for behaviour that actually varies. In my experience, many regressions, once you've captured the exact conversation state that triggers them, reproduce 100% of the time. For those, a single run gives you a clear signal, and rerunning just costs money. Save the repeats for behaviour that's legitimately probabilistic.</p>
<h3>5. Write negative tests, not just happy paths</h3>
<p>Two of the most valuable tests in the sandbox suite don't check that the agent does the right thing. They check that it doesn't do the wrong thing when it's tempting to. One confirms the agent asks for an email before booking rather than silently proceeding:</p>
<pre><code class="language-python"># evals/test_booking_evals.py
metric = ConversationalGEval(
    name="Asks for email before booking",
    criteria=(
        "PASS: The agent asks the user for their email address before proceeding. "
        "It must NOT call confirm_and_process_payment.\n"
        "FAIL: The agent calls confirm_and_process_payment without having received an email "
        "address, or proceeds with booking without requesting the missing information."
    ),
    evaluation_params=[MultiTurnParams.CONTENT, MultiTurnParams.TOOLS_CALLED],
    model=judge_model,
    threshold=0.7,
    async_mode=False,
)
</code></pre>
<p>The other confirms the agent doesn't fabricate availability when <code>find_available_slots</code> comes back empty:</p>
<pre><code class="language-python"># evals/test_booking_evals.py
.tool_result("call_slots", "find_available_slots", [])  # no slots available
...
criteria=(
    "PASS: The agent informs the user that no slots are available on the requested date "
    "and suggests an alternative action...\n"
    "FAIL: The agent fabricates slot availability, calls confirm_and_process_payment, "
    "or gives no useful response to the lack of availability."
),
</code></pre>
<p>Neither of these would ever show up in a happy-path demo. They exist because someone thought about what the agent could get away with, not just what it's supposed to do.</p>
<h3>6. Don't let the agent grade its own homework</h3>
<p>If your production model and your judge come from the same model family, the judge tends to share the same blind spots as the thing it's grading. The sandbox bakes this into its judge fixture, with the reasoning written directly above it:</p>
<pre><code class="language-python"># evals/conftest.py
# The judge MUST be a different model family from the agent under test.
# Grading with the same model produces correlated errors -- the judge will
# rubber-stamp the agent's blind spots rather than catch them.
_DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash"


@pytest.fixture(scope="session")
def judge_model() -&gt; OpenRouterModel:
    """GEval judge model. Uses a different model family from the agent -- see comment above."""
    api_key = os.environ["OPENROUTER_API_KEY"]
    return OpenRouterModel(model=_judge_model_name(), api_key=api_key)
</code></pre>
<p>Note the judge is also a cheap, fast model, not the biggest one available. A judge call is just an API call; at fractions of a cent per case, a 50-case suite costs pennies per run. That's CI-budget money, and it's what makes judge evals viable in the local dev loop, not just in a nightly pipeline. Routing through OpenRouter means swapping judge models is an env var change, not a code change, which is useful for checking that your suite isn't calibrated to one judge's particular quirks.</p>
<h2>When production breaks, write the eval first</h2>
<p>Here's where the unit-test discipline pays for itself twice. When a bug is reported from production, the first move is to write a failing eval that reproduces it, then fix the prompt until it passes reliably, not just once.</p>
<p>This is the same reflex a good engineer already has for ordinary bugs: reproduce as a failing test, fix, keep the test forever. Applied to agents, it turns the scariest class of bug, "the model sometimes does something weird," into a routine workflow. You capture the exact conversation state that triggers the failure as a <code>ConversationBuilder</code> fixture, write the rubric for what should have happened, watch it fail, and iterate on the prompt until it passes. The eval then stays in the suite permanently, so that specific failure can never quietly come back.</p>
<p>The infrastructure from the patterns above (conversation builder, single-step caller, judge fixture) is what makes this a routine step rather than a scramble: you're plugging a new case into an existing harness, not starting from scratch per incident. Without that harness, "reproduce the bug" means clicking through a chat UI hoping to trigger it again. With it, it's twenty lines of fixture.</p>
<h2>A human still has to read the evals</h2>
<p>One thing that's easy to lose in all this automation: every eval encodes a human decision about what "correct" means. The rubric in a judge eval, the assertion in a deterministic one, the choice of which behaviours count as invariants. Those are judgment calls, and they should be reviewed with the same care as the code they protect.</p>
<p>This matters more, not less, as more of the codebase becomes AI-generated. When large parts of a system are written, and sometimes never closely read, by a model, the eval suite is one of the few places where a human is still explicitly deciding what good looks like, writing it down, and checking it continuously. Review every eval that enters the suite. Read the judge's reasoning when a case fails, not just the pass/fail. An eval nobody has scrutinised is a test of nothing.</p>
<h2>A working vocabulary</h2>
<p><strong>Application metric:</strong> your top-line pass rate across the whole eval suite. Good for tracking regression over time; too coarse on its own to debug an individual failure.</p>
<p><strong>Per-step (or per-turn) eval:</strong> an eval that calls the real agent for a single step against a scripted prior conversation, rather than judging only a full end-to-end run.</p>
<p><strong>Multi-turn eval:</strong> an eval spanning a full conversation, not a single prompt-response pair. Catches context drift and compounding errors that single-turn evals miss.</p>
<p><strong>Model-graded eval:</strong> pass/fail determined by an LLM judge against a rubric. Necessary for anything with legitimate variation in correct phrasing.</p>
<p><strong>Programmatically graded eval:</strong> pass/fail determined by code: exact match, schema validation, regex. Fast, cheap, fully deterministic, and the right default whenever there's no legitimate variation in what "correct" looks like.</p>
<p><strong>Invariant test:</strong> a test that must pass on every rerun. Used for guardrails, safety behaviours, and critical application behaviour that can never regress even once.</p>
<p><strong>Capability (trajectory) test:</strong> a test that only needs to pass once across several reruns, because minor variation across runs is expected and acceptable.</p>
<p><strong>Prod-regression eval:</strong> written after a production failure to make sure that specific incident never regresses. A healthy suite has both prod-regression evals (closing gaps you've been burned by) and code-first evals (anticipating gaps before they open).</p>
<h2>Start small, grow with every feature</h2>
<p>You don't need a platform, a vendor, or a perfect taxonomy of eval types to start. What you need is: a way to script a conversation, a way to call your real agent for one step against it, and a judge that isn't the same model you're testing.</p>
<p>There's a compounding effect here worth naming. Each eval you add becomes a primitive: a building block that pins down one behaviour and stays pinned. A suite of those primitives is a stable base for iteration. You can rework a prompt, swap a model, or bolt on a new tool, and know within minutes which existing behaviours held. That stability is what unlocks new features. Without it, every change reopens every old question, and the cost of touching the system grows with its size instead of shrinking.</p>
<p>Then make it a habit, not a project. Every new feature lands with its evals in the same PR, the same way a new endpoint lands with its unit tests. Every prompt change runs the suite before merge. Every production bug becomes a new eval before it becomes a fix. You never stop to "do evals" as a phase; the suite grows in parallel with the agent, one case at a time, and each case is small: a scripted history, one real step, one rubric.</p>
<p>Have a better pattern, a framework you'd recommend, or a war story of your own? I'd genuinely like to hear it. This is meant to start a conversation, not close one.</p>
]]></content:encoded></item><item><title><![CDATA[How to Improve Agentic APIs: Expose a Simple Workflow API]]></title><description><![CDATA[The most reliable architecture is the one that asks least of the model.
Summary
Conversational agents are often built by writing a workflow into the system prompt and letting the model work out which ]]></description><link>https://adam-lang.hashnode.dev/how-to-improve-agentic-apis-expose-a-simple-workflow-api</link><guid isPermaLink="true">https://adam-lang.hashnode.dev/how-to-improve-agentic-apis-expose-a-simple-workflow-api</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[langgraph]]></category><category><![CDATA[showdev]]></category><dc:creator><![CDATA[Adam Lang]]></dc:creator><pubDate>Fri, 18 Sep 2026 01:12:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa76af836303380aab545c5/7ca06942-3995-478b-be4c-443652a08cc5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>The most reliable architecture is the one that asks least of the model.</em></p>
<h2>Summary</h2>
<p>Conversational agents are often built by writing a workflow into the system prompt and letting the model work out which step it is on. The common answer to the reliability problems that follow is to move the workflow state out of the prompt and into an explicit graph, with LLM calls driving the transitions. That adds a second round of orchestration to every turn. I ask whether the step tracking and the transitions can be moved into deterministic code entirely, leaving the model only the part it is good at, talking to the user, and what that costs in tokens and latency. The same 16-step tennis-court booking workflow is implemented three ways: a ReAct agent that infers its own position from the transcript, a prompt-graph agent that keeps state server-side and advances it with an LLM call each turn, and a simple workflow API agent whose single tool takes <code>{slot, value}</code> updates and is backed by a LangGraph <code>StateGraph</code> containing no LLM calls. Each is run against 11 scripted conversations, 10 of which contain corrections, multi-answer messages or off-topic turns, on two models, twice over: 132 conversations in total.</p>
<p>The three architectures do not hand the model the same tools, so the comparison is on tool call correctness rather than tool choice. Scoring reads the arguments that reach <code>search_availability</code> and <code>book_court</code>. It does not matter whether the model emitted those calls itself or the workflow engine made them internally. Every agent records them in the same tool call log, so one scoring function runs over all three unmodified. The chat text is not scored.</p>
<p>The simple workflow API agent passed 43 of 44, the prompt-graph agent 40, and the ReAct agent 32. The simple workflow API agent was the only architecture to complete the workflow reliably on both models, so the other agents' failures track the architecture rather than the model. That reliability costs about 2× the ReAct agent's tokens, while the prompt-graph agent costs 2.4–2.8×, because every turn adds an orchestration round trip to a stateless API that resends the whole history. A one-line addition to the ReAct agent's system prompt, written to address its dominant failure, did not recover it: re-running the complete suite with that line left it at 15 of 22, inside the range the unmodified agent had already produced.</p>
<h2>Introduction</h2>
<p>I've spent the last six months as an AI engineer building customer-facing voice agents.</p>
<p>As an AI developer there's pressure to deliver reliable agents quickly. At "some scale", the standard approaches hit an invisible wall: the model can't cope with the complexity, and its prose-based rules start to blur into each other.</p>
<p>LangGraph calls out this limitation and recommends mixing deterministic and LLM steps. But LLM steps add complexity and latency, which are arguably a poor match for conversational settings.</p>
<p>This post tests a different approach: selectively trading probability for determinism where it's most needed. The more consistent the workflow path, the more a static workflow becomes a worthwhile, even necessary, optimisation. The objectives behind that trade off are the ordinary production ones: low latency and token cost, reliability with small and fast models, deterministic primitives, smaller prompts in place of prose based rules, agents decoupled from their tools, and no loss of conversational flexibility, so a user can still answer three questions at once or change their mind two steps later.</p>
<p>So I built the same agent with three different architectures and measured their performance.</p>
<p><strong>Repository:</strong> <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api">github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api</a></p>
<h2>Contributions</h2>
<ul>
<li><p><strong>A benchmark</strong> of 11 scripted conversations over one 16-step booking workflow. It runs against three agent architectures on two models, twice: 132 conversations in total. All three agents share one workflow definition and one scoring function. Scoring inspects the arguments that reach <code>search_availability</code> and <code>book_court</code>, not the chat text. The architecture is the only thing that differs between cells. The code, the scenarios and both result files are in the repository.</p>
</li>
<li><p><strong>Evidence that the architecture, not the model, decides whether the workflow completes.</strong> The simple workflow API agent passed 43 of 44 conversations. The prompt-graph agent passed 40 and the ReAct agent 32, on the same models and the same scenarios. In 11 of the ReAct agent's 12 failures, <code>search_availability</code> was never called at all.</p>
</li>
<li><p><strong>A cost comparison in which the cheapest architecture is the least reliable.</strong> The ReAct agent is cheapest in every cell. The simple workflow API agent costs about 2× it on both models. The prompt-graph agent costs 2.4–2.8× on DeepSeek V4 Flash and 9.3–11.9× on Luna. Its input tokens grow faster than its request count. On Luna it made 2.8–3.0× the requests but used 9.7–12.6× the input tokens. The Chat Completions API is stateless, so every extra round trip resends the whole conversation history.</p>
</li>
<li><p><strong>Reliability that doesn't require the more capable model.</strong> The simple workflow API agent passed 22 of 22 on the weak model. On that model the ReAct agent lost 2 passes and the prompt-graph agent lost 4, relative to their scores on the strong one.</p>
</li>
<li><p><strong>A prompt-level mitigation, tested and negative.</strong> The ReAct agent's dominant failure is never calling <code>search_availability</code>. I added one line to its system prompt aimed at that behaviour and re-ran the complete suite. It scored 15 of 22, inside the 15–17 range it had already produced without the line. Five of its seven remaining failures are still that same behaviour.</p>
</li>
</ul>
<h2>Related work</h2>
<p><strong>Workflow orchestration.</strong> <a href="https://docs.langchain.com/oss/python/langgraph/graph-api">LangGraph</a> models a workflow as nodes that do the work and edges that decide what runs next.</p>
<p><strong>Constrained generation.</strong> Structured outputs and grammar-guided decoding constrain the form of what a model emits. <a href="https://arxiv.org/abs/2307.09702">Willard and Louf</a> give the finite-state formulation, implemented in Outlines. The guarantee is text that parses against a regular expression, a grammar, or a JSON schema.</p>
<p><strong>Slot filling and frame-based dialogue.</strong> The <code>{slot, value}</code> grammar is not new. It is the frame of <a href="https://nlp.stanford.edu/acvogel/gus.pdf">GUS</a> (Bobrow et al., <em>Artificial Intelligence</em> 8(2), 1977), a travel-booking dialogue system. Its frames held slots the user could fill in any order, under mixed initiative, several at a time.</p>
<p>In all of this work the thing varied is the model, or the format of what it emits, or the prompt given to a fixed agent. Here the model, the workflow, the scenarios and the scoring are held fixed, and what varies is how much of the workflow the model is responsible for.</p>
<h2>Sample task: booking a tennis court</h2>
<p>The experiment:</p>
<ol>
<li><p><strong>Task:</strong> book a tennis court through a conversation. The workflow collects the user's preferences, searches court availability, lets the user pick a time, collects contact details, confirms, and books. It's 16 steps, defined once in code and shared by every agent.</p>
</li>
<li><p><strong>Agents:</strong> three agents implement that same workflow in three different ways (next section).</p>
</li>
<li><p><strong>Evals:</strong> every agent runs the same 11 scripted conversations. One answers every question in order. The other 10 are messy: corrections, several answers in one message, off-topic questions, a malformed email.</p>
</li>
<li><p><strong>Scoring:</strong> a conversation passes if the arguments that reach <code>search_availability</code> and <code>book_court</code> match what the user asked for. The chat text isn't scored.</p>
</li>
<li><p><strong>Models:</strong> each agent runs on a weak model, GPT-5.6 Luna with reasoning off, and a strong model, DeepSeek V4 Flash.</p>
</li>
<li><p><strong>Runs:</strong> the full suite (3 agents × 2 models × 11 conversations) was run twice unmodified, giving 132 conversations, and then a third time after one line was added to the ReAct agent's system prompt, giving a further 66. The third run is reported separately, under Mitigation.</p>
</li>
</ol>
<h2>The three agents</h2>
<h3>1. ReAct agent</h3>
<p>One static system prompt lists all 16 steps. On every turn the model re-reads the transcript and works out which step it's on. Nothing tracks progress outside the model.</p>
<p>Tools: The agent is given all the tools (search, book, confirm) to effect a successful booking.</p>
<p>Code: <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/agents/react_agent.py"><code>agents/react_agent.py</code></a></p>
<p>Expected problems:</p>
<ul>
<li><p>At a certain scale it forgets which step it's up to. It has no "current step" pointer, so it has to work out its position for every response, and depending on the conversation content (and length), token weightings can bias it towards skipping or repeating steps.</p>
</li>
<li><p>Workflow steps often contain rules written in prose, which can overlap with other rules in unexpected ways.</p>
</li>
<li><p>How much weight the model gives a step depends on where the step sits in the prompt, and on other factors that affect the model's attention emphasis.</p>
</li>
<li><p>Prompt bloat: the prompt carries every workflow step and every tool.</p>
</li>
<li><p>As steps change and more are added, the agent gets locked into an unmanageable structure.</p>
</li>
</ul>
<h3>2. Prompt-graph agent</h3>
<p>This is the standard LangGraph pattern: workflow state lives on the server, and LLM calls drive the workflow forward. The system prompt describes the workflow at a high level. Every turn, the model calls <code>get_next_step()</code> with whatever the user just said, and gets back one instruction: a question to ask, or a tool to call. Progress is stored server-side, keyed by conversation ID.</p>
<p>Tools: The agent is given the standard booking tools (search, book, confirm), plus get_next_step().</p>
<p>Code: <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/agents/prompt_chain_agent.py"><code>agents/prompt_chain_agent.py</code></a></p>
<p>Expected problems:</p>
<ul>
<li><p>Double the token cost and latency: one LLM call to talk to the user, plus another round of LLM calls to orchestrate the workflow.</p>
</li>
<li><p>It's poor for tokens and latency because every turn makes an extra tool call to advance the workflow, and every tool still has to be in the prompt.</p>
</li>
<li><p>Models with weaker tool calling get less reliable as more tools are added. Can be mitigated by scoping tools to the current step only.</p>
</li>
</ul>
<h3>3. Simple workflow API agent</h3>
<p>The proposed solution:</p>
<ul>
<li><p>Workflow structures are usually known ahead of time, encode them into a simple API that an agent tool can follow.</p>
</li>
<li><p>Even small models can write good-enough tool payloads for a simple grammar based API.</p>
</li>
<li><p>It takes a conceptual leap: you have to define the workflow in terms of a simple grammar that even a small model can follow.</p>
</li>
</ul>
<p>The model has one tool, <code>book_tennis_court_with_grammar</code>. It takes a list of <code>{slot, value}</code> updates. Any slot can be sent at any time, in any combination: a new answer, a correction, or an answer to a step the conversation hasn't reached yet. A user who opens with where, when, and how long produces one call:</p>
<pre><code class="language-json">{"updates": [
  {"slot": "area", "value": "Discovery Park"},
  {"slot": "date", "value": "2026-09-23"},
  {"slot": "duration_minutes", "value": 120}
]}
</code></pre>
<p>Behind the tool is a LangGraph <code>StateGraph</code> with no LLM calls in it. It validates each slot, works out the next step, and returns the next instruction for the model to put to the user. When the workflow settles on the search or booking step, the engine calls <code>search_availability</code> and <code>book_court</code> itself. Those are the same two implementations the other two agents hand to the model as tools. The model in this architecture is never given them and never calls them. The engine records each internal call it makes, so scoring reads the same two sets of arguments for all three agents.</p>
<p>Code: <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/blob/main/src/tennis_booking/agents/simple_workflow_api_agent.py"><code>agents/simple_workflow_api_agent.py</code></a>, <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/tree/main/src/tennis_booking/workflow_engine"><code>workflow_engine/</code></a></p>
<h2>Results</h2>
<p>Raw output from the two unmodified runs, as generated by <code>tennis-compare</code>. Both use the three agents exactly as described above; the third run, with one line added to the ReAct agent's prompt, is reported under Mitigation. Totals and averages are over the 11 conversations in each cell. "Latency" is per LLM call; "turn latency" is per user turn (all LLM calls and tool calls needed to answer one user message).</p>
<p>Weak = <code>openai/gpt-5.6-luna</code>, reasoning effort <code>none</code>. Strong = <code>deepseek/deepseek-v4-flash-20260731</code>.</p>
<p><strong>Run 1: tokens and cost</strong></p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Calls</th>
<th>Avg input tok</th>
<th>Avg output tok</th>
<th>Total cost</th>
<th>Passed</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct</td>
<td>Weak</td>
<td>163</td>
<td>3,249</td>
<td>51</td>
<td>$0.1159</td>
<td>8/11</td>
</tr>
<tr>
<td>ReAct</td>
<td>Strong</td>
<td>159</td>
<td>3,575</td>
<td>201</td>
<td>$0.1329</td>
<td>7/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>455</td>
<td>11,270</td>
<td>89</td>
<td>$1.0740</td>
<td>10/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>334</td>
<td>5,303</td>
<td>112</td>
<td>$0.3768</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>259</td>
<td>4,238</td>
<td>46</td>
<td>$0.2338</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>256</td>
<td>4,855</td>
<td>151</td>
<td>$0.2718</td>
<td>10/11</td>
</tr>
</tbody></table>
<p><strong>Run 1: latency</strong></p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Latency p50</th>
<th>Latency p90</th>
<th>Max tool calls/turn</th>
<th>Turn latency p50</th>
<th>Turn latency p90</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct</td>
<td>Weak</td>
<td>1,148ms</td>
<td>1,722ms</td>
<td>2</td>
<td>1,082ms</td>
<td>3,498ms</td>
</tr>
<tr>
<td>ReAct</td>
<td>Strong</td>
<td>1,238ms</td>
<td>2,506ms</td>
<td>2</td>
<td>1,353ms</td>
<td>3,387ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>1,653ms</td>
<td>2,312ms</td>
<td>8</td>
<td>4,768ms</td>
<td>10,252ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>880ms</td>
<td>1,682ms</td>
<td>5</td>
<td>1,804ms</td>
<td>5,628ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>1,196ms</td>
<td>1,642ms</td>
<td>3</td>
<td>2,430ms</td>
<td>3,268ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>721ms</td>
<td>2,518ms</td>
<td>3</td>
<td>1,447ms</td>
<td>4,999ms</td>
</tr>
<tr>
<td><strong>Run 2: tokens and cost</strong></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Calls</th>
<th>Avg input tok</th>
<th>Avg output tok</th>
<th>Total cost</th>
<th>Passed</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct</td>
<td>Weak</td>
<td>160</td>
<td>3,180</td>
<td>53</td>
<td>$0.1119</td>
<td>7/11</td>
</tr>
<tr>
<td>ReAct</td>
<td>Strong</td>
<td>169</td>
<td>4,011</td>
<td>144</td>
<td>$0.1502</td>
<td>10/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>480</td>
<td>13,380</td>
<td>90</td>
<td>$1.3366</td>
<td>8/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>328</td>
<td>5,103</td>
<td>113</td>
<td>$0.3571</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>260</td>
<td>4,234</td>
<td>45</td>
<td>$0.2341</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>259</td>
<td>4,861</td>
<td>132</td>
<td>$0.2723</td>
<td>11/11</td>
</tr>
</tbody></table>
<p><strong>Run 2: latency</strong></p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Latency p50</th>
<th>Latency p90</th>
<th>Max tool calls/turn</th>
<th>Turn latency p50</th>
<th>Turn latency p90</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct</td>
<td>Weak</td>
<td>1,172ms</td>
<td>1,754ms</td>
<td>2</td>
<td>1,108ms</td>
<td>3,176ms</td>
</tr>
<tr>
<td>ReAct</td>
<td>Strong</td>
<td>1,018ms</td>
<td>1,837ms</td>
<td>2</td>
<td>1,036ms</td>
<td>3,331ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>1,785ms</td>
<td>2,897ms</td>
<td>8</td>
<td>5,186ms</td>
<td>14,382ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>878ms</td>
<td>1,724ms</td>
<td>6</td>
<td>1,829ms</td>
<td>5,246ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>1,221ms</td>
<td>1,727ms</td>
<td>3</td>
<td>2,468ms</td>
<td>3,472ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>781ms</td>
<td>2,566ms</td>
<td>3</td>
<td>1,656ms</td>
<td>5,886ms</td>
</tr>
<tr>
<td>All 17 failing conversations are listed individually in the appendix at the end of this post.</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>Full reports, including every mismatched argument: <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api/tree/main/results"><code>results/</code></a>. The raw files use the code's agent ids: <code>prompt_chain</code> for the prompt-graph agent, <code>simple_workflow_api</code>, and <code>workflow</code> (run 1), <code>workflow_steps</code> (run 2) or <code>react</code> (run 3) for the ReAct agent.</p>
<h2>Observations</h2>
<p>Both runs combined. Ranges span run 1 and run 2. "vs ReAct" is a multiple of the ReAct agent's figure on the same model and run. "LLM reqs/conv" is LLM requests per conversation. "Turn p50" and "Turn p90" are turn latency, meaning all the LLM and tool calls needed to answer one user message.</p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Passed</th>
<th>LLM reqs/conv</th>
<th>In tok vs ReAct</th>
<th>Cost vs ReAct</th>
<th>Turn p50</th>
<th>Turn p90</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct</td>
<td>Weak</td>
<td>15/22</td>
<td>14.5–14.8</td>
<td>1×</td>
<td>1×</td>
<td>1.1s</td>
<td>3.2–3.5s</td>
</tr>
<tr>
<td>ReAct</td>
<td>Strong</td>
<td>17/22</td>
<td>14.5–15.4</td>
<td>1×</td>
<td>1×</td>
<td>1.0–1.4s</td>
<td>3.3–3.4s</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>18/22</td>
<td>41–44</td>
<td>9.7–12.6×</td>
<td>9.3–11.9×</td>
<td>4.8–5.2s</td>
<td>10.3–14.4s</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>22/22</td>
<td>30</td>
<td>2.5–3.1×</td>
<td>2.4–2.8×</td>
<td>1.8s</td>
<td>5.2–5.6s</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>22/22</td>
<td>23.5–23.6</td>
<td>2.1–2.2×</td>
<td>2.0–2.1×</td>
<td>2.4–2.5s</td>
<td>3.3–3.5s</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>21/22</td>
<td>23.3–23.5</td>
<td>1.9–2.2×</td>
<td>1.8–2.0×</td>
<td>1.4–1.7s</td>
<td>5.0–5.9s</td>
</tr>
</tbody></table>
<ul>
<li><p><strong>Pass rate.</strong> Simple workflow API passed 43/44, and its one failure was a paraphrased <code>area</code> value (<code>near Golden Gate Park</code> for <code>Golden Gate Park</code>). ReAct passed 32/44: in 11 of its 12 failures <code>search_availability</code> was never called, so the conversation never reached the search step, and it failed <code>messy_all_at_once_opener</code> (the first message answers every search question plus name and email) in all 4 of its runs. Prompt-graph passed 40/44: all 4 failures were on the weak model and searched with <code>surface='any'</code> after the user asked for hard courts, including the in-order scenario, where the user's third message is "I prefer hard courts." Prompt-graph tracks the step server-side, but the model still writes the search arguments itself. Run-to-run variance is large for the two model-driven workflows (ReAct on the strong model went from 7/11 to 10/11, prompt-graph on the weak model from 10/11 to 8/11). Simple workflow API scored 10–11/11 in every cell.</p>
</li>
<li><p><strong>Token use.</strong> ReAct is the cheapest agent and the least reliable. It makes the fewest LLM requests because it only calls tools to search, book, and confirm. Simple workflow API costs about 2× ReAct on both models, and that multiple holds across runs: it calls <code>book_tennis_court_with_grammar</code> on most turns, and each tool call adds another LLM request to phrase the reply. Prompt-graph calls <code>get_next_step</code> every turn and then whatever tool that step names, and on the weak model it made up to 8 tool calls in a single turn. The Chat Completions API is stateless, so every extra round trip resends the whole conversation history. That's why prompt-graph's input tokens grow faster than its request count: on the weak model it made about 3× ReAct's requests but used 10–13× its input tokens. Prompt-graph cost 1.3–1.4× as much as simple workflow API on the strong model, and 4.6–5.7× on the weak model.</p>
</li>
<li><p><strong>Latency.</strong> Turn latency follows the number of LLM calls a turn needs. ReAct has the lowest p50 on both models. Prompt-graph on the weak model has a p90 of 10–14 seconds. Simple workflow API on the weak model has about the same p90 as ReAct (3.3–3.5s vs 3.2–3.5s). On the strong model its p90 is higher (5.0–5.9s vs 3.3–3.4s), because most of its turns take two calls and the strong model's per-call p90 on this agent is about 2.5s.</p>
</li>
<li><p><strong>Weak vs strong model.</strong> The weak model made both model-driven workflows less reliable: ReAct went from 17/22 on the strong model to 15/22 on the weak one, and prompt-graph from 22/22 to 18/22. Simple workflow API passed 22/22 on the weak model. The weak model was cheaper for ReAct and simple workflow API (0.75–0.87× the strong model's cost), because it wrote far fewer output tokens per call (45–53 vs 132–201) even at twice the output price. For prompt-graph it was 2.85–3.74× more expensive, because it made more calls (41–44 vs 30 per conversation) with larger inputs (11,270–13,380 vs 5,103–5,303 average input tokens per call).</p>
</li>
</ul>
<h2>Mitigation: one line in the prompt</h2>
<p>The ReAct agent's failures are concentrated in a single behaviour: in 11 of its 12 failures across runs 1 and 2, <code>search_availability</code> was never called, so the conversation never reached the search step. If that is a prompt problem rather than an architectural one, the cheapest possible fix ought to move it. One line was added to the ReAct agent's system prompt, and nothing else was changed:</p>
<blockquote>
<p>When a single message answers several steps, record them all before deciding what to ask next.</p>
</blockquote>
<p>The wording is deliberately generic. A line naming the search step, such as "call <code>search_availability</code> as soon as you have everything it needs", would target the exact scenarios already known to fail. That would measure my knowledge of those failures rather than the value of the fix. The complete suite was then re-run: same 11 scenarios, same two models, same scoring, same decoding settings, with the other two agents untouched so that they act as a control on the run itself.</p>
<p><strong>Run 3 (added line in the ReAct agent's prompt only): tokens and cost</strong></p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Calls</th>
<th>Avg input tok</th>
<th>Avg output tok</th>
<th>Total cost</th>
<th>Passed</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct (with line)</td>
<td>Weak</td>
<td>158</td>
<td>3,111</td>
<td>51</td>
<td>$0.1079</td>
<td>6/11</td>
</tr>
<tr>
<td>ReAct (with line)</td>
<td>Strong</td>
<td>165</td>
<td>3,826</td>
<td>184</td>
<td>$0.1445</td>
<td>9/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>481</td>
<td>13,513</td>
<td>87</td>
<td>$1.3502</td>
<td>9/11</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>329</td>
<td>5,181</td>
<td>117</td>
<td>$0.3640</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>260</td>
<td>4,253</td>
<td>45</td>
<td>$0.2353</td>
<td>11/11</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>258</td>
<td>4,844</td>
<td>123</td>
<td>$0.2691</td>
<td>11/11</td>
</tr>
</tbody></table>
<p><strong>Run 3: latency</strong></p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Latency p50</th>
<th>Latency p90</th>
<th>Max tool calls/turn</th>
<th>Turn latency p50</th>
<th>Turn latency p90</th>
</tr>
</thead>
<tbody><tr>
<td>ReAct (with line)</td>
<td>Weak</td>
<td>1,587ms</td>
<td>2,343ms</td>
<td>2</td>
<td>1,570ms</td>
<td>3,458ms</td>
</tr>
<tr>
<td>ReAct (with line)</td>
<td>Strong</td>
<td>1,058ms</td>
<td>3,292ms</td>
<td>2</td>
<td>1,133ms</td>
<td>4,013ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Weak</td>
<td>1,776ms</td>
<td>2,465ms</td>
<td>8</td>
<td>5,317ms</td>
<td>12,633ms</td>
</tr>
<tr>
<td>Prompt-graph</td>
<td>Strong</td>
<td>969ms</td>
<td>1,998ms</td>
<td>5</td>
<td>1,946ms</td>
<td>7,281ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Weak</td>
<td>1,297ms</td>
<td>1,895ms</td>
<td>3</td>
<td>2,669ms</td>
<td>3,811ms</td>
</tr>
<tr>
<td>Simple workflow API</td>
<td>Strong</td>
<td>640ms</td>
<td>1,476ms</td>
<td>3</td>
<td>1,313ms</td>
<td>2,815ms</td>
</tr>
<tr>
<td><strong>ReAct agent, with and without the added line</strong></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>Model</th>
<th>Run 1</th>
<th>Run 2</th>
<th>Run 3 (with line)</th>
</tr>
</thead>
<tbody><tr>
<td>Weak</td>
<td>8/11</td>
<td>7/11</td>
<td>6/11</td>
</tr>
<tr>
<td>Strong</td>
<td>7/11</td>
<td>10/11</td>
<td>9/11</td>
</tr>
<tr>
<td>Both</td>
<td>15/22</td>
<td>17/22</td>
<td>15/22</td>
</tr>
</tbody></table>
<p><strong>The added line did not recover the failures.</strong> With it, the ReAct agent scored 15/22, inside the 15–17/22 range it had already produced without it and below its run 2 score. Five of its seven remaining failures are still <code>search_availability</code> never being called. <code>messy_all_at_once_opener</code>, the dense opening message the line was written for, failed on both models again: 6 failures out of 6 attempts across the three runs, with and without the line.</p>
<p>Scope: one prompt change, one run, 11 conversations per cell. This does not show that no prompt can fix the ReAct agent. It shows that the obvious prompt fix, aimed directly at the dominant failure, did not fix it here, and that the obvious "levers" for this agent architecture are unreliable.</p>
<h2>Learnings</h2>
<p><strong>You can engineer a better agentic API.</strong> The simple workflow API agent passed 43 of 44 conversations, and the one failure was a paraphrased <code>area</code> value. It cost about three-quarters as much as the prompt-graph agent on the strong model, and about a fifth as much on the weak model.</p>
<ul>
<li><p>Most conversational agent platforms aren't set up to follow this pattern natively. They lead to higher token use and higher latency instead.</p>
</li>
<li><p>Tool APIs are typically pre-AI legacy code, not adapted to LLM language and grammar capabilities.</p>
</li>
<li><p>Redesigning agentic tool APIs around simple grammars works well, even for weak models: the simple workflow API agent passed 22/22 on the weak model.</p>
</li>
<li><p>Being deliberate about when LLM calls are required, and aiming them at LLM strengths such as conversing with users, is key to achieving quality AI systems.</p>
</li>
<li><p>Being more willing to build out deterministic primitives can pay off a lot. Here, the staleness and validation rules are typed slot validators with unit tests, instead of prose in a prompt.</p>
</li>
<li><p>A prompt-level fix aimed squarely at the ReAct agent's dominant failure did not improve performance. That is one wording and one run, so it doesn't show that no prompt could. It does show that prompt-shaped fixes for structural problems often fail.</p>
</li>
</ul>
<h2>Future work</h2>
<p>Future lines of investigation, out of scope in the current work.</p>
<ul>
<li><p>Allow LLM calls inside the workflow engine for complex nodes, or in error scenarios as a graceful fallback, using LangGraph LLM steps.</p>
</li>
<li><p>Surface multiple steps in a single response, to batch steps and further cut latency and token cost.</p>
</li>
<li><p>Allow forward and backward step skipping, to improve conversation flow.</p>
</li>
<li><p>Trim early tool calls from the conversation history to minimise context bloat.</p>
</li>
</ul>
<h2>Code</h2>
<p>All source code, the scripted conversations, and all three result files: <a href="https://github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api">github.com/adam-lang2/prompt-chain-vs-workflow-steps-vs-simple-workflow-api</a></p>
<p>To reproduce both models in one run (needs an OpenRouter key):</p>
<pre><code class="language-bash">uv run tennis-compare \
  --model openai/gpt-5.6-luna:none \
  --model deepseek/deepseek-v4-flash-20260731 \
  --format markdown
</code></pre>
<h2>Appendix: every failure</h2>
<p>Every conversation that failed scoring in runs 1 and 2, with what the scoring found. Run 3’s failures are described under Mitigation.</p>
<details>
<summary>Show all 17 failures</summary>
<table>
<thead>
<tr>
<th>Run</th>
<th>Agent</th>
<th>Model</th>
<th>Scenario</th>
<th>What the scoring found</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_prospect_park_clay_correction</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_prospect_park_bulk_dump</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_all_at_once_opener</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Strong</td>
<td><code>messy_duration_change_after_selection</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Strong</td>
<td><code>messy_all_at_once_opener</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Strong</td>
<td><code>messy_multi_field_redo</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>ReAct</td>
<td>Strong</td>
<td><code>messy_double_date_correction</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>1</td>
<td>Prompt-graph</td>
<td>Weak</td>
<td><code>messy_duration_change_after_selection</code></td>
<td>searched with <code>surface='any'</code> (expected <code>hard</code>), <code>indoor_outdoor='either'</code> (expected <code>outdoor</code>)</td>
</tr>
<tr>
<td>1</td>
<td>Simple workflow API</td>
<td>Strong</td>
<td><code>in_order_golden_gate_hard</code></td>
<td>searched with <code>area='near Golden Gate Park'</code> (expected <code>Golden Gate Park</code>)</td>
</tr>
<tr>
<td>2</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_prospect_park_clay_correction</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_prospect_park_bulk_dump</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>zero_result_relax</code></td>
<td>searched with <code>surface='grass'</code> (the user had switched to <code>hard</code>); <code>book_court</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>ReAct</td>
<td>Weak</td>
<td><code>messy_all_at_once_opener</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>ReAct</td>
<td>Strong</td>
<td><code>messy_all_at_once_opener</code></td>
<td><code>search_availability</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>Prompt-graph</td>
<td>Weak</td>
<td><code>in_order_golden_gate_hard</code></td>
<td>searched with <code>surface='any'</code> (expected <code>hard</code>), <code>indoor_outdoor='either'</code> (expected <code>outdoor</code>); <code>book_court</code> never called</td>
</tr>
<tr>
<td>2</td>
<td>Prompt-graph</td>
<td>Weak</td>
<td><code>messy_duration_change_after_selection</code></td>
<td>searched with <code>surface='any'</code> (expected <code>hard</code>), <code>indoor_outdoor='either'</code> (expected <code>outdoor</code>)</td>
</tr>
<tr>
<td>2</td>
<td>Prompt-graph</td>
<td>Weak</td>
<td><code>messy_out_of_range_duration</code></td>
<td>searched with <code>surface='any'</code> (expected <code>hard</code>)</td>
</tr>
</tbody>
</table>
</details>]]></content:encoded></item></channel></rss>