Architecture¶
This document is a map of how agent6 runs end-to-end. The diagrams
are mermaid (mermaid fenced blocks render natively on GitHub). For
per-file conventions and stability rules see AGENTS.md.
For the security model (threat model, defense layers, sandbox isolation),
see security.md.
Layering¶
ui/ is the presentation layer and composition root: ui/cli, ui/tui,
ui/web, ui/acp (the four front-ends), ui/mcp_server.py (agent6 as an
MCP server), and the write helpers ui/spawn + ui/notify, over the
shared headless read-model fold (viewmodel). app/ sits between: the
application pipelines that compose the engine but are not a front-end, taking
the presentation, process-spawn, and run-dir bridge callables the front-end
injects. Boundaries are enforced by tach (see
tach.toml). Workflows never import each other; the engine
(app and everything below) never imports the UI. Crossing a boundary is
almost always a sign of the wrong design.
- cli (src/agent6/ui/cli/): argument parsing,
optional TUI spawn, top-level dispatch. Picks a workflow.
cli_mainis the one error boundary: anOperatorError(agent6.errors;ConfigErrorandMemoryStoreErrorsubclass it) means the operator's input or file is bad and prints as anERROR:refusal at exit 2; anything else is a bug and crash-reports with a saved traceback at exit 1. Config is resolved by config/layer.py (built-in secure defaults < global~/.config/agent6/config.toml< per-repo config <--config FILE), with paths + sudo/root resolution in paths.py and API keys in secrets.py. Per-repo state (config and run state together) lives out of the workspace under$XDG_STATE_HOME/agent6/<repo-id>/; the base is settable via the global-only[agent6].state_diror theAGENT6_STATE_HOMEenv var. The id is keyed on the PROJECT (the nearest enclosing.git), so running from a subdirectory reaches the same runs, memories and config. Every config edit, from any surface, goes through config/write.py: one lock-held cycle that validates the written value standalone, revalidates the merged config, and rolls back -- or keeps the write with a "kept as written" error when the fail-open lock was not held (docs/config.md). Roles:workerdrivesrun/resume,plannerdrivesplan(falls back toworker),reviewerdrivesreview+ the in-loop critic. - app (src/agent6/app/): the application
pipelines composed over the engine but never a front-end -- the
run/resume/fork/machine-agent lifecycles, the run-branch merge + finalize,
provider construction, the sandbox cross-checks (
app.confine), and the--parallelfan-out + coordinator dispatch. Never importsagent6.ui: what it can't do itself (own a terminal, render a live view, spawn a detachedagent6) the front-end injects as frozen callables (SessionFrontend,LaneRuntime), and its output goes through an injected two-channelReporter. That keepsui/presentation-only. - workflows (src/agent6/workflows/): two
exist,
loop(the agent loop drivingagent6 run/agent6 resume) andreview(the read-only review pass drivingagent6 review). The single-turncode_reviewcall shape lives here too (code_review.py); the agent loop makes its own provider calls inline. - tools (src/agent6/tools/): the fixed tool surface the LLM sees, plus dispatch.
- sandbox (src/agent6/sandbox/): the
agent6-jaillauncher and its policy. Every boundary is per command; the agent process itself is never confined.
Workflow: run¶
This is the agent. One provider, one model, one message history. The model drives by calling tools; the workflow dispatches tools, snapshots state, and tracks budget.
stateDiagram-v2
[*] --> snapshot
snapshot --> llm_call
llm_call --> dispatch: model emits tool calls
llm_call --> [*]: budget exhausted
dispatch --> snapshot: non-terminal tool
dispatch --> commit: run_verify_command (exit 0)
commit --> snapshot
dispatch --> [*]: finish_session
Notes:
- One LLM, one history, one loop. By default there is no
planner→worker handoff, no critic step, no separate reviewer agent:
multi-step work is the model calling the next tool in the same
conversation. The in-loop critic and the adversarial review panel
are opt-in (
[review]config) and layer onto this same history. - Snapshot before every LLM call.
loop_state.jsonis rewritten in the session directory (<state-dir>/<repo-id>/sessions/<bucket>/<session-id>/, out of the workspace) before each provider request, with a per-turn copy undercheckpoints/<NNNN>.json(see "Session state on disk").agent6 resume <session-id>rehydrates fromloop_state.json,agent6 fork --at-turn Nfrom the matching checkpoint; combined with the per-tool transcripts undertranscripts/, any interrupted run can be replayed deterministically up to the model call that comes next. - Per-step commits fire when
run_verify_commandreturns 0, viagit_ops.pyfrom outside the jail, onto the run's detached chain (refs/agent6/<id>/head, staged through a temp index;branch_per_runalso advances a visibleagent6/<id>branch). HEAD, your index, and your checkout are never touched, so operator or model git activity mid-run cannot collide with the run's record. Every passing step commits, so a run stays resumable and forkable; how those commits consolidate onto your branch is chosen later atagent6 sessions mergetime viagit.merge_strategy(squash/merge/ff). - DAG-as-scaffold.
add_task/update_task/list_taskswrite to a curator-owned side store: the worker's task breakdown, with explicit ordering edges viadepends_on(onadd_taskat creation orupdate_taskafter; cycle-checked by the curator). They do not pick which tool runs next, but agent6 reads the DAG to keep a small or weak model focused on a long task. Each turn it surfaces the current task -- the cursor when it still points at an open subtask, else the first dependency-satisfied pending subtask -- into the prompt, advances the cursor as tasks pass, and marks the surfaced taskin_progress. It also refusesfinish_sessionwhile the worker's own subtasks are still open (capped, so a task it cannot close cannot stall the run forever). The surfaced banner survives tier-1 elision and is re-injected after each tier-2 restart, so the worker always sees its current task without it being re-appended every turn. If the focus task holds for many turns with no forward motion (a weak model grinding one task without concluding or decomposing it), a nudge offers to split / pass / skip it -- re-firing periodically up to a small cap (a weak model was seen ignoring a single nudge); any progress resets the counter, so a healthy run never sees it. - Standing tasks and the park. A run can carry a STANDING task
(
run --standing "<goal>", oradd_task(standing=true)): the frontier's never-passing fallback, worked only when no ordinary subtask is ready -- new work always outranks it. While one exists, the soft out-of-work endings (finish_session, the settled family, a quiet turn) convert into re-entry; faults, operator verbs, the iteration cap, and a spent budget still end the run, and a re-entry with no tool call since the last one is a spin, honoured as the original end. Its sibling for a watched run: on an INTERACTIVE run (run -i/resume -i), a quiet turn parks the same in-memory conversation on the steer bridge instead of ending -- a steer from any composer or the pause menu continues it in place, and abort/undo/detach keep their meanings. One park-instead-of-exit mechanism, two triggers. - Context compaction. Long runs are kept inside the model's context
window in two tiers (thresholds in
[context]): atdrop_at_charsthe oldest tool_results are replaced by a short placeholder naming the elided call (read_file src/x.py ...), with reads of files the worker edited in the last few turns elided last (a placeholder there would force a paid re-read before the next edit; it is a priority, not an exemption, so the bound holds). A largeread_fileresult decays in two stages: first to a placeholder carrying a distilled gist of the file's load-bearing facts (one batched reviewer-model call per drop event; measured on the longhorizon bench, bare elision of reference docs halves a retention task's score under a small window), then under continued pressure to the bare marker, oldest gists first, so the byte bound always holds. Hot files are never gisted (their content is changing under edits). Atsummarise_at_charsthe elided history is summarised by thereviewermodel and the conversation restarts from (task + summary). The curator-owned task DAG survives the restart: agent6 re-surfaces the current task into the fresh context (above), so the worker resumes the right task instead of starting over. At that tier-2 restart agent6 also asks the summariser which tracked tasks the transcript shows finished and what new work it found, then marks the finished onespassedand queues the new ones in the DAG -- so task state stays accurate even though weak models rarely callupdate_taskthemselves. Compaction is visible, not silent: the events carry what was elided (call identities, gist paths) and the full restart summary, every conversation view marks elisions and restarts in place, and/statusshows the elided/gist counts. The operator steers it with/compact [focus](pause menu and composers; the focus text is appended to the summariser request) and/pin <text>, a steer the loop records and re-injects verbatim into every tier-2 restart (total pins capped at 4000 chars; an over-cap pin is delivered as an ordinary steer and the refusal is loud). Pins persist in the resume snapshot. - Cross-run memory. At run start the loop loads the active entries
from the per-repo memory store (
<state-dir>/<repo-id>/memories/, written byagent6 memory addor a previous run) and injects them as a size-capped<memories>block after the repo priors; newest entries win when the cap trims. The worker persists new knowledge withadd_memoryand retires stale entries withinvalidate_memory(non-destructive; the body stays on disk foragent6 memory list --all). Run mode only: plan and ask read memories but cannot write them, machine modes see neither. A resumed run keeps the<memories>block frozen in its snapshot, like the rest of its system prompt. Because models never calladd_memoryunprompted (measured: 46 bench legs, zero calls), the loop surfaces it twice: an advisory when verify first goes green after a red one, and a once-deferredfinish_sessionafter such a recovery if nothing was recorded. Each fires at most once per run and never on a run whose verify never failed.agent6 memory pin <id>exempts a load-bearing entry from the block's newest-win trim (never from the byte bound: pins over the cap elide oldest-first, andmemory listwarns). - Notes scratchpad. One durable rewritable document per repo
(
<state-dir>/<repo-id>/notes.md, 16000 chars), injected verbatim as<notes>and rewritten whole withwrite_notes;read_notesre-reads it mid-session.agent6 notes showprints it andagent6 notes editopens it in$EDITOR, so the operator reads and edits the same file the agent does. Where memories are append-only so an invalidation keeps its audit trail, notes are restructured freely -- resolved items deleted, duplicates merged -- which is what keeps a working document readable past twenty sessions. Whole-file replace, not patch. Over the cap the write is refused, not truncated: dropping the tail loses the newest thinking, and the refusal is what forces a prune. The block clips at the same cap on the way in, because the file is the operator's to edit and one written by hand reaches the prompt having passed no check. Never in the workspace, so never in a diff and never mounted into the jail. Same mode rule as memories: machine and agent modes see none. A resumed run keeps the<notes>block frozen in its snapshot like the rest of its system prompt, butread_notesalways reads the file, so a later leg can still pick up what it wrote. - Skills. At run start the loop resolves operator-installed SKILL.md
packs (
<data-dir>/skills/, plus[skills].extra_dirs) through the dispatcher's single resolution, so the<skills>system-prompt index and whatuse_skillserves can never diverge.always-state skills inject their full text; the rest get one index line each and load on demand. Run mode only. Delivery is measured, not assumed: small models never calluse_skillfrom the index alone, so the reliable paths arealways,/namein the pause menu, andrun --skill(see docs/config.md). finish_session(summary)is the only terminal tool. Calling it emits asession.endevent and returns control to the CLI.
Workflow: review¶
A single read-only pass (src/agent6/workflows/review.py)
over a diff (working tree, branch-vs-base, or arbitrary range) using
the workflows/code_review.py call shape. Produces structured findings; no
edits, no commits, no run_command.
stateDiagram-v2
[*] --> collect_diff
collect_diff --> code_review
code_review --> [*]
Parallel runs: fan-out and coordinator dispatch¶
Three consumers drive one primitive (a task run as a subordinate isolated run
whose branch joins back): run --parallel, the web/TUI composer's /parallel
new-work directive (it spawns run --parallel), and a live run's /parallel
steer. All three share ONE grammar,
src/agent6/directive.py
(a pure-stdlib leaf both workflows and ui import):
/parallel [spec] <task> [/parallel [spec] <task>]..., where spec is an
optional lane count or model list (omitted = one lane) and parse_spec maps a
spec to one model per lane -- the same value grammar as run --parallel <spec>.
A segment's first token counts as a spec when it contains a comma or a slash
(model ids are provider/model shaped); a bare comma-less slash-less name
(opus) stays task text, and a task whose first word is a path (src/foo.py)
parses as a bogus model spec -- start the task with a verb. Before any clone, a
spec's models are checked against the models a lane can actually run: lanes
inherit the WORKER provider (only the model is overridden per lane), so the
universe is the worker's configured model plus the worker provider's cached
listing, in one helper,
src/agent6/models/validate.py:
an unknown model refuses early with a did-you-mean when the worker provider has
a cache to check against, or proceeds with a warning when none does (a
fresh/offline machine is never blocked on a regenerable cache). All three consumers validate through this
one helper -- the run --parallel CLI preflight, the composer submit paths, and
the coordinator's ui-built group dispatcher (so workflows needs no models
dependency; a refused dispatch raises and the loop's group-failure feedback
carries the message). sessions compare is not one of them; it only reads
already-finished runs to rank them, never cloning, importing, or joining. The
primitive is pure git plumbing in
src/agent6/workflows/subrun.py
(no LLM, no UI, no process spawning), over git_ops:
clone_workspace(origin, dest): plaingit cloneof a disposable lane workspace.import_run(origin, lane_repo, branch, lane_session_dir, origin_state): fetches the lane's branch into origin and moves its session dir under<origin_state>/sessions/runs/; refuses to overwrite an existing branch or session dir.join_branch(workspace, branch): merges a branch into the current branch; returns the merged sha, orNoneafter an aborted conflict.LaneSpawner/GroupLaneSpawnerProtocols: one lane, or a sibling group, dispatched and awaited to completion.
src/agent6/app/parallel.py
is the orchestrator: it implements the spawner Protocols and is the only module
that knows how to actually run a lane; the detached spawn it drives
(ui.spawn, the same path attach/resume use) is injected as a
LaneRuntime by the CLI adapter ui/cli/parallel.py. Lane liveness/stop
requests go straight to the run-dir bridge (agent6.sessions.ipc), which
agent6.app already depends on. agent6.app is the application layer:
pipelines composed over the engine, never importing agent6.ui.
agent6 run --parallel N|model-a,model-b(dispatch_parallel/run_parallel): plans oneLaneSpecper lane, spawns each as an ordinary detachedagent6 run(its own jail andrun_commandspolicy -- see security.md), symlinks each lane's live session dir into<origin_state>/sessions/runs/as soon as it is located, and polls until every lane is terminal. Every hub (agent6 sessions, the TUI, the web hub) resolves that symlink like any other session dir, so a fan-out is visible live, not just at the end. On completion each lane is imported (import_run) and the symlink is replaced by the real directory; an un-imported lane (failed to start, still running, import refused) keeps its clone and symlink rather than losing the only copy of its work. Imported candidates are auto-compared (workflows/judge.py's structured judge call when a reviewer model is configured, else a mechanical verify-then-cost ranking) into a ranked report withagent6 sessions merge <id>lines. The auto-compare also stamps acompareblock (rank/of/winner/ranked_by/rationale/judge_cost_usd+judge_cost_partial, the judge call's group cost; the fan-out id itself is the manifest's top-levelparallel_id) into each imported lane's manifest -- the ONE writer (sessions comparestays stateless, the coordinator never compares its lanes) -- so every run view (sessions show, TUI/web run headers) shows where a lane placed and why, and the listings mark the winner with a★. Nothing merges automatically.--max-usdis per lane (total spend up to--max-usdx lane count; the orchestrator prints the$X/lane x N = $Y totalline before spawning). The web hub and TUI home new-run composers spawn the same fan-out from a/parallel [spec] <task>directive (parsed byagent6.directive); a multi-segment message spawns one detachedrun --parallel <spec>per segment (omitted spec =--parallel 1, one isolated lane). A malformed directive is refused before any spawn (all-or-nothing).agent6 sessions compare <id> <id> ...(ui/cli/_compare.py, shared by both callers so the ranking/report logic exists once): the same ranked report over any >=2 existing runs, including different-task runs; degrades to the mechanical table without a reviewer model.- Coordinator dispatch:
Workflow.lane_spawner: GroupLaneSpawner | Noneon the agent loop (workflows/loop.py) is the injection point that keepsworkflowsfrom importingui(the Protocol lives insubrun.py, workflows never importsui/cli/parallel.pydirectly;tachenforces it).run.py/resume.pybuild the real spawner (build_coordinator_spawner) and wire it in for arun-mode workflow only. A steer message starting with the exact/paralleltoken (Ctrl-C pause menu, or any steer surface) dispatches a sibling group: the loop blocks (no provider calls while lanes run), chain-commits a changed worktree first (lanes cut from the chain tip only), expands each segment into its lanes (spec-> one model per lane), then clones + spawns + awaits + imports every lane and merges each branch onto the run's chain sequentially in dispatch order (chain_merge, which also syncs the merged files into the worktree). Like the fan-out, each lane's session dir is symlinked into<origin_state>/sessions/runs/while it runs, so coordinator lanes appear in the hubs like fan-out lanes and their approvals/asks are answered there. Each SEGMENT (task) gets one DAG node stampedpassed(with the last joined sha; its note names every lane) orfailed(all lanes failed or conflicted); a conflict aborts that one merge and tells the model to resolve it manually, and the run continues either way. Eventsloop.parallel.dispatched/joined/failedrecord the fan-out; the shared transcript fold (viewmodel/transcript.py) renders them as conversation markers on every surface (dispatched: the task count + tasks, truthfully -- lane ids do not exist yet; joined: each lane's id/branch/sha/ status; a dispatch failure: the error), so the coordinator's blocked wait is visible rather than silent. The injected[parallel]summary message is what carries the user-facing outcome to the model and continues the conversation.
Depth 1. Every spawned lane carries AGENT6_SUBRUN=1; both --parallel
and build_coordinator_spawner refuse to wire a lane_spawner when it is
set, so a lane's own run can never itself fan out or dispatch.
Enforcement layering¶
security.md details which guarantee each layer provides. As a diagram:
flowchart TD
LLM[LLM choice of tool] --> Tools[tools/dispatch.py]
Tools -->|apply_edit, apply_patch, read, list, outline| FS[(workspace fs)]
Tools -->|run_verify_command, run_metric_command, run_command| Jail[agent6-jail]
Jail --> NS[user/mount/pid/ipc/uts/net NS]
Jail --> Pivot[pivot_root into minimal rootfs]
Jail --> ROBinds[strict only: RO bind .git]
Jail --> Land[Landlock FS rules]
Jail --> Sec[seccomp filter]
Jail --> Caps[NO_NEW_PRIVS]
Land -.-> Child[child process]
Sec -.-> Child
ROBinds -.-> Child
Caps -.-> Child
Workflow[workflow git_ops.py] -->|outside jail| Git[(.git)]
Workflow -. blocks .-> Push[push / --force / reset --hard]
git_ops.pyruns outside the jail (the agent's own process), so the RO bind of.gitdoes not stop the workflow from committing. It stops the worker.protect_gitis strict-only. On strict the jail read-only bind-remounts.giton top of the workspace mount. The hardened isolation (no mount namespace to carve with) grants blanket read-write on the repo cwd, so.gitis writable by jailed commands there. Carving.gitread-only on hardened would also deny new top-level entries and break toolchains like cargo/pytest that createtarget/or.pytest_cache/. The writable.giton hardened is acceptable: it is gated byrun_commands(defaultask), recoverable (branch-per-run, commits go throughgit_ops), and the surrounding container is the blast radius.- Run state is safe from jailed commands because it lives out of the
workspace (
<state-dir>/<repo-id>/), unreachable from the repo cwd that jailed commands run on.
Curator¶
The task graph is owned by an in-process GraphCurator (graph/curator.py).
The agent constructs one per run; the worker / planner / critic /
alignment-guard roles are in-process in the one loop and mutate through it. The
same process writes the rest of the run state (resume snapshot, event log,
transcripts).
flowchart LR
Agent[agent6 run<br/>main process] -->|in-process GraphCurator| Graph[(graph.jsonl, graph/*.md, cursor.json)]
Agent -->|in-process| Rest[(loop_state.json, logs.jsonl, transcripts)]
Every mutation is validated against a pydantic schema before it applies, so the
on-disk graph stays consistent. What keeps the whole session directory safe from
jailed commands is its location: it lives out of the workspace
(<state-dir>/<repo-id>/), unreachable from the repo cwd that jailed commands
run on.
One curator per run is an invariant: two live curators on one session dir cache the
graph independently, so a second one's write silently drops the first's
parent→child links. agent6 run/resume/fork therefore take a run-level
single-writer flock on <run-dir>/worker.lock (the analogue of machine.lock);
a second process on the same run refuses. A crashed writer releases the lock on
death, so resume-after-crash is never blocked. The curator additionally holds a
per-mutation flock on the session dir, guarding the files against a concurrent
operator-CLI read/write. A write-path fault (ENOSPC, a serialization error)
after the in-memory update reloads the graph from disk before surfacing, so a
later read never observes a node that was never persisted.
One live run-mode worker per CHECKOUT is the level above (sessions/lock.py, a
repo-wide flock on <state-dir>/repo.lock): run-mode workers share one working
tree, so a second one would interleave the two runs' edits into each other's
chain commits. A second agent6 run refuses loudly and PARKS the submitted task verbatim
in a new run's manifest (parked_task; listings show "parked"); the refusal
prints the two follow-ups -- agent6 resume <id> once the checkout is free
(resume runs the parked task as written), or a /parallel 1 <task> steer that
hands it to the live run as an isolated lane. Plan/ask expose no edit tools and spawn
freely; --parallel lanes work in isolated workdirs under the coordinator's
one lock.
Session state on disk¶
Each session's directory <state-dir>/<repo-id>/sessions/<bucket>/<session-id>/ holds
(the bucket is the mode plus s: runs/, plans/, asks/, machines/
for machine create authoring):
graph.jsonl: append-only journal of every task-graph mutation (curator-owned).graph/*.md: one markdown file per task node, rewritten atomically (curator-owned).logs.jsonl: the structured event stream (below), written by the main process.loop_state.json: the latest resume snapshot that drivesagent6 resume, written by the main process before each LLM call and at iteration end.checkpoints/<NNNN>.json: append-only per-turn snapshots (NNNN = zero-paddednext_iteration), each a byte-identical copy ofloop_state.jsonat that turn (the snapshot payload already carries the workspacehead_shaand curatorgraph_version).loop_state.jsonis the latest-pointer for resume;checkpoints/is the per-turn history.agent6 fork --at-turn Nrolls a run back to turn N by cloning the matching checkpoint into a new run. Kept in full (a run is dozens of turns); written by the main process alongsideloop_state.json.plan.md(plan sessions): the plan itself.finish_planningis its only writer,agent6 plan editits only editor. The planner re-reads it before every turn and is shown it whenever it differs from what it last saw, so answers the operator wrote there survive the nextfinish_planninginstead of being overwritten by the planner's own older copy.agent6 run --from-planfeeds it as a new run's task.transcripts/: full provider request/response pairs for replay, written by the main process.
A fork (agent6 fork <src>) clones a source run's state, as of a checkpoint,
into a NEW session dir with a new id: it copies the checkpoint as the new run's
loop_state.json + seed checkpoints/0000.json, REBUILDS the curator DAG
(graph/, graph.jsonl, cursor.json) at the checkpoint's graph_version,
writes a manifest with parent_session_id / forked_from_turn / forked_from_sha,
and cuts agent6/<new> at the turn's sha (additive git branch, the operator's
checkout is untouched). The source run is never mutated. One fork edge per line
lands in a per-repo lineage.jsonl at the state-dir root.
The rebuild (graph/replay.py) undoes every journal-stamped mutation newer than
that version, so a fork's tasks, statuses, cursor, and journal match the turn its
conversation came from -- no future subtasks, no passed for work its tree does
not contain. Node content the journal never records (title, rationale,
acceptance, paths) is immutable after creation and comes from the current nodes;
notes and updated_at cannot be unwound and stay current (display-only). A
checkpoint with graph_version: 0 (written before the stamp, or with the curator
unreadable) has no version to rebuild at, so its fork copies the DAG verbatim.
The fork's tree is the repo as of that committed sha, nothing more. On a gated
run (commits fire only on a green verify) an edit not yet committed at the forked
turn is absent from the fork's tree even though the copied transcript mentions
it, the same "head_sha is committed history only" posture resume documents
(resume differs only in continuing on the live working tree). The forked run
picks it back up by re-reading the real on-disk files. Modelling a fork as a
commit plus the conversation up to that turn is the design choice: predictable
and cheap, versus snapshotting uncommitted bytes into every checkpoint.
One headless core, four thin front-ends: the CLI, the Textual TUI
(src/agent6/ui/tui/),
the browser web UI
(src/agent6/ui/web/,
agent6 web), and the ACP agent an editor drives
(src/agent6/ui/acp/,
agent6 acp, see editor integration) all fold the same event stream
and render their own way. Two shared layers sit under all four: the read side
src/agent6/viewmodel/
(the SessionState/MachineState fold + its *_as_dict wire form, exactly what
agent6 attach --json and the web JSON/SSE endpoints emit) and the textual-free
write side:
src/agent6/ui/spawn.py
spawns the CLI detached, and
src/agent6/sessions/ipc.py
holds the approval / question / steer / compact-request file contract the
workflow process polls. See the web UI.
The journal is durable by contract: an append failure on anything but the
streaming deltas (role.*_delta, best-effort by design) stops the run loudly
(EventWriteError) rather than letting it run on with an unrecordable
outcome, and in-process listeners see an event only after its write landed.
The logs.jsonl vocabulary is small and stable: the data contract for
any external viewer (the fold to render-ready state lives in
src/agent6/viewmodel/state.py as a pure function, shared by the CLI, the TUI, and the web client):
| Event | Notable fields |
|---|---|
session.start |
user_task |
tool.call / .result |
name, args (preview), ok, summary; emitted as a pair for every dispatched tool, including ones a guard rejects (ok=false, trusted reason), so no call is unaccounted for. Execution tools (run_command/run_metric_command) also carry capped stdout_tail/stderr_tail like verify.end |
verify.start / .end |
cmd, exit_code, duration_s, *_tail |
loop.verify_inferred |
command (argv, [] if none), source (agents_md/manifest/llm/none); adopted_at (iteration) when a gateless run adopts one mid-run |
role.call / .result |
role, model, tokens_in, tokens_out |
role.text_delta |
streamed assistant text chunk |
role.thinking_delta |
streamed reasoning chunk (TUI "thinking" pane) |
session.steer_requested |
source ("sigint"): mid-run Ctrl-C |
budget.update |
totals + caps for input/output tokens |
approval.prompt/.answer |
id, prompt, standing (is an "allow all" on offer) / id, approved, source (frontend/stdin/session/away-deny/await-frontend/headless) |
question.prompt/.answer |
id, question, options / id, answer, source: the ask_user tool and machine questioner states |
loop.* |
agent progress: loop.auto_commit, loop.compact.*, loop.critic.*, loop.metric.*, loop.steer.* |
loop.budget |
per-iteration usage heartbeat: iteration, input_tokens, output_tokens, cache_read_tokens, cost_usd (read by agent6 sessions show) |
loop.review.* |
adversarial review panel: loop.review.start (trigger, seats), loop.review.seat (seat, model, verdict, findings), loop.review.panel (blocked, raw_blocked, decision, n_block, disarmed), loop.review.skipped |
session.end |
reason, iterations, all_passed; one shape from every exit path (loop, machine-create, interrupt fallback) |
A run_command approval is published as approval.prompt; the dashboard
TUI shows an Allow/Deny modal and writes the operator's literal choice (yes,
no, session, session-deny) to approvals/<id>.answer, which the workflow
reads, then records approval.answer. What a choice GRANTS is the asking
side's: each prompt names the SCOPE an "allow all" would cover (command, or
one MCP server), a standing answer is recorded per scope, and a gate that
offers none (fetch) sets standing: false so no front-end shows the button. The answer poll falls back
headless (stdin prompt, or deny for a machine state) only after the front-end
has stayed dead for 30 consecutive seconds, so a transient drop (a page
reload, a phone locking its browser) does not convert a pending approval into
a deny. The web UI drives the same answer-file contract (via
src/agent6/sessions/ipc.py):
while a browser watches a run it registers as the run's answer front-end, so
approval / question / steer prompts bridge to the page. The task DAG is not in this stream; it is
curator-owned and lives in graph.jsonl (read via agent6 sessions
graph).
Where things live¶
| Concern | File / dir |
|---|---|
| Config schema | src/agent6/config/model.py |
| Tool surface | src/agent6/tools/schema.py |
| Tool dispatch | src/agent6/tools/dispatch.py |
| agent loop | src/agent6/workflows/loop.py |
| Prompt/template text | src/agent6/prompts/ (pure strings; loop/review/judge/machine assemble them) |
| Review workflow | src/agent6/workflows/review.py |
| Code-review call shape | src/agent6/workflows/code_review.py |
| Jail launcher (Python wrapper) | src/agent6/sandbox/jail.py |
| Jail launcher (Rust binary) | src/agent6/jail/src/main.rs |
| Git policy | src/agent6/git_ops.py |
| Subordinate-run primitive (clone/import/join) | src/agent6/workflows/subrun.py |
| Run-dir single-writer lock | src/agent6/sessions/lock.py (the worker.lock flock; see "Curator") |
| Compare judge (structured ranking) | src/agent6/workflows/judge.py |
Fan-out orchestrator (run --parallel, coordinator spawner) |
src/agent6/app/parallel.py (pipeline), src/agent6/ui/cli/parallel.py (CLI adapter) |
| Provider clients | src/agent6/providers/ |
| Knowledge graph (curator) | src/agent6/graph/ |
| Event log + view-model fold | src/agent6/events.py (writer), src/agent6/viewmodel/ (SessionState/MachineState fold), src/agent6/ui/tui/ (textual render) |
| Front-end write bridge | src/agent6/ui/spawn.py (spawn detached) + src/agent6/ui/notify.py (desktop notify), src/agent6/sessions/ipc.py (approval/question/steer/compact file contract); shared by CLI, TUI, web |
Web UI (agent6 web) |
src/agent6/ui/web/ (stdlib HTTP server + one embedded page over the view-model + frontend) |
| Cross-run memory store | src/agent6/memory.py (store), <state-dir>/<repo-id>/memories/ (data) |
| Notes scratchpad | src/agent6/notes.py (store), <state-dir>/<repo-id>/notes.md (data) |
| Session state on disk | <state-dir>/<repo-id>/sessions/<bucket>/<session-id>/ (out of the workspace) |
Pre-1.0 stability¶
See AGENTS.md. Until 1.0 every public shape (config TOML, IPC frames, on-disk graph, CLI flags, transcript layout) is liquid; we break cleanly rather than carry shims.