<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: James O'Connor</title>
    <description>The latest articles on DEV Community by James O'Connor (@james_oconnor_dev).</description>
    <link>https://dev.to/james_oconnor_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3940356%2F8b7ecfc2-82da-41e4-b887-959272780323.png</url>
      <title>DEV Community: James O'Connor</title>
      <link>https://dev.to/james_oconnor_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/james_oconnor_dev"/>
    <language>en</language>
    <item>
      <title># The Partial JSON Looked Done. It Wasn't. Here's What Streaming Structured Output Actually Requires.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Mon, 06 Jul 2026 00:40:29 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/-the-partial-json-looked-done-it-wasnt-heres-what-streaming-structured-output-actually-j0e</link>
      <guid>https://dev.to/james_oconnor_dev/-the-partial-json-looked-done-it-wasnt-heres-what-streaming-structured-output-actually-j0e</guid>
      <description>&lt;p&gt;Last quarter we shipped a contract extraction feature that streamed its output field by field into a review UI, so a paralegal could start reading the extracted renewal date and counterparty name before the model finished the whole document. Nice idea. Faster perceived latency, better demo, the kind of thing that gets a thumbs up in a design review.&lt;/p&gt;

&lt;p&gt;Then a reviewer flagged a contract where the counterparty name in the UI read "Meridian Hold" and the final, settled value was "Meridian Holdings Group LLC." Nobody had touched anything. The UI had simply displayed the string as it existed at a moment mid-stream, before the model had finished writing it, and the user had already glanced at it, nodded, and moved to the next tab. By the time the full value arrived, the case for that field was already closed in the reviewer's head.&lt;/p&gt;

&lt;p&gt;That's the part that stings about streaming structured output. It doesn't usually fail loud. It fails by being plausible at exactly the wrong instant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gic5drjw34cmjxx8qiy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gic5drjw34cmjxx8qiy.png" alt=" " width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The naive approach is to accumulate the streamed text and try &lt;code&gt;json.loads&lt;/code&gt; on the buffer every time a new chunk lands, catching the exception when it's not valid yet. That works, technically, right up until it doesn't, and the failure mode is worse than a crash: it's a false negative that becomes a false positive.&lt;/p&gt;

&lt;p&gt;Concretely, here's what breaks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Strings that parse as valid but aren't finished.&lt;/strong&gt; &lt;code&gt;json.loads&lt;/code&gt; doesn't fail on &lt;code&gt;{"counterparty": "Meridian Hold"}&lt;/code&gt;. That's completely valid JSON. It's also not the value you want. A string field only becomes trustworthy at the character where its closing quote lands, and nothing before that tells you where that is, because the model doesn't announce "I'm two tokens from done."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Arrays that look closed but aren't.&lt;/strong&gt; With tool-calling providers that stream token-by-token, we've seen an array of extracted clauses close its bracket, then reopen because the provider's underlying generation retried a truncated chunk server-side (this is provider-side behavior we can't fully control or always observe, and it showed up more under load, though I'd want a longer sample before I called that a hard rule rather than a pattern we noticed on high-traffic days). If your parser saw the first closing bracket and moved on, you've committed to a value that got silently superseded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Parallel tool calls interleaving.&lt;/strong&gt; When a model issues more than one tool call in the same turn, providers don't always guarantee the chunks for call A finish before chunks for call B start arriving. We had a case where two &lt;code&gt;extract_clause&lt;/code&gt; calls were in flight and a naive buffer-per-response (rather than buffer-per-call) approach spliced fragments from both into one JSON blob that parsed successfully and meant nothing.&lt;/p&gt;

&lt;p&gt;None of these are edge cases in the sense of being rare. In our sample of roughly 400 flagged extraction sessions over about six weeks (anecdotal, drawn from our own error queue, not an industry number), something in this family, a field displayed before it was complete, showed up in just under 3% of streamed sessions. Low frequency, high cost, because the ones that go wrong are the ones a human trusted.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "schema-aware" actually means here
&lt;/h3&gt;

&lt;p&gt;The fix isn't a smarter JSON parser. Full-document partial-JSON parsers exist and are useful for a different problem (rendering a tree view of an in-progress object), but they answer the wrong question. The question isn't "can I parse this yet." It's "is this specific field's value done being written."&lt;/p&gt;

&lt;p&gt;That reframes the problem as a cursor per field, not a parser over the whole buffer. You track, for each key you care about, whether you've seen its terminating character. For strings, that's an unescaped closing quote. You do not surface the field to any downstream consumer, UI or otherwise, until that condition is met. Everything before that point stays internal state, not output.&lt;/p&gt;

&lt;p&gt;Here's a minimal version of that, enough to show the shape (not a production parser, we handle nested objects and arrays with a similar but longer state machine):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FieldExtractor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Extracts only fields whose values are structurally complete
    from a growing buffer of streamed JSON text. Never calls
    json.loads on the partial buffer.

    A field counts as &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;safe to surface&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; only once we&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ve seen the
    terminating character for its type: here, a closing quote for
    strings (not preceded by an odd number of backslashes).
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected_keys&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
        &lt;span class="n"&gt;newly_closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_closed_string_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emitted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
                &lt;span class="n"&gt;newly_closed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;newly_closed&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_extract_closed_string_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;after_colon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after_colon&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;after_colon&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# not a string field, or value hasn't started
