Architecture
This page explains the core design decisions in data-harness and why they
exist. Understanding them makes the API surface predictable.
Layers
The package is layered, bottom up. A layer may import the ones below it and
none above; tests/test_layers.py enforces that statically.
data_harness/
llm/ provider adapters and the wire types they speak
core/ the loop, the session tree, RunResult, hooks, compaction
data/ the session cache, interpreter, SQL, connectors, MCP
app/ Agent, ask(), the CLI
The boundary that matters is core not knowing what a DataFrame is. The loop
takes a RunEnvironment supplying two things it cannot decide for itself: how
to render a tool's return value, and what the run's final state was. The data
layer's implementation is backed by the SessionCache; NullEnvironment is
the domain-free default. That is why the harness can be read, tested, and
reused without pandas.
One loop, two drivers
_HarnessBase._plan is the only ReAct loop. It is a generator that owns every
decision in a turn and performs no network I/O: it asks for I/O by yielding
CallProvider, CallTool, or ToolFinished, and the driver answers with
Ok(value) or Failed(exc).
Harnessperforms those inline on the calling thread. No event loop is created, so Ctrl-C lands promptly and a tool handler keeps its thread affinity: asqlite3connection opened at setup still works.AsyncHarnessawaits the provider and offloads blocking handlers to a worker thread, so a long pandas call cannot stall a shared event loop.
Both drivers run the same generator, so a sync and an async run make identical decisions about reminders, tool gating, and when to stop.
The session tree
A session is an append-only tree of typed entries, each naming its parent, with a movable leaf. The conversation the model sees is derived by walking root to leaf. There is only ever one copy of the conversation: the derived context, computed from the tree each time it is needed.
Everything else follows from that:
- Resume — reopen a session file in another process and carry on.
- Forking — move the leaf and append; both branches survive, so a turn can be retried with a different model without losing the first attempt.
- Reversible compaction — a compaction is its own entry. The compacted turns stay in the tree, so moving the leaf back restores them.
- Linear writes — one line per entry, so writing a session costs is proportional to how many entries it has, independent of how long the conversation already is.
A derived context is always something a provider will accept: a tool call with no result, or a result with no call, is dropped. Both are reachable without a bug in this library, because a run killed mid-tool leaves an orphaned call on disk.
Hooks
Four events, each with a decision a hook may return:
BeforeTurn -> Reminder(text) | Stop(reason)
BeforeToolCall -> Block(reason, is_error)
AfterToolCall -> Replace(content, is_error)
AfterTurn -> Stop(reason)
The interpreter approval gate is built from exactly this mechanism. AfterTurn
is where a spend cap belongs: the tokens are already counted by then, so the
decision is made on real numbers.
Hooks must not raise. One that does is reported as HookError, and the run
ends with its usage intact.
Compaction
max_turns caps how many turns a run gets. Compaction gives a long run a way
to keep going within that cap: it summarises older turns and replays the
summary in their place, freeing up room for more turns.
The cut always lands on a turn boundary, so an assistant tool call is never separated from the result answering it. When there is nowhere safe to cut, compaction does nothing that turn.
The data a compacted turn discussed is unaffected: it lives in the cache under handles, separate from the transcript. Compacting away the turn that loaded a DataFrame removes the discussion of it from the context; the DataFrame itself is still there under its handle.
Errors
Every failure carries a stable code. A caller deciding between retrying,
showing the user an error, and billing for the attempt can act on the code
directly, distinguishing a rate-limited provider from a bug in the model's
pandas from a sandbox timeout. Codes are part of the API; messages can be
reworded freely.
ExecutionError means the sandboxed code never ran to completion — a
timeout, or the process was killed. PythonInterpreterError means it ran and
raised, which is the model's problem: it comes back as a tool result so the
model can see the traceback and fix its own code.
No bash
Giving an agent shell access is the path of least resistance, but it creates real problems in data workflows:
- Unpredictable side effects — shell commands can touch files, processes, and network resources in ways that are hard to audit.
- Security exposure — prompt injection or a confused model can run destructive commands.
- Reproducibility — shell state is implicit and hard to reconstruct from logs.
data-harness constrains the model to a Python interpreter only. Python is
expressive enough for all data work, and the interpreter can be given explicit
globals and have dangerous operations blocked.
Handle/snapshot pattern
Large objects (DataFrames, arrays, query results) live in the SessionCache
and are represented in message history by a compact snapshot:
- The tool result calls
SessionCache.put(name, value). format_tool_outputreturns a compact snapshot — shape, columns, a few sample rows — as the tool result string.- The model writes Python against the handle name (
sales_df,result_2, etc.) to operate on the data.
This keeps context small while still showing the model enough of the data to work with it.
Tool result → {"type": "dataframe", "shape": [1200, 5],
"columns": ["date", "revenue", ...], "sample": [...]}
Model code → result = sales_df[sales_df.revenue > 1000].groupby("category").sum()
save("top_categories", result)
Prefix-stable system prompt
The system prompt is byte-identical across every turn of a run. Only the conversation suffix changes. This is a KV-cache discipline: a stable prefix lets the provider cache it, reducing latency and cost on long runs with many turns.
Reminders, nags, and dynamic state updates are always appended to the suffix.
The Harness enforces this — it has no API to modify the system prompt after
construction.
Progressive connector disclosure
Connector tools start hidden (visible=False). The model must call
load_connectors(connector_name="...") before the tools for that connector
appear in its tool list.
A shorter tool list means the model makes better routing decisions at each turn. The model loads only the connectors it needs for the current task, instead of choosing from every registered connector's tools on every call.
Suffix-only reminders
The Planner escalates reminders when the model has made no progress for
several turns. These reminders are always appended to the conversation suffix
as TextBlock items, so they change the working set the model reasons over
without touching the cached prefix.
This preserves the stable-prefix invariant above and keeps the provider's KV cache valid across a reminder injection.
Subagent isolation
Spawned subagents get:
- A fresh
AsyncProviderAdapter(orProviderAdapter) - A fresh message history (empty)
- A fresh
SessionCache
Parent state crosses the boundary only through explicit input_handles.
The parent cache is not shared. This makes subagent behaviour reproducible
and debuggable independently of the parent run.
Subagents cannot spawn further subagents — SubagentRecursionError is raised
if they try.
Per-turn accounting
Every turn is recorded on the session tree as a TurnEntry, as part of the
same append that records the turn's messages:
- Token counts (input, output, cache read/write) and latency
- The provider's stop reason
- Tool error count and the names of the tools visible on that turn
A TurnEntry carries these counts and metadata. The actual message content —
the same Message objects the model saw, snapshots included — lives
separately in the tree's MessageEntry nodes. JsonlSessionStore persists
the whole tree to disk, one line per entry, so a run can be reconstructed
from disk in full: what was asked, what the model did, what it cost, and how
long each turn took, all without reading a dataset's actual values back out
of the log.
Key invariants
These are design constraints that tests assert directly, in tests/:
- The system prompt is byte-identical across turns.
- Adapters never mutate harness-owned state.
- Dynamic reminders are suffix-only.
- Tool-use messages are always followed by matching tool-result messages before the next assistant call.
- Large values stay in
SessionCache. The session tree and the messages sent to the provider hold snapshots of them, never the values themselves. - Cache handles are valid Python identifiers.
python_interpreteruses fresh locals per call.- Subagents do not inherit the parent cache automatically.
- The session tree can be replayed into the same conversation a run actually had, from disk, without ever reading a cached value's contents back out of the log file.