Agent Harness · AI Security

Agent Harness Architecture: How Coding Agents Actually Execute Tools

Illustration comparing China's Kimi K3 open-weight AI model against Western coding assistants Claude and Codex.
OP

AI security researcher · Updated Sep 12, 2026, 12:04 PM EDT

A mechanism-first breakdown of the runtime that wraps a model: the loop, tool schemas, sandboxing, context strategy, permissions, and injection defence.

A language model is a function from text to text. Given a prompt it emits tokens, then stops. It cannot open a file, run a test, or notice that the command it just suggested exited non-zero. Everything an agent does is done by a separate program that wraps the model and mediates between it and the world. The industry has settled on a name for it: the harness.

The word gets used constantly and defined almost never, which is a problem, because the harness is where most of the engineering is. Two teams can build on identical weights and ship agents that differ enormously in how often they finish a task, how badly they fail, and how much damage they do when fed hostile input. Almost all of that difference is harness.

What the term actually covers

A harness is everything that turns model output into actions and feeds the consequences back. Six subsystems:

  • The loop. Invoke the model, interpret the response, act, append the result, invoke again.
  • The tool registry. The callable operations, their machine-readable schemas, and the descriptions that say when each applies.
  • The execution boundary. Where and under what constraints a call runs.
  • The context manager. What stays in the window, what gets compressed or externalized, and in what shape results arrive.
  • The permission layer. Which calls run unattended, which need a human, and how grants are scoped.
  • The termination logic. Budgets, loop detection, error policy, and what a clean stop looks like.

The model contributes reasoning and tool-selection judgment. The harness contributes which tools exist, how legible their results are, whether the agent still has working memory on step forty, and the blast radius when the model is wrong or manipulated. When one agent is called smarter than another on the same base model, what changed was the harness.

The loop

The control flow is genuinely simple, in pseudocode with provider naming stripped out:

messages = [system_prompt, user_request]

while True:
    response = model.invoke(messages, tools=tool_schemas)
    messages.append(response)

    if response.stop_reason != "tool_use":
        return response.text          # final answer, loop exits

    results = []
    for call in response.tool_calls:
        validated = validate(call)     # schema check before anything runs
        results.append(dispatch(validated))

    messages.append(results)           # each result keyed to its call id

The system prompt and tool schemas go in on every invocation. The model emits either prose, meaning it is done, or structured tool calls. The harness validates each call, dispatches it, captures the result, formats it into an observation, appends it keyed to the originating call id, and invokes again. Two details are easy to get wrong: results must be correlated by id rather than position, since parallel calls complete out of order, and the model's tool-call message must be appended verbatim before its results.

That is the whole loop, and it is fifteen lines. Every hard problem in agent engineering lives in the four things it delegates to: what tool_schemas says, what dispatch may do, what happens to messages as it grows, and the conditions under which while True keeps going.

Tool definition and dispatch

A tool declaration is a JSON Schema plus a name and a description. The schema is a contract; the description is a prompt.

{
  "name": "read_file",
  "description": "Read a file by path relative to the project root. Returns at most 2000 numbered lines; page with offset. Use search_code when you do not already know the path.",
  "input_schema": {
    "type": "object",
    "properties": {
      "path":   { "type": "string",  "description": "Relative to project root; escaping paths rejected." },
      "offset": { "type": "integer", "description": "First line returned, 1-indexed." }
    },
    "required": ["path"]
  }
}

Schema quality drives tool-selection accuracy more than anything in the system prompt. The reason is structural: the prompt is general guidance, while the schema sits adjacent to the decision being made and is the only thing defining the boundary between this tool and its neighbours. Most selection failures are two tools with overlapping descriptions, neither saying where the other takes over.

Rules that hold up. Name for the effect, not the implementation: search_code beats ripgrep_wrapper. Put the negative case in the description, since "use X instead when Y" resolves more ambiguity than another sentence about what the tool does. Constrain the type system, document the return shape, and keep the set small.

Validate before dispatch so missing fields and wrong types are caught before any side effect, and treat a validation failure as an observation rather than an exception. Told which field was wrong, the model usually fixes the call next turn. A hallucinated tool name is the same case: return an error listing the valid names.

Parallel calls are a throughput win and a source of bugs. Emitting several in one turn asserts they are independent, and that is often wrong: two writes to the same file race, and a read issued alongside a write that should have preceded it observes stale state. Run read-only calls concurrently, serialize anything that mutates, and fail the second of two conflicting writes with an explanatory observation. If ordering mattered, the model reissues.

Where the call actually runs

dispatch is where the harness stops being a text-processing program and becomes a security boundary. In increasing isolation strength and cost:

