7a40227cf66877b29a90447c6bdcf80641765bde
raw

Agent Smith Architecture

Overview

All file paths in this doc are relative to <repo_root>/agent_smith/ directory.

Agent Smith is a Lilush shell mode (agent_smith, declared in lilpack.json) that implements a tool-using coding agent on top of a Chat-Completions-compatible client.

The user types at the mode prompt; each non-command line becomes a turn in which the model is called in a loop, calls tools, and the loop continues until the model stops or a terminal condition is reached.

The stack is layered:

mode adapter        mode/agent_smith.lua      shell integration, UI, combos
      |
runtime             core/runtime.lua          session/turn lifecycle, services
      |
services            role model tool dispatch conversation persistence
      |             config ui prompt command  (composed, frozen handles)
      |
turn engine         core/turn.lua             the LLM/tool loop
      |
   +--------------------+-------------------+
   |                    |                   |
LLM client          tools               persistence
llm/                tool/               persistence/

The runtime owns no protocol, storage, or UI logic directly — it composes service handles and drives the turn engine, which in turn calls the LLM client and the tool executor. State is persisted through a key-value store.


Runtime & services

Source: core/runtime.lua.

Service composition

compose_services(config) (core/runtime.lua) builds ten service handles by name:

role  model  tool  dispatch  conversation
persistence  config  ui  prompt  command

Each handle is created via <module>.new(service_options(config, name)). Two override hooks exist:

  • config.service_handles[name] — inject a pre-built handle (used for testing and for the dispatch child shim).

  • config.services[name] (or top-level config[name]) — per-service options table passed to new(...).

Frozen execution context

Services and the config snapshot are deep-frozen via freeze_snapshot / freeze_table (core/runtime.lua) — frozen tables raise on write — so every turn observes an immutable execution_context (session_id, active_role, conversation_id, config_snapshot, service_handles).

Runtime state

The runtime instance tracks:

  • current_turn — the active turn record, or nil.

  • next_turn_id — monotonic turn counter.

  • conversation_state — the transcript accumulator (see Persistence and conversation.lua).

  • session — status created | running | stopped | failed, plus started_at, stopped_at, stop_reason, error.

  • usage_tracker — token totals for the parent plus per-child-role (worker, explorer).

Per-turn scope

recompute_turn_scope() resolves, per turn: cwd, the git project (root, origin, normalized, index file content), and the derived project_id (see Persistence → Project identity).

Lifecycle

  • start_session / stop_session / fail_session move the session through its status states (stop/fail cancel any active turn).

  • begin_turn(request) validates that no turn is active, mints a turn id and a root cancel token, and snapshots scope. with_turn wraps the body in xpcall and normalizes any thrown/returned error; finish_turn clears the active turn and finalizes the result.

  • process_raw_input(line, options) routes /-prefixed lines to run_command and everything else to run_turncore.turn.run.

Mode adapter

mode/agent_smith.lua integrates the runtime with the Lilush shell:

  • Lazy runtime creation with config merge on first use (defaults → user JSON → config repository → ephemeral overrides; see Config overrides).

  • An on_event subscription that drives the UI: prompt status (thinking/streaming/tool/idle/error/cancelled), the spinner, the streamed text, and tool/dispatch result rendering.

  • Combo handling: the cancel combo (default ctrl-c) cancels the active turn; the conversation-view combo (default ALT+h) renders the transcript to the pager; the inspect combo (default ALT+i) opens the Inspect View (ui/inspect.lua) over the persisted messages, with edit/delete writing back through the conversation repo and rebuilding the live session.


Config overrides

Source: config/loader.lua, config/schema.lua, persisted via persistence/config.lua.

Layered merge

loader.load_effective_config(options) merges four layers, lowest to highest precedence:

  1. Defaults — built in (see below).

  2. User JSON~/.config/lilush/agent_smith.json (missing file tolerated when allow_missing_user_config_json is set).

  3. Persisted patch — a partial config table from the config repository, deep-merged like the user-JSON layer. Written by /config set (see Runtime overrides).

  4. Ephemeral overrides — in-memory, never persisted.

Merging is a recursive deep_merge: tables merge key-by-key, scalars replace. The merged result is validated by schema.validate before use, and the function returns both the effective config and the individual layers.

Defaults

The built-in defaults applied by loader.load_effective_config (the lowest merge layer) are:

