Four Layers of Agent Engineering

Graph, harness, loop, context — why mixing them up is why your debugging sessions go nowhere.

Most "the agent got dumber" bugs have nothing to do with the model. They usually live in one of four engineering layers around it, and until you know which layer owns the bug, you'll fix the wrong thing with a lot of confidence.

I split agent engineering into four practical layers, organized from the model outward:

  • Context engineering: what the agent knows
  • Loop engineering: how it thinks and acts repeatedly
  • Harness engineering: how it's controlled and protected
  • Graph engineering: how multiple steps and agents are organized

Here's how those four fit together, plus the system prompt, the layer underneath all of them that started this whole naming trend. The list above goes from the model outward; the diagram below goes the other way, outside-in, since that's the direction the layers actually nest:

How the four layers of agent engineering fit together: Graph, Harness, Loop, Context, and Prompt Engineering nested around the LLM

Five boxes here, not four. Prompt isn't a separate layer in this post, it's the innermost part of context, more on that in section 7.

Table of Contents

1. Context Engineering

What does the model know right now?

Every call to the model is really just a text blob assembled from several different sources, sent fresh each time. Context engineering is the job of deciding what goes into that blob and what gets left out: not the whole conversation ever had, not every document you have access to, just the slice that's actually relevant to answering this one request right now. Get it wrong and the model isn't reasoning worse, it's reasoning correctly over the wrong inputs, which looks identical to a dumber model from the outside but has a completely different fix.

This layer controls everything the LLM is given on a call:

  • System instructions: the role, constraints, and output format the agent operates under for the entire task
  • User request: what was actually asked, verbatim, not a paraphrase the agent invents later
  • Conversation history: prior turns in this session, either raw or compacted depending on how long the conversation has run
  • Retrieved documents (RAG): knowledge pulled from a vector store, search index, or file system, specific to this query
  • Tool descriptions: the schemas telling the model what tools exist and how to call them
  • Tool results: the actual output from a tool call, fed back in on the next iteration, distinct from the tool's description
  • Memory: anything persisted across sessions, not just recalled within this one conversation
  • Current task state: structured data the agent is tracking, like a cart, a plan, or a checklist

Three terms in this space get used as if they mean the same thing, and don't:

Term What it actually is Example
Prompt The static instructions, set once and rarely rewritten per call "You are a helpful travel assistant. Respond in JSON."
Context Everything assembled fresh on this call, the prompt included System prompt + history + RAG + tool results, from the list above
RAG One technique for filling part of the context, not the whole discipline A vector store or search index queried for this specific request
Goal: give the model the right information, in the right format, at the right time.

Every call boils down to one assembly step:

user question
+ system prompt
+ relevant documents
+ previous tool results
= model context

Poor context, not a "dumber" model, is usually what causes hallucination, confusion, and irrelevant answers. The hard part is that every call competes for the same fixed token budget, so context engineering is really budget allocation:

TOKEN_BUDGET = 8000

def build_context(system_prompt, retrieved_docs, history, latest_tool_output):
    reserved_system = count_tokens(system_prompt)
    reserved_tool = count_tokens(latest_tool_output)
    remaining = TOKEN_BUDGET - reserved_system - reserved_tool

    # compact history first, it's usually the cheapest to summarize
    history_budget = int(remaining * 0.4)
    history_trimmed = compact_if_needed(history, max_tokens=history_budget)

    # whatever's left goes to retrieval, ranked by relevance not recency
    docs_budget = remaining - count_tokens(history_trimmed)
    docs_trimmed = top_ranked_chunks(retrieved_docs, max_tokens=docs_budget)

    return {
        "system": system_prompt,
        "history": history_trimmed,
        "retrieved": docs_trimmed,
        "tool_output": latest_tool_output,
    }

Whatever gets compacted away is a bet that it wasn't important. I covered the caching and compaction mechanics ADK gives you for this in Google ADK - Context Management. The rule I use: anything the agent must never forget goes in state or the system prompt, not in history that's a candidate for summarization.

Compaction isn't the only lever, sometimes the fix is never loading the full thing in the first place. If a task only touches 200 lines out of a 100,000-token repository, hand that slice to a subagent instead of stuffing the whole codebase into the main agent's window. A subagent that only sees what it needs can't get confused by what it doesn't.

Position matters too, not just size. Models pay the most attention to whatever sits at the very start and the very end of the context window, and noticeably less to the middle. The system prompt and the most recent turn already sit in strong positions by default, so the thing worth double-checking is whether a critical instruction has ended up buried in the middle of a long block of retrieved documents, where it's statistically likely to get less weight than it needs.

2. Loop Engineering