In-process is a function call in the harness: microsecond latency, no isolation, every tool sharing its credentials and filesystem view. Subprocess buys crash isolation, working timeouts, and OS controls such as a separate uid and rlimits, but keeps the same kernel and filesystem. Container adds namespace and cgroup isolation, a scoped filesystem, a controllable network namespace and resource ceilings, at tens to hundreds of milliseconds of startup unless you keep a warm pool; the kernel is shared, so a kernel escape is a full escape. MicroVM gives its own kernel and hardware-enforced separation, the strongest boundary in general deployment, at the cost of image management. Remote execution makes isolation someone else's problem and charges network latency per call.

Four controls are non-optional whatever the mechanism. Resolve paths fully, following symlinks, and only then check containment under the allowed root; the recurring bug is checking the string before resolution and letting a symlink walk through. Make egress default-deny, since it is both the exfiltration path and the return channel for anything injected. Cap CPU, memory, process count and disk. Set timeouts per call plus a session budget, since a hung call is an agent that appears to be thinking.

One point deserves emphasis: an agent with a general shell tool is a fundamentally different risk object from one with a fixed tool set. A fixed set is enumerable, so you can reason about the whole action space, write policy over it, and audit it. A shell tool collapses the registry into one entry whose capability is the union of everything installed, plus everything it can install. That is not an argument against shell access, which is often right for a coding agent. It is an argument that once you grant it, the tool list has stopped being your boundary and the sandbox has to be.

Context management

The hardest real problem, and it is not about the conversation. It is about tool output.

Naive appending fails for two compounding reasons. The transcript is resent on every invocation, so cost and latency grow with the square of the step count. And the window is finite, so a long task does not degrade gracefully; it hits a wall mid-work and dies with everything half done. Tool results dominate that growth: one build log can outweigh the entire preceding conversation.

What works, in rough order of payoff:

Shape the observation at the tool, not in the loop. A search tool returns matching lines with file and line numbers, not files. A test runner returns failures and a pass count, not the full log. An HTTP tool returns status and a bounded body. A writer returns a diff summary, not the content. This is the highest-leverage intervention available and the most underinvested, because it needs per-tool thought rather than a generic mechanism.

Return references instead of payloads. When a result is large and genuinely needed, write it to the workspace and return a handle plus a preview: path, size, structure, first records. The agent queries the artifact later for the slice it needs, turning an unbounded cost into a bounded one. Where you truncate instead, mark the elision, because an agent that can see it lost content can ask for the rest.

Compact. Replace an older prefix with a model-written summary, keeping recent turns verbatim. The failure mode is losing the one detail that matters twenty steps later, so pin structured facts through compaction unconditionally: files touched, decisions made, the task statement, open threads. Compaction invalidates prompt caching after the rewrite point, so compact in large infrequent steps.

Externalize state, and index what you drop. A notes file the agent maintains is immune to compaction, survives a subagent boundary, and is inspectable mid-run. Keep the full transcript in external storage and give the agent a tool to search its own past, turning a window requirement into a retrieval problem.

Permission and approval

Autonomy trades against safety, and the trade is managed by classifying tools by effect rather than identity. Read-only tools change nothing. Reversible writes modify state inside a workspace under version control, so undo exists. Irreversible actions reach outside the sandbox or cannot be undone: pushing a branch, publishing a package, calling a payment API, rotating a credential.

Approval models layer over that classification. An allowlist grants named operations for the session; per-call confirmation puts a human on a specific invocation; session-scoped grants sit between, approving a command shape once and running it unattended thereafter. Granularity decides whether any of it works: allowing "the bash tool" is a rubber stamp, while allowing a specific command with specific argument patterns is a decision.

The failure mode that defeats all of it is approval fatigue. A harness that prompts on everything trains the operator to approve reflexively, and after a hundred confirmations the click is automatic and the control is decorative. Classification quality therefore decides whether the approval layer functions at all. Two details follow: the prompt must show the fully resolved call, since approving a paraphrase approves nothing, and the grant must be scoped to what was shown.

Errors, retries, and knowing when to stop

The instinct from ordinary software, to raise on failure, is wrong here. An error is an observation. A tool that throws out of the loop ends the run; one that returns "no such file: src/config.ts; did you mean src/config.js" lets the model recover next turn. Format errors as a terse colleague would: the operation, the failure, the actionable detail, and not a four-thousand-line stack trace.

Retry policy hinges on one distinction. Transient failures such as timeouts, connection resets and rate limits should be retried inside the tool with backoff and never surfaced, since a retry observation is context spent on nothing. Deterministic failures such as bad arguments and missing files must never be retried, because the identical call fails identically and you have burned a step. No non-idempotent write is retried silently.

Loop detection is a separate mechanism and every harness needs one. Hash the tool name with its normalized arguments; if the same call recurs with the same failure more than a few times, stop returning the same observation, because the model has proven it cannot escape on what it is being given. Inject something different: state that the call has failed repeatedly and require a change of approach or a stop.