{
  "index_file": "INDEX.md",
  "llm_config": {
    "api_key_env": "LLM_API_KEY",
    "retry_max_retries": 2,
    "retry_backoff_ms": [2000, 8000],
    "stream_retry_after_output": false
  },
  "empty_response_retry_max_retries": 2,
  "empty_response_retry_backoff_ms": [500, 1000],
  "context_management": {
    "enabled": true,
    "tool_output_keep_rounds": 7,
    "tool_output_elide_over_chars": 1024,
    "trim_threshold_pct": 80
  }
}

Two additional defaults are not supplied by the config loader and so do not appear above:

  • active_role defaults to direct. It is a plain top-level config field (direct or orchestrator) with a final "direct" fallback applied when the runtime builds the execution context (core/runtime.lua); it is not a config-loader default.

  • The transport timeouts (llm_config.timeout, connect_timeout, send_timeout, recv_timeout, stream_stall_timeout) are defaulted inside the LLM client (llm/client.lua), not the config loader — see LLM timeouts and retries below.

Schema

schema.validate checks the merged config:

  • default_model — non-empty string (or absent).

  • index_file — relative safe path (no leading /, no .. segments, no drive prefix).

  • active_roledirect or orchestrator (or absent).

  • tool_step_budget — positive integer (or absent — unlimited): the per-turn tool-call budget enforced by the turn loop.

  • models — map of non-empty string key → non-empty string model id.

  • services — table (or absent).

  • llm_configapi_key_env (non-empty string, or absent), retry_max_retries (non-negative number), retry_backoff_ms (non-empty array of non-negative numbers), stream_retry_after_output (boolean), and the transport timeouts timeout, connect_timeout, send_timeout, recv_timeout, stream_stall_timeout (positive numbers, or absent — defaulted in llm/client.lua).

  • empty_response_retry_max_retries, empty_response_retry_backoff_ms — as above.

  • context_management — table (or absent): enabled (boolean), tool_output_keep_rounds / tool_output_elide_over_chars (non-negative numbers), trim_threshold_pct (number in (0, 100]).

Context management

Source: conversation/window.lua, driven from core/turn.lua. Before every LLM call the model-bound copy of the transcript (system + history, never the persisted state) is run through a two-layer policy:

  1. Elide old tool output (always on). Tool results from the most recent tool_output_keep_rounds tool-call rounds are kept verbatim. Older results larger than tool_output_elide_over_chars are replaced with a short [elided N chars of tool output] placeholder. The assistant tool_calls and the tool message structure are preserved, so linkage and history stay valid. When a result is recoverable from a durable store — the message carries a recall_key (e.g. dispatch results, auto-persisted to the scratchpad) — the placeholder names it: ... retrieve with recall(key="dispatch/<id>"), so the model can pull the full content back instead of treating it as lost. This is what makes eliding an otherwise non-recoverable dispatch result safe.

  2. Drop under token pressure. Size is estimated with a chars/4 heuristic (no tokenizer exists). When the estimate exceeds context_window * trim_threshold_pct/100 — where context_window comes from the resolved model (model.lua, 128000 fallback) — the oldest whole rounds are dropped, newest-first, until it fits.

Both layers preserve the invariants: the first system message and the most recent user message are always kept, and an assistant tool_calls message is never kept unless all its tool results are. Setting enabled to false disables both layers (no trimming at all). The policy clones any message it rewrites, so the persisted transcript and /pager always show the full, un-elided history.

The context_management.* leaves are in the runtime-override allowlist (below), so they can be changed at runtime with /config set (e.g. /config set context_management.enabled false) as well as through the user JSON config file.

Runtime overrides (/config set)

Persisted runtime overrides are a single partial-config patch table, stored in the config keyspace under config:runtime_overrides (persistence/config.lua) and deep-merged as layer 3 of the effective config. There is no entry/domain/ provenance machinery — the patch is just a config subtree.

  • /config set <dot.path> <value> sets a leaf; /config unset <dot.path> removes it (pruning now-empty parent tables).

  • Allowlist. The path must be in the curated overridable set (schema.is_overridable_path): the exact paths default_model, index_file, active_role, tool_step_budget, any models.<key>, and the enumerated llm_config.*, context_management.*, and empty_response_retry_* leaves. Unknown paths are rejected (catches typos).

  • Value coercion. Values arrive as strings and are JSON-decoded first, so 80, false, and [2000,8000] become typed; anything that isn't valid JSON (e.g. a model id like openrouter/qwen/qwen3.6-27b) falls back to the string.

  • Validate before persist. The coerced path = value is run through schema.validate as a single-leaf candidate object; invalid values are rejected and nothing is written.