What should it do next, and when should it stop?

An agent rarely makes just one LLM call and stops. Ask it to do anything non-trivial and it needs to check something, act on what it finds, and decide whether that was enough, then repeat until the task is actually done or it's clear it won't get there. That repeating cycle is the loop, and loop engineering is the discipline of deciding how it should behave: how many iterations are reasonable, what counts as progress, what counts as stuck. A loop with no real termination logic doesn't crash or throw an error. It just keeps running, quietly burning tokens on a task it already failed several steps ago.

flowchart LR Observe[Observe] --> Reason[Reason] Reason --> Act[Act] Act --> Result[Receive Result] Result --> Update[Update State] Update --> Observe style Reason fill:#fff3e0,stroke:#ef6c00

For example, "find the cheapest flight":

  1. User asks: find the cheapest flight.
  2. LLM decides to call the flight-search tool.
  3. Tool returns flights.
  4. LLM compares results.
  5. LLM may search another date.
  6. LLM returns the recommendation.

Loop engineering controls:

  • When to call tools: deciding the current step needs an action, not just more reasoning
  • When to try a different approach: recognizing the last attempt didn't move things forward and something else needs to change
  • The maximum number of steps: a hard ceiling so a non-converging task fails loudly instead of running forever
  • When to ask for clarification: recognizing the task is underspecified rather than guessing and burning a step on a wrong assumption
  • When to stop: the actual termination condition, model self-declares done, an explicit tool signal, or a step budget running out

Not all stopping conditions are equally trustworthy. A model announcing "I fixed the bug!" is a self-declared stop, and it's the weakest one, since it's just the model's opinion of its own work. A verified stop checks an actual ground truth instead: run pytest, look at the real exit code and the un-truncated output, and only exit the loop when that says done. The difference matters because a self-declared stop can be wrong in exactly the way a confident-but-incorrect answer usually is.

A quick vocabulary check for this section too, since these three get flattened into just "the agent" a lot:

Term What it actually is Example
Step One pass through observe, reason, act One tool call and the result that comes back
Loop The whole repeating cycle across many steps The for step in range(MAX_STEPS) loop below
Agent A model plus a loop, working a task start to finish The flight-search example above, beginning to end

Worth being precise about "retry" here, because it means something different at this layer than it does at the harness layer below. A loop retry is a reasoning decision: the agent tries a different search query because the first result wasn't good enough. A harness retry is an infrastructure decision: the runtime resends the exact same API call because it timed out. One is the agent changing its mind; the other is the runtime not trusting the network. Getting that mixed up is why "add more retries" so often makes a flaky agent worse instead of better.

The goal at this layer is simple: get the agent to finish the task reliably, without looping forever. I'd rather write the stopping condition out explicitly than trust a framework default I can't see:

MAX_STEPS = 8

def run_agent_loop(query, model, tools):
    transcript = [{"role": "user", "content": query}]
    seen_calls = set()

    for step in range(MAX_STEPS):
        response = model.generate(transcript, tools=tools)

        if response.is_final_answer:
            return response.text

        call_signature = (response.tool_name, tuple(response.tool_args.items()))
        if call_signature in seen_calls:
            # same tool and args as before, the loop isn't converging
            transcript.append({
                "role": "system",
                "content": "That exact call already ran. Try a different approach or stop.",
            })
            continue
        seen_calls.add(call_signature)

        result = tools[response.tool_name](**response.tool_args)
        transcript.append({"role": "tool", "name": response.tool_name, "content": result})

    return "Stopped: step budget exhausted without a final answer."

That seen_calls check catches more real bugs than prompt tweaks ever have. An agent repeating itself is usually stuck because the last result gave it nothing new to act on.

3. Harness Engineering

How is execution controlled, secured, and observed?

None of the reasoning a model does matters if nothing stands between its decision and the real world. The harness is that boundary: the code that actually executes what the model decided, checks whether it was allowed to, and contains the blast radius when it's wrong. It has to behave the same way every single time, the identical permission check, the identical timeout, regardless of how confident or reckless the model sounded on that particular call. An agent without one isn't cautious by default. It's just fast.

Worth separating three things people tend to lump together here:

Term What it actually is Example
Model Generates the reasoning and the tool calls, nothing else Gemini 3.5 Flash, Claude Sonnet 5
Framework The SDK you write agent logic against Google ADK, LangGraph, AutoGen
Harness The actual runtime that executes what the framework produces, permissions, sandboxing, and all Claude Code's own harness, a sandboxed CLI runtime

The ADK code in the Graph section further down is a framework. It's what you write. The harness is what runs it, and that distinction is exactly why a well-built harness can make a smaller, cheaper model outperform a frontier model running loose in a sloppy one.

