<?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: John Hu</title>
    <description>The latest articles on DEV Community by John Hu (@john_hu_c9e41268b1f330d2e).</description>
    <link>https://dev.to/john_hu_c9e41268b1f330d2e</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%2F4210209%2F2889c2cb-4e99-4e7d-ad55-d40823039542.png</url>
      <title>DEV Community: John Hu</title>
      <link>https://dev.to/john_hu_c9e41268b1f330d2e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/john_hu_c9e41268b1f330d2e"/>
    <language>en</language>
    <item>
      <title>Why AI Agents Are Rediscovering Grep: The Limits of Vector RAG in Real-World Engineering</title>
      <dc:creator>John Hu</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:06:58 +0000</pubDate>
      <link>https://dev.to/john_hu_c9e41268b1f330d2e/why-ai-agents-are-rediscovering-grep-the-limits-of-vector-rag-in-real-world-engineering-2lf8</link>
      <guid>https://dev.to/john_hu_c9e41268b1f330d2e/why-ai-agents-are-rediscovering-grep-the-limits-of-vector-rag-in-real-world-engineering-2lf8</guid>
      <description>&lt;p&gt;As AI agents move from demos to production, engineering teams are rediscovering an important lesson: reliable access to information matters more than sophisticated retrieval algorithms.&lt;/p&gt;

&lt;p&gt;For the past few years, vector databases have been treated as an almost essential component of AI applications. Whether the goal was to build an internal knowledge assistant, a code-search tool, or an autonomous agent, the architectural pattern was remarkably consistent: split documents into chunks, generate embeddings, store them in a vector database, and retrieve the most semantically similar passages whenever the model needs information.&lt;/p&gt;

&lt;p&gt;For many applications, this approach works well.&lt;/p&gt;

&lt;p&gt;But as AI agents take on increasingly complex engineering tasks — particularly debugging, code modification, system administration, and long-running workflows — the limitations of vector-based retrieval are becoming harder to ignore.&lt;/p&gt;

&lt;p&gt;In some of these environments, a simple ripgrep command can deliver more useful results than an elaborate retrieval pipeline.&lt;/p&gt;

&lt;p&gt;This is not because semantic search is fundamentally flawed. It is because semantic similarity and factual precision solve different problems, and autonomous agents often need the latter far more than traditional RAG architectures were designed to provide.&lt;br&gt;
The broader shift is not necessarily away from RAG itself, but toward a more deliberate division of responsibility: probabilistic models for reasoning and planning, deterministic tools for retrieving facts and interacting with the environment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Semantic Relevance Is Not the Same as Precision
Vector search is designed to find content that is similar in meaning, even when the words differ.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Consider an employee asking:&lt;br&gt;
“What changed in our expense reimbursement policy last fall?”&lt;/p&gt;

&lt;p&gt;The employee may not remember the exact policy name, relevant department, or original document. Semantic search is useful precisely because it can bridge that gap between a vague question and the documents likely to contain the answer.&lt;/p&gt;

&lt;p&gt;But software engineering agents face a different kind of problem.&lt;/p&gt;

&lt;p&gt;Imagine an agent investigating an application failure involving the error:&lt;br&gt;
invalid_token_handler&lt;br&gt;
The agent does not need a general explanation of authentication failures. It needs to identify exactly where that error is defined, raised, handled, or referenced in the codebase.&lt;/p&gt;

&lt;p&gt;A vector retrieval system may return documentation about token expiration, failed login attempts, authentication middleware, or other conceptually related topics.&lt;/p&gt;

&lt;p&gt;Those results might be semantically relevant, but they may have nothing to do with the actual defect.&lt;/p&gt;

&lt;p&gt;Worse, the exact function or error constant the agent needs could be missing from the retrieved results entirely.&lt;/p&gt;

&lt;p&gt;A simple text search takes a different approach:&lt;br&gt;
rg -n "invalid_token_handler" .&lt;br&gt;
This command returns the locations where the exact string appears, including file paths and line numbers.&lt;/p&gt;

&lt;p&gt;No semantic interpretation is involved.&lt;/p&gt;

&lt;p&gt;That distinction is critical.&lt;/p&gt;

&lt;p&gt;When debugging, a plausible but incorrect result can be more dangerous than an empty result. An agent may treat loosely related documentation as evidence and construct an entire debugging strategy around a false assumption.&lt;/p&gt;

&lt;p&gt;An exact search, by contrast, establishes a concrete starting point.&lt;br&gt;
For engineering agents, the value of retrieval is not merely finding something relevant. It is finding the specific evidence needed to take the next action.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Vector Indexes Struggle with Rapidly Changing Environments
Traditional RAG systems are often built around data that changes relatively slowly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A collection of internal documents might be indexed periodically, then queried thousands of times before the next update.&lt;/p&gt;

&lt;p&gt;Code-editing agents operate under very different conditions.&lt;/p&gt;

&lt;p&gt;An autonomous coding agent might modify eight files, create two new modules, and delete several obsolete functions within a few minutes.&lt;/p&gt;

&lt;p&gt;Every modification changes the information the agent needs to reason about.&lt;/p&gt;

&lt;p&gt;This creates an important architectural question:&lt;/p&gt;

&lt;p&gt;How do you keep the retrieval system synchronized with the actual state of the codebase?&lt;/p&gt;

&lt;p&gt;There are two common options, and both introduce trade-offs.&lt;br&gt;
Option A: Update the vector index immediately&lt;br&gt;
Every file modification potentially triggers a pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect the changed content&lt;/li&gt;
&lt;li&gt;Identify which chunks need to be updated&lt;/li&gt;
&lt;li&gt;Recompute the affected embeddings&lt;/li&gt;
&lt;li&gt;Update the vector database&lt;/li&gt;
&lt;li&gt;Ensure the new index state is available to subsequent queries
Incremental indexing can reduce the overhead, but it does not eliminate the challenges.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A seemingly small code change may affect chunk boundaries, dependencies, or the meaning of surrounding code.&lt;/p&gt;

&lt;p&gt;More importantly, index updates introduce another consistency boundary between the agent and the filesystem.&lt;br&gt;
Option B: Update the index asynchronously&lt;br&gt;
Asynchronous indexing reduces the impact on the agent’s immediate execution time.&lt;/p&gt;

&lt;p&gt;But it creates a different problem: stale retrieval results.&lt;/p&gt;

&lt;p&gt;The agent may modify a function, search for it moments later, and receive an older version from the vector index.&lt;/p&gt;

&lt;p&gt;It may then reason about code that no longer exists.&lt;/p&gt;

&lt;p&gt;In a long-running workflow, this can lead to incorrect edits, repeated failures, or attempts to fix problems the agent has already resolved.&lt;/p&gt;

&lt;p&gt;Filesystem-based search avoids much of this complexity.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
rg -n "process_payment" src/&lt;br&gt;
The command searches the current files directly. Assuming the changes have been saved and the search scope is correct, the results reflect the code currently available on disk.&lt;/p&gt;

&lt;p&gt;There is no separate embedding index waiting to catch up.&lt;/p&gt;

&lt;p&gt;There are still practical concerns — ignored files, unsaved editor buffers, generated code, and repository scope. But the consistency model is far simpler.&lt;br&gt;
For agents that constantly modify their environment, reducing the distance between the source of truth and the retrieval tool is a major reliability advantage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Chunking Can Destroy the Structure That Makes Code Meaningful
One of the most heavily optimized parts of traditional RAG systems is document chunking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Developers experiment with different chunk sizes, overlapping windows, paragraph boundaries, recursive splitting, and model-assisted segmentation.&lt;/p&gt;

&lt;p&gt;These techniques are useful for many kinds of text.&lt;/p&gt;

&lt;p&gt;But source code is not simply a sequence of independent paragraphs.&lt;/p&gt;

&lt;p&gt;Its meaning depends on relationships.&lt;/p&gt;

&lt;p&gt;A method may reference a variable declared hundreds of lines earlier. A class may inherit behavior from another module. A function’s actual purpose may only become clear once its callers and dependencies are examined.&lt;/p&gt;

