Architecture¶
One request path¶
Both the REST endpoint (POST /api/chat) and the WebSocket
(WS /ws/chat) call the same orchestrator. The transport only translates
internal events into frames for the client — it contains no workflow logic
of its own. That matters in practice: two implementations of the same loop
drift, and drift is where a feature works in the chat UI but not in the API,
or vice versa.
This is a loop, not a pipeline¶
Each iteration, the manager model sees what has actually run and chooses the next move; the run ends when it says it can answer, or the budget is spent.
graph TD
classDef client fill:#0ea5e9,stroke:#0369a1,stroke-width:2px,color:#fff;
classDef api fill:#10b981,stroke:#047857,stroke-width:2px,color:#fff;
classDef brain fill:#db2777,stroke:#9d174d,stroke-width:2px,color:#fff;
classDef sandbox fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#000;
classDef store fill:#64748b,stroke:#334155,stroke-width:2px,color:#fff;
UI["Next.js client<br/>(streams every move)"]:::client
WS["FastAPI · WS /ws/chat"]:::api
Session["Session<br/>tables · documents · sandbox"]:::api
Loop["Analysis loop<br/>bounded by the tier budget"]:::api
Manager["Manager model<br/>decide · revise · answer"]:::brain
Worker["Worker model<br/>Python"]:::brain
Guard["Code guard<br/>AST policy check"]:::sandbox
Box["Per-session sandbox<br/>subprocess or container"]:::sandbox
Trust["Trust layer<br/>verify · ground · assumptions"]:::store
Store["SQLite<br/>cache · trajectories · memory"]:::store
Retr["Retriever<br/>columns · memory · documents"]:::store
UI <-->|typed event frames| WS
WS --> Session --> Loop
Loop <--> Retr <--> Store
Loop -->|"1 what next?"| Manager
Manager -->|"2 inspect / consult"| Retr
Manager -->|"3 write code for this sub-task"| Worker
Worker --> Guard -->|allowed| Box
Box -->|"4 real output"| Loop
Loop -.->|"repeat until answerable"| Manager
Loop -->|5| Trust
Trust -->|"6 synthesise from real output"| Manager
The dotted line is the part that matters: step 4 feeds back into step 1. The manager sees what the code actually produced and picks the next move from it, so a plan that turns out to be wrong gets rewritten instead of carried out regardless. Real analytical work is rarely one step — you find out a join key is dirty, or that "active customer" means three different things in three tables, only once you've looked. A fixed-plan design can't recover from that; a loop can.
The old shape (fix a plan up front, feed 200 characters of each step's output into the next) could not recover when the data contradicted the plan. DABstep measures the gap this closes: hard tasks need 6+ dependent steps, and the best model scored 14.55% on them versus 76.39% on single-step ones — with planning as the largest error category.
The actions available each iteration¶
| Action | What happens |
|---|---|
inspect |
Answered deterministically from the session state — no model call. Cheap enough to reach for often. |
code |
The worker writes Python for the current sub-task; it's statically screened, then executed. |
consult |
Retrieves from attached reference documents or installed skills. |
reflect |
The manager revises its plan given what's happened so far. |
parallel |
Fans one step out into several concurrent, isolated mini-investigations (larger models/tiers only). |
answer |
Ends the loop and triggers verification (unless running in fast mode). |
Malformed model output never crashes the loop — it resolves to a sensible
default (code mid-run, a forced answer on the final iteration). A small
model saying something unparseable shouldn't derail an otherwise-working
analysis.
Depth and tiers¶
Three depths are available: fast (one shot, no verification — the cheapest and least self-checking option), auto (the agent picks its own depth), and deep (thorough, with a decision round-trip on every iteration regardless of model size).
Below a certain model size, the loop stops asking the model to choose its next action at all — reading the transcript and picking from three options a small model doesn't reliably distinguish costs a round-trip and buys nothing. Instead: a step that succeeded and printed something means stop, anything else means write code. That's the difference between a nine-call turn and a three-call one on a laptop-sized model. Picking Deep in the composer restores the full round-trip at any size.
Self-correction¶
When execution fails, the traceback is fed back into the worker's prompt and the sub-task is retried, up to a configured limit. A failure that's successfully repaired is remembered and shown as a counter-example the next time a similar question comes up — so the same mistake doesn't repeat itself. A sub-task that fails outright isn't fatal to the turn: it becomes an observation, and the agent routes around it.
Trust, not just an answer¶
See Data Modes & Privacy for what leaves your machine, but the trust layer itself runs regardless of provider:
- Verification re-derives the headline result by a different route and reports a match or mismatch. A wrong join grain produces a confident, plausible, wrong number that self-review by the same model wouldn't catch — a second, independent computation can.
- Grounding flags any number in the answer that appears in no execution
output. Tolerance is based on the answer's own precision, so reporting
3.14from an output of3.14159isn't flagged as invention. - Assumptions are read back out of the code that actually ran — dropped nulls, an inner join, a top-N cut, a coerced date — because each one changes what the number means, and none of them are visible from the answer text alone.
This is deterministic, not another model call reviewing its own work, and it reports rather than edits: it never rewrites the model's answer, only annotates it.