The harness is the software runtime around the loop. It handles:

A production-grade agent harness manages five critical functions: tool execution and MCP integration, state and memory management, guardrails and security, the ReAct loop, and observability and telemetry logs, all sitting between the LLM and the outside world
  • Tool execution: actually running the function call the model requested, in a sandboxed or controlled environment
  • Authentication and permissions: verifying the agent, and the user behind it, is allowed to take this specific action
  • Input/output validation: checking arguments and results match expected shapes before anything downstream trusts them
  • Timeouts and automatic retries on transient failures: resending a call that failed for infrastructure reasons, not reasoning reasons
  • Logging and tracing: recording what actually happened, so a failure is debuggable afterward instead of a mystery
  • Cost and token limits: hard caps on spend per call, per run, or per user, so one runaway loop can't blow the budget
  • Guardrails: rules that block specific categories of action outright, regardless of what the model argues for
  • Human approval: a pause point before anything irreversible, where a person has to say yes
  • Error handling: what happens when something fails cleanly, versus when it fails in a way nobody anticipated
  • Evaluation and monitoring: ongoing checks on whether the agent is still behaving the way it did when it was tested

Say the LLM decides to call delete_database. The harness is what asks, before anything runs:

  • Is this tool permitted?
  • Does the user have access?
  • Is human approval required?
  • Are the arguments valid?
  • Should this action be logged?
Goal: make an unpredictable, probabilistic model operate safely inside a deterministic system.

I've learned to keep this code deliberately boring. It shouldn't be clever. It should be hard to bypass:

import time
import functools

class StepBudgetExceeded(Exception):
    pass

def harnessed(max_retries=2, timeout_s=15, cost_ledger=None, cost_limit=1.00):
    def decorator(tool_fn):
        @functools.wraps(tool_fn)
        def wrapper(*args, **kwargs):
            if cost_ledger and cost_ledger.total >= cost_limit:
                raise StepBudgetExceeded(f"Cost cap ${cost_limit} reached")

            last_err = None
            for attempt in range(max_retries + 1):
                try:
                    result = tool_fn(*args, timeout=timeout_s, **kwargs)
                    if cost_ledger:
                        cost_ledger.record(tool_fn.__name__)
                    return result
                except TimeoutError as e:
                    last_err = e
                    time.sleep(2 ** attempt)  # backoff, not a hot loop
            raise last_err
        return wrapper
    return decorator

@harnessed(max_retries=2, timeout_s=10)
def call_search_api(query: str, timeout: int):
    ...

Nothing in that code reasons about anything, and that's on purpose. If the agent does the right thing too many times, too slowly, or too expensively, that's a harness problem. If it does the wrong thing, that's not this layer.

4. Graph Engineering

How are agents and workflow steps connected?

A single agent running a single loop only gets you so far. Past a certain complexity you need a researcher, a reviewer, and a database specialist all working off the same request, not always in the same order, sometimes in parallel, sometimes routed back for another pass. Graph engineering treats that whole arrangement as a structure you design deliberately, rather than a pile of function calls that happened to end up working together. Without it, multiple agents don't collaborate. They take turns being confidently wrong about the same task, and nobody notices until the output ships.

Graph engineering defines how agents, tools, and deterministic steps are connected, routed, and coordinated:

The three fundamental building blocks of graph engineering: nodes as units of computation, edges for routing and control flow, and shared state as the central memory passed between them
  • Nodes: the individual units of work, agents, tools, deterministic functions, or validators, each with a single responsibility
  • Edges: the transitions between nodes, sequential when one step always follows another, conditional when the next node depends on what the last one returned, or fan-out/fan-in when several nodes run in parallel and their results merge back into one, like the ParallelAgent below
  • State: the data that flows across the workflow, written by one node and read by another downstream
  • Conditions: the rules that decide which node runs next, based on a node's output or the current state
  • Branches: alternative paths through the graph, for retries, escalations, or parallel work that later reconverges

Same exercise for this section's vocabulary, since "node" doesn't always mean "agent":

Term What it actually is Example
Node One unit of work in the graph, not always an agent search_agent, or a plain validation function with no model call at all
Edge The transition between two nodes Sequential, conditional, or fan-out/fan-in, as above
Graph The whole structure of nodes and edges together The research_pipeline in the code below
flowchart TD U[User Request] --> S[Search Agent] U --> D[Database Agent] S --> Rv[Reviewer Agent] D --> Rv Rv --> Out[Final Answer] style Rv fill:#e8f5e9,stroke:#2e7d32
from google.adk.agents import SequentialAgent, ParallelAgent, LlmAgent