&lt;p&gt;Consider a large class with the following structure:&lt;br&gt;
payment_service.py&lt;br&gt;
├── Imports&lt;br&gt;
├── Class definition&lt;br&gt;
├── Constructor&lt;br&gt;
├── Configuration&lt;br&gt;
├── Authentication helpers&lt;br&gt;
├── Payment validation&lt;br&gt;
├── Transaction execution&lt;br&gt;
└── Error handling&lt;br&gt;
If this file is split into fixed-length chunks, the error-handling logic could become separated from the function signature, relevant configuration, and surrounding control flow.&lt;/p&gt;

&lt;p&gt;A retrieval system might return the error-handling chunk without showing the code needed to understand it.&lt;/p&gt;

&lt;p&gt;The model receives text that appears useful but lacks the structural context required to reason correctly.&lt;/p&gt;

&lt;p&gt;Various approaches attempt to address this, including parent-child retrieval, hierarchical indexing, syntax-aware chunking, and reranking.&lt;/p&gt;

&lt;p&gt;These can significantly improve retrieval quality.&lt;/p&gt;

&lt;p&gt;However, they also introduce complexity and additional operational overhead.&lt;/p&gt;

&lt;p&gt;A different approach is to let the agent locate the relevant code first, then retrieve the surrounding structure on demand.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
rg -n "validate_token" src/&lt;br&gt;
Once the agent finds the symbol, it can inspect the relevant function, class, or module.&lt;/p&gt;

&lt;p&gt;If necessary, it can follow imports, examine callers, or query the language server for references.&lt;/p&gt;

&lt;p&gt;This is increasingly practical because modern language models can process substantially larger contexts than earlier generations.&lt;/p&gt;

&lt;p&gt;Larger context windows do not guarantee perfect understanding, and reading entire repositories indiscriminately is rarely efficient.&lt;/p&gt;

&lt;p&gt;But they make a more flexible retrieval strategy possible:&lt;br&gt;
Find the exact location first. Expand the context only as much as the task requires.&lt;/p&gt;

&lt;p&gt;Instead of forcing all information through a predefined chunking scheme, the agent can decide what constitutes a meaningful unit of context.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agents Need Exploration, Not Just Retrieval
Traditional RAG applications often follow a straightforward pipeline:
User question
↓
Retrieve relevant documents
↓
Add documents to the prompt
↓
Generate an answer
This works reasonably well for many question-answering tasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But autonomous agents operate through an iterative process:&lt;br&gt;
Observe&lt;br&gt;
   ↓&lt;br&gt;
Form a hypothesis&lt;br&gt;
   ↓&lt;br&gt;
Choose an action&lt;br&gt;
   ↓&lt;br&gt;
Execute a tool&lt;br&gt;
   ↓&lt;br&gt;
Inspect the result&lt;br&gt;
   ↓&lt;br&gt;
Revise the hypothesis&lt;br&gt;
   ↺&lt;br&gt;
The difference is fundamental.&lt;/p&gt;

&lt;p&gt;An agent investigating an unfamiliar codebase rarely knows exactly what to search for at the beginning.&lt;/p&gt;

&lt;p&gt;It needs to explore.&lt;/p&gt;

&lt;p&gt;Suppose the agent starts with:&lt;br&gt;
rg -n "token" .&lt;br&gt;
The command returns 3,000 matches.&lt;/p&gt;

&lt;p&gt;That result is immediately informative: the search term is too broad.&lt;/p&gt;

&lt;p&gt;The agent can narrow the scope:&lt;br&gt;
rg -n "token" src/auth/ -g "*.py"&lt;br&gt;
If that still produces too many results, it can search for a more specific identifier:&lt;br&gt;
rg -n "invalid_token_handler" src/auth/&lt;br&gt;
If there are no results, it can try related identifiers, inspect imported modules, or widen the directory scope.&lt;/p&gt;

&lt;p&gt;Each tool response provides actionable feedback.&lt;/p&gt;

&lt;p&gt;The agent can observe the number of matches, file locations, and error messages, then adjust its next step accordingly.&lt;/p&gt;

&lt;p&gt;Semantic search can also support iterative exploration, especially when systems expose similarity scores, metadata filters, and multiple retrieval strategies.&lt;/p&gt;

&lt;p&gt;However, its feedback is often less transparent.&lt;/p&gt;

&lt;p&gt;When a vector query returns five loosely related documents, it may be difficult for the agent to determine whether the query was poorly phrased, the relevant content was never indexed, or the embedding model simply failed to rank it highly enough.&lt;/p&gt;

&lt;p&gt;Deterministic search tools provide a more explicit relationship between the query and the result.&lt;/p&gt;

&lt;p&gt;That makes them particularly well suited to agents that need to formulate and test hypotheses.&lt;br&gt;
The important capability is not a single perfect retrieval call. It is the ability to search, inspect, learn, and search again.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Best Retrieval Tool Often Depends on the Question
Exact text search is powerful, but it is not sufficient for every engineering task.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Consider three different questions.&lt;br&gt;
Question A: Where does this exact error string appear?&lt;br&gt;
rg -n "invalid_token_handler" .&lt;br&gt;
A text-search tool is an excellent fit.&lt;br&gt;
Question B: Which functions call validate_token()?&lt;br&gt;
A language server or compiler-backed reference lookup is often more reliable than searching for the function name as plain text.&lt;/p&gt;

&lt;p&gt;Text matching may find comments, unrelated methods with the same name, or references that cannot be resolved without understanding the programming language.&lt;br&gt;
Question C: Where is the business logic that prevents expired sessions from being reused?&lt;br&gt;
The relevant implementation might use different terminology entirely.&lt;/p&gt;

&lt;p&gt;It may involve session timestamps, middleware, cache invalidation, or database state.&lt;/p&gt;

&lt;p&gt;In this situation, semantic search can help discover potential entry points, especially when the agent has little knowledge of the repository.&lt;/p&gt;

&lt;p&gt;These examples point toward a broader retrieval architecture:&lt;/p&gt;

&lt;p&gt;Exact strings and error codes → ripgrep, regular expressions&lt;/p&gt;

&lt;p&gt;File paths and configuration names → path matching, glob patterns&lt;/p&gt;

&lt;p&gt;Symbol definitions and references → LSP, compiler indexes&lt;/p&gt;

&lt;p&gt;Code structure and syntax → AST parsers&lt;/p&gt;

&lt;p&gt;Unfamiliar functionality → semantic search, repository exploration&lt;/p&gt;

&lt;p&gt;Cross-document concepts → vector search, hybrid retrieval&lt;/p&gt;

&lt;p&gt;Relationships between components → dependency graphs, call graphs&lt;/p&gt;

&lt;p&gt;The right solution is rarely to force every type of information through a single retrieval mechanism.&lt;/p&gt;

&lt;p&gt;Instead, agents should be able to choose the appropriate tool based on the nature of the question.&lt;/p&gt;

&lt;p&gt;This is especially important for complex engineering tasks, where the agent may move repeatedly between exact lookup, structural analysis, and semantic understanding.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deterministic Tools Make Probabilistic Agents More Reliable
Large language models are probabilistic systems.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even when their outputs appear confident, their reasoning and predictions are not guarantees of factual correctness.&lt;/p&gt;

&lt;p&gt;That uncertainty is manageable when the surrounding system provides reliable ways to verify assumptions.&lt;/p&gt;

&lt;p&gt;Problems arise when every layer introduces additional uncertainty.&lt;/p&gt;

&lt;p&gt;Consider an agent architecture in which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A semantic retrieval system may fail to surface the correct evidence&lt;/li&gt;
&lt;li&gt;The language model may misinterpret the retrieved context&lt;/li&gt;
&lt;li&gt;The agent may then execute an action based on that interpretation
Errors introduced during retrieval can propagate through reasoning and eventually affect the external system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One way to reduce this risk is to make the agent’s observations as verifiable as possible.&lt;/p&gt;