The pre-release entry-document shape ({ entries = [...] }) is no longer migrated on read; if an old database still holds it, clear the overrides (/config unset each path, or delete the database) and re-set them.

LLM timeouts and retries

All timeouts are in seconds. The transport timeouts live under llm_config and are defaulted by the LLM client (llm/client.lua); override any of them per-field (via the user JSON or /config set, validated as positive numbers).

KeyDefaultCovers
llm_config.timeout600Overall deadline for the non-streaming chat request. Not used by the streaming path.
llm_config.connect_timeout120TCP connect + TLS handshake + status/header read for the streaming (SSE) request.
llm_config.send_timeout30Sending the streaming request body.
llm_config.recv_timeout0.5Per-poll read granularity while reading the SSE stream.
llm_config.stream_stall_timeout120Wall-clock with the SSE connection open but no event arriving before the stream fails with stream_stalled. There is no overall cap on a stream that keeps producing output.

Retries are two independent layers:

KeyDefaultMeaning
llm_config.retry_max_retries2Transport-error retries, including HTTP 5xx and 429 (→ up to 3 attempts). Cancellation is never retried.
llm_config.retry_backoff_ms[2000, 8000]Backoff per retry (ms); the last value repeats for further retries.
llm_config.stream_retry_after_outputfalseWhen false, a streaming call that already emitted output is not retried.
empty_response_retry_max_retries2Retries when the model returns a valid-but-empty response (→ up to 3 attempts).
empty_response_retry_backoff_ms[500, 1000]Backoff per empty-response retry (ms).

The two retry layers nest (empty-response wraps transport), so the worst-case attempt count with defaults is (2+1) × (2+1) = 9 LLM calls. Transport and empty-response retries are applied by the turn layer (core/turn.lua), not the raw LLM client; see LLM.md for the streaming/retry contract.


Roles

Source: role.lua (catalog), role/matrix.lua (policy), model.lua (model resolution).

Catalog

Four roles are registered. Each role_spec carries name, user_facing, can_dispatch, dispatch_targets, prompt_profile, and default_model_key:

Roleuser_facingdispatch targetsprompt_profile
directyesworker, explorerdirect
orchestratoryesworker, explorerorchestrator
workernoexplorerworker
explorernoexplorer

resolve_role(name) and list_roles() query the catalog. All roles use default_model_key = "default".

Policy matrix

role/matrix.lua holds two tables and two queries:

  • DISPATCH_TARGETS + check_dispatch(role, target) — enforces the dispatch edges above.

  • TOOL_MATRIX + check_tool(role, tool_name)"allow" | "deny" (unknown role/tool → "deny").

Tool availability per role:

Tooldirectorchestratorworkerexplorer
bash
read
code_map
code_grep
code_files
code_symbols
code_outline
git
edit
write
web_search
web_fetch
note
dispatch
record
recall
report

The read-only code_* exploration tools are available to every role. The git tool is orchestrator-only: it is the one role without bash, so it relies on git to review what a worker changed without a mutation vector or unbudgeted output; the other roles use bash for git. The orchestrator also has no web tools: web research is delegated to an explorer, keeping that bulk out of its context. report is the terminal tool required of delegated child roles; record / recall are the scratchpad for the user-facing roles (direct and orchestrator) — both dispatch, and dispatch results are auto-persisted to the scratchpad (see Dispatch & delegation), so both need read access to recall them.

Model resolution

model.lua select_model(request, role_spec, runtime_config) walks a deterministic eight-step fallback. The first step with a non-nil value wins; later steps are only consulted if every earlier one is nil.

#Source expressionResulting sourceNotes
1request.model_overrideoverridePer-call override; set by llm.chat callers and slash commands that need to bypass the rest of the chain.
2options.models[role]rolePer-role model from the on-disk config (options.models.<role>).
3options.models[default_model_key]rolePer-role model addressed by the role's default_model_key indirection.
4runtime_config.models[role]roleSame as #2, but from the in-memory runtime overrides (/config set).
5runtime_config.models[default_model_key]roleSame as #3, but from the in-memory runtime overrides.
6options.default_modeldefaultGlobal default model from the on-disk config.
7runtime_config.default_modeldefaultGlobal default from the in-memory runtime overrides.
8role_spec.default_model_keyfallbackHard-coded last resort defined by the role's catalog entry; always resolves because role specs require it.

