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-levelconfig[name]) — per-service options table passed tonew(...).
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 andconversation.lua).session— statuscreated | running | stopped | failed, plusstarted_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_sessionmove 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_turnwraps the body inxpcalland normalizes any thrown/returned error;finish_turnclears the active turn and finalizes the result.process_raw_input(line, options)routes/-prefixed lines torun_commandand everything else torun_turn→core.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_eventsubscription 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 (defaultALT+h) renders the transcript to the pager; the inspect combo (defaultALT+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.
The detailed turn, command, dispatch, and cancellation flows are diagrammed in WORKFLOW.md — refer there rather than re-deriving them.
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:
Defaults — built in (see below).
User JSON —
~/.config/lilush/agent_smith.json(missing file tolerated whenallow_missing_user_config_jsonis set).Persisted patch — a partial config table from the config repository, deep-merged like the user-JSON layer. Written by
/config set(see Runtime overrides).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": 3,
"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_roledefaults todirect. It is a plain top-level config field (directororchestrator) 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_role—directororchestrator(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_config—api_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 timeoutstimeout,connect_timeout,send_timeout,recv_timeout,stream_stall_timeout(positive numbers, or absent — defaulted inllm/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:
Elide old tool output (always on). Tool results from the most recent
tool_output_keep_roundstool-call rounds are kept verbatim. Older results larger thantool_output_elide_over_charsare replaced with a short[elided N chars of tool output]placeholder. The assistanttool_callsand thetoolmessage structure are preserved, so linkage and history stay valid.Drop under token pressure. Size is estimated with a
chars/4heuristic (no tokenizer exists). When the estimate exceedscontext_window * trim_threshold_pct/100— wherecontext_windowcomes from the resolved model (model.lua,128000fallback) — 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 pathsdefault_model,index_file,active_role,tool_step_budget, anymodels.<key>, and the enumeratedllm_config.*,context_management.*, andempty_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 likeopenrouter/qwen/qwen3.6-27b) falls back to the string.Validate before persist. The coerced
path = valueis run throughschema.validateas 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).
| Key | Default | Covers |
|---|---|---|
llm_config.timeout | 600 | Overall deadline for the non-streaming chat request. Not used by the streaming path. |
llm_config.connect_timeout | 120 | TCP connect + TLS handshake + status/header read for the streaming (SSE) request. |
llm_config.send_timeout | 30 | Sending the streaming request body. |
llm_config.recv_timeout | 0.5 | Per-poll read granularity while reading the SSE stream. |
llm_config.stream_stall_timeout | 120 | Wall-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:
| Key | Default | Meaning |
|---|---|---|
llm_config.retry_max_retries | 2 | Transport-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_output | false | When false, a streaming call that already emitted output is not retried. |
empty_response_retry_max_retries | 2 | Retries 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:
| Role | user_facing | dispatch targets | prompt_profile |
|---|---|---|---|
direct | yes | worker, explorer | direct |
orchestrator | yes | worker, explorer | orchestrator |
worker | no | explorer | worker |
explorer | no | — | explorer |
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:
| Tool | direct | orchestrator | worker | explorer |
|---|---|---|---|---|
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 orchestrator's scratchpad.
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 expression | Resulting source | Notes |
|---|---|---|---|
| 1 | request.model_override | override | Per-call override; set by llm.chat callers and slash commands that need to bypass the rest of the chain. |
| 2 | options.models[role] | role | Per-role model from the on-disk config (options.models.<role>). |
| 3 | options.models[default_model_key] | role | Per-role model addressed by the role's default_model_key indirection. |
| 4 | runtime_config.models[role] | role | Same as #2, but from the in-memory runtime overrides (/config set). |
| 5 | runtime_config.models[default_model_key] | role | Same as #3, but from the in-memory runtime overrides. |
| 6 | options.default_model | default | Global default model from the on-disk config. |
| 7 | runtime_config.default_model | default | Global default from the in-memory runtime overrides. |
| 8 | role_spec.default_model_key | fallback | Hard-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:
Resolve active role (
direct/orchestrator/worker/explorer).Build prompt blocks in fixed order.
Join non-empty blocks with double newlines.
Prepend result as
systemmessage before transcript messages.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:
Role frame
Project context
Project-memory index (only when memories are provided)
Assembly behavior:
The
assemblehelper inprompt/template.luafilters empty blocks and concatenates non-empty blocks with"\n\n"(double newline).
Per-block details
Role frame
Source:
prompt/role.luaSelector:
role_spec.prompt_profilefrom role catalog (role.lua)API:
get_prompt_fragment(profile_name)(prompt/role.lua)
Profiles provided:
direct: full coding assistant, can dispatch worker/explorer.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.luaThe block is emitted only when
turn.projectis 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_filedefaults toINDEX.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 contextsection is appended only when the resolved index file exists and is non-empty.
Project-memory index
Builder:
prompt.luaAdded only when
project_memoriesis non-emptyStructure:
## Project memory indexproject_idper-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:
Decode JSON arguments.
Resolve the spec with role-policy enforcement.
Pre-execution cancellation check.
Run the handler with a wrapped context (
role,turn_id,cancel_token,is_cancelled()), plus inherited scope fields.Post-execution cancellation check.
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 byrg/fd(viatool/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 nobash). 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 withoutbash), so it can review what a worker changed; other roles usebashfor git.edit— hash-guarded line-range edit (replace/insert/delete) viatool/file_edit.lua; the caller passes the last-seen full-file hash, and the edit is rejected if the file changed underneath. Returnsnew_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.report— terminal; the structured completion a delegated child role must emit. Args are coerced then validated bydispatch/report_schema.lua:coerceis liberal in what it accepts (a bare-stringfindingsbecomes 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.record/recall— conversation-scoped scratchpad (key/value).note— project-memory CRUD (list/get/upsert/delete).dispatch— non-terminal delegation to a child role (see Dispatch & delegation).
The tool loop
Inside a turn (core/turn.lua), the loop runs:
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.Call the LLM, with transport retries (
retry_max_retries,retry_backoff_ms) and empty-response retries (empty_response_retry_*).Extract tool calls; append the assistant message.
Execute calls in order, decrementing the per-turn tool budget (
tool_step_budget, unlimited when unset) and checking cancellation before each.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 WORKFLOW.md §2
for the loop diagram and 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-emptysummary.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. See WORKFLOW.md §3.
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 ::
| Keyspace | Key format |
|---|---|
meta | schema_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> |
config | runtime_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). 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. See WORKFLOW.md §4.
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.
ui/renderer.lua— streaming Markdown rendering and result formatting.new_stream(options)wraps the streaming Markdown renderer for live LLM text;render_role_label,render_tool_call,render_command_result,render_dispatch_summary,render_error, andrender_cancelledformat the discrete surfaces.conversation_to_markdown/show_markdown_pagerback the/pagerand/promptviews. Styling is applied per text span via the theme.ui/status.lua— transient status-line output:write_line,count_output_lines,clear_previous_output, and the spinner (show_spinner/clear_spinner) that reflects the thinking/streaming/tool/ idle states driven by the event subscription.ui/matrix.lua— the Matrix "digital rain" thinking indicator for dispatched sub-agents (see Matrix rain below).ui/theme_accessor.lua— the single shared accessor used by the renderer, status, and prompt.new(section)subscribes to the hostthememodule for a section (defaultagent_smith) and returns a getter for the current theme-style-sheet table, degrading tonilwhen theming is unavailable.theme.lua— the theme section registered inlilpack.json. It is a palette-driven builder defining styles for the subagent role labels (niobe/link/tank/smith), the tool call display (bracket/name/args/result plus ok/failed/cancelled states), and the prompt line (see the README's Prompt line format).
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
README.md — user guide: overview, install, roles & modes, minimal config, commands.
ARCHITECTURE.md — this document: runtime, services, contracts.
LLM.md — embedded LLM client/tool/model contracts.
USER_COMMANDS.md — user-defined command files.