&lt;p&gt;Rather than asking a model to guess whether a function exists, give it a tool that can check.&lt;/p&gt;

&lt;p&gt;Rather than asking it to infer where a method is referenced, give it access to language-server functionality.&lt;/p&gt;

&lt;p&gt;Rather than relying on a potentially stale vector index to understand the current repository, let it inspect the actual files.&lt;/p&gt;

&lt;p&gt;This does not mean the entire system becomes deterministic.&lt;/p&gt;

&lt;p&gt;An agent can still choose the wrong search query, misunderstand a correct result, or make an invalid code change.&lt;/p&gt;

&lt;p&gt;Even compiler-backed tools can be limited by incomplete project configuration or dynamic language features.&lt;/p&gt;

&lt;p&gt;But deterministic tools narrow the scope of uncertainty.&lt;/p&gt;

&lt;p&gt;They give the model concrete observations against which its hypotheses can be evaluated.&lt;/p&gt;

&lt;p&gt;A useful engineering principle emerges:&lt;br&gt;
Use probabilistic intelligence to decide what to investigate, and deterministic mechanisms to establish what is actually true.&lt;/p&gt;

&lt;p&gt;The model should handle interpretation, planning, prioritization, and decision-making.&lt;/p&gt;

&lt;p&gt;Tools should handle exact lookup, state inspection, validation, and execution wherever possible.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bigger Context Windows Change How Agents Should Retrieve Information
Early RAG systems were designed during a period when model context windows were relatively small.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Breaking documents into chunks was often a practical necessity.&lt;/p&gt;

&lt;p&gt;Sending a complete large file, let alone an entire repository, was either impossible or prohibitively expensive.&lt;/p&gt;

&lt;p&gt;That constraint has changed.&lt;/p&gt;

&lt;p&gt;Modern models can handle much longer inputs, making it feasible to inspect complete functions, classes, modules, or substantial portions of a repository when necessary.&lt;/p&gt;

&lt;p&gt;This changes the economics of retrieval.&lt;/p&gt;

&lt;p&gt;Instead of optimizing exclusively for how much information can be compressed into a few retrieved chunks, we can optimize for whether the retrieved material preserves the relationships needed for the task.&lt;/p&gt;

&lt;p&gt;An agent might first use ripgrep to find a suspicious function.&lt;/p&gt;

&lt;p&gt;Then it can read the relevant module.&lt;/p&gt;

&lt;p&gt;Next, it can inspect callers and dependencies.&lt;/p&gt;

&lt;p&gt;Finally, it can examine tests to understand the expected behavior.&lt;/p&gt;

&lt;p&gt;The result is a context assembled through purposeful investigation rather than a single similarity-ranking operation.&lt;/p&gt;

&lt;p&gt;Still, longer context windows are not unlimited memory.&lt;/p&gt;

&lt;p&gt;Large prompts introduce costs, latency, distraction, and the possibility that important details receive insufficient attention.&lt;/p&gt;

&lt;p&gt;For this reason, targeted retrieval remains essential.&lt;/p&gt;

&lt;p&gt;The objective is not to replace small chunks with enormous prompts.&lt;br&gt;
It is to give agents control over which information to retrieve, how much context to expand, and when to stop exploring.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;This Is Not the End of RAG. It Is a More Mature Approach to Retrieval
It would be a mistake to conclude that vector databases have no place in agent systems.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Semantic search remains valuable for many use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprise knowledge discovery&lt;/li&gt;
&lt;li&gt;Research across large document collections&lt;/li&gt;
&lt;li&gt;Questions expressed in unfamiliar terminology&lt;/li&gt;
&lt;li&gt;Finding conceptually related material&lt;/li&gt;
&lt;li&gt;Exploring repositories without knowing exact symbols&lt;/li&gt;
&lt;li&gt;Matching natural-language requirements to possible implementations
These tasks are genuinely difficult to solve using exact text matching alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem begins when vector search is treated as a universal abstraction for accessing knowledge.&lt;/p&gt;

&lt;p&gt;A database containing embeddings does not automatically provide accurate memory, complete understanding, or reliable access to the current state of a system.&lt;/p&gt;

&lt;p&gt;Those capabilities require deliberate system design.&lt;/p&gt;

&lt;p&gt;For a coding agent, that might mean direct filesystem access, language-server integration, syntax-aware tools, and selective semantic retrieval.&lt;/p&gt;

&lt;p&gt;For a business knowledge agent, it might mean combining vector search with SQL, metadata filtering, keyword search, and authoritative source references.&lt;/p&gt;

&lt;p&gt;For an operations agent, it might mean querying live infrastructure APIs and observability systems instead of relying on embedded documentation describing how those systems behaved in the past.&lt;/p&gt;

&lt;p&gt;In each case, the same principle applies:&lt;br&gt;
Retrieve facts from the most authoritative and appropriate source available.&lt;/p&gt;

&lt;p&gt;Semantic search is one tool in that architecture, not the architecture itself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Rediscovery of Unix Philosophy
There is something revealing about the growing importance of tools such as grep, ripgrep, compiler services, and filesystem utilities in modern AI engineering.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These tools are not new.&lt;/p&gt;

&lt;p&gt;Their underlying principles have existed for decades: do one thing well, expose predictable behavior, and compose small tools into larger workflows.&lt;/p&gt;

&lt;p&gt;What has changed is the interface.&lt;/p&gt;

&lt;p&gt;Previously, a human engineer had to know which commands to run and how to interpret their output.&lt;/p&gt;

&lt;p&gt;Now a language model can decide when to use those commands, adapt its queries based on the results, and incorporate what it discovers into a broader plan.&lt;/p&gt;

&lt;p&gt;This creates a powerful combination.&lt;/p&gt;

&lt;p&gt;The model contributes flexible reasoning.&lt;/p&gt;

&lt;p&gt;The tools contribute grounded observations and controlled interactions with the environment.&lt;/p&gt;

&lt;p&gt;Neither needs to replace the other.&lt;/p&gt;

&lt;p&gt;In fact, the more capable the reasoning model becomes, the more useful a collection of simple, reliable tools can be.&lt;/p&gt;

&lt;p&gt;A sophisticated agent does not necessarily require a sophisticated retrieval pipeline at every step.&lt;/p&gt;

&lt;p&gt;Sometimes, the most effective architecture is a model with access to a shell, a language server, a filesystem, and a small number of well-designed APIs.&lt;/p&gt;

&lt;p&gt;That simplicity has tangible benefits: fewer moving parts, easier debugging, lower operational overhead, and clearer failure modes.&lt;br&gt;
Conclusion: Reliability Matters More Than Architectural Novelty&lt;br&gt;
The evolution of AI agents is forcing engineering teams to reconsider an assumption that became popular during the early RAG boom: that more advanced retrieval technology necessarily produces more capable systems.&lt;/p&gt;

&lt;p&gt;It does not.&lt;/p&gt;

&lt;p&gt;Vector retrieval, keyword search, structural code analysis, and direct API access each solve different problems.&lt;/p&gt;

&lt;p&gt;As agents become responsible for longer-running and more consequential workflows, choosing the right information source becomes just as important as improving the model itself.&lt;/p&gt;

&lt;p&gt;For many engineering tasks, the best first step is not to retrieve the five most semantically similar chunks.&lt;/p&gt;

&lt;p&gt;It is to find the exact symbol, inspect the actual file, follow the real dependency, or query the live system.&lt;/p&gt;

&lt;p&gt;Then let the model reason over the evidence.&lt;br&gt;
The deeper lesson is not that grep is better than RAG.&lt;/p&gt;

&lt;p&gt;It is that reliable agents need the freedom to interact with reality directly, rather than being confined to a preprocessed approximation of it.&lt;/p&gt;

&lt;p&gt;The future of agent engineering is unlikely to belong exclusively to either semantic retrieval or deterministic tools.&lt;/p&gt;

&lt;p&gt;It will belong to systems that combine them intelligently — and know when not to use one.&lt;/p&gt;

