Architecture¶
glia has one rule: no hidden control flow. If you want to know what the agent
does, you read agent.py. If you want to know what state it holds, you read
trajectory.py. That's the whole system.
The pieces¶
┌─────────────────────────────────────────────┐
│ Agent │
│ the loop: call model → run tools → repeat │
└───────┬───────────────┬──────────────┬───────┘
│ │ │
┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼───────┐
│ LLM │ │ ToolRegistry│ │ Trajectory │
│ (protocol) │ │ (@tool fns)│ │ state+events│
└───────┬──────┘ └─────────────┘ └─────────────┘
│
┌─────────┴─────────┐
┌────▼─────┐ ┌─────▼──────┐
│ ClaudeLLM│ │ EchoLLM │
│ (Anthropic) │(offline/CI)│
└──────────┘ └────────────┘
| Module | Responsibility | Lines you'd read to understand it |
|---|---|---|
types.py |
Content blocks, Message, Usage. Immutable, JSON-serialisable. |
~170 |
llm.py |
The provider boundary: LLMRequest, LLMResponse, and the LLM protocol. |
~90 |
tools.py |
@tool decorator (schema from type hints), ToolRegistry. |
~230 |
trajectory.py |
The run state + the Event types. The glass box itself. |
~250 |
agent.py |
The loop. run() and run_events(). |
~230 |
memory.py |
Context engineering: compactors. | ~140 |
guardrails.py |
Input/output validators. | ~90 |
structured.py |
Structured output via a forced tool call. | ~110 |
checkpoint.py |
Save/load a trajectory; a checkpointer hook. | ~70 |
evals.py |
Evals-as-tests harness. | ~140 |
providers/ |
EchoLLM (offline) and ClaudeLLM (Anthropic). |
~120 each |
The loop, precisely¶
Agent.run_events() is the single source of behaviour. Each iteration:
- (optional) compact — if a
Compactorsays the trajectory is too big, shrink it first, emitting aCompactedevent. - build an
LLMRequestfrom the current trajectory + tool schemas. - call the provider (
ModelCall→ModelResponse). Whenstream=Trueand the provider supports it, text deltas are re-emitted asModelDeltaevents first. The assistant message and itsUsageare appended to the trajectory. - if the model asked for tools: announce all calls (
ToolCalled), optionally run each past theapprovalgate (ApprovalRequested→ApprovalResolved), execute the approved ones — concurrently by default (asyncio.gather) — emitToolReturnedin call order, append all results as one user turn, loop. - otherwise: run output guardrails and finish (
RunFinished).
run() is just run_events() drained to completion. That's the whole thing.
Why these choices¶
- Blocks are a closed union, not a class hierarchy. You
isinstance/match on four types; there are no subclasses to discover or plugins to register. - Tool results are a
userturn. That's the Anthropic convention, and it keeps our message model and the wire format aligned with no translation debt. - Events are records, not commands. Hooks observe; they never mutate the loop. This keeps behaviour readable and hooks safe.
- The provider boundary is tiny on purpose. Adapting to a new model or an API change touches one file. Vendor churn can't ripple through the codebase.
EchoLLMis a first-class citizen. Deterministic, offline testing of the entire loop is a design goal, not an afterthought.
Extending it¶
- New provider: implement
async def generate(request) -> LLMResponse. ~40 lines. Seeproviders/echo.pyfor the simplest possible example. - New context strategy: implement the
Compactorprotocol (should_compact+compact). Seememory.py. - New guardrail: write a
(text) -> Nonefunction that raisesGuardrailTripped. That's the whole interface. - Observability: add a hook. Every event flows through it; export to logs, a tracer, or a UI.