Skip to content

Architecture ​

Odin separates conversation handling, model generations, tool execution, and durable evidence. This page describes the source architecture, not a running installation or its selected model, accounts, hosts, or extensions.

Source baseline: 9411b73ae63ce959295fa9968f63d92c129b8578. All source links below are pinned to that revision.

Follow one request ​

text
Discord message
  -> MessageIntake: scrub, admit, deduplicate, collect attachments
  -> MessagePipeline: channel lock, session history, prompt, route
       -> chat-only completion (guest)
       -> ToolLoopRunner (tool-enabled request)
            -> capture serving identity + context budget
            -> generate -> accept tool calls -> dispatch
                 -> Discord-native domain handler
                 -> MCP dispatch
                 -> ToolExecutor -> domain handler -> local/remote operation
            <- paired tool results + retained-output pointers
            -> next generation or finalization
  -> scrub response, persist bounded session history
  -> ResponseDelivery: reply/chunks/attachments

Alongside execution:
  audit + trajectories     observations and diagnostics
  durable turn state       checkpoints and side-effect ledger
  retained output          retrievable evidence, not execution authority
  1. Intake is not execution. MessageIntake.handle logs redacted channel content, ignores self-messages, handles detected credentials before conversational intake, applies user/channel and mention policy, and deduplicates admitted messages. Prefix-command dispatch is disabled; this is conversational intake only. See src/discord/intake_pipeline.py.
  2. The pipeline owns the conversation boundary. A per-channel lock serializes requests; new threads may inherit parent context. The pipeline appends the user message, routes guests to chat without tools, and assembles the prompt and task history for tool-enabled requests. Explicit resume is checked before fresh prompt/history assembly. See pipeline admission and tool-loop invocation.
  3. The tool loop owns iterative work. Each generation captures a serving identity and budget snapshot, applies context handling, calls the model, and either finalizes or appends structured assistant tool calls and executes them. Cancellation, stuck-work checks, and iteration caps are part of the loop, not assumptions delegated to the model. See src/discord/tool_loop.py.
  4. Dispatch is an authorization boundary. The loop checks requester permissions before native, MCP, or executor routing. It records the durable transition before an external effect, distinguishes failures from uncertain outcomes, retains large output, audits the outcome, and settles the operation with a correlated tool_result. See dispatch and settlement.
  5. Delivery and memory are different products. The pipeline scrubs the final response and saves bounded history or a sanitized error marker. Delivery uses retrying replies, code-fence-aware chunks, and a file fallback for very long responses. A saved session is not a copy of every byte delivered to the user. See pipeline completion and src/discord/delivery.py.

Composition: two stages, explicit owners ​

src/discord/wiring.py is the composition root. build_services(config) constructs bot-independent services in dependency order: agent and loop managers, trajectory infrastructure, context/search/session services, authorization, execution, and the other shared services collected in BotServices. The order is intentional: consumers must receive the same service objects, not convenient replacement instances.

build_components(bot, services) constructs the bot-coupled layer: the LLM gateway, prompt builder, tool catalog, native dispatcher, delivery, recorder, tool loop, and pipeline components. Live roots use provider callables such as lambda: bot.config, because publication can replace the configuration object. Capturing the boot object would leave a consumer stale.

There is one explicit construction cycle: agent/task handlers need the tool loop, while the tool loop needs the native dispatcher. Wiring creates the dispatcher, then the loop, then the agent/task owner, attaches that owner, and only then registers native handlers. See late owner attachment.

Tool definitions are not tool dispatch ​

LayerResponsibility
src/tools/registry.pyConcatenates definition modules in exact order, builds the name map, rejects duplicate names, and decorates served descriptions with affordances.
src/discord/tool_catalog.pyBuilds the available catalog from built-ins, extensions, and published MCP definitions; applies disabled/backend visibility policy and reserves built-in names against shadowing.
src/tools/executor.pyMaps executor-routed names to domain owners. Handler lookup is late-bound at call time, preserving owner-level test seams.
src/discord/native_tools/registry.pyShares one native dispatch table between chat and loop paths; native handlers receive Discord-aware context.

The executor additionally enforces tool scope, disabled-tool policy, requester permission, host acquisition, timeout handling, risk assessment, and structured results. Its dispatch implementation is the enforcement path; showing or hiding a tool in a prompt is not authorization. Native dispatch also checks disabled-tool policy, while its callers supply the RBAC gate.

The generated tool reference describes the built-in definitions. It is not an inventory of extensions installed by an operator.

Providers, generation identity, and the Codex auth pool ​

The LLMProvider interface offers plain chat, structured chat_with_tools, and lifecycle cleanup. LLMGateway owns provider selection and guarded calls, including usage and subsystem-health wiring. Provider changes invalidate the tool catalog; when persistence is supplied, the switch restores the previous selection on persistence failure under the same provider lock.

A logical generation captures the actual client/model/effort identity together with its budget; retries must not silently switch to whatever configuration became current during an await. Client lifecycle leases prevent newly admitted work from using a retired client and let existing work drain before closing it. These are source capabilities, not a claim about which backend an installation is using.

The optional Codex integration has a dedicated CodexAuthPool:

  • It supports one or multiple credential sets and owns shared account-scoped quota tracking.
  • Account refresh is serialized per account; rotated credentials are propagated back to canonical storage. Reload reconciles newer credentials for the same account rather than overwriting them with stale copies.
  • Acquisition pins an account index to the request. Refresh runs outside the pool lock so one slow refresh does not serialize unrelated requests.
  • Rate-limit and authentication failures are marked against the account that served the request, with rotation/backoff and typed errors on exhaustion.