&lt;p&gt;Sometimes, progress in AI engineering means adding a new capability.&lt;/p&gt;

&lt;p&gt;And sometimes, it means realizing that a decades-old Unix tool was exactly what you needed all along.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Built an Agent Loop by Hand. Then I Tried to Migrate It to the OpenAI Agents SDK</title>
      <dc:creator>John Hu</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:23:25 +0000</pubDate>
      <link>https://dev.to/john_hu_c9e41268b1f330d2e/i-built-an-agent-loop-by-hand-then-i-tried-to-migrate-it-to-the-openai-agents-sdk-36hb</link>
      <guid>https://dev.to/john_hu_c9e41268b1f330d2e/i-built-an-agent-loop-by-hand-then-i-tried-to-migrate-it-to-the-openai-agents-sdk-36hb</guid>
      <description>&lt;p&gt;A few weeks ago I wrote a small Kubernetes operations agent without using any agent framework. It is one Python &lt;code&gt;for&lt;/code&gt; loop that talks to an LLM, calls cluster tools through MCP (Model Context Protocol), and writes a health report.&lt;/p&gt;

&lt;p&gt;Then I read the example in the OpenAI Agents SDK repo. It wraps everything I had written by hand in a few clean abstractions: &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Runner&lt;/code&gt;, tools, tracing. The obvious question followed: should I just migrate?&lt;/p&gt;

&lt;p&gt;I didn’t want to answer that from the docs alone, so I treated it as an engineering evaluation. I listed every behavior my agent has today, checked each one against the SDK source, and wrote a proof-of-concept script to measure the cases I couldn’t settle by reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; the migration is feasible, and it went better than my first estimate. But preserving my guardrails means spreading my loop logic across three different places. Along the way I found a real philosophical difference between my code and the SDK, and that difference is what this post is about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All measurements below are against &lt;code&gt;openai-agents 0.24.0&lt;/code&gt;, &lt;code&gt;openai 3.35.1&lt;/code&gt;, &lt;code&gt;mcp 1.30.0&lt;/code&gt;, and Python 3.13.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The loop I wrote by hand
&lt;/h2&gt;

&lt;p&gt;The core of the agent fits on one screen:&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;step&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_steps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&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;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&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;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&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="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&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;step&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;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&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;message&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;  &lt;span class="c1"&gt;# after evidence checks
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&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="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;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&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="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;messages&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;role&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;tool&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;tool_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;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;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;content&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire idea of an agent: ask the model, run the tools it asks for, feed the results back, repeat until it stops asking.&lt;/p&gt;

&lt;p&gt;The interesting part is everything I had to add after the first version met a real model. Each guardrail exists because the model did something wrong at least once, and my git history reads like a list of failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It answered without investigating.&lt;/strong&gt; So step 1 forces &lt;code&gt;tool_choice="required"&lt;/code&gt;, and if the model still answers without calling any tool, the loop pushes back once.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It called tools that don’t exist.&lt;/strong&gt; My favorite was &lt;code&gt;delete_pod&lt;/code&gt;. The loop replies with a tool message listing the tools that do exist.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It sent arguments of the wrong type.&lt;/strong&gt; Every call is validated against the tool’s JSON Schema before it reaches the MCP server, and the model is told exactly which field to fix.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It called the same tool with the same arguments over and over.&lt;/strong&gt; Duplicates return the cached result plus a nudge, and too many duplicates force a summary.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It invented pod names in the final report.&lt;/strong&gt; Every pod name in the report must appear in some tool result; otherwise the loop pushes back once and names the invented pods.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It never converged.&lt;/strong&gt; After &lt;code&gt;MAX_STEPS&lt;/code&gt;, the loop removes the tools and asks for a summary of what it already has.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these has a test. The tests use a scripted fake LLM, a tiny local HTTP server that plays a misbehaving model, against a real MCP mock server. Six tests, no API key needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Those six tests became the most important artifact in this whole evaluation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning the SDK by mapping it onto my own code
&lt;/h2&gt;

&lt;p&gt;The fastest way I found to learn a framework is to stop reading it as new concepts and read it as a set of names for things I already built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Agent&lt;/code&gt; is my configuration.&lt;/strong&gt; Instructions, tools, model, model settings. It doesn't run anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Runner&lt;/code&gt; is my &lt;code&gt;for&lt;/code&gt; loop.&lt;/strong&gt; &lt;code&gt;Runner.run(agent, input, max_turns=10)&lt;/code&gt; calls the model, executes tool calls, appends results, and stops when the model produces a final answer. &lt;code&gt;max_turns&lt;/code&gt; is my &lt;code&gt;MAX_STEPS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;reset_tool_choice&lt;/code&gt; is my step-1 trick.&lt;/strong&gt; This was the moment the SDK clicked for me. My loop forces &lt;code&gt;tool_choice="required"&lt;/code&gt; on step 1 and relaxes it afterwards. The SDK has a flag for exactly that, and its docstring says it “ensures that the agent doesn't enter an infinite loop of tool usage.” Someone on the SDK team hit the same problem I did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools are my MCP glue.&lt;/strong&gt; &lt;code&gt;@function_tool&lt;/code&gt; builds a JSON Schema from a Python signature. For lower-level control there is a raw &lt;code&gt;FunctionTool&lt;/code&gt;, which receives the model's arguments as a JSON string. MCP servers can also be attached directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing is my &lt;code&gt;_save_trace&lt;/code&gt;, grown up.&lt;/strong&gt; I save each run as a JSONL file of messages. The SDK records a tree of spans, one per LLM call, tool call, guardrail, and handoff, each with timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guardrails and handoffs are things I hadn’t built yet.&lt;/strong&gt; Output guardrails check the final answer. Handoffs let one agent transfer the conversation to another.&lt;/p&gt;

&lt;p&gt;Having built the loop by hand first, almost every SDK parameter answered a question I had already asked myself. I don’t think I would have understood &lt;code&gt;reset_tool_choice&lt;/code&gt; or &lt;code&gt;tool_use_behavior&lt;/code&gt; nearly as quickly otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method: tests as a migration contract
&lt;/h2&gt;

&lt;p&gt;“Can I migrate?” is too vague to answer. “Can the SDK pass my six guardrail tests?” is not.&lt;/p&gt;

&lt;p&gt;So the six tests became the contract, and every behavior in my agent got one of three verdicts: moves cleanly, moves with a redesign, or doesn’t move.&lt;/p&gt;

&lt;p&gt;One practical detail before anything else worked: the SDK uses the OpenAI Responses API by default. My agent supports any OpenAI-compatible endpoint (DeepSeek, Qwen, Ollama), and my fake LLM speaks Chat Completions. The fix is to use the Chat Completions model class explicitly:&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;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncOpenAI&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;OpenAIChatCompletionsModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set_tracing_disabled&lt;/span&gt;

&lt;span class="nf"&gt;set_tracing_disabled&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="c1"&gt;# otherwise traces try to upload to OpenAI
&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAIChatCompletionsModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fake-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;openai_client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AsyncOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fake_llm_url&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;With that in place, I reused the same fake-server pattern from my tests and wrote a PoC script for the four behaviors I couldn’t settle by reading source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;reset_tool_choice&lt;/code&gt; doesn’t send &lt;code&gt;"auto"&lt;/code&gt;. It sends nothing.
&lt;/h3&gt;

&lt;p&gt;I expected request #2 to carry &lt;code&gt;tool_choice: "auto"&lt;/code&gt;. Here is what the fake server actually received:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request 1: tool_choice = "required"
request 2: tool_choice = &amp;lt;field absent&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source resets &lt;code&gt;tool_choice&lt;/code&gt; to &lt;code&gt;None&lt;/code&gt;, and the Chat Completions converter omits a &lt;code&gt;None&lt;/code&gt; value. For OpenAI that is equivalent to &lt;code&gt;auto&lt;/code&gt;. But my test asserted the literal string &lt;code&gt;"auto"&lt;/code&gt;, so it would fail. The behavior matches; the assertion has to change.&lt;/p&gt;

