<?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: xn</title>
    <description>The latest articles on DEV Community by xn (@xbstack).</description>
    <link>https://dev.to/xbstack</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%2F4041503%2F9c739d89-3716-4032-abe2-f2a93ffb32fd.png</url>
      <title>DEV Community: xn</title>
      <link>https://dev.to/xbstack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xbstack"/>
    <language>en</language>
    <item>
      <title>Google ADK state_delta Not Applied on Resume: Runner.run_async Reproduction and Workaround</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Sun, 09 Aug 2026 13:26:30 +0000</pubDate>
      <link>https://dev.to/xbstack/google-adk-statedelta-not-applied-on-resume-runnerrunasync-reproduction-and-workaround-k7d</link>
      <guid>https://dev.to/xbstack/google-adk-statedelta-not-applied-on-resume-runnerrunasync-reproduction-and-workaround-k7d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A production-focused update based on a real project: Google ADK 2.6.2 repro: Runner.run_async ignores state_delta when resuming by invocation_id without new_message. Includes four…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you use Google ADK resumability, there is a failure mode that is harder to notice than an exception: &lt;code&gt;Runner.run_async()&lt;/code&gt; resumes successfully, the invocation continues, but the &lt;code&gt;state_delta&lt;/code&gt; passed with that resume never reaches &lt;code&gt;session.state&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I reproduced the behavior locally on &lt;code&gt;google-adk==2.6.2&lt;/code&gt; without an API key or an external model call. The boundary was consistent across both dispatch paths I tested: the Node path using &lt;code&gt;LlmAgent&lt;/code&gt; and the legacy path using a plain &lt;code&gt;BaseAgent&lt;/code&gt;. When the resume has no &lt;code&gt;new_message&lt;/code&gt;, the delta is not applied. With a &lt;code&gt;new_message&lt;/code&gt;, the same delta is persisted.&lt;/p&gt;

