<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged agent-harness</title>
<link>https://coderlegion.com/tag/agent-harness</link>
<description>Powered by Question2Answer</description>
<item>
<title>Build a Python agent harness you can extend and test without calling a model</title>
<link>https://coderlegion.com/31972/build-a-python-agent-harness-you-can-extend-and-test-without-calling-a-model</link>
<description>&lt;p&gt;A team ships a small Python agent with two tools. A month later someone renames an argument on one tool function and forgets the hand-written JSON schema sitting a few lines above it. The model keeps sending the old argument name, the function raises on every call, and nobody finds out until a real task hits that tool, because the only way anyone ever tested the loop was by running it against a live model.&lt;/p&gt;
&lt;p&gt;The layout caused this one. When tool contracts are written by hand and the loop only runs end to end, every change to a tool risks the whole agent, and every test costs an API call. A harness you can live with keeps three things apart: the loop, the tool contracts, and the tests that prove the loop behaves.&lt;/p&gt;
&lt;h4 id=&quot;the-loop-has-three-parts-and-only-one-of-them-should-change-when-you-add-a-tool&quot;&gt;The loop has three parts, and only one of them should change when you add a tool&lt;/h4&gt;
&lt;p&gt;A minimal agent harness in Python has a loop over the model's &lt;code&gt;stop_reason&lt;/code&gt;, a dispatcher that maps a tool name to a Python function, and a &lt;code&gt;messages&lt;/code&gt; list that grows every turn. The loop keeps running while the model asks for tools (&lt;code&gt;stop_reason == &quot;tool_use&quot;&lt;/code&gt;) and returns the text answer when it sends &lt;code&gt;end_turn&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Of those three, only the set of tools should grow as the agent gains abilities. The loop itself should not change when you add a tool, and the message list should follow the same rules on every turn. A production harness wraps more around this core, such as sandboxing, permission checks, context compaction and session state. Those layers are easier to add later when the core stays this small.&lt;/p&gt;
&lt;h4 id=&quot;generate-the-schema-from-the-function-s-input-type&quot;&gt;Generate the schema from the function's input type&lt;/h4&gt;
&lt;p&gt;Every tool needs a name, a description and an &lt;code&gt;input_schema&lt;/code&gt; that tells the model which arguments it accepts. Writing that JSON Schema by hand is how the opening bug happens. The schema and the function live in two places, and only one of them gets edited.&lt;/p&gt;
&lt;p&gt;Describe each tool's inputs with a Pydantic model and generate the schema from it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One model per tool.&lt;/strong&gt; A &lt;code&gt;ReadFile&lt;/code&gt; model with a single &lt;code&gt;path: str&lt;/code&gt; field describes the input of &lt;code&gt;read_file&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let the library write the schema.&lt;/strong&gt; &lt;code&gt;ReadFile.model_json_schema()&lt;/code&gt; returns a plain dict with &lt;code&gt;path&lt;/code&gt; as a required string, which is the &lt;code&gt;input_schema&lt;/code&gt; shape the Anthropic API validates against.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edit the type and the schema follows.&lt;/strong&gt; Add a field or mark one optional and the generated schema updates itself. This method ships with Pydantic v2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Pydantic model becomes the single source of truth for a tool's inputs, and the function's arguments have to match its fields. The dispatch fixture in the testing section below fails the moment the two drift apart, long before a real task finds it.&lt;/p&gt;
&lt;h4 id=&quot;dispatch-by-name-so-the-loop-never-names-a-tool&quot;&gt;Dispatch by name so the loop never names a tool&lt;/h4&gt;
&lt;p&gt;Keep a dict called &lt;code&gt;HANDLERS&lt;/code&gt; that maps each tool name to its function, and dispatch with one line: &lt;code&gt;HANDLERS[block.name](**block.input)&lt;/code&gt;. The loop reads the tool name from the model's &lt;code&gt;tool_use&lt;/code&gt; block and never refers to a specific tool.&lt;/p&gt;
&lt;p&gt;That one decision is what makes the harness extendable. Adding a tool touches three places, and none of them is the loop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Define the input model.&lt;/strong&gt; A &lt;code&gt;WriteFile&lt;/code&gt; model with &lt;code&gt;path&lt;/code&gt; and &lt;code&gt;content&lt;/code&gt; fields.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add one entry to the tool list.&lt;/strong&gt; Name, description, and &lt;code&gt;WriteFile.model_json_schema()&lt;/code&gt; as the schema.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Register the function.&lt;/strong&gt; &lt;code&gt;HANDLERS[&quot;write_file&quot;] = write_file&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The agent can now write files as well as read them, and &lt;code&gt;run_agent&lt;/code&gt; did not change by a single character. If adding a tool ever forces an edit inside the loop, a tool name has leaked into it.&lt;/p&gt;
&lt;h4 id=&quot;treat-what-comes-back-as-part-of-the-contract&quot;&gt;Treat what comes back as part of the contract&lt;/h4&gt;
&lt;p&gt;The input side is only half of a tool's contract. The output has rules too. The &lt;code&gt;content&lt;/code&gt; of a &lt;code&gt;tool_result&lt;/code&gt; must be a string or a list of content blocks. A &lt;code&gt;read_file&lt;/code&gt; that returns a string passes straight through, while a &lt;code&gt;list_files&lt;/code&gt; that returns a Python list has to be serialized first, for example with &lt;code&gt;json.dumps&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Put that rule in the loop once, so no single tool has to remember it. The same goes for pairing: copy the &lt;code&gt;id&lt;/code&gt; from each &lt;code&gt;tool_use&lt;/code&gt; block into the result's &lt;code&gt;tool_use_id&lt;/code&gt;, and append the assistant message before the user message that carries the results. These are loop rules, and they belong next to the dispatch line, where every tool gets them for free.&lt;/p&gt;
&lt;h4 id=&quot;test-the-loop-without-calling-a-model&quot;&gt;Test the loop without calling a model&lt;/h4&gt;
&lt;p&gt;Testing a harness by running it against a live model and reading the output costs money, takes seconds per run, and only covers the path the model happened to take that time.&lt;/p&gt;
&lt;p&gt;The loop is ordinary Python, so test it like ordinary Python. Mock the &lt;code&gt;messages.create&lt;/code&gt; call to return fixtures, and assert on what the loop does with them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A tool-use fixture drives one turn.&lt;/strong&gt; Return a response with &lt;code&gt;stop_reason&lt;/code&gt; set to &lt;code&gt;tool_use&lt;/code&gt; and a fake &lt;code&gt;tool_use&lt;/code&gt; block, then check that the matching handler ran with the arguments in the block.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The append order holds.&lt;/strong&gt; After that turn, the assistant message should sit before the user message that carries the result, and the result's &lt;code&gt;tool_use_id&lt;/code&gt; should match the block's &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallel calls arrive together.&lt;/strong&gt; Return a fixture with two &lt;code&gt;tool_use&lt;/code&gt; blocks and assert that both results go back in a single user message before the next call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-string results get serialized.&lt;/strong&gt; Point a fake tool at a list return and check that the &lt;code&gt;content&lt;/code&gt; the loop sends back is a string.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The loop exits on &lt;code&gt;end_turn&lt;/code&gt;.&lt;/strong&gt; Follow the tool turn with an &lt;code&gt;end_turn&lt;/code&gt; fixture and check that &lt;code&gt;run_agent&lt;/code&gt; returns its text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ceiling fires.&lt;/strong&gt; Feed &lt;code&gt;tool_use&lt;/code&gt; fixtures forever and check that the loop raises once it reaches &lt;code&gt;max_turns&lt;/code&gt;, instead of spinning.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One detail matters when you read the mock's recorded calls: &lt;code&gt;run_agent&lt;/code&gt; keeps appending to the same &lt;code&gt;messages&lt;/code&gt; list, so copy the list inside the mock if you want to assert on what each call saw. These tests run in milliseconds and never reach the API, and they cover the rules that break a harness without anyone noticing. Run them on every change, and keep the live-model run for checking behavior that only a model can show.&lt;/p&gt;
&lt;h4 id=&quot;know-what-a-framework-would-take-over&quot;&gt;Know what a framework would take over&lt;/h4&gt;
&lt;p&gt;The OpenAI Agents SDK runs the same loop for you through &lt;code&gt;Runner.run&lt;/code&gt;. You describe tools as Python functions with type annotations, and the SDK raises &lt;code&gt;MaxTurnsExceeded&lt;/code&gt; when a run passes its &lt;code&gt;max_turns&lt;/code&gt;, which is the same safety valve as the manual counter.&lt;/p&gt;
&lt;p&gt;Reach for the SDK when you need multi-agent handoffs, built-in guardrails or tracing. Keep the manual loop when you want zero framework dependency or need to see exactly what happens on every turn. OpenAI Swarm is an experimental, stateless project, and the Agents SDK has superseded it for anything you plan to run in production.&lt;/p&gt;
&lt;p&gt;Either way, the design rule carries over. Tool definitions stay separate from loop logic, and switching to a framework later holds no surprises in the control flow.&lt;/p&gt;
&lt;h4 id=&quot;pitfalls-that-keep-showing-up&quot;&gt;Pitfalls that keep showing up&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hand-written schema dicts.&lt;/strong&gt; They drift from the function the first time someone edits a signature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool names inside the loop.&lt;/strong&gt; Every new tool turns into a change to the control flow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Raw Python objects in &lt;code&gt;tool_result&lt;/code&gt;.&lt;/strong&gt; The API expects a string or content blocks, so a returned list fails validation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A missing &lt;code&gt;max_tokens&lt;/code&gt;.&lt;/strong&gt; It is required on every Anthropic request and has no default, so the SDK raises before the model ever runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live-model runs as the only test.&lt;/strong&gt; They are slow, they cost money, and they never force the edge cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full program, with the tool definitions, the loop and the stop conditions in one file you can run, is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://futureagi.com/blog/build-agent-harness-python/?utm_source=fagiCoderlegion&amp;amp;utm_medium=organic&amp;amp;utm_campaign=blogdistribution&quot;&gt;build guide with the full code&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A loop that never names a tool, schemas generated from types and tests that need no model call together keep a harness small. Every new tool after that is a few lines next to the others.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31972/build-a-python-agent-harness-you-can-extend-and-test-without-calling-a-model</guid>
<pubDate>Wed, 21 Oct 2026 12:50:26 +0000</pubDate>
</item>
</channel>
</rss>