This document is the architecture textbook for Mimir: package boundaries, dependency rules, the main runtime path, and where a change should land. Read it with CODEBASE_READING_GUIDE.md open, and prefer opening the cited files while reading rather than memorizing names.
Audience: contributors who need to change runtime behavior without accidentally inventing a second source of truth.
Not this document: user onboarding, provider API keys, or benchmark runbooks. Those live in the root README and the evaluation docs.
After this document, you should be able to:
- Point to the package that owns a given concern (orchestration, facts, projection, tools, permissions, UI).
- Explain why
tools/permissions/utilsmust not importagent. - Trace one user turn from CLI/TUI into durable JSONL facts and back to UI.
- Decide whether a change belongs in
AgentLoop,ContextWindowManager, a tool executor, a provider adapter, or a slash-command handler. - Name the single assembly path for create / resume / fork sessions.
Mimir is a local coding agent: a model proposes actions; Python code executes tools under policy; an append-only log keeps the audit trail.
Think in four concentric layers:
┌────────────────────────────────────────────────────────────┐
│ Presentation: app/ (TUI, slash commands, picker, stream) │
├────────────────────────────────────────────────────────────┤
│ Composition: factory, SessionBootstrap, ports │
├────────────────────────────────────────────────────────────┤
│ Orchestration: agent/ (AgentLoop, AgentSession) │
│ uses: providers, tools, permissions, skills, runtime │
├────────────────────────────────────────────────────────────┤
│ Facts & projection: context/ (+ session lifecycle) │
│ Side effects: tools/ executors under permissions/ │
└────────────────────────────────────────────────────────────┘
Three contracts matter more than any class name:
| Contract | Meaning |
|---|---|
| Facts vs view | JSONL events are durable; provider messages are a projection. |
| Coordinate vs execute | AgentLoop decides when; tools/providers do what. |
| Policy vs prompt | Safety is enforced by code paths; prompts only guide the model. |
If a design idea violates one of these, it is almost always wrong for this codebase.
Rough sizes fluctuate; treat them as orientation, not budgets. At the time of
writing, mimir/ is ~25k lines of Python across 174 files. The largest
packages are context/, app/, tools/, and agent/.
| Package | Responsibility | Read first | Must not own |
|---|---|---|---|
runtime/ |
Shared cancellation + structured user-input requests | cancellation.py, user_input.py |
Loop policy, tools, UI |
app/ |
Composition root, TUI, slash commands, UI-edge ports | factory.py, runtime.py, ports.py, tui.py |
Provider protocol translation |
input/ |
Attachment discovery, clipboard reads, and session staging | attachments.py, clipboard.py |
Provider wire encoding or widget state |
agent/ |
One-turn orchestration and the session runtime object | loop.py, session.py, loop_limits.py |
Shell/HTTP concrete work |
context/ |
Append-only facts, projection, compaction L1–L4 | store.py, writer.py, context_builder.py, manager.py |
Widgets, vendor SDKs |
session/ |
Catalog / index / new / resume / fork / share | bootstrap.py, catalog.py, resume.py |
Model completion |
tools/ |
Schemas, executors, session registry, hidden-tool list | builtin.py, registry.py, session_registry.py, hidden.py |
Final permission decisions |
permissions/ |
allow / ask / deny and grant persistence | manager.py, policy.py, grants.py |
Executing tools |
providers/ |
Internal ↔ vendor protocol adapters | types.py, factory.py, adapters |
Session persistence |
skills/ |
Discovery, routing, loading audit | discovery.py, router.py, loader.py |
Tool registration |
mcp/ |
External MCP servers exposed as tools | client + manager modules | Core loop control |
utils/ |
Sandbox access, subprocess, text helpers | sandbox_access.py |
Business orchestration |
config/ |
Settings resolution | loader / config models |
Runtime state |
eval/ |
Benchmark adapters and metrics | package entry | Product UI |
cli / app
├── session.bootstrap ──► AgentSession (tools + permissions + skills)
├── providers.factory ──► ChatProvider
├── context.manager ──► compaction decisions
└── AgentChatRunner ──► agent.loop.AgentLoop
├── input attachments → session store
├── context.writer / builder
├── providers.complete|astream
├── tools (+ PermissionAwareToolRegistry)
└── runtime (cancel / user-input requests)
cli / app
-> agent / session / context / providers / mcp / skills
agent
-> context / tools / providers / permissions / skills / runtime
session
-> agent (AgentSession object) / context / skills / permissions / runtime helpers
tools / permissions / utils
-> runtime # never agent
context
-> providers.types / tools.types # data shapes, not orchestration
providers / config
-> almost nothing above them
-
utils,permissions, andtoolsmust not importagent.- Why: those packages are leaf-ish. If they import the orchestrator, every small helper change risks circular imports and UI/test coupling.
- Where shared types live:
mimir.runtime(CancellationToken,UserInputRequest, …). - Runtime primitives are imported directly from
mimir.runtime.
-
UI and CLI depend on ports, not concrete loop internals.
mimir.app.ports:CommandHandlerLike,ChatRunnerLike,CurrentSessionLike,ContextManagerLike.mimir.agent.ports: minimalContextManagerLikefor the loop.- Why: TUI tests and alternate frontends can fake a runner without constructing a full model provider.
-
Session construction is centralized.
mimir.session.bootstrap.SessionBootstrapis the single assembly path for create / resume / fork / factory.- Why: grants path, skill catalog, AGENTS.md, tools resolution, and sandbox wiring used to drift across call sites.
-
Hidden control-plane tools are listed once.
mimir.tools.hidden.HIDDEN_TOOL_STATUS_NAMES- The session registry retains internal
task_boundaryfor the hidden classifier, but the main model request filters it out.
| Edge | Direction | Note |
|---|---|---|
| Session object lives in agent | session → agent |
Expected: bootstrap builds AgentSession. |
| Catalog maintenance | context.store → session.index (lazy) |
Keep lazy; do not add a second write path. |
| Type-only imports | e.g. runtime.user_input → ToolResult under TYPE_CHECKING |
Avoid runtime cycles. |
| Do not | Do instead |
|---|---|
Import AgentLoop from a tool executor |
Return a structured ToolResult; let the loop decide |
| Copy grant-store / skill wiring into a new service | Call SessionBootstrap |
Put vendor-specific fields on AgentSession |
Keep them in the provider adapter |
Hide a tool from the UI by special-casing tui.py |
Add the name to tools.hidden |
| “Fix” context size by deleting JSONL lines | Use compaction / projection |
This is the spine of the system. Memorize the shape, not every helper name.
User submits text and optional staged attachments in TUI / CLI
│
▼
mimir/cli.py
-> app.factory.create_mimir_app(...)
builds: store, tools (+ MCP), provider, SessionBootstrap.from_project,
ContextWindowManager, AgentChatRunner, command router, MimirApp
│
▼
app.runtime.AgentChatRunner.run_user_turn / resume_with_user_input
│
▼
agent.loop.AgentLoop
1. copy attachments beneath session storage; append user message/metadata
via the session writer (durable facts)
2. initialize the first task, or run hidden task-boundary classification
3. compact triggers:
_auto_compact
_compact_for_prompt_too_long
_compact_after_task_hash_changed
→ ContextWindowManager.compact_if_needed
4. ContextBuilder builds ChatRequest(messages, tools, system)
5. provider.complete or provider.astream
6. for each tool_call:
session tool registry (+ permissions preflight)
on ASK: pause with UserInputRequest (kind=permission_confirmation)
on direct file mutation: build trusted prewrite diff; standard asks for
permission, allowed/aggressive paths ask for review-only Apply
on allow: execute; append tool result fact
7. settle according to AgentLoopLimits; verification results return to the model as evidence
8. if the model stops with unfinished active-task-plan work, send one
ephemeral system instruction to reconcile it; it is neither a user message
nor a durable fact
│
▼
runtime events → AgentChatRunner → TUI transcript / activity / permission UI
| Durable (survive process exit) | Process-local (rebuild or lose) |
|---|---|
.mimir/sessions/<id>.jsonl facts |
SessionRuntimeState |
.mimir/attachments/<session-id>/ staged attachment bytes |
pending attachment chips in the composer |
permission grants file (permissions.json) |
pending permission original tool_call |
task_plan_updated snapshots replayed into SessionView.task_plan |
current review-card expansion state |
| skill files on disk | prompt prefix cache |
| MCP server configs | live MCP connections |
Resume rebuilds what it can by replaying JSONL. Anything that must not be lost across restarts must be a fact or an explicit grant—not only a Python object.
Successful TaskPlan mutations emit runtime tool events so the TUI refreshes
immediately. Replay later projects the same task_plan_updated snapshot; the
UI labels it as model-reported state and does not infer completion from commands,
file mutations, or passing tests. linear plans derive their sequence from
stable order; dag plans use explicit dependencies. All ordinary mutations
address stable task IDs and carry the current revision. A revision conflict is
resolved by reading task_list and retrying; it is never resolved by replacing
the whole plan.
TaskPlan sessions use a strict schema boundary. Resume and fork reject old, missing-version, and future-version event schemas before rebuilding state. There is no migration, legacy-event fallback, or tool-result reconstruction path.
| Object | Package | Role |
|---|---|---|
ChatRequest / ChatResponse |
providers.types |
Internal model I/O |
UserAttachment / PreparedAttachment |
input.attachments |
composer input and session-safe attachment metadata |
ContentPart |
providers.types |
provider-neutral text/image content projection |
Tool / ToolCall / ToolResult |
tools.types |
Schema + execution result |
PermissionRequest / decision |
permissions |
allow / ask / deny |
UserInputRequest |
runtime.user_input |
Pause for human (permission or ask_user) |
ContextCompactRequest |
context.manager |
Whether/how to compact |
AgentTurnResult / status |
agent.user_input |
Turn outcome for the app layer |
AgentLoopLimits |
agent.loop_limits |
Round / call / time budgets |
SessionBootstrap is intentionally boring: one place that knows how a
project-bound AgentSession is built.
SessionBootstrap
resolve tools (static list or tools_provider)
permission_manager = project policy + FilePermissionGrantStore(data_root)
create / resume / from_project
-> AgentSession.*
writer, runtime_state, session tool registry,
agents_md, skill_catalog, sandbox_access
Call sites:
session.new.NewSessionServicesession.resume.ResumeServicesession.fork.ForkSessionServiceapp.factory.create_mimir_app
If you are about to paste “create PermissionManager + discover skills + read
AGENTS.md” into a fifth file, stop and extend SessionBootstrap instead.
Session discovery helpers are public on purpose:
session.catalog.record_from_pathsession.catalog.build_record_from_eventssession.catalog.session_sort_key
session.index imports these public functions. Compatibility aliases such as
_record_from_path may remain for older tests/monkeypatches, but new code
should use the public names.
AgentLoop is a transaction manager for one user turn, not a kitchen sink.
It owns:
- turn lifecycle (start → model ↔ tools → settle → stop)
- compact triggers (when to ask the context manager)
- pause/resume for permissions and
ask_user - stop reasons from
AgentLoopLimits(tool_round_limit,provider_call_limit,turn_timeout)
It does not own:
- vendor HTTP details (providers)
- file/shell side effects (tools)
- token math and L1–L4 algorithms (context)
- widget rendering (app)
Related modules (splits of loop concerns):
| Module | Concern |
|---|---|
agent/tool_execution.py |
Executing and recording tool calls |
agent/tool_flow.py |
Flow control around tool batches |
agent/tool_settlement.py |
Settling tool outcomes into the turn |
agent/task_boundary_classifier.py |
Task-boundary classification |
agent/task_plan_policy.py |
Active TaskPlan lookup and one-time final reconciliation instruction |
agent/loop_limits.py |
Budgets and stop-reason enums |
Prefer named helpers over re-assembling trigger flags at each call site:
_auto_compact()_compact_for_prompt_too_long()_compact_after_task_hash_changed()
They wrap _compact_if_needed so the intent is readable in the loop.
Ownership split:
| Layer | Owner | Role |
|---|---|---|
| Append-only log | context.store.JsonlSessionStore |
Bytes on disk |
| Write API | context.writer.SessionEventWriter |
Typed appends for the session |
| Effective facts | replay → SessionView / runtime replay helpers |
What “is true now” |
| Provider projection | context.context_builder.ContextBuilder |
ChatMessage[] for this request |
| Compact routing | context.manager.ContextWindowManager |
Whether / which level |
| L1–L3 | context.compaction / context.content.* |
Deterministic compression |
| L4 | context.llm_compact |
Model-authored coding handoff |
JSONL events
-> replay -> SessionView
-> ContextBuilder -> ChatMessage[] (+ tools schema on ChatRequest)
-> provider
Invariants worth tattooing on your monitor:
- Compaction does not delete the audit log; it changes the projection.
- Provider history must never start with an orphan
role=toolmessage. - Every tool result keeps its original
tool_call_idpairing. - Tool schemas travel on
ChatRequest.tools, not by being pasted into the system prompt.
Deep dive: CONTEXT_MANAGEMENT_DESIGN.md.
model tool_call
-> PermissionAwareToolRegistry
PermissionManager.preflight
ALLOW -> execute tool
DENY -> structured denial result (still a tool message)
ASK -> UserInputRequest(kind="permission_confirmation")
AgentLoop stores original tool_call locally
UI answers by request_id
resolve_confirmation -> resume execution
ask_user uses the same UserInputRequest shape with kind="ask_user".
For write, edit, apply_patch, and delete, ToolExecutor builds a
trusted PrewriteReview before execution. In standard mode the diff is part of
the normal permission pause; an ALLOW decision (including aggressive mode or
a matching grant) still becomes a review-only Apply pause. Bypass emits the
same diff as a non-blocking event and proceeds; non-interactive benchmark
adapters can explicitly disable it. Resume rechecks the saved snapshots, and
the UI never supplies the executable call payload.
Critical safety rule: the pending original tool_call must come from local
session state, never from a model-replayed payload the user could not see.
Hidden tools (tools.hidden) still execute when called; they are only omitted
from noisy human-facing activity streams.
Deep dives: TOOLS_DESIGN.md, PERMISSIONS_DESIGN.md.
Providers convert internal ChatRequest / stream events to vendor protocols
(OpenAI-compatible, Anthropic, …) and back. They must not write sessions or
decide permissions.
Skills are discovered and routed into the prompt surface; they are not a parallel tool registry. MCP servers are extra tools, merged at composition time via the factory’s tool provider, and still pass through permissions.
Deep dives: PROVIDERS_DESIGN.md, SKILL_SYSTEM_DESIGN.md, MCP.md.
ContextBuilder also projects persisted image attachments into ContentPart
values at request time. It reads only paths that resolve under the session store;
the JSONL log stores relative paths and metadata, never image base64.
Is it how a vendor HTTP body is shaped?
-> providers/ (+ config)
Is it a new local capability the model can call?
-> tools/ (Tool + permission spec), register in builtin/session registry
Is it when allow/ask/deny happens?
-> permissions/policy.py or grants
Is it what the model is allowed to *see* from history?
-> context projection / compaction (never delete JSONL for this)
Is it stop/pause/continue of a turn?
-> agent/loop.py (+ loop_limits / tool_* helpers)
Is it a slash command or TUI widget?
-> app/ commands / views; call session services, do not reimplement them
Is it create/resume/fork wiring of grants, skills, tools?
-> session/bootstrap.py
Is it a shared cancel or user-input DTO needed by tools/permissions?
-> runtime/
- Would a lower layer import
agentonly for a type? Move the type toruntime(or another neutral package). - Creating or restoring a session? Use
SessionBootstrap. - New command or chat-runner dependency? Extend
app.portsfirst. - Tool that should not spam the status stream? Add it to
tools.hidden. - Changing compaction strategy? Prefer
context.manager/ pipeline over the loop or TUI. - Does a runtime-behavior claim in a PR description point at a real file? If not, the design is still fog.
# dependency intent: tools/permissions/utils should not import agent
rg -n "from mimir\.agent|import mimir\.agent" mimir/tools mimir/permissions mimir/utils
# single assembly path
rg -n "SessionBootstrap" mimir tests
# ports surface
rg -n "ChatRunnerLike|CommandHandlerLike|ContextManagerLike" mimir tests
# hidden tools single list
rg -n "HIDDEN_TOOL_STATUS_NAMES" mimir tests
# focused suite often used after architecture edits
.venv/bin/python -m pytest \
tests/test_app_tui.py tests/test_session_*.py \
tests/test_app_factory.py tests/test_app_runtime.py \
tests/test_cli.py tests/test_permissions_manager.py \
tests/test_permission_results.py -qUse pytest tests (or explicit files), not a bare repo-wide pytest:
generated benchmark trees can contain their own tests/ directories.
| Topic | Doc |
|---|---|
| First end-to-end reading route | CODEBASE_READING_GUIDE.md |
| TUI assembly and streaming | CLI_TUI_DESIGN.md |
| Turn stop / pause / continue | AGENT_LOOP_GUARDRAILS.md |
| Facts and compaction | CONTEXT_MANAGEMENT_DESIGN.md |
| Tools | TOOLS_DESIGN.md |
| Permissions | PERMISSIONS_DESIGN.md |
| Providers | PROVIDERS_DESIGN.md |
| Multimodal attachment path | MULTIMODAL_INPUT_DESIGN.md |
| Skills | SKILL_SYSTEM_DESIGN.md |
| MCP | MCP.md |
| Index of all tech docs | README.md |
Architecture moves in this tree preferred structural changes (extract shared modules, ports, bootstrap) over feature rewrites. That can temporarily increase line count (shims, ports, extra files) while reducing coupling. Line-count goals and decoupling goals are not the same optimization.