&lt;/span&gt;        &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'"'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# closing quote found, value is safe
&lt;/span&gt;            &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# still being written
&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;extractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FieldExtractor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewal_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;stream_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme Ind&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ustries, Inc.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewal_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2027-0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;safe to show: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and the first chunk produces nothing (the vendor name string hasn't closed), the second chunk produces &lt;code&gt;vendor_name&lt;/code&gt;, and the third produces &lt;code&gt;renewal_date&lt;/code&gt;. Nothing gets displayed until its own value is structurally complete, independent of whatever else is still being written elsewhere in the object.&lt;/p&gt;

&lt;p&gt;The version we actually run in production extends this with a stack for nested objects and arrays (so a clause list only emits an item once that item's closing brace is seen, not when the array itself closes), and a separate buffer keyed by tool-call ID so parallel calls can't splice into each other. Pydantic still validates the finished object at the end, same as before. This layer sits earlier and answers a narrower question: not "is this valid," but "is this done."&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that's easy to miss
&lt;/h3&gt;

&lt;p&gt;The instinct once you've built something like this is to treat it as purely a UI nicety, debounce the flicker, smooth the experience. But the actual failure we hit wasn't a UI glitch. It was an epistemic one: a human formed a belief about a fact ("counterparty is Meridian Hold, whatever that is") from data that hadn't finished existing yet. The fix isn't cosmetic. It's a correctness boundary between "data that exists" and "data that is still in the process of becoming data," and once you see it that way, treating it as a parsing convenience undersells what's actually broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where I'd push back on this
&lt;/h3&gt;

&lt;p&gt;The strongest objection to all of the above is that schema-aware incremental parsing adds real complexity, a stateful cursor per field, careful handling of escape sequences, tool-call ID bookkeeping, for a problem that a simpler mitigation might solve just as well: don't stream field values into a UI at all, stream only a coarse progress indicator ("extracting... 60%"), and reveal the full object once &lt;code&gt;json.loads&lt;/code&gt; succeeds on the complete response. That removes the entire failure class in one move, and for a lot of products that's the right call, especially early on, when the team building the UI doesn't want to own a state machine.&lt;/p&gt;

&lt;p&gt;I'd accept that objection for a lot of use cases. Where I wouldn't accept it is anywhere the whole point of streaming was the perceived-latency win, which was our actual reason for building this. If the product requirement is "show the paralegal something before the model finishes," you've already decided you need partial data, and the choice is really between partial data that's honest about its own completeness and partial data that isn't. Once you frame it that way, the extra state tracking looks less like gold-plating and more like the minimum bar for showing someone a fact you're asking them to trust.&lt;/p&gt;

&lt;p&gt;The other pushback worth taking seriously: none of this catches a value that's structurally complete but semantically wrong, a well-formed string that just happens to be the wrong string. That's a different failure class with a different fix, and conflating the two is its own mistake.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Structured output broke on us three times. The third time taught us operator-ready.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Fri, 03 Jul 2026 00:32:22 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/structured-output-broke-on-us-three-times-the-third-time-taught-us-operator-ready-2fcj</link>
      <guid>https://dev.to/james_oconnor_dev/structured-output-broke-on-us-three-times-the-third-time-taught-us-operator-ready-2fcj</guid>
      <description>&lt;h1&gt;
  
  
  Structured output broke on us three times. The third time taught us what "operator-ready" means.
&lt;/h1&gt;

&lt;p&gt;Last quarter we shipped a contract-extraction agent to an enterprise legal team. Schema validation passing at 97%. Human reviewers satisfied with the output quality in testing. Rollout went smoothly.&lt;/p&gt;

&lt;p&gt;Then it broke. Three times. In three completely different ways.&lt;/p&gt;

&lt;p&gt;The first two failures we fixed with better prompts and stricter schemas. The third one taught us something the first two hadn't: that "operator-ready" is not a technical checklist. It's a claim about your agent's behavior under conditions you didn't design it for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure one: the validation paradox
&lt;/h2&gt;

&lt;p&gt;Week two. A lease agreement came through with a renewal clause formatted as a table instead of prose. Our extractor looked for renewal terms in a specific JSON path. The table format populated the schema differently. Validation passed. The extracted renewal date was off by two years.&lt;/p&gt;

&lt;p&gt;The fix was obvious in retrospect: add a canonical-format normalization step before extraction. But the lesson was sharper than that.&lt;/p&gt;

&lt;p&gt;Schema validation tells you the shape of the output, not whether the content is correct. A JSON object with the right keys and the right types can still contain wrong values. Our 97% validation success rate was measuring the wrong thing. It was measuring structure conformance, not content accuracy.&lt;/p&gt;

&lt;p&gt;After this failure, we separated validation into two signals: schema validity (does the object have the required fields) and field confidence (do we have evidence the content is correct). We started logging both. An output is trusted only when both signals are above threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: the retry loop that lies
&lt;/h2&gt;

&lt;p&gt;Month one. A particular clause type appeared in a contract format we hadn't trained our test set on. The extractor failed schema validation on the first attempt. Our retry logic kicked in, filled missing fields with model-inferred defaults, and passed validation on the third try.&lt;/p&gt;

&lt;p&gt;The output looked right. The content was wrong. The inferred defaults were plausible values that did not match the actual contract.&lt;/p&gt;

&lt;p&gt;No alert fired. No human review was triggered. The error surfaced three weeks later when the legal team flagged a discrepancy in a signed agreement.&lt;/p&gt;

&lt;p&gt;This is the retry paradox: the retry loop is supposed to handle uncertainty, but in practice it converts "the model doesn't know" into "the model confidently guessed." The schema never sees the difference.&lt;/p&gt;

&lt;p&gt;The fix: when a retry fails because of missing content (not format), the correct behavior is a human-review flag, not a default fill. "I cannot extract this clause with confidence" is a better output than a wrong value that passes validation.&lt;/p&gt;

&lt;p&gt;We changed the retry logic to distinguish format failures (retry and reformat) from content failures (flag for review). The human-review rate went up. The silent error rate went to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure three: the operator's data
&lt;/h2&gt;

&lt;p&gt;This one took longer to understand.&lt;/p&gt;

&lt;p&gt;Six weeks in, a new batch of contracts arrived from a subsidiary the legal team had recently acquired. Different contract structure, different clause naming conventions, different language patterns. Our extraction accuracy dropped from 94% on the training-corpus contracts to 61% on the acquired subsidiary's contracts.&lt;/p&gt;

&lt;p&gt;We had not seen a single document from that subsidiary during development. Neither had our test suite.&lt;/p&gt;

&lt;p&gt;This is the distribution shift problem. And it is the actual definition of not-operator-ready.&lt;/p&gt;

&lt;p&gt;Production-ready means your agent handles the inputs you tested it on. Operator-ready means your agent handles the inputs the operator is actually going to give it. Those are not the same set.&lt;/p&gt;

&lt;p&gt;The fix was not a better model or a better prompt. It was a process change: before any operator handoff, run the agent on a sample of the operator's own documents, measure accuracy on that corpus specifically, and establish a baseline before you commit to SLA numbers.&lt;/p&gt;

&lt;p&gt;We now require 50 documents from the operator's corpus as part of the pre-handoff checklist. Not synthetic. Not ours. Theirs. If the accuracy on those 50 documents is not close to the accuracy on our training corpus, the handoff gets delayed until we understand why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these three failures have in common
&lt;/h2&gt;

&lt;p&gt;All three were invisible to our eval suite. All three were visible with the right diagnostic.&lt;/p&gt;

&lt;p&gt;The pattern: our eval was measuring our best case (our data, our test set, our format assumptions). Operator-ready means measuring the operator's case. Those are different measurement problems.&lt;/p&gt;

&lt;p&gt;The three things we added to our pre-handoff process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Field-level confidence scoring on every output (not just schema validity)&lt;/li&gt;
&lt;li&gt;Content-failure-vs-format-failure separation in retry logic (fail loudly, not silently)&lt;/li&gt;
&lt;li&gt;Operator corpus sampling before go-live (50 documents from their actual data, reviewed manually)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are in the standard "production-ready" checklist. They're in the operator-ready checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on this
&lt;/h2&gt;

&lt;p&gt;The common response to these failures is "just add more training data" or "fine-tune on the operator's corpus." That's the right long-term fix. It's not the short-term answer.&lt;/p&gt;

&lt;p&gt;Fine-tuning takes weeks and requires labeling budget. An operator pilot that's already started does not have that runway. The faster path is: understand the distribution shift before you commit to accuracy numbers, not after you've already missed them.&lt;/p&gt;

&lt;p&gt;There's also a steelman for the current "validation is enough" approach: for low-stakes use cases with structured, predictable inputs, schema validation really is sufficient. If every contract you're extracting is from the same template, format conformance and content accuracy are highly correlated.&lt;/p&gt;

&lt;p&gt;The problem is that enterprise operators rarely have one template. The legal team that deployed our extractor manages contracts from 14 different counterparties, each with their own conventions. Validation-only was always going to break.&lt;/p&gt;

&lt;p&gt;The concession I'll make: this is a data problem as much as an engineering problem. The teams that invest in building labeled corpora per operator will have substantially better outcomes than the teams that treat operator-ready as a single deployment decision. We didn't invest in that early enough. The second and third failures were partly the cost of that.&lt;/p&gt;

&lt;p&gt;Operator-ready is not a state you reach. It's a process you run.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>production</category>
    </item>
    <item>
      <title># Evaluating an AI agent is not evaluating an LLM call:</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Sun, 28 Jun 2026 22:58:50 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/-evaluating-an-ai-agent-is-not-evaluating-an-llm-call-353i</link>
      <guid>https://dev.to/james_oconnor_dev/-evaluating-an-ai-agent-is-not-evaluating-an-llm-call-353i</guid>
      <description>&lt;p&gt;I compared six tools for evaluating AI agents: LangSmith, Galileo, Arize Phoenix, Braintrust, Future AGI, and Langfuse. My thesis, up front so you can argue with it early: the mistake that wastes the most time is grading the agent's final answer like it is a single LLM call. An agent has a trajectory, which tools it called, in what order, how it recovered, and a wrong final answer and a right-by-luck final answer look identical until you score the path. Here is the rundown as of June 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final answer is not the unit of evaluation
&lt;/h2&gt;

&lt;p&gt;An LLM-call eval grades one output. An agent eval has to grade a sequence: did it call the right tool, with the right arguments, in a sensible order, and recover when a call failed. Two runs can produce the same final answer, one by reasoning correctly and one by luck, and only trajectory-level scoring tells them apart. If your agent eval only looks at the final response, you are testing a chatbot, not an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six, by how deep they score
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;LangSmith.&lt;/strong&gt; The LangChain-native pick. Agent traces plus eval, automatic if you are on LangChain or LangGraph. Deep on traces, proprietary and coupled to that stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Galileo.&lt;/strong&gt; The agent-focused eval pick. Built around agentic workflows with metrics aimed at tool use and task completion, managed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arize Phoenix.&lt;/strong&gt; The open-source OTel pick. Span-level agent traces plus eval, self-hostable, good if you want trajectory visibility without a license.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Braintrust.&lt;/strong&gt; The polished-SaaS pick. Strong eval and observability UI for agents, proprietary, no self-host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future AGI.&lt;/strong&gt; The simulate-then-score pick. Their Simulation runs synthetic voice or text personas through your agent before prod, and agentic_eval scores the multi-turn trajectory, tool calls, stepwise reasoning, and the full conversation, not just the final output (github.com/future-agi, as of June 2026). The draw for me was running a synthetic-persona session through the agent like an integration test and then scoring the path it took, not only where it ended up. It is one option among several here, not the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Langfuse.&lt;/strong&gt; The open-source observability pick. Agent traces plus eval, self-hostable, framework-agnostic; the eval layer is lighter than the eval-specialist tools.&lt;/p&gt;

&lt;p&gt;I am not crowning one. LangSmith if you live in LangChain, Phoenix or Langfuse for self-hosted OTel traces, Galileo or Braintrust for managed agent metrics, the simulate-then-score approach if you want to generate the sessions, not just observe them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually score on a trajectory
&lt;/h2&gt;

&lt;p&gt;Tool-selection-correct (right tool for the step), tool-args-valid, recovery (did it handle a failed call gracefully), and only then final-answer-correct. The first three catch the agent-specific failures the final-answer score hides. The agent that reached a fine answer through three wrong tool calls is a latent incident, not a pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Objections I'd accept / wouldn't
&lt;/h2&gt;

&lt;p&gt;Accept: "single-turn metrics still matter." They do. They grade each response, and you want them. They just miss the cross-turn failures (state, tool ordering) that are the whole reason you built an agent rather than a chatbot, so they are necessary and not sufficient.&lt;/p&gt;

&lt;p&gt;Wouldn't accept: "trajectory scoring is overkill, ship on final-answer accuracy." That is the position that produces the right-by-luck pass. The agent that stumbles to a correct answer through three wrong tool calls will fail differently next week, and your final-answer metric will not have warned you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on this
&lt;/h2&gt;

&lt;p&gt;Steelmanning against myself: trajectory scoring assumes I know what the right path looks like, and for open-ended agents there is often more than one valid path to a good answer. A lot of what I call "wrong trajectory" might be "a reasonable path I did not anticipate," and if I over-fit my eval to one golden path I will punish agents for being creative in ways that are actually fine. The concession: I do not have a clean way to score "took a reasonable path I did not anticipate" without hand-labeling every trajectory. What I hold onto is narrower than full-path matching: tool-args-valid and graceful-recovery are path-independent, they are correct or not regardless of which route the agent took, so I trust those two even when I cannot agree on the one true path. If you have a way to score path-reasonableness without hand-labeling everything, that is the comment I want.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>agents</category>
    </item>
    <item>
      <title>Pydantic passed. Types matched. The downstream system still got garbage.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Thu, 25 Jun 2026 07:01:37 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/pydantic-passed-types-matched-the-downstream-system-still-got-garbage-530b</link>
      <guid>https://dev.to/james_oconnor_dev/pydantic-passed-types-matched-the-downstream-system-still-got-garbage-530b</guid>
      <description>&lt;p&gt;I want to walk through three production failures on the same contract-extraction agent, because they looked unrelated at the time and turned out to be the same problem wearing different clothes. My claim, stated up front so you can disagree with it early: schema validation tells you the grammar is correct and nothing about whether the meaning is. Those are two different jobs, and most teams (mine included, for a while) only build the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: valid JSON, wrong semantics
&lt;/h2&gt;

&lt;p&gt;The extractor used Claude 3.5 Sonnet with Pydantic schemas. A &lt;code&gt;termination_clauses&lt;/code&gt; field accepted &lt;code&gt;list[str]&lt;/code&gt;. Validation passed every time. The trouble was the model returned paraphrases, not verbatim clause text, and the downstream tool did exact-string matching against a database. Paraphrases never matched.&lt;/p&gt;

&lt;p&gt;Pydantic had no way to catch this. The schema said &lt;code&gt;list[str]&lt;/code&gt;. Strings arrived. Valid. The fix was a second-pass semantic check (a model call with a rubric asking, in effect, "are these strings verbatim from the source?"). Success on that field moved from 61% to 94%.&lt;/p&gt;

&lt;p&gt;Lesson: structured-output validation is syntax validation. Semantic validation is a separate layer (and you have to build it on purpose).&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: the retry cost spike
&lt;/h2&gt;

&lt;p&gt;Retry logic via tenacity. One customer's documents carried a dual-signatory clause with an optional co-signer. The schema expected &lt;code&gt;co_signer: Optional[str]&lt;/code&gt;; the model kept returning nested objects instead. Each retry was about $0.04, and on the worst documents that compounded past $2 each before anything escalated.&lt;/p&gt;

&lt;p&gt;Two changes: cap retries at 5 with escalation to human review, and audit any new document type before it hits production.&lt;/p&gt;

&lt;p&gt;Lesson: unlimited retry logic on validation failures is a latent billing incident (it just hasn't billed you yet).&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 3: the model-switch regression
&lt;/h2&gt;

&lt;p&gt;We moved &lt;code&gt;GPT-4o&lt;/code&gt; to &lt;code&gt;GPT-4.5&lt;/code&gt;. Success on &lt;code&gt;party_obligations&lt;/code&gt; (a field that needs three-level nesting for conditional logic) fell from 91% to 73%. The newer model handled ambiguous cases with flatter structures. Valid JSON, wrong nesting, Pydantic waved it through, downstream broke quietly.&lt;/p&gt;

&lt;p&gt;The fix was shadow evaluation after any upgrade: run old and new models against the same production documents, and flag any field where agreement drops below 95% before shipping.&lt;/p&gt;

&lt;p&gt;Lesson: model upgrades are schema-compatibility events (treat them like a dependency bump, not a free swap).&lt;/p&gt;

&lt;h2&gt;
  
  
  The common thread
&lt;/h2&gt;

&lt;p&gt;None of these surfaced as a Pydantic error. The schema was valid each time. The real failures were semantic drift, an uncontrolled retry loop, and a model-specific regression. In every case the grammar was fine and the meaning was not, which is precisely the thing type validation cannot see.&lt;/p&gt;

&lt;p&gt;What the stack looks like now: Pydantic for syntax, a lightweight evaluator for semantics, DeepEval's correctness metric for the text fields, retries capped, an escalation field on every extraction schema so failure modes are a design-time decision, and a shadow-eval checklist of 200 production documents on any model change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Objections I'd accept / wouldn't
&lt;/h2&gt;

&lt;p&gt;Accept: "stricter schemas would have caught some of this." Partly true. Enums, discriminated unions, and constrained types genuinely shrink the semantic-validation surface when your domain is stable and bounded. If that's you, lean on them.&lt;/p&gt;

&lt;p&gt;Wouldn't accept: "so you don't need eval around structured output." Three production failures, two of them customer escalations, disagree. Stricter types reduce the surface; they do not remove it, and they get brittle the moment a new document shape arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on this
&lt;/h2&gt;

&lt;p&gt;If I'm steelmanning the opposite of my own thesis: maybe the honest read is that I under-specified my schemas and called it a semantics problem to feel better about it. A verbatim-quote field could have been a constrained type backed by a span reference into the source, not a free &lt;code&gt;str&lt;/code&gt;. A lot of what I'm calling "semantic validation" is really "validation I was too lazy to encode structurally."&lt;/p&gt;

&lt;p&gt;So here's the concession. If you have shipped high-volume extraction without a semantic eval layer and held accuracy above 92% for more than six months, I'd genuinely like to see the schema design, because either you bounded the domain harder than I did, or you encoded meaning into types better than I did. The part I won't give up: somewhere, a field has to assert meaning, and if it isn't your schema doing it, it has to be something downstream of the schema.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>I put 6 LLM guardrail tools inline and measured what they cost me. Here is the latency-vs-recall tradeoff.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Thu, 18 Jun 2026 05:50:48 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/i-put-6-llm-guardrail-tools-inline-and-measured-what-they-cost-me-here-is-the-latency-vs-recall-433g</link>
      <guid>https://dev.to/james_oconnor_dev/i-put-6-llm-guardrail-tools-inline-and-measured-what-they-cost-me-here-is-the-latency-vs-recall-433g</guid>
      <description>&lt;p&gt;An input guardrail runs on every request. Too slow and you rip it out; fast but blind and you get owned. That tradeoff, not the feature list, is the whole decision.&lt;/p&gt;

&lt;p&gt;TL;DR: I ran six guardrail and prompt-injection tools inline on a production agent for a few weeks (Lakera Guard, Llama Guard, NeMo Guardrails, Guardrails AI, Future AGI's fi.evals scanners, and ProtectAI's LLM Guard). The deciding axis was not which one detects the most attack types, it was which one was fast enough to run on every request without anyone noticing, while still catching the injections that mattered. Here is the rundown as of June 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  An input guardrail sits on the hot path, so latency is the spec
&lt;/h2&gt;

&lt;p&gt;A guardrail that inspects every prompt before it reaches the model adds to every request. Anything over about 50ms inline and users feel it; over about 200ms and someone disables it during an incident. So the real spec is narrow: catch the attack classes you care about (jailbreak, injection, PII or secret leak) inside a latency budget you can afford on the hot path. A 99 percent recall guardrail that adds 400ms is worse in practice than a 95 percent one at 10ms, because the slow one gets turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six, on the latency-vs-recall axis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lakera Guard&lt;/strong&gt;: the commercial-API pick. Strong prompt-injection detection, hosted, low effort to integrate. The tradeoff is a network hop per call (latency plus a third party in your request path) and per-call cost.&lt;br&gt;
&lt;strong&gt;Llama Guard&lt;/strong&gt;: Meta's open LLM-based safeguard model. Flexible policy taxonomy, runs on your own infra. It is an LLM, so it is the heaviest of these on latency unless you serve it carefully.&lt;br&gt;
&lt;strong&gt;NeMo Guardrails&lt;/strong&gt;: NVIDIA's open-source programmable rails (you write flows in Colang). Powerful for conversational and topical boundaries; more of a framework than a drop-in scanner, with the setup cost to match.&lt;br&gt;
&lt;strong&gt;Future AGI fi.evals scanners&lt;/strong&gt;: the inline-speed pick, from their Apache-2.0 ai-evaluation SDK (github.com/future-agi). Local scanners for jailbreak, code injection, PII, and secrets that block in under 10ms and tell you what tripped via result.blocked_by, as of June 2026. The draw was the latency: it runs on the hot path with no network hop, and the managed tier adds model-backed ensemble guardrails on top. Worth saying plainly: these cover attack and safety classes, not business-rule semantic checks.&lt;br&gt;
&lt;strong&gt;Guardrails AI&lt;/strong&gt;: the open-source validation-framework pick. A library of validators (structure, PII, toxicity) you compose; some are fast, some call a model, so your latency depends on which you switch on.&lt;br&gt;
&lt;strong&gt;ProtectAI LLM Guard&lt;/strong&gt;: open-source scanners for input and output (prompt injection, secrets, toxicity). Similar shape to a scanner pipeline; benchmark it against your own latency budget.&lt;/p&gt;

&lt;p&gt;I am not crowning one. For lowest-effort hosted detection it was Lakera; for policy flexibility on your own infra, Llama Guard or NeMo; for inline speed with no network hop, the local-scanner approach. They sit at different points on the same curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I gate on, and what I only log
&lt;/h2&gt;

&lt;p&gt;Hard-gate (block the request) on the cheap, high-precision classes: secret and API-key leaks, obvious jailbreak strings, code injection. Log-and-alert (do not block) on the fuzzy classes where a false positive is worse than a miss, because blocking a legitimate user is its own incident. The split is by "how bad is a false positive here," the same logic as eval gating.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Inline or async?&lt;/strong&gt; The cheap deterministic scanners go inline on the hot path; the heavy model-based ones run async or on a sample, unless you can afford the latency.&lt;br&gt;
&lt;strong&gt;Do these catch business-logic abuse?&lt;/strong&gt; No. They catch attack and safety classes (injection, PII, secrets). "The agent did something it should not for THIS user" is a semantic and authorization check you still have to write.&lt;br&gt;
&lt;strong&gt;One tool or several?&lt;/strong&gt; Usually a fast local scanner inline plus a heavier model-based check async. Different tools for different points on the curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;Every one of these catches the attack classes you name in advance. The injection that gets through is the one shaped like a class you did not configure, and injection is adversarial, so the attack distribution shifts under you. I do not have a clean way to catch the novel injection that matches no configured scanner. If you have, that is the comment I want.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Bounded retries for agent tool calls: the budget that stopped our infinite-loop incidents</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:11:50 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/bounded-retries-for-agent-tool-calls-the-budget-that-stopped-our-infinite-loop-incidents-4354</link>
      <guid>https://dev.to/james_oconnor_dev/bounded-retries-for-agent-tool-calls-the-budget-that-stopped-our-infinite-loop-incidents-4354</guid>
      <description>&lt;h2&gt;
  
  
  The worst incident our agent caused was not a wrong answer. It was a loop.
&lt;/h2&gt;

&lt;p&gt;The worst incident our agent ever caused was not a wrong answer. It was a loop. A tool call failed, the agent retried, the retry failed the same way, and it kept going, burning tokens and hammering a downstream API a few hundred times in a minute before anything stopped it. The agent was doing exactly what we had told it: if a tool fails, try again. We just never told it when to stop.&lt;/p&gt;

&lt;p&gt;Retries are the right instinct. A transient failure should be retried. The problem is that an agent does not reliably distinguish "transient" from "this will fail every time," and left to its own judgment it will cheerfully retry a permanently broken call until something external kills it. Human-written code learned this lesson decades ago: every retry loop has a bound. Agent tool-calling quietly forgot it, because the retry decision moved from a for-loop you can see into the model's reasoning, where it is invisible and unbounded.&lt;/p&gt;

&lt;p&gt;So we put the bound back, outside the model. Two budgets: a per-call retry cap (this specific tool call gets N attempts, then it is a hard failure the agent must handle differently), and a per-session attempt budget (the whole task gets a ceiling on total tool calls, after which we stop and escalate rather than let it spin).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_call&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;per_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_session&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_calls&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolGivingUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts, stop retrying, try another path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_calls&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SessionGivingUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool budget exhausted, escalate to a human&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the cap, it is what happens at the cap. A retry that just stops leaves the agent stuck. So when the per-call budget is exhausted we hand the agent a specific message ("this call has failed twice, do not retry it, try a different approach or ask for help"), which turns a dead loop into a decision. Most of the time the agent then does something sensible, because now it knows retrying is off the table.&lt;/p&gt;

&lt;p&gt;Tool-misuse loops in our logs went from a handful of nasty incidents a month to basically none, and the few that remain hit the session budget and escalate cleanly instead of paging someone at 2am about a runaway API bill.&lt;/p&gt;

&lt;p&gt;The tension I have not fully resolved: a per-session budget that is too tight kills legitimately long tasks (a genuine multi-step workflow can need a lot of tool calls), and one that is too loose lets a slow loop run up real cost before it trips. We set ours from the 95th percentile of healthy task lengths and pad it, which is empirical and a little arbitrary. If you have found a non-arbitrary way to bound agent tool-call budgets, that is the comment I am reading.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>We version our tool schemas like an API contract, because the agent is a consumer</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Mon, 15 Jun 2026 05:40:22 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/we-version-our-tool-schemas-like-an-api-contract-because-the-agent-is-a-consumer-43fc</link>
      <guid>https://dev.to/james_oconnor_dev/we-version-our-tool-schemas-like-an-api-contract-because-the-agent-is-a-consumer-43fc</guid>
      <description>&lt;p&gt;TL;DR: We changed a tool's return schema, shipped it, and watched about 1 in 5 of that tool's calls start failing downstream, even though every call still validated. The schema was internal, so nobody treated the change like a breaking API change. But the agent is a consumer of that schema exactly like an external client is, and we had just shipped a breaking change to a consumer with no version, no deprecation, no migration. Now we version tool schemas like the public contracts they actually are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent is a client you forgot you had
&lt;/h2&gt;

&lt;p&gt;When a human team consumes your API, you version it, you deprecate fields with notice, you do not rename a field on a Tuesday. When the consumer is your own agent, all of that discipline evaporates, because the schema lives in your repo and feels internal. It is not internal. The agent was trained, prompted, or wired against the old shape, and renaming &lt;code&gt;order_id&lt;/code&gt; to &lt;code&gt;orderId&lt;/code&gt; is as breaking for it as for any client, just quieter, because it fails as worse decisions rather than a 400.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a breaking change looks like to an agent
&lt;/h2&gt;

&lt;p&gt;This is the part that makes it sneaky. A human client gets a hard error on a missing field. An agent gets a &lt;code&gt;None&lt;/code&gt;, treats it as "the value is absent," and makes a plausible wrong decision with a perfectly valid-looking tool call. Our validation passed every time. The damage showed up two hops later as the agent choosing the wrong branch because a field it depended on had silently moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we version them now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolSchemaV2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;          &lt;span class="c1"&gt;# kept; do NOT rename without a major bump
&lt;/span&gt;    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="c1"&gt;# added in v2, optional so v1 consumers still parse
&lt;/span&gt;    &lt;span class="n"&gt;refund_window_closes_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules we adopted, lifted straight from API practice: additive changes are a minor bump and optional; renames and removals are a major bump with both shapes served during a deprecation window; the agent's expected schema version is pinned and checked, so a mismatch is a loud failure at the boundary instead of a quiet wrong decision two hops later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The open question
&lt;/h2&gt;

&lt;p&gt;Deprecation windows assume you can run two shapes at once, which is fine for fields but hard for semantics: if the &lt;em&gt;meaning&lt;/em&gt; of a field changes rather than its name, there is no optional-field trick that saves you. I do not have a clean way to version a semantic change to a tool's contract, only structural ones. If you have versioned a meaning change to an agent's tool cleanly, that is the comment I want to read.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>We logged every rejected tool call for a month. A third were our validation being wrong, not the model.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Mon, 15 Jun 2026 05:33:09 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/we-logged-every-rejected-tool-call-for-a-month-a-third-were-our-validation-being-wrong-not-the-3nm1</link>
      <guid>https://dev.to/james_oconnor_dev/we-logged-every-rejected-tool-call-for-a-month-a-third-were-our-validation-being-wrong-not-the-3nm1</guid>
      <description>&lt;p&gt;TL;DR: Everyone logs tool calls that error or return junk. We started logging the calls our own validation REJECTED before they ever ran. Over a month, about 1 in 3 of those rejections were false: a valid user intent our schema or precheck was too rigid to accept. We had spent weeks hardening the guardrail and never checked whether it was now blocking real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot in "we added validation"
&lt;/h2&gt;

&lt;p&gt;After an incident where our agent made a structurally valid but wrong tool call, we added a precheck layer in front of every state-mutating tool. Failures dropped, we moved on. What we did not log was the other side of the ledger: every time the precheck said no. A block felt like a success by definition. The agent tried something bad, we stopped it, good.&lt;/p&gt;

&lt;p&gt;Then support started forwarding tickets where the agent refused something the user was clearly allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rejection log showed
&lt;/h2&gt;

&lt;p&gt;So we logged every rejection with three fields: which check fired, the full arguments, and the user-visible outcome. One month, 612 rejections. We hand-reviewed a sample.&lt;/p&gt;

&lt;p&gt;Roughly a third were false rejections. The pattern was almost always the same: a check written to stop one specific bad case was also catching a legitimate neighbouring case nobody thought about when they wrote it. The "is this order in the cancellation window" check rejected legitimate cancellations on orders whose timezone put them one hour outside a window they were actually inside. The "does this id exist in retrieved context" check rejected valid ids that arrived through a second tool the author had not considered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_precheck&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasons&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;
&lt;span class="c1"&gt;# the 'rejected' branch is the one nobody reads. read it weekly.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;Two things. First, a weekly fifteen-minute review of a sample of rejections, same as we review errors. False rejections get the check loosened or split. Second, checks now fail with a specific reason string the agent can act on, not a generic block, so a too-strict check often self-corrects: the agent reads "outside cancellation window by your local timezone" and escalates instead of dead-ending.&lt;/p&gt;

&lt;p&gt;False rejections fell from a third to under a tenth over six weeks. The number that matters more: support tickets about the agent refusing valid requests basically stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tension I have not resolved
&lt;/h2&gt;

&lt;p&gt;Every loosened check is a check that now lets more through, which is the exact surface the check was added to close. We have not found a principled way to loosen a guardrail without quietly reopening the hole. Right now we lean on the canonical-examples test (the bad case that prompted the check stays frozen as a must-block), but that only protects against the failures we have already seen.&lt;/p&gt;

&lt;p&gt;If you run guardrails on an agent: do you measure your false-rejection rate at all, and if so, how do you loosen a check without trusting that a frozen example covers the regression? That is the part I keep getting wrong.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your schema validation passes and the agent still picks the wrong tool. The bug is semantic.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Wed, 10 Jun 2026 04:05:27 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/your-schema-validation-passes-and-the-agent-still-picks-the-wrong-tool-the-bug-is-semantic-2i41</link>
      <guid>https://dev.to/james_oconnor_dev/your-schema-validation-passes-and-the-agent-still-picks-the-wrong-tool-the-bug-is-semantic-2i41</guid>
      <description>&lt;p&gt;Pydantic and JSON-schema guarantee the shape of a tool call. They say nothing about whether it was the right call for the user's intent.&lt;/p&gt;

&lt;p&gt;TL;DR: We put strict Pydantic validation on every tool call our agent makes, expecting tool-call failures to drop. They barely did. When I categorized 40 logged failures, 31 of them passed schema validation cleanly. They were well-formed calls to the wrong tool, or the right tool with arguments that were valid types but wrong values. Schema validation catches structural errors. Our actual problem was semantic, and the validator is blind to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What schema validation actually guarantees
&lt;/h2&gt;

&lt;p&gt;Pydantic checks types, required fields, enums, ranges. A call like cancel_order(order_id="A123") is structurally perfect even when the user asked to cancel a subscription, not an order. The validator passes it. The user is still angry. Shape is not intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 40-failure breakdown
&lt;/h2&gt;

&lt;p&gt;Of 40 tool-call failures we logged over a few weeks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9 were real schema violations the validator caught (working as intended).&lt;/li&gt;
&lt;li&gt;18 were the wrong tool for the intent, all schema-valid.&lt;/li&gt;
&lt;li&gt;13 were the right tool with a semantically wrong argument (valid type, wrong value).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So 31 of 40 sailed through validation. The thing we added to fix tool-call failures addressed less than a quarter of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cheap semantic precheck that helped
&lt;/h2&gt;

&lt;p&gt;After structural validation passes, run a deterministic check that the call's preconditions match the resolved state. No model required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;precheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# structural validation already passed; now check semantics vs resolved state
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancel_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CANCELABLE&lt;/span&gt;
    &lt;span class="c1"&gt;# ... one branch per destructive tool
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This killed the 13 wrong-argument cases almost entirely: the id was valid as a string but did not resolve to a cancelable order owned by this user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case this does not solve
&lt;/h2&gt;

&lt;p&gt;The wrong-tool-for-intent bucket (the 18) is harder. Detecting that the agent chose cancel_order when the user meant cancel_subscription is itself an intent-understanding problem, and using another model to judge it just inherits the same blind spot. We stopped trying to verify intent automatically for destructive tools and put a one-line confirmation step in front of them instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;How do you test that an agent picked the right tool, not just a well-formed one, without leaning on an LLM judge that shares the failure mode? The precheck handles arguments. Tool selection itself I still gate behind a human-style confirmation, which feels like admitting defeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Doesn't an LLM judge catch the wrong-tool case?&lt;/strong&gt; Sometimes, but it misreads intent the same way the agent did, so we do not trust it on the destructive path.&lt;br&gt;
&lt;strong&gt;Which model?&lt;/strong&gt; Genericize: the agent and any judge should be from different model families, but the precheck above is model-agnostic on purpose.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>Validate your Pydantic schema before the LLM call, not after.</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Tue, 09 Jun 2026 08:12:31 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/validate-your-pydantic-schema-before-the-llm-call-not-after-5c5c</link>
      <guid>https://dev.to/james_oconnor_dev/validate-your-pydantic-schema-before-the-llm-call-not-after-5c5c</guid>
      <description>&lt;p&gt;A small change that cut our schema-related retries: validate the Pydantic model before sending the request, not after the LLM responds. The usual flow is call, parse, catch the validation error, retry. That burns a full token budget before you learn the schema was wrong. Instead we instantiate the target model with dummy data at boot and on every schema change, and dry-parse a known-good example before the real call. If the schema itself is broken (a bad discriminator, a wrong field type, a renamed enum) it fails in CI or at boot, not on a paid call. Two lines, zero runtime cost, and it caught about 60 percent of our schema bugs before they reached the model. The other 40 percent are genuine model failures, and those are the ones worth retrying. Separating 'my schema is wrong' from 'the model is wrong' is the whole point. Most retry loops conflate them and pay for it in tokens.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>MCP tool naming: 6 patterns ranked by how well they survive a refactor</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Fri, 05 Jun 2026 05:29:55 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/mcp-tool-naming-6-patterns-ranked-by-how-well-they-survive-a-refactor-2gf3</link>
      <guid>https://dev.to/james_oconnor_dev/mcp-tool-naming-6-patterns-ranked-by-how-well-they-survive-a-refactor-2gf3</guid>
      <description>&lt;p&gt;What happens to your agent when the team renames users to accounts, and why the tool name you picked six months ago decides whether anything breaks&lt;/p&gt;

&lt;p&gt;TL;DR: I have shipped MCP servers where the tool names were a thin shell over the underlying REST API, and I have shipped servers where the names came from the domain model instead. The domain-named ones survived backend refactors with close to zero churn. The pass-through ones broke every time someone renamed a table or split a service. After ranking six naming patterns by two axes (how well a name survives a refactor of the system underneath it, and how cleanly the model can pick the right tool), my house pick is ubiquitous-language naming inside a bounded-context prefix, with Pydantic discriminated-union return types doing the schema work. The one-line version of my opinion: Domain-Driven Design plus tool-use schemas is the production fix for agents. The MCP layer is where the anti-corruption-layer belongs, not an afterthought you bolt on later.&lt;/p&gt;

&lt;p&gt;A quick scoping note. I am going to use a refund as a naming example throughout, because everyone has a mental model of what a refund is. I am not describing a system that lets a model issue refunds on its own. The refund here is a stand-in for any operation whose name you have to choose. Treat it as a label, not as a production design I am endorsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two axes
&lt;/h2&gt;

&lt;p&gt;Refactor-survival is whether the name stays correct when the system underneath it changes. A name with high refactor-survival describes something stable (an intent in the business domain) rather than something volatile (the current shape of the database or the current REST route).&lt;/p&gt;

&lt;p&gt;LLM-selection clarity is whether the model reliably picks the right tool from the name and description alone. Names that are too clever, too abstract, or too collision-prone make the model hesitate or pick wrong. This axis sometimes rewards the literal names that the first axis punishes, which is the whole tension.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Refactor-survival&lt;/th&gt;
&lt;th&gt;LLM-selection clarity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Pass-through (mirrors the API)&lt;/td&gt;
&lt;td&gt;create_user, delete_user&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Verb-first&lt;/td&gt;
&lt;td&gt;process_refund&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Noun-first / resource-dot-action&lt;/td&gt;
&lt;td&gt;refund.create&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Domain-prefix + bounded context&lt;/td&gt;
&lt;td&gt;billing.refund.process&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Ubiquitous-language naming&lt;/td&gt;
&lt;td&gt;deactivate_account (not delete)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Schema-first (Pydantic discriminated union)&lt;/td&gt;
&lt;td&gt;name plus typed return&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High (with caveats)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ranking I defend, best to worst on the combined axes: 5, then 6, then 4, then 2, then 3, then 1. Patterns 5 and 6 are not rivals. You use them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pass-through naming
&lt;/h2&gt;

&lt;p&gt;The backend has POST /users, PATCH /users/{id}, DELETE /users/{id}, so the tools become create_user, update_user, delete_user. Fast to write, the model reads it fine. Failure mode: the name is welded to the current API surface. The day someone decides a user is really an account, the underlying route changes and your tool name is now a lie. If you rename the tool, every prompt and eval fixture and agent that learned create_user has to be updated. If you do not, new engineers read create_user and go looking for a users table that no longer exists. High on clarity today, low on survival tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Verb-first
&lt;/h2&gt;

&lt;p&gt;process_refund, cancel_subscription, send_invoice. The action leads, which reads well because tool selection is fundamentally a verb-matching problem. A good verb often describes intent rather than transport, so a backend refactor can leave the name intact. Failure mode: verbs collide and drift as the surface grows. You add process_payment, process_payout, process_chargeback, and now process carries four meanings. process is a weak verb, so people reach for it whenever they cannot think of the precise word. Medium survival, high clarity that decays as the tool count climbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Noun-first, resource-dot-action
&lt;/h2&gt;

&lt;p&gt;refund.create, subscription.cancel. Groups nicely for a human browsing the list. Failure mode: it optimizes for the resource taxonomy at the cost of the thing the model is best at, verb matching. With the action second, the model reads refund first and has to hold it before the verb that tells it what to do. The grouping also tempts you into CRUD-over-resources when the domain operation you want is richer than create/update/delete. The pattern I reach for least.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Domain-prefix with a bounded context
&lt;/h2&gt;

&lt;p&gt;billing.refund.process, identity.account.deactivate. The first segment names the bounded context (the term is from Domain-Driven Design): the part of the business this tool lives in. Namespacing that means something: a billing.transfer and an inventory.transfer stay apart for both model and reader. And a bounded context is a deliberately stable concept, so the prefix survives refactors that wreck pass-through names. Failure mode: the prefix is only as good as your context boundaries, and most teams have not drawn them. If billing and payments and invoicing are three overlapping prefixes nobody can distinguish, you have added ceremony without clarity. The other failure is verbosity. Used with real boundaries it is excellent. Used as decoration it is worse than a flat verb-first name.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ubiquitous-language naming
&lt;/h2&gt;

&lt;p&gt;The one I will defend hardest. Name the tool after the word the domain experts actually use, not the database verb. Do not call it delete_account. In almost every real billing or identity domain you do not delete an account, you deactivate it or close it, and the row sticks around for compliance and audit. So the tool is deactivate_account, and that name encodes a true fact about the domain that delete hides. Two good things happen: the model gets a precise verb (deactivate is far less collision-prone than delete or process), and the name stays correct across refactors because the business meaning does not change when you swap the database. Failure mode: it requires that a ubiquitous language actually exists, and on a lot of teams it does not, or there are three competing dialects. And there is a discipline cost: someone has to resist the easy delete and insist on the accurate deactivate in code review, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Schema-first with Pydantic discriminated unions
&lt;/h2&gt;

&lt;p&gt;The first five patterns are about the name. This one is about the return type, and it is where the leverage actually is. A vague return schema (a bare dict, a stringified blob) undoes all the discipline you put into the name. A discriminated union lets one tool return several clearly-distinguished outcomes, each with its own typed shape, tagged by a literal field the model can branch on.&lt;/p&gt;

&lt;p&gt;from typing import Literal, Annotated, Union&lt;br&gt;
from decimal import Decimal&lt;br&gt;
from pydantic import BaseModel, Field&lt;/p&gt;

&lt;p&gt;class RefundIssued(BaseModel):&lt;br&gt;
    status: Literal["issued"] = "issued"&lt;br&gt;
    refund_id: str&lt;br&gt;
    amount: Decimal&lt;br&gt;
    currency: str = Field(min_length=3, max_length=3)&lt;/p&gt;

&lt;p&gt;class RefundPending(BaseModel):&lt;br&gt;
    status: Literal["pending_review"] = "pending_review"&lt;br&gt;
    request_id: str&lt;br&gt;
    reason: str&lt;/p&gt;

&lt;p&gt;class RefundRejected(BaseModel):&lt;br&gt;
    status: Literal["rejected"] = "rejected"&lt;br&gt;
    code: Literal["already_refunded", "outside_window", "amount_exceeds_charge"]&lt;br&gt;
    message: str&lt;/p&gt;

&lt;p&gt;RefundOutcome = Annotated[&lt;br&gt;
    Union[RefundIssued, RefundPending, RefundRejected],&lt;br&gt;
    Field(discriminator="status"),&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;class RefundResult(BaseModel):&lt;br&gt;
    outcome: RefundOutcome&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;a class="mentioned-user" href="https://dev.to/mcp"&gt;@mcp&lt;/a&gt;.tool()
&lt;/h1&gt;

&lt;p&gt;def request_account_refund(charge_id: str, amount: Decimal) -&amp;gt; RefundResult:&lt;br&gt;
    """Request a refund against a charge. Returns one of three outcomes:&lt;br&gt;
    issued, pending_review, or rejected (with a reason code)."""&lt;br&gt;
    ...&lt;/p&gt;

&lt;p&gt;The model reads three named outcomes with three shapes, branches on status, and never parses prose to find out what happened. Failure mode: discriminated unions are easy to over-build. Nine variants where three would do is a decision tree the model did not need. The other trap is letting the union drift out of sync with reality, at which point the model gets a typed promise the system cannot keep, which is worse than an honest untyped blob.&lt;/p&gt;

&lt;h2&gt;
  
  
  House pick
&lt;/h2&gt;

&lt;p&gt;My default, for any MCP server fronting a system I expect to change, is pattern 5 inside a pattern 4 prefix, with pattern 6 on the return side. Concretely: billing.deactivate_account, returning a typed discriminated union. The reason I keep coming back to it is the anti-corruption layer. In DDD an anti-corruption-layer is the translation seam between your clean domain model and a messier external system. An MCP server sits in exactly that position between a model and your backend. If you name tools after the backend, you have no anti-corruption-layer, you have a passthrough, and every backend change corrupts the model's view of your system. That is the whole argument for why Domain-Driven Design plus tool-use schemas is the production fix for agents.&lt;/p&gt;

&lt;p&gt;One number, modest and from one project: when we moved a cluster of pass-through tool names over to ubiquitous-language names on a server with roughly thirty tools, our internal tool-selection eval went from about 88 percent to about 94 percent correct-tool-on-first-try. I would not over-read a single before/after on one codebase. The renames that helped most were the ones that killed verb collisions (three different process_* tools) and the ones that replaced delete with the accurate domain verb.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Is dotted naming like billing.refund.process even valid for an MCP tool name? Depends on the server framework and client. Some accept dots, others constrain names and you simulate the hierarchy with underscores. Check what your server and client allow. The principle (a stable context prefix) survives whichever character you use.&lt;/p&gt;

&lt;p&gt;Will a discriminated union confuse the model more than a flat dict? In my experience it does the opposite, as long as the variants are genuinely distinct and few. The confusion comes from too many variants, not from the union itself.&lt;/p&gt;

&lt;p&gt;How is ubiquitous-language naming different from just picking a good verb? A good verb is a writing instinct. Ubiquitous language is a sourcing rule: the verb has to be the one the domain experts actually use, verified against how the business talks, not invented at your desk.&lt;/p&gt;

&lt;p&gt;Do I need DDD to get value here? No. You can adopt ubiquitous-language naming and typed returns without ever drawing a context map. The bounded-context prefix specifically only pays off if you have real boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open questions I am still chewing on
&lt;/h2&gt;

&lt;p&gt;When a discriminated-union return genuinely has to gain a fourth variant, what is the least disruptive way to roll it out to agents that already learned the three-variant shape? If two bounded contexts legitimately share a verb and a noun, is the prefix enough, or does the duplication signal the boundary is drawn wrong? And the one I go back and forth on: how much naming discipline is worth it before the tool count is high enough to matter. On a five-tool server, pass-through names are fine and contexts and unions are overhead. Somewhere between five and fifty the calculus flips, and I do not have a clean threshold.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>architecture</category>
      <category>mcp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Pydantic V2 discriminator pattern for MCP return types</title>
      <dc:creator>James O'Connor</dc:creator>
      <pubDate>Tue, 02 Jun 2026 14:55:09 +0000</pubDate>
      <link>https://dev.to/james_oconnor_dev/pydantic-v2-discriminator-pattern-for-mcp-return-types-3pnl</link>
      <guid>https://dev.to/james_oconnor_dev/pydantic-v2-discriminator-pattern-for-mcp-return-types-3pnl</guid>
      <description>&lt;p&gt;When an MCP tool can return one of several shapes (success, partial-success, error), the cleanest typing is a Pydantic V2 discriminated union. The pattern survived our last 3 refactors of the tool-return surface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundPartial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partial&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partial&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;amount_remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="n"&gt;RefundResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;RefundSuccess&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;RefundPartial&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;RefundError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discriminator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this beats the alternatives:&lt;/p&gt;

&lt;p&gt;The literal discriminator (&lt;code&gt;status&lt;/code&gt;) is the field MCP clients (and downstream LLMs) parse first. Pydantic builds the right model based on that field. No if-elif branching in the client.&lt;/p&gt;

&lt;p&gt;Subclassing a common parent works but loses the structural specificity. The LLM gets a parent type and has to guess the actual shape.&lt;/p&gt;

&lt;p&gt;Optional fields with None for the missing branches works but the type signature lies to readers. The discriminator approach makes the shape obvious.&lt;/p&gt;

&lt;p&gt;The gotcha: keep the discriminator field name consistent across the whole MCP server. Pydantic V2's &lt;code&gt;discriminator=&lt;/code&gt; is per-union; if one union uses &lt;code&gt;status&lt;/code&gt; and another uses &lt;code&gt;type&lt;/code&gt;, contributors will mix them up. Pick one and document it in the contributor guide.&lt;/p&gt;

</description>
      <category>python</category>
      <category>mcp</category>
      <category>pydantic</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
