Brain and
Hand¶
Trails separates reasoning (what to do) from execution (doing it) across every major subsystem. This isn't accidental — it's the framework's dominant architectural idiom.
Notation
Throughout the guides, look for these markers:
Brain — the planning/reasoning component
Hand — the execution/action component
Why This Matters¶
The split buys three things:
- Testability — mock the hand, unit-test the brain. Verify planning logic without touching a database or calling an LLM.
- Pluggability — swap the hand without rewriting the brain. Different storage backends, LLM providers, or vector stores slot in without changing planning logic.
- Observability — track planning cost (LLM tokens, CPU time for query
building) separately from execution cost (SPARQL queries, API calls).
The
CostScopenesting in ADR-0012a is designed around this split.
Where It Shows Up¶
Agent Planners¶
The most visible instance. Three strategies, one split.
graph LR
subgraph brain["🧠 Brain"]
PLAN["LLM produces<br/>{thought, action, action_input}<br/>or full step list"]
end
subgraph hand["🤚 Hand"]
EXEC["trails.invoke()<br/>dispatches to<br/>capability handlers"]
end
brain -->|"action + input"| hand
hand -->|"observation"| brain
style brain fill:#7c4dff20,stroke:#7c4dff
style hand fill:#ff910020,stroke:#ff9100
| Strategy | ||
|---|---|---|
| ReAct | One LLM call per step: {thought, action, action_input} |
invoke() dispatch, observation fed back |
| Plan-and-Execute | One LLM call produces ordered step list with rationales | Execute each step, replan on failure |
| Reflexion | Critic LLM evaluates answer quality | Re-execute with revised strategy |
All three reuse discover_tools() for the brain's catalog and the same
invoke() dispatcher for the hand. The strategies differ only in planning
style.
Schema Transformation¶
graph LR
subgraph brain["🧠 plan_transform()"]
M1["Exact name match"]
M2["Fuzzy match"]
M3["Type coercion"]
M4["LLM suggestion"]
M1 --> M2 --> M3 --> M4
end
TP["TransformPlan<br/>(serializable JSON)"]
subgraph hand["🤚 execute_transform()"]
SPARQL["Run SPARQL CONSTRUCT"]
APPLY["Apply to KG store"]
SPARQL --> APPLY
end
brain --> TP --> hand
style brain fill:#7c4dff20,stroke:#7c4dff
style hand fill:#ff910020,stroke:#ff9100
The TransformPlan object is serializable — you can log it, cache it,
replay it, or send it to another instance for execution. The brain and hand
can run on different machines.
ORM QueryBuilder¶
# 🧠 Brain: build the query plan
notes = (
Note.where(tag="urgent")
.annotate(comment_count=Count("comments"))
.order_by("-created_at")
.limit(10)
)
# 🤚 Hand: materialize and execute
results = await notes.afetch(ctx)
The QueryBuilder accumulates filters, projections, and sort orders as a
plan object. Nothing touches the store until .fetch(ctx) or
.afetch(ctx) materializes the plan into SPARQL and executes it.
Ingestion Pipeline¶
| Phase | Role | What happens |
|---|---|---|
| MIME detection, hash-based dedup, strategy choice | Decides how to process each file | |
Extractors, chunkers, load_document() |
Actually reads the file and creates KG nodes |
When an rml_mapping is supplied, the brain delegates to the RML engine —
which is itself a brain/hand system (generate mapping → run mapping).
Vector Retrieval¶
graph TD
subgraph brain["🧠 retrieve() dispatcher"]
DEC{mode?}
DEC -->|graph| G["SPARQL filter plan"]
DEC -->|vector| V["Embedding plan"]
DEC -->|hybrid| H["Filter-then-rank<br/>strategy"]
end
subgraph hand["🤚 Execution"]
GE["_retrieve_graph()<br/>Execute SPARQL"]
VE["_retrieve_vector()<br/>Embed + search"]
HE["_retrieve_hybrid()<br/>SPARQL → vector rerank"]
end
G --> GE
V --> VE
H --> HE
style brain fill:#7c4dff20,stroke:#7c4dff
style hand fill:#ff910020,stroke:#ff9100
The hybrid mode's filter-then-rank is a brain decision documented in the retrieval docstring. Three different hands execute the three modes.
Runtime Dispatch¶
Every invoke() call passes through the brain before reaching the hand:
sequenceDiagram
participant C as Caller
participant B as 🧠 Brain
participant H as 🤚 Hand
C->>B: invoke("notes.create", title="...")
B->>B: Lookup capability handler
B->>B: Validate arguments
B->>B: Cedar policy check
B->>B: Open cost envelope
B->>H: Call handler function
H-->>B: Result + cost
B-->>C: Response + provenance
More Brain/Hand Splits¶
| Subsystem | ||
|---|---|---|
| RML Mapping | generate_mapping() + validate_mapping() — infer types, produce RML Turtle |
run_mapping() — delegate to Morph-KGC, load with PROV |
| Enrichment | Discover unfilled placeholders, match against @enrichment registry |
Execute enrichment functions, persist results |
| Federation | SPARQL validation, query form detection, Cedar policy | ctx.kg.query() + result serialization |
| Auto-Ontology | onto_infer analyzes data patterns, onto_generate uses LLM |
Apply inferred types to KG |
Design Consequences¶
Plans Are Serializable¶
TransformPlan, PlanResult, QueryBuilder state — these are all data
structures you can log, cache, or replay. This enables:
- Audit trails — provenance records what was planned, not just what ran
- Dry runs — generate the plan, inspect it, approve it, then execute
- Distributed execution — brain on one machine, hand on another
Hands Are Pluggable¶
The same brain logic works with different backends:
| Layer | Pluggable backends |
|---|---|
| KG store | Oxigraph, Fuseki, Qlever |
| LLM | Anthropic, OpenAI, Ollama, Mock |
| Vector | SqliteVec, Qdrant |
| RML engine | Morph-KGC |
Switching backends is a config change, not a code change. The brain never knows which hand it's talking to.
Cost Tracking Lives in the Brain¶
The brain opens a CostScope before delegating to the hand. Planning tokens
(LLM reasoning calls) and execution tokens (capability-internal LLM calls)
are tracked in nested scopes with dedup via call_id / parent_call_id
(ADR-0012a).
Opportunities for Sharper Separation¶
Some subsystems still couple the brain and hand tighter than necessary:
| Area | Current state | Possible improvement |
|---|---|---|
| SHACL validation | Inline at ctx.kg.add() time |
Explicit validation phase before write |
| Vector indexing | Embedding prep and store insertion not cleanly split | Explicit IndexPlan object |
| Policy + dispatch | Interleaved in runtime.py |
Separate "can this principal act?" from "execute if yes" |
These are not bugs — they work fine. But making the plan/execute split explicit everywhere would make the framework more consistent and the architecture more legible.