The return value is { model, source, key, context_window, max_output_tokens }. context_window and max_output_tokens are looked up from the LLM model catalog (llm/model/catalog.lua, cached after first fetch) and used by the context-management and streaming layers; see LLM.md.

Role system prompts

High-level behavior:

  1. Resolve active role (direct / orchestrator / worker / explorer).

  2. Build prompt blocks in fixed order.

  3. Join non-empty blocks with double newlines.

  4. Prepend result as system message before transcript messages.

  5. Build tool specs separately (tool:llm_specs(role)), independent from prompt text.

Primary assembly entrypoint: prompt.lua (build_system_prompt, build_messages).

System-prompt block order

build_system_prompt(context, role_spec, project_memories) in prompt.lua builds blocks in this order:

  1. Role frame

  2. Project context

  3. Project-memory index (only when memories are provided)

Assembly behavior:

  • The assemble helper in prompt/template.lua filters empty blocks and concatenates non-empty blocks with "\n\n" (double newline).

Per-block details

Role frame
  • Source: prompt/role.lua

  • Selector: role_spec.prompt_profile from role catalog (role.lua)

  • API: get_prompt_fragment(profile_name) (prompt/role.lua)

Profiles provided:

  • direct: full coding assistant, can dispatch worker/explorer, has record/recall.

  • orchestrator: planning/delegation assistant, no file mutation, use record/recall.

  • worker: delegated implementation agent, may dispatch to explorer.

  • explorer: read-focused investigation agent, do not mutate files.

Project context
  • Source: prompt/project.lua

  • The block is emitted only when turn.project is present.

  • Markdown shape:

## Project information
- CWD: <turn.cwd>                       # included when available
- Repository root: <project.root|cwd>   # included when available
- Git origin: <project.origin>           # optional
- Normalized origin: <project.normalized># optional
- Project ID: <turn.project_id>          # optional

## Project context                         # optional section
<contents of <repo-root>/<runtime.index_file>>
  • runtime.index_file defaults to INDEX.md (config loader default).

  • The index file is resolved only at Git repository root (<repo-root>/<runtime.index_file>), not relative to arbitrary CWD locations.

  • The ## Project context section is appended only when the resolved index file exists and is non-empty.

