Home / Articles

The Naked Agent: What Breaks When Nothing Guards the Model

Build the same unprotected travel agent in the Claude Agent SDK and LangChain Deep Agents, then watch three failures show which harness controls are missing.

Publisher: REACTAPP.TOP9 min read2152 words

This article is published in English.

Sources: Harness Engineering: The Naked Agent: Why Your Framework Hands You a Loop, Not a Harness — I

A customer asks an agent to rebook a flight. The model sounds sure of itself, the tool call succeeds, and the reply says everything is handled. Then somebody notices the date was March 32nd.

That's the instant the illusion cracks. Intelligence wasn't what the model lacked. What failed was everything surrounding it, since no component knew when to push back. What follows starts with a naked agent: no validators, no memory, no recovery, no safety net. Seeing it fail exposes where production agents go wrong, and it argues that the runtime around a language model counts for more than the model. The same stripped-down agent gets built twice, with the Claude Agent SDK and with LangChain Deep Agents, and then it breaks in three concrete ways, each pointing at a control that a real agent must provide. The payoff is a method for telling a model problem from a harness problem.

The March 32nd incident

Picture a travel-booking agent receiving the text "March 32nd" from a real user. The model passed that string directly to the flights API. Around forty minutes and fifty dollars of model calls afterward, a person was left explaining a nonexistent booking to a customer who'd been assured everything was settled.

The model fabricated nothing. It sent along precisely what it was given, because the harness had no view on whether it should. That sums up the case. Intelligence comes from the model and discipline from the harness, so when a production agent misbehaves, the fault nearly always lies in the discipline layer. Swapping in a stronger model or polishing the prompt won't supply a control that isn't there, and building controls starts with feeling what their absence costs. That's why the starting point is bare: one fragile agent, two frameworks, no harness.

Evidence that the runtime dominates

Benchmark data supports the point. Researchers at Princeton froze the model at GPT-4 Turbo and altered only the interface code linking it to the codebase. The share of SWE-bench issues resolved rose from 3.8% to 12.47%, about triple, while the weights stayed untouched. The work, titled "SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering," came out in 2024 and was accepted at NeurIPS that year. Interface design drove the gain and intelligence didn't.

The underlying discipline has an older label, mechanical sympathy: writing software that works with the hardware or platform beneath it rather than against it. Harness engineering extends that thinking to a fresh substrate, the language model, with its context window and attention budget. A ceiling is set by the model, and the harness decides how consistently the system gets near it.

The shift in thinking changes what you do after a failure:

  • Before: after a wrong answer, adjust the prompt, pile on caveats, try a larger model and hope
  • After: after a wrong answer, identify which control was missing and shift that rule out of the prompt and into the runtime

Prompts persuade, and harnesses enforce.

The loop under every framework

All agent frameworks sit on one five-step loop. Offer the tools to the model, get a tool call back, execute it, return the result and receive the next move. Anthropic's tool-use docs, OpenAI's Responses API and LangChain's agent runtime reduce to the same thing. SDKs come and go while the shape stays.