&lt;p&gt;Small, but this is exactly the kind of difference that only shows up when you check the request body.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Invalid JSON arguments: the decorator hides the error from the model
&lt;/h3&gt;

&lt;p&gt;I made the fake model send &lt;code&gt;{not-json&lt;/code&gt; as tool arguments, then tried both ways of defining a tool.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;@function_tool&lt;/code&gt;, my function never ran, and the model received this as the tool result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An error occurred while running the tool. Please try again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s a safe default for production, since it leaks nothing. But it’s useless for self-correction: the model has no idea what it did wrong.&lt;/p&gt;

&lt;p&gt;With a raw &lt;code&gt;FunctionTool&lt;/code&gt;, my handler received the raw string and could return a precise message, identical to what my hand-written loop sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool arguments are not valid JSON: Expecting property name enclosed
in double quotes: line 1 column 2 (char 1); raw arguments: {not-json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This decided the architecture: I would wrap my existing MCP client as raw &lt;code&gt;FunctionTools&lt;/code&gt; instead of using the decorator or the native MCP integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Unknown tools: the default fails hard, and 0.24.0 has an escape hatch
&lt;/h3&gt;

&lt;p&gt;When the fake model called &lt;code&gt;delete_pod&lt;/code&gt;, the default behavior was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ModelBehaviorError: Tool delete_pod not found in agent poc-unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole run aborts. Every &lt;code&gt;AgentsException&lt;/code&gt; carries a &lt;code&gt;run_data&lt;/code&gt; attribute with the run history. In my test the failure happened on the first turn, so there was nothing to recover, but even in principle catching the exception and rebuilding the conversation is clumsy.&lt;/p&gt;

&lt;p&gt;My first draft of this evaluation filed unknown tools under “cannot migrate.” Then I read &lt;code&gt;RunConfig&lt;/code&gt; more carefully and found this:&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;RunConfig&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;whitelist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown tool &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Available tools: list_pods.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="n"&gt;run_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RunConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tool_not_found_behavior&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_error_to_model&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_error_formatter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;whitelist&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;With that config, the model received a normal tool output and the run continued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unknown tool 'delete_pod'. Available tools: list_pods.
Tool 'delete_pod' not found.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is almost exactly my hand-written behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned: the examples show you the happy path; the config classes show you the edge cases.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Forced summaries: a sentinel works better than an exception
&lt;/h3&gt;

&lt;p&gt;My loop has two ways to force a summary: too many duplicate calls, or too many steps. The SDK gives two candidate mechanisms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_turns&lt;/code&gt; raises &lt;code&gt;MaxTurnsExceeded&lt;/code&gt;. Unlike the unknown-tool case, here &lt;code&gt;run_data&lt;/code&gt; carried the full history in my test: four items (two tool calls, two tool outputs) and two raw model responses. So it is recoverable. I also noticed that 0.24.0 has run error handlers for the &lt;code&gt;max_turns&lt;/code&gt; case, which I haven't tested yet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tool_use_behavior&lt;/code&gt; turned out to be cleaner. It is a callback that runs after each round of tool calls and can declare the run finished:&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;ToolsToFinalOutputResult&lt;/span&gt;

&lt;span class="n"&gt;FORCE_SUMMARY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__FORCE_SUMMARY__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop_when_stuck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_results&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ToolsToFinalOutputResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;is_final_output&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="n"&gt;final_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FORCE_SUMMARY&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="nc"&gt;ToolsToFinalOutputResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_final_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my PoC, returning the sentinel ended the run with &lt;code&gt;final_output == "__FORCE_SUMMARY__"&lt;/code&gt;, and &lt;code&gt;result.to_input_list()&lt;/code&gt; still contained the tool call and its output. From there, a forced summary is one more run of a tool-less clone of the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration scorecard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Moves cleanly
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The core loop&lt;/strong&gt;, which becomes &lt;code&gt;Runner.run&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing tools on step 1&lt;/strong&gt;, via &lt;code&gt;tool_choice="required"&lt;/code&gt; plus the default &lt;code&gt;reset_tool_choice&lt;/code&gt; (one test assertion changes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI-compatible providers&lt;/strong&gt;, via &lt;code&gt;OpenAIChatCompletionsModel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging&lt;/strong&gt;, via &lt;code&gt;RunHooks&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown tool feedback&lt;/strong&gt;, via &lt;code&gt;tool_not_found_behavior="return_error_to_model"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Moves with a redesign
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;JSON errors, schema validation, duplicate caching.&lt;/strong&gt; These all move into a &lt;code&gt;FunctionTool&lt;/code&gt; wrapper around my existing MCP client:&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;wrap_mcp_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&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;def&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_args&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_args&lt;/span&gt; &lt;span class="ow"&gt;or&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&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;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;Tool arguments are not valid JSON: &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="n"&gt;errors&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;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid arguments for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &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;errors&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;message&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="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&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="si"&gt;}&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;sig&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[Duplicate call] Result unchanged:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;]&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;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&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="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;params_json_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;on_invoke_tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;strict_json_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# keep the MCP schema untouched
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Forced summaries.&lt;/strong&gt; These move into &lt;code&gt;tool_use_behavior&lt;/code&gt; plus a follow-up run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pushback on lazy answers and invented pods.&lt;/strong&gt; These are the ones that change shape the most, and they deserve their own section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Changes format
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Traces.&lt;/strong&gt; &lt;code&gt;to_input_list()&lt;/code&gt; returns SDK items like &lt;code&gt;function_call&lt;/code&gt; and &lt;code&gt;function_call_output&lt;/code&gt;, not Chat Completions messages, so my existing JSONL replays would need a new format, or a custom trace processor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real difference: feedback vs. exceptions
&lt;/h2&gt;

&lt;p&gt;Here is the pattern that kept coming up.&lt;/p&gt;

&lt;p&gt;My hand-written loop treats almost every model mistake as feedback: tell the model what went wrong, append it to the conversation, let it try again. Wrong arguments, unknown tool, invented pod name — they’re all just more messages.&lt;/p&gt;

&lt;p&gt;The SDK’s defaults treat model mistakes as exceptions: a &lt;code&gt;ModelBehaviorError&lt;/code&gt; for unknown tools, a generic error string for bad arguments, a tripwire exception when an output guardrail fires. The run stops, and the developer decides what happens next.&lt;/p&gt;

&lt;p&gt;Neither is wrong. They are designed for different failure models.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail-fast is the right default for production systems.&lt;/strong&gt; A model calling a tool that doesn’t exist might be a prompt injection, a version mismatch, or a bug, and you probably want to know rather than have the agent quietly improvise. Hiding exception details from the model also avoids leaking internals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-correction is the right choice when you expect the model to be wrong often and cheaply fixable&lt;/strong&gt;, which is the everyday reality with smaller or open-weight models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This difference shows up most clearly in my two pushback guardrails. In my loop, “you answered without investigating” and “these pods never appeared in any tool result” are just user messages appended mid-loop. Inside &lt;code&gt;Runner&lt;/code&gt;, a plain-text answer is the final output; there is no hook to reject it and continue. Output guardrails can detect the problem, but they end the run with an exception rather than giving the model a second chance.&lt;/p&gt;

&lt;p&gt;So pushback moves outside the &lt;code&gt;Runner&lt;/code&gt;, into an outer loop:&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="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;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_turns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;run_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_report&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="c1"&gt;# no tools used? invented pods?
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;retry_input&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_input_list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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;role&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;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;}&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;retry_input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_turns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;run_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_config&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 works. But my single &lt;code&gt;for&lt;/code&gt; loop has now become a tool wrapper, a &lt;code&gt;tool_use_behavior&lt;/code&gt; callback, and an outer loop that has to split the step budget across multiple runs. Each piece is simple; the whole is harder to read in one sitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SDK didn’t make my guardrails impossible. It made me decide, for each one, whether the mistake should be the model’s problem or the developer’s.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My decision
&lt;/h2&gt;

&lt;p&gt;I’m keeping the hand-written loop as the main implementation of this project, because its whole point is to show how an agent works with nothing hidden. Next to it, I’m adding an SDK version built on the design above, run against the same six tests.&lt;/p&gt;

&lt;p&gt;I would reach for the SDK first when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I need multiple agents with handoffs, where the SDK’s abstractions save real work.&lt;/li&gt;
&lt;li&gt;I need tracing I can inspect visually rather than grepping JSONL files.&lt;/li&gt;
&lt;li&gt;A team needs a shared vocabulary for agents instead of everyone’s own loop.&lt;/li&gt;
&lt;li&gt;The deployment is production-facing, where fail-fast defaults are a feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would keep a hand-written loop when the guardrails are the product: when the value lies in exactly how the agent recovers from a bad model output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build it by hand once before adopting the framework.&lt;/strong&gt; Every SDK parameter I understood quickly was one I had needed myself. The abstractions stop being magic and become design decisions you can evaluate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Turn “can we migrate?” into tests.&lt;/strong&gt; My guardrail tests turned a vague judgment call into a list of pass, fail, and needs-redesign verdicts. They also caught a difference (&lt;code&gt;"auto"&lt;/code&gt; vs. absent) that I would never have noticed by reading documentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Measure, and pin the version.&lt;/strong&gt; My first draft said unknown-tool handling couldn’t be migrated. Version 0.24.0 proved me wrong. Agent frameworks are moving fast; any claim about their behavior has an expiry date.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Tested with &lt;code&gt;openai-agents 0.24.0&lt;/code&gt;, &lt;code&gt;openai 3.35.1&lt;/code&gt;, &lt;code&gt;mcp 1.30.0&lt;/code&gt;, and Python 3.13.&lt;/p&gt;

&lt;p&gt;The hand-written agent, its guardrail tests, and the PoC script (&lt;code&gt;poc_sdk_behaviors.py&lt;/code&gt;) are on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/rhuab/agent_loop_demo_for_kubernetes" rel="noopener noreferrer"&gt;View the code: agent_loop_demo_for_kubernetes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>openai</category>
    </item>
    <item>
      <title>How I Stopped an LLM From Skipping the Investigation, Looping Forever, and Inventing Pods — Without LangChain</title>
      <dc:creator>John Hu</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:46:34 +0000</pubDate>
      <link>https://dev.to/john_hu_c9e41268b1f330d2e/how-i-stopped-an-llm-from-skipping-the-investigation-looping-forever-and-inventing-pods-without-dcj</link>
      <guid>https://dev.to/john_hu_c9e41268b1f330d2e/how-i-stopped-an-llm-from-skipping-the-investigation-looping-forever-and-inventing-pods-without-dcj</guid>
      <description>&lt;p&gt;An agent loop is just a for loop. The hard part is the invariants inside it.&lt;/p&gt;

&lt;p&gt;LLMs misbehave in predictable ways: they skip steps, invent tools, loop forever, and fabricate evidence.&lt;br&gt;
Every failure can be caught by a small, deterministic guardrail that replies with feedback instead of crashing.&lt;/p&gt;
&lt;h2&gt;
  
  
  3 a.m., and payment-service is down again
&lt;/h2&gt;

&lt;p&gt;You know the routine. The pager fires, you open a terminal, and you start the ritual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe pod payment-service-6f7b8d9c0-ghi56 &lt;span class="nt"&gt;-n&lt;/span&gt; production
kubectl logs payment-service-6f7b8d9c0-ghi56 &lt;span class="nt"&gt;-n&lt;/span&gt; production &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Four commands, three context switches, and a lot of squinting before you spot the line that matters:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config validation failed: missing required field 'DB_PASSWORD'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The investigation follows a predictable shape, but it still takes a senior engineer’s instincts to run it efficiently. That combination — a repeatable process, with judgment calls at every step — is exactly what LLM agents should be good at.&lt;/p&gt;

&lt;p&gt;So I built one. You ask it a question in plain English:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agent.py &lt;span class="s2"&gt;"Which pods in the production namespace are unhealthy, and why?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It decides which tools to call, digs through several rounds of evidence on its own, and returns a report 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;payment-service-6f7b8d9c0-ghi56 — CrashLoopBackOff, 14 restarts
Evidence: logs show missing required field 'DB_PASSWORD'
Fix: restore the missing Secret entry, then roll out the deployment

inventory-service-59d8f7c6a-mno90 — ImagePullBackOff
Evidence: events show image v0.9.9-beta — manifest unknown
Fix: push the missing image or correct the tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every conclusion cites the tool result it came from.&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%2F7od3xcci07g9m7kued64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7od3xcci07g9m7kued64.png" alt="One question in, a structured health report out — every step logged." width="800" height="873"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One question in, a structured health report out — every step logged.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Getting a demo to produce that output once was easy. Getting it to produce that output &lt;strong&gt;reliably&lt;/strong&gt;, even when the model misbehaves, was the real work. That is what this article is about.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I didn’t use a framework
&lt;/h2&gt;

&lt;p&gt;An agent loop is, at its core, embarrassingly simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send messages + tools to the LLM&lt;/li&gt;
&lt;li&gt;Did the LLM return &lt;code&gt;tool_calls&lt;/code&gt;?

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No&lt;/strong&gt; → that text is the final answer, exit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yes&lt;/strong&gt; → execute each tool, append results as &lt;code&gt;role="tool"&lt;/code&gt;, go to 1&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Still not done after &lt;code&gt;MAX_STEPS&lt;/code&gt; → force a summary&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That fits in a single &lt;code&gt;for&lt;/code&gt; loop. The hard part is not the loop itself.&lt;/p&gt;

&lt;p&gt;The hard part is the &lt;strong&gt;invariants&lt;/strong&gt; that have to hold inside the loop: every &lt;code&gt;tool_call&lt;/code&gt; gets exactly one matching tool message, the loop always terminates, and the model never reports a fact it didn't observe.&lt;/p&gt;

&lt;p&gt;Frameworks hide those invariants behind abstractions. That’s great — until production breaks and you need to know exactly what the model saw at step 4, and why it called &lt;code&gt;list_pods&lt;/code&gt; for the third time.&lt;/p&gt;

&lt;p&gt;So I wrote the loop by hand, using only the low-level &lt;code&gt;openai&lt;/code&gt; and &lt;code&gt;mcp&lt;/code&gt; SDKs. The entire agent fits in about 350 lines you can read in one sitting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture: three small pieces
&lt;/h2&gt;

&lt;p&gt;Three files, three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;agent.py&lt;/code&gt;&lt;/strong&gt; — the loop and all of its guardrails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp_client.py&lt;/code&gt;&lt;/strong&gt; — a thin adapter. It launches the MCP server as a subprocess, converts MCP tool definitions to OpenAI’s format, and flattens tool results into strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mock_k8s_server.py&lt;/code&gt;&lt;/strong&gt; — a FastMCP server simulating a 3-node, 12-pod cluster with planted failures: CrashLoopBackOff, ImagePullBackOff, and two Pending pods caused by resource pressure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key piece of glue is surprisingly small. The LLM only understands OpenAI’s tool format, and the server only speaks MCP:&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;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&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&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="ow"&gt;or&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;parameters&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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# MCP JSON Schema -&amp;gt; OpenAI parameters
&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;Because the tool list is fetched from the server at runtime, switching from the mock to a real cluster is a single line in &lt;code&gt;.env&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;MCP_SERVER_COMMAND=npx -y mcp-server-kubernetes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;agent.py&lt;/code&gt; doesn't change at all.&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%2F45j0k1rm79t1rnuwhy2t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F45j0k1rm79t1rnuwhy2t.png" alt="Architecture: agent.py, mcp_client.py, mock or real Kubernetes MCP server" width="800" height="252"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Seven ways the model misbehaves — and the guardrails that catch them
&lt;/h2&gt;

&lt;p&gt;This is the heart of the project. Every guardrail below exists because a model actually did the thing it prevents.&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%2F4da5r3qr1hvtt9i1bjbg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4da5r3qr1hvtt9i1bjbg.png" alt="Agent loop with deterministic guardrails for tool calls, duplicates, and grounding" width="800" height="802"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Answering without investigating
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; You ask for a health check, and the model confidently replies “All pods look healthy” — without calling a single tool. It has no idea what the cluster looks like. It is pattern-matching on what a health report usually says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Force tool use on the first step only, then let the model decide freely after that:&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;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&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;step&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;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&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;Why only step 1? If you force tools on every step, the model can never stop and write the report. You’ve replaced “answers too early” with “never answers.”&lt;/p&gt;

&lt;p&gt;The second layer: not every OpenAI-compatible provider honors &lt;code&gt;tool_choice&lt;/code&gt;. So if the model still answers with zero tool calls, the harness pushes back:&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;tool_calls_made&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_steps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;user&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;content&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;You have not called any tool yet. Investigate the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster with the provided tools before answering.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; don’t trust a single API parameter to enforce behavior across providers. Enforce it in your own code.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Hallucinating tools
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The model calls a tool that doesn’t exist, such as &lt;code&gt;delete_pod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That case is worth pausing on. A hallucinated tool name is not just a bug. In an ops context, it is a safety issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; A whitelist built from what the server actually exposes. If the model asks for something else, the call is rejected — and the model is told what it can use:&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;name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;known_tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;tool&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;tool_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;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown tool &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Available tools: &lt;/span&gt;&lt;span class="si"&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;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;known_tools&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  3. Malformed arguments
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;get_pod_logs({"name": "x", "tail": "fifty"})&lt;/code&gt;. A required namespace is missing, and &lt;code&gt;tail&lt;/code&gt; is a string instead of an integer.&lt;/p&gt;

&lt;p&gt;You could forward that to the server and let it fail. But that wastes a round trip, and server error messages are often vague.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Pre-flight validation. Every MCP tool already ships with a JSON Schema, so the harness compiles one Draft 2020–12 validator per tool at startup, and checks arguments before the 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="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&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;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;absolute_path&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;detail&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="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="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;map&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;absolute_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;root&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# -&amp;gt; "Invalid arguments for get_pod_logs: &amp;lt;root&amp;gt;: 'namespace' is a
&lt;/span&gt;    &lt;span class="c1"&gt;#     required property; tail: 'fifty' is not of type 'integer'"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The model gets a precise, field-level correction it can act on in the next step.&lt;/p&gt;

&lt;p&gt;One more detail: if a tool’s schema is itself invalid, the harness logs a warning and skips validation for that tool instead of crashing. A guardrail should never become a new single point of failure.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Looping forever
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The model calls &lt;code&gt;list_pods(namespace="all")&lt;/code&gt;, gets the result, and calls &lt;code&gt;list_pods(namespace="all")&lt;/code&gt; again. And again. Each loop burns tokens and moves nothing forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Signature-based duplicate detection. Each call is normalized to &lt;code&gt;tool:json(args, sort_keys=True)&lt;/code&gt;, so &lt;code&gt;{"a": 1, "b": 2}&lt;/code&gt; and &lt;code&gt;{"b": 2, "a": 1}&lt;/code&gt; count as the same call.&lt;/p&gt;

&lt;p&gt;A duplicate is not executed again. The model gets the cached result plus a nudge:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Duplicate call] You already called this tool with identical arguments;
the result has not changed. Base your next step on it, try a different
tool, or write the final report.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;After &lt;code&gt;MAX_REPEATS&lt;/code&gt; duplicates, the harness stops the investigation and forces a summary.&lt;/p&gt;

&lt;p&gt;The protocol trap that bit me: my first instinct was to &lt;code&gt;break&lt;/code&gt; as soon as the repeat limit was hit. That corrupts the conversation.&lt;/p&gt;

&lt;p&gt;An assistant message can contain several &lt;code&gt;tool_calls&lt;/code&gt;, and the API requires every one of them to have a matching &lt;code&gt;role="tool"&lt;/code&gt; message. Break halfway through, and the next request is rejected outright.&lt;/p&gt;

&lt;p&gt;So the harness sets a flag, finishes answering every call in the current round, and only then exits:&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;repeat_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_REPEATS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Do not break mid-round: every tool_call in this assistant
&lt;/span&gt;    &lt;span class="c1"&gt;# message still needs a matching tool message, or the next
&lt;/span&gt;    &lt;span class="c1"&gt;# request would be rejected.
&lt;/span&gt;    &lt;span class="n"&gt;force_summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the kind of invariant a framework hides from you — until it doesn’t.&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%2Fdy0bzoxtjw2duoeo0bg7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdy0bzoxtjw2duoeo0bg7.png" alt="Break mid-round and the next request fails with a 400. Finish the round, then exit." width="800" height="839"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Break mid-round and the next request fails with a 400. Finish the round, then exit.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Inventing evidence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The report is well written and confident — and it mentions &lt;code&gt;ghost-app-12345-abcde&lt;/code&gt;, a pod that does not exist.&lt;/p&gt;

&lt;p&gt;This is the most dangerous failure, because it looks correct. An on-call engineer may spend 20 minutes chasing a ghost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; A grounding check. Before accepting a final answer, the harness extracts every pod-like name from the report and verifies that it appeared in at least one tool result:&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;POD_NAME_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b[a-z0-9]+(?:-[a-z0-9]+){2,}\b&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;_invented_pods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&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;evidence&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;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="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="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;POD_NAME_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&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;n&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;evidence&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 any are found, the model is told exactly which names to remove or correct.&lt;/p&gt;

&lt;p&gt;The trade-offs, stated honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The regex requires three or more hyphen-separated segments, matching Deployment-style names like &lt;code&gt;payment-service-6f7b8d9c0-ghi56&lt;/code&gt;. That deliberately skips &lt;code&gt;node-03&lt;/code&gt; and &lt;code&gt;kube-system&lt;/code&gt; to avoid false positives — which means node names aren't checked.&lt;/li&gt;
&lt;li&gt;It only verifies names, not claims. “payment-service has 99 restarts” would pass.&lt;/li&gt;
&lt;li&gt;The pushback happens at most once. A second wrong answer is accepted, because an unbounded correction loop is just a slower infinite loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s a narrow check. But it targets the highest-cost hallucination: sending a human after something that doesn’t exist.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Never converging
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The model keeps finding “one more thing” to check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; A hard &lt;code&gt;MAX_STEPS&lt;/code&gt; budget (10 by default). When it runs out, the harness appends a final instruction and makes one more call with no &lt;code&gt;tools&lt;/code&gt; parameter at all:&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;resp&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;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&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;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The model physically can’t call another tool. It has to summarize what it has already gathered. The loop is guaranteed to terminate.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. Tool failures crashing the run
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The MCP subprocess hiccups, the arguments aren’t valid JSON, or the server returns &lt;code&gt;isError&lt;/code&gt;. Naive code raises an exception, and the whole investigation is lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; None of these raise. Each one becomes a tool result string sent back to the model:&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;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;Tool call failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The model can then decide to retry, pick another tool, or explain the gap in its report. A human engineer does the same thing when &lt;code&gt;kubectl logs&lt;/code&gt; fails: you note it, and move on.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principles underneath
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The LLM proposes, the harness disposes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Step back from the individual fixes, and a few principles emerge. I think they apply to almost any production agent.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. The LLM proposes, the harness disposes
&lt;/h3&gt;

&lt;p&gt;The model is a reasoning engine, not an executor. Every action it proposes passes through deterministic checks — whitelist, schema, deduplication, grounding — before it touches anything real.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Every guardrail is a feedback message, not an exception
&lt;/h3&gt;

&lt;p&gt;None of the guards above crash the run. Each one rejects the action and tells the model how to fix it. Errors become observations, and observations are what the model reasons over. This turns a fragile pipeline into a self-correcting one.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Bound every correction
&lt;/h3&gt;

&lt;p&gt;Every pushback has a budget: one evidence pushback, &lt;code&gt;MAX_REPEATS&lt;/code&gt; duplicates, &lt;code&gt;MAX_STEPS&lt;/code&gt; steps, and a final tool-less summary. Self-correction without a bound is a liability. With a bound, termination is guaranteed by construction — not by hoping the model behaves.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Tool descriptions are prompts
&lt;/h3&gt;

&lt;p&gt;This one is easy to overlook. The docstrings on the MCP tools steer the model’s strategy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The response already includes this pod’s events, so do not call &lt;code&gt;get_events&lt;/code&gt; for the same pod; use &lt;code&gt;get_pod_logs&lt;/code&gt; next if it is CrashLoopBackOff.&lt;/p&gt;

&lt;p&gt;Do not use this for ImagePullBackOff or Pending pods: their containers never started, so no logs exist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those two sentences teach the model to skip wasted calls. If you own the MCP server, the tool documentation is your highest-leverage prompt.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Observability is not optional
&lt;/h3&gt;

&lt;p&gt;Every run writes the complete conversation — system prompt, every tool call, every result, the final answer — to a JSONL trace. When the agent reaches a strange conclusion, you replay exactly what it saw. Trace-writing itself never raises, because diagnostics must not break the thing they’re diagnosing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Testing a non-deterministic system deterministically
&lt;/h2&gt;

&lt;p&gt;How do you write reliable tests for something built on top of an unpredictable LLM?&lt;/p&gt;

&lt;p&gt;You don’t test the model. You test the harness against a model that misbehaves on purpose.&lt;/p&gt;

&lt;p&gt;The test suite spins up a real local HTTP server that impersonates an OpenAI-compatible API. Each test scripts a “bad model,” response by response. The MCP side stays fully real: calls go through the actual client to the actual mock server.&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%2Fwe9upvdjt5e7iy7e2vvn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwe9upvdjt5e7iy7e2vvn.png" alt="A scripted fake LLM over real HTTP, a real agent loop, and a real MCP server — no API key required." width="800" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A scripted fake LLM over real HTTP, a real agent loop, and a real MCP server — no API key required.&lt;/em&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;def&lt;/span&gt; &lt;span class="nf"&gt;test_unknown_tool_is_rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake_llm&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;script&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&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;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nf"&gt;_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c1&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;delete_pod&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&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;joined&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;_tool_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown tool &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delete_pod&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;joined&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list_pods&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;joined&lt;/span&gt;  &lt;span class="c1"&gt;# the whitelist is offered back
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_completion&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;That tool does not exist; sticking to the whitelist.&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;There are six tests, one per failure mode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;forced first-step tool use&lt;/li&gt;
&lt;li&gt;pushback on a zero-tool answer&lt;/li&gt;
&lt;li&gt;schema rejection&lt;/li&gt;
&lt;li&gt;duplicate-driven forced summary&lt;/li&gt;
&lt;li&gt;invented-pod pushback&lt;/li&gt;
&lt;li&gt;unknown-tool rejection&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of them needs an API key, and all of them run in CI in seconds.&lt;/p&gt;

&lt;p&gt;This is the part I’d most encourage other agent builders to copy: &lt;strong&gt;each guardrail should have a test that proves it fires.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters beyond a demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Faster incident response.&lt;/strong&gt; A four-command investigation with manual correlation becomes one question. The agent follows the same path a senior SRE would: overview first, then the unhealthy pods, then logs or events depending on the failure type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reports you can trust and audit.&lt;/strong&gt; Every conclusion cites its source tool, the grounding check filters invented resources, and the full trace is on disk. When the report says “missing &lt;code&gt;DB_PASSWORD&lt;/code&gt;”, you can see the exact log line it read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe by default.&lt;/strong&gt; The agent only has read-only tools, and the whitelist means it cannot call an action it wasn’t given — however confidently it hallucinates one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No model lock-in.&lt;/strong&gt; Any OpenAI-compatible endpoint works: OpenAI, DeepSeek, Qwen, or a local Ollama model for teams that can’t send cluster data to a third party. No data leaves your network unless you want it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small enough to own.&lt;/strong&gt; About 350 lines of agent code and no framework. Your team can read it in an afternoon, audit it before a security review, and change it without fighting abstractions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pattern, not just a tool.&lt;/strong&gt; Nothing in the guardrail layer is specific to Kubernetes. Swap the MCP server, and the same loop can diagnose databases, CI pipelines, or cloud billing anomalies. Any read-only investigation over structured tools fits.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it doesn’t do (yet)
&lt;/h2&gt;

&lt;p&gt;Being honest about limits is part of building something people can trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The grounding check is narrow.&lt;/strong&gt; It verifies pod names, not numbers, node names, or causal claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cluster is simulated by default.&lt;/strong&gt; The mock makes the demo reproducible. A real cluster is one config line away, but expect messier data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context grows with every step.&lt;/strong&gt; For long investigations, history compression or summarization would be needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It’s read-only.&lt;/strong&gt; Remediation actions like restarts or rollbacks would require human-in-the-loop approval, and I deliberately haven’t built that yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tool list is simple.&lt;/strong&gt; For example, if one container starts but fails to pass the health check (the process would be still running), the agent may not be able to detect it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next on the roadmap: schema-validated structured reports, an offline evaluation set built from saved traces, and approval-gated write actions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Back to 3 a.m.
&lt;/h2&gt;

&lt;p&gt;The pager still fires. But now the first thing you run is a single question — and in the time it takes to open your laptop, you have a report that names the broken pods, explains why each is broken, cites the evidence, and suggests a fix.&lt;/p&gt;

&lt;p&gt;The LLM does the reasoning. A few hundred lines of deterministic code keep it honest.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/rhuab" rel="noopener noreferrer"&gt;
        rhuab
      &lt;/a&gt; / &lt;a href="https://github.com/rhuab/agent_loop_demo_for_kubernetes" rel="noopener noreferrer"&gt;
        agent_loop_demo_for_kubernetes
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;agent_loop_demo_for_kubernetes&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagnosing unhealthy Kubernetes Pods usually means manually chaining &lt;code&gt;kubectl get pods&lt;/code&gt;, &lt;code&gt;describe&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, and &lt;code&gt;get events&lt;/code&gt; until the root cause shows up. This project automates that investigation with a hand-written Agent Loop — no agent framework, just the low-level &lt;code&gt;openai&lt;/code&gt; and &lt;code&gt;mcp&lt;/code&gt; SDKs — so an LLM calls Kubernetes tools over MCP, digs in over multiple rounds on its own, and produces a structured cluster health report.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;📦 Core Features&lt;/h2&gt;
&lt;/div&gt;


&lt;ul&gt;

&lt;li&gt;

&lt;strong&gt;Hand-written Agent Loop, zero framework&lt;/strong&gt; — no LangChain / AutoGen. The core loop in &lt;code&gt;run_agent()&lt;/code&gt; (&lt;code&gt;agent.py&lt;/code&gt;) is a single &lt;code&gt;for&lt;/code&gt; loop: send &lt;code&gt;messages&lt;/code&gt; + tools to the LLM, execute any &lt;code&gt;tool_calls&lt;/code&gt; via MCP, append results as &lt;code&gt;role="tool"&lt;/code&gt; messages, and repeat until the model answers in plain text.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Standard MCP tool calling&lt;/strong&gt; — &lt;code&gt;mcp_client.py&lt;/code&gt; launches the MCP Server as a stdio subprocess, converts MCP tool definitions into OpenAI's &lt;code&gt;tools&lt;/code&gt; format (&lt;code&gt;inputSchema&lt;/code&gt; → &lt;code&gt;parameters&lt;/code&gt;)…&lt;/li&gt;

&lt;/ul&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/rhuab/agent_loop_demo_for_kubernetes" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;Clone it, run &lt;code&gt;python -m pytest&lt;/code&gt; with no API key, then point it at your own cluster. If it catches something interesting — or misbehaves in a way my guardrails don't cover — I'd love to hear about it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