Project-memory index
  • Builder: prompt.lua

  • Added only when project_memories is non-empty

  • Structure:

    • ## Project memory index

    • project_id

    • per-entry id + label (the memory's first content line, capped ~80 chars)


Tools and tool loops

Source: tool/registry.lua, tool/executor.lua, tool/builtin/*, tool/file_edit.lua; the loop itself lives in core/turn.lua.

Registry

tool/registry.lua exposes register / get / resolve / list / llm_specs. A tool spec is:

{ name, schema, handler,
  description?, role_allowlist?, role_denylist?,
  result_renderer?, is_terminal? }

Availability is a dual gate: a tool must pass both the global role_matrix.check_tool(role, name) and the spec's own local allow/deny metadata before it is offered to a role.

Executor

tool/executor.lua runs one tool call:

  1. Decode JSON arguments.

  2. Resolve the spec with role-policy enforcement.

  3. Pre-execution cancellation check.

  4. Run the handler with a wrapped context (role, turn_id, cancel_token, is_cancelled()), plus inherited scope fields.

  5. Post-execution cancellation check.

  6. Normalize the return into a tool_result: { call_id, ok, content, error, cancelled, mutation_state, metadata }.

mutation_state (none | attempted | unknown) records whether a mutating tool may have changed state when cancelled mid-flight.

Built-in tools

Registered by tool/builtin.lua:

  • bash — async shell execution with a timeout and cancellation; output truncated. No command guardrails (YOLO mode); isolation is the safety boundary, not pattern matching.

  • read — file content with line count and a content hash.

  • code_map / code_grep / code_files / code_symbols / code_outline — read-only code exploration backed by rg/fd (via tool/code/* helpers). Ignore-aware, ranked, deduped, and output-budgeted: a repo overview, content search, fuzzy filename search, repo-wide symbol lookup, and single-file symbol outline. Available to all roles (the orchestrator has no bash). Symbol extraction (tool/code/symbols.lua) is heuristic, for Lua and C.

  • git — read-only git inspection (status/log/diff/show/blame) via a fixed subcommand whitelist; output-budgeted, cannot mutate. Granted to the orchestrator only (the one role without bash), so it can review what a worker changed; other roles use bash for git.

  • edit — hash-guarded line-range edit (replace / insert / delete) via tool/file_edit.lua; the caller passes the last-seen full-file hash, and the edit is rejected if the file changed underneath. Returns new_hash, affected range, line delta, and a context snippet.

  • write — create/overwrite a file, returning the content hash.

  • web_search, web_fetch — web search and HTTP fetch. web_search defaults to the Linkup backend (tool/backend/linkup.lua, reads LINKUP_API_KEY); both still accept an injected options.search / options.fetch backend for tests. Linkup defaults are overridable via the services.tool.web_search config block (api_key_env, endpoint, depth, output_type, max_results, timeout).

  • reportterminal; the structured completion a delegated child role must emit. Args are coerced then validated by dispatch/report_schema.lua: coerce is liberal in what it accepts (a bare-string findings becomes a one-element array; omitted role fields get empty defaults), so a well-formed report succeeds on the first call instead of bouncing off a strict schema. One hard rule: an explorer report carrying findings must include at least one evidence entry, so findings (function names, paths) are grounded in a source the child actually consulted rather than fabricated.

  • record / recall — conversation-scoped scratchpad (key/value), available to the user-facing roles. Dispatch results are auto-persisted here (see Dispatch & delegation).

  • note — project-memory CRUD (list / get / upsert / delete).

  • dispatchnon-terminal delegation to a child role (see Dispatch & delegation).

The tool loop

Inside a turn (core/turn.lua), the loop runs:

  1. Build model messages and trim for the context window (conversation/window.lua) — invariants: always keep the first system message, the most recent user message, and any assistant/tool-result pair that must stay linked.

  2. Call the LLM, with transport retries (retry_max_retries, retry_backoff_ms) and empty-response retries (empty_response_retry_*).

  3. Extract tool calls; append the assistant message.

  4. Execute calls in order, decrementing the per-turn tool budget (tool_step_budget, unlimited when unset) and checking cancellation before each.

  5. Append tool results, then continue the loop.

The loop stops when: a terminal tool (report) succeeds, the turn is cancelled, the tool budget is exhausted, or the model returns no further tool calls. The outcome is reported as stop_reason ∈ { completed, cancelled, error, budget_exhausted }. See LLM.md for the retry/streaming contract.


Dispatch & delegation

Source: dispatch/job.lua, dispatch/worker.lua, dispatch/result.lua, dispatch/report_schema.lua; orchestrated by dispatch.lua.

Dispatch lets a parent role hand a scoped sub-task to a child role and receive a structured result, without the parent's full transcript leaking into the child.

Job

dispatch/job.lua builds a validated dispatch_job. The parent_role is a validation input only — the parent_role → target_role edge must pass check_dispatch — and is not stored on the job. The constructed job table holds: job_id, parent_turn_id, target_role, task, structured context (user_request_summary + role_instructions; a raw transcript is rejected unless explicitly flagged), optional sorted files, and a derived cancel_token.

Worker

dispatch/worker.lua derives a child cancel token from the parent and builds an isolated child execution context: session-child-<job_id>, active_role = target_role, a child conversation scoped under the parent (<parent_conversation>:child:<job_id>), and inherited config/service handles. It runs a child turn that must terminate via report.

Result

A single schema (dispatch/report_schema.lua) drives both the report tool and result normalization (dispatch/result.lua):

  • status ∈ { ok, partial, blocked }, non-empty summary.

  • Worker fields: changes, verification.

  • Explorer fields: findings, evidence.

  • Common optional: limitations, cancelled, error, usage.

The parent receives the normalized dispatch_result as the dispatch tool's content and continues its own loop; child token usage is attributed to the child role in the usage tracker.

Auto-persisted findings

A dispatch_result is non-recoverable — the child turn is finished and its distilled findings cannot be regenerated — yet it is the parent's primary working memory across a decompose-and-delegate task. So dispatch.lua writes the full result to the parent conversation's scratchpad under dispatch/<job_id> (the same job_id stamped onto the result), keyed off the parent's conversation_id. The write is best-effort: a failure never fails the dispatch, and a recall_key is surfaced (and threaded onto the tool-result message, then into any elision placeholder) only when the write succeeded. This keeps findings durable and addressable (recall(prefix=true, key="dispatch/") enumerates them), which is what lets context-window elision shrink the inline copy without losing the substance — and prevents the parent from re-dispatching work it already has or fabricating findings it can no longer see.


Persistence

Source: persistence.lua (service), persistence/repository.lua (factory), persistence/schema.lua (keys), persistence/codec.lua (serialization), persistence/mneme.lua (adapter), plus the per-entity repositories.

Backend

Storage is the MNEME key-value store (persistence/mneme.lua), with the database at ~/.local/share/lilush/agent_smith.mneme and full-text-search keyspaces for searchable content. Batch writes are atomic with rollback.

Repositories

persistence/repository.lua builds a set of typed repositories over a shared adapter: conversation, conversation_note, usage, project_memory, and config. All records are encoded/decoded by codec.lua, which validates record types and emits deterministic (sorted-key) output.

Keyspaces and scoping

persistence/schema.lua (schema version 2) defines the keyspaces and the key formats. A key already lives inside a named keyspace, so keys do not repeat the keyspace name — they are just the composite id segments, joined by ::

KeyspaceKey format
metaschema_version, active_conversation_id
conversation<conversation_id>
message / message_search<conversation_id>:<seq¹²> / <conversation_id>:<message_id>
conversation_note<conversation_id>:<note_id>
usage<conversation_id>:<turn_id>:parent or …:child:<job_id>
project_memory / project_memory_search<project_id>:<memory_id>
configruntime_overrides

Structural id segments (conversation_id, turn_id, project_id, job_id) must be :-free; the trailing note_id segment is opaque and may contain : (the scratchpad tools use scratchpad:<key> note ids — e.g. auto-persisted dispatch results land under scratchpad:dispatch/<job_id>). Per-conversation scans use the shared <conversation_id>: prefix via build_conversation_scope_prefix.

Project memory is keyed by project_id, so it is shared across conversations for the same repository; everything else is conversation- or turn-scoped.

Full-text indexes (written, not yet queried)

message_search and project_memory_search are full-text keyspaces populated on every message / project-memory write (and rebuildable via rebuild_message_search_index / rebuild_search_index). No code queries them yet — they exist so a future /search-style feature has a maintained index from day one. Treat the dual-writes as intentional, not dead code.

Project identity

persistence/project_identity.lua normalizes a git origin URL (SSH / HTTPS / file) to a canonical form and derives a 16-char SHA-256 project_id. This is why project memory follows the repository across sessions and checkouts.

Turn persistence

On turn completion, persistence.lua persist_turn(record) writes the turn's transcript slice verbatim, in true per-round order (user message, then each round's assistant tool_calls, its tool results, interleaved assistant text, and the final assistant message — exactly as the turn loop appended them), parent and child usage records, and a full turn-record JSON note under turn_record-<turn_id>. Child usage is keyed by the real dispatch job_id (a std.nanoid() minted by the dispatch tool and stamped onto its result), not a synthetic counter.

Auto-resume

persist_turn also records meta:active_conversation_id. On the first runtime of a session, mode/agent_smith.lua reads it and, if it names a different, non-empty conversation, transparently reopens it (pinning the id and seeding the transcript). This makes a restart resume where the user left off. It runs at most once per session and is a no-op for explicit /new and /load, and after /clear (the emptied transcript skips resume).


Cancellation & errors

Cancellation

Source: cancel.lua. A cancel token wraps a lev cancel token and forms a parent → child tree (new_root, new_child). request(token) cancels eagerly and fans out to all children, so cancelling a parent turn cascades into any in-flight dispatch child. Helpers: is_cancelled, check, with_token (merges the token into an options table). The turn engine checks cancellation at the top of its loop and before each tool call; dispatch child tokens derive from the parent's.

Errors

Source: error.lua. Errors are normalized to a canonical agent_error:

{ kind, code, message, retryable, origin, data, cause }

kind ∈ { transport, protocol, schema, tool, cancelled, budget_exhausted, policy, persistence, internal }. normalize() coerces strings, the literal "cancelled", and partial tables into this shape, applying defaults for any missing fields. Errors are normalized at the runtime and turn boundaries and surfaced through turn_result.error and stop_reason.


Events

Source: core/event.lua.

The turn engine reports its progress over a tiny event bus. M.new() returns a bus with subscribe(fn), emit(event), events() (peek), and drain() (return-and-clear). emit calls every subscriber synchronously, each wrapped in pcall so a faulty subscriber cannot break the turn, then appends the event to an accumulated list. M.event(type, turn_id, data) builds a well-formed event, stamping ts via lev.now() and validating that type is a non-empty string.

Event types are not hard-enforced — the turn loop supplies them as plain strings. The vocabulary the turn loop emits is:

turn_started            prompt_built            llm_request_started
llm_text_delta          llm_tool_call_detected  tool_execution_started
tool_execution_finished llm_request_completed   turn_completed
turn_persisted          turn_cancel_requested   llm_request_cancelled
tool_execution_cancelled dispatch_cancel_requested turn_failed

The mode adapter's on_event subscription (see Mode adapter above) is the primary consumer: it maps these events onto prompt status, the thinking indicator (spinner or matrix rain), streamed text, and tool/dispatch result rendering through the UI service.


UI & rendering

Source: ui.lua (service), ui/renderer.lua, ui/status.lua, ui/matrix.lua, ui/theme_accessor.lua, and theme.lua.

The ui service handle (ui.lua) is thin: it exposes renderer, status, and the new_stream shortcut. Agent Smith runs the terminal in raw mode (no ONLCR), so both surfaces translate bare \n to \r\n (the crnl helper) to keep streamed output from stair-stepping.

Matrix rain

Source: ui/matrix.lua.

While a dispatched sub-agent (Niobe/Link/Tank) waits on its LLM request, the mode adapter shows a Matrix-style rain of falling glyphs instead of the braille spinner — the rain is the thinking indicator for delegated work (top-level "Smith" turns keep the stock std.progress_text spinner). Columns of enigmatic 5×7 bitmap glyphs (digit-, symbol- and katakana-like), tinted with the role's palette colour, fall inside a wide strip placed in the left part of the screen.

Rendering. Frames are drawn into a term.gfx pixel canvas and displayed through the Kitty graphics protocol at a negative z-index (z=-10), so the terminal composites the rain behind the text layer — it can never clobber a text cell, which is what lets it coexist with live tool-call rendering. Pixels are transmitted via POSIX shared memory (the escape stream carries only the shm name, ~100 bytes/frame); each frame re-transmits under a fixed image id and re-places with a fixed placement id, atomically replacing the previous frame. The strip is sized proportionally (~40% of terminal width, glyph-column count capped) and the c/r placement box stretches each glyph column across two terminal cells, so the canvas pixel budget stays flat on any terminal width.

Placement. The strip is anchored relative to the cursor on every frame: save cursor → CUU rows+1 (clamps at the top margin) → CHA to the strip column → place (C=1, no cursor move) → restore cursor. Re-placing each frame keeps the rain behind the most recent output and makes it ride along as text scrolls.

Concurrency. The animation is an in-process lev.spawn task ticking at 10 fps — the LLM request yields to the lev loop, so the task is scheduled while the request is in flight. The tty has exactly one writer: each tick is emitted as a single io.write (sync-wrapped), and the mode adapter never runs the subprocess spinner while the rain is active (a second writer would corrupt the spinner's backspace-erase accounting). The task deliberately sleeps without a cancel token: stop() flips a flag and deletes the image; the task wakes once within a tick, sees the flag, and exits without drawing.

Wiring (mode/agent_smith.lua, run_user_turn_line): probe() (a pure tty/module check, cached) runs once per turn setup; on llm_request_started with a source_role, start(role) is called — it returns an inert handle (active = false) on non-dispatched roles or when graphics are unavailable, in which case the stock spinner is started instead. clear_matrix() runs alongside clear_spinner() on every output/terminal event and in the post-turn cleanup.

Completion

Slash-command and argument completion is a small peripheral subsystem (completion/slash.lua, completion/source.lua), wired through the completion block in lilpack.json. completion/source.lua enumerates the slash commands (DEFAULT_COMMANDS) and feeds /config path, model-id, and role candidates; completion/slash.lua does the prefix/suffix matching against the current input tokens.


Documentation map