See acquisition, failure marking, and per-account refresh locking. No credential values or configured account identities are needed to understand this boundary.

Agents: execution lifetime versus retained results ​

AgentManager owns spawn admission, parent/child relationships, in-memory worker state, messages, and cleanup. Concurrent admission reads current configuration; admitted trees retain their own depth/child limits. A lifetime spawn counter prevents cleanup from resetting a tree's budget.

The transcript contract preserves ordered native calls and one paired result per accepted call. Missing IDs are assigned once; duplicate IDs and malformed arguments produce paired errors, not execution with invented defaults. Replay inputs are copied at acceptance. Correlation does not mean exactly-once execution or permission to repeat a mutation. See src/llm/tool_history.py and the agent transcript contract.

src/agents/tool_cycle.py executes a generation's calls sequentially, bounds tool waits by remaining lifetime, and records partial outcomes when cancelled. Parent messages interrupt child waits without cancelling the children; other tools reach a safe boundary before consuming corrections.

Final results are separate from live workers. src/agents/results.py publishes durable snapshots and pages a scrubbed immutable body on UTF-8 boundaries. wait_for_agents returns bounded status previews; use get_agent_results continuations for complete results. Result storage can outlive registry cleanup, but trajectories and saved results do not resume a running agent after process restart. See result delivery.

Sessions and context budgets ​

SessionManager.get_task_history compacts when needed, retains recent messages, relevance-filters older candidates, prepends summaries as read-only context, and applies the configured history send budget. Historical instructions are explicitly marked as completed context, not new work.

That session budget is distinct from the model-generation budget in src/llm/context_budget.py. The resolver combines capability floors or overrides, observed capacity clamps, utilization policy, a fixed non-history envelope reserve, and workload density into a frozen ContextBudgetSnapshot. Soft compaction uses the working policy budget; predictive admission uses the effective capacity budget. Rescue targets never enlarge context. Character/token density is an estimate that can be calibrated downward, not proof that a payload fits. A new configuration or observation affects the next generation, not a retry halfway through the current one.

Durable turns and recovery ​

src/turn_state/store.py keeps checkpoints and the side-effect ledger in one SQLite database, with content-addressed payload blobs alongside it. This is deliberately separate from sessions, audit logs, and disposable execution workspaces. Writes are fenced by turn generation, revision, and lease; a stale owner must stop. Once a turn is durable, a persistence failure halts further generation or mutation rather than silently dropping back to an uncheckpointed path.

src/llm/recovery.py supplies shared deadline-based generation retry policy. It retries typed capacity/transport failures with bounded backoff and model-scoped breaker admission, but fast-fails authentication, invalid-request, and exhausted-rate-limit errors. The recovery deadline bounds waiting between attempts; a healthy in-flight generation has its own transport limits.

TurnResumeManager rechecks the original request and current authorization before resuming suspended chat work. In-process auto-resume also requires the session not to have advanced. After restart, resume is explicit. Ledger repair supplies stored results or explicit uncertainty for unmatched tool calls; it does not automatically execute them again. Unresolved external effects block automatic continuation.

Managed hosts: desired state, runtime identity, access ​

HostRegistry publishes an immutable runtime view of the inventory in config.yml. An operation acquires a lease on one exact host generation. Ordinary edit/disable/remove denies new work while existing leases drain; force-revoke cancels matching leases and may leave an external outcome unknown.

HostEnrollmentManager validates candidates and SSH trust before they become targetable. The host management API stages a runtime publication, persists desired configuration, then publishes the new registry generation and clears cached host prompt information. Merely changing a configuration object is not this complete control-plane transaction.

HostAccessManager separately stores per-user/default access policy in host_access.json and resolves available aliases through the runtime inventory provider. The policy store is not a second host inventory. Request scope intersects access; knowing an alias is not permission to execute on it.

Audit, trajectories, and output retention ​

AuditLogger records execution identity, bounded scrubbed inputs and result summaries, elapsed time, errors, and optional risk/diff metadata. TurnRecorder handles trajectory/context-trace recording and reflection/lifecycle hooks. These explain what happened; the durable turn ledger governs replay safety. Neither replaces the other.

Large outputs should be retrieved, not regenerated merely because a preview was short. The retention contracts are intentionally different:

EvidenceRetrieval and lifetime
General tool outputget_tool_output reads immutable scrubbed snapshots with Unicode code-point cursors, a fixed 24-hour TTL, and default 4 MiB/result and 64 MiB aggregate quotas. Every read rechecks original owner/channel and current tool/host authority.
Agent resultsget_agent_results uses immutable-body UTF-8-byte continuations. Durable results have no automatic deletion in this source revision; live worker cleanup is separate.
Process outputmanage_process poll uses generation-bound byte pages of a bounded spool. Evidence is retained for 24 hours after observed exit; restored handles are read-only, not recovered execution authority.

See src/tools/output_retention.py, src/tools/process_manager.py, and the detailed output delivery and process retention contracts. A cursor is a retrieval coordinate, never an authorization token. Follow continuations until truncated=false; initial labelled tails are context, not permission to skip the intervening evidence. Quota failure and expiration must remain visible rather than promising a continuation that was never saved.

Next steps ​

Released under the MIT License.