The agent loop
Every turn runs through the same state machine:input_guard, output_guard, and compaction are conditional nodes. The
guards toggle via guardrails_policy.yaml, compaction via
compaction_policy.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
agent_config.yaml(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.