search_agent = LlmAgent(
    name="search_agent",
    model="gemini-3.5-flash",
    instruction="Search the web for the requested topic and write findings to state.",
    output_key="search_results",     # the contract with reviewer_agent
)

database_agent = LlmAgent(
    name="database_agent",
    model="gemini-3.5-flash",
    instruction="Query internal records relevant to the topic.",
    output_key="db_results",
)

reviewer_agent = LlmAgent(
    name="reviewer_agent",
    model="gemini-3.5-flash",
    # reads {search_results} and {db_results} out of shared state
    instruction="Using {search_results} and {db_results}, produce the final answer.",
)

pipeline = SequentialAgent(
    name="research_pipeline",
    sub_agents=[
        ParallelAgent(name="gather", sub_agents=[search_agent, database_agent]),
        reviewer_agent,
    ],
)

Graph engineering is especially useful for multi-agent systems, approval workflows, long-running processes, parallel execution, conditional routing, and deterministic business processes. The point isn't to make things look impressive on a whiteboard. It's to coordinate a complex workflow in a way you can actually reason about later.

I've found that most graph bugs live in that output_key contract. The reviewer reads {search_results} and {db_results}. If either name drifts between nodes, the reviewer writes about nothing: no error, no exception, just a bland answer that looks like a prompting problem until you check the state dict.

5. How the Four Fit Together

A graph organizes nodes and transitions. Each agent node may run a reasoning loop. The harness supervises that execution with permissions, retries, limits, tracing, and approvals. Every model invocation receives a constructed context. That's one common shape, not the only valid one. Some runtimes fold the loop directly into the harness rather than treating them as strictly separate layers, but the four questions below still apply regardless of how the code is organized.

A simple way to remember what each layer is actually asking:

Layer Main question
Context What does the model know right now?
Loop What should it do next, and when should it stop?
Harness How is execution controlled, secured, and observed?
Graph How are agents and workflow steps connected?

The LLM is the reasoning engine. These four layers are what turn it into a reliable production agent.

6. A Layer-by-Layer Debugging Checklist

Most "the model got dumber" tickets aren't about the model at all, and prompt edits won't touch any of them. Here's a real symptom run through all four layers: the flight-search agent from earlier keeps calling the same tool with the same dates, over and over, and never returns an answer. Same symptom, four different possible causes, four different things to actually go check:

Layer What to check What you'd find
Context Log the exact dict build_context() returns for this call, field by field, and diff it against the previous call The earlier flight-search result got compacted out of history_trimmed, so the model has no memory of already trying this
Loop Grep the transcript for repeated entries in seen_calls The same (tool, args) signature appears twice, meaning the dedup check exists but nothing stopped the retry
Harness Check the retry counter inside the harnessed() wrapper against cost_ledger.total 2 attempts logged for a call the agent's own transcript shows as happening once, so the timeout/retry wrapper is the one repeating it
Graph Log which node routed back to the search agent, and why A downstream reviewer node rejects the result and routes back every time, regardless of the actual rejection reason

All four produce the exact same symptom from the outside: the same tool call, again and again. Only one of them is the actual cause, and the fix is different depending on which layer it is.

7. Further Reading

None of these terms appeared at once. Each one emerged as a reaction to the limits of the one before it, in a tight sequence over about four years:

Term When Credited to
Prompt Engineering 2020–2022 Coined around GPT-3 (Gwern Branwen); went mainstream in 2022 as developers found small wording changes produced large output changes
Context Engineering June 2025 Tobi Lütke (Shopify), in a June 19, 2025 tweet; amplified by Andrej Karpathy days later
Harness Engineering February 2026 Mitchell Hashimoto (HashiCorp/Terraform), February 5, 2026; institutionalized six days later by an OpenAI engineering post
Loop Engineering June 2026 Peter Steinberger (OpenClaw), June 7, 2026; popularized by Addy Osmani, citing Boris Cherny at Anthropic
Graph Engineering July 2026 Earliest documented use by Josh Simmons, July 4, 2026; crystallized by Peter Steinberger's July 18 post asking whether the field had "shifted to graphs yet"

Prompt engineering isn't a separate layer in this post's framework. It's folded into context engineering as one input among several, see section 1. Here's where to read more on each term:

Related Articles

Context Engineering

Context Management

Caching and compaction mechanics in Google ADK

Loop Engineering

Workflow Agents

Sequential, loop, and parallel workflows in Google ADK

Harness Engineering

Callbacks

Intercepting agent behavior at execution points

Graph Engineering

Multi-Agent Systems

Composing agents into hierarchical systems

Comments