The agent loop
Every turn runs through the same state machine:input_guard, output_guard, and compaction are conditional nodes. The
guards toggle via the guardrails section, compaction via the compaction
section of settings.yaml. When they are disabled they are not in the graph at all.
The nodes
input_guard
input_guard
Evaluates the incoming user message against the agent’s input guardrail
policy. If the guard blocks, the graph routes straight to
END with a
refusal message and no LLM call is made. See
Guardrails.compaction
compaction
Runs once per turn at a clean turn boundary, between
input_guard and
agent. If the whole request (system prompt, running summary, bound tool
schemas, and messages) exceeds trigger_pct of the usable input budget, it
picks an orphan-safe split point, folds the dropped span into a running
summary, and emits RemoveMessage for the dropped ids. That shrinks both the
in-flight request and the persisted checkpoint. See
Compaction.agent
agent
The LLM call. Before each call the node injects into the system prompt:
- the configured persona from the
personasection (composed with the active flavour’s persona; see Flavours) - the workspace
AGENTS.mdblock (top-level turns only) - the derived project-memory index
- long-term user memories loaded from the store
- the running compaction summary
DynamicToolNode
DynamicToolNode
Executes the tool calls the LLM requested, then routes back to
agent.
Every call passes through the pre_tool hook chain first, which is where the
permission gate lives: a deny becomes an error ToolMessage, an ask
raises a human-in-the-loop interrupt. Tool output is capped at ingestion
(max_tool_message_tokens) before it enters the message history.reprompt and reply_fallback
reprompt and reply_fallback
A reasoning-only turn that produces no text and no tool calls is re-prompted
once (the
reprompt node, bounded by MAX_EMPTY_REPROMPTS). If the empty
reply persists, a non-blank reply_fallback message is served instead, so a
turn never ends with a blank response.output_guard
output_guard
Evaluates the final assistant message against the output guardrail policy
before it reaches you. A block routes to
END with a refusal.Storage: two runtime modes
The runtime mode is selected by a single environment variable,DATABASE_URL.
Nothing else changes between the two modes: the graph, the tools, and the
policies are identical.
--thread <THREAD_ID> resumption work: the
conversation content lives in checkpoints, and <DATA_DIR>/threads.json holds
only the metadata (user, agent, flavour, title, timestamps).
The store holds long-term facts, keyed ("memory", user_id, agent_id), or
("memory", user_id, flavour) when a flavour is active, so every agent in a
flavour shares one memory. See Memory.
Package split
Ziro is auv workspace monorepo with a deliberate seam between the engine and
the frontends.
packages/ziro-core
The engine. Import package
ziro. A workspace member with its own
pyproject.toml and wheel: the graph, tools, memory, RAG, guardrails,
compaction, plugins, and the LLM provider registry.app/
The frontends:
main.py, the Textual TUI in tui/, the slash-command
surface in commands/, the onboarding flow in onboarding/, and the thin
CLI entry points in cli/.The app.* -> ziro.* alias finder
A meta-path alias finder in app/__init__.py redirects every app.<moved>
import (at any depth) to the identical ziro.<moved> module object. One
identity, no double execution. That keeps existing app.* imports working
unchanged: user configs, dotted paths written into hooks.yaml before the
migration, and back-compat scripts.
Code-first agents
Alongside the YAML agent folders,ziro.create_agent(...) builds an agent from
pluggable objects (an LLMAdapter, guardrail backends, BaseTools, a
BaseStore, a BaseCheckpointSaver, and policy configs) via the frozen
AgentSpec tree in ziro/spec.py.
SpecProfile adapts a spec to the AgentProfile duck-type the engine consumes,
and ziro.profiles.load(<agent-id-or-path>) parses a YAML agent into an
AgentSpec. Spec-driven agents never read project config layers, using spec
fields or library defaults only, and they run outside flavours.
The old
Ziro class is a deprecated alias for the spec-driven Agent.Startup shape
Two properties are worth knowing because they explain otherwise-surprising behaviour. The TUI paints before the engine is ready.python -m app.main paints the
Textual shell in roughly 1.6 seconds and it is usable immediately (input box,
transcript, footer) while the engine builds on a background thread. Any turn you
type before the engine is ready is queued and runs, in order, once it is. The
footer shows ● starting… → warming model → connecting MCP → clear.
“Defer, don’t degrade.” The embedding model load is kept off the startup
critical path, but no consumer ever returns a degraded result: it waits for the
real model. search_tools waits while the provider is warming and then uses the
semantic index; the keyword fallback fires only on a permanent failure, never
during warmup.
Pre-cache the models so launches can go offline:
Inspecting the graph
Next steps
Agents
What an agent is and how its config resolves.
Tools
Progressive loading, tiers, and namespaces.
Compaction
How long conversations stay inside the context window.