The bare version uses a raw provider SDK, two travel tools and no safety net whatsoever:

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
TOOLS = [
    {"name": "search_flights",
     "description": "Search flights between two cities for a date.",
     "input_schema": {"type": "object", "properties": {
         "origin": {"type": "string"}, "destination": {"type": "string"},
         "date": {"type": "string", "description": "YYYY-MM-DD"}},
         "required": ["origin", "destination", "date"]}},
    {"name": "book_flight",
     "description": "Book a specific flight.",
     "input_schema": {"type": "object", "properties": {
         "flight_id": {"type": "string"}, "passenger_name": {"type": "string"}},
         "required": ["flight_id", "passenger_name"]}},
]
def run_naked(user_msg: str) -> str:
    messages = [{"role": "user", "content": user_msg}]
    while True:                                   # ① no iteration cap
        resp = client.messages.create(
            model="claude-sonnet-4-6", max_tokens=1024,
            tools=TOOLS, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return resp.content[0].text
        call = next(b for b in resp.content if b.type == "tool_use")
        result = dispatch(call.name, call.input)
                                             # ② direct side effect, no check
        messages.extend([
                                             # ③ whole history, every turn
            {"role": "assistant", "content": resp.content},
            {"role": "user", "content": [{"type": "tool_result",
                "tool_use_id": call.id, "content": result}]},
        ])

Read it with three failure seams in mind:

  1. The while True: loop is uncapped. A broken plan keeps invoking the model until the budget is gone, because nothing counts iterations.
  2. dispatch passes model-supplied arguments directly to a side effect without any check beforehand, so a value that parses fine but can't exist reaches the live API.
  3. The full message history is extended and resent every turn, silently inflating context until quality declines.

None of the safety properties you'd want live inside this loop. They sit in code that hasn't been written. Adopting a framework doesn't close the seams either. The Claude Agent SDK and Deep Agents offer a better-constructed loop, with a proper tool protocol and a tidy execution path, but out of the box they ship no validators, no memory you can reason about and no recovery. A loop is what arrives, and the harness remains the developer's job.

Here is the same naked agent in each framework.

The Claude Agent SDK version

The SDK wraps the loop for you. Tools are declared with the @tool decorator, exposed as an in-process MCP server, and run through query(). What's absent is any control: arguments get checked only against the schema, nothing is remembered across calls, and there's no recovery.

import asyncio
from claude_agent_sdk import (
    query, ClaudeAgentOptions, tool,
    create_sdk_mcp_server, AssistantMessage, ResultMessage,
)

@tool("search_flights", "Search flights between two cities for a date.",
      {"origin": str, "destination": str, "date": str})
async def search_flights(args):
                                              # ① no check that date exists
    hits = flights_api.search(**args)
    return {"content": [{"type": "text", "text": str(hits)}]}
@tool("book_flight", "Book a specific flight.",
      {"flight_id": str, "passenger_name": str})
async def book_flight(args):
                                               # ② destructive, ungated
    confirmation = flights_api.book(**args)
    return {"content": [{"type": "text", "text": confirmation}]}
server = create_sdk_mcp_server("travel", tools=[search_flights, book_flight])
async def main():
    options = ClaudeAgentOptions(
        mcp_servers={"travel": server},
        allowed_tools=["mcp__travel__search_flights",
                       "mcp__travel__book_flight"],
    )
    async for msg in query(prompt="Rebook this customer for March 32nd.",
                           options=options):
        if isinstance(msg, AssistantMessage):
            for b in msg.content:
                if hasattr(b, "text"):
                    print(b.text)
        elif isinstance(msg, ResultMessage):
            print("done:", msg.subtype)
                                              # ③ no state survives this run

Three things stand out:

  1. search_flights takes anything the model proposes that fits the schema, so a date that can't exist flows into the tool body.
  2. book_flight is destructive and nothing guards it. The decorator wires it into the loop with no approval gate or validator.
  3. After the ResultMessage arrives and query() returns, the session is over, and nothing from that run survives into the next call.

The SDK delivered a clean loop. It has no view on bad dates, double charges or what deserves remembering.

The Deep Agents version

Deep Agents wraps the loop differently. Plain LangChain tools go into create_deep_agent(), which returns a compiled graph to invoke. The seams are the same: there's no validator, and since no checkpointer is supplied, the graph forgets everything between calls.

from langchain.tools import tool
from deepagents import create_deep_agent

@tool
def search_flights(origin: str, destination: str, date: str) -> str:
    """Search flights between two cities for a date (YYYY-MM-DD)."""
    return str(flights_api.search(origin, destination, date))
                                                # ① no date check
@tool
def book_flight(flight_id: str, passenger_name: str) -> str:
    """Book a specific flight."""
    return flights_api.book(flight_id, passenger_name)
                                                 # ② ungated side effect
agent = create_deep_agent(
                                                 # ③ the loop, no controls
    model="anthropic:claude-sonnet-4-6",
    tools=[search_flights, book_flight],
)
result = agent.invoke({"messages": [{"role": "user",
    "content": "Rebook this customer for March 32nd."}]})
print(result["messages"][-1].content)
# Ask a follow-up in a second invoke, and it starts from zero: no thread,
# no memory.

Again three points:

  1. The body of search_flights calls the API with the raw date string, and nothing confirms the date is real before the request goes out.
  2. book_flight performs the destructive booking the moment the model picks it, with no gate between proposal and side effect.
  3. create_deep_agent gives back a graph that runs the loop and little more. With no checkpointer, there's no thread and no memory between invocations.

Three ways a naked agent breaks

Two frameworks gave two clean loops and zero discipline, which is what naked looks like in 2026. It isn't a strawman either. It's the shape of dozens of agent demos pushed to GitHub weekly. Under real traffic it fails three ways, and each failure maps to a control a proper harness adds.

Impossible input. Given "March 32nd," the model does what models do and fills the date field with a string that's well formed yet meaningless. The schema only confirms that date is a string, and it can't tell whether the day exists. book_flight then fires against a phantom itinerary, and the model announces success.

The call is book_flight with a made-up flight ID and a passenger name, and it returns "Booked." The action fired, and nothing in the loop asked whether it should.

book_flight(flight_id=”AC-PHANTOM”, passenger_name=”J. Moffatt”)
# -> “Booked.” The action fired. Nothing in the loop asked whether it should.

The failure came from a missing action boundary, with no validator standing between what the model proposed and what actually happened. Filling that gap is the work of tool contracts and validators.

Bloated context. The naked loop resends the complete history on every turn. A long rebooking exchange full of search results, policy text and retries can pass fifty thousand tokens, and nothing prunes it. What the literature calls context rot sets in, and the agent drops steps it performed cleanly earlier in the same run. It raises no error. The agent just gets worse, quietly.

Here the missing pieces were working memory and context assembly, which fall under memory and durable state and under context assembly.

Smoothed-over errors. A tool comes back with an error payload, and the model, more often than anyone expects, writes a courteous summary that skips past the failure. One field analysis of production incidents described agents as confident liars: a database that returns zero rows is taken as a factual answer instead of a miss. Choosing the correct tool was never the tough part. Distinguishing a tool that truly worked from one that convincingly imitated working is. No feedback loop observed outcomes here, and supplying one belongs to observability and drift detection.

Treat these as a diagnostic pattern instead of a list of embarrassments. In all three cases the model did what models do, and the surrounding system failed to catch it. None involves a model that wasn't smart enough.

A working loop for harness engineering

A four-step move recurs when building any control:

  1. Introduce the harness function and the failure it controls.
  2. Implement it in both frameworks as a focused snippet.
  3. Trigger the failure to prove the control carries load.
  4. Inspect how the harness responds.

The sections above ran that move three times without labeling it: spot a naked seam, trigger the failure it causes, and name the control it points to. Closing the seams then proceeds one at a time.

Try it yourself

The gap can be felt without reading further. In an editor, a few quick experiments show it:

  • Point the naked agent in either framework at an instruction to rebook the customer for March 32nd, and watch the impossible date travel into book_flight
  • Cap the bare loop at five iterations. That's the first harness control actually written, and it belongs to the runtime and not the prompt
  • Have book_flight return {"is_error": true, ...} and check whether the final summary owns up to the failure or papers over it
  • Fix one claude-agent-sdk release and one deepagents or langchain release before starting, since both APIs shift between versions, and read the model names (claude-sonnet-4-6, anthropic:claude-sonnet-4-6) as stand-ins for whatever is current

Each framework delivers a tidy, well-made loop and neither delivers a harness. That's a feature of the situation and not a defect in the tools; it's where the real work lies. Claude Code, OpenCode, Codex and Deep Agents all count as agent harnesses, and a developer who struggles with them differs from one who gets dependable results by engineering the controls the loop omits. The model has been the easy part for some time. Reliability grows out of discipline built around it, failure by failure, and the natural first piece is the action boundary, the first rule to leave the prompt and live in the runtime.