Skip to main content

agent86

CI

An agentic harness on the command line. A Python CLI that connects to remote or local models and lets them use tools and skills — a faithful, runnable implementation of the five-tier architecture and four pillars from The Agentic Harness (Tony Fleming, 2026).

The design contract lives in docs/ARCHITECTURE.md.


Status

v1.0.0 — complete against the architecture contract, and published. A five-tier agentic harness that runs on remote or local models and uses tools, skills, MCP servers, and sub-agents: a full-screen interactive TUI that works like a coding agent, cloud providers, remote MCP, a cost meter and security posture you can trust, a context window spent deliberately, and a flight recorder you can leave on forever. Every pillar and tier from The Agentic Harness is implemented, tested (1,229 tests in the default run, plus 14 packaging tests that build and install a real wheel), and verified live against a local model.

Tier / Pillar What's there
Tier 1 Gateway the CLI/TUI entry point + input sanitization (session lifecycle lives in orchestration/)
Tier 2 Orchestration (Pillar 1) ReAct loop, FSM state, dynamic routing, circuit breakers
Tier 3 Cognitive Anthropic · OpenAI-compatible (incl. built-in OpenRouter & Groq) · Ollama · llama.cpp/LM Studio; prompt compilation; token budgeting
Tier 4 Tools (Pillar 3) built-ins (read_file · write_file · edit_file · list_dir · run_command · python_exec · web_fetch) + memory/skill/delegate + MCP (stdio · SSE · streamable HTTP); subprocess or Docker sandbox
Tier 5 Guardrails/Obs (Pillar 4) ingress/egress scanning, HITL approvals, circuit breakers, a redacted and rotated flight recorder, OpenTelemetry spans with a configured exporter, trace show / trace export
Pillar 2 Memory working + episodic + semantic (SQLite + sqlite-vec), session persistence, automatic retention/pruning
Multi-agent sub-agents via delegate, message envelopes, broker, supervisor orchestrator
Interactive TUI full-screen Textual app: Markdown transcript, collapsible tool-call blocks, multi-line prompt with persistent history and @file mentions, session picker, live status footer, slash-command palette, arrow-key pickers, diff-showing approval modal, in-app /config model + /config mcp; plain fallback for any terminal
Skills (Pillar 3) the Agent Skills convention — SKILL.md frontmatter, progressive disclosure, a five-root search order, and allowed-tools enforced as a gate for the turn
Context & cost the conversation budgeted against the model's real context window, summarizing compaction of the oldest span, max_tokens continuation, parallel read-only tool calls, Anthropic prompt caching, cache-aware pricing, and a per-turn cost line
Cost & resilience real per-model price table + [pricing.models] overrides (limits.max_cost_usd actually trips), retries with backoff on transient provider failures, sub-agent spend rolled into the session total
Security web_fetch SSRF guard, cross-platform sandbox env allowlist, MCP stdio env scrubbing, process-tree kill on timeout

New in v1.0 — the release pass: the flight recorder redacts secret-shaped values and clips huge fields before anything reaches disk, rotates by size instead of growing forever, and can be exported (agent86 trace export, including a reconstructed OTLP span tree); OpenTelemetry spans reach a real exporter rather than the no-op global provider; the scripting contract is pinned by tests; the errors a first-run user hits name the fix; and pip install agent86 replaces a git clone (see Observability and docs/RELEASING.md). New in v0.9 — the coding-agent UX pass: a finished reply renders as Markdown with syntax-highlighted code, each tool call folds into one expandable line, the prompt is a multi-line composer with persistent history and @file mentions, sessions have names and a picker, edit_file does exact-match edits and answers with a unified diff, the approval prompt shows the change rather than 300 characters of JSON, and a skill's allowed-tools is a real gate (see Interactive TUI and Skills). New in v0.8 — the context & cost pass: the conversation is budgeted against the model's real context window instead of a flat 8000 tokens, the span that no longer fits is summarized rather than forgotten, an answer truncated at the output cap continues instead of stopping mid-sentence, a step's read-only tool calls run concurrently, the Anthropic prompt cache is used and priced, and every turn ends with one line saying what it cost (see Context management). New in v0.7 — the trustworthy pass: the cost meter is backed by a real price table (and says n/a rather than $0.00 when it doesn't know), transient provider failures retry with backoff instead of killing the turn, guardrails.egress = "redact" actually redacts what is streamed and stored, [limits] max_steps is the only step budget, delegated sub-agents trim their context and bill their spend to the parent turn, and four security holes are closed (see Security model). New in v0.6: the full-screen TUI is the default interactive UI, with in-app model/provider and MCP configuration (keyring-backed keys, live connection tests, comment-preserving config writes) and cancellable turns. Since v0.2: first-class cloud providers (OpenRouter/Groq built in, any OpenAI-compatible endpoint via config), live mid-session /model switching, automatic memory retention/pruning, and cleaner web_fetch (main-content extraction, model-friendly sizing).

Optional heavy deps degrade gracefully: no torch → hash-embedder memory; no Docker → subprocess sandbox; no mcp → MCP disabled; no opentelemetry → spans become no-ops with a note. The rule is the same every time — the feature degrades, the harness says so in one line, and the turn goes through — and there is one integration test per optional dependency holding it to that. Install extras as needed: pip install "agent86[all]".

Try it (with a running Ollama chat model, or provider API keys set):

agent86 --model ollama:qwen2.5:3b            # interactive TUI: type `/` for the command palette
agent86 --model openai:gpt-4o run "Summarize the harness in one sentence"
agent86 --sandbox docker run --yes "Use python_exec to print the OS you're running on"
agent86 run --yes "Delegate to a 'researcher' sub-agent: find X. Then summarize."
agent86 skills list ; agent86 mcp tools ; agent86 memory stats ; agent86 trace show

Route cheap vs frontier per turn in .agent86/config.toml:

[model]
router = "triage"
[model.route]
cheap = "ollama:qwen2.5:3b"
frontier = "anthropic:claude-opus-4-8"

Interactive TUI

Running agent86 with no subcommand opens a full-screen Textual app: a Markdown transcript, a multi-line prompt, and a footer status bar that stays live while a turn runs — active model, a context gauge measured against the window the harness actually budgets against, tok <in>/<out> (plus (1.9k cached) once a session has prompt-cache traffic), session cost, sandbox and approval mode, and the current phase. Turns execute on a worker thread, so streamed output arrives incrementally and the UI never freezes. Tool approvals appear as a modal dialog, and each finished turn ends with a dim per-turn read-out (see Cost tracking).

The footer stays one row. Rather than wrapping on a narrow terminal, it fits itself to the available width and sheds whole segments in a fixed order: the [Shift+Tab] hint first (it is in /help too), then the token counts (one /cost away), then the context gauge. The model name, the cost, the approval mode, and the working/phase indicator are never shed — they say what is running, what it costs, and whether it can act without asking. Widening the terminal brings the shed segments straight back.

Type / to open the command palette — an autocompleting list of every command with its description. Commands that need a choice (/model, /mode) present an arrow-key picker instead of demanding a typed argument:

/help    /config    /config model    /config mcp    /models    /model <provider:model>
/tools   /skills    /memory          /mode [ask|auto|deny]     /cost    /clear    /exit
/sessions          /resume [id]
Key What it does
Enter submit the prompt
Shift+Enter / Ctrl+J insert a newline instead of submitting
/ walk the prompt history (from the first/last line); move through the palette or a picker
Escape clear the draft; dismiss the palette; otherwise cancel the running turn
Ctrl+O expand/collapse the last tool-call block
Ctrl+Shift+O expand/collapse every tool-call block
Ctrl+C cancel the running turn; quit if none is running (or on a second press)
Ctrl+Q quit
Shift+Tab cycle the approval mode (askautodeny) live

The transcript

A finished reply is re-rendered as Markdown — headings, lists, tables, inline code, and fenced blocks with syntax highlighting — rather than arriving as its own source. It streams as plain escaped text (a half-written fence or table renders as garbage, and re-parsing the document on every delta is exactly the cost this avoids) and is re-parsed once when the reply completes. A reply with no Markdown structure is left alone, so a short answer pays nothing. [ui] markdown = false turns it off.

A tool call is one collapsible block rather than two flat lines with the interesting part truncated away:

▸ read_file({"path": "src/agent86/tui/app.py"}) → 1284 lines

Ctrl+O expands the last block, Ctrl+Shift+O expands them all, to the full arguments as pretty JSON and the full result text (capped at 200 lines with a truncated tail). The data comes from the session state, not from the display lines, so nothing you might want was thrown away on the way in. Toggling is keyboard-driven rather than click-driven — see the note in docs/BACKLOG.md.

A turn that fails names the exception type, keeps its message, and tells you where the rest is:

error: ProviderError: stream ended without a stop reason
  see agent86 trace show -s 0f3a91c2

The prompt

The prompt is a multi-line composer. Enter submits; Shift+Enter and Ctrl+J add a newline; the box grows to 8 rows before it scrolls; Escape clears the draft. Up/Down walk a persistent history — kept in [ui] history_file, capped at [ui] history_size, and shared with the plain loop, which appends to the same file. History follows bash's rules: blanks, consecutive duplicates, and any line typed with a leading space are not recorded. A missing or unwritable history file degrades to "this session only" rather than refusing to start.

@file mentions put a file in front of the model without spending a tool round trip on it:

what does @src/agent86/tui/app.py do with @"docs/ARCHITECTURE.md"?

Each path is resolved inside the workspace jail before anything is opened — a path outside it is refused and never read, not even stat'ed — then inlined as a fenced block appended to the prompt. A directory is listed (up to 200 names) instead of read; binaries are refused; a file over [tools] mention_max_bytes is refused with a note telling the model to read it with a tool instead. Refusals are shown to you and carried in the prompt, so neither side assumes a file arrived when it didn't. The palette completes @ paths as you type (directories first, .git/.venv/node_modules/__pycache__ skipped). Mentions work in --plain too.

Sessions

Sessions are named after the first thing you said to them (truncated to 60 characters), so the history is readable rather than a wall of 12-character ids:

/sessions          # recent sessions, the active one highlighted
/resume 0f3a91c2   # by id, or the 8-character prefix the listing shows
/resume            # arrow-key picker with a type-to-filter box

An ambiguous prefix is refused rather than guessed at. Resuming rebuilds the transcript from the session's messages — prompts plain, replies through the Markdown path, and every tool call a collapsed block with its arguments and result attached.

Approving a change

In ask mode a side-effecting tool raises a modal showing what the call does, not just its name and 300 characters of JSON. write_file and edit_file render a unified diff against the file on disk (syntax-highlighted, scrollable, and never parsed as console markup — a diff is full of [); run_command and python_exec show the whole command or snippet, capped at 60 lines rather than truncated mid-token. y/n answer it, as does Escape.

The plain loop asks the same question at the terminal with the same diff when stdin is a TTY:

edit_file wants to run:
--- src/agent86/config.py
+++ src/agent86/config.py
@@ -156,6 +156,7 @@
     web_allow_private: bool = False
+    mention_max_bytes: int = 200_000
Approve? [y/N]

agent86 run without --yes is non-interactive and still declines, unchanged.

Configuring from inside the app

/config model walks the whole provider setup in-app: a provider manager lists what's configured, a type-to-filter catalog picker browses the provider's live model list (with a free-text fallback), the API key is entered masked and stored in the OS keyring, a live connection test confirms the endpoint actually answers, and the exact TOML diff is shown for confirmation before anything is written. Keys are never written to config.

/config mcp does the same for MCP servers — add, edit, remove, enable/disable across stdio, SSE, and streamable HTTP. Secret values are held as ${VAR} references and resolved at connect time; a pre-save connection test starts the server and enumerates its tools; and after saving, the server's tools are mounted into the running session without a restart.

Both write through a comment-preserving TOML writer, defaulting to user scope (~/.agent86/config.toml) with a project-scope option.

Plain mode

--plain, AGENT86_PLAIN=1, or a non-TTY stdin/stdout runs the dependable stdlib input() loop instead — same slash-commands (they share one registry), no full-screen app. It shares the TUI's prompt history file, expands @path mentions through the same module, and asks for approval with the same diff; what it doesn't have is a line editor, so history is append-only there (see docs/BACKLOG.md). run and run --json are unaffected and never import Textual.

[ui]
tui          = true                  # false forces the plain loop (pre-v0.6 `status_line` works too)
markdown     = true                  # re-render a finished reply as Markdown in the transcript
history_file = "~/.agent86/history"  # shared by the TUI and the plain loop
history_size = 1000                  # entries kept; the oldest are dropped past the cap

[tools]
mention_max_bytes = 200_000          # largest file an `@path` mention may inline

Configuring model providers

Everything in this section can also be done from inside the app with /config model — including storing the key in the OS keyring and testing the connection before saving.

Models are named provider:model. Config lives in ~/.agent86/config.toml (user) or ./.agent86/config.toml (project). API keys are never stored in config — each provider names the environment variable that holds its key, and the key is read at call time.

Built-in providers: anthropic, openai, openrouter, groq, ollama, llamacpp. The last four openai/openrouter/groq/… all speak the OpenAI-compatible API.

Claude (first-class, and the default) — just set the key:

export ANTHROPIC_API_KEY=sk-ant-...
agent86 -m anthropic:claude-opus-4-8 run "hello"

OpenRouter / Groq ship as ready-to-use prefixes — set the key and go:

export OPENROUTER_API_KEY=sk-or-...
agent86 -m "openrouter:anthropic/claude-3.7-sonnet" run "hello"

export GROQ_API_KEY=gsk-...
agent86 -m "groq:llama-3.3-70b-versatile" run "hello"

Any other OpenAI-compatible endpoint (Together, Fireworks, Azure OpenAI, vLLM, LM Studio, …) works by adding a [providers.<name>] block with a base_url. The name becomes a first-class prefix; add api_key_env if the endpoint needs a key (omit it for a keyless local server):

[providers.together]
base_url    = "https://api.together.xyz/v1"
api_key_env = "TOGETHER_API_KEY"
max_retries = 2                          # transient 429/5xx/connection failures (0 = off)

[providers.localvllm]
base_url = "http://localhost:8000/v1"    # keyless local endpoint

[model]
default = "together:meta-llama/Llama-3.3-70B-Instruct-Turbo"
agent86 -m "localvllm:my-model" run "hello"

To override an endpoint (e.g. point openai at an Azure deployment), set its base_url and api_key_env under [providers.openai]. Run agent86 models to see what's configured.

Connecting MCP servers

Everything in this section can also be done from inside the app with /config mcp — with a pre-save connection test and live mounting, no restart.

Each [mcp.servers.<name>] block mounts an external MCP server's tools as first-class harness tools (same schema, approval gating, and tracing). Servers are reached over one of three transports:

# stdio — spawn a local subprocess (transport inferred from `command`)
[mcp.servers.files]
command = "npx"
args    = ["-y", "@modelcontextprotocol/server-filesystem", "."]

# streamable HTTP — connect to a remote URL (the default when `url` is set)
[mcp.servers.remote]
url = "https://mcp.example.com/mcp"
[mcp.servers.remote.headers]      # optional auth / custom headers
Authorization = "Bearer sk-..."

# SSE — the legacy HTTP transport; opt in explicitly
[mcp.servers.legacy]
url       = "https://mcp.example.com/sse"
transport = "sse"

Set exactly one of command (stdio) or url (sse/http); transport is inferred but can be given explicitly (stdio | sse | http). Add enabled = false to keep a server configured but unmounted. Inspect with agent86 mcp list and agent86 mcp tools. Requires the mcp extra (pip install -e ".[mcp]").

Skills

A skill is a folder with a SKILL.md in it: YAML frontmatter the harness reads, and a body of instructions the model loads on demand. Only each skill's name and description sit in the system prompt; the full instructions arrive when the model calls use_skill, so a dozen skills cost a dozen lines of context rather than a dozen documents.

.agent86/skills/release-notes/
├── SKILL.md          # frontmatter + instructions
└── reference.md      # resources the skill may point at
---
name: release-notes
description: >
  Draft the CHANGELOG entry for a release from the commit range,
  grouped Added / Changed / Fixed.
allowed-tools: run_command read_file edit_file
---

1. `git log --stat <base>..HEAD` for the range.
2. Group by workstream, not by commit order.
...

Frontmatter follows the Agent Skills convention: the closing delimiter is a --- on its own line (so a horizontal rule in the body no longer truncates the skill), block scalars (> folded, | literal), quoted values, and inline or block lists all parse. PyYAML is used when it happens to be installed and a built-in mini parser covers the same ground when it is not — a skill must never need a dependency. license and metadata are carried through.

Search order — first root wins, so a project skill shadows a user skill and nothing shadows the project:

# Root Scope
1 ./.agent86/skills project
2 ./.claude/skills project
3 ~/.agent86/skills user
4 ~/.claude/skills user
5 [skills] paths extra directories from config

Project roots resolve against the workspace, not the process CWD. Every existing root is also granted to the sandbox policy, so a skill's bundled resources — which for a user skill live outside the workspace — are readable instead of a jail error on the first "see reference.md".

allowed-tools is a gate, not a comment. While a skill is active, ToolRegistry.dispatch refuses any tool outside a non-empty allowed-tools, naming the skill and the list so the model can re-plan rather than retry. The list is space-delimited per the convention (commas, brackets, and YAML lists are tolerated). use_skill itself is always callable — a skill that forgot to list it would be a one-way door — activating another skill replaces the restriction, and the restriction is lifted at the end of every turn. The system prompt names each skill's allowed-tools, so the model knows the boundary before a refusal costs it a step.

[skills]
enabled = true
paths   = ["/opt/shared-skills"]   # searched after the four conventional roots

Inspect what's discovered with agent86 skills list / agent86 skills show <name>, or /skills in the app.

Context management

Before v0.8 the conversation was trimmed to a flat 8000 tokens no matter which model was answering — which threw away ~96% of a 200k Claude window you are paying for, and overspent a 4k local model into a context-length error. The budget is now derived from the model's real window, and the span that no longer fits is summarized instead of forgotten.

The window. Resolved in priority order, by cognitive/capabilities.py:

Source What it gives Notes
[model.context_window] your explicit override looked up by full provider:model ref, then bare model id
the provider, where the server owns the window ollama[providers.ollama] num_ctx; llamacpp → 8192 llama.cpp's -c isn't discoverable over the API, so guessing 128k from llama3.1 would hand the budget a number the server truncates
the built-in family table Claude 4.x/5.x 200k; gpt-5 400k, gpt-4.1 1,047,576, gpt-4o 128k, o-series 200k; common open-weights ids (llama-3.x 131,072, qwen/mixtral/mistral 32,768, gemma 8,192, phi 16,384) matched as a substring of the model id
the default 8192 the smallest window any modern model ships with

The budget. Every request is measured against the window, minus what is already in the request before any history: the compiled system prompt and the tool schemas, minus limits.context_reserve_tokens of headroom (slack for the provider's token counting differing from ours — an error the model call otherwise pays for with a hard 400), minus the output cap the call will ask for. The reserve is clamped to half the window so a small local model isn't starved, and limits.max_context_tokens is an optional hard cap applied last, for anyone who wants to spend less than the window allows. It is recomputed before every request — skills, MCP servers and the episodic recall note all change the overhead mid-session — and shared with sub-agents so they trim to the same number.

Compaction. When the conversation outgrows the budget, [limits] compaction decides what happens to the oldest turns:

Mode Behaviour
"summarize" (default) the oldest prefix is replaced by a model-written digest — GOAL / DECISIONS / FACTS / OPEN, instructed to reproduce paths, identifiers and numbers verbatim — written by the cheap route model when routing is on, else the current provider. It rides on a user message headed [Conversation summary — earlier turns compacted].
"drop" the pre-v0.8 sliding window: the oldest messages are discarded.

An assistant message that requested tools is never separated from its tool results, the last 6 messages and the whole current turn are never compacted, and compaction never raises — a failed or empty summary falls back to dropping and records compaction status="failed" in the trace. The compacted history is persisted immediately so a resumed session sees it, and the originals are archived verbatim to episodic memory (held out of recall, so a later turn is never handed a raw transcript). You see it happen: [compacted N messages into a summary] or [compaction failed; dropped N messages] appears dim in the transcript.

Continuation. A completion that stops because it hit the output cap (stop_reason is max_tokens) with no tool calls used to just end mid-sentence, with the turn reported as done. The harness now asks the model to continue where it left off — up to 3 times per turn, each one a full step the circuit breaker counts and budgets, shown as [continuation k/3] — and stitches the pieces into one assistant message, so neither the partials nor the harness's own prompts survive into the history.

Parallel tool calls. A model that asks for five files in one step no longer waits for five sequential round-trips. Approvals for the whole step are resolved first (sequentially — the gate may prompt you, and must ask exactly once per call), then read-only calls run together on a 4-worker pool, then side-effecting calls run one at a time in the order the model asked for them: two writes racing could interleave edits to one file, and a write racing a read could hand the model a half-written one. Results are observed in call order regardless of completion order. [limits] parallel_tools = false restores strictly sequential execution.

[limits]
max_context_tokens    = 0           # optional HARD cap; 0 = none (was a flat 8000 budget)
max_output_tokens     = 8192        # tokens the model may generate when no provider cap is set
context_reserve_tokens = 4096       # headroom kept free inside the window
compaction            = "summarize" # summarize | drop
parallel_tools        = true        # run a step's read-only tool calls concurrently

[model.context_window]
"ollama:qwen2.5:3b" = 16384         # override the resolved window for one model

[providers.anthropic]
max_tokens = 8192                   # per-provider output cap (None = the provider's own default)

Cost tracking

The status footer and /cost report what a session actually spent, and [limits] max_cost_usd is a real circuit breaker that trips on it. Both read cognitive/pricing.py, which ships a built-in price table in USD per million tokens:

Provider Priced from Notes
anthropic the Claude API model/pricing reference dated snapshots resolve by prefix
openai developers.openai.com pricing, fetched 2026-09-19 gpt-*, o* families
ollama, llamacpp free: they run on your hardware, so $0.0000 is the truth
groq, openrouter deliberately unpriced (see below)

A lookup tries the full provider:model ref, then the bare model id, then a dated-snapshot prefix — so anthropic:claude-sonnet-5-20260101 resolves to the claude-sonnet-5 entry. (Only a dated suffix is stripped: a loose longest-prefix rule would happily price gpt-5.6-sol off the gpt-5 row.)

Unknown is not zero. Groq's rates change often and OpenRouter ids are vendor/model with per-route pricing that can't be derived from the id, so neither is in the table. A model with no known rate reads cost n/a (unpriced model) in the status line rather than $0.0000 — showing a free call for a paid one is the one mistake a cost meter must not make. Local models are a separate, correct zero.

Override or add a rate with [pricing.models]. Keys are a provider:model ref (which wins) or a bare model id, and overrides beat the built-in table:

[pricing.models."anthropic:claude-sonnet-5"]
input_per_mtok  = 3.0
output_per_mtok = 15.0

[pricing.models."my-finetune"]          # bare id: matches any provider
input_per_mtok  = 0.5
output_per_mtok = 1.5

[limits]
max_cost_usd = 5.0                      # trips the circuit breaker for real now

Delegated work counts too: a sub-agent's usage and cost roll up into the parent turn's totals and against the same cap.

Prompt caching

Every turn used to re-send the whole system prompt and tool list at full input price, though neither changes across a session. The Anthropic adapter now marks the stable prefix as cacheable: caching is a prefix match over tools → system → messages, so one breakpoint on the last tool caches the tool list and one on the system block caches tools + system — both stable while the conversation after them is not. A marker is placed only when the prefix it closes clears that model's minimum cacheable length (512 tokens on the newest models, 4096 on Opus 4.6/4.5 and Haiku 4.5 — the minimum is not monotonic across generations), because below it the API silently ignores the marker and the breakpoint is spent for nothing. At most two of the four available breakpoints are used.

[providers.anthropic]
prompt_cache = true     # false for an endpoint that proxies Anthropic and rejects cache_control

Caching is billed at its own rates, so the meter tells the truth about it: cache reads at 0.1× the input rate, 5-minute cache writes at 1.25×, and the uncached remainder at the input rate (with a per-model override where a model prices reads differently, and optional explicit cache_read_per_mtok / cache_write_per_mtok in a [pricing.models] entry). Billing every prompt token at the input rate made limits.max_cost_usd wrong in both directions: a fully-cached turn is a tenth of the price, a cache write a 25% premium. OpenAI's cached_tokens is read the same way.

/cost reports the session's cache traffic — cache read N written N tok, plus saved $X where it can be computed — alongside the usual totals.

The per-turn line

The session total has been climbing all afternoon; the number you actually watch while deciding whether to hit Escape is this turn's. Every finished turn now ends with one dim read-out:

— 3 steps · 2 tools · 4.1k in / 612 out (1.9k cached) · $0.0123 · 8.2s

It appears on the TUI transcript, in the plain loop, and on agent86 run's stderr (non-JSON), all through one formatter so they cannot drift. An unpriced model reads cost n/a (unpriced model) rather than a fabricated $0.0000, and the cached parenthetical is dropped when nothing was cached. It is written on the error path too — a turn that failed halfway still spent tokens.

run --json carries the same summary under an additive turn key; every pre-existing key is untouched, so the scripting contract holds.

agent86 run --json "hello" | jq '.turn'

Resilience

A transient provider failure no longer ends a turn you've already paid the prompt for. cognitive/retry.py retries 429 / 500 / 502 / 503 / 504 and transport errors (connect failures, connect timeouts, protocol errors) with exponential backoff plus equal jitter, honouring Retry-After as either delta-seconds or an HTTP-date:

[providers.anthropic]
max_retries = 2        # per-provider retry budget; 0 disables retrying

Two rules keep it honest. Nothing is retried once the first delta has been streamed — a second attempt would duplicate text you've already read, so that failure surfaces as a ProviderError instead. And the Anthropic provider hands max_retries to the SDK client rather than wrapping a client that already retries.

Whatever isn't retryable fails cleanly: the turn aborts into the ERROR phase, a turn_end status="error" lands in the flight recorder, the session is persisted and resumable, and you get a ProviderError naming the provider and model — instead of a half-written turn and a raw decode error.

Egress redaction is on the output path. With [guardrails] egress = "redact", the step's text deltas are buffered and replayed from the inspected text, so a leaked secret is never shown, never stored in the transcript or episodic memory, and never recalled later (a cancelled turn still gets its buffered partial, redacted). warn and off stream live as before. In both warn and redact, tool-call arguments are scanned as well — a model that reads a key from a file and posts it to a URL never puts it in its prose. Findings are recorded, not blocked: the approval gate is what stops side effects.

Security model

The model is untrusted, so the harness — not the model — decides what a tool may reach.

  • Sandbox environment allowlist. Tool subprocesses get a curated environment, never yours. PATH, the locale and encoding vars, plus the platform set (Windows: SYSTEMROOT, COMSPEC, APPDATA, TEMP, …; POSIX: HOME, USER, SHELL, TMPDIR, TERM, TZ, the LC_* and XDG_* families, and the CA-bundle vars so git/pip/npm still work). Everything else — every API key on the machine included — is scrubbed.
  • [sandbox] env_passthrough forwards extra variables by name when a tool genuinely needs one. Values are read from the parent environment at spawn time, so no secret is written to config. Credential-looking names (*TOKEN*, *SECRET*, *PASSWORD*, *API_KEY*, *_KEY) are refused even when named explicitly, with a logged warning: the allowlist must not become a way to hand the model's subprocesses the key that pays for the model.
  • MCP stdio servers get the same scrubbed environment, plus whatever that server's own env block asks for. A third-party MCP subprocess no longer inherits the whole host environment; a server that needs one token gets exactly that token via ${VAR}.
  • web_fetch SSRF guard. Every hop is vetted before a connection: http/https only; all A/AAAA answers resolved and refused when loopback, private, link-local, multicast, reserved, or unspecified (IPv4-mapped, 6to4, and Teredo forms unwrapped first); redirects followed manually with a bound of 5 hops so a public host can't bounce the fetch into your intranet; the body streamed and stopped at 2 MB before any decoding; and a textual content type required. Cloud metadata (169.254.169.254), your local Ollama, and RFC1918 hosts are all off-limits.
  • [tools] web_allow_private is the escape hatch for local development targets — off by default, and named in every refusal message.
  • Process-tree kill on timeout. A timed-out command takes its children with it: each runs in its own process group (CREATE_NEW_PROCESS_GROUP on Windows, start_new_session on POSIX) and the whole group is killed (taskkill /T /F or killpg). Under Docker, each run gets a unique --name and a timeout follows up with docker kill, so the container dies with the client. stdin is closed (EOF) rather than inherited, so a command that reads stdin fails fast.
  • API keys are never written to config, in any flow. Config names only the env var (api_key_env) or holds a ${VAR} reference resolved at connect time; keys live in the environment or the OS keyring, and config_writer.py refuses secret-looking leaf keys outright.
[sandbox]
mode            = "subprocess"          # subprocess | docker  (validated enum)
env_passthrough = ["GIT_SSH_COMMAND"]   # names only; secret-looking names are refused

[tools]
web_allow_private = false               # true lets web_fetch reach localhost/RFC1918

[guardrails]
ingress = "warn"                        # off | warn | block
egress  = "redact"                      # off | warn | redact

[limits]
tool_timeout_s = 60                     # per-tool budget (was derived from max_wall_clock_s)
max_steps      = 40                     # the only step cap — no hidden 12-step ceiling

[agents]
max_steps = 8                           # per-sub-agent cap, clamped by limits.max_steps

A typo in any of the enum fields (egress = "redcat") now fails validation with the allowed values named, rather than silently turning the guardrail off.

Observability

Every turn is recorded twice: always to a local flight recorder, and — when you ask for it — to an OpenTelemetry collector.

The flight recorder is an append-only JSONL file under ~/.agent86/traces, tagged with the session id, covering turn boundaries, model calls, tool calls, guardrail hits and errors. It needs no collector and no network, and it is the thing agent86 trace show reads. Two properties make it safe to leave on forever:

  • It redacts. Everything the harness sees flows through it — your task text, the model's tool arguments, whatever a tool read off disk — so a key pasted into a prompt, or a .env a tool happened to cat, used to land in plain text in a file that outlives the session and gets attached to bug reports. Every string, at any depth, is now rewritten with the same regexes the guardrail tier uses (provider key shapes, credential assignments, key-shaped tokens) and a match becomes a loud ***REDACTED***. The big free-text fields — arguments, task, content, error, outcome, and anything nested inside them — are clipped to [observability] max_field_chars with a visible …[truncated N chars], so a tool that returned a 40 MB file doesn't become 40 MB of trace. Redaction never raises: a failure degrades to the untouched event, because a trace that drops events is worse than a trace with a long line in it. redact = "none" is the explicit local opt-out.
  • It rotates. The live file is capped at max_trace_bytes (50 MB); crossing it shifts trace.jsonltrace.1.jsonl … and drops the oldest generation past keep_traces (5). Rotation happens between events — flush, then rename — so no event is ever half-written. Reads stream rather than slurp and walk the rotated generations newest-first, so --kind tool_call -n 50 really shows fifty tool calls rather than whatever few survive the last fifty events of any kind. max_trace_bytes = 0 turns rotation off.

Reading it back:

agent86 trace path                              # where it lives, plus the rotated generations
agent86 trace show -s 0f3a91c2                  # one session
agent86 trace show -k tool_call -k model_call   # only these kinds (repeatable)
agent86 trace show --since 2h -n 200            # a window; 30m | 2h | 7d

trace show prints time · session · kind · in · out · cost · detail, with the token and cost columns filled only on a model_call (where they mean something) and a totals line underneath: 4,812 in / 1,003 out tokens, $0.0412 across 37 event(s).

Exporting it:

agent86 trace export -s 0f3a91c2 -o session.jsonl      # the filtered events
agent86 trace export -f json --since 7d                # one JSON array
agent86 trace export -f otlp-json -o spans.json        # a reconstructed OTLP span tree

otlp-json rebuilds a span tree out of the recorder's own turn_start / turn_end / model_call / tool_call events, so a trace captured with no collector running can still be handed to one afterwards. Span ids are derived rather than random, so exporting the same trace twice produces the same output.

OpenTelemetry. With otel = true and the otel extra installed, each turn is a turn span with a model_call child per provider call and a tool_call child per execution, carrying gen_ai.* attributes (gen_ai.request.model, gen_ai.usage.input_tokens / output_tokens, gen_ai.response.finish_reasons, gen_ai.tool.name) where the GenAI semantic conventions name one and agent86.* where they don't:

pip install "agent86[otel]"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
agent86 run "hello"
[observability]
trace           = true                  # the JSONL flight recorder
path            = "~/.agent86/traces"
redact          = "secrets"             # secrets | none
max_field_chars = 2000                  # clip for the big free-text fields
max_trace_bytes = 50_000_000            # rotate past this; 0 disables rotation
keep_traces     = 5                     # rotated generations kept
otel            = false                 # emit OpenTelemetry spans (needs the `otel` extra)
otel_exporter   = "otlp"                # otlp | console | none
otel_endpoint   = "http://localhost:4317"   # overrides OTEL_EXPORTER_OTLP_ENDPOINT when set

The standard OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS are honoured — the exporters read them themselves, which is how every other instrumented process is configured — and otel_endpoint is the override for when config, not environment, is the source of truth. Spans go through a BatchSpanProcessor, flushed when the harness closes. otel_exporter = "console" pretty-prints to stderr for local debugging; "none" records spans and exports nothing.

agent86 deliberately does not install its provider as the global OTel provider: it can be imported inside a host that owns its own tracing, and stealing the global provider would be a side effect of an import. Every failure path degrades to a one-line note — a missing extra, a missing collector, a broken exporter mean "no traces", never "no agent".

Install

From PyPI:

pip install agent86
uv tool install agent86      # or: install it as a standalone tool

Optional backends are extras — install what you need:

pip install "agent86[anthropic]"   # Claude
pip install "agent86[openai]"      # OpenAI / OpenAI-compatible
pip install "agent86[local]"       # sentence-transformers + sqlite-vec (semantic memory)
pip install "agent86[mcp]"         # MCP client
pip install "agent86[otel]"        # OpenTelemetry SDK + OTLP exporter
pip install "agent86[docker]"      # Docker sandbox
pip install "agent86[web]"         # readability extraction for web_fetch
pip install "agent86[all]"         # everything

Releases are built and published from a v* tag by .github/workflows/release.yml using PyPI trusted publishing — the procedure and the one-time setup are in docs/RELEASING.md.

From source (development)

With uv (recommended):

uv venv
uv pip install -e ".[dev]"

Or with pip:

python -m venv .venv
# Windows PowerShell:  .venv\Scripts\Activate.ps1
pip install -e ".[dev]"

The extras are the same from a checkout (pip install -e ".[all]"). [dev] adds pytest, ruff, mypy, build and twine.

Usage

agent86                     # interactive TUI (full-screen)
agent86 --plain             # interactive plain loop (also AGENT86_PLAIN=1, or a non-TTY)
agent86 run "your goal"     # one-shot, scriptable
agent86 run "goal" --json   # structured output for automation
agent86 config path         # show resolved config location
agent86 models              # list configured models
agent86 trace show          # recent flight-recorder events
agent86 --help

The scripting contract

run, run --json and --plain are what a script or a CI job is allowed to depend on, and tests/integration/test_scripting_contract.py pins every promise below:

  • run --json writes one JSON object to stdout, with these five keys. New keys may be added (turn was one, in v0.8); these may never be removed or renamed:

    Key What it is
    session_id the session this turn belongs to — pass it back with --session
    output the final assistant text
    steps the steps the loop took, in order
    usage token usage and cost for the turn
    turn the per-turn summary — steps, tools, tokens, cost, duration
    agent86 run --json "hello" | jq -r '.output'
    agent86 run --json "hello" | jq '.turn'
    
  • A failure is exit code 1, a message on stderr, and nothing on stdout — so agent86 run … > out.json never leaves a half-file that parses as success.

  • Approvals are explicit. Piped (non-TTY) and without --yes, side-effecting tools are declined; --yes runs them.

  • --session <id> continues an existing session rather than starting a new one.

  • The egress guardrail applies to the JSON as much as to streamed text.

  • --plain and run never import Textual, keyring, tomlkit, OpenTelemetry or torch — held by an import-graph test and a cold-start budget (AGENT86_SKIP_PERF=1 skips the wall-clock half on a loaded runner).

  • The read-only inspection commands work on a machine with no config and no API keys.

Design

The core rule (from the book): the model proposes; the deterministic harness validates, executes, and persists. The model never touches the sandbox, the database, or the terminal directly. Everything crosses the harness.

Tier 1  Gateway         cli.py + tui/     entry point, input sanitization (guardrails/ingress)
Tier 2  Orchestration   orchestration/    ReAct loop, state machine, routing, circuit breakers
Tier 3  Cognitive       cognitive/        provider adapters, prompt compilation, token budget
Tier 4  Tool & Exec     tools/            built-ins + MCP + sandbox
Tier 5  Guardrails/Obs  guardrails/ + observability/   HITL, OTel, flight recorder
        Memory          memory/           SQLite + sqlite-vec (working/episodic/semantic)

Tier 1 is deliberately thin: gateway/ holds no logic of its own — the entry point and input sanitization live in cli.py/tui/ and guardrails/ingress.py, and session lifecycle is orchestration/state.py.

License

MIT

Release files for agent86 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for agent86 1.0.0
File Size Uploaded
agent86-1.0.0.tar.gz 308.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for agent86 1.0.0
File Interpreter ABI Platform
agent86-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 590.1 kB

Release files / agent86-1.0.0.tar.gz

Download URL agent86-1.0.0.tar.gz
Size 308.2 kB
Tags Source
SHA-256 checksum
How to use checksums
530782427f2d7fcc117c44b7de487d48d10ca9ae3a087ef44f257e5663ad62a9
BLAKE2b-256 checksum
How to use checksums
c797c7baadee2d9a41da5ba7cd3e0d9424a20b4461f521bfd534c7f1d81dda4a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / agent86-1.0.0-py3-none-any.whl

Download URL agent86-1.0.0-py3-none-any.whl
Size 282.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d54dfca0549307889d9a4f4c0d1834d204d4c37b93889d57c2f40670f0ce3a4a
BLAKE2b-256 checksum
How to use checksums
dccf0566a36b4ec3366a8e4101f394d41de41a15e5a8192a406b7fee90e9a312
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page