Building an Advanced Agentic Harness
From a single pilot to an air campaign: planning, parallelism, memory, verification, and observability for production-shaped agents.
In Building a Basic Agentic Harness we borrowed Colonel John Boyd’s OODA loop and built the simplest thing that deserves to be called an agent: a loop that observes its state, asks the LLM to decide, validates the decision, executes a tool, and folds the result back into the state. The LLM is the pilot, and we built the fighter jet around it.
In this post we continue exploring the finer points of Agentic Harnesses and build a full fledge Advanced Harness.
The companion notebook is available on the LLMs for Data Science GitHub repository:
That Basic Harness loop is correct, but naive. A lone pilot in a well-built jet might win a dogfight, but nobody runs an air campaign that way. Real operations add mission planners who decide what sorties to fly before anyone takes off, squadrons that fly independent sorties in parallel, fuel budgets and bingo calls that force a return to base before the tanks run dry, flight recorders that make every mission reconstructible after the fact, and after-action reviews that decide whether the mission actually succeeded. None of these replace the pilot. They wrap the pilot in structure so that the whole system stays fast, safe, debuggable, and measurable.
Claude Code, Devin, Cursor, Hermes, and other production agents do exactly the same thing to the basic loop. In this post we upgrade every piece of our basic harness toward that production shape, without hiding any of the mechanics behind a framework. The guiding question for the whole exercise is a simple one:
How do you turn a single LLM call into a reliable system that can plan, act, recover, and prove it did the right thing?
Our answer is composition. We build small, testable primitives: typed tools, a plan DAG, tiered memory, a verification hierarchy, budgets, and a tracer, and wire them together with a deliberately thin orchestrator. Each primitive exists because naive agents fail in a specific, predictable way. LLMs invent invalid tool arguments, so we add typed tools with Pydantic validation. Everything runs sequentially, so we add a dependency graph and parallel execution. The context window fills with junk, so we add multi-tier memory under a retrieval budget. Bad outputs propagate silently, so we add a verification hierarchy. One prompt tries to do everything, so we split it into Planner, Worker, and Critic roles. Costs run away, so we add multi-dimensional budgeting with graceful degradation.
Proving the harness usually works, with an eval suite, retrieval benchmarks, and specialized worker pools will get a full fledge treatment in a future post.
The running example
Throughout the post we build a city comparison agent: given a list of cities, it produces a report comparing them on population, timezone, and a short narrative summary of each. The task looks almost insultingly simple, but it was chosen carefully. Each city-attribute lookup is independent of every other one, which means a three-city request naturally decomposes into nine tool calls that could all run at the same time. The final report, on the other hand, depends on all of the lookups finishing first, so we’re. well beyond a flat list of steps. We can programmatically check that every requested city actually appears in the report to verify the results. And the tools have wildly different costs: population and timezone lookups are in-memory dictionary reads, while the per-city summaries and the final aggregation each call the LLM, which gives us realistic budget pressure to manage.
For the sake of reproducibility, lookup tools read from a small mocked dictionary, CITY_FACTS, so the notebook is fully reproducible without network access. The LLM-backed pieces can run against either a real Anthropic model or a deterministic mock, which brings us to the first primitive.
A pluggable brain
Every component we are about to build eventually calls an LLM: the planner, the summarizer, the aggregator, the critic. If that call is hard-wired to one SDK, the entire harness becomes untestable and vendor-locked.
So before anything else, we define a base class that provides an abstraction over the details of the various LLM calling APIs
class LLMProvider:
"""Shared interface. Subclass to plug in a different backend."""
def complete(self, system: str, user: str, role: str = “default”) -> str:
raise NotImplementedError
async def acomplete(self, system: str, user: str, role: str = “default”) -> str:
# Wrap sync call in a thread; works for any SDK.
return await asyncio.to_thread(self.complete, system, user, role)We also implement a MockProvider for testing and debugging purposes that returns deterministic, role-aware responses: a canonical plan when asked to plan, a templated one-line summary when asked to summarize, a rule-based pass/fail verdict when asked to judge. This allows us to separate “is my orchestration wrong?” from “is the model planning badly?” during development, and it is the reason every experiment in this post is reproducible on any machine.
Typed tools
In the basic harness we validated tool arguments by hand, an approach collapses quickly: every new tool duplicates validation logic, the LLM never sees a formal schema and just guesses argument shapes, and the resulting errors are ad hoc strings the model can’t self-correct from.
The upgrade is to declare each tool’s arguments as a Pydantic model and let one definition drive everything:
@dataclass
class TypedTool:
name: str
description: str
args_model: type[BaseModel] # Pydantic model defining the arg schema
fn: Callable[..., Any]
cost_hint: float = 0.0 # relative cost for budget accounting
def schema(self) -> dict:
"""Shape expected by Anthropic/OpenAI tool-use APIs."""
return {
"name": self.name,
"description": self.description,
"input_schema": self.args_model.model_json_schema(),
}
def run(self, raw_args: dict) -> Any:
args, err = self.validate_args(raw_args)
if err is not None:
raise ValueError(err)
return self.fn(**args.model_dump())This approach gets us runtime validation, a JSON Schema in exactly the shape that the Anthropic and OpenAI tool-use APIs expect, documentation (each Field(..., description=...) becomes part of the catalog the planner reads), and a hook for cost accounting via cost_hint. Failing before execution allows us to avoid expensive tool calls with potential side effects. A bad plan should fail fast, at the validation layer, and not deep inside a database query. This approach is similar to what full fledge frameworks like LangChain tools, Anthropic tool use, and OpenAI function calling all converge on.
Our registry holds four tools with three cost tiers: get_population and get_timezone() are essentially free dictionary lookups (cost_hint=0.1), summarize_city() makes one LLM call per city (cost_hint=1.0), and aggregate_report() makes the token-heavy synthesis call that produces the final markdown (cost_hint=2.0). Note that the last two are tools that call the LLM internally. LLMs are just like any other tool. The worker sees a uniform tool interface, but some tools are wrappers around sub-prompts, which means you can cache, rate-limit, or swap the inner model independently of the harness.
The plan is a Graph
The basic harness executed one action per turn. That works when steps are strictly sequential, but our task has nine independent lookups feeding a single aggregation:
A while-loop runs these one at a time. A Directed Acyclic Graph expresses the dependencies explicitly and lets an executor run everything that is ready right now, concurrently. So instead of asking the LLM for one action at a time, we ask the Planner for the whole graph up front. The LLM declares the structure before we execute anything. Since the planner is an LLM, it can hallucinate structure too: dependencies on node IDs that don’t exist, or circular dependencies that can never complete. So the very first thing we do with a plan is to validate it before possibly wasting tokens trying to execute a broken plan.
def ready_nodes(self) -> list[PlanNode]:
"""Nodes whose deps are all DONE and are themselves PENDING."""
out = []
for n in self.nodes.values():
if n.status != NodeStatus.PENDING:
continue
if all(self.nodes[d].status == NodeStatus.DONE for d in n.deps):
out.append(n)
return outready_nodes() is the heart of the scheduler: at any moment, it returns the set of nodes whose dependencies are all satisfied. For our three-city goal, the planner emits ten nodes: nine fetches with empty dependency lists, all eligible to run in parallel, and one aggregate_report capstone that depends on all nine.
Executing the graph in parallel
The executor is a level-synchronous DAG walker: compute the ready set, launch every ready node concurrently with asyncio.gather, mark each one done or failed, and repeat until nothing is left or no forward progress is possible.
MAX_CONCURRENT = 5 # cap concurrent tool/LLM calls
async def execute_dag(dag, tools, on_step=None):
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
async def run_node(node):
node.status = NodeStatus.RUNNING
async with semaphore:
try:
# Sync tools run in a thread pool so other nodes can proceed
node.result = await asyncio.to_thread(tools[node.tool].run, node.args)
node.status = NodeStatus.DONE
except Exception as exc:
node.error = f”{type(exc).__name__}: {exc}”
node.status = NodeStatus.FAILED
while not dag.is_done():
ready = dag.ready_nodes()
if not ready:
break # remaining nodes depend on FAILED ancestors
await asyncio.gather(*(run_node(n) for n in ready))Two small decisions carry most of the weight here. First, asyncio.to_thread runs our synchronous tool functions in a thread pool, which means we never have to rewrite tools as async def or couple the harness to async-native SDKs. Second, the semaphore caps concurrency, because without it a fifty-node plan would spawn fifty simultaneous LLM calls and promptly hit rate limits or a cost spike. This is deliberately not a full dynamic scheduler with work-stealing and priority queues. For agent workloads like ours, where each node is an API call lasting hundreds of milliseconds to seconds, level-synchronous parallelism captures most of the win. Sequentially, wall time is roughly the sum of the fetch latencies; in parallel, it is roughly the maximum of them plus the aggregation step.
Remembering the right things
Naive agents dump everything into the prompt: the full chat history, every tool output, every prior task. That fails twice over — you pay for tokens you’ll never use, and models measurably degrade when irrelevant text dilutes the goal. Production agents instead use tiered memory, loosely inspired by cognitive science. Working memory is the always-in-context scratchpad: the current goal, a plan summary, and the last few results. Episodic memory stores the outcomes of past runs and is retrieved when a past task looks similar to the current one. Semantic memory holds background facts, retrieved the same way but not tied to any particular run.
We never inject everything; we pull the top-k memories by similarity to the current goal and then assemble the context under a hard character budget:
def build_context(working: WorkingMemory, store: MemoryStore,
budget_chars: int = 4000) -> str:
"""Assemble working memory + retrieved memories, respecting a char budget."""
pieces = [working.to_prompt()]
used = len(pieces[0])
# Episodic first (past similar tasks), then semantic (facts)
for kind in (”episodic”, “semantic”):
for m in store.retrieve(working.goal, k=3, kind=kind):
snippet = f”[{kind}] {m.content}”
if used + len(snippet) + 1 > budget_chars:
return “\n”.join(pieces) + “\n(...truncated at budget...)”
pieces.append(snippet)
used += len(snippet) + 1
return "\n".join(pieces)Episodic memories get priority over semantic ones because past mistakes on similar tasks are usually more actionable than generic facts, and when the budget runs out, truncation is explicit rather than silent. Context should be actively assembled, not passively accumulated.
For the similarity function itself, the store supports two backends. Jaccard similarity costs nothing and is fine for teaching, but it fails on paraphrase: “famous landmarks in France” shares almost no words with “Paris is known for the Eiffel Tower.” Real sentence embeddings using 384 dimensional vectors generated by all-MiniLM-L6-v2 map paraphrases to nearby vectors. Our MemoryStore tries embeddings first with Jaccard as a backup if the model isn’t available. Quantifying the effect of this upgrade requires a proper benchmark, which we’ll run in a future post.
Trust, and verify
Agents produce fluent, confident, and wrong output. Without verification, a report that silently dropped a city ships to the user, and regressions go unnoticed until a human happens to read the output. But not all checks cost the same, so we arrange them as a hierarchy: deterministic structural checks that are essentially free, and an LLM judge for subjective quality that costs real tokens. The rule is to always run the cheap tier first and only escalate survivors.
def verify_report(report, goal, required_cities, provider) -> Verdict:
det = deterministic_check_report(report, required_cities)
if not det.passed:
return det # Cheap tier caught it — don’t bother the LLM
# Deterministic passed → escalate to LLM judge for subjective quality
return llm_judge_report(report, goal, provider)Feed this a deliberately incomplete report (say, Paris only, when three cities were requested) and it will fail at the deterministic tier with reason=”Missing cities: [’Tokyo’, ‘New York’]”. Zero tokens were spent on judging, and the reason string is actionable enough that a replanner step (or a human) can see exactly what went wrong. This two-tier gate is a robust pattern behind most production eval pipelines: cheap filters first, expensive judges on survivors only. And just as important as the hierarchy is the separation of concerns behind it: the Worker produces and the Critic evaluates, so the generator is never grading its own homework.
Meet the crew: Planner, Worker, Critic
A single prompt that plans, executes, summarizes, and self-critiques tends to confuse its objectives (planning constraints bleed into writing style) and is unable to isolate “the planning part” complicating both testing and swapping.
We split the work into narrow agents, each with a short system prompt and a single contract. The Planner receives the goal plus the tool schemas and returns DAG JSON, which we validate before running. The Worker receives the DAG and simply executes it. The Critic receives the goal and the finished report and returns a verdict. The Planner’s system prompt has the live tool catalog spliced directly into it, so it can only reference tools that actually exist:
PLANNER_SYSTEM = """You are a Planner agent. Given a GOAL, produce a dependency graph
of tool calls that will satisfy it.
Output ONLY JSON in this shape (no prose, no markdown):
{”nodes”: [{”id”: “...”, “tool”: “...”, “args”: {...}, “deps”: [...]}, ...]}
Nodes may run in parallel if their `deps` are empty or already satisfied.
The final aggregate_report node must depend on all upstream fetch/summary nodes.
Prefer id “aggregate” for that capstone node (any unique id is acceptable).
Available tools (name and schema):
<<TOOLS_JSON>>
"""With a real model behind the planner, plans come back structurally valid but stylistically varied, and pinning down the one node the orchestrator needs to find later is much cheaper in the prompt than in cleanup code.
This mirrors what production systems like AutoGPT-style planners, SWE-agent workers, or LLM-as-judge evaluators actually do while staying minimal enough to read in one sitting. And because all three roles go through the same LLMProvider.complete(..., role=...) interface, swapping the planner model or mocking the critic is a one-line change.
Fuel gauges and failure modes
The basic harness had a single max_steps counter, which hides the real constraints: you can have steps left but no tokens left, be under the token budget but rate-limited on tool calls, or have a hung network call burn wall-clock time without incrementing any counter at all. BudgetMulti tracks tokens, tool calls, wall time, and estimated dollars simultaneously, and a run stops when any dimension is exhausted. Its most useful output is a single scalar:
def pressure(self) -> float:
return max(
self.tokens_used / self.max_tokens,
self.tool_calls_used / self.max_tool_calls,
self.elapsed() / self.max_wall_seconds,
self.cost_usd / self.max_cost_usd,
)Pressure is the maximum utilization across all dimensions. You are limited by whichever resource runs out first, exactly like real billing. That one number drives graceful degradation: below 0.7 the full pipeline runs, including the LLM judge; above 0.9 the orchestrator skips the expensive critic and falls back to deterministic checks only; at 1.0 the run halts with partial results. Production agents use the same kind of signal to switch to cheaper models, reduce retrieval depth, or ask the user for confirmation.
The other half of operational sanity is recognizing that not every error deserves the same response. A rate limit or timeout is transient with exponential back off (with jitter, so a fleet of agents doesn’t retry in lockstep) and retry. A validation error is tool misuse and we feed the structured error back to the LLM so it can correct its own arguments. An unknown entity is missing information so retrying is actively harmful, because retrying a hallucinated city name will fail identically every time; the right move is to re-plan without it. And a policy violation is fatal, we should halt immediately. A small classify_error() function maps error strings into these four classes, and the recovery policy follows from the class rather than from blind retries.
The flight recorder
When an agent fails, we need an append-only log of structured events that can answer what happened in what order, how long each step took, which role consumed the tokens, and whether budget pressure was rising before things went wrong. Every event in our Tracer captures identity (a step ID and a parent ID linking worker steps back to the plan that spawned them), semantics (the role and the action taken), economics (latency, tokens, cost, and a snapshot of budget pressure at write time), and, for critic events, the verdict. The schema is flat and boring: a list of JSON-serializable dicts you can dump to a file, ship to OpenTelemetry or LangSmith, or plot directly in matplotlib. You don’t need a proprietary format to get real observability, just a sufficient schema.
Per-step latency, colored by role, shows immediately that the summarize_city() and aggregate_report() nodes dominate wall time while the lookups are flat. LLM calls are where the seconds go, which is exactly why running them in parallel matters.
Budget pressure over time rises monotonically with a sharp jump at the aggregator, and if it crosses the 0.9 degradation threshold before the critic runs, the trace itself explains why the LLM judge was skipped.
Finally, tokens by role shows whether planning or execution is consuming the budget.This task it skews heavily toward the worker, with its many summarize calls.
Putting it all together
With the primitives in place, the Orchestrator becomes almost boring. It builds context from memory, asks the Planner for a DAG, hands the DAG to the Worker with a callback that records a trace event and charges the budget on every tool call, checks for failures, verifies the result with pressure-aware degradation, stores the outcome in episodic memory for future runs, and returns a RunResult bundling the report, verdict, DAG, trace, and budget. The only new behavior it adds is the re-planning loop. If execution fails and the error classifies as missing information, it sends the failure context back to the Planner instead of retrying blindly, up to a configurable max_replans.
The heart of the method fits in a few lines:
for attempt in range(self._max_replans + 1):
dag = self.planner.plan(goal) if attempt == 0 else self.planner.replan(goal, dag)
await self.worker.execute(dag, on_step=on_step)
if dag.any_failed():
failed = (n for n in dag.nodes.values() if n.status == NodeStatus.FAILED)
if any(classify_error(n.error or “”) == ErrorClass.MISSING_INFO for n in failed) \
and attempt < self._max_replans and budget.has_room():
continue # informed re-plan, not a blind retry
return RunResult(..., status=”failed_execute”)
break # success
# Pressure-aware degradation: skip the LLM judge if budget is tight
if budget.pressure() > 0.9:
verdict = deterministic_check_report(report, required_cities)
else:
verdict = self.critic.judge(report, goal, required_cities)The mock planner always names the capstone node aggregate, and an early version of the orchestrator simply looked it up by that id. Switch to a real model and that assumption quietly breaks. The planner often mirrors the tool name aggregate_report or invents its own id, producing a plan that is structurally valid but not what the mock trained us to expect. The fix is twofold: the DAG now resolves the capstone by tool rather than by id dag.aggregate_node() returns the unique aggregate_report node, (and refuses plans that contain more than one), and the planner prompt gained the explicit capstone instructions we saw earlier. Never take the LLM at its word, not even about node names.
What we’re still missing
Between the Basic Harness and this Advanced one, we covered practically all the main concepts and ideas you need to build a successful custom harness. We took the ~35-line loop from the previous post and layered on seven production-shaped primitives: typed tools that give validation and LLM introspection from one schema, a DAG executor that buys parallelism without rewriting a single tool, tiered memory that assembles relevant context under a hard budget, a verification hierarchy that spends tokens only on outputs that survive the cheap checks, narrow Planner/Worker/Critic roles that can be tested and swapped independently, multi-dimensional budgets that degrade gracefully instead of crashing, and a tracer that turns runs into comparable experiments instead of anecdotes.
None of these pieces requires the others, but they compose. Add a tool to the registry and the planner automatically sees its schema. Tighten verify_report() and every future run is held to the new bar. Swap the entire LLM backend and the harness reruns unchanged. Composability is the difference between a PoC and an extensible harness, and is why the orchestrator stayed thin.
A few caveats are in order: memory here is in-process, while production systems persist embeddings to Chroma, Weaviate, or pgvector; tool outputs are trusted as instructions, where production systems must sandbox them as data against prompt injection; irreversible actions should require human approval; and our token accounting estimates from character counts where real systems read usage metadata from the SDK. Each of those is one more compositional layer away — which is precisely the point.
There is also one deliberate omission. A single successful demo proves the harness can work; nothing in this post proves it actually works in most cases. That is the job of an eval harness, and it is where we pick up in a future follow up post
The complete notebook, runnable end-to-end with or without an API key, is available in the
If you enjoyed this post, consider subscribing and sharing it with a colleague who is building agents, and let me know in the comments which primitive you’d want a deeper dive on next.