&lt;p&gt;That distinction matters for approval callbacks, background-job resumes, and long-running workflows where an external system may need to update state without inventing another user message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;p&gt;My local environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS 26.5.2 arm64&lt;/li&gt;
&lt;li&gt;Python 3.10.2&lt;/li&gt;
&lt;li&gt;&lt;code&gt;google-adk==2.6.2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ResumabilityConfig(is_resumable=True)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;InMemorySessionService&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;an offline Echo model stub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four-case matrix was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner path&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;new_message&lt;/code&gt; on resume&lt;/th&gt;
&lt;th&gt;Was &lt;code&gt;state_delta&lt;/code&gt; applied?&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node / &lt;code&gt;LlmAgent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Failure reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node / &lt;code&gt;LlmAgent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Control passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;legacy / &lt;code&gt;BaseAgent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Failure reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;legacy / &lt;code&gt;BaseAgent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Control passes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Filr0dwn9p455ccbhlgro.webp" 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%2Filr0dwn9p455ccbhlgro.webp" alt="Four Google ADK state_delta reproduction cases across Node and legacy runner paths with and without new_message" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2. The same delta is lost on both resume paths without &lt;code&gt;new_message&lt;/code&gt;, and applied on both control paths when a user message exists.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Actual output from the local reproduction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node-no-message        new_message=False applied=False state={}
node-with-message      new_message=True  applied=True  state={'resumed_key': 'resumed_value'}
legacy-no-message      new_message=False applied=False state={}
legacy-with-message    new_message=True  applied=True  state={'resumed_key': 'resumed_value'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problematic call is structurally simple:&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;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;state_delta&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;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On ADK 2.6.2 in this test, the call can continue without an exception while the delta is not persisted. The important wording is &lt;strong&gt;the delta is ignored&lt;/strong&gt;. Existing session state is not proven to be erased or reset by this test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;At first, this looks like a SessionService problem. You might suspect that &lt;code&gt;InMemorySessionService&lt;/code&gt; did not persist state, that the wrong &lt;code&gt;invocation_id&lt;/code&gt; was resumed, or that a callback later overwrote the state.&lt;/p&gt;

&lt;p&gt;The controls narrow that down. The SessionService, agent, delta, and resumability configuration are the same; the only meaningful difference is whether a &lt;code&gt;new_message&lt;/code&gt; exists on resume. With no message the final state is &lt;code&gt;{}&lt;/code&gt;. With a message the final state contains:&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resumed_key&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;resumed_value&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;That makes this different from “ADK state does not work on resume.” State updates do work on the control path. The failure is tied to how &lt;code&gt;state_delta&lt;/code&gt; reaches the event-persistence path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ADK 2.6.2 source path shows
&lt;/h2&gt;

&lt;p&gt;I inspected the locally installed 2.6.2 Runner implementation. On the Node execution path, appending the user event is gated by the presence of &lt;code&gt;new_message&lt;/code&gt;:&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;if&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;user_event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;_append_user_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state_delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state_delta&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_append_user_event()&lt;/code&gt; is where the delta is attached to &lt;code&gt;EventActions&lt;/code&gt;:&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="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;EventActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state_delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state_delta&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&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;The event is then persisted through:&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append_event&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That explains the control result: when a user message exists, the delta rides on the user event and reaches the SessionService. When the invocation is resumed without a &lt;code&gt;new_message&lt;/code&gt;, that event path is skipped and there is no independent persistence path in this version to carry the supplied delta.&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%2Fo39wozzjvz7cgtwx5wt2.webp" 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%2Fo39wozzjvz7cgtwx5wt2.webp" alt="Google ADK Runner.run_async failing path without new_message and the tested explicit SessionService append_event workaround" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3. Left: the failing resume path. Right: the temporary event-level workaround tested locally. The workaround is not an upstream fix.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This matches Google ADK issue #6644. The issue states that &lt;code&gt;Runner.run_async&lt;/code&gt; accepts &lt;code&gt;state_delta&lt;/code&gt; as an optional state change, but the delta is silently discarded when resuming by &lt;code&gt;invocation_id&lt;/code&gt; without &lt;code&gt;new_message&lt;/code&gt;. The report covers both the Node and legacy dispatch paths.&lt;/p&gt;

&lt;p&gt;As of August 9, 2026, #6644 is still open and the GitHub issue page shows no linked branch or pull request. Treat any workaround below as temporary, not as a released Google fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not use a fake &lt;code&gt;new_message&lt;/code&gt; as the default fix
&lt;/h2&gt;

&lt;p&gt;The controls make one tempting workaround obvious: if a message makes the state update work, why not send an empty or synthetic user message every time you resume?&lt;/p&gt;

&lt;p&gt;I would not make that the production default.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;new_message&lt;/code&gt; is not a state-update flag. It becomes part of the session event history and can affect callbacks, model context, auditing, and later workflow behavior. In a human-in-the-loop flow, inventing a user message only to trigger state persistence can make the event history semantically false.&lt;/p&gt;

&lt;p&gt;A safer temporary approach is to separate two operations that your application actually means: persist the state change, then resume the invocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temporary workaround I verified locally
&lt;/h2&gt;

&lt;p&gt;ADK session state is updated through events carrying &lt;code&gt;EventActions(state_delta=...)&lt;/code&gt;. I therefore tested an explicit event-level path: append a content-less event containing the delta through the configured SessionService, then resume the invocation without passing a fake user message.&lt;/p&gt;

&lt;p&gt;Core example:&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;google.adk.events.event&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.events.event_actions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EventActions&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app_name&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;EventActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;state_delta&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;resumed_key&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;resumed_value&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="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;invocation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that workaround on both local paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node       applied=True state={'resumed_key': 'resumed_value'}
legacy     applied=True state={'resumed_key': 'resumed_value'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So in this 2.6.2 test, explicitly persisting a content-less event with &lt;code&gt;EventActions(state_delta=...)&lt;/code&gt; avoided the loss seen when relying on &lt;code&gt;Runner.run_async(state_delta=...)&lt;/code&gt; during a message-less resume.&lt;/p&gt;

&lt;p&gt;This is still a workaround. I only verified it with &lt;code&gt;InMemorySessionService&lt;/code&gt;. If you use a database-backed or managed SessionService, re-test event persistence, idempotency, concurrency, ordering, and audit semantics before using the same pattern in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bug matters most
&lt;/h2&gt;

&lt;p&gt;A normal chat application may never notice the issue because each turn naturally contains a new user message. The higher-risk cases are workflows where resume input comes from somewhere other than natural-language chat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a human approves an action in an external UI;&lt;/li&gt;
&lt;li&gt;a background job finishes and resumes an invocation;&lt;/li&gt;
&lt;li&gt;a webhook changes workflow state;&lt;/li&gt;
&lt;li&gt;an operator updates approval metadata without adding a user message;&lt;/li&gt;
&lt;li&gt;a long-running process uses &lt;code&gt;invocation_id&lt;/code&gt; as its resume handle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&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="n"&gt;state_delta&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;approval_status&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;approved&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;reviewer&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;human-42&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;If that resume has no &lt;code&gt;new_message&lt;/code&gt;, application code may see &lt;code&gt;run_async()&lt;/code&gt; complete and assume the approval state was saved. A later node can then read the old value or no value at all.&lt;/p&gt;

&lt;p&gt;For state that gates payments, deletion, sending, deployment, or other high-impact tools, add a write-after-read assertion instead of treating a successful Runner call as proof of persistence:&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="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval_status&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;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is every Google ADK version affected?
&lt;/h2&gt;

&lt;p&gt;This article does not claim that.&lt;/p&gt;

&lt;p&gt;The XBSTACK reproduction is scoped to &lt;code&gt;google-adk==2.6.2&lt;/code&gt;. The upstream report also demonstrates the issue against 2.6.2-era code. A later release may change the behavior.&lt;/p&gt;

&lt;p&gt;If you are reading this on a newer version, check two things before copying any workaround:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check whether issue #6644 is closed and whether a fix is included in your installed release.&lt;/li&gt;
&lt;li&gt;Run the four-case matrix locally. It requires no external model API, so it is cheap to keep as a regression test.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the no-message cases begin returning the expected state on a newer version, remove the workaround rather than preserving obsolete event logic indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal troubleshooting checklist
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;state_delta&lt;/code&gt; appears not to work during a Google ADK resume:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Record the exact &lt;code&gt;google-adk&lt;/code&gt; version.&lt;/li&gt;
&lt;li&gt;Confirm the call is resuming with &lt;code&gt;invocation_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirm resumability is enabled for the app.&lt;/li&gt;
&lt;li&gt;Check whether &lt;code&gt;new_message&lt;/code&gt; is absent.&lt;/li&gt;
&lt;li&gt;Re-read &lt;code&gt;session.state&lt;/code&gt; after the run; do not rely only on the lack of an exception.&lt;/li&gt;
&lt;li&gt;Run the same delta once with and once without &lt;code&gt;new_message&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the current status of #6644 and your release notes.&lt;/li&gt;
&lt;li&gt;Prefer an upstream fixed release when available; otherwise evaluate an explicit state-event workaround in your own SessionService.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h3&gt;
  
  
  Why does &lt;code&gt;run_async()&lt;/code&gt; succeed while the state remains unchanged?
&lt;/h3&gt;

&lt;p&gt;Because the failure is in the state persistence path, not in invocation validation. ADK can find and resume the invocation while the 2.6.2 message-less path fails to attach the supplied delta to a persisted user event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does sending a &lt;code&gt;new_message&lt;/code&gt; fix it?
&lt;/h3&gt;

&lt;p&gt;In my four-case test, the same delta is applied when a &lt;code&gt;new_message&lt;/code&gt; exists. That is useful as a control, but manufacturing a fake message just to trigger persistence changes your event history and is not the workaround I recommend by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;SessionService.append_event()&lt;/code&gt; the official fix?
&lt;/h3&gt;

&lt;p&gt;No. It is a temporary event-level workaround I verified on 2.6.2 with &lt;code&gt;InMemorySessionService&lt;/code&gt;. The upstream issue remains open as of August 9, 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the bug model-specific?
&lt;/h3&gt;

&lt;p&gt;The reproduction does not require Gemini, OpenAI, LiteLLM, or any external model API. The Node-path test uses an offline Echo stub, and the legacy-path test uses a plain &lt;code&gt;BaseAgent&lt;/code&gt;, so the tested failure boundary is independent of a provider call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final recommendation
&lt;/h2&gt;

&lt;p&gt;The most important lesson here is not simply “ADK has a state bug.” The narrower production lesson is that resumability and state persistence need separate regression tests.&lt;/p&gt;

&lt;p&gt;A run can resume without throwing while a state mutation is not persisted. For approval systems, long-running workflows, and external callbacks, verify the state after resume and keep a small no-network regression test around this boundary.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;google-adk==2.6.2&lt;/code&gt;, I reproduced the failure on both dispatch paths, confirmed the &lt;code&gt;new_message&lt;/code&gt; control on both paths, and verified an explicit event-level workaround. I would still prefer an upstream fixed release as soon as one is available, then remove the temporary workaround after the same matrix passes.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Google ADK issue #6644: &lt;a href="https://github.com/google/adk-python/issues/6644" rel="noopener noreferrer"&gt;https://github.com/google/adk-python/issues/6644&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google ADK repository: &lt;a href="https://github.com/google/adk-python" rel="noopener noreferrer"&gt;https://github.com/google/adk-python&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;XBSTACK Production AI Agent Systems: &lt;a href="https://www.xbstack.com/en/ai/agent/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_agent_hub" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/agent/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_agent_hub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;XBSTACK AI Engineering Resource Hub: &lt;a href="https://www.xbstack.com/en/ai/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_ai_hub" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_ai_hub&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;XBSTACK AI Agent Framework Guide 2026: &lt;a href="https://www.xbstack.com/en/ai/ai-agent-framework-comparison/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_framework_guide" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/ai-agent-framework-comparison/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=google_adk_state_delta_resume&amp;amp;utm_content=related_framework_guide&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduction assets
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;repro.py&lt;/code&gt;: four failure/control cases&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workaround.py&lt;/code&gt;: explicit content-less state-event workaround&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requirements.txt&lt;/code&gt;: pins &lt;code&gt;google-adk==2.6.2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;README.md&lt;/code&gt;: environment, results, and evidence boundary&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Canonical article on XBSTACK：&lt;a href="https://www.xbstack.com/en/ai/google-adk-state-delta-resume/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=google-adk-state-delta-resume&amp;amp;ref=devto" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/google-adk-state-delta-resume/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=google-adk-state-delta-resume&amp;amp;ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;标签：#AI #SoftwareEngineering #DeveloperTools #Google ADK #Runner.run_async&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>OpenAI Responses API Stream Abort: Fix No tool call found</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Wed, 05 Aug 2026 05:29:55 +0000</pubDate>
      <link>https://dev.to/xbstack/openai-responses-api-why-stream-abort-causes-no-tool-cal-54ac</link>
      <guid>https://dev.to/xbstack/openai-responses-api-why-stream-abort-causes-no-tool-cal-54ac</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A production-focused update based on a real project: A function_call can be visible before it is durable in Conversation state. This guide explains the 400 No tool call found erro…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  OpenAI Responses API: Why Stream Abort Causes No tool call found for function call output
&lt;/h1&gt;

&lt;p&gt;Here is the production conclusion first: &lt;strong&gt;seeing a streamed &lt;code&gt;function_call&lt;/code&gt; is not the same as having a durable call that the Conversation can reference later.&lt;/strong&gt; If a client closes the Responses API stream before the response commits, but the application already executes the tool, the next turn may reject the matching &lt;code&gt;function_call_output&lt;/code&gt; with &lt;code&gt;400 No tool call found for function call output&lt;/code&gt;. The error is bad; the orphaned side effect is worse. A payment, deployment, email, or database write may already exist even though the model conversation has no call record for it.&lt;/p&gt;

&lt;p&gt;The fastest recovery is not to keep retrying the stale output. Read the Conversation Items first. If the matching &lt;code&gt;call_id&lt;/code&gt; exists, reuse or produce the tool result idempotently and submit it. If the call is absent, treat the streamed call as uncommitted, discard that &lt;code&gt;call_id&lt;/code&gt;, and generate a new turn. Separately, every external write needs an application-owned idempotency key, because durable model state does not provide business exactly-once execution.&lt;/p&gt;

&lt;p&gt;The server-side behavior described here comes from OpenAI Python SDK Issue #3561. I did not use an unapproved production API key and do not present the local lab as an independent server reproduction. The lab makes the reported persistence boundary explicit and tests which recovery policies create or prevent orphaned side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What the 400 Error Actually Means
&lt;/h2&gt;

&lt;p&gt;The representative error is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 No tool call found for function call output with call_id call_xxx.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a missing Python function and not a JSON Schema validation failure. It is an association failure. A &lt;code&gt;function_call_output&lt;/code&gt; says, in effect, “this value belongs to the tool call identified by &lt;code&gt;call_id&lt;/code&gt;.” The server must be able to find that call in the current state before it can continue the model turn. If no matching call exists, accepting the output would attach data to an event that the conversation does not know happened.&lt;/p&gt;

&lt;p&gt;Start by separating three failure classes. The first is wiring: the output was sent to the wrong Conversation, a stale ID was reused, or concurrent tabs crossed user state. The second is state-mechanism mismatch: one turn used a Conversation while another turn was built against a different context path. The third is the abort window covered here: the client observed a call in the stream, but the response never reached the state where that call was durably available in Conversation Items.&lt;/p&gt;

&lt;p&gt;Issue #3561 documents that exact path. The reporter creates a Conversation, starts a streamed Responses request, receives a &lt;code&gt;function_call&lt;/code&gt; in &lt;code&gt;response.output_item.added&lt;/code&gt;, closes the stream, and then lists Conversation Items. The list is empty. The local tool is nevertheless executed. Submitting its output to the same Conversation on the next turn fails with the 400 error. The report uses Windows 10, Python 3.11.5, and &lt;code&gt;openai 2.45.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Official Status as of August 5, 2026
&lt;/h2&gt;

&lt;p&gt;Version numbers can be misleading here. The latest OpenAI Python SDK release is &lt;code&gt;v2.53.0&lt;/code&gt;, published on August 3, 2026. Issue #3561 remains open, carries the &lt;code&gt;bug&lt;/code&gt; label, and was updated on August 4. It has no linked Python fix pull request, milestone, or release note identifying a first fixed version.&lt;/p&gt;

&lt;p&gt;The accurate statement is therefore limited: there is a runnable report in the official repository, and the report has not been closed with a Python release conclusion. You can test 2.53.0 in your own environment, but you should not remove the reconciliation gate merely because the package number is newer than 2.45.0.&lt;/p&gt;

&lt;p&gt;Do not automatically downgrade either. The behavior crosses the client stream, the Responses service commit boundary, and Conversation persistence. A downgrade may not change server behavior and can reintroduce unrelated type, authentication, retry, or transport problems. A safer upgrade policy is to freeze the current production version, add a deterministic abort matrix, run the same matrix against a candidate release in an isolated OpenAI Project, and promote only when official status, live integration results, and side-effect counts agree.&lt;/p&gt;

&lt;p&gt;The issue links to a related fix in the JavaScript Agents SDK, PR #1241, which reconciles streamed function calls when server-managed runs abort. That relationship makes the failure mode credible across adjacent stacks, but it is not proof that the Python Responses path is fixed. SDK runner behavior, API service behavior, and conversation storage must be verified independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stream Visibility Is Not a Commit Token
&lt;/h2&gt;

&lt;p&gt;Streaming exists to expose useful information before a full response completes. &lt;code&gt;response.output_item.added&lt;/code&gt; tells the client that an output item has appeared in the current stream. &lt;code&gt;response.function_call_arguments.done&lt;/code&gt; tells it that the function arguments are complete. Those events are useful for UI, validation, logging, and scheduling low-risk work. They are not, by themselves, a durable authorization token for an external side effect.&lt;/p&gt;

&lt;p&gt;There are at least three distinct guarantees:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;What you know&lt;/th&gt;
&lt;th&gt;What you still do not know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client observed an event&lt;/td&gt;
&lt;td&gt;A call and &lt;code&gt;call_id&lt;/code&gt; appeared in the stream&lt;/td&gt;
&lt;td&gt;The Conversation durably contains the call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response completed&lt;/td&gt;
&lt;td&gt;The model turn reached a normal completion boundary&lt;/td&gt;
&lt;td&gt;The tool will be delivered only once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation reconciliation&lt;/td&gt;
&lt;td&gt;The server lists the matching &lt;code&gt;function_call&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The business action is exactly once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The unsafe implementation jumps from the first row directly to execution. That may be tolerable for a cacheable weather read. It is not acceptable for money movement, order creation, deployment, outbound email, access changes, or database mutation. If the stream aborts before commitment, the application has created an external fact without a model-state fact that can receive the result.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;call_id&lt;/code&gt; is also not a sufficient business idempotency key. It is primarily the correlation ID between a model call and a tool output. A regenerated model turn can produce a new &lt;code&gt;call_id&lt;/code&gt; for the same order or deployment. A stable key should come from the business intent, such as &lt;code&gt;tenant + operation + resource + desired_version&lt;/code&gt;, with &lt;code&gt;call_id&lt;/code&gt; stored as supporting evidence rather than the only uniqueness boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant-a:deploy:release-2026-08-05:call_xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The Local Lab and Its Evidence Boundary
&lt;/h2&gt;

&lt;p&gt;The public lab is located at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;experiments/openai-responses-stream-abort-tool-call-loss/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It uses only the Python standard library. &lt;code&gt;provisional_items&lt;/code&gt; represents calls already exposed through stream events but not committed. &lt;code&gt;committed_items&lt;/code&gt; represents calls that can be referenced by later outputs. Normal completion moves provisional calls into committed state. Abort discards them in the modeled failure path. Submitting an output for a missing call raises the same semantic error as the official report.&lt;/p&gt;

&lt;p&gt;This is not a reverse-engineered copy of OpenAI internals. It does not prove how every production request is stored. Its purpose is narrower and honest: given the officially reported condition that the client can observe a call that the Conversation later lacks, which application policies are safe?&lt;/p&gt;

&lt;p&gt;The lab fixes four scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;complete the stream, confirm the call, then execute;&lt;/li&gt;
&lt;li&gt;execute immediately after &lt;code&gt;output_item.added&lt;/code&gt;, then abort;&lt;/li&gt;
&lt;li&gt;abort, reconcile, and execute only if the call exists;&lt;/li&gt;
&lt;li&gt;commit the call, deliver the tool job twice, and deduplicate with a business ledger.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It does not measure live API latency, token cost, browser behavior, HTTP/2 teardown, proxy buffering, model tool-selection quality, or the &lt;code&gt;previous_response_id&lt;/code&gt; path. OpenAI behavior claims are sourced from official documentation and Issue #3561. Local results support the application control model only.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Four Scenarios, One Critical Invariant
&lt;/h2&gt;

&lt;p&gt;The experiment writes the verified result to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;experiments/openai-responses-stream-abort-tool-call-loss/results/verification.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four standard-library unit tests pass. The result matrix is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Conversation items&lt;/th&gt;
&lt;th&gt;Deliveries&lt;/th&gt;
&lt;th&gt;External effects&lt;/th&gt;
&lt;th&gt;Next turn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Complete, then execute&lt;/td&gt;
&lt;td&gt;call + output&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execute on observed call, then abort&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;rejected with 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abort, reconcile, then decide&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;stale call discarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed call delivered twice with idempotency&lt;/td&gt;
&lt;td&gt;call + output&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row is the failure that matters. The tool side effect count is already 1, while the Conversation contains no items. The output cannot be attached, so the next turn receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No tool call found for function call output with call_id call_aborted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a read-only tool, generating another turn may be enough. For a charge or deployment, regeneration may produce a second valid call and a second side effect. Refusing every retry avoids duplication but can leave the user with a failed task after the action actually succeeded. That conflict cannot be solved with prompting; it requires an application truth store and idempotent execution.&lt;/p&gt;

&lt;p&gt;The third scenario proves the conservative gate: after abort, reconcile first. If the call is missing, do not execute. The fourth scenario proves that reconciliation is not an exactly-once mechanism. A valid committed call can still be delivered repeatedly by a queue, manual retry, or worker recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Fast Triage for Production Incidents
&lt;/h2&gt;

&lt;p&gt;Do not begin by rewriting the prompt or randomly changing SDK versions. Follow the association chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the Conversation and call_id belong to the same turn
&lt;/h3&gt;

&lt;p&gt;Capture complete correlation fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request_id
conversation_id
response_id
call_id
user_id
tenant_id
stream_status
tool_name
business_idempotency_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify that the output is being sent to the same Conversation that produced the call. Check concurrent tabs, retries, and tenant boundaries. Partial IDs are often insufficient when two live requests overlap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Read Conversation Items
&lt;/h3&gt;

&lt;p&gt;List Conversation Items and look for the exact &lt;code&gt;call_id&lt;/code&gt;. If it is absent, stop submitting the output. Mark the application intent as &lt;code&gt;uncommitted&lt;/code&gt; or &lt;code&gt;orphaned-intent&lt;/code&gt; and route it to regeneration or manual reconciliation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Identify how the stream ended
&lt;/h3&gt;

&lt;p&gt;Distinguish normal completion, user stop, component unmount, &lt;code&gt;AbortController&lt;/code&gt;, reverse-proxy timeout, mobile network loss, worker restart, and server crash. These paths can look identical to the UI while producing different backend states.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Check whether the tool already produced a side effect
&lt;/h3&gt;

&lt;p&gt;Verify the external side effect independently. An empty Conversation does not mean the tool never ran. Check payment records, deployment IDs, message IDs, database constraints, or the application ledger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Choose the recovery path
&lt;/h3&gt;

&lt;p&gt;Use the reconciliation result and the external side-effect state together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call in Conversation&lt;/th&gt;
&lt;th&gt;Side effect&lt;/th&gt;
&lt;th&gt;Recovery&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;reserve idempotency key, execute, submit output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;reuse stored result, submit output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;discard stale call_id and generate a new turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;do not execute again; compensate or reconcile manually, then rebuild model context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row should not be hidden behind a generic retry loop. The business system has a fact that the model system lacks. Whether you send that fact back as a normal message, create an auditable recovery event, or require human review depends on the risk and regulatory context.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F02-recovery-flow-en.svg" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F02-recovery-flow-en.svg" alt="Safe OpenAI Responses API recovery after a stream abort: reconcile Conversation Items, discard a missing call_id, or reserve a business idempotency key before execution" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A Production Tool-Call State Machine
&lt;/h2&gt;

&lt;p&gt;A reliable orchestrator needs more than &lt;code&gt;pending/success/failed&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stream_observed
→ call_committed
→ execution_reserved
→ execution_succeeded
→ output_submitted
→ model_acknowledged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failure branches need names as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stream_aborted_before_commit
commit_unknown
execution_unknown
output_rejected
manual_reconciliation_required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stream_observed&lt;/code&gt; is safe for UI. It is not safe for a high-risk executor. &lt;code&gt;call_committed&lt;/code&gt; means the Conversation reconciliation succeeded. &lt;code&gt;execution_reserved&lt;/code&gt; means a unique business key has locked the action. &lt;code&gt;execution_succeeded&lt;/code&gt; stores the immutable external result. &lt;code&gt;output_submitted&lt;/code&gt; records delivery to the model, and &lt;code&gt;model_acknowledged&lt;/code&gt; closes the complete workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the state record should store
&lt;/h3&gt;

&lt;p&gt;Persist this state in a transactional store, not only in traces. A minimal record should include the business key, tenant and user, Conversation and Response IDs, &lt;code&gt;call_id&lt;/code&gt;, tool name, argument digest, every transition timestamp, abort reason, external result ID, result reference, retry count, and last error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intent_id
business_idempotency_key
user_id
tenant_id
conversation_id
response_id
call_id
tool_name
arguments_hash
stream_observed_at
call_committed_at
execution_reserved_at
execution_succeeded_at
output_submitted_at
model_acknowledged_at
status
abort_reason
external_result_id
result_payload_ref
retry_count
last_error_code
created_at
updated_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a unique constraint on the business idempotency key and another on &lt;code&gt;tenant + conversation_id + call_id&lt;/code&gt;.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F03-state-machine-en.svg" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F03-state-machine-en.svg" alt="Production tool-call state machine from stream_observed to model_acknowledged, with abort, unknown execution, and manual reconciliation branches" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The argument digest matters. Reusing one business key with different tool arguments should fail closed rather than silently return an old result. The external result ID should be a payment transaction, deployment ID, email Message ID, or other verifiable fact that a recovery worker can query after a crash.&lt;/p&gt;

&lt;p&gt;Useful metrics include &lt;code&gt;stream_observed_without_commit_total&lt;/code&gt;, &lt;code&gt;tool_execution_before_commit_total&lt;/code&gt;, &lt;code&gt;function_call_output_not_found_total&lt;/code&gt;, &lt;code&gt;reconciliation_latency_ms&lt;/code&gt;, &lt;code&gt;duplicate_delivery_total&lt;/code&gt;, &lt;code&gt;idempotency_reuse_total&lt;/code&gt;, and &lt;code&gt;manual_reconciliation_total&lt;/code&gt;. For high-risk tools, &lt;code&gt;tool_execution_before_commit_total&lt;/code&gt; should remain zero. A falling 400 rate is not enough if errors are merely swallowed while side effects diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. A Safer Python Control Skeleton
&lt;/h2&gt;

&lt;p&gt;The following is a control-flow example, not a complete OpenAI wrapper. The important separation is observed call, committed call, idempotency reservation, tool execution, and output submission:&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;conversation_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;response_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;call_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;tool_name&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;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;
    &lt;span class="n"&gt;business_key&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;def&lt;/span&gt; &lt;span class="nf"&gt;handle_streamed_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ToolIntent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;committed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function_call&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discarded_uncommitted_call&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;call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ledger&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;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_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;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tool_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reservation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execution_in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;tool_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_result&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;input&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;type&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;function_call_output&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;call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool_result&lt;/span&gt;&lt;span class="p"&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;Two crash windows remain. The process may fail after checking Conversation Items but before output submission. It may also fail after the external action succeeds but before the ledger is completed. The ledger therefore needs durable reservation, unique constraints, external result lookup, and result reuse. A recovery worker must query the ledger and external system before deciding to execute again.&lt;/p&gt;

&lt;p&gt;High-risk tools should add authorization and human approval before &lt;code&gt;execution_reserved&lt;/code&gt;. See &lt;a href="https://xbstack.com/en/ai/ai-agent-tool-authorization-policy-gate/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent tool authorization&lt;/a&gt; for a per-call Policy Gate. If your application uses OpenAI Agents SDK approval interruptions, the separate &lt;a href="https://xbstack.com/en/ai/openai-agents-sdk-runstate-approval-resume/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto" rel="noopener noreferrer"&gt;RunState resume and v0.19.3 persistence guide&lt;/a&gt; covers that layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Recovering Browser Disconnects and Proxy Timeouts
&lt;/h2&gt;

&lt;p&gt;Production disconnects are rarely a clean &lt;code&gt;stream.close()&lt;/code&gt;. A browser can background the tab, mobile connectivity can switch networks, a reverse proxy can close the downstream while the upstream is still working, and a server process can die after an event was received but before the log was flushed. The correct state is often “unknown,” not “failed.”&lt;/p&gt;

&lt;p&gt;Persist one application record per streamed response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;response_id
conversation_id
last_sequence_number
last_event_type
observed_call_ids
completed_at
abort_reason
reconciliation_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After an ambiguous disconnect, a background reconciler should list Conversation Items and classify each observed call as &lt;code&gt;committed&lt;/code&gt; or &lt;code&gt;missing&lt;/code&gt;. Committed calls can enter the idempotent execution queue. Missing calls must be discarded and must not receive an output. If a frontend or another service already executed the tool, the business ledger must route the operation into compensation or manual reconciliation.&lt;/p&gt;

&lt;p&gt;Do not give the browser direct ownership of high-risk execution. The browser should receive the stream and present approval. A backend orchestrator should persist the intent, reconcile Conversation state, execute the tool, and submit the output. Closing a page then becomes a presentation failure, not a payment or deployment consistency failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Regression Tests That Are Worth Keeping
&lt;/h2&gt;

&lt;p&gt;A manual Ctrl-C test is not sufficient. The regression suite should actively abort at several boundaries and verify Conversation state, ledger state, and external effects together.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F04-regression-matrix-en.svg" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fopenai-responses-stream-abort-tool-call-lost-en%2F04-regression-matrix-en.svg" alt="OpenAI Responses API stream-abort regression matrix comparing abort points, Conversation persistence, side effects, stale output handling, and upgrade decisions" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run commit-boundary tests after &lt;code&gt;response.output_item.added&lt;/code&gt;, after &lt;code&gt;response.function_call_arguments.done&lt;/code&gt;, after the final argument delta, immediately before completion, and after &lt;code&gt;response.completed&lt;/code&gt;. Store the Response ID, call ID, final sequence number, and the Conversation Items result. Do not encode assumptions about which event commits; let the current API behavior populate the matrix.&lt;/p&gt;

&lt;p&gt;Run side-effect-gate tests with a transactional fake payment, deployment table, or outbox. Before reconciliation, the count must be zero. After a committed call and first business-key reservation, it must be one. Deliver the same work two, five, or twenty times and assert that the external fact still appears once.&lt;/p&gt;

&lt;p&gt;Inject process crashes in at least five windows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before ledger reservation
after reservation, before tool execution
after tool success, before ledger completion
after ledger completion, before function_call_output
after output submission, before final model response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each window has a different recovery rule. Retry is safe before reservation. A stale reservation needs ownership and expiry logic. Success before ledger completion requires external lookup. A completed ledger result should be reused. A lost final model response should not execute the tool again.&lt;/p&gt;

&lt;p&gt;Keep a version matrix in CI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SDK&lt;/th&gt;
&lt;th&gt;Abort point&lt;/th&gt;
&lt;th&gt;Call persisted&lt;/th&gt;
&lt;th&gt;Side effects&lt;/th&gt;
&lt;th&gt;Old output accepted&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;td&gt;after item added&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;safe/unsafe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;td&gt;after arguments done&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;safe/unsafe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;td&gt;after response completed&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;candidate&lt;/td&gt;
&lt;td&gt;same three points&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;invariant&lt;/td&gt;
&lt;td&gt;measured&lt;/td&gt;
&lt;td&gt;promote/block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Unit tests should validate the application state machine on every commit. A live integration test should run when the SDK, streaming wrapper, proxy configuration, or cancellation logic changes. Use an isolated OpenAI Project, a low-cost model, a fake tool, and a strict spend limit. Save sanitized Conversation Items, package version, HTTP status, and timestamps as build artifacts.&lt;/p&gt;

&lt;p&gt;The public XBSTACK lab deliberately stops before a live API call. A project adopting this pattern should add an environment-gated &lt;code&gt;live_integration_test.py&lt;/code&gt; only after explicit credential authorization. That preserves the evidence boundary while making future official fixes comparable under the same test.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Common Mistakes and Final Decision
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake: treating &lt;code&gt;function_call_arguments.done&lt;/code&gt; as a commit.&lt;/strong&gt; It means argument streaming is complete, not that the Conversation can reference the call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake: using &lt;code&gt;call_id&lt;/code&gt; as the only exactly-once key.&lt;/strong&gt; A regenerated turn can produce a new call ID for the same order or deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake: retrying the same output after every 400.&lt;/strong&gt; If the call is absent, retries do not create it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake: assuming an empty Conversation means the tool did not execute.&lt;/strong&gt; External systems and the application ledger are the source of truth for side effects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake: deleting safeguards because the current SDK is newer than the reported version.&lt;/strong&gt; As of August 5, 2026, the issue remains open and no Python fixed release is named.&lt;/p&gt;

&lt;p&gt;The decision is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A streamed tool call is observable data, not an execution credential. Confirm the server committed it, reserve a stable business idempotency key, execute through a backend orchestrator, and reconcile every ambiguous disconnect. If the call ID is missing, discard it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read-only tools may accept a looser policy if duplicate cost is low. Payments, email, deployments, writes, and permission changes should not. They need the state machine, ledger, constraints, authorization, and recovery path described above. For broader timeout and retry design, see &lt;a href="https://xbstack.com/en/ai/langgraph-agent-error-recovery-retry-timeout/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent error recovery&lt;/a&gt;. For the full tool-call production boundary, continue with &lt;a href="https://xbstack.com/en/ai/ai-agent-tool-use/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent Tool Use&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why can the client have a call_id while Conversation Items are empty?
&lt;/h3&gt;

&lt;p&gt;The stream event and durable state are different guarantees. The client can observe a provisional function call before the response reaches its persistence boundary. If the response is aborted first, the reported failure path leaves no Conversation item to reference later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is waiting for response.completed enough?
&lt;/h3&gt;

&lt;p&gt;It is safer than executing on the added event. For a high-risk tool, still reconcile Conversation Items before execution. Response completion addresses the model-turn boundary; an idempotency ledger addresses duplicate tool delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the tool executed but the call_id is missing?
&lt;/h3&gt;

&lt;p&gt;Do not invent a tool call or continue submitting output to the missing ID. Record the real external result, prevent duplicate execution, and start a new auditable model turn or require human reconciliation. The appropriate recovery depends on whether the business allows the model to continue automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this the same as the OpenAI Agents SDK RunState persistence issue?
&lt;/h3&gt;

&lt;p&gt;No. This page covers a Responses API Conversation that did not commit a streamed &lt;code&gt;function_call&lt;/code&gt;. The RunState article covers approval resume and Session persistence in the Agents SDK. Both can create a mismatch between model state and external action, but the APIs and recovery boundaries differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the local lab prove the OpenAI server implementation?
&lt;/h3&gt;

&lt;p&gt;No. It tests application policies under the condition documented by the official issue. OpenAI server behavior, affected versions, and the first fixed release must come from official Issues, pull requests, and release notes. This page will update its version conclusion only when new official status and comparable verification are available.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt; &lt;a href="https://github.com/xbstack/my-blog-public/tree/main/experiments/openai-responses-stream-abort-tool-call-loss" rel="noopener noreferrer"&gt;OpenAI Responses API Stream Abort Tool Call Loss Lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Official sources:&lt;/strong&gt; &lt;a href="https://github.com/openai/openai-python/issues/3561" rel="noopener noreferrer"&gt;OpenAI Python SDK Issue #3561&lt;/a&gt; · &lt;a href="https://developers.openai.com/api/docs/guides/function-calling" rel="noopener noreferrer"&gt;Function Calling Guide&lt;/a&gt; · &lt;a href="https://developers.openai.com/api/docs/guides/conversation-state" rel="noopener noreferrer"&gt;Conversation State Guide&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Canonical article on XBSTACK：&lt;a href="https://www.xbstack.com/en/ai/openai-responses-api-stream-abort-tool-call-lost/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/openai-responses-api-stream-abort-tool-call-lost/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=openai-responses-stream-abort&amp;amp;utm_content=en%2Fopenai-responses-api-stream-abort-tool-call-lost&amp;amp;ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;标签：#AI #SoftwareEngineering #DeveloperTools #OpenAI Responses API #Function Calling&lt;/p&gt;

</description>
    </item>
    <item>
      <title>OpenAI Agents SDK Duplicate Tool Names: Why the Later Tool Wins</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:11:41 +0000</pubDate>
      <link>https://dev.to/xbstack/openai-agents-sdk-duplicate-tool-names-why-the-later-tool-wins-1fj5</link>
      <guid>https://dev.to/xbstack/openai-agents-sdk-duplicate-tool-names-why-the-later-tool-wins-1fj5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A production-focused update based on a real project: An offline reproduction on openai-agents 0.19.2 shows that duplicate FunctionTool names pass SDK validation, remain in Agent.g…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  OpenAI Agents SDK Duplicate Tool Names: Why the Later Tool Wins
&lt;/h1&gt;

&lt;p&gt;Here is the direct result: &lt;strong&gt;with &lt;code&gt;openai-agents==0.19.2&lt;/code&gt;, two plain &lt;code&gt;FunctionTool&lt;/code&gt; objects can expose the same public name without the SDK rejecting the Agent configuration.&lt;/strong&gt; In the offline fixture, two different Python functions both use &lt;code&gt;name_override="lookup"&lt;/code&gt;. The SDK validation function returns &lt;code&gt;None&lt;/code&gt;, &lt;code&gt;Agent.get_all_tools()&lt;/code&gt; still returns two &lt;code&gt;lookup&lt;/code&gt; tools, and the internal dispatch lookup map keeps only the later one. With OpenAI APIs, the request can fail with a duplicate-function-name 400. With a compatible provider that accepts duplicate names, the run may continue while the wrong local implementation executes.&lt;/p&gt;

&lt;p&gt;The issue was filed in the official OpenAI Agents SDK repository on August 2, 2026 as &lt;a href="https://github.com/openai/openai-agents-python/issues/4116" rel="noopener noreferrer"&gt;issue #4116&lt;/a&gt;. As of August 3, it remained open with no linked pull request.&lt;/p&gt;

&lt;p&gt;This page answers one narrow search problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why can duplicate FunctionTool names enter an OpenAI Agents SDK Agent, why does local dispatch become last-wins, and how can a production service fail before sending a model request?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is separate from the existing &lt;a href="https://xbstack.com/en/ai/openai-agents-sdk-runstate-approval-resume/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=openai-agents-sdk-duplicate-tool-names&amp;amp;ref=devto" rel="noopener noreferrer"&gt;RunState approval resume guide&lt;/a&gt;. That article covers interruptions, approval, serialization, and cross-process recovery. This one covers tool-registry identity before the model call starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest collision
&lt;/h2&gt;

&lt;p&gt;The Python function names are different, but both public tool names are &lt;code&gt;lookup&lt;/code&gt;:&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;agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;function_tool&lt;/span&gt;


&lt;span class="nd"&gt;@function_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lookup&lt;/span&gt;&lt;span class="sh"&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;lookup_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Look up customers.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&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;customer:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@function_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lookup&lt;/span&gt;&lt;span class="sh"&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;lookup_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Look up orders.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&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;order:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official tools documentation says &lt;code&gt;@function_tool&lt;/code&gt; normally uses the Python function name and allows an explicit &lt;code&gt;name_override&lt;/code&gt;. The identity that must be unique is the resulting &lt;code&gt;FunctionTool.name&lt;/code&gt;, not the Python identifier.&lt;/p&gt;

&lt;p&gt;This collision can emerge when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CRM and order modules both export a generic &lt;code&gt;lookup&lt;/code&gt; tool;&lt;/li&gt;
&lt;li&gt;two plugins expose &lt;code&gt;search&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;Agent.clone()&lt;/code&gt; flow appends the original tools again;&lt;/li&gt;
&lt;li&gt;tenant or feature-flag logic adds tools dynamically;&lt;/li&gt;
&lt;li&gt;sub-agents are converted to tools with repeated &lt;code&gt;tool_name&lt;/code&gt; values;&lt;/li&gt;
&lt;li&gt;multiple teams choose &lt;code&gt;execute&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;, or &lt;code&gt;fetch&lt;/code&gt; as overrides;&lt;/li&gt;
&lt;li&gt;an old and new implementation are registered during a migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each module may be valid in isolation. The conflict appears only after the final registry is assembled.&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%2F2627t8open1pmzdld1vx.webp" 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%2F2627t8open1pmzdld1vx.webp" alt="Two OpenAI Agents SDK FunctionTools expose the same lookup name, both enter the Agent tool list, and local dispatch keeps only the later lookup_orders implementation" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline test environment
&lt;/h2&gt;

&lt;p&gt;The fixture avoids model behavior, API keys, network calls, and provider differences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3.10.2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Agents SDK&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.19.2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API key&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model call&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verified paths&lt;/td&gt;
&lt;td&gt;Tool validation, Agent tool list, dispatch lookup map&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;experiments/openai-agents-duplicate-tool-names-repro/
├── repro.py
├── requirements.txt
├── results/verification.json
├── RESEARCH.md
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
.venv/bin/python repro.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer one: the SDK validator does not reject the conflict
&lt;/h2&gt;

&lt;p&gt;The SDK contains a function with an explicit purpose:&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="nf"&gt;validate_function_tool_lookup_configuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing the duplicate tools returns normally:&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_function_tool_lookup_configuration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;lookup_customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lookup_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;UserError&lt;/code&gt;, warning, or deduplication.&lt;/p&gt;

&lt;p&gt;Issue #4116 identifies the relevant branch: the validator detects an existing owner for the same qualified name, but when neither plain tool has an explicit namespace, it executes &lt;code&gt;continue&lt;/code&gt;. The common collision is recognized and then ignored.&lt;/p&gt;

&lt;p&gt;The issue also notes inconsistent handling across tool categories: duplicate names from MCP servers and Codex tools already have explicit checks, while plain FunctionTools do not receive the equivalent gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: both duplicate tools remain visible to the model
&lt;/h2&gt;

&lt;p&gt;After constructing the Agent:&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;agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RunContextWrapper&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&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;Support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lookup_customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lookup_orders&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_all_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;RunContextWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&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="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixture prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;['lookup', 'lookup']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK does not rename, select, or remove one of them at the Agent layer. The provider request can therefore contain two function definitions with the same public name.&lt;/p&gt;

&lt;p&gt;The upstream issue reports that OpenAI Responses and Chat Completions APIs reject this payload, producing a provider-side 400 even though the actual configuration error is local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: local dispatch becomes last-wins
&lt;/h2&gt;

&lt;p&gt;If a compatible provider accepts duplicate names and returns a tool call such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A-100"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the SDK still needs to select a Python implementation.&lt;/p&gt;

&lt;p&gt;The fixture builds the internal lookup map:&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;agents._tool_identity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_function_tool_lookup_map&lt;/span&gt;

&lt;span class="n"&gt;lookup_map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_function_tool_lookup_map&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;lookup_customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lookup_orders&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;Only one key remains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;('bare', 'lookup')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It points to the later tool:&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="n"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lookup_map&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bare&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;lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;lookup_orders&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;lookup_customers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sdk_validator_returned_none"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"advertised_tool_names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lookup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dispatch_lookup_keys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"bare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dispatch_selected_python_function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"first_tool_reachable_by_bare_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"second_tool_reachable_by_bare_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The later dictionary assignment replaces the earlier one.&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%2Fg78h7grtdrniph8g8buu.webp" 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%2Fg78h7grtdrniph8g8buu.webp" alt="Offline verification on openai-agents 0.19.2: the SDK validator does not fail, the Agent exposes two lookup tools, and local dispatch selects lookup_orders" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silent last-wins is more dangerous than a 400
&lt;/h2&gt;

&lt;p&gt;A provider 400 stops the request. It harms availability, but it does not execute the wrong operation. A tolerant provider can be worse: the model returns &lt;code&gt;lookup&lt;/code&gt;, and the local SDK cannot infer whether it meant customer lookup or order lookup. It executes whichever implementation survived the map construction.&lt;/p&gt;

&lt;p&gt;Imagine that the two underlying implementations are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lookup_customer_account
lookup_refund_order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both are exposed as &lt;code&gt;lookup&lt;/code&gt;, one tool may read customer data while the other initiates a refund, delete, message send, or database write. The identity collision becomes a side-effect risk.&lt;/p&gt;

&lt;p&gt;The failure surface includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two indistinguishable schemas sent to the model;&lt;/li&gt;
&lt;li&gt;provider request rejection;&lt;/li&gt;
&lt;li&gt;incorrect local implementation selection;&lt;/li&gt;
&lt;li&gt;audit logs that show only the ambiguous public name;&lt;/li&gt;
&lt;li&gt;retries that repeat the deterministic configuration problem;&lt;/li&gt;
&lt;li&gt;different failure behavior after switching providers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Temporary fix: validate uniqueness before model dispatch
&lt;/h2&gt;

&lt;p&gt;Until the SDK rejects duplicate plain FunctionTools, applications can fail after assembling the final registry and before constructing the production request:&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;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections.abc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Iterable&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FunctionTool&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agents.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UserError&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_duplicate_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Iterable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;],&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;list&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;names&lt;/span&gt; &lt;span class="o"&gt;=&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;name&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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;FunctionTool&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="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;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;def&lt;/span&gt; &lt;span class="nf"&gt;require_unique_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Iterable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;duplicates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_duplicate_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&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;duplicates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;quoted&lt;/span&gt; &lt;span class="o"&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;repr&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;duplicates&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;UserError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Duplicate FunctionTool names are not allowed: &lt;/span&gt;&lt;span class="sh"&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;quoted&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Use a unique Python function name, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name_override=, or a tool namespace.&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;Apply it to the final set:&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="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_static_tools&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;load_plugin_tools&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_tenant_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;require_unique_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&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;Support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tools&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;The duplicate fixture now fails locally with an actionable message instead of waiting for a provider response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Duplicate FunctionTool names are not allowed: 'lookup'.
Use a unique Python function name, name_override=, or a tool namespace.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix the public names
&lt;/h2&gt;

&lt;p&gt;Give the second tool a distinct identity:&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="nd"&gt;@function_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lookup_orders&lt;/span&gt;&lt;span class="sh"&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;lookup_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Look up orders.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&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;order:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting list is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;['lookup', 'lookup_orders']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer business-specific names over numeric suffixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer_lookup
order_lookup
invoice_lookup
knowledge_search
shipment_track
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good name helps the model distinguish capabilities as well as satisfying the uniqueness constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use namespaces for larger registries
&lt;/h2&gt;

&lt;p&gt;The official tools documentation recommends namespaces where possible, especially when many related tools exist. A registry can expose clearer identities such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;crm.lookup_customer
orders.lookup_order
billing.lookup_invoice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Namespaces reduce collisions on generic verbs such as &lt;code&gt;lookup&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, and &lt;code&gt;create&lt;/code&gt;, and give the model a better high-level surface.&lt;/p&gt;

&lt;p&gt;They do not replace testing. CI should validate the actual assembled callable identities rather than only checking source-level function names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gate should run
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Agent factory unit tests
&lt;/h3&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;test_support_agent_tool_names_are_unique&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_support_tools&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;require_unique_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugin registration
&lt;/h3&gt;

&lt;p&gt;Validate after all plugins load. Per-plugin uniqueness cannot detect collisions between plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-tenant configuration
&lt;/h3&gt;

&lt;p&gt;Different tenants can enable different combinations. Precompute valid combinations or validate and cache each final set at worker startup or request entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clone and dynamic append paths
&lt;/h3&gt;

&lt;p&gt;Cloning, list concatenation, feature flags, and A/B tests are common duplication sources. Validate the final list rather than the initial constant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release gates
&lt;/h3&gt;

&lt;p&gt;CI can enumerate production factories:&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;for&lt;/span&gt; &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;all_production_toolsets&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;require_unique_function_tool_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;UserError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&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;AssertionError&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;agent_name&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;exc&lt;/span&gt;&lt;span class="si"&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;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fypl52k73c842n8n938g0.webp" 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%2Fypl52k73c842n8n938g0.webp" alt="Mitigation and release gate for duplicate OpenAI Agents SDK tool names: assemble the final tool set, run a unique-name preflight, resolve collisions, and enforce factory and CI checks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Name uniqueness is only the first registry check
&lt;/h2&gt;

&lt;p&gt;A production tool registry should also validate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Failure risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unique &lt;code&gt;FunctionTool.name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Provider 400 or wrong dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable tool schema&lt;/td&gt;
&lt;td&gt;Cache invalidation and argument drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct descriptions&lt;/td&gt;
&lt;td&gt;Unstable model selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval or guardrails for high-risk tools&lt;/td&gt;
&lt;td&gt;Unauthorized side effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable tool IDs in audit logs&lt;/td&gt;
&lt;td&gt;Inability to identify implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproducible dynamic enablement&lt;/td&gt;
&lt;td&gt;Different workers expose different registries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For authorization and policy enforcement, continue with the &lt;a href="https://xbstack.com/en/ai/ai-agent-tool-authorization-policy-gate/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=openai-agents-sdk-duplicate-tool-names&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent Tool Authorization Policy Gate&lt;/a&gt;. This page remains limited to naming identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three approaches to avoid
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Waiting for the provider 400
&lt;/h3&gt;

&lt;p&gt;A deterministic local error is delayed until after network work, increasing latency, retry noise, and debugging cost. A provider change can convert the visible 400 into silent wrong dispatch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treating list order as configuration
&lt;/h3&gt;

&lt;p&gt;Import order, plugin discovery, and configuration merging can change ordering. Last-wins is not an explicit or auditable routing policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking only &lt;code&gt;__name__&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Different Python functions can share the same public name through &lt;code&gt;name_override&lt;/code&gt;. Validate &lt;code&gt;FunctionTool.name&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression test after an upstream fix
&lt;/h2&gt;

&lt;p&gt;As of August 3, 2026, issue #4116 had no linked pull request. A reasonable upstream fix should reject the configuration during tool resolution with an error that names the conflict and suggests a unique override or namespace, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ambiguous function tool configuration:
the tool name `lookup` is used by multiple tools.
Pass a unique name_override= or namespace.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After upgrading, test the SDK behavior directly:&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;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agents.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UserError&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sdk_rejects_duplicate_bare_function_tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;validate_function_tool_lookup_configuration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="n"&gt;lookup_customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;lookup_orders&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;Also verify that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Agent.get_all_tools()&lt;/code&gt; cannot expose duplicate sendable names;&lt;/li&gt;
&lt;li&gt;dynamic and static tool collisions are both detected;&lt;/li&gt;
&lt;li&gt;namespace rules match the documented behavior;&lt;/li&gt;
&lt;li&gt;the error identifies the conflicting name and remediation;&lt;/li&gt;
&lt;li&gt;pre-request behavior is consistent across providers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Relationship to the RunState article
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://xbstack.com/en/ai/openai-agents-sdk-runstate-approval-resume/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=openai-agents-sdk-duplicate-tool-names&amp;amp;ref=devto" rel="noopener noreferrer"&gt;OpenAI Agents SDK RunState guide&lt;/a&gt; covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tool-approval interruptions;&lt;/li&gt;
&lt;li&gt;RunState serialization;&lt;/li&gt;
&lt;li&gt;cross-process resume;&lt;/li&gt;
&lt;li&gt;redelivery and business idempotency;&lt;/li&gt;
&lt;li&gt;context filtering and version governance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This problem happens earlier. The tool registry is already ambiguous before the Agent sends a model request. A durable approval pipeline cannot make an ambiguous tool identity safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Duplicate FunctionTool names in OpenAI Agents SDK 0.19.2 have two failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;strict providers reject the duplicate function definitions;&lt;/li&gt;
&lt;li&gt;tolerant providers allow the request, while local dispatch keeps the later implementation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The offline XBSTACK fixture verifies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SDK validator       -&amp;gt; no error
Agent tools          -&amp;gt; ['lookup', 'lookup']
Local dispatch map   -&amp;gt; later lookup_orders only
First tool reachable -&amp;gt; False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until an upstream release rejects the conflict, production systems should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;validate unique &lt;code&gt;FunctionTool.name&lt;/code&gt; values after final registry assembly;&lt;/li&gt;
&lt;li&gt;use explicit business-specific names or &lt;code&gt;name_override&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;organize larger tool sets with namespaces;&lt;/li&gt;
&lt;li&gt;run the check in factory tests, plugin registration, and release gates;&lt;/li&gt;
&lt;li&gt;keep a regression test during SDK upgrades.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This configuration should fail at local startup, not through a provider 400 or an incorrect external side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/openai-agents-python/issues/4116" rel="noopener noreferrer"&gt;OpenAI Agents SDK issue #4116: Reject duplicate function tool names&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.github.io/openai-agents-python/tools/" rel="noopener noreferrer"&gt;OpenAI Agents SDK: Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.github.io/openai-agents-python/agents/" rel="noopener noreferrer"&gt;OpenAI Agents SDK: Agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Canonical article on XBSTACK：&lt;a href="https://www.xbstack.com/en/ai/openai-agents-sdk-duplicate-tool-names/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=openai-agents-sdk-duplicate-tool-names&amp;amp;ref=devto" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/openai-agents-sdk-duplicate-tool-names/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=article_distribution&amp;amp;utm_content=openai-agents-sdk-duplicate-tool-names&amp;amp;ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;标签：#AI #SoftwareEngineering #DeveloperTools #OpenAI Agents SDK #FunctionTool&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why LangGraph State Disappears After Run Cancellation: Streaming, Checkpoints and Recovery</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:33:15 +0000</pubDate>
      <link>https://dev.to/xbstack/why-langgraph-state-disappears-after-run-cancellation-streaming-checkpoints-and-recovery-4jgo</link>
      <guid>https://dev.to/xbstack/why-langgraph-state-disappears-after-run-cancellation-streaming-checkpoints-and-recovery-4jgo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A production-focused update based on a real project: Why does streamed LangGraph output disappear after Stop? A LangGraph 1.2.9 and SQLite experiment compares 16 streaming cases, …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Why LangGraph State Disappears After Run Cancellation: Streaming, Checkpoints and Recovery Consistency
&lt;/h1&gt;

&lt;p&gt;A user sees half an answer, presses &lt;strong&gt;Stop&lt;/strong&gt;, and then watches that answer disappear on the next refresh or message. It is tempting to blame the frontend. It is equally tempting to switch LangGraph from the default durability mode to &lt;code&gt;sync&lt;/code&gt; and assume the problem is solved.&lt;/p&gt;

&lt;p&gt;The experiment in this article points to a more precise boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A stream event that has reached the UI is not necessarily part of the authoritative LangGraph checkpoint. The decisive question is not only whether durability is &lt;code&gt;sync&lt;/code&gt;, &lt;code&gt;async&lt;/code&gt;, or &lt;code&gt;exit&lt;/code&gt;; it is whether the visible progress crossed a completed graph-step boundary and became a state update.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ran a deterministic matrix on Python 3.11.15, LangGraph 1.2.9, and &lt;code&gt;AsyncSqliteSaver&lt;/code&gt;. The fixture uses no model and no external API. It compares two graph shapes, four durability inputs, and two termination paths, producing sixteen streaming cases plus one &lt;code&gt;interrupt()&lt;/code&gt;/resume case.&lt;/p&gt;

&lt;p&gt;On normal completion, every combination produced six visible chunks and six checkpointed chunks. When the consumer closed the stream after the third visible chunk, all four durability modes on a single long-running node produced the same mismatch: &lt;strong&gt;UI 3, checkpoint 0&lt;/strong&gt;. When the same progress was modeled as completed graph steps, all four cases restored &lt;strong&gt;3 of 3&lt;/strong&gt; chunks. The interrupt experiment added a second result: prior completed state was preserved, but code before &lt;code&gt;interrupt()&lt;/code&gt; ran twice because the interrupted node restarted from its beginning on resume.&lt;/p&gt;

&lt;p&gt;This is not a model-quality article. It is a state-semantics article. The practical question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a user cancels a streaming LangGraph run, how do we keep the last visible UI state, the recoverable graph state, and any external side effects consistent?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Test design, environment, and explicit boundaries
&lt;/h2&gt;

&lt;p&gt;Using a real LLM would introduce provider buffering, token batch size, network variance, and model latency. The fixture instead streams six deterministic strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LangGraph | streams | visible | progress | before | checkpoint.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a 60 ms delay between chunks. In the cancellation path, the consumer closes the async stream after the third visible update. That is a deliberately narrow model of a browser disconnect, a client that stops consuming, or an application that abandons a local run.&lt;/p&gt;

&lt;p&gt;The recorded environment is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Recorded value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;3.11.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;1.2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;langgraph-checkpoint-sqlite&lt;/td&gt;
&lt;td&gt;3.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkpointer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AsyncSqliteSaver&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External model calls&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External API calls&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream format&lt;/td&gt;
&lt;td&gt;&lt;code&gt;version="v2"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream modes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;custom&lt;/code&gt; and &lt;code&gt;updates&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancellation point&lt;/td&gt;
&lt;td&gt;after 3 visible chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durability inputs&lt;/td&gt;
&lt;td&gt;default, sync, async, exit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default case omits the durability argument. The current LangGraph reference documents the default as &lt;code&gt;async&lt;/code&gt;: completed changes are persisted while the next step executes. &lt;code&gt;sync&lt;/code&gt; persists completed changes before starting the next step. &lt;code&gt;exit&lt;/code&gt; persists when the graph exits.&lt;/p&gt;

&lt;p&gt;That wording matters. Durability controls &lt;strong&gt;when an existing graph-state change is written&lt;/strong&gt;. It does not promise to convert arbitrary local variables or custom stream events inside an unfinished node into checkpointed state.&lt;/p&gt;

&lt;p&gt;The three tasks were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monolithic stream node&lt;/strong&gt; — one node emits six custom events, then returns its state only after the loop finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stepwise progress graph&lt;/strong&gt; — each chunk is appended by a completed node execution, followed by a conditional edge back to the same node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interrupt and resume&lt;/strong&gt; — a &lt;code&gt;prepare&lt;/code&gt; node returns an approval payload; an &lt;code&gt;approval&lt;/code&gt; node records a prefix event, calls &lt;code&gt;interrupt()&lt;/code&gt;, and resumes with the same thread ID and &lt;code&gt;Command(resume=True)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fixture does not test LangSmith Agent Server cancellation endpoints, &lt;code&gt;disconnect_mode&lt;/code&gt;, a remote Postgres checkpointer, reverse-proxy disconnect behavior, or real provider token streams. Closing a local async iterator is not identical to every managed runtime. The experiment establishes a local runtime/checkpointer boundary that a production deployment must verify again in its own stack.&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%2Fozfm5tivvm5cnnvufppm.webp" 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%2Fozfm5tivvm5cnnvufppm.webp" alt="Test scope separating official documentation, official issues, the local fixture, and untested managed-runtime behavior" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The official persistence model: checkpoints are super-step state
&lt;/h2&gt;

&lt;p&gt;LangGraph’s persistence documentation describes checkpoints as thread state snapshots created across graph execution. Full &lt;code&gt;StateSnapshot&lt;/code&gt; checkpoints are associated with super-step boundaries. The runtime can also persist per-task writes so that, when one task in a super-step succeeds and another fails, the successful task does not have to be recomputed. Those pending writes are useful for fault tolerance, but they are not the same as an arbitrary snapshot of every in-memory value at every line of a node.&lt;/p&gt;

&lt;p&gt;Streaming serves a different purpose.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;custom&lt;/code&gt; can expose progress emitted from inside a node through &lt;code&gt;get_stream_writer()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages&lt;/code&gt; can expose model tokens.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;updates&lt;/code&gt; exposes state updates after graph work produces them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;checkpoints&lt;/code&gt; exposes checkpoint creation events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A UI may receive a custom event before the node returns. At that moment, three facts can all be true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the node is still running;&lt;/li&gt;
&lt;li&gt;the browser has rendered several chunks;&lt;/li&gt;
&lt;li&gt;the graph state has not received those chunks as a node return value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The checkpoint is the authoritative recovery source. Reusing a &lt;code&gt;thread_id&lt;/code&gt; asks the checkpointer to load committed graph state. It does not ask the browser for the last text it happened to render.&lt;/p&gt;

&lt;p&gt;Official issue #5672 reports the resulting product failure in LangGraph Platform/API: users see streamed content, cancel before the next checkpoint, and later resynchronize to an older backend state. The issue was originally filed against LangGraph 0.3.31. This article does not claim to reproduce the exact Agent Server implementation bug on 1.2.9. It reproduces the underlying local semantic boundary: &lt;strong&gt;a custom event emitted by an unfinished node is not automatically a state update merely because a consumer observed it.&lt;/strong&gt;&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%2F3h9gnljlv4w2uwd1d1u9.webp" 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%2F3h9gnljlv4w2uwd1d1u9.webp" alt="Monolithic cancellation: the UI receives three chunks before the node returns, while the checkpoint remains at the previous graph boundary" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1: the UI saw three chunks and the checkpoint stored none
&lt;/h2&gt;

&lt;p&gt;The monolithic node is intentionally simple:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;long_streaming_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StreamState&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;StreamState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_stream_writer&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;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TOKENS&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="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&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;visible_chunk&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visible_text&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&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;emitted_chunks&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;final_text&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;emitted&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;When the node completes normally, all four durability inputs converge on the same business result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;six chunks reached the consumer;&lt;/li&gt;
&lt;li&gt;six chunks were available from &lt;code&gt;get_state()&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the final text matched;&lt;/li&gt;
&lt;li&gt;no runtime error occurred.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SQLite write pattern differed. Default, &lt;code&gt;sync&lt;/code&gt;, and &lt;code&gt;async&lt;/code&gt; each recorded three checkpoints and ten write rows. &lt;code&gt;exit&lt;/code&gt; recorded one final checkpoint and no intermediate writes. These counts are diagnostic evidence for this fixture, not a throughput benchmark.&lt;/p&gt;

&lt;p&gt;When the stream closed after the third custom event, every durability mode produced the same state mismatch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Durability&lt;/th&gt;
&lt;th&gt;UI chunks&lt;/th&gt;
&lt;th&gt;Checkpoint chunks&lt;/th&gt;
&lt;th&gt;Missing&lt;/th&gt;
&lt;th&gt;Authoritative match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sync&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;async&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exit&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This does not mean SQLite wrote nothing. The default, &lt;code&gt;sync&lt;/code&gt;, and &lt;code&gt;async&lt;/code&gt; cases each contained two checkpoints and seven write rows. &lt;code&gt;exit&lt;/code&gt; contained one checkpoint. What they did not contain was the node-local &lt;code&gt;emitted&lt;/code&gt; list, because the node never reached its return statement.&lt;/p&gt;

&lt;p&gt;That distinction invalidates a common fix:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Use &lt;code&gt;durability="sync"&lt;/code&gt; so partial tokens are never lost.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;sync&lt;/code&gt; can narrow the persistence window for a &lt;strong&gt;completed step&lt;/strong&gt;. It cannot persist a state update that the unfinished node never returned. There is no state update for the checkpointer to save.&lt;/p&gt;

&lt;p&gt;This is the first production lesson: before changing durability, identify whether the disappearing object is a stream event, a returned graph-state update, a pending write, or an external business record. They are not interchangeable.&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%2Foxhrbzu15z6r5ytcr0de.webp" 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%2Foxhrbzu15z6r5ytcr0de.webp" alt="Recorded matrix: normal completion converges at 6/6, monolithic cancellation ends at 3/0, and stepwise cancellation restores 3/3" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 2: completed graph steps restored all three visible chunks
&lt;/h2&gt;

&lt;p&gt;The second graph changes the state boundary rather than the storage parameter. Each node execution appends one chunk and returns:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;durable_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StreamState&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;StreamState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emitted_chunks&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="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOKENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;running&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;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&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;completed&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;emitted_chunks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;final_text&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;index&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A conditional edge returns to &lt;code&gt;durable_step&lt;/code&gt; until all six chunks exist. The consumer follows &lt;code&gt;updates&lt;/code&gt;, so the visible representation now comes from a completed graph-state update rather than an arbitrary event emitted midway through a node.&lt;/p&gt;

&lt;p&gt;Normal completion still produced six visible chunks and six checkpointed chunks for all durability inputs. The write cost increased:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;default, &lt;code&gt;sync&lt;/code&gt;, and &lt;code&gt;async&lt;/code&gt;: eight checkpoints and thirty-five writes;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exit&lt;/code&gt;: one final checkpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is expected. A finer recovery boundary creates more persistent work. A production system should not create one graph node per token; the tiny steps exist only to make the semantic boundary measurable.&lt;/p&gt;

&lt;p&gt;When the consumer closed after the third completed update, every case restored all three chunks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Durability&lt;/th&gt;
&lt;th&gt;UI chunks&lt;/th&gt;
&lt;th&gt;Checkpoint chunks&lt;/th&gt;
&lt;th&gt;Missing&lt;/th&gt;
&lt;th&gt;Authoritative match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sync&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;async&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exit&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Default, &lt;code&gt;sync&lt;/code&gt;, and &lt;code&gt;async&lt;/code&gt; recorded four checkpoints and twenty-one writes. &lt;code&gt;exit&lt;/code&gt; recorded one checkpoint and five writes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;exit&lt;/code&gt; result needs careful wording. Closing the stream caused this graph execution to exit, and the runtime had an opportunity to persist the current state on that exit path. It does not prove that &lt;code&gt;exit&lt;/code&gt; had stored every intermediate step while the graph was still running. It also does not prove what would happen if the process were killed before cleanup completed.&lt;/p&gt;

&lt;p&gt;The stronger conclusion is independent of that nuance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durable partial progress requires an authoritative boundary.&lt;/strong&gt; That boundary can be a completed graph step, an application-owned append-only event log, or a task record in a separate store. It cannot be merely “the browser received an event.”&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%2F8apx455645dm2z52oqdv.webp" 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%2F8apx455645dm2z52oqdv.webp" alt="Stepwise progress: each completed state update creates a recovery boundary, so cancellation returns to the last completed chunk" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;sync&lt;/code&gt;, &lt;code&gt;async&lt;/code&gt;, and &lt;code&gt;exit&lt;/code&gt; actually change
&lt;/h2&gt;

&lt;p&gt;The three modes describe write timing for completed changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sync&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A completed step is persisted before the next step starts. This is the strongest choice at a critical workflow boundary: approval decisions, durable task handoff, or a transition immediately before an external write. The trade-off is direct storage latency on the execution path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;async&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Completed changes are persisted while the next step executes. This is the documented default and often provides a useful balance between recovery and throughput. There is still a cleanup and flush window. Discussion on issue #5672 in 2026 points to cancellation-path cleanup and pending async persistence as an implementation concern. The fixture did not patch the runtime or kill the process during cleanup, so that community analysis remains a source clue rather than a locally proven result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;exit&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;State is persisted when the graph exits. It can be appropriate for short, deterministic work that can be restarted as a whole. It is a poor fit when each step is an audit fact that must survive a worker crash.&lt;/p&gt;

&lt;p&gt;A practical selection table is more useful than declaring one mode “best”:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Recommended design&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;State immediately before high-risk approval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sync&lt;/code&gt; and a separate node&lt;/td&gt;
&lt;td&gt;persistence confirmed before advancing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinary multi-step agent&lt;/td&gt;
&lt;td&gt;default/&lt;code&gt;async&lt;/code&gt; and idempotent nodes&lt;/td&gt;
&lt;td&gt;balanced persistence and throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short deterministic calculation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;exit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fewer intermediate writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token-level UI rendering&lt;/td&gt;
&lt;td&gt;stream plus application event log&lt;/td&gt;
&lt;td&gt;tokens are display events, not graph checkpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long external tool progress&lt;/td&gt;
&lt;td&gt;durable task table and sequence&lt;/td&gt;
&lt;td&gt;survives page, worker, and runtime changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Irreversible side effect&lt;/td&gt;
&lt;td&gt;idempotency key and domain database&lt;/td&gt;
&lt;td&gt;checkpointing is not a business transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When debugging a cancellation rollback, ask these questions in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Was the disappearing object a stream event or graph state?&lt;/li&gt;
&lt;li&gt;Had the node returned before cancellation?&lt;/li&gt;
&lt;li&gt;Which source is used after refresh: browser memory, application event storage, or the checkpointer?&lt;/li&gt;
&lt;li&gt;Did runtime cleanup complete?&lt;/li&gt;
&lt;li&gt;Did an external side effect happen even though graph state did not advance?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Changing durability before answering those questions treats write timing as if it were state modeling.&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%2F031s92ety4ssjd3itar0.webp" 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%2F031s92ety4ssjd3itar0.webp" alt="Durability controls when completed state is persisted; it does not convert unfinished node-local progress into a checkpoint" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;interrupt()&lt;/code&gt; is a recoverable pause, not ordinary cancellation
&lt;/h2&gt;

&lt;p&gt;Cancellation usually means “stop the current run.” &lt;code&gt;interrupt()&lt;/code&gt; means “pause at a recoverable graph position and wait for external input.” Official documentation requires a checkpointer and a stable thread ID. Resuming with the same thread and &lt;code&gt;Command(resume=...)&lt;/code&gt; provides the return value of the &lt;code&gt;interrupt()&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;The fixture first completed a preparation node:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepared_payload&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;draft-action-v1&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;status&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;prepared&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;The approval node then recorded observable prefix and suffix events:&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;approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;side_effect_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_interrupt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;Approve draft-action-v1?&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;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&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;prepared_payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepared_payload&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="n"&gt;side_effect_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after_interrupt&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="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&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;approved&lt;/span&gt;&lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&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;approved&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;rejected&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;Before resume, the checkpoint contained &lt;code&gt;prepared_payload: draft-action-v1&lt;/code&gt; and the graph was positioned at the approval work. After &lt;code&gt;Command(resume=True)&lt;/code&gt;, the final state was &lt;code&gt;approved&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The event counts expose the important behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;before_interrupt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;after_interrupt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LangGraph did not restore a frozen Python stack and continue at the next line. It restarted the interrupted node from its beginning, matched the supplied resume value to &lt;code&gt;interrupt()&lt;/code&gt;, and then continued. The result matches the official interrupt rules.&lt;/p&gt;

&lt;p&gt;That behavior turns prefix side effects into a production risk. A call that creates an order, sends an email, appends an audit record, or decrements inventory before &lt;code&gt;interrupt()&lt;/code&gt; may run again on resume.&lt;/p&gt;

&lt;p&gt;Safe patterns are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;move irreversible work after &lt;code&gt;interrupt()&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;put it in a separate node with its own checkpoint boundary;&lt;/li&gt;
&lt;li&gt;use a stable operation ID and an idempotent upsert;&lt;/li&gt;
&lt;li&gt;check the authoritative external result before retrying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Official issues #6792 and #7361 report additional resume/replay edge cases involving subgraphs or a specific checkpoint ID. They are not evidence from this local fixture, but they justify a release gate: nested subgraphs, multiple interrupts, and checkpoint-specific resume paths must be tested against the exact version and deployment used in production.&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%2Fjfri02lp26cuj17pb99r.webp" 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%2Fjfri02lp26cuj17pb99r.webp" alt="Interrupt/resume timeline: earlier state persists, but the interrupted node restarts and the prefix runs twice" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Production repair: separate visible progress, recoverable state, and business truth
&lt;/h2&gt;

&lt;p&gt;A cancellation model with only &lt;code&gt;running&lt;/code&gt; and &lt;code&gt;completed&lt;/code&gt; is too coarse. A user can request cancellation while a model is generating, a tool is executing, an external API has completed but the local result has not been committed, or async checkpointer cleanup is still running.&lt;/p&gt;

&lt;p&gt;A more explicit state machine is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queued
  -&amp;gt; running
  -&amp;gt; cancel_requested
      -&amp;gt; cancelled_confirmed
      -&amp;gt; completed_before_cancel
      -&amp;gt; externally_completed_local_pending
      -&amp;gt; cancel_failed
  -&amp;gt; failed
  -&amp;gt; completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cancel_requested&lt;/code&gt; represents user intent. It is not proof that execution stopped. &lt;code&gt;externally_completed_local_pending&lt;/code&gt; is essential for payment, email, ticket, upload, or job systems: the external world changed, but local graph state may still be behind. Blindly replaying from the last checkpoint can duplicate the operation.&lt;/p&gt;

&lt;p&gt;I use three persistence layers for this class of system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. UI stream&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This layer optimizes latency. Every event should carry at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;run_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sequence&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;timestamp&lt;/li&gt;
&lt;li&gt;safe payload or payload reference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI can render it optimistically. It should not silently promote the event to durable conversation history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Application event ledger&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This layer stores progress the product promises to retain. A minimal append-only shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;run_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;safe_payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequence&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;It does not have to write every token. A system can batch every 200–500 ms, store sentence boundaries, record tool phases, or persist a partial message object. Its job is to reconstruct what the user was shown after page reload or device change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. LangGraph checkpoint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This layer stores recoverable graph state, next nodes, task writes, and thread history. It is a workflow recovery mechanism. It should not be forced to contain every animation or token, and it should not replace an order, approval, or task database.&lt;/p&gt;

&lt;p&gt;A production cancellation flow can then be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client sends a cancel request containing &lt;code&gt;run_id&lt;/code&gt;; it does not only close the SSE connection.&lt;/li&gt;
&lt;li&gt;The backend atomically records &lt;code&gt;cancel_requested&lt;/code&gt;, who requested it, and when.&lt;/li&gt;
&lt;li&gt;The runtime stops cancellable work. Non-cancellable external work enters reconciliation.&lt;/li&gt;
&lt;li&gt;The application event ledger commits its last confirmed sequence.&lt;/li&gt;
&lt;li&gt;Completed graph steps persist according to durability.&lt;/li&gt;
&lt;li&gt;The backend sets a confirmed final status.&lt;/li&gt;
&lt;li&gt;Reload merges authoritative graph state with the event ledger by run and sequence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every layer needs idempotency. &lt;code&gt;run_id + sequence&lt;/code&gt; deduplicates visible events. &lt;code&gt;operation_id&lt;/code&gt; deduplicates external effects. Recovery checks the domain database before retrying a tool.&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%2Ff3arwce6mzt7zpk03mmn.webp" 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%2Ff3arwce6mzt7zpk03mmn.webp" alt="Production cancellation state machine separating requested cancellation, confirmed stop, external completion, and local persistence" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not turn every token into a graph node
&lt;/h2&gt;

&lt;p&gt;The 3/3 stepwise result is not a recommendation to checkpoint every token.&lt;/p&gt;

&lt;p&gt;In the recorded fixture, default, &lt;code&gt;sync&lt;/code&gt;, and &lt;code&gt;async&lt;/code&gt; normal completion produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;monolithic graph: three checkpoints and ten writes;&lt;/li&gt;
&lt;li&gt;stepwise graph: eight checkpoints and thirty-five writes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those counts only describe this deterministic local fixture, not production latency or cost. They still reveal the direction of the trade-off: a finer recovery boundary usually creates more serialization and storage work.&lt;/p&gt;

&lt;p&gt;Good graph boundaries carry business meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a plan is accepted;&lt;/li&gt;
&lt;li&gt;a tool call finishes;&lt;/li&gt;
&lt;li&gt;a batch of document pages is parsed;&lt;/li&gt;
&lt;li&gt;an approval package is ready;&lt;/li&gt;
&lt;li&gt;an external object ID is obtained;&lt;/li&gt;
&lt;li&gt;a retryable shard completes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Poor graph boundaries are display details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every token;&lt;/li&gt;
&lt;li&gt;animation percentage;&lt;/li&gt;
&lt;li&gt;arbitrary character chunks;&lt;/li&gt;
&lt;li&gt;transient UI state that can be derived later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For long text generation, tokens can stay on the stream. An application event ledger can persist paragraphs or a partial message every bounded interval. The graph state receives the complete message when the node finishes. On cancellation, the partial object can be labeled &lt;code&gt;aborted_partial&lt;/code&gt; rather than disappearing or pretending to be a final answer.&lt;/p&gt;

&lt;p&gt;For a long tool task, let the tool own a durable &lt;code&gt;task_id&lt;/code&gt; and stage status. The graph stores the task ID, last confirmed stage, and recovery policy. The page reads progress from the task API. A graph worker restart no longer erases progress that existed only in a node’s local variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression tests that prove the repair
&lt;/h2&gt;

&lt;p&gt;A repair is incomplete if it only passes the normal completion path. At minimum, automate four scenarios and assert both the last visible sequence and the last authoritative backend sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Natural completion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Six progress events are emitted. The UI shows six. The event ledger stores six. After the node finishes, graph state contains the complete result. This protects the ordinary path from cancellation-specific changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation at a fixed sequence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the third event, send a real cancel request, wait for a confirmed terminal state, create a fresh client, and reload the same thread. The result must match product policy: either preserve three events and label them partial, or deliberately discard them and remove them before presenting a stable state. “Show three now, silently lose them after refresh” is not acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation racing an external side effect&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Complete the external operation immediately before and immediately after the cancel request. Assert that the same &lt;code&gt;operation_id&lt;/code&gt; produces one order, one ticket, one email, or one file object. The important assertion is in the external system, not only the graph output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker/process failure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After one meaningful business step, terminate the worker process and restart it. Reuse the same thread and compare &lt;code&gt;sync&lt;/code&gt;, default &lt;code&gt;async&lt;/code&gt;, and &lt;code&gt;exit&lt;/code&gt;. Record the last recoverable step rather than inferring it from documentation.&lt;/p&gt;

&lt;p&gt;Logs should connect five identifiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;thread_id&lt;/code&gt; — long-lived workflow;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_id&lt;/code&gt; — one execution attempt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sequence&lt;/code&gt; — visible event ordering;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;checkpoint_id&lt;/code&gt; — recoverable graph snapshot;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;operation_id&lt;/code&gt; — external side effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful production metrics include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cancel request to confirmed stop latency;&lt;/li&gt;
&lt;li&gt;UI sequence minus checkpoint/event-ledger sequence;&lt;/li&gt;
&lt;li&gt;partial-message recovery success;&lt;/li&gt;
&lt;li&gt;duplicate side-effect prevention count;&lt;/li&gt;
&lt;li&gt;nodes re-executed after resume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A low cancellation count does not prove consistency. A stable zero or explainable value for these divergence metrics is much stronger evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failures, limitations, and claims this test does not support
&lt;/h2&gt;

&lt;p&gt;The experiment intentionally retains failure evidence.&lt;/p&gt;

&lt;p&gt;First, all four monolithic cancellation cases failed the consistency test: UI 3, checkpoint 0. This is the central negative result.&lt;/p&gt;

&lt;p&gt;Second, the first regression-test loader dynamically imported the experiment without registering the module in &lt;code&gt;sys.modules&lt;/code&gt;. Python 3.11 dataclass type resolution failed. Registering the module fixed the harness, and the semantic test then passed. This was a test-harness defect, not a LangGraph defect.&lt;/p&gt;

&lt;p&gt;Third, several orphaned Astro development processes had exhausted system file descriptors, causing the first isolated installation attempt to fail with &lt;code&gt;Too many open files in system&lt;/code&gt;. Stopping those project processes allowed the clean Python 3.11 environment to install. That environmental incident is excluded from the LangGraph conclusions.&lt;/p&gt;

&lt;p&gt;The fixture also does not test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent Server cancellation endpoints;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disconnect_mode="cancel"&lt;/code&gt; versus &lt;code&gt;"continue"&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Postgres checkpointer behavior under process death;&lt;/li&gt;
&lt;li&gt;parallel pending writes within one super-step;&lt;/li&gt;
&lt;li&gt;every nested-subgraph interrupt path;&lt;/li&gt;
&lt;li&gt;provider-specific token streaming and abort signals;&lt;/li&gt;
&lt;li&gt;a reverse proxy disconnect while backend execution continues;&lt;/li&gt;
&lt;li&gt;durability performance, throughput, or cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It would therefore be inaccurate to say “LangGraph 1.2.9 still has exactly issue #5672” or “stepwise nodes solve every cancellation problem.” The supported claim is narrower:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;On the local LangGraph 1.2.9 runtime, the experiment reproduces the underlying state boundary behind the same user-visible rollback: unfinished in-node stream output is not checkpointed state, while completed graph-step updates are recoverable.&lt;/p&gt;
&lt;/blockquote&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%2F9brcsmpwczn3e5in7rav.webp" 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%2F9brcsmpwczn3e5in7rav.webp" alt="Recorded experiment and regression evidence: seventeen cases, persisted SQLite results, and a passing unittest" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Final decision: never let a stream impersonate a database
&lt;/h2&gt;

&lt;p&gt;For a personal demo where users can regenerate an answer, discarding an unfinished response may be acceptable. The UI should make that policy explicit and remove partial content consistently.&lt;/p&gt;

&lt;p&gt;For customer support, research, finance, approvals, long-running tools, or any auditable workflow, the UI and backend cannot disagree about what happened. Such systems need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an explicit distinction between stream events, graph state, and business facts;&lt;/li&gt;
&lt;li&gt;state updates at meaningful business boundaries;&lt;/li&gt;
&lt;li&gt;an application event ledger for visible progress the product promises to retain;&lt;/li&gt;
&lt;li&gt;stable thread, run, sequence, checkpoint, and operation identifiers;&lt;/li&gt;
&lt;li&gt;a cancellation confirmation state machine;&lt;/li&gt;
&lt;li&gt;idempotent side effects around interrupts and retries;&lt;/li&gt;
&lt;li&gt;real disconnect, worker-restart, and same-thread regression tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The experiment’s most useful result is not a winning durability parameter. It is a clean separation of responsibilities:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Streaming helps users see progress quickly. Checkpoints let the graph recover. Domain storage proves what changed in the outside world. The three layers may reference one another, but none can safely impersonate the others.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before implementing “Stop generation,” decide what must survive: a visible partial message, recoverable workflow state, or an external business fact. Consistency becomes tractable only after those three objects have explicit owners.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does &lt;code&gt;durability="sync"&lt;/code&gt; prevent state loss on cancellation?
&lt;/h3&gt;

&lt;p&gt;It protects completed graph-step changes by persisting them before the next step. It cannot save node-local progress that has been streamed but not returned as a state update. In all four monolithic cancellation cases, the UI showed three chunks and the checkpoint held zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;interrupt()&lt;/code&gt; resume at the exact Python line?
&lt;/h3&gt;

&lt;p&gt;No. The interrupted node restarts from its beginning. Code before &lt;code&gt;interrupt()&lt;/code&gt; runs again, so side effects must be idempotent, moved after the interrupt, or isolated in a separate node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should every token be checkpointed?
&lt;/h3&gt;

&lt;p&gt;Usually not. Stream tokens for latency, persist paragraphs or partial messages in an application event ledger, and write the complete message to graph state when the node finishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did &lt;code&gt;exit&lt;/code&gt; preserve three chunks in the stepwise cancellation test?
&lt;/h3&gt;

&lt;p&gt;Closing the iterator caused the graph execution to exit, giving the runtime an opportunity to persist the current state. It does not mean every step had already been persisted during execution, and it does not establish behavior under hard process termination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is local browser storage enough for partial responses?
&lt;/h3&gt;

&lt;p&gt;It can mask a rollback in one browser, but it does not survive every reload, device change, or multi-instance synchronization path. If the product promises that visible partial content will remain, persist it in a backend event ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources and related XBSTACK guides
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence" rel="noopener noreferrer"&gt;LangGraph persistence documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.langchain.com/oss/python/langgraph/streaming" rel="noopener noreferrer"&gt;LangGraph streaming documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.langchain.com/oss/python/langgraph/interrupts" rel="noopener noreferrer"&gt;LangGraph interrupts documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://reference.langchain.com/python/langgraph/types/Durability" rel="noopener noreferrer"&gt;LangGraph durability reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/langchain-ai/langgraph/issues/5672" rel="noopener noreferrer"&gt;Official issue #5672: streamed state lost on run cancellation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/langchain-ai/langgraph/issues/6792" rel="noopener noreferrer"&gt;Official issue #6792: task output reuse around subgraph interrupt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/langchain-ai/langgraph/issues/7361" rel="noopener noreferrer"&gt;Official issue #7361: checkpoint resume versus replay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.xbstack.com/en/ai/langgraph-checkpointer-memory-sqlite-redis/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto" rel="noopener noreferrer"&gt;Choosing a LangGraph checkpointer: memory, SQLite, Redis, and production boundaries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.xbstack.com/en/ai/langgraph-human-in-the-loop-approval/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto" rel="noopener noreferrer"&gt;LangGraph human-in-the-loop approval workflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.xbstack.com/en/ai/langgraph-agent-error-recovery-retry-timeout/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto" rel="noopener noreferrer"&gt;LangGraph tool errors, timeouts, and retry recovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.xbstack.com/en/ai/langgraph-observability-agent-trace/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto" rel="noopener noreferrer"&gt;LangGraph observability and agent decision tracing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Canonical article on XBSTACK：&lt;a href="https://www.xbstack.com/en/ai/langgraph-cancel-run-streaming-checkpoint-state-loss/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/langgraph-cancel-run-streaming-checkpoint-state-loss/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=langgraph_cancel_checkpoint_consistency&amp;amp;utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&amp;amp;ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;标签：#AI #SoftwareEngineering #DeveloperTools #LangGraph #Streaming&lt;/p&gt;

</description>
    </item>
    <item>
      <title>MCP initialize Removed: Reproducing Unknown MCP Session Across Replicas</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Tue, 28 Jul 2026 02:49:24 +0000</pubDate>
      <link>https://dev.to/xbstack/mcp-initialize-removed-reproducing-unknown-mcp-session-across-replicas-j13</link>
      <guid>https://dev.to/xbstack/mcp-initialize-removed-reproducing-unknown-mcp-session-across-replicas-j13</guid>
      <description>&lt;p&gt;The first MCP request returns 200. The next &lt;code&gt;tools/call&lt;/code&gt; returns 400 with &lt;code&gt;Unknown MCP session&lt;/code&gt;. The endpoint did not change, the tool schema is valid, and the network is healthy. What changed was the replica.&lt;/p&gt;

&lt;p&gt;In a legacy Streamable HTTP flow, &lt;code&gt;initialize&lt;/code&gt; can create protocol state in Replica A and return &lt;code&gt;Mcp-Session-Id&lt;/code&gt;. A round-robin proxy may send the following tool call to Replica B. If the session lives only in A's memory, B cannot resolve it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /mcp initialize
  -&amp;gt; Replica A
  &amp;lt;- 200
  &amp;lt;- Mcp-Session-Id: session_a_123

POST /mcp tools/call
Mcp-Session-Id: session_a_123
  -&amp;gt; Replica B
  &amp;lt;- 400 Unknown MCP session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I built a small fixture with two legacy replicas, two draft-shaped stateless replicas, a real localhost round-robin proxy, and an external SQLite store. The legacy path produced a deterministic 200/400 result. The self-contained path alternated between &lt;code&gt;draft-a&lt;/code&gt; and &lt;code&gt;draft-b&lt;/code&gt; successfully. The wire lab passed 6/6 cases.&lt;/p&gt;

&lt;p&gt;The important boundary is not 'state versus no state.' It is protocol state versus application state.&lt;/p&gt;

&lt;p&gt;Protocol facts such as version, client information, capabilities, and routing metadata should be available on the current request. Durable product state still exists, but it should use explicit domain handles such as &lt;code&gt;basket_id&lt;/code&gt;, &lt;code&gt;approval_id&lt;/code&gt;, &lt;code&gt;browser_id&lt;/code&gt;, or &lt;code&gt;task_id&lt;/code&gt;, backed by storage every replica can reach.&lt;/p&gt;

&lt;p&gt;That makes the application contract inspectable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the handle has an owner and tenant;&lt;/li&gt;
&lt;li&gt;the record has an explicit lifecycle;&lt;/li&gt;
&lt;li&gt;expired or completed objects are rejected intentionally;&lt;/li&gt;
&lt;li&gt;concurrent writes can use versions or ETags;&lt;/li&gt;
&lt;li&gt;side-effecting tools can use idempotency keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sticky sessions can reduce the immediate failure rate during migration, but they do not preserve process memory after restarts, rolling deployments, or connection movement. A shared session store can support the old protocol temporarily, but it should not become a permanent opaque blob containing client capabilities, identity, business objects, and recovery state.&lt;/p&gt;

&lt;p&gt;The locked MCP 2026-07-28 revision is still labeled RC / pre-release as of publication. This is therefore an RC experiment and migration plan, not a final-spec conformance claim. Package version, SDK API shape, and the wire protocol also need to be verified separately; installing an SDK v2 prerelease does not prove that production traffic is speaking the new protocol.&lt;/p&gt;

&lt;p&gt;The complete article includes the request examples, replica routing evidence, explicit-handle lifecycle, SDK v2 smoke test, old/new client-server compatibility matrix, rollout gates, and rollback criteria.&lt;/p&gt;

&lt;p&gt;Read the full experiment and migration checklist: &lt;a href="https://www.xbstack.com/en/ai/mcp-2026-07-28-stateless-migration/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mcp_2026_07_28_stateless_migration&amp;amp;utm_content=article_body" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/mcp-2026-07-28-stateless-migration/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mcp_2026_07_28_stateless_migration&amp;amp;utm_content=article_body&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>mcp</category>
    </item>
    <item>
      <title>AI Agent Tool Authorization: Build a Policy Gate Before Tool Execution</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Mon, 27 Jul 2026 04:21:31 +0000</pubDate>
      <link>https://dev.to/xbstack/ai-agent-tool-authorization-build-a-policy-gate-before-tool-execution-4kpp</link>
      <guid>https://dev.to/xbstack/ai-agent-tool-authorization-build-a-policy-gate-before-tool-execution-4kpp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A tool being visible to an agent does not mean every generated call is authorized. The enforcement boundary belongs between the proposed tool call and the real executor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An Agent that can see &lt;code&gt;send_email&lt;/code&gt;, &lt;code&gt;query_customer&lt;/code&gt;, and &lt;code&gt;issue_refund&lt;/code&gt; has been given capabilities. That does not mean every model-generated call is authorized. One of the most dangerous architecture mistakes is treating “the tool is registered,” “the arguments match JSON Schema,” “the guardrail passed,” and “the user may execute this action” as the same fact.&lt;/p&gt;

&lt;p&gt;Consider a call that is syntactically valid: query a customer record, then send the result to an external email address. The input can look normal, the arguments can match the schema, and both tools can belong to the Agent. The combination can still be data exfiltration. What is missing is a deterministic pre-execution authorization layer that reads trusted identity, tenant, scope, resource ownership, destination, and exact arguments before a real side effect occurs.&lt;/p&gt;

&lt;p&gt;The load-bearing design rule is: &lt;strong&gt;put a Policy Gate between the model output and the Tool Executor, not only in the prompt, UI confirmation, or tool description.&lt;/strong&gt; At minimum, it should return &lt;code&gt;ALLOW&lt;/code&gt;, &lt;code&gt;REQUIRE_CONFIRM&lt;/code&gt;, &lt;code&gt;STEP_UP&lt;/code&gt;, or &lt;code&gt;BLOCK&lt;/code&gt;, and generate a structured, redacted, traceable audit record for every decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails, HITL, and authorization solve different problems
&lt;/h2&gt;

&lt;p&gt;The OpenAI Agents SDK documents Tool Guardrails and Human-in-the-loop. Tool Guardrails can inspect function-tool input and output, block or replace results, and trigger a tripwire. HITL can turn a sensitive call into an interruption that waits for approval or rejection. &lt;a href="https://openai.github.io/openai-agents-python/guardrails/" rel="noopener noreferrer"&gt;Guardrails&lt;/a&gt; &lt;a href="https://openai.github.io/openai-agents-python/human_in_the_loop/" rel="noopener noreferrer"&gt;Human-in-the-loop&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Authorization remains distinct. &lt;a href="https://github.com/openai/openai-agents-python/issues/2868" rel="noopener noreferrer"&gt;Issue #2868&lt;/a&gt; requests per-tool authorization middleware that evaluates identity, role, scope, rate limits, and session context before execution, supports non-binary decisions, and emits structured audit records.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Typical inputs&lt;/th&gt;
&lt;th&gt;Typical result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;Who is calling?&lt;/td&gt;
&lt;td&gt;Token, certificate, session&lt;/td&gt;
&lt;td&gt;Verified principal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capability exposure&lt;/td&gt;
&lt;td&gt;Which tools can the Agent see?&lt;/td&gt;
&lt;td&gt;Registry, filters&lt;/td&gt;
&lt;td&gt;Candidate tool set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guardrails&lt;/td&gt;
&lt;td&gt;Is the content or argument shape safe and valid?&lt;/td&gt;
&lt;td&gt;Prompt, arguments, result&lt;/td&gt;
&lt;td&gt;Pass, block, replace, tripwire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;May this principal execute this exact call now?&lt;/td&gt;
&lt;td&gt;Identity, tenant, scope, resource, parameters, policy&lt;/td&gt;
&lt;td&gt;ALLOW, BLOCK, STEP_UP, DEFER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HITL&lt;/td&gt;
&lt;td&gt;Does a person need to decide this call?&lt;/td&gt;
&lt;td&gt;Pending call and context&lt;/td&gt;
&lt;td&gt;Approve, reject, edit, expire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;td&gt;Will the same side effect land only once?&lt;/td&gt;
&lt;td&gt;Stable key and ledger&lt;/td&gt;
&lt;td&gt;Execute once or reuse result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HITL can handle &lt;code&gt;REQUIRE_CONFIRM&lt;/code&gt;, but it cannot replace authorization. Cross-tenant access, missing scope, and unknown tools should normally be blocked immediately rather than consuming human review capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Policy Gate belongs
&lt;/h2&gt;

&lt;p&gt;A production topology should look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Request
  -&amp;gt; Agent / Model
  -&amp;gt; Proposed Tool Call + Arguments
  -&amp;gt; Policy Enforcement Point
       -&amp;gt; Identity verification
       -&amp;gt; Tenant and ownership check
       -&amp;gt; Scope / role check
       -&amp;gt; Argument and destination policy
       -&amp;gt; Risk and assurance evaluation
       -&amp;gt; ALLOW / REQUIRE_CONFIRM / STEP_UP / BLOCK
  -&amp;gt; Approval Service when required
  -&amp;gt; Idempotency Ledger
  -&amp;gt; Tool Executor
  -&amp;gt; Audit / Trace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fs4u0q4fbgvmnwvpmsuxx.webp" 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%2Fs4u0q4fbgvmnwvpmsuxx.webp" alt="A user request moves through the AI Agent, proposed tool call, Policy Gate, and policy decision before any executor can run" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Policy Enforcement Point intercepts the call; a Policy Decision Point evaluates the rules. A small application can keep both in one process. A distributed platform can use a gateway or external policy service. Two conditions are non-negotiable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the Tool Executor cannot be reached around the gate;&lt;/li&gt;
&lt;li&gt;identity and tenant context cannot come from model-generated arguments.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model may propose &lt;code&gt;target_tenant_id=tenant-b&lt;/code&gt;, but the trusted tenant must come from a verified session, service token, or gateway context. Treating model-generated tenant data as an authorization fact is equivalent to letting the request sign its own access pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The local lab: seven per-call decisions
&lt;/h2&gt;

&lt;p&gt;The experiment lives in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;experiments/agent-tool-authorization-policy-gate/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It evaluates a trusted principal and a proposed call:&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="nc"&gt;Principal&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scopes&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;customer:read&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;email:send&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;refund:write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;assurance_level&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="p"&gt;)&lt;/span&gt;

&lt;span class="nc"&gt;ToolCall&lt;/span&gt;&lt;span class="p"&gt;(&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;issue_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;arguments&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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;CNY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;target_tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write&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;All seven deterministic cases pass:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Expected decision&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same-tenant customer read&lt;/td&gt;
&lt;td&gt;ALLOW&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-tenant customer read&lt;/td&gt;
&lt;td&gt;BLOCK&lt;/td&gt;
&lt;td&gt;Denied immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer data sent to an external recipient&lt;/td&gt;
&lt;td&gt;REQUIRE_CONFIRM&lt;/td&gt;
&lt;td&gt;Human confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNY 8,000 refund with low assurance&lt;/td&gt;
&lt;td&gt;STEP_UP&lt;/td&gt;
&lt;td&gt;Stronger authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Record deletion&lt;/td&gt;
&lt;td&gt;REQUIRE_CONFIRM&lt;/td&gt;
&lt;td&gt;Human confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund without the required scope&lt;/td&gt;
&lt;td&gt;BLOCK&lt;/td&gt;
&lt;td&gt;Denied immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unregistered arbitrary shell tool&lt;/td&gt;
&lt;td&gt;BLOCK&lt;/td&gt;
&lt;td&gt;Default deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The audit record redacts sensitive fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REQUIRE_CONFIRM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_data_to_external_recipient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email-dlp-v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"audit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c3d46b41f63c84c50927"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redacted_arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"recipient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"partner@external.test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[REDACTED]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contains_customer_data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab includes five unit tests and a generated &lt;code&gt;verification.json&lt;/code&gt; covering all seven cases. It does not integrate an actual Agent SDK, so the evidence verifies policy semantics rather than end-to-end SDK behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why binary allow/deny is insufficient
&lt;/h2&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%2Fx2di2jlandihievb9hkc.webp" 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%2Fx2di2jlandihievb9hkc.webp" alt="ALLOW, REQUIRE_CONFIRM, STEP_UP, and BLOCK route different tool calls through distinct enforcement paths" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ALLOW
&lt;/h3&gt;

&lt;p&gt;Use for low-risk, same-tenant, correctly scoped operations with clear ownership, such as reading the current user's order status.&lt;/p&gt;

&lt;h3&gt;
  
  
  REQUIRE_CONFIRM
&lt;/h3&gt;

&lt;p&gt;Use for side effects that a human can meaningfully review, such as sending customer data externally, deleting a record, or issuing a moderate refund. The Policy Gate first proves that the request is not obviously unauthorized, then routes it to HITL.&lt;/p&gt;

&lt;h3&gt;
  
  
  STEP_UP
&lt;/h3&gt;

&lt;p&gt;Use when the principal may have permission but the current authentication assurance is too weak. A high-value refund can require password re-entry, MFA, a hardware key, or an administrator signature. This differs from ordinary approval: the goal is to strengthen the caller's identity evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  BLOCK
&lt;/h3&gt;

&lt;p&gt;Use for cross-tenant access, missing scope, ownership mismatch, unknown tools, expired policy, or disabled targets. Fail closed when the policy service is unavailable; a timeout must not become implicit permission.&lt;/p&gt;

&lt;p&gt;Production systems may add &lt;code&gt;MODIFY&lt;/code&gt; or &lt;code&gt;DEFER&lt;/code&gt;, for example capping an amount or scheduling a call inside a business window. Any policy-side mutation should preserve an original argument digest and a reason so the audit trail remains reconstructable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining RBAC, ABAC, and argument-level policy
&lt;/h2&gt;

&lt;p&gt;RBAC answers coarse questions such as whether a support role may access a refund tool. ABAC answers contextual questions such as whether this support user can refund this order for this tenant at this amount.&lt;/p&gt;

&lt;p&gt;A real decision can inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;principal: user ID, service ID, role;&lt;/li&gt;
&lt;li&gt;tenant and organization boundary;&lt;/li&gt;
&lt;li&gt;scope: &lt;code&gt;customer:read&lt;/code&gt;, &lt;code&gt;refund:write&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;resource: owner, region, classification;&lt;/li&gt;
&lt;li&gt;arguments: amount, recipient, environment;&lt;/li&gt;
&lt;li&gt;session: assurance level, device, risk score;&lt;/li&gt;
&lt;li&gt;time: policy version, expiry, maintenance window;&lt;/li&gt;
&lt;li&gt;rate: calls or cumulative amount per window.&lt;/li&gt;
&lt;/ul&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%2Fu3med9fj45e4oiimheav.webp" 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%2Fu3med9fj45e4oiimheav.webp" alt="The policy engine combines identity, scope, tenant ownership, risk, destination, and exact parameters into an explainable decision" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF tool = issue_refund
AND principal.scope includes refund:write
AND order.tenant_id = principal.tenant_id
AND amount &amp;lt;= 500
THEN ALLOW

IF amount &amp;gt; 500 AND amount &amp;lt;= 5000
THEN REQUIRE_CONFIRM

IF amount &amp;gt; 5000 AND assurance_level &amp;lt; 2
THEN STEP_UP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model does not evaluate this policy. It can explain why a refund was requested, but it cannot decide that its caller satisfies the scope or amount threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration with OpenAI Agents SDK, LangGraph, and MCP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  OpenAI Agents SDK
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;needs_approval&lt;/code&gt; and Tool Guardrails are integration points. Evaluate the Policy Gate first: continue on &lt;code&gt;ALLOW&lt;/code&gt;, return a denial on &lt;code&gt;BLOCK&lt;/code&gt;, surface an interruption on &lt;code&gt;REQUIRE_CONFIRM&lt;/code&gt;, and route &lt;code&gt;STEP_UP&lt;/code&gt; to stronger authentication. Preserve the policy ID, decision reason, and audit ID rather than reducing the result to a boolean.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangGraph
&lt;/h3&gt;

&lt;p&gt;Add a dedicated Policy Node before the Tool Node. It reads trusted context and the proposed Tool Call, then writes the decision into graph state. Route &lt;code&gt;REQUIRE_CONFIRM&lt;/code&gt; to &lt;code&gt;interrupt()&lt;/code&gt;, &lt;code&gt;BLOCK&lt;/code&gt; to a safe terminal state, and only &lt;code&gt;ALLOW&lt;/code&gt; to the executor. The existing &lt;a href="https://www.xbstack.com/en/ai/langgraph-human-in-the-loop-approval/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;LangGraph Human-in-the-loop guide&lt;/a&gt; covers pause and resume; the Policy Gate determines whether the call is eligible to enter approval at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP
&lt;/h3&gt;

&lt;p&gt;Tool filtering can reduce which MCP capabilities a client sees, but the remote server should still authorize every &lt;code&gt;tools/call&lt;/code&gt; using trusted principal, tenant, resource, and arguments. Client-side approval cannot replace server-side authorization because a faulty or malicious client may bypass the UI and call the server directly. Continue with &lt;a href="https://www.xbstack.com/en/ai/mcp-server-production-governance/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;MCP Server production governance&lt;/a&gt; and &lt;a href="https://www.xbstack.com/en/ai/mcp-security-best-practices/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;MCP security best practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimum audit fields
&lt;/h2&gt;

&lt;p&gt;Store at least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;audit_id
request_id / trace_id
principal_id
trusted_tenant_id
tool_name
tool_version
argument_digest
redacted_argument_summary
policy_id
policy_version
decision
reason
approval_id when present
assurance_level
created_at
executor_result_id after execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F83nk9x84nuw4o2ovfoon.webp" 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%2F83nk9x84nuw4o2ovfoon.webp" alt="The tool request, policy decision, execution or denial, actor, reason, and result are joined in a traceable audit record" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do not place complete customer records, tokens, or email bodies into the audit log. Store a redacted summary and irreversible digest, then use controlled access to retrieve authoritative data during an investigation.&lt;/p&gt;

&lt;p&gt;Record the policy version as well. If an approval waits for two days, permission rules may change. Resume should re-evaluate the current policy rather than blindly trusting an old &lt;code&gt;approved&lt;/code&gt; flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why authorized calls still need idempotency
&lt;/h2&gt;

&lt;p&gt;The Policy Gate answers whether a call may execute, not whether it already executed. Queue redelivery, network timeout, worker restart, or a duplicate click can send the same authorized call to the executor again.&lt;/p&gt;

&lt;p&gt;A side-effecting tool needs a stable key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idempotency_key = hash(
  tenant_id,
  business_action,
  business_object_id,
  approved_call_id
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The executor checks a ledger first: return the stored result if complete, reject concurrent work if processing, and retry only under explicit failure policy. Authorization, approval, idempotency, and audit are separate layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimum production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Can the Tool Executor be reached only through the Policy Gate?&lt;/li&gt;
&lt;li&gt;Do identity and tenant come from trusted context?&lt;/li&gt;
&lt;li&gt;Are unregistered tools default-denied?&lt;/li&gt;
&lt;li&gt;Is cross-tenant access blocked immediately?&lt;/li&gt;
&lt;li&gt;Do high-risk actions use &lt;code&gt;REQUIRE_CONFIRM&lt;/code&gt; or &lt;code&gt;STEP_UP&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Does a policy-service failure fail closed?&lt;/li&gt;
&lt;li&gt;Are audit arguments redacted?&lt;/li&gt;
&lt;li&gt;Does resume re-evaluate the current policy version?&lt;/li&gt;
&lt;li&gt;Do writes use idempotency keys?&lt;/li&gt;
&lt;li&gt;Has the team tested bypassing the frontend and calling the Tool API directly?&lt;/li&gt;
&lt;li&gt;Are revocation, expiry, and emergency disable controls available?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final decision
&lt;/h2&gt;

&lt;p&gt;Agent tool safety cannot rely on a compliant model. The Tool Registry determines visible capabilities, schemas determine argument shape, guardrails inspect content and format, HITL collects human decisions, and authorization proves whether the current principal may execute this exact call.&lt;/p&gt;

&lt;p&gt;A reliable system lets the model propose an action, lets the Policy Gate decide whether it can proceed, and lets the Tool Executor accept only authorized requests protected by idempotency. Prompt injection, model misunderstanding, or unauthorized arguments then meet a deterministic execution boundary before they can affect the real system.&lt;/p&gt;

&lt;p&gt;Continue with &lt;a href="https://www.xbstack.com/en/ai/ai-agent-tool-use/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent Tool Use: registry, validation, and audit&lt;/a&gt;, &lt;a href="https://www.xbstack.com/en/ai/ai-agent-security/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;AI Agent Security&lt;/a&gt;, and &lt;a href="https://www.xbstack.com/en/ai/openai-agents-sdk-runstate-approval-resume/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=internal_link&amp;amp;ref=devto" rel="noopener noreferrer"&gt;OpenAI Agents SDK RunState approval resume&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Full experiment, code, and production checklist: &lt;a href="https://www.xbstack.com/en/ai/ai-agent-tool-authorization-policy-gate/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=article_body&amp;amp;ref=devto" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/ai-agent-tool-authorization-policy-gate/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_agent_tool_authorization_20260727&amp;amp;utm_content=article_body&amp;amp;ref=devto&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>n8n AI Agent Not Calling Tools: The Request Contract Behind tool_choice</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Sun, 26 Jul 2026 15:17:04 +0000</pubDate>
      <link>https://dev.to/xbstack/n8n-ai-agent-not-calling-tools-the-request-contract-behind-toolchoice-3b4f</link>
      <guid>https://dev.to/xbstack/n8n-ai-agent-not-calling-tools-the-request-contract-behind-toolchoice-3b4f</guid>
      <description>&lt;p&gt;An n8n AI Agent can have tools connected, receive a system instruction that says “you must call the tool,” and still return prose without executing anything.&lt;/p&gt;

&lt;p&gt;The first diagnostic is not the final answer. It is the execution trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if the Tool node never starts, inspect the model request and provider behavior;&lt;/li&gt;
&lt;li&gt;if the Tool node starts and fails schema validation, inspect arguments and JSON Schema;&lt;/li&gt;
&lt;li&gt;if the Tool succeeds but the agent reports failure, inspect the returned Tool message;&lt;/li&gt;
&lt;li&gt;if single-turn execution works but later turns do not, inspect memory persistence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;n8n issue #31135 reports a specific first-iteration boundary: tools are present, but &lt;code&gt;tool_choice: required&lt;/code&gt; is not explicitly sent. A compatible provider that interprets the missing field as &lt;code&gt;auto&lt;/code&gt; may answer directly.&lt;/p&gt;

&lt;p&gt;I built a provider-free deterministic contract lab. It is not a model benchmark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Tool calls&lt;/th&gt;
&lt;th&gt;Text replies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing &lt;code&gt;tool_choice&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-iteration proxy injects &lt;code&gt;required&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proxy also verifies three safeguards: do nothing when no tools exist, preserve explicit choices such as &lt;code&gt;none&lt;/code&gt;, and stop forcing after an assistant tool call appears in the message history.&lt;/p&gt;

&lt;p&gt;The broader debugging path still includes provider compatibility, ambiguous tool descriptions, invalid schemas, structured Tool Results, and memory backends that omit tool-call evidence.&lt;/p&gt;

&lt;p&gt;Canonical analysis and complete code:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.xbstack.com/en/ai/n8n-ai-agent-not-calling-tools/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=n8n_agent_tool_call&amp;amp;utm_content=article_canonical" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/n8n-ai-agent-not-calling-tools/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=n8n_agent_tool_call&amp;amp;utm_content=article_canonical&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>debugging</category>
      <category>llm</category>
    </item>
    <item>
      <title>OpenAI Agents SDK RunState: Resume Tool Approvals Without Duplicate Side Effects</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Fri, 24 Jul 2026 06:23:10 +0000</pubDate>
      <link>https://dev.to/xbstack/openai-agents-sdk-runstate-resume-tool-approvals-without-duplicate-side-effects-36np</link>
      <guid>https://dev.to/xbstack/openai-agents-sdk-runstate-resume-tool-approvals-without-duplicate-side-effects-36np</guid>
      <description>&lt;p&gt;OpenAI Agents SDK &lt;code&gt;RunState&lt;/code&gt; can persist a run that is waiting for human approval, let the original process exit, and resume the approved or rejected run in another worker.&lt;/p&gt;

&lt;p&gt;That solves durability. It does &lt;strong&gt;not&lt;/strong&gt; solve exactly-once execution.&lt;/p&gt;

&lt;p&gt;In my reproducible &lt;code&gt;openai-agents==0.18.3&lt;/code&gt; lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;side effects before approval: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;side effects after cross-process approval and resume: &lt;strong&gt;1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;side effects after rejection: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;side effects after replaying the same approved state in two workers: &lt;strong&gt;2&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;side effects after adding an idempotency ledger: &lt;strong&gt;1&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduction setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python 3.10.2
openai-agents 0.18.3
RunState schema 1.12
SQLite
deterministic custom Model
no external model provider API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deterministic model always emits one &lt;code&gt;deploy_release&lt;/code&gt; tool call, then returns a final message after receiving the tool result. This isolates SDK interruption, serialization, and resume behavior from model variance and network behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pause before the side effect
&lt;/h2&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;agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;function_tool&lt;/span&gt;

&lt;span class="nd"&gt;@function_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;needs_approval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;deploy_release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;release&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;idempotency_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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;deployed:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;release&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deploy release 2026.07.24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interruptions&lt;/span&gt;&lt;span class="p"&gt;)&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;assert&lt;/span&gt; &lt;span class="nf"&gt;effect_count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;needs_approval=True&lt;/code&gt; is enforced by the runner at tool execution time. The model has already proposed a call and produced arguments, but the Python function has not run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serialize only durable context
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RunContextWrapper.context&lt;/code&gt; participates in RunState serialization. A plain mapping containing a token can therefore write that token into the state blob.&lt;/p&gt;

&lt;p&gt;Use a strict serializer that keeps identifiers and reconstructs runtime dependencies later:&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;context_serializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context_serializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;context_serializer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;strict_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;On restore, obtain credentials from a secret manager instead of the blob.&lt;/p&gt;

&lt;h2&gt;
  
  
  The application still needs an approval record
&lt;/h2&gt;

&lt;p&gt;RunState is an SDK execution snapshot. It should not be the only record for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who requested the action&lt;/li&gt;
&lt;li&gt;who is authorized to approve it&lt;/li&gt;
&lt;li&gt;tenant and resource boundaries&lt;/li&gt;
&lt;li&gt;expiry and revocation&lt;/li&gt;
&lt;li&gt;the immutable tool argument digest&lt;/li&gt;
&lt;li&gt;queue delivery and worker leases&lt;/li&gt;
&lt;li&gt;whether the external side effect already happened&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A production design separates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run_state_blob       SDK execution snapshot
approval_request     authorization and lifecycle
idempotency_ledger   external-effect ownership and result reuse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The approval UI should display an immutable review snapshot. Canonicalize the tool arguments and store a SHA-256 digest. Before execution, the worker extracts the arguments from RunState and recomputes the digest. A mismatch invalidates the approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resume in another process
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;RunState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;initial_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stored_payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context_deserializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;context_deserializer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;strict_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;interruption&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_interruptions&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interruption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker then rebuilds a compatible agent and resumes:&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="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AGENT_FACTORIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app_state_version&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;runtime_dependencies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;from_json()&lt;/code&gt; still requires an initial Agent because JSON cannot recreate Python functions, tool implementations, clients, database connections, or a complete executable graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  The duplicate-delivery failure
&lt;/h2&gt;

&lt;p&gt;Worker A consumed the approved state and executed the tool. I then replayed the original approved state to Worker B instead of using Worker A's new result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"worker_a"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deployed:2026.07.24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"worker_b"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deployed:2026.07.24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effect_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK cannot know whether this is a queue redelivery, a lost acknowledgement, an operator retry, or a second legitimate resume. The snapshot says the call is approved and unfinished, so executing it again is consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim execution with an idempotency ledger
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEGIN IMMEDIATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT result FROM idempotency_ledger WHERE idempotency_key = ?&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="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;reused:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;perform_external_effect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO idempotency_ledger(idempotency_key, result) VALUES (?, ?)&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="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&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;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operation key should be generated by the application, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant_id + approval_id + logical_operation + target_resource
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the downstream API supports an idempotency key, forward the same value. A local ledger cannot close the crash window where the remote operation succeeds but the local result is not recorded. In that case, query the downstream system or mark the operation as &lt;code&gt;uncertain&lt;/code&gt; and require reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;verify zero side effects before approval&lt;/li&gt;
&lt;li&gt;keep approval authorization separate from RunState&lt;/li&gt;
&lt;li&gt;persist a tool argument digest&lt;/li&gt;
&lt;li&gt;encrypt state blobs and apply tenant ACLs&lt;/li&gt;
&lt;li&gt;route old states through versioned Agent factories&lt;/li&gt;
&lt;li&gt;design queues for at-least-once delivery&lt;/li&gt;
&lt;li&gt;make side-effecting tools idempotent&lt;/li&gt;
&lt;li&gt;test rejection, expiry, revocation, redelivery, lost ACKs, and corrupted blobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complete article includes the experiment files, state shapes, SQL schema, failure logs, and architecture diagrams.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.xbstack.com/en/ai/openai-agents-sdk-runstate-approval-resume/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=openai_agents_runstate_approval_resume_20260724&amp;amp;utm_content=article_body&amp;amp;ref=devto" rel="noopener noreferrer"&gt;Read the canonical article on XBSTACK&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>openai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>AI SDK 7 Migration: The Production Breakages a Codemod Cannot Fix</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Fri, 24 Jul 2026 03:41:13 +0000</pubDate>
      <link>https://dev.to/xbstack/ai-sdk-7-migration-the-production-breakages-a-codemod-cannot-fix-1dih</link>
      <guid>https://dev.to/xbstack/ai-sdk-7-migration-the-production-breakages-a-codemod-cannot-fix-1dih</guid>
      <description>&lt;h1&gt;
  
  
  AI SDK 7 Migration: The Production Breakages a Codemod Cannot Fix
&lt;/h1&gt;

&lt;p&gt;Most AI SDK 7 migrations look small in a pull request.&lt;/p&gt;

&lt;p&gt;A few option names change. TypeScript points at a callback. The app builds again, the chat page streams text, and the migration is declared complete.&lt;/p&gt;

&lt;p&gt;That is enough for a prototype. It is not enough for a production system that calls tools, stores conversation state, retries failures, resumes interrupted runs, or performs external side effects.&lt;/p&gt;

&lt;p&gt;I built a provider-free migration lab with two isolated fixtures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js &lt;code&gt;22.18.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ai@6.0.230&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ai@7.0.31&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;strict TypeScript&lt;/li&gt;
&lt;li&gt;deterministic mock language models from &lt;code&gt;ai/test&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;no API key, paid request, provider latency, or model-quality variance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was not to benchmark speed. It was to compare control flow and result semantics under identical tool-calling, persistence, failure, cancellation, retry, and timeout scenarios.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the runtime boundary
&lt;/h2&gt;

&lt;p&gt;Before changing any API names, check two requirements that a codemod cannot solve:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AI SDK 7 requires Node.js 22 or newer.&lt;/li&gt;
&lt;li&gt;AI SDK 7 is ESM-only and no longer supports CommonJS &lt;code&gt;require()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal package boundary looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=22"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your local environment, CI worker, serverless runtime, and production image must all satisfy the same boundary. Otherwise, the migration can pass locally and fail after deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visible renames are the easy part
&lt;/h2&gt;

&lt;p&gt;The obvious API changes are straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI SDK 6&lt;/span&gt;
&lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are an order assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;onFinish&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI SDK 7&lt;/span&gt;
&lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are an order assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;onEnd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&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;The event stream also moves from &lt;code&gt;fullStream&lt;/code&gt; to &lt;code&gt;stream&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A codemod can help with syntax. It cannot decide what your application meant when it read top-level tool fields, persisted raw SDK messages, retried a side-effecting tool, or handled cancellation after a tool had already completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 1: top-level tool calls no longer mean what some v6 code assumes
&lt;/h2&gt;

&lt;p&gt;Both fixtures ran the same two-step workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user asks for order A-100
        ↓
model emits lookupOrder tool call
        ↓
tool returns ready_for_pickup
        ↓
model returns final answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions produced the same final text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order A-100 is ready for pickup.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But their result shapes differed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;AI SDK 6 fixture&lt;/th&gt;
&lt;th&gt;AI SDK 7 fixture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top-level &lt;code&gt;toolCalls&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw &lt;code&gt;response.messages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final-step tool calls&lt;/td&gt;
&lt;td&gt;read from step data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;finalStep.toolCalls = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In AI SDK 7, top-level tool fields represent the full run. Code that previously treated them as “the final step” can silently change behavior.&lt;/p&gt;

&lt;p&gt;The migration rule is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allRunToolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalStepToolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finalStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audit every read of &lt;code&gt;toolCalls&lt;/code&gt; and &lt;code&gt;toolResults&lt;/code&gt;. Decide whether each call site needs full-run history or final-step-only semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 2: &lt;code&gt;response.messages&lt;/code&gt; is not a stable database contract
&lt;/h2&gt;

&lt;p&gt;The same deterministic workflow produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;three raw response messages in the v6 fixture;&lt;/li&gt;
&lt;li&gt;one raw response message in the v7 fixture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not make either version wrong. It means the SDK response shape is not the right long-term storage model for your product.&lt;/p&gt;

&lt;p&gt;A safer persistence boundary is application-owned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;conversation
  └── message
      └── run
          └── step
              └── logical tool_call
                  ├── tool_attempt 1
                  ├── tool_attempt 2
                  └── tool_result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist normalized business records first. Keep the raw SDK payload only as optional audit evidence.&lt;/p&gt;

&lt;p&gt;This protects your database from changes in message count, nesting, aggregation, or provider-specific metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 3: abort is not rollback
&lt;/h2&gt;

&lt;p&gt;I tested three interruption reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;page_closed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;network_disconnected&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;manual_cancel&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each fixture completed a tool call, committed the result, and then aborted before final text generation.&lt;/p&gt;

&lt;p&gt;The invariant was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user message survives;&lt;/li&gt;
&lt;li&gt;the completed tool result survives;&lt;/li&gt;
&lt;li&gt;the final assistant response is absent;&lt;/li&gt;
&lt;li&gt;the run is marked &lt;code&gt;aborted&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the tool executes exactly once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once an external side effect has succeeded, closing the page does not undo it. Recovery must resume from persisted state, not replay the tool blindly.&lt;/p&gt;

&lt;p&gt;For every side-effecting tool, use an idempotency key such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant_id + run_id + logical_tool_call_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Breakage 4: provider retries and tool retries are different policies
&lt;/h2&gt;

&lt;p&gt;A provider retry repeats model I/O. A tool retry may repeat a payment, email, database write, deployment, or ticket creation.&lt;/p&gt;

&lt;p&gt;The fixture models one logical tool call with two attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1 → transient failure
attempt 2 → success
one successful result returned to the model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Track attempts separately from the logical call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ToolAttempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;logicalCallId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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;Do not let an SDK step double as your retry ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage 5: timeout needs more than one budget
&lt;/h2&gt;

&lt;p&gt;The v6 fixture uses an application-owned timer and &lt;code&gt;AbortController&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The v7 fixture directly verifies a first-class total timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;totalMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&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;AI SDK 7 also exposes broader timeout controls in its public API. In production, separate budgets for the whole run, individual steps, idle chunks, and tools. Verify the exact type definitions for the version in your lockfile, and add a deterministic fixture before relying on a new timeout field.&lt;/p&gt;

&lt;p&gt;A timeout is not complete until it is represented in persisted state and propagated through providers and tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real HTTP/SSE disconnects change the recovery problem
&lt;/h2&gt;

&lt;p&gt;The deterministic fixtures prove state transitions, but I also added a real localhost HTTP/SSE test using Node's native server and &lt;code&gt;fetch&lt;/code&gt;. The client disconnected immediately after receiving a completed tool result.&lt;/p&gt;

&lt;p&gt;In request-bound mode, the observed state was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run.status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aborted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool executions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool result saved&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final response saved&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disconnect observed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Recovery reused the same &lt;code&gt;run_id&lt;/code&gt; and idempotency key. The application checked the tool ledger before execution and reused the stored result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tool executions before resume = 1
tool executions after resume  = 1
duplicate executions          = 0
final response saved          = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ordering matters. The user message, planned tool call, executing attempt, and completed tool result should be committed independently of the final assistant response. Otherwise, a broken stream can erase the evidence needed to avoid replaying a side effect.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;consumeStream()&lt;/code&gt; is useful for continuing server-side stream consumption and completion callbacks after a client disconnect. It does not know whether an order, email, deployment, or CRM update has already happened. It cannot replace an application-owned tool ledger and idempotency check.&lt;/p&gt;

&lt;p&gt;I also tested a detached mode in which the run was not cancelled with the HTTP connection. The client disconnected, while the run completed with one tool execution and a saved final response. This proves that request and execution lifecycles can be separated. It does not turn an in-process promise into a durable job: process restarts, deployments, instance eviction, and delayed approvals still require a queue, worker, or durable workflow owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;chunkMs&lt;/code&gt; and Cloudflare 524 are different timeout layers
&lt;/h2&gt;

&lt;p&gt;A local reverse proxy used a deliberately tiny 70 ms read-silence budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slow first byte      -&amp;gt; simulated 524
heartbeat every 35ms -&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was not a real Cloudflare edge test: there was no public domain, Ray ID, or production edge node. It only verifies the boundary between application state and proxy read behavior.&lt;/p&gt;

&lt;p&gt;The controls are separate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;What it cannot replace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser/request&lt;/td&gt;
&lt;td&gt;Page close or network disconnect&lt;/td&gt;
&lt;td&gt;Knowledge of whether a tool completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI SDK &lt;code&gt;chunkMs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Silence between stream chunks&lt;/td&gt;
&lt;td&gt;A reverse proxy's read budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI SDK &lt;code&gt;toolMs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One tool's execution budget&lt;/td&gt;
&lt;td&gt;Side-effect reconciliation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare 524&lt;/td&gt;
&lt;td&gt;Proxy waiting for the origin response&lt;/td&gt;
&lt;td&gt;Persisted run and step state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A heartbeat can keep a proxy connection active, but it does not prove business progress. A useful heartbeat should include a &lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;step_id&lt;/code&gt;, status, and update time, and it still needs a total SLA. Tasks that may outlive the HTTP budget should return a &lt;code&gt;job_id&lt;/code&gt;, persist each step and tool result, and continue under a queue or durable workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safer rollout order
&lt;/h2&gt;

&lt;p&gt;The migration sequence I would use is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upgrade local development and CI to Node.js 22 and ESM.&lt;/li&gt;
&lt;li&gt;Run the official migration tooling for mechanical renames.&lt;/li&gt;
&lt;li&gt;Audit all reads of top-level tool fields.&lt;/li&gt;
&lt;li&gt;Move final-step-only logic to &lt;code&gt;finalStep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Introduce version-neutral persistence.&lt;/li&gt;
&lt;li&gt;Propagate &lt;code&gt;AbortSignal&lt;/code&gt; through providers and tools.&lt;/li&gt;
&lt;li&gt;Add idempotency keys and attempt-level retry records.&lt;/li&gt;
&lt;li&gt;Add deterministic cancellation and timeout fixtures.&lt;/li&gt;
&lt;li&gt;Migrate one internal workflow behind a feature flag.&lt;/li&gt;
&lt;li&gt;Compare normalized events before moving production traffic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reproducible evidence
&lt;/h2&gt;

&lt;p&gt;The complete dual-version fixture, raw JSON results, migration diff, and persistence architecture are public:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbstack/xbstack-ai-sdk-7-migration-demo" rel="noopener noreferrer"&gt;https://github.com/xbstack/xbstack-ai-sdk-7-migration-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full production migration guide, including the complete schema and rollback checklist, is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.xbstack.com/en/ai/vercel-ai-sdk-7-migration-production/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_sdk7_migration&amp;amp;utm_content=article_body" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/vercel-ai-sdk-7-migration-production/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ai_sdk7_migration&amp;amp;utm_content=article_body&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The main conclusion is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI SDK 7 is worth upgrading to, but a safe migration begins by separating application state from SDK state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A build that passes after a codemod proves syntax compatibility. It does not prove that your tools, persistence, cancellation, retries, or recovery semantics are still correct.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>production</category>
      <category>softwareengineering</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Tested Kimi K3 on a Real Astro Codebase: Strong Cross-File Analysis, Unsafe First Fix</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:40:54 +0000</pubDate>
      <link>https://dev.to/xbstack/i-tested-kimi-k3-on-a-real-astro-codebase-strong-cross-file-analysis-unsafe-first-fix-bn5</link>
      <guid>https://dev.to/xbstack/i-tested-kimi-k3-on-a-real-astro-codebase-strong-cross-file-analysis-unsafe-first-fix-bn5</guid>
      <description>&lt;h1&gt;
  
  
  I Tested Kimi K3 on a Real Astro Codebase: Strong Cross-File Analysis, Unsafe First Fix
&lt;/h1&gt;

&lt;p&gt;Kimi K3 arrived with a 2.8-trillion-parameter architecture, up to a 1M-token context window, and a strong focus on long-horizon coding and agent tasks. Those specifications are interesting, but they do not answer the question I care about as an independent developer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can Kimi K3 inspect a real, existing codebase, understand relationships across files, and produce a remediation plan that is safe enough to use?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tested K3 Max in Kimi web Chat against a sanitized evidence pack from XBSTACK, a production Astro website with bilingual content, legacy routes, Canonical rules, Content Collections, and build-time validation.&lt;/p&gt;

&lt;p&gt;The result was mixed in a useful way. K3 understood the cross-file architecture better than a shallow code assistant. It identified the correct Collection owner, connected route generation with Canonical behavior, and noticed that an old URL could not be treated as an isolated file problem.&lt;/p&gt;

&lt;p&gt;But its first final recommendation would have hidden a valid warning instead of fixing the underlying state.&lt;/p&gt;

&lt;p&gt;That difference matters more than any benchmark score.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I gave K3
&lt;/h2&gt;

&lt;p&gt;I did not upload the entire private repository. I prepared a sanitized pack containing only the files required to investigate one real problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Astro content configuration;&lt;/li&gt;
&lt;li&gt;the affected Markdown frontmatter;&lt;/li&gt;
&lt;li&gt;the route implementation;&lt;/li&gt;
&lt;li&gt;the bilingual content mapping;&lt;/li&gt;
&lt;li&gt;the validation output that triggered the investigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used Kimi web Chat with K3 and Max reasoning. This means the model could inspect the uploaded files and reason about them, but it could not edit the local repository, run &lt;code&gt;npm&lt;/code&gt;, execute the Astro build, or inspect files I did not provide.&lt;/p&gt;

&lt;p&gt;That boundary is important. This was a code-review and remediation-planning test, not an autonomous coding-agent test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What K3 got right
&lt;/h2&gt;

&lt;p&gt;The strongest part of the response was architectural reconstruction.&lt;/p&gt;

&lt;p&gt;K3 correctly inferred that the problem crossed several layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content frontmatter
→ Astro Content Collection ownership
→ generated route
→ Canonical URL
→ bilingual mapping
→ legacy URL behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not reduce the warning to a typo in one Markdown file. It also recognized that changing the public route could affect existing links, search indexing, and the English counterpart.&lt;/p&gt;

&lt;p&gt;That is where a large-context model can be genuinely useful. The value is not simply reading more files. The value is preserving enough relationships to avoid treating each file as an independent snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the first recommendation failed
&lt;/h2&gt;

&lt;p&gt;The first plan contained a dangerous shortcut: suppress or hide the warning so the validation output would become clean.&lt;/p&gt;

&lt;p&gt;The warning, however, represented a real mismatch. Removing the signal would not repair the route, Canonical, redirect, or translation relationship. It would only make the system quieter.&lt;/p&gt;

&lt;p&gt;I added more repository evidence and asked K3 to reassess the plan. In the second round, it withdrew the suppression approach and moved toward a safer remediation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;preserve the valid public URL;&lt;/li&gt;
&lt;li&gt;repair the content or mapping source that produced the mismatch;&lt;/li&gt;
&lt;li&gt;verify the Chinese and English route pair;&lt;/li&gt;
&lt;li&gt;run the local build and release checks;&lt;/li&gt;
&lt;li&gt;inspect the final diff before publishing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The self-correction was useful, but the failure still establishes the operating boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;K3 is strong as an investigator and second opinion. Its first final plan is not a production approval.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a 1M context window does not remove the need for scope control
&lt;/h2&gt;

&lt;p&gt;Kimi Code documentation now makes the membership boundary explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;K3 is available to Moderato plans and above;&lt;/li&gt;
&lt;li&gt;Moderato supports up to 256K context;&lt;/li&gt;
&lt;li&gt;Allegretto and higher unlock up to 1M context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A larger context window can reduce manual chunking, but it can also encourage a bad workflow: loading the entire repository without defining the question, evidence set, or success criteria.&lt;/p&gt;

&lt;p&gt;For this test, a selected evidence pack was better than a repository dump because it made the reasoning auditable. I could see which relationships K3 derived from the provided files and which conclusions still depended on missing context.&lt;/p&gt;

&lt;h2&gt;
  
  
  July 21 update: subscription pause and Kimi Code 0.28
&lt;/h2&gt;

&lt;p&gt;The operational picture changed after the original test.&lt;/p&gt;

&lt;p&gt;Kimi said it temporarily paused new consumer subscriptions after K3 demand exceeded forecasts and pushed current compute clusters close to capacity. Existing paid users were prioritized while additional capacity was prepared. Reuters independently reported the pause on July 20.&lt;/p&gt;

&lt;p&gt;Kimi Code 0.26 expanded the coder sub-agent with background tasks, todo lists, plan mode, skills, and nested agents. Version 0.27 added &lt;code&gt;/copy&lt;/code&gt;, automatic model-list refresh for API-key users, better network error reporting, and a security fix preventing the built-in URL fetcher from reaching loopback or internal network targets through crafted domains and redirects. Version 0.28 standardized the foreground web mode as &lt;code&gt;kimi web&lt;/code&gt;, deprecated &lt;code&gt;kimi server&lt;/code&gt;, fixed Conservative reasoning-effort persistence for K2.5/K3 sessions and the status line, and corrected inconsistent YOLO/Auto mode descriptions.&lt;/p&gt;

&lt;p&gt;Another practical detail can look like a sudden quota or pricing problem: switching models or reasoning effort invalidates the existing prompt cache. In a long session, the old context may need to be prefilled again. Starting with &lt;code&gt;/new&lt;/code&gt; before selecting K3 is the safer default when previous context is unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow I would use in production
&lt;/h2&gt;

&lt;p&gt;For a real repository, I would separate investigation from execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Define one concrete engineering question.
2. Provide the smallest complete evidence set.
3. Ask K3 to map files, routes, state, and risks.
4. Require it to list assumptions and missing evidence.
5. Reject any plan that merely suppresses a warning.
6. Apply changes locally through a controlled coding agent or by hand.
7. Run typecheck, tests, build, route checks, and release gates.
8. Review the final diff before commit or deployment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;K3 can shorten steps 2 through 4. It does not replace steps 6 through 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final decision
&lt;/h2&gt;

&lt;p&gt;Kimi K3 is worth testing for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cross-file investigation;&lt;/li&gt;
&lt;li&gt;architecture review;&lt;/li&gt;
&lt;li&gt;long code-review sessions;&lt;/li&gt;
&lt;li&gt;identifying dependencies before a change;&lt;/li&gt;
&lt;li&gt;generating a second remediation plan after new evidence arrives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not treat it as autonomous proof that a repository is fixed. The model did not run the build in this test, and its first recommendation optimized the warning rather than the system.&lt;/p&gt;

&lt;p&gt;The useful conclusion is not that K3 is universally better. It is that K3 can hold a larger engineering picture in one investigation, while production safety still depends on evidence selection, local verification, idempotent execution, and human approval.&lt;/p&gt;

&lt;p&gt;Read the complete evidence-backed review, including the two-round correction and Astro-specific details:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.xbstack.com/en/ai/tools-lab/kimi-k3-real-astro-project-test/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=kimi_k3_july20_update&amp;amp;utm_content=article_body" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/tools-lab/kimi-k3-real-astro-project-test/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=kimi_k3_july20_update&amp;amp;utm_content=article_body&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Source and disclosure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This article is adapted from my original XBSTACK test.&lt;/li&gt;
&lt;li&gt;The test used Kimi web Chat, not a locally authorized Kimi Code agent.&lt;/li&gt;
&lt;li&gt;No private credentials, private routes, or unpublished repository data are included.&lt;/li&gt;
&lt;li&gt;Kimi Code releases and model limits: &lt;a href="https://www.kimi.com/code/docs/en/release-notes.html" rel="noopener noreferrer"&gt;https://www.kimi.com/code/docs/en/release-notes.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reuters report on the subscription pause: &lt;a href="https://www.reuters.com/legal/transactional/chinas-moonshot-pauses-kimi-subscriptions-amid-hot-demand-ipo-push-2026-07-20/" rel="noopener noreferrer"&gt;https://www.reuters.com/legal/transactional/chinas-moonshot-pauses-kimi-subscriptions-amid-hot-demand-ipo-push-2026-07-20/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Canonical source: &lt;a href="https://www.xbstack.com/en/ai/tools-lab/kimi-k3-real-astro-project-test/" rel="noopener noreferrer"&gt;https://www.xbstack.com/en/ai/tools-lab/kimi-k3-real-astro-project-test/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>GPT-5.6 Real-World Test: What Changed in an Astro, Content, and Analytics Workflow</title>
      <dc:creator>xn</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:40:54 +0000</pubDate>
      <link>https://dev.to/xbstack/gpt-56-real-world-test-what-changed-in-an-astro-content-and-analytics-workflow-5cf7</link>
      <guid>https://dev.to/xbstack/gpt-56-real-world-test-what-changed-in-an-astro-content-and-analytics-workflow-5cf7</guid>
      <description>&lt;h1&gt;
  
  
  GPT-5.6 Real-World Test: What Changed in an Astro, Content, and Analytics Workflow
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Search performance review, updated July 22, 2026&lt;/strong&gt;: Search Console currently has data through July 19. This page recorded 47 impressions and an average position of 12.6 in the current 28-day opportunity window. Only 26 of 28 metric dates have returned, so this update uses impressions and position to confirm that the page is close to the first two result pages; it does not calculate a new CTR from an incomplete daily window.&lt;/p&gt;

&lt;p&gt;The original workflow snapshot was captured on July 10, 2026. The 218-item content inventory, the site-wide 16 clicks from 794 impressions, and the historical 404 summary describe that earlier test. They are not the site's July 22 totals. Historical evidence, current opportunity data, and incomplete metric dates remain separate throughout this article.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Five questions this test had to answer
&lt;/h2&gt;

&lt;p&gt;The workflow was designed around five practical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can GPT-5.6 understand and safely modify an existing Astro and React content project?&lt;/li&gt;
&lt;li&gt;Does it improve technical content production, or merely produce a more complete report?&lt;/li&gt;
&lt;li&gt;Can it analyse aggregate GSC and GA4 data without inventing page-level attribution?&lt;/li&gt;
&lt;li&gt;How should Sol, Terra, Luna, max, and ultra be routed in everyday production?&lt;/li&gt;
&lt;li&gt;Which actions can a coding agent perform automatically, and which still require explicit approval?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who is this article for?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Independent developers and engineering teams evaluating AI tools in real repositories.&lt;/li&gt;
&lt;li&gt;Site owners and growth engineers using AI for content operations, SEO, Search Console, and GA4 analysis.&lt;/li&gt;
&lt;li&gt;Developers building coding agents who need explicit permission, evidence, and production-safety boundaries.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The conclusion: it closes more of the loop, but it still needs a human stop condition
&lt;/h2&gt;

&lt;p&gt;The most useful change I observed was continuity across a long, mixed workflow. GPT-5.6 had to read an Astro content system, respect existing routes and frontmatter, compare the proposed topic against a published inventory, recalculate Search Console and GA4 metrics, write the article, and run local verification. The value was not a better isolated answer. It was the ability to keep earlier constraints active while moving from research to files, data, and validation.&lt;/p&gt;

&lt;p&gt;That does not make it an autonomous project owner. The task was bounded: no navigation changes, no production deployment, no credential access, no destructive file operations, and no database migration. The model could plan and execute inside the allowed scope, but the author still decided which conclusions were supported, which sections should be deleted, and whether the final change was safe to publish.&lt;/p&gt;

&lt;p&gt;The central tension is simple: a model that is more willing to finish the job also needs clearer permission boundaries. In content work, over-completion produced a 25-heading report instead of a readable technical essay. In analytics, correct arithmetic could still become an unsupported causal story if the input dimensions were too coarse. In code, a valid change would still be unacceptable if it exceeded the authorized file or deployment scope.&lt;/p&gt;

&lt;p&gt;My 87/100 score applies only to this XBSTACK workflow. It is not a general model ranking, a latency benchmark, or a claim that GPT-5.6 beats every alternative on every task.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F02-gpt56-test-scope.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F02-gpt56-test-scope.webp" alt="Overview of GPT-5.6 Testing Tasks for Real Projects, Content Creation, and Data Analysis" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Test design: three real tasks and explicit boundaries
&lt;/h2&gt;

&lt;p&gt;I did not use puzzle questions or a synthetic demo repository. I placed GPT-5.6 inside XBSTACK's existing Astro content workflow and asked it to complete three connected tasks. The acceptance criteria were project artifacts, recalculated data, and local verification—not how persuasive the chat response sounded.&lt;/p&gt;

&lt;p&gt;I kept three deep tasks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Inputs&lt;/th&gt;
&lt;th&gt;Success criteria&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real project modification&lt;/td&gt;
&lt;td&gt;Astro, React, Node SSR, content collections, dynamic routes, and build scripts&lt;/td&gt;
&lt;td&gt;Find the existing content path, preserve the information architecture, and pass local verification&lt;/td&gt;
&lt;td&gt;File diff, route, build log, generated HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content review&lt;/td&gt;
&lt;td&gt;A 218-item inventory, existing articles, writing rules, and SEO/GEO requirements&lt;/td&gt;
&lt;td&gt;Avoid a duplicate search intent, separate official facts from observations, and produce valid metadata&lt;/td&gt;
&lt;td&gt;Draft history, metadata, internal links, manual edits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data analysis&lt;/td&gt;
&lt;td&gt;GSC daily exports and a GA4 404 page-title summary&lt;/td&gt;
&lt;td&gt;Recalculate the metrics, state the supported conclusion, and stop where the dimensions are insufficient&lt;/td&gt;
&lt;td&gt;Formula, date range, metric definition, unsupported claims removed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This was a production-shaped test, not a controlled model laboratory. It involved file reads, official-document verification, local edits, several rounds of revision, and build checks. GPT-5.6 could inspect the repository and edit the specified article, but it was not authorized to deploy, delete production resources, migrate credentials, alter navigation, or write to a production database.&lt;/p&gt;

&lt;p&gt;I also did not run a rigorous GPT-5.5 A/B test. There were no repeated trials under the same API account, prompt, cache state, network path, and tool permissions, and I did not capture a complete latency and token ledger. This article therefore makes no personal claim about a speed multiplier or a universal win rate. Official benchmarks remain external evidence, not my test result.&lt;/p&gt;

&lt;p&gt;The analytics inputs had limits as well. The original GSC export was an aggregate snapshot, and the GA4 material contained 404 page titles and views rather than the original failing URLs. The model could verify the arithmetic and identify concentration, but it could not support page-level attribution or a causal explanation without the missing dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The updates that actually changed how I work
&lt;/h2&gt;

&lt;p&gt;The changes that matter here are not a larger parameter count. They are the Sol, Terra, and Luna tiers, Programmatic Tool Calling, prompt caching, and the max and ultra runtimes. Together, they make model routing, tool-result preprocessing, and failure-cost management part of the application architecture instead of an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose Sol, Terra, and Luna?
&lt;/h2&gt;

&lt;p&gt;OpenAI's public positioning gives each model a different capability, speed, and price envelope. This review did not run a controlled latency benchmark across accounts, regions, prompts, cache states, and repeated trials, so the earlier draft's precise latency ranges, "strong/medium/weak" tool ratings, and fixed usage percentages have been removed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Official role&lt;/th&gt;
&lt;th&gt;Input price&lt;/th&gt;
&lt;th&gt;Output price&lt;/th&gt;
&lt;th&gt;My workflow decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sol&lt;/td&gt;
&lt;td&gt;Highest-capability tier for complex work&lt;/td&gt;
&lt;td&gt;$5 / 1M tokens&lt;/td&gt;
&lt;td&gt;$30 / 1M tokens&lt;/td&gt;
&lt;td&gt;Reserve it for cross-file changes, deep research, and high failure-cost tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terra&lt;/td&gt;
&lt;td&gt;Balanced quality and cost for everyday work&lt;/td&gt;
&lt;td&gt;$2.50 / 1M tokens&lt;/td&gt;
&lt;td&gt;$15 / 1M tokens&lt;/td&gt;
&lt;td&gt;The default candidate for routine development, editing, and data explanation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Luna&lt;/td&gt;
&lt;td&gt;Faster, lower-cost tier for lighter work&lt;/td&gt;
&lt;td&gt;$1 / 1M tokens&lt;/td&gt;
&lt;td&gt;$6 / 1M tokens&lt;/td&gt;
&lt;td&gt;Use it for classification, extraction, routing, and low-risk batch processing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final column is an XBSTACK routing decision, not an OpenAI recommendation for every application. A production router still needs its own success rate, human rework time, cache behavior, and failure-cost data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max&lt;/code&gt; and &lt;code&gt;ultra&lt;/code&gt; are not the fourth or fifth models. &lt;code&gt;max&lt;/code&gt; allows individual models to spend more time on inference, explore solutions, run checks, and revise results; &lt;code&gt;ultra&lt;/code&gt; is a multi-agent parallel model. The official default is that four agents handle different workflows separately, which are then aggregated by the main agent. Simply put, Max is one person thinking several rounds of work, Ultra is about splitting tasks into several people to complete simultaneously.&lt;/p&gt;

&lt;p&gt;If Ultra is used for this work, it can be broken down into four branch lines: official document verification, project structure check, content inventory analysis, and GSC/GA4 recalculation. But if you change a title, extract a few fields, or format data, there's no need to enable four agents. Multi-agent entities increase the upper limit and completion speed of complex tasks, while also consuming more tokens; When tasks can't be disassembled, they only create repetitive work.&lt;/p&gt;

&lt;p&gt;Another change that deserves more attention from developers is Programmatic Tool Calling. Traditional tool calls often push a large number of intermediate results back into the context before the model judges the next step. GPT-5.6 can write and run lightweight programs within the Responses API, filtering, grouping, and aggregating tool results first, retaining only the truly important content.&lt;/p&gt;

&lt;p&gt;For example, when handling 10,000 logs, a more reasonable process is not to send each log to the model individually, but to first group them by status code, count repeat paths, extract abnormal samples, and then let the model determine the cause and priority. The program is responsible for stable computation, while the model handles uncertainty judgment. This is more practical for website logs, financial reports, MCP, multi-tool agents, and n8n workflows than "the model can write a script."&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F03-gpt56-model-routing.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F03-gpt56-model-routing.webp" alt="GPT-5.6 Luna, Terra, Sol, Max, Ultra, and Programmatic Tool Calling workflows " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Real project test: did it read the rules before editing files?
&lt;/h2&gt;

&lt;p&gt;In this bounded task, GPT-5.6 read the content collection, Tools Lab routing, and frontmatter conventions before it edited the article. It used the existing directory and route, did not invent a new collection, and did not change navigation. Those inspectable actions are stronger evidence than a generic claim that it "understood the project."&lt;/p&gt;

&lt;p&gt;The instruction was not simply "write a GPT-5.6 article." It first had to determine whether the topic duplicated an existing page, identify the correct collection and route, preserve the frontmatter contract, and only then edit the file.&lt;/p&gt;

&lt;p&gt;The resulting content placement was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content file: src/content/ai/gpt56-test.md
section: tools-lab
hub: tools-lab
subcategory: tools-lab
series: ai-tools-lab
page route: /ai/tools-lab/gpt56-test/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path itself is simple. The important part is what happened next: the task continued through the content ledger, Astro build, target route, sitemap, Pagefind, canonical URL, JSON-LD, and generated HTML. In that historical build snapshot, the page pre-rendered successfully, Pagefind indexed 820 static HTML pages, the structured data parsed, and machine-oriented metadata did not leak into the visible article. Those checks belong to the July 10 test snapshot; they are not the site's current global page count.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F04-gpt56-project-proof.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F04-gpt56-project-proof.webp" alt="GPT-5.6 reads rules, writes articles, and completes build validation " width="800" height="400"&gt;&lt;/a&gt; in XBSTACK Astro projects&lt;/p&gt;

&lt;p&gt;Continuity was the strongest part of the project task. Repository rules read at the beginning still constrained the later file edit and verification steps. An abstract requirement such as "GEO must not pollute the visible article" was translated into concrete checks around &lt;code&gt;hideStructuredBlocks&lt;/code&gt;, frontmatter, JSON-LD, the layout, and the generated page head.&lt;/p&gt;

&lt;p&gt;This does not prove that GPT-5.6 can independently maintain an arbitrary production system. The change was narrow and reversible. I did not test a database migration, a large refactor, an automated deployment, or a live rollback. The defensible conclusion is narrower: when a repository already has clear rules, a bounded file scope, and testable outputs, GPT-5.6 can absorb a substantial amount of reading, locating, editing, and verification work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content test: strong evidence collection, weak editorial hierarchy
&lt;/h2&gt;

&lt;p&gt;The content task exposed a different failure mode: over-structuring. GPT-5.6 was good at retrieving facts, checking metadata, and making sure every requirement appeared somewhere. That same behavior produced a draft with 25 H2 headings. It was complete in a checklist sense and weak as a continuous technical essay.&lt;/p&gt;

&lt;p&gt;The historical inventory contained 218 published entries, including 134 files in the AI collection and 96 entries with &lt;code&gt;category: ai&lt;/code&gt;. Those are different dimensions: one describes storage, the other a business category. The model kept them separate and correctly placed the article in AI Tools Lab rather than Agent, LangGraph, or general Notes.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F05-xbstack-content-inventory.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F05-xbstack-content-inventory.webp" alt="XBSTACK collection of 218 published articles, categorized and distributed by Tools Lab " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The useful work included duplicate-intent checking, metadata alignment, canonical and internal-link planning, and separating official specifications from observations. The weak point was editorial judgment. Test scope, data definitions, inventory, GSC, GA4, GEO, build evidence, benchmarks, pricing, security, and audience were each turned into separate sections. Most facts survived review, but the reading experience resembled a delivery report.&lt;/p&gt;

&lt;p&gt;I had to merge sections, remove repeated conclusions, delete unsupported precision, and rebuild the article around one tension: GPT-5.6 can close more of a complex workflow, but the stronger the execution capability, the more important human acceptance and permission boundaries become.&lt;/p&gt;

&lt;p&gt;That is the division of labor I would keep. The model handles evidence collection, recalculation, inventory checks, a first structure, metadata, and repetitive validation. The author decides which evidence matters, what to remove, what order serves the reader, and whether the final article still reflects an accountable point of view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data test: correct arithmetic is not the same as correct attribution
&lt;/h2&gt;

&lt;p&gt;The arithmetic was easy. The real test was whether the model would stop before turning an aggregate ratio into a causal explanation. It correctly recalculated the historical GSC CTR and the distribution of views across three 404 page titles. It did not have the original failing URLs, referrers, unique users, or redirect chain, so those inputs could not support a page-level root cause.&lt;/p&gt;

&lt;p&gt;The first calculation was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CTR = 16 ÷ 794 × 100%
    = 2.0151%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounded to two decimals, the historical site-wide CTR was 2.02%. The supported conclusion was limited: Google was already showing the site, but much of that visibility occurred at weak average positions and produced few clicks. That does not prove that the titles alone were the problem.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F06-xbstack-gsc-analysis.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F06-xbstack-gsc-analysis.webp" alt="XBSTACK Analysis of Search Console Impressions, Clicks, CTR, and Weighted Average Ranking for the Past Three Months" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, it does not directly attribute the problem to "the title not being attractive enough." A weighted average ranking of 34.19 means that many impressions occur in lower positions in the search results, which in itself lowers the CTR. A more prudent course of action should be to first break down the data by page and query, identify pages with an average ranking of 8–30 that already have a certain number of impressions, and then check the title, meta description, and search intent. Pages ranked beyond the thirtieth position with few impressions should not have their titles frequently changed solely because of low CTR.&lt;/p&gt;

&lt;p&gt;The second data set came from the GA4 Page titles and screens report for April 11 through July 9, 2026. Three explicit 404 titles recorded 523, 57, and 25 views, for a total of 605. The currently used "No Man's Land | 404 - XBSTACK" title accounted for 523 of those views.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;523 ÷ 605 × 100% = 86.4463%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounded to one decimal, the share was 86.4%. It only means that most views across these three named 404 titles were attached to the current title. It does not mean that 86.4% of broken URLs came from one page type, one route migration, or &lt;code&gt;/tags/&lt;/code&gt;. The export did not contain those dimensions.&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F07-xbstack-ga4-404-overview.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F07-xbstack-ga4-404-overview.webp" alt="XBSTACK GA4 Three Clear 404 Page Titles Page View Overview " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What really tests the model here is not division, but whether it can stop at the data boundary in time. The current exports only have page titles and view counts, without triggering the original page path of 404, so it is impossible to further determine whether these visits come from tabs, historical article addresses, case values, URL encoding, or external old links. 605 is just the total number of views for three categories of 404 titles, not 605 unique visitors or 605 unique invalid URLs.&lt;/p&gt;

&lt;p&gt;Combining the content inventory, GSC, and GA4 produced a clearer action order. First export the original 404 paths and sources, then fix the concentrated broken destinations. In parallel, improve pages that already have impressions and average positions near the first two result pages. New AI content should pass a stricter duplicate-intent check, while weaker sections should be expanded only with real material. The model did not choose the site's strategy; it separated supported actions from questions that still required more data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official benchmarks are context, not project evidence
&lt;/h2&gt;

&lt;p&gt;Official benchmark gains are useful background, but they are not the acceptance test for this article. I used local build results, recalculated metrics, preserved constraints, and the amount of manual correction as the evidence. A benchmark can explain why a capability may have improved; it cannot prove that a particular repository change, analytics conclusion, or publishing decision was correct.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Review&lt;/th&gt;
&lt;th&gt;GPT-5.6 Sol&lt;/th&gt;
&lt;th&gt;GPT-5.6 Sol Ultra&lt;/th&gt;
&lt;th&gt;GPT-5.5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terminal-Bench 2.1&lt;/td&gt;
&lt;td&gt;88.8%&lt;/td&gt;
&lt;td&gt;91.9%&lt;/td&gt;
&lt;td&gt;85.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BrowseComp&lt;/td&gt;
&lt;td&gt;90.4%&lt;/td&gt;
&lt;td&gt;92.2%&lt;/td&gt;
&lt;td&gt;84.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OSWorld 2.0&lt;/td&gt;
&lt;td&gt;62.6%&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;47.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SWE-Bench Pro&lt;/td&gt;
&lt;td&gt;64.6%&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;59.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSWE v1.1&lt;/td&gt;
&lt;td&gt;72.7%&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;67.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The benchmark pattern is directionally consistent with the workflow I observed: the model was most useful when browsing, repository reading, tool use, and repeated verification had to remain connected. That is still an interpretation, not a private reproduction of the benchmark.&lt;/p&gt;

&lt;p&gt;The official table also shows that GPT-5.6 is not first on every evaluation, and each benchmark has its own environment and success criteria. I did not run a controlled GPT-5.5 comparison, so the supported claim is limited to a better experience with long-task continuity—not a measured percentage gain in overall ability.&lt;/p&gt;

&lt;p&gt;Benchmarks explain why a capability may be worth testing. The project decides whether it is useful. In this review, a passing build, a correctly bounded analytics conclusion, and the amount of manual restructuring mattered more than a small difference on an unrelated leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and model routing: daily work does not need Sol everywhere
&lt;/h2&gt;

&lt;p&gt;The published prices make a single-model strategy difficult to justify. A router should consider not only token price, but also task failure cost, expected human rework, input stability, and whether cached context can be reused. The cheapest request is not cheaper if it regularly produces a result that must be redone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input / 1M tokens&lt;/th&gt;
&lt;th&gt;Output / 1M tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Sol&lt;/td&gt;
&lt;td&gt;$5&lt;/td&gt;
&lt;td&gt;$30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Terra&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Luna&lt;/td&gt;
&lt;td&gt;$1&lt;/td&gt;
&lt;td&gt;$6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GPT-5.6 also supports explicit prompt cache breakpoints and a cache lifecycle of at least 30 minutes. Cache writes are charged at 1.25 times the input price of uncached inputs, while cache reads continue to enjoy a 90% discount on input prices. For workflows containing a large number of stable project rules, duplicate documents, or fixed tool descriptions, caching strategies directly affect costs.&lt;/p&gt;

&lt;p&gt;This test took place in the ChatGPT/Codex workflow, and I didn't have a complete API token bill, so I couldn't give "the exact amount cost to complete this article." If there is no real billing record, only the API unit price is listed, without falsifying the cost per task.&lt;/p&gt;

&lt;p&gt;My actual route would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tagging, classification, formatting, simple extraction  → Luna
Routine editing, summaries, standard data explanation   → Terra
Cross-file changes, deep research, high failure cost    → Sol
One difficult task that needs deeper checking            → Sol + max
A task that cleanly splits into independent workstreams  → ultra / multi-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terra is more suitable as the default setting for daily use. Many content organization tasks, general development, and routine Agent tasks do not require paying Sol's output price. Sol should be reserved for work with high failure costs, cross-file or cross-tool tasks, and results that need thorough review. Ultra is also not a free quality button; when tasks cannot be naturally divided, multiple Agents will repeatedly read materials, perform reasoning, and consume tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failures and limits that were actually observed
&lt;/h2&gt;

&lt;p&gt;I re-audited the earlier version of this section and removed three examples that did not have preserved logs or a direct official source: an Astro API error, a fabricated-looking prompt-cache threshold error, and a credential-search shell error. A review cannot promote a plausible incident into an observed one merely because it sounds technically possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 1: a complete draft that read like a project report
&lt;/h3&gt;

&lt;p&gt;The first draft tried to satisfy every requirement as a separate section. A roughly 6,500-Chinese-character article ended up with 25 H2 headings. The problem was not missing information. It was the absence of editorial hierarchy. The model optimized for checklist coverage; the author needed a single argument, a deliberate pace, and evidence placed where it changed the decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 2: correct arithmetic could still become an unsupported attribution
&lt;/h3&gt;

&lt;p&gt;The historical GSC CTR and the 404 title-view share were recalculated correctly. The input still lacked the dimensions required to explain the root cause. The model could say that 523 of 605 views belonged to one 404 title. It could not say those views came from one route, one migration, one platform, or one class of broken URL.&lt;/p&gt;

&lt;p&gt;The useful behavior in an analytics task is often a stop condition: "this calculation is supported; the attribution is not." A model that always returns a complete explanation can be less useful than one that explicitly identifies the next data export required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 3: no controlled A/B means no personal speed claim
&lt;/h3&gt;

&lt;p&gt;I did not run GPT-5.6 and GPT-5.5 repeatedly under the same API, account, prompt, cache state, network path, and tool permissions. I also did not preserve a full token and latency ledger. The article can describe a better experience with long-task continuity. It cannot claim a measured speedup, win rate, or cost reduction.&lt;/p&gt;

&lt;p&gt;The previous version included precise first-token latency ranges and fixed model-usage percentages without test logs. Those figures have been removed. Deleting unsupported precision improves the review more than adding another benchmark table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limit 4: the task ran inside explicit permissions
&lt;/h3&gt;

&lt;p&gt;GPT-5.6 was not allowed to deploy, push, delete files, change navigation, migrate credentials, or write to production data. It completed a bounded local task. That result does not prove that an unsupervised coding agent should control an arbitrary production environment.&lt;/p&gt;

&lt;p&gt;OpenAI's GPT-5.6 System Card also discusses a somewhat greater tendency than GPT-5.5 to exceed the user's original intent in a small number of agentic coding cases, while noting that the absolute rates remain low. That finding supports permission design; it does not justify claiming that the model will inevitably overreach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Greatest Risk: The more it wants to get things done, the more it needs to limit what it can do
&lt;/h2&gt;

&lt;p&gt;The risk is not limited to incorrect code. A coding agent can produce technically valid code and still perform an unauthorized action. That changes the safety question from "is the patch correct?" to "was this action allowed, reversible, and independently verified?"&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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F08-gpt56-permission-boundary.webp" 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/..%2F..%2F..%2Fassets%2Fuploads%2Fgpt56-article-images%2F08-gpt56-permission-boundary.webp" alt="GPT-5.6 Coding Agent permission boundaries: read-only defaults, scoped writes, manual approval, and production verification" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Permissions should follow action risk, not a single on/off switch
&lt;/h3&gt;

&lt;p&gt;I use four permission levels. Read-only inspection, repository search, public-document research, and plan generation are allowed by default. Scoped local edits and test commands are allowed only in named directories with a visible diff. Deletion, overwrite, dependency changes, database migrations, push, deployment, external messages, and public publishing require separate approval. Credential discovery, cross-environment token copying, bypassing safety checks, and altering audit records are prohibited.&lt;/p&gt;

&lt;p&gt;The same principle applies to sub-agents: parallel workers must not inherit more authority than the parent task, and their operations must return to the same audit trail. Multi-agent execution can reduce elapsed time, but it also increases the number of actions that need to be observable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data analysis needs explicit stop conditions
&lt;/h3&gt;

&lt;p&gt;I now define both the allowed conclusion and the stopping point before asking for an analytics explanation. Aggregate page metrics can support prioritisation; they cannot identify a page-level cause. A 404 title report can show concentration; it cannot identify the original broken URL. A high Direct share can justify better campaign tagging; it cannot prove which untagged platform caused the visits. When a required dimension is missing, the output should name the next export instead of completing the story with a plausible guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evidence has three different owners
&lt;/h3&gt;

&lt;p&gt;I now separate every major statement into three categories. Official facts—model roles, prices, benchmarks, product features, and safety findings—must point to OpenAI material. Project evidence—file paths, diffs, build output, route checks, recalculated metrics, and generated HTML—must come from the local workflow. Routing choices and editorial decisions are author judgments, not universal product claims.&lt;/p&gt;

&lt;p&gt;This separation prevents a common review failure: writing an official benchmark, one successful project run, and a personal preference in the same authoritative voice. A reader should be able to tell whether a claim came from the vendor, this test, or my operating context.&lt;/p&gt;

&lt;h3&gt;
  
  
  A file write is not the end of a project test
&lt;/h3&gt;

&lt;p&gt;For this Astro task, the verification chain was longer than "the markdown file exists." The content path had to match the collection, the frontmatter had to parse, the route had to generate, the build had to pass, the canonical URL and structured data had to be correct, the sitemap and Pagefind output had to include the page, and machine-oriented metadata had to remain outside the visible article.&lt;/p&gt;

&lt;p&gt;Any broken link in that chain would change the status from complete to partial. This is why a polished answer or a successful file edit is not enough evidence for a coding-agent review. The acceptance criteria must be specified before the model starts, and each completed check needs an artifact.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the human editor changed
&lt;/h3&gt;

&lt;p&gt;The final article is not the model's untouched output. I merged the 25-heading structure, removed repeated conclusions, separated historical and current data, changed the model name to the official GPT-5.6 form, corrected the 86.4% interpretation, and deleted unsupported latency ranges, tool-capability ratings, cache errors, and credential errors.&lt;/p&gt;

&lt;p&gt;The model handled a large amount of retrieval, organization, recalculation, file work, and validation. The human work was evidence review, deletion, sequencing, and deciding how far each conclusion could go. Hiding those edits would overstate the model's contribution and make the test impossible to learn from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the method
&lt;/h3&gt;

&lt;p&gt;The exact result will change with the repository, model service, and data window, but the method is reproducible. Use a real repository, define the allowed files and prohibited actions, provide a mixed task with a measurable output, preserve the prompt and plan, record the diff and test commands, and include at least one task where the correct behavior is to stop for missing evidence or permission.&lt;/p&gt;

&lt;p&gt;For analytics, preserve the date range, dimensions, metric definitions, and formulas. For content, preserve the first draft and the human-edited version. The goal is not to reproduce the same score. It is to reproduce the evidence format: what went in, what changed, what failed, how the result was verified, and which conclusions remain out of scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I updated this page instead of publishing a second GPT-5.6 review
&lt;/h3&gt;

&lt;p&gt;For the 28-day period ending July 17, the page had 46 impressions, 3 clicks, and an average position of 12.8. The sample is too small for a causal SEO claim, but it shows that Google already associates this URL with the relevant intent. Publishing another page with the same core query would split signals and create a duplicate-intent problem.&lt;/p&gt;

&lt;p&gt;The update therefore keeps the route and canonical URL, improves the title and description, adds evidence and boundaries, and removes weak claims. I will review indexing after seven days, query growth after fourteen days, and page-level impressions, clicks, CTR, and average position after twenty-eight days. Short-term volatility will be recorded, not promoted as proof that the rewrite succeeded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release acceptance needs its own stop conditions
&lt;/h3&gt;

&lt;p&gt;The article does not pass merely because the prose is finished. Publication stops if a core number cannot be recalculated, a screenshot exposes a local path or credential, the English page is only an unreviewed literal translation, the canonical or schema fails, or the build and article gates do not pass. An untested production action remains unverified, and an undeployed change cannot be described as live. These conditions prevent a timely review from becoming a less trustworthy one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2026-07-19 Update: Compare It with the Kimi K3 Real-Project Test
&lt;/h3&gt;

&lt;p&gt;I later tested Kimi K3 on the same type of Astro content-architecture problem. Kimi K3 correctly identified Collection ownership, dynamic routing, and legacy URL risks in its first pass, but its final recommendation initially tried to hide a valid warning through an exception; it withdrew that recommendation only after receiving additional evidence. The result reinforces the conclusion of this article: the model's greatest value is reducing investigation and review time, but routing, canonical URLs, redirects, and production changes still require human approval. See the full process in &lt;a href="https://dev.to/en/ai/tools-lab/kimi-k3-real-astro-project-test/"&gt;Kimi K3 Real-Project Test: Strong Cross-File Analysis, but Final Decisions Still Need Human Review&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final decision: use more of the workflow, not more authority
&lt;/h2&gt;

&lt;p&gt;GPT-5.6 earned a larger role in my research, repository reading, content inventory, data recalculation, drafting, and local verification workflow. The reason is continuity: it connected work that would otherwise be split across browsing, code inspection, spreadsheets, metadata, and build commands without discarding most of the earlier constraints.&lt;/p&gt;

&lt;p&gt;It did not replace editorial judgment, data acceptance, business priority, or permission decisions. The 25-heading draft showed that complete coverage can still produce poor reading. The analytics task showed that correct math can still support an incorrect story. The permission analysis showed that a technically valid action can still be unauthorized.&lt;/p&gt;

&lt;p&gt;I would route low-risk batch work to Luna, most daily production work to Terra, and genuinely complex or expensive-to-fail tasks to Sol. Max is for one difficult task that benefits from deeper checking. Ultra is useful only when the work can be split into independent branches with clear aggregation criteria.&lt;/p&gt;

&lt;p&gt;Production writes, deletion, deployment, credentials, database changes, cloud resources, and public publishing remain human-approved. The practical upgrade is not that GPT-5.6 should receive every task and every permission. It is that more of the workflow can be delegated while the stop conditions become explicit.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is GPT-5.6 good for content creation?
&lt;/h3&gt;

&lt;p&gt;Yes, especially for evidence collection, duplicate-intent checks, structural drafts, metadata, internal links, and revision passes. Its tendency toward complete coverage can produce too many sections and a report-like rhythm, so the author still needs to control evidence selection, sequence, voice, and factual boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can GPT-5.6 Analyze Search Console and GA4 Data?
&lt;/h3&gt;

&lt;p&gt;It can recalculate metrics, identify concentration, compare priorities, and turn the available dimensions into an action list. In this test it verified &lt;code&gt;16/794 = 2.02%&lt;/code&gt; and &lt;code&gt;523/605 = 86.4%&lt;/code&gt;. Without page, query, referrer, or original broken-URL data, it could not support page-level attribution or a causal claim.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to Choose Between GPT-5.6 Sol, Terra, and Luna?
&lt;/h3&gt;

&lt;p&gt;Use Sol for cross-file development, deep research, long tasks, and work with a high failure cost. Use Terra as the everyday default for development, editing, and routine agent work. Use Luna for classification, extraction, routing, format conversion, and high-volume low-risk tasks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is GPT-5.6 Ultra a More Powerful Standalone Model?
&lt;/h3&gt;

&lt;p&gt;No. Ultra is a multi-agent parallel mode, with four Agents by default dividing tasks and then summarizing them. It can increase the upper limit and completion speed of complex tasks, but consumes more tokens; simple tasks are usually not worth activating.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Much Faster Is GPT-5.6 Than GPT-5.5?
&lt;/h3&gt;

&lt;p&gt;This article did not conduct rigorous personal A/B testing, so it cannot provide speed multipliers. The token, latency, and success rate improvements announced by the official and partners can only be used as external references and cannot be directly equated with the actual performance of all projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is GPT-5.6 suitable for directly taking over production projects?
&lt;/h3&gt;

&lt;p&gt;No. Start read-only, show the plan before execution, restrict directories and tools, approve risky actions separately, inspect the diff, run verification, and keep a rollback point. Deletion, dependency changes, deployment, credentials, database changes, and production data writes require explicit approval.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will the GEO information in this article affect the reading of ordinary users?
&lt;/h3&gt;

&lt;p&gt;Machine query terms and structured summaries will not be additionally displayed. XBSTACK uses &lt;code&gt;hideStructuredBlocks: true&lt;/code&gt; to disable automatically visible queries, summaries, and audience modules; TechArticle, FAQPage, Dataset, BreadcrumbList, keywords, and references are written into the page header via JSON-LD and Meta, but the main text still only has one set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Information
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://openai.com/index/gpt-5-6/" rel="noopener noreferrer"&gt;GPT-5.6 Official Release Page &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.openai.com/en/articles/9624314-model-release-notes" rel="noopener noreferrer"&gt;OpenAI Model Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.openai.com/en/articles/20001354" rel="noopener noreferrer"&gt;GPT-5.6 in ChatGPT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://deploymentsafety.openai.com/gpt-5-6" rel="noopener noreferrer"&gt;GPT-5.6 System Card&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/tools-lab/kimi-k3-real-astro-project-test/"&gt;Kimi K3 real-project test: strong cross-file analysis, but final decisions still need human review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/en/ai/tools-lab/chatgpt-work-chat-codex-difference/"&gt;What are the differences between ChatGPT Work, Chat, and Codex? A complete workflow&lt;/a&gt; for a real website task&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/tools-lab/"&gt;AI Tools Lab: Model and Tool Testing &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/model-updates/"&gt;Model Updates: Large Model Update Portal &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/growth/"&gt;Growth Lab: SEO, GEO, Search Console, and Content Operations Experiment &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/tools-lab/claude-sonnet-5-astro-bundle-optimization/"&gt;Claude Sonnet 5 test: Astro chunk over-optimizes the entire process &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/xbstack-pre-release-data-support-audit/"&gt;Pre-release data quality inspection for personal websites: How to determine whether GSC, GA4, 404, and product evidence are sufficient?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/search-console-ctr-title-fix/"&gt;Search Console exposed but not clicked, only then did I realize the issue wasn't in indexing, but in the title and entry &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/xbstack-content-quality-audit-builder-log/"&gt;My personal website posted 160 articles, and only then did I realize the reason for the lack of traffic &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/en/ai/ai-agent-production-governance/"&gt;AI Agent Production-Based Governance: Evaluation, Observability, Cost Control, and Closed-Loop Human Approval &lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.xbstack.com/en/ai/tools-lab/gpt56-test/" rel="noopener noreferrer"&gt;XBSTACK&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