Budgets bound the rest: maximum steps, tokens, wall clock, and where applicable spend. Exhaustion should not be a hard cut. Reserve headroom for a final turn reporting what was accomplished and what is unfinished, because a run that stops cleanly with a summary of partial work is recoverable while one that vanishes at the token limit leaves a human to reverse-engineer the workspace. Clean termination comes from exactly one of: no tool call emitted, a budget exhausted, a human interrupt, or unrecoverable environment failure.

Subagents and delegation

The standard answer to context exhaustion is to spawn a child with its own window. The parent calls a tool that starts a fresh loop with its own system prompt, transcript and usually a restricted tool set; the child works and returns a text report; the parent pays only for the report. The economics are compelling for search-shaped work, where locating three functions in a large repository costs tens of thousands of tokens of reading and the output is three paths and a paragraph.

That report is the entire interface, which makes delegation lossy by construction. The child usually cannot ask a clarifying question mid-task, so the brief must be complete up front: the exact question, the shape of the answer, the constraints. Coordination costs are real too. Children duplicate setup, cannot see each other's discoveries, and redo overlapping reading; parallel children that write conflict, and it surfaces as a corrupted workspace rather than a clean error. Delegate read-heavy, bounded work with a narrow output, and keep writes and irreversible decisions in the parent.

Delegation is also a permission boundary: a child must never hold more capability than its parent, or a parent under approval constraints can launder a forbidden action through a child, and the audit trail records a delegation rather than the action.

Security properties

One threat dominates: prompt injection through tool output.

The mechanism is structural, not a bug to patch. The model has one input channel. The system prompt, the operator's instruction, a file's contents, a fetched web page, a CI log and a dependency README all arrive as tokens in the same window, with no privileged channel and no type separating instruction from data. Any content the agent reads is a candidate instruction source, and a harness that cannot distinguish operator instructions from retrieved data is exploitable by construction, not by accident.

The shape is a classic confused deputy. The agent holds credentials, filesystem access and network reach that the author of the retrieved content does not. The attacker supplies text, the agent acts with its own authority, and the attacker never touches the credentials.

Filtering is insufficient, and understanding why matters for where you spend effort. There is no reliable classifier for "is this text an instruction," because natural language is unbounded, the same sentence is an instruction or a description depending on context, and indirection defeats pattern matching. Treat filtering as defence in depth, never as the control.

What helps is architectural:

  • Capability restriction. Assume the agent will at some point act on hostile instruction, and size its permissions so the worst outcome is acceptable: fewer tools, narrower scopes, read-only credentials by default, short-lived session tokens rather than long-lived ones in the environment.
  • Provenance tracking. Tag every message with its origin, then make policy a function of that tag: once untrusted content enters the window the context is tainted, and privileged tools either require approval or go unavailable for the session. The closest thing to a real fix, and the least implemented.
  • Human approval on irreversible actions. The only control that does not depend on the model behaving correctly, which returns you to classification quality: it works only if prompts stay rare enough to be read.
  • Egress control. Exfiltration needs a path out, and default-deny networking removes most of them. Watch indirect channels: a URL assembled from workspace contents, an image reference in rendered output, a DNS lookup encoding data in the hostname. Any tool that fetches an attacker-influenced address is an egress channel whatever it is called.

What separates a good harness from a bad one

  • Tool schema quality. Clear boundaries between tools, constrained types, documented return shapes, guidance on when not to use each.
  • Observation shaping. Per-tool decisions about which slice of output is useful, made where that knowledge lives.
  • Context strategy. An explicit plan for what happens at eighty percent window occupancy, chosen before it is needed rather than discovered in production.
  • Failure feedback. Errors as actionable observations, transient failures absorbed, deterministic failures surfaced, repeated failures broken out of.
  • Permission granularity. Risk classified by effect, approval scoped to what was shown, prompts rare enough to be read.
  • Observability. A full trace of calls and observations, per-step cost and latency, and the ability to replay a run.

An architecture checklist

  1. Can you enumerate the complete set of actions the agent can take? With a shell tool the answer is no, and the sandbox is your only boundary.
  2. Where does a call execute, and what does an escape from that boundary reach?
  3. Is filesystem scoping enforced after full path resolution, and is egress default-deny?
  4. What happens when the window fills, and is that behaviour designed or emergent?
  5. Does each tool return the useful slice or the whole payload?
  6. Are errors returned as observations, and is there loop detection on repeated identical failures?
  7. What are the step, token, time and spend budgets, and is a summary turn reserved on exhaustion?
  8. Are tools classified by reversibility, does the approval model follow that classification, and does a prompt show the exact resolved call?
  9. Is message provenance tracked, and does any policy depend on it?
  10. Can a subagent hold capability its parent does not?
  11. Is every call and observation logged well enough to reconstruct a run afterwards?

The loop is fifteen lines. The answers to those eleven questions are the agent.