mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
task_cost_budget, task_time_budget_minutes and task_token_budget no longer exist: a task is not capped by what it spends. Old config files carrying them are cleaned on start with a one-time notice, and the settings declaration and config template drop them. The budget machinery a Goal run injects for itself (goal_token_budget, off by default) and the run usage accounting stay. The effect tests that only exercised the configured budgets are removed, and the Windows CI steps that named them are pruned in the same commit.
1355 lines
80 KiB
Markdown
1355 lines
80 KiB
Markdown
# Reasonix Engineering Spec
|
||
|
||
> Reasonix is a coding agent: a thin harness driving multiple models, with **all
|
||
> capabilities supplied by configuration and plugins**. This document is the
|
||
> contract — code follows it. Change the contract first, then the code.
|
||
|
||
## 1. Design Principles
|
||
|
||
1. **Config- and plugin-driven core.** The core knows only interfaces. Concrete
|
||
models and tools are resolved by name from registries, declared in config, or
|
||
injected by plugins. No hardcoded `switch model`.
|
||
2. **Single static binary.** `CGO_ENABLED=0`; cross-compile with one command;
|
||
CLI works out of the box.
|
||
3. **Lean dependencies.** Standard library by default. A third-party dependency
|
||
must be pure-Go, lightweight, and must not compromise the single-binary /
|
||
cross-platform / distribution story. TOML parsing is the one accepted dependency.
|
||
4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`),
|
||
and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible).
|
||
5. **Interface-first & registry-based.** `Provider` and `Tool` are interfaces.
|
||
6. **Evolve, don't over-engineer.**
|
||
|
||
Language: **English is the primary language for all code** — comments,
|
||
user-facing strings, tool descriptions, system prompts, and this spec. The
|
||
README is bilingual (`README.md` English + `README.zh-CN.md`).
|
||
|
||
## 2. Layout
|
||
|
||
```
|
||
reasonix/
|
||
├── go.mod / go.sum # module reasonix; require BurntSushi/toml
|
||
├── Makefile # build / cross / vet / fmt / test
|
||
├── README.md / README.zh-CN.md
|
||
├── reasonix.example.toml # sample config
|
||
├── docs/SPEC.md # this file
|
||
├── cmd/reasonix/main.go # entry; blank-imports built-in providers/tools
|
||
├── cmd/reasonix-plugin-example/ # reference MCP stdio plugin (a runnable example)
|
||
└── internal/
|
||
├── cli/ # subcommand routing, flags, assembly, exit codes
|
||
├── config/ # TOML loading (flag > project > user > defaults)
|
||
├── provider/ # Provider interface + types + kind→factory registry
|
||
│ └── openai/ # OpenAI-compatible impl; init() registers "openai"
|
||
├── tool/ # Tool interface + Registry
|
||
│ └── builtin/ # read_file/write_file/edit_file/move_file/bash/ls/glob/grep
|
||
├── permission/ # per-call Policy: allow/ask/deny rules → Decision
|
||
├── command/ # custom slash commands loaded from .reasonix/commands/*.md
|
||
├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools
|
||
├── remote/ # SSH transport for the Remote-SSH module
|
||
│ ├── forward/ # -L / -R port-forward lifecycle
|
||
│ ├── sftpfs/ # SFTP file layer (quarantines pkg/sftp)
|
||
│ └── bootstrap/ # detached `reasonix serve` bootstrap over SSH
|
||
└── agent/ # Session + harness loop
|
||
```
|
||
|
||
Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`.
|
||
Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to
|
||
self-register; parents never import children. The Remote-SSH module layers
|
||
`cli → remote/bootstrap → remote → {remote/forward, remote/sftpfs, config,
|
||
netclient}`; `remote` and its subpackages never import `cli`, `agent`, or
|
||
`serve`, and all interactivity flows through callbacks (host-key / secret
|
||
prompts) so the desktop module consumes the same surface. See §Remote below.
|
||
|
||
## 3. Core Abstractions
|
||
|
||
### 3.1 Provider + registry (`internal/contract/provider`)
|
||
|
||
```go
|
||
type Provider interface {
|
||
Name() string
|
||
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
|
||
}
|
||
|
||
// Factory builds a Provider from a resolved config instance.
|
||
type Factory func(cfg Config) (Provider, error)
|
||
|
||
// Register adds a factory under a kind (e.g. "openai"). Called from init().
|
||
func Register(kind string, f Factory)
|
||
|
||
// New instantiates the provider of the given kind.
|
||
func New(kind string, cfg Config) (Provider, error)
|
||
|
||
type Config struct {
|
||
Name string // instance name, e.g. "deepseek"
|
||
BaseURL string
|
||
Model string
|
||
APIKey string
|
||
Extra map[string]any // kind-specific options
|
||
}
|
||
```
|
||
|
||
- The `openai` kind is an OpenAI-compatible `/chat/completions` implementation.
|
||
- **OpenAI-compatible vendors are config instances** of `kind = "openai"`,
|
||
differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI-
|
||
compatible model is a config edit, not a code change.
|
||
- **A provider is a vendor endpoint** (one `base_url` + `api_key_env`) that offers
|
||
one or more models. `request_url`, when set, is the exact request target for
|
||
OpenAI-compatible, Anthropic-compatible, and Responses providers. Legacy
|
||
`chat_url` retains its historical OpenAI-only behavior; other legacy entries
|
||
derive the protocol path from `base_url`. An entry declares either a single `model = "..."` or a
|
||
`models = ["...", "..."]` list (with an optional `default`); the list form lets
|
||
one vendor expose several models without re-declaring the endpoint/key. A
|
||
**model reference** (`default_model`, the `--model` flag, the desktop switcher)
|
||
resolves via `Config.ResolveModel`, which accepts a provider name (→ its default
|
||
model), a bare model name, or an explicit `provider/model`. `context_window` is
|
||
the provider-wide fallback; `model_overrides.<model>.context_window` can replace
|
||
it for one model. Per-model `prices` use model IDs as keys.
|
||
- Streaming tool-call deltas are accumulated by index inside the provider; only
|
||
complete `ToolCall`s are emitted.
|
||
|
||
### 3.2 Tool + registry (`internal/contract/tool`)
|
||
|
||
```go
|
||
type Tool interface {
|
||
Name() string
|
||
Description() string
|
||
Schema() json.RawMessage // JSON Schema for parameters
|
||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||
}
|
||
```
|
||
|
||
- Built-in tools self-register into a process-global builtin set via `init()`
|
||
(`tool.RegisterBuiltin(t)`); `tool.Builtins()` lists them.
|
||
- A runtime `*Registry` is assembled per run: enabled built-ins (filtered by
|
||
config) **plus** plugin-provided tools. The agent only sees the `*Registry`.
|
||
- Tool schemas are canonicalized on registry insertion. The built-in contract is
|
||
documented in [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md) and backed by tests that
|
||
compare the documented surface against the same canonical schema path.
|
||
- `Execute` parses raw JSON args itself. Errors are returned, not fatal — the
|
||
agent feeds them back so the model can self-correct.
|
||
|
||
### 3.3 Plugins (`internal/ext/plugin`) — MCP client
|
||
|
||
An external plugin is an MCP server declared in config. The wire protocol is
|
||
**JSON-RPC 2.0** in every case; only the transport differs. A `transport`
|
||
interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
|
||
(handshake, `tools/list`, `tools/call`, …) is written once.
|
||
|
||
- **Transports** (config `type`):
|
||
- `stdio` (default) — a local subprocess; one JSON message per line over the
|
||
child's stdin/stdout (the MCP stdio convention). Declared with
|
||
`command` / `args` / `env`; terminated on ctx cancel / shutdown.
|
||
- `http` (a.k.a. `streamable-http`) — a remote server at `url`. Each request
|
||
is an HTTP POST; the server replies with either `application/json` (one
|
||
response) or `text/event-stream` (an SSE stream carrying the response plus
|
||
any server notifications). The `Mcp-Session-Id` response header, once seen,
|
||
is echoed on subsequent requests. Static `headers` (e.g. a bearer token) are
|
||
sent on every request. When no static `Authorization` header is configured,
|
||
user-initiated OAuth uses Protected Resource Metadata and Authorization
|
||
Server Metadata discovery, dynamic client registration, PKCE S256, a
|
||
loopback callback, resource indicators, and refresh-token rotation. Client
|
||
credentials and tokens are stored with mode `0600` in the server's private
|
||
Reasonix MCP state directory, outside the workspace; tokens are bound to the
|
||
configured resource URL and are never reused after that URL changes. OAuth
|
||
discovery, registration, and token requests honor Reasonix's resolved
|
||
network-proxy settings. Removing a declaration clears this state unless the
|
||
effective fallback uses the same OAuth resource.
|
||
- `sse` — the legacy 2024-11-05 HTTP+SSE transport. A persistent GET stream
|
||
receives an announced relative POST endpoint, JSON-RPC responses, and server
|
||
messages. Cross-origin announced endpoints are rejected so static headers
|
||
cannot leak.
|
||
- `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`,
|
||
and `headers` so secrets come from the environment, not the config file.
|
||
- `${REASONIX_WORKSPACE_ROOT}` and `${CLAUDE_PROJECT_DIR}` expand, in those
|
||
fields only, to the server's absolute workspace path and outrank an
|
||
environment or `.env` variable of the same name.
|
||
- Lifecycle: `initialize` → `notifications/initialized` → `tools/list`;
|
||
invocation via `tools/call {name, arguments}`.
|
||
- When a workspace root exists, initialize advertises `roots` and transports
|
||
answer `roots/list` with its file URI. `tools/call` includes a per-call
|
||
`_meta.progressToken`; matching `notifications/progress` messages stream into
|
||
the existing tool-progress event path.
|
||
- A local stdio server's `tools/call` also carries `_meta["io.reasonix/cwd"]` (the
|
||
workspace root) and `_meta["io.reasonix/readOnly"]` (true in plan mode).
|
||
- Remote HTTP and SSE servers get neither, so no local path leaves the machine. A
|
||
key the call already carries is kept, and both are hints, not authority.
|
||
- A stdio server uses one persistent transport for initialize, reads, and
|
||
writes, preserving state such as browser sessions across tool calls. The
|
||
process uses the server's process sandbox because process confinement cannot
|
||
change per RPC; read-only eligibility and destructive filtering remain local
|
||
workflow gates rather than separate process sandboxes.
|
||
- Configuration provenance is runtime metadata and determines persistence scope.
|
||
Desktop and CLI installs write the user-global `config.toml`; project
|
||
`reasonix.toml` and `.mcp.json` entries remain in their owning project file.
|
||
Every configured source is trusted without a separate launch-confirmation
|
||
step. Project entries override same-name global entries, and project
|
||
`reasonix.toml` overrides `.mcp.json`. Editing writes to the effective entry's
|
||
source; removing it reveals the next lower-priority declaration.
|
||
- Each remote tool is adapted to the `Tool` interface and injected into the run
|
||
registry, namespaced `mcp__<server>__<tool>` (spaces normalised to `_`) to
|
||
match Claude Code and avoid clashes.
|
||
- A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults
|
||
to false (a remote tool is opaque — we can't see its side effects), so a
|
||
plugin opts a tool into parallel-batch dispatch and the permission layer's
|
||
reader-default by declaring `readOnlyHint: true` in `tools/list`.
|
||
- Installation is the trust decision for tool metadata. Reasonix assumes an
|
||
installed server reports `readOnlyHint` and `destructiveHint` honestly;
|
||
planner/read-only filtering is a workflow boundary for trusted servers, not
|
||
containment against a malicious MCP server. Explicit deny rules and the
|
||
process sandbox remain host-controlled boundaries.
|
||
- `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash
|
||
commands; `resources/list` + `resources/read` are referenced as
|
||
`@<server>:<uri>` in chat. `/mcp` shows connected servers and their counts.
|
||
- `cmd/reasonix-plugin-example` is a runnable reference stdio server (`echo`,
|
||
`wordcount`), driven by an end-to-end test that builds the real binary.
|
||
|
||
### 3.4 Agent (`internal/runtime/agent`)
|
||
|
||
- `Session` holds `[]Message`.
|
||
- `Run(ctx, input)` loop: build `Request` (with tool schemas) → `provider.Stream`
|
||
→ print text deltas live, collect complete tool calls → if none, done; else
|
||
execute each tool (built-in or plugin) and append results → repeat, bounded by
|
||
`maxSteps`. `ctx` threads throughout (Ctrl-C aborts in-flight requests).
|
||
- A `Runner` is anything with `Run(ctx, input) error`; both `Agent` and
|
||
`Coordinator` satisfy it, so the CLI is agnostic to single- vs two-model mode.
|
||
|
||
### 3.5 Two-model collaboration (`Coordinator`)
|
||
|
||
When `agent.planner_model` names a provider different from the executor, a
|
||
`Coordinator` runs two models in **separate sessions** to keep each one's prompt
|
||
prefix cache-stable:
|
||
|
||
- The **planner** (low-frequency) runs in its own session with the same standing
|
||
memory context plus a filtered read-only research tool set, then produces a
|
||
concise plan. A deterministic host policy chooses executor-only, light
|
||
planning, full planning, plan-for-approval, or explicit plan-only from
|
||
pristine user text plus trusted turn metadata. It does not call a classifier
|
||
model and does not infer host state from controller-authored prompt blocks.
|
||
Explicit Plan Mode, synthetic turns, short contextual replies, atomic edits,
|
||
and bounded read-only actions avoid a second planner; cross-surface,
|
||
structured, ambiguous, and high-risk work uses the full contract. Active Goal
|
||
and Delivery turns upgrade non-atomic mutation work, while bounded read-only
|
||
actions remain executor-only. The privacy-safe
|
||
route/depth/reason decision is emitted in phase detail.
|
||
- Light plans use a small per-turn research-round budget and return a compact
|
||
objective, 1-4 ordered steps, likely touchpoints, and primary verification.
|
||
Full plans use a larger bounded budget and distinguish verified from candidate
|
||
touchpoints, with risks, acceptance criteria, command-level verification, and
|
||
rollback when relevant. The depth contract stays in one stable system prompt;
|
||
only a small host-authored `<planner-turn>` block changes per user turn. If
|
||
the planner still does not finalize after the bounded research and grace
|
||
round, plan-and-execute falls back to the executor with the pristine task;
|
||
plan-only and plan-for-approval remain fail-closed. The incomplete planner
|
||
turn is rolled back rather than exposed as a broken manual continuation.
|
||
- A bare plan-first route hands the completed plan directly to the executor.
|
||
Plan-for-approval is reserved for an explicit request to wait for
|
||
confirmation; the host enforces that boundary even if the planner omits its
|
||
marker, then hands the approved plan to the executor. A headless host persists
|
||
the plan so a later turn can continue. Explicit plan-only requests persist the
|
||
plan and end the current turn without execution. A planner failure on either
|
||
execution boundary cannot fall back to the executor. These directives may
|
||
appear after the task clause; quoted examples do not change the route.
|
||
- The plan is handed off as structured text to the **executor** — a full
|
||
tool-using `Agent` in its own session — which validates candidate assumptions
|
||
and carries it out.
|
||
- The sessions never mix, so neither model's prefix is disturbed by the other's
|
||
turns; both grow prepend-only and stay cache-friendly. This reconciles
|
||
"cache-first" with "two-model collaboration": switching models *inside one
|
||
shared conversation* would break the prefix and tank cache hits, so we don't.
|
||
|
||
### 3.6 Context management (content-driven summary)
|
||
|
||
Long tasks fill the model window. Reasonix keeps a **cache-first, append-only**
|
||
canonical transcript and installs a short **provider-visible checkpoint** only
|
||
when the sole automatic threshold is crossed.
|
||
|
||
- Each provider declares `context_window` (tokens). The only automatic trigger is
|
||
`agent.compact_ratio` (default **0.90**; presets 0.70 / 0.80 / 0.90; range
|
||
strictly between 0 and 1).
|
||
`triggerTokens = floor(context_window × compact_ratio)`.
|
||
- Ratio bounds follow `CompactRatioMin` / `CompactRatioMax` in
|
||
`internal/contract/config`.
|
||
- **Below the trigger** history is never rewritten: no summary, no prune/snip
|
||
projection, no sidecar write, no projection-version bump, no maintenance event.
|
||
Any rewrite would invalidate the prompt cache from that point on.
|
||
- **At the trigger** Reasonix runs **one** summary transaction:
|
||
`stable prefix + one structured digest + recent verbatim tail`.
|
||
Acceptance (normal path): candidate ≤ 50% of the window, strictly smaller than
|
||
the source, and below `triggerTokens`. Candidates are **not** padded toward 50%.
|
||
Typical landings are about 10%–30% of the window.
|
||
Internal construction budgets (not user settings):
|
||
`recentTailBudget = clamp(window×10%, 32K, 96K)`, summary output max **16K**.
|
||
- Users inspect or change the threshold with
|
||
`reasonix config compact-ratio [--local] [VALUE]`. Project config overrides the
|
||
user-global value used by desktop and new CLI sessions. UI always shows the
|
||
**effective** ratio.
|
||
- `max_output_tokens` is an independent **per-turn** completion ceiling.
|
||
Recommended: `0` (**automatic**, not unlimited; DeepSeek default high → ~64K).
|
||
User presets: `32768` ordinary coding / cost control, `65536` heavy reasoning /
|
||
long tool loops, `131072` only after repeated `finish_reason=length`.
|
||
Negative omits optional wire limits when the protocol allows. Clipped only at
|
||
send time against remaining window and **never** changes `triggerTokens` or
|
||
maintenance timing. Billing follows actual completion tokens, not the ceiling.
|
||
- Giant tool results are bounded **once**, on first entry to the model:
|
||
`Content` is the stable ≤32KB visible form; `RawContent` holds the full original
|
||
only when they differ. Maintenance never rewrites old tool bodies.
|
||
`ModelMessages` strips `RawContent` so provider serialization and cache hashes
|
||
never include it.
|
||
- Automatic maintenance is planned once in `ContextManager.Prepare` from the
|
||
current projection plus the append-only canonical tail. The canonical
|
||
transcript is never rewritten. Subsequent thresholds merge
|
||
**prior digest + new history** into a single digest (no multi-span merge). Failure records a generation-scoped
|
||
`blocked`/`failed` receipt; the same generation does not pay for another
|
||
automatic summary. Manual `compress` can retry.
|
||
- A summary cut at the output limit is retried once with a larger output cap;
|
||
if that is cut too, the failure keeps its typed code and records the cap.
|
||
- Old multi-threshold keys (`soft_compact_ratio`, `tool_result_snip_ratio`,
|
||
`compact_force_ratio`, `cold_resume_prune`, `context_editing`) are removed on
|
||
ordinary start and ignored at runtime. Native provider tool clearing is not
|
||
used; every provider uses the local summary checkpoint path.
|
||
- Keep policy (`keep` / `recent_keep`) and the active tool turn remain protected
|
||
content. Restart restores an existing checkpoint without re-summarizing or
|
||
replaying timeline cards.
|
||
- Full history remains in the session transcript. The read-only `history` tool
|
||
provides BM25 retrieval over sessions; new summary checkpoints do not create
|
||
prune archives.
|
||
- The read-only `history` tool gives the agent on-demand BM25 retrieval over
|
||
saved session JSONL files. `scope="project"` searches the current controller's
|
||
session directory; `scope="global"` also searches the user-global session
|
||
directory and compacted-history archives. `operation="around"` can then read a
|
||
bounded transcript window around a returned hit. Search keeps the best hit and
|
||
trims trailing common-word-only noise with a relative score floor; a 0-result
|
||
response tells the agent how to retry with rarer terms or widen scope.
|
||
- The read-only `memory` tool gives the agent on-demand search/list/read access
|
||
to saved auto-memory files. It complements the writer tools: `memory` checks
|
||
what already exists, `remember` saves or updates a fact, and `forget` removes
|
||
a stale one from the active index while archiving the file for traceability.
|
||
Archived memory files are visible in local management surfaces (`/memory`,
|
||
TUI, desktop panel) but are excluded from active-memory retrieval. Memory
|
||
search uses the same relative BM25 floor and guides the agent to fall back to
|
||
history when exact original wording or tool output matters.
|
||
- Before each real user turn, bounded BM25 recall selects relevant active facts
|
||
from the raw user message and appends them as a low-authority user-turn suffix.
|
||
Generic turns are suppressed, project facts override equivalent global
|
||
fallbacks, stale facts are down-ranked, and recall is bounded by result/character
|
||
budgets. This never mutates the stable system prompt or tool schemas.
|
||
- The owning controller may auto-allow only a bounded, non-sensitive,
|
||
create-only project/reference `remember`, including in a top-level headless
|
||
run. Global facts, preferences, feedback,
|
||
updates, duplicates, sensitive/oversized content, and every `forget` require a
|
||
fresh human approval even under Auto or YOLO. Guardian/safety review cannot
|
||
answer these prompts on the user's behalf. Sub-agents and headless surfaces
|
||
without the owning scoped controller fail closed. The approval request includes a compact preview, while
|
||
external notification hooks only receive the tool name.
|
||
- Facts carry immutable IDs, monotonic revisions, timestamps, type, and scope.
|
||
Updates snapshot the previous revision; restore and archive recovery create a
|
||
higher revision and reject path escapes, symlinks, collisions, and overwrites.
|
||
User-initiated memory edits in the local UI are already explicit user actions.
|
||
See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the
|
||
detailed implementation contract.
|
||
|
||
**What survives a fold.** Verbatim, at every compaction: the system prompt, the
|
||
first user turn when it is small enough to be a brief, **every user turn in the
|
||
fold region that fits the retention budget**, and the recent tail. The messages
|
||
the keep policy protects also survive, though a failure with a recorded execution
|
||
keeps only its failure-carrying lines. Everything else is **best-effort** — it
|
||
reaches the summarizer and survives only as well as the digest captured it.
|
||
|
||
That protection has to hold across *repeated* folds, which is why a stored
|
||
projection keeps the host's `ToolExecution` record while a provider request does
|
||
not. `KeepErrors` classifies a failure from that record rather than from text,
|
||
because a real `go test` log opens with `=== RUN` and no prefix match can see
|
||
it; a projection written without the record would leave the *next* fold unable
|
||
to classify what the current one just protected. The strip therefore belongs at
|
||
the provider boundary — `ModelMessages` — and not at projection write time,
|
||
where `ProjectionMessages` preserves it.
|
||
|
||
User turns are held to a different standard than the work they govern. A
|
||
constraint stated at turn 4 ("do not change the public API") exists nowhere but
|
||
the transcript, while the code it constrains stays re-derivable from the
|
||
workspace — so the asymmetry of loss, not the token count, decides. Retention is
|
||
bounded rather than unconditional, because hoisting user turns without a budget
|
||
is what padded an earlier revision's candidates past the acceptance ceiling,
|
||
failing compaction outright instead of degrading it. One turn may spend up to
|
||
1500 tokens and all of them together `min(8192, window×5%)`, oldest first — the
|
||
recent tail already covers the newest turns, and an old turn has survived more
|
||
folds than a new one. Unlike the keep policy this is not scoped to the latest
|
||
digest, so a constraint keeps its protection across repeated compaction.
|
||
|
||
A turn past those bounds folds like any other content. Prefix it with `[[keep]]`
|
||
(keep policy `user_marked`, on by default) to hold it verbatim regardless of
|
||
its own size.
|
||
|
||
Marked turns have a budget of their own: together they may spend `window×15%`,
|
||
newest first, and the oldest fold into the digest past it. A kept turn rides
|
||
into every later fold, so an unbounded mark would grow what each fold carries
|
||
until its candidate could not land under the trigger.
|
||
|
||
That drop is never silent: the finished-fold notice (`compaction_telemetry`, typed
|
||
payload) carries `user_turns_kept` and `user_turns_dropped` counts, and a committed checkpoint that had to fold one of your
|
||
turns, marked or not, emits a warning. The projection reads as complete either
|
||
way, so the count is the only thing that distinguishes them.
|
||
|
||
Two properties bound that loss. Each fold re-derives its digest from the
|
||
canonical transcript rather than from the previous digest, so digests do not
|
||
chain and repeated compaction does not compound summarizer drift. And compaction
|
||
only ever writes a projection: the canonical transcript keeps every original, so
|
||
a folded detail stays recoverable through the `history` tool and the archive
|
||
(`reasonix/archive/<timestamp>.jsonl`) even when the digest missed it.
|
||
|
||
This is the **only** point where the prompt prefix changes — a deliberate, rare
|
||
"cache-reset point". Between compactions the session grows prepend-only and
|
||
stays cache-friendly, so cache hit rate (the key observability signal) stays
|
||
high. `context_window = 0` disables compaction for an instance.
|
||
|
||
### 3.7 Permissions (`internal/safety/permission`) — per-call gating
|
||
|
||
A coding agent runs shell commands and edits files autonomously. The permission
|
||
layer decides, **per tool call**, whether to allow it, deny it, or ask the user
|
||
first. It is independent of the model and of the CLI — the agent consults a
|
||
`Gate` interface at execute time; the gate is built from a static `Policy` plus
|
||
an optional interactive `Approver`.
|
||
|
||
```go
|
||
type Decision int // permission package
|
||
const (Allow Decision = iota; Ask; Deny)
|
||
|
||
// Policy evaluates static rules against a tool call. Pure, no I/O.
|
||
type Policy struct { Mode Decision; Allow, Ask, Deny []Rule }
|
||
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision
|
||
```
|
||
|
||
- **Rule syntax.** A rule is `Tool` (matches any call in that tool family) or
|
||
`Tool(specifier)` (matches when the call's *subject* matches the specifier).
|
||
Bash and file mutation approvals use Claude Code-style families such as
|
||
`Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`. Built-in
|
||
file mutations include writes, edits, notebook edits, symbol/range deletes,
|
||
and `move_file` renames/moves. Legacy lowercase tool IDs still load for
|
||
compatibility. `Bash=<literal>` is the exact-command form: metacharacters in
|
||
the literal are ordinary characters and only the identical complete command
|
||
matches. The
|
||
`:*` suffix marks a Bash command-prefix approval; generated prefix rules also
|
||
reject later commands that introduce shell operators, so `Bash(go test:*)`
|
||
does not cover `go test ./... && rm -rf tmp`.
|
||
Legacy `Bash(go test *)` prefix rules still load, but new rules are saved as
|
||
`Bash(go test:*)`. The subject is extracted generically from the call's JSON
|
||
args by a small set of
|
||
known keys — `command` (bash), `path` / `file_path` (file tools), `pattern`
|
||
(grep/glob) — so tools need not change. A rule whose subject the args don't
|
||
expose only matches in its bare `Tool` form.
|
||
- **Dynamic Bash.** Parameter/arithmetic expansions, assignments, heredocs, unproved
|
||
redirects, and shell globs cannot reuse bare Bash, prefix, or glob allows;
|
||
remembered approvals are exact `Bash=<literal>` rules. They still follow the
|
||
normal posture fallback, so Auto and an approved-plan window may execute them
|
||
without prompting. Nested or indirect execution is stricter: command and
|
||
process substitution, a dynamic command name, parse failures, `eval`,
|
||
`source`, shell `-c`, PowerShell/cmd command strings, and runtime inline-code
|
||
flags require a human in interactive Ask/Auto. Guardian, allowing hooks, and
|
||
the approved-plan window cannot answer that decision; only an identical exact
|
||
grant or YOLO can bypass it by default. The advanced
|
||
`[permissions] allow_dynamic_bash = true` opt-in lets an Allow fallback,
|
||
including Auto, cover this class; explicit `ask` and `deny` rules retain
|
||
precedence.
|
||
- **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for
|
||
read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so
|
||
a broad `allow = ["Bash"]` can still be carved by `deny = ["Bash(rm -rf*)"]`;
|
||
conversely `ask` overrides a broad `allow` to force a prompt on a risky subset.
|
||
- **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user
|
||
— allow once / allow this approval scope for the session / always allow this
|
||
approval scope / deny — via an `Approver`. For Bash, the default scope is the
|
||
concrete command subject, and the user may choose a conservative command-prefix
|
||
scope when available (for example `Bash(go test:*)`) so similar invocations in
|
||
the same session or saved config do not prompt again. For file-mutation tools,
|
||
a session grant covers editing for the rest of the session while a persisted
|
||
grant is path-scoped when a path is available, stored as `Edit(<path>)` so all
|
||
built-in file-mutating tools share it. A
|
||
non-interactive run
|
||
(`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt.
|
||
Its explicit posture therefore resolves without blocking: Ask/manual fails
|
||
closed, Auto allows only ordinary writer fallback, and YOLO may bypass ordinary
|
||
Ask decisions. Nested or indirect Bash remains stricter: headless
|
||
Ask/Auto/DontAsk reject it unless an identical literal grant exists; YOLO or
|
||
`allow_dynamic_bash = true` with an Allow fallback may opt out. A `Deny` is a
|
||
hard block in *every* mode: the tool never executes and the model receives a
|
||
"blocked" result it can adapt to (the same shape as a plan-mode refusal).
|
||
- **MCP authorization.** Installing an MCP server authorizes all of its tools;
|
||
there is no second server, raw-tool, writer, or destructive approval policy.
|
||
Project configuration is trusted the same way and requires no separate launch
|
||
confirmation. Explicit global deny rules still win. `readOnlyHint` and
|
||
`destructiveHint` remain internal
|
||
facts for scheduling, Plan/read-only restrictions, and cached-to-live safety
|
||
reclassification. Strict read-only sub-agent registries expose only
|
||
authorized tools with `readOnlyHint: true` and no `destructiveHint`. The
|
||
two-model Planner uses the fixed `use_capability` proxy (never direct
|
||
`mcp__*` schemas) for authorized, non-destructive MCP without requiring
|
||
`readOnlyHint`; destructive tools are left for the Executor. In Balanced
|
||
two-model sessions the Executor owns an isolated frontend for the same proxy,
|
||
so Planner-discovered capability IDs remain executable after handoff. Schema-only
|
||
changes refresh the next-session cache without adding an execution approval
|
||
or retry. Immediately before dispatch, the proxy re-checks the current
|
||
controller's enablement, authorization, and complete runtime connection
|
||
identity; a same-name client on a shared Host is never sufficient authority.
|
||
- **Relationship to plan mode.** Plan mode (§3.4) is a plan-first collaboration
|
||
workflow, not an all-tools read-only mode. Before Permissions/Sandbox, the
|
||
host enforces explicit phase opt-outs (`complete_step` is read-only but
|
||
belongs to the post-approval execution phase, so it self-reports plan-unsafe
|
||
and is refused). The dedicated two-model Planner may call authorized,
|
||
non-destructive MCP even when `readOnlyHint` is absent; it hard-blocks
|
||
destructive targets and readers from unauthorized servers for the entire
|
||
planning phase. A single-model Plan without the dedicated Planner continues
|
||
to block MCP writer/destructive targets while Plan is active.
|
||
Ordinary built-in and Bash calls then use the same Ask/Auto/YOLO, explicit
|
||
`ask`/`deny`, and Sandbox path as Standard mode. A third-party MCP
|
||
`readOnlyHint` affects dispatch classification and strict-child eligibility,
|
||
but not the dedicated Planner's non-destructive trust path. Once the server is
|
||
installed or declared in project configuration, all non-destructive
|
||
capabilities enter the dedicated Planner proxy; only hinted readers enter
|
||
strict read-only sub-agent execution. `plan_mode_read_only_commands` is
|
||
retained for config/session round trips and does not grant or revoke calls in
|
||
the main Plan workflow. `read_only_task` and `read_only_skill` remain strict
|
||
read-only capabilities with their own tool registry and safe foreground Bash;
|
||
writer-capable `task` and skill execution remain permission-gated instead of
|
||
Plan-blocked, and their child turns inherit the Plan workflow marker and
|
||
explicit phase opt-outs.
|
||
- **User decisions are separate from tool approvals.** Runtime tool approval has
|
||
three user-facing postures: `ask` ("需要批准"), `auto` ("自动批准"), and
|
||
`yolo` ("Yolo批准"). `auto` lets the permission policy auto-approve the writer
|
||
fallback while preserving explicit ask/deny rules; `yolo` skips ordinary tool
|
||
permission prompts for approval-gated tools such as writers and Bash. Explicit
|
||
deny rules and forced fresh reviews still apply. Nested or indirect Bash
|
||
commands require a human in interactive Ask/Auto even during the approved-plan
|
||
window; ordinary expansions, assignments, redirects, and globs continue under
|
||
Auto fallback but cannot inherit reusable Bash rules. YOLO is the sole mode
|
||
bypass for the human-required class, while an identical exact literal remains
|
||
an ordinary explicit authorization.
|
||
Neither posture answers `ask` questions or approves `exit_plan_mode` plans.
|
||
Plan Mode is entered only through an explicit user choice and remains
|
||
independent of the active tool-approval posture. After a user approves a
|
||
plan, the controller opens a short `approvedPlanAutoApproveTools` execution
|
||
window so the model can perform the approved writes without re-prompting; that
|
||
transient window still does not auto-approve future plans. In headless `ask`
|
||
execution, any fallback answer is labelled as a model assumption, not as a
|
||
user decision.
|
||
|
||
- **Collaboration mode is separate from tool approval.** The desktop composer
|
||
presents collaboration as `normal` ("正常模式"), `plan` ("计划模式"), and
|
||
`goal` ("目标模式"). `/goal <objective>` starts an autonomous, session-scoped
|
||
active goal: the controller prepends goal context to user turns outside the
|
||
cache-stable system prompt and keeps issuing continuation turns until the
|
||
model reports completion, repeats the same blocked state three times, the user
|
||
stops it, or the safety continuation limit is reached. Blocked-state matching
|
||
is normalized for casing, whitespace, and punctuation so minor wording drift
|
||
does not reset the audit; restarting a goal begins a fresh blocked audit. A
|
||
goal is treated as a task contract: if the objective includes Context,
|
||
Request, Output format, Constraints, or Pause policy sections, those sections
|
||
define the autonomous work boundary. When they are absent, the model infers a
|
||
lightweight contract from the conversation and workspace. The injected goal
|
||
block tells the model to pause only for irreversible or externally visible
|
||
operations, scope changes, or information only the user can provide; ordinary
|
||
uncertainty should be handled with sensible defaults and reported as an
|
||
assumption. Completion requires the concrete request, output format,
|
||
constraints, and relevant verification expectations to be satisfied or
|
||
explicitly reported as unverified.
|
||
Goal has no default model-round, cross-Run turn, wall-clock, or numeric
|
||
no-progress boundary. Goal-scoped novelty accepts new read/search results and state changes
|
||
but rejects exact tool/argument/result repeats. All classes use the same Goal
|
||
FSM, host receipts, Delivery readiness, and bounded evaluator; there is no second research
|
||
protocol or writable sidecar runtime. Legacy `.reasonix/autoresearch/...`
|
||
archives remain read-only and explicit old paths recover as ordinary Goals.
|
||
Outside goal mode, ordinary prompts never change collaboration mode; the user
|
||
must choose Goal or use `/goal` explicitly.
|
||
Repeated host failures, zero-evidence rounds, and Todo stalls trigger bounded
|
||
strategy redirects and intervention-epoch resets, never a Goal pause. Turns,
|
||
tokens, provider requests, and active work duration remain observational when
|
||
the corresponding budget is not configured. Positive user-selected
|
||
`[agent].goal_token_budget` and `max_steps` remain
|
||
explicit resumable boundaries. The Goal token budget defaults to `0` (off);
|
||
resuming a `budget_spend` pause grants a fresh slice without clearing
|
||
cumulative Goal statistics.
|
||
`/goal clear` removes the active goal. Switching into plan/normal mode clears
|
||
the active goal in the desktop UI so the collaboration mode remains one of
|
||
the three choices, while the underlying tool approval posture is preserved.
|
||
|
||
| Tool approval posture | Tool approvals | Plan approval | `ask` questions |
|
||
| --- | --- | --- | --- |
|
||
| Need approval / `ask` | Follow permission policy (`Ask` prompts interactively) | Waits for user | Waits for user |
|
||
| Auto approve / `auto` | Writer fallback auto-allowed; explicit ask/deny rules still apply | Waits for user | Waits for user |
|
||
| YOLO approval / `yolo` | Ordinary prompts auto-allowed; deny rules and fresh reviews remain | Waits for user | Waits for user |
|
||
| Approved-plan execution window | Approved plan's writer fallback is auto-allowed; explicit `ask` / `deny` rules remain | Future plans still wait | Waits for user |
|
||
|
||
Out of the box (`mode = "ask"`, no rules), interactive `reasonix` prompts before
|
||
each writer/bash call and `reasonix run` fails closed on those calls because it
|
||
has no approver. Use `reasonix run --auto ...` / `-y` to allow ordinary writer
|
||
fallback in unattended automation; `--permission-mode auto` is equivalent.
|
||
Explicit `ask` rules still fail closed under Auto, and `deny` rules harden every
|
||
posture.
|
||
|
||
### 3.8 Slash commands (`internal/ext/command`)
|
||
|
||
The chat TUI accepts `/command` input. Three kinds share one dispatch:
|
||
|
||
- **Built-in actions** (`/compact`, `/new`, `/clear`, `/effort`, `/mcp`, `/help`) manipulate session
|
||
state locally and never reach the model. `/new` starts a new session while
|
||
saving the previous transcript for resume/history. `/clear` requires
|
||
confirmation, then discards the current context without saving it; it does not
|
||
delete project memory.
|
||
- **Custom commands** are Markdown files under `.reasonix/commands/` (project) and
|
||
the user config dir, e.g. `~/.reasonix/commands/` on macOS/Linux; the project dir overrides the user dir on a
|
||
name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it
|
||
(`git/commit.md` → `/git:commit`). Invoking one renders its body and sends the
|
||
result as the next user turn.
|
||
- **MCP prompts** (§3.3) appear as `/mcp__<server>__<prompt>`.
|
||
|
||
```markdown
|
||
---
|
||
description: Review the staged diff
|
||
argument-hint: [focus-area]
|
||
---
|
||
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
|
||
```
|
||
|
||
- Frontmatter is an optional `---`-fenced block of simple `key: value` lines;
|
||
`description` and `argument-hint` are recognised (no YAML dependency — Reasonix
|
||
stays lean). The remainder is the body template.
|
||
- Substitution in the body: `$ARGUMENTS` (all args, space-joined), `$1`…`$N`
|
||
(positional, empty when absent), `$$` (a literal `$`). Arguments are the
|
||
space-separated tokens after the command.
|
||
- Loading is pure (`command.Load(dirs...)`) and tested; a malformed file is
|
||
skipped, not fatal. Custom and MCP-prompt commands both resolve to text and
|
||
reuse the same "start a turn" path as a typed message.
|
||
|
||
#### CLI modal/composer ownership
|
||
|
||
The Bubble Tea chat TUI has one bottom composer. A slash-command overlay must
|
||
declare whether it owns keyboard input:
|
||
|
||
- **Modal overlays** own navigation/confirm/cancel keys and must hide the
|
||
composer while open. Examples: `/mcp`, `/resume`, `/rewind`, approval prompts,
|
||
and non-typing `ask` choice cards.
|
||
- **Input-owned overlays** are attached to the textarea and must keep the
|
||
composer visible. Examples: slash/@ autocomplete and `ask` free-text mode.
|
||
|
||
New CLI overlays must update `chat_tui.hideComposer()` and add/extend layout
|
||
tests so `bottomRows()` accounts for either `panel + status` or
|
||
`panel + composer + status`. This prevents inactive chat input boxes from being
|
||
rendered under modal panels.
|
||
|
||
### 3.9 Chat references (`@`)
|
||
|
||
A chat message may embed `@` references; before the turn is sent, each is
|
||
resolved and prepended to the message as a tagged block the model can read.
|
||
|
||
- `@<server>:<uri>` where `<server>` is a connected MCP server → an MCP
|
||
resource (`resources/read`), wrapped `<resource ref="…">…</resource>`.
|
||
- `@<path>` otherwise → a **local file or directory**, but only when the path
|
||
actually exists on disk. This existence gate is the disambiguator: an ordinary
|
||
`@mention` or an email address resolves to no file and stays literal text. A
|
||
file is wrapped `<file path="…">…</file>` (size-capped, binary files noted not
|
||
dumped); a directory becomes a recursive listing (depth-first, skipping common
|
||
noise like `.git` and `node_modules`).
|
||
- Resolution is asynchronous (off the TUI event loop); a fetch failure surfaces
|
||
as a notice but doesn't block the turn. Reads are user-initiated and read-only
|
||
— they do **not** pass the permission gate (§3.7).
|
||
- Typing `/` or `@` opens an autocomplete menu above the input. The `@` menu
|
||
navigates **one directory level at a time** (`os.ReadDir`, never a recursive
|
||
walk — bounded for huge directories): a directory entry descends, a file
|
||
completes, and MCP resources appear alongside top-level entries. The
|
||
bottom-region menu changes height only on these discrete actions, never per
|
||
streamed token, so scrollback stays clean (§ rendering).
|
||
|
||
### 3.10 Subagent profiles and explicit CLI execution
|
||
|
||
A subagent profile is a Skill with `runAs: subagent` and, for profiles managed
|
||
by the desktop or CLI editors, `invocation: manual`. Profiles reuse the existing
|
||
project/global Skill files; they do not introduce another state format or
|
||
database. Manual invocation excludes a profile from the pinned Skill index so
|
||
the model cannot discover it implicitly, while explicit `/<name> <task>`
|
||
invocation remains available.
|
||
|
||
Interactive slash invocation and `Controller.RunSubagentProfile` both execute
|
||
the profile with the Boot-wired Skill runners. Each run gets an isolated child
|
||
session and returns only its final answer to the caller. The headless contract is
|
||
explicit:
|
||
|
||
- `reasonix subagent try <name> ... <task>` uses the read-only Skill runner;
|
||
- `reasonix subagent run <name> ... <task>` uses the normal permission and
|
||
sandbox path; and
|
||
- ordinary `Controller.Run` / `reasonix run` remains unchanged and does not
|
||
reinterpret slash-prefixed input as a subagent command.
|
||
|
||
Desktop and CLI profile mutations share
|
||
`skill.ValidateEditableSubagentProfile`. Only simple manual project/global
|
||
profiles can be rewritten or deleted. Custom-scope Skills, unmanaged
|
||
frontmatter, and Skill directories containing `references/` or `scripts/` are
|
||
refused so an editor cannot silently flatten or discard rich Skill content.
|
||
Built-in profiles support configuration overrides but have no writable file.
|
||
|
||
Effective model and effort precedence is: per-profile
|
||
`agent.subagent_models` / `agent.subagent_efforts`, this call's `model` /
|
||
`effort` on `task`/`fleet`, profile frontmatter, `agent.subagent_model` /
|
||
`agent.subagent_effort`, then executor/default model configuration.
|
||
|
||
`task` accepts optional `profile` and `write_paths`. `fleet` dispatches 2–64
|
||
profile-aware tasks under a session scheduler
|
||
(`agent.max_subagent_concurrency`, default 6; `agent.max_parallel_writers`,
|
||
default 3). Profile names are resolved at runtime from the Skill store and
|
||
must never enter tool schemas or the parent system prompt. Custom and named
|
||
built-in profile bodies are the full child system prompt (no implicit
|
||
concise default). `parallel_tasks` remains the compatible read-only batch
|
||
API on the same scheduler. In a persisted parent session, parallel/fleet
|
||
children save independent transcripts; the aggregate carries bounded previews
|
||
and stable refs, and `read_subagent_result` pages a referenced final answer by
|
||
UTF-8 byte offset under the current conversation-lineage/workspace boundary.
|
||
A ref is the only address a child answer has, so an aggregate that never
|
||
arrived — a background task or fleet a restart repaired to `interrupted` —
|
||
would strand every child it had already completed. `list_subagents` enumerates
|
||
the children a conversation owns, newest first, under the same
|
||
lineage/workspace boundary, so that work is recovered by reference instead of
|
||
re-run. Both readers are parent-only: a child enumerating its siblings would be
|
||
exactly the ambient channel §3.13 denies.
|
||
Headless runs remain ephemeral and return fair bounded previews without refs.
|
||
See [Subagent profiles](./SUBAGENT_PROFILES.md)
|
||
for the user-facing command and file-format contract.
|
||
|
||
A profile describes a worker, not a run. Delegation is five separate concepts:
|
||
the profile says how a worker thinks, `TaskSpec` what this call wants,
|
||
`CapabilityGrant` what it may touch, `ContextCapsule` what it starts from, and
|
||
`SchedulerPolicy` when it runs. A field belongs to whichever member decides its
|
||
value, so a profile may carry a capability *ceiling* (`allowed-tools`,
|
||
`read-only`) but never a per-call value such as `max_turns`, `write_paths`, or a
|
||
retry or verification policy — those are decided by the task or the scheduler.
|
||
Skill frontmatter may keep growing; `agent.ProfileFromSkill` is the single
|
||
narrowing point, and routing metadata (triggers, auto-use, cost, freshness)
|
||
stops there because it decides *when* a worker is chosen, not how it thinks.
|
||
`internal/runtime/delegation/profile_boundary_test.go` fails on any widening.
|
||
|
||
### 3.11 Sub-agents close with a host-adjudicated claim
|
||
|
||
A writer sub-agent ends its run by calling `complete_subtask` with a `status`,
|
||
a `summary`, the `acceptance_criteria` it was held to (each with the command it
|
||
ran or the paths it changed), and whatever it left `unresolved`. Prose alone is
|
||
still accepted, but it is no longer the interface the parent reasons over.
|
||
|
||
The submitted status is a claim, not a verdict. Before the parent sees it, the
|
||
host checks every citation against its own receipts: a `verification` criterion
|
||
must name a command the host recorded as run, `diff`/`files` must name paths the
|
||
host observed written or read, and a `manual` note is never self-backing. Any
|
||
criterion the receipts cannot back is lowered to `unsatisfied`, a report holding
|
||
one cannot stay `complete`, and the downgrade is printed with its reason. The
|
||
host never raises a status.
|
||
|
||
The parent therefore receives, in order: the adjudicated status and criteria,
|
||
the child's own prose, and the host's own receipts of what it changed and ran.
|
||
|
||
### 3.12 Write claims are enforced, not advisory
|
||
|
||
A declared `write_paths` is one truth source used for both scheduling and
|
||
enforcement. When a writer sub-agent declares explicit paths, the host binds its
|
||
registry to that claim before the child runs:
|
||
|
||
- path-aware built-in writers (`write_file`, `edit_file`, `multi_edit`,
|
||
`move_file`, `notebook_edit`, `delete_range`, `delete_symbol`) reject any
|
||
argument path outside the claim, with both ends of a `move_file` checked;
|
||
- paths are compared after symlink resolution against the deepest existing
|
||
ancestor, so neither `..` traversal nor a symlink inside the claim can launder
|
||
a write out of it;
|
||
- `bash` is kept only if the OS sandbox can rebind its write roots to the claim,
|
||
and is otherwise removed from the child's registry entirely;
|
||
- MCP goes through `use_capability`, which refuses at resolve time — before any
|
||
MCP process runs — every target not proven read-only;
|
||
- writers the host cannot path-scope (custom, unknown) are dropped;
|
||
- after the run, the host compares the mutations it recorded against the claim
|
||
and reports any outside path to the parent in the sub-agent's host receipts.
|
||
|
||
Omitting `write_paths` is not an unscoped writer: the run claims the whole
|
||
workspace and therefore serialises against every other writer claim. That claim
|
||
is a scheduling boundary only — inside the workspace nothing is refused, because
|
||
no concurrent writer can hold an overlapping claim at the same time. Writes that
|
||
leave the workspace are still reported as claim violations.
|
||
|
||
Declaring paths is what buys parallelism; it costs `bash` on hosts where the OS
|
||
sandbox cannot enforce write roots.
|
||
|
||
- On resume after yielding for user input, the owner-side claim record identifies
|
||
other sessions that claimed overlapping paths during the wait.
|
||
- A foreign claim or incomplete history adds a persistent `workspace_claim_yielded`
|
||
checkpoint coverage gap alongside the model notice and completion-receipt gap.
|
||
- A crossing observation cannot attribute watched paths or a whole-workspace
|
||
diff to its tool, even when files look unchanged. Unknown mutations retain
|
||
their `unproven_mutation` obligation.
|
||
- The lease owner tracks observation boundaries per prepared call, including
|
||
child calls and optimistic calls that claim no additional paths.
|
||
- Fresh or disjoint operations are not tainted by another waiter's repeated
|
||
notice, and cannot discharge the earlier operation's debt.
|
||
|
||
The existing bounded claim log reports unknown coverage when a full retained
|
||
history no longer includes the yield's mark; a short complete history is not
|
||
treated as truncation. These facts describe cooperating lease owners, not writes
|
||
by external processes that bypass the claim protocol.
|
||
|
||
### 3.13 Sub-agent context inheritance is explicit
|
||
|
||
A child inherits nothing implicitly. What it receives is exactly this:
|
||
|
||
| Given to the child | Where it comes from |
|
||
| --- | --- |
|
||
| System prompt | `DefaultTaskSystemPrompt`, `DefaultReadOnlyTaskSystemPrompt`, or the profile body — nothing else is composed into it |
|
||
| Workspace root | `<workspace-context>` on the first user turn |
|
||
| The task text | the user turn itself |
|
||
| Completion contract | appended to a writer's task turn (§3.11) |
|
||
| Delegation guidance | `<subagent-context>` on a nested child's fresh session |
|
||
| Plan-mode marker, reasoning/response language | run options, when set |
|
||
| A prior transcript | only via `continue_from` / `fork_from` |
|
||
| A dependency's final answer | `<upstream-results>` on the first user turn, only for a fleet item that declared `depends_on` (§3.14) |
|
||
|
||
Not inherited, by construction: `REASONIX.md`, `AGENTS.md`, `CLAUDE.md`, project
|
||
and global memory (the memory queue is disabled, so a child cannot record memory
|
||
either), the parent conversation, the current Goal, planner output, and the
|
||
results of siblings it did not declare a dependency on. A constraint that must
|
||
reach a child today has to be in its profile body, in the task text, or on a
|
||
`depends_on` edge — there is no ambient channel.
|
||
|
||
Every run records a `ContextCapsule` in its transcript sidecar: the workspace,
|
||
the system-prompt source and hash, the resolved tool scope and schema hash, the
|
||
model and effort, the parent session and tool-call id, any resumed transcript,
|
||
and an `inherited` block whose fields are all false except `upstream` on an item
|
||
a `depends_on` edge fed. `capsuleHash` is its stable
|
||
identity, so *why did this reviewer not see that constraint* is answered from
|
||
the record, and two runs that behaved differently can be diffed instead of
|
||
guessed at. The capsule holds references and digests only — never copied parent
|
||
context, which is what keeps delegation cheap and the child prefix cacheable.
|
||
|
||
### 3.14 Fleet is a small dependency graph
|
||
|
||
A fleet item may declare `id` and `depends_on`. That is the whole graph
|
||
vocabulary: no conditions, no expressions, no dynamic fan-out. It is enough for
|
||
|
||
```
|
||
research ──▶ implement backend ──┐
|
||
└──▶ implement frontend ─┴──▶ integration test ──▶ review
|
||
```
|
||
|
||
Ids default to the 1-based position. A duplicate id, an id no task declares, a
|
||
self-edge, or a cycle fails preflight, so a fleet that cannot finish never
|
||
starts. Items run as soon as their dependencies complete; items with no ordering
|
||
between them run in parallel under the same session scheduler as before.
|
||
|
||
Dependencies are a property of the graph, never of a task: they live in the
|
||
fleet plan and never reach `TaskSpec`, which is what keeps `depends_on` from
|
||
becoming the first keyword of a workflow language.
|
||
|
||
The edge carries the dependency's answer, not only its order. A dependent opens
|
||
with each completed dependency's final answer in an `<upstream-results>` block,
|
||
in `depends_on` order, sharing one byte budget so a wide fan-in cannot flood the
|
||
opening turn and no single dependency is starved out of it. An edge that ordered
|
||
its endpoints and nothing else would leave a `research → implement` pair to pay
|
||
for the same research twice. What the edge delivers is context rather than
|
||
instruction, so it travels as `ContextRequest.Upstream` — never inside the task
|
||
text, which has to stay the pristine objective that delivery classification
|
||
judges (§3.11), and never on a profile, which describes a worker and not a run.
|
||
|
||
The graph relaxes the write-claim preflight in the one place it should. Only
|
||
items that can run at the same time need disjoint `write_paths`; an
|
||
`implement → review` pair is serialised by its edge and may share paths, which
|
||
a flat fleet could not express.
|
||
|
||
Failure handling has one knob. A failed or skipped task always skips its whole
|
||
downstream branch — running a dependent on a broken input only buys a result the
|
||
parent must discard. Independent branches keep going unless `fail_fast` is set,
|
||
which stops *starting* new tasks; tasks already running are left to finish so a
|
||
writer is never abandoned mid-write.
|
||
|
||
A failure carries an identity, not only a message. A failed item reports
|
||
`reason=` beside its status — `provider.transient` for a status a later attempt
|
||
can clear, `provider.rejected` for one no retry fixes, `context.exhausted` for an
|
||
input that outgrew its window — and the branch it killed inherits that reason,
|
||
because a skipped item is worth re-issuing exactly when its cause was. The
|
||
classification reads the identity the error already carries (a
|
||
`*provider.APIError` status, `ErrCompactionRequired`, an interrupted stream) and
|
||
never its message, so an error whose producer named nothing is reported
|
||
unclassified rather than guessed at. Deciding *re-issue this graph with the
|
||
finished nodes adopted* is then a judgement about a code, not about a sentence.
|
||
|
||
A run that reached its end answers no error, however many of its items failed:
|
||
the aggregate reports them, headed by how many of the tasks were answered.
|
||
`context.Canceled` is reserved for a call that was actually interrupted, because
|
||
that is what the turn loop reads to tell an interrupted call from one that ran
|
||
and reported. A branch skipped behind a failure is that failure's consequence,
|
||
not an interruption, and saying otherwise cost the caller the difference.
|
||
|
||
A node may stand down to a reference instead of running. `adopt_ref` names an
|
||
already-completed child whose answer takes that node's place: nothing executes,
|
||
so the item claims no `write_paths` and cannot collide with a live writer, and
|
||
its dependents open with that answer over the ordinary dependency edge. It is
|
||
proven in preflight through the same gate as `read_subagent_result` — completed,
|
||
inside this conversation's lineage and workspace — so a reference that cannot be
|
||
adopted starts nothing, and `prompt` and every execution field are refused on it
|
||
rather than silently ignored.
|
||
|
||
What the host will not do is decide whether an adopted answer is still valid.
|
||
Nothing in the structure says whether the workspace moved on under it, and a
|
||
judgement this kernel cannot read from structure is one it does not make, so
|
||
that one stays with the caller that listed the reference while the host proves
|
||
only ownership and completion. It is also why a plan fingerprint does not quietly reuse matching
|
||
children: reuse the caller cannot see is reuse it cannot correct. With
|
||
`list_subagents` this is the recovery path for a fleet a restart interrupted —
|
||
enumerate the children, re-issue the same graph with the finished nodes adopted,
|
||
and pay only for what is left.
|
||
|
||
No dynamic fan-out is a measured exclusion, not only a taste. A `for_each` that
|
||
mapped one item's prompt over the subjects another item submitted was built and
|
||
shipped behind the surface, then measured against the call it replaces — the
|
||
same graph with one task handling every subject, which is what it had to be
|
||
without it. Across three fixtures and twenty-eight graded runs against a real
|
||
provider, every run of both arms solved, and mapping spent 70% to 98% more
|
||
tokens for it (exact permutation p ≤ 0.03 on each). The third fixture was built
|
||
to be the case mapping should win: ten modules of near-identical code, an answer
|
||
that is the sum of ten separate derivations, so a slip anywhere is a wrong
|
||
answer. It did not win there either.
|
||
|
||
The reason is that a mapped child pays a full sub-agent's fixed cost — prefix,
|
||
reasoning, closing contract — once per subject, while a single child with a tool
|
||
belt does not have to hold the subjects at all. It reads them one at a time, and
|
||
when the facts are extractable it greps instead. "One context cannot hold N
|
||
subjects" assumes an ingestion the agent never has to perform. The code and its
|
||
suite were removed; the history holds them if a case that does need per-subject
|
||
isolation ever turns up.
|
||
|
||
### 3.15 One child-construction primitive
|
||
|
||
The APIs that spawn a child are many — `task`, `read_only_task`, `fleet`,
|
||
`parallel_tasks`, `run_skill`, `/<profile>`, `reasonix subagent run|try`,
|
||
desktop preview. The execution primitive behind them must stay one. Each entry
|
||
point compiles its request into a `ProfileExecSpec` and hands it to
|
||
`TaskTool.RunProfileSpec`, which is the only place that resolves depth, tool
|
||
scope, permissions, sandbox, write claims, scheduler slots, the MCP frontend,
|
||
the transcript and capsule, the evidence ledger, and the completion contract.
|
||
|
||
This is not a style preference. A safety boundary spread across several
|
||
construction paths only has to be forgotten once: past regressions where a
|
||
preview path built unconfined file tools, and where a profile editor dropped
|
||
`read-only` on save, were both one entry point missing one layer.
|
||
|
||
An entry point that must not persist a transcript says so with
|
||
`ContextRequest.Ephemeral` rather than building its own session, so its promise
|
||
is a field on the spec instead of a second construction path.
|
||
|
||
`internal/runtime/agent/spawn_boundary_test.go` enumerates the files that still call the
|
||
low-level runners directly and fails on any new one. The remaining entries —
|
||
`internal/assembly/boot` (skill runners), `internal/frontend/cli/review.go`, and
|
||
`desktop/subagents_app.go` — are known debt, not precedent.
|
||
|
||
### 3.16 MCP concurrency: read-only is not stateless
|
||
|
||
Sub-agents share one session Host and its connections while each keeps its own
|
||
`use_capability` frontend and ledger. For a stdio server that means they share
|
||
one process, and therefore its session state.
|
||
|
||
Read-only does not imply stateless. A browser server opens a page, selects a
|
||
tab, scrolls; every one of those tools may honestly declare `readOnly` because
|
||
nothing reaches the filesystem, yet two children calling it concurrently
|
||
interleave on state neither of them can see. Write claims do not help — there is
|
||
nothing to claim.
|
||
|
||
A configured server therefore carries a concurrency policy:
|
||
|
||
```toml
|
||
[[mcp.servers]]
|
||
name = "browser"
|
||
concurrency = "serial" # parallel (default) | serial
|
||
```
|
||
|
||
`serial` means the runtime never runs two calls to that server at once across
|
||
the whole session, whichever child issues them. The gate lives on the shared
|
||
runtime because the process being interleaved on is shared at exactly that
|
||
scope, and a call waiting on it still honours its own cancellation. Servers
|
||
whose names look known-stateful (browser, playwright, puppeteer, chrome,
|
||
chromium, selenium) default to `serial`; explicit configuration always wins, and
|
||
everything else stays parallel so the shared-Host tradeoff is unchanged.
|
||
|
||
This is deliberately the conservative first version: one policy per server, not
|
||
per capability. Per-tool `parallel_safe` / `exclusive` hints and explicit
|
||
`concurrency_key` grouping are the later refinement, once real servers show
|
||
which tools within one server genuinely differ.
|
||
|
||
### 3.17 Measuring whether delegation pays
|
||
|
||
Orchestration is easy to add and hard to justify: more agents always cost more
|
||
tokens, and the extra tokens alone can look like an improvement. Comparing arms
|
||
therefore has to hold the model fixed and read host-recorded facts, not prose.
|
||
|
||
`reasonix run --json` emits per-run delegation counters alongside the existing
|
||
token, cache, cost, and duration totals:
|
||
|
||
| Counter | Answers |
|
||
| --- | --- |
|
||
| `subagent_runs`, `subagent_nested_runs` | which shape actually ran, not which was configured |
|
||
| `tool_calls` − `subagent_tool_calls` | parent versus child work split |
|
||
| `subagent_mutations`, `duplicate_work_paths` | did two children redo the same file |
|
||
| `completion_reports`, `completions_prose_only` | how much of the run ended in a checkable claim |
|
||
| `false_completions`, `criterion_downgrades` | claims the host refused to back |
|
||
| `write_scope_violations` | writes that escaped a declared claim |
|
||
|
||
The control axis is partial, and the counters are what revealed it.
|
||
`--ablate subagent` removes `task`, `read_only_task`, `fleet`, and
|
||
`parallel_tasks`, but a run can still delegate through a `runAs=subagent`
|
||
profile skill: a measured `no-subagent` arm spent a child run on `explore`.
|
||
Treat that arm as "no task-tool delegation", not "single agent", and read
|
||
`subagent_runs` to see what actually happened rather than trusting the label.
|
||
Nested depth is `agent.max_subagent_depth`.
|
||
|
||
`false_completions` is the counter that matters most. It comes from the
|
||
adjudication in §3.11, so it measures claims the host refused rather than a
|
||
reviewer's opinion, and it is the one number that separates "the fleet finished
|
||
faster" from "the fleet said it finished".
|
||
|
||
Read these against the measured noise floor. Running the same arm twice over
|
||
the same tasks moved per-task token use by a median of 19% and up to 54%, while
|
||
the whole between-arm difference in that experiment was 2.5%. A single run per
|
||
cell therefore proves nothing about delegation: the effect has to clear the
|
||
variance before it is an effect. Budget repetitions, or restrict the comparison
|
||
to tasks where `subagent_runs` shows delegation actually happened — in that
|
||
experiment it happened on one task in six.
|
||
|
||
What the counters have measured so far, on one model over four task shapes,
|
||
each comparing a neutral prompt against a forced-delegation twin over identical
|
||
work: three one-line fixes in separate modules cost 3.8x the tokens; a 24-file
|
||
search 1.5x tokens and 2.2x wall; a 36-file three-package migration 2.6x tokens
|
||
and 4.1x wall; three genuinely heterogeneous branches, the shape with the best
|
||
theoretical case, 2.4x tokens and 3.7x wall over three repetitions. Success rate
|
||
was 100% everywhere, and the forced arm's spread was about twice the neutral
|
||
arm's, so delegation also buys variance.
|
||
|
||
Read a child's token figure carefully: 27 measured child runs averaged 134k
|
||
tokens each, but that is cumulative prompt tokens over 9.3 model calls with the
|
||
same ~14k context re-sent each time, not 134k tokens of new material. At ~90%
|
||
cache hit the real price of a child averaged ¥0.017. The 2-4x above is the
|
||
number that matters, because both arms are counted the same way; the per-child
|
||
total is not a threshold to compare a branch's size against.
|
||
|
||
Why delegation is rare is answerable from the same runs, and the answer is not
|
||
that the model weighs it and declines. Across 33 runs with delegation available,
|
||
15% delegated and bash outnumbered every delegation-class call 10:1. The
|
||
recorded reasoning shows the model deliberating over how to read efficiently —
|
||
"that's 25 files... read them in parallel batches... I can read multiple files
|
||
at once" — on a task built for `explore`, without delegation entering the
|
||
decision at all.
|
||
|
||
Three things explain that, and only one of them is a defect. The base system
|
||
prompt never mentions delegation; every mention lives in the skills index, and
|
||
each is a brake ("the heavy path... only when the task genuinely needs
|
||
context-heavy work, not on weak relevance") next to an accelerator for inline
|
||
skills ("even plausibly relevant... cheap"). The `task` tool description says
|
||
what the tool does and never when to reach for it. And the model already has
|
||
cheaper parallelism — several tool calls in one round trip, with no context
|
||
duplicated — which is what it reasons in terms of.
|
||
|
||
Given the measured 2.4-4.5x, a brake is the correct default; the gap is that
|
||
nothing recognises the rare case where delegation would pay. Forcing it does not
|
||
close that gap: in the forced fleet run the parent worked out all three fixes in
|
||
its own reasoning before dispatching, so the children re-read the code to apply
|
||
edits the parent had already derived. Delegation moved the typing, not the
|
||
thinking.
|
||
|
||
One hypothesis remains untested rather than disproved: delegation's isolation
|
||
should pay when the parent is actually hurt by what it read. It could not be
|
||
provoked here. Pinning a workspace `compact_ratio` down to 0.5% still produced
|
||
zero compactions, because the agent keeps its session small by writing a script
|
||
instead of reading — the same behaviour that wins it the comparisons. Context
|
||
pressure needs a task that cannot be scripted away, which this corpus does not
|
||
yet contain.
|
||
|
||
The migration is the instructive one. Left alone the agent read a single file,
|
||
wrote a script and changed 108 call sites in 28 seconds; split across three
|
||
packages, no branch could see the transformation that solved all three. A task
|
||
looking parallel-shaped is not evidence that splitting it is cheaper.
|
||
|
||
Not yet measured, and deliberately not faked: rework-after-handoff needs
|
||
mutation ordering across a whole run, which belongs to the harness driving the
|
||
arms rather than the instrument recording one.
|
||
|
||
## 4. Data Types (`internal/contract/provider`)
|
||
|
||
```go
|
||
type Role string
|
||
const (RoleSystem Role = "system"; RoleUser Role = "user"
|
||
RoleAssistant Role = "assistant"; RoleTool Role = "tool")
|
||
|
||
type Message struct {
|
||
Role Role `json:"role"`
|
||
Content string `json:"content,omitempty"`
|
||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
}
|
||
|
||
type ToolCall struct { ID, Name, Arguments string } // Arguments: raw JSON
|
||
type ToolSchema struct { Name, Description string; Parameters json.RawMessage }
|
||
type Request struct { Messages []Message; Tools []ToolSchema; Temperature float64; MaxTokens int }
|
||
|
||
type ChunkType int
|
||
const (ChunkText ChunkType = iota; ChunkToolCall; ChunkDone; ChunkError)
|
||
|
||
type Chunk struct {
|
||
Type ChunkType
|
||
Text string // ChunkText
|
||
ToolCall *ToolCall // ChunkToolCall
|
||
Err error // ChunkError
|
||
}
|
||
```
|
||
|
||
## 5. Configuration (TOML)
|
||
|
||
Resolution order: **flag > project config > the user config file
|
||
> built-in defaults**. Starting with **Reasonix v1.8.1**, the user config lives
|
||
at `~/.reasonix/config.toml` on macOS/Linux and
|
||
`%AppData%\reasonix\config.toml` on Windows. See
|
||
[Configuration paths](./CONFIG_PATHS.md) for migration and related data paths.
|
||
|
||
- Project config uses `./reasonix.toml` when present, otherwise
|
||
`.reasonix/reasonix.toml`; the two are never merged.
|
||
- Reads and edits use the same selected path. New project configs still default
|
||
to `./reasonix.toml`.
|
||
- A malformed or inaccessible root file does not permit fallback.
|
||
- Fields marked user/global only are not overridden by either project config.
|
||
|
||
Provider entries name secrets with `api_key_env`; saved key values live in
|
||
Reasonix's global `<Reasonix home>/.env`, shared by CLI and desktop. Project
|
||
`.env`, home `.env`, inherited shell environment variables, legacy credentials,
|
||
and the OS keyring are not provider-key runtime fallbacks. Project `.env` still
|
||
feeds workspace-scoped, non-provider `${VAR}` expansion for MCP/plugin settings
|
||
without importing provider keys or Reasonix control variables.
|
||
|
||
```toml
|
||
default_model = "deepseek" # provider name (→ its default model) or "provider/model"
|
||
# language = "zh" # ui language tag; empty = auto-detect from $LANG / $REASONIX_LANG
|
||
|
||
[ui]
|
||
# shortcut_layout = "desktop" # classic|desktop; compatibility setting
|
||
# cursor_shape = "bar" # CLI/TUI textarea cursor: underline|block|bar
|
||
show_turn_usage = false # hide per-request token/cost receipts in the TUI; default true
|
||
|
||
[agent]
|
||
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
|
||
temperature = 0.0
|
||
reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||
# plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions
|
||
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
|
||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||
# subagent_effort = "high" # optional default reasoning effort for subagents
|
||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||
# subagent_efforts = { review = "max", security_review = "high" }
|
||
|
||
# A vendor endpoint exposing several models under one base_url/key.
|
||
[[providers]]
|
||
name = "deepseek"
|
||
kind = "anthropic"
|
||
base_url = "https://api.deepseek.com/anthropic"
|
||
# request_url = "https://proxy.example.com/anthropic/v1/messages" # optional exact provider request URL
|
||
# models_url = "https://proxy.example.com/v1/models" # optional model discovery URL
|
||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||
default = "deepseek-flash" # optional; defaults to models[0]
|
||
api_key_env = "DEEPSEEK_API_KEY"
|
||
web_search = true
|
||
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
|
||
# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K)
|
||
# max_output_tokens = 32768 # ordinary coding / cost control
|
||
# max_output_tokens = 65536 # heavy reasoning / long tool loops
|
||
# max_output_tokens = 131072 # only after repeated finish_reason=length
|
||
# max_output_tokens never changes compact_ratio
|
||
# idle_timeout_seconds = 300 # per-provider stream idle watchdog; default 300s (a silent pre-header wait is retried once, so up to ~2×)
|
||
# model_overrides = { "deepseek-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
|
||
|
||
# A single-model entry still works for custom OpenAI-compatible endpoints.
|
||
|
||
[environment]
|
||
enabled = true # inject a stable startup summary of OS, shell, and common tool versions
|
||
offline = false # set true when outbound network access is unavailable; prevents futile retries
|
||
|
||
# Optional trusted executable paths shown to the model when PATH probing is not enough.
|
||
# Workspace-local paths are listed but not auto-executed during startup probing.
|
||
# [environment.tools]
|
||
# go = "/opt/homebrew/bin/go"
|
||
|
||
[tools]
|
||
enabled = [] # omit/empty = all built-ins
|
||
bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap
|
||
protect_changed_files = true # refuse a whole-file write over a change made since the agent last read or wrote the file
|
||
browser_tools = true # built-in browser tools; the browser starts on first use
|
||
mcp_startup_timeout_seconds = 30 # background initialize + tools/list safety cap
|
||
mcp_call_timeout_seconds = 300 # default MCP call safety cap; plugin/tool overrides may raise it
|
||
|
||
[tools.shell]
|
||
prefer = "auto" # auto (default) | bash | powershell | pwsh — force the shell tool's interpreter
|
||
# path = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" # explicit executable for the chosen shell
|
||
# [tools.shell.env] # extra variables every bash command inherits (user-global; a project reasonix.toml cannot set this)
|
||
# BASH_ENV = "~/.reasonix/bashsandbox.rc"
|
||
# A preset runs before every command, read-only and verification runs included: keep it side-effect free and outside the workspace's writable roots. Remote workspaces and `reasonix review` do not carry it.
|
||
|
||
[skills]
|
||
# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots
|
||
# excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders
|
||
# disabled_skills = ["review"] # hidden from prompt, slash invocation, and skill tools
|
||
# suppress_warnings = true # quiet non-fatal skill loading warnings; doctor still reports issues
|
||
|
||
[permissions]
|
||
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
|
||
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
|
||
allow = ["Bash(go test:*)", "Bash(git status:*)"] # never prompted
|
||
ask = [] # force a prompt even if otherwise allowed
|
||
|
||
[sandbox]
|
||
# workspace_root = "" # file-writers confined here; empty = cwd
|
||
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit/move_file may modify
|
||
# forbid_read = ["${HOME}/.ssh"] # paths read/list/search tools and sandboxed bash may not inspect
|
||
|
||
[serve]
|
||
# auth_mode = "token" # token (default)|password|none; none still requires the launch token for changes
|
||
# token = "" # optional fixed token; empty token mode generates one at startup
|
||
# password_hash = "" # bcrypt hash generated with reasonix serve --hash-password --password '...'
|
||
# behind_proxy = false # trust X-Forwarded-* only behind a trusted reverse proxy
|
||
|
||
[[plugins]]
|
||
name = "example" # type defaults to "stdio"
|
||
command = "reasonix-plugin-example"
|
||
args = []
|
||
# env = { FOO = "bar" }
|
||
# startup_timeout_seconds = 60 # initialize + tools/list cap; 0 = global/default cap
|
||
# call_timeout_seconds = 600 # per-server MCP call timeout; 0 = global/default cap
|
||
# tool_timeout_seconds = { "generate_video" = 1800 } # raw MCP tool names
|
||
# [[plugins]] # a remote MCP server over Streamable HTTP
|
||
# name = "stripe"
|
||
# type = "http" # "stdio" (default) | "http" | "sse"
|
||
# url = "https://mcp.stripe.com"
|
||
# headers = { Authorization = "Bearer ${STRIPE_KEY}" } # ${VAR} / ${VAR:-default} expanded
|
||
```
|
||
|
||
The native CLI updater always installs the latest strict `vX.Y.Z` official
|
||
release. Legacy channel configuration and arguments remain parseable during
|
||
1.x, resolve to the official release, and are omitted on subsequent writes.
|
||
|
||
The executor tracks an adaptive progress lease while a todo is active. A new
|
||
completion, unique successful read, command, or mutation renews the lease;
|
||
exact repeats do not. After 8 no-progress tool-call rounds the host appends a
|
||
one-shot reassessment nudge. In Goal mode, the later threshold forces a re-plan
|
||
and continues; outside Goal it may end the current attempt. The serial contract is level-aware while preserving the
|
||
single-in_progress rule: in a two-level list the active level-1 sub-step is
|
||
the only `in_progress` item and its level-0 phase stays `pending`; sub-steps
|
||
complete in order, and the phase becomes `in_progress` — and signs off — only
|
||
after all of its sub-steps have completed. A level-1 item with no phase above
|
||
it is rejected. Retired `[agent].max_steps` and `planner_max_steps` keys remain
|
||
parseable for upgrade compatibility, but are ignored and removed by a one-time
|
||
migration. The CLI `--max-steps` flag remains the explicit control for one-off
|
||
execution.
|
||
|
||
`reasonix setup` writes this default config so the CLI is usable out of the box.
|
||
|
||
`[ui].cursor_shape` is normalized to `underline`, `block`, or `bar`; empty or
|
||
unknown values fall back to `bar`. It applies to the Bubble Tea CLI/TUI
|
||
textarea only, while desktop and browser inputs keep their platform-native
|
||
cursor behavior.
|
||
|
||
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. An unset
|
||
`auth_mode` generates a per-launch token. `none` is an explicit opt-out for a
|
||
Serve fronted by other authentication; reads stay open, while every
|
||
state-changing request, approvals included, still requires the launch token and
|
||
otherwise refuses with `auth.launch_token_required`.
|
||
|
||
The token goes to a 0600 file under `<Reasonix home>/remote/`, not the
|
||
terminal. `[serve]` is read from the user config only.
|
||
|
||
Password mode requires either a startup `--password` or a stored
|
||
bcrypt `password_hash`. `behind_proxy` must stay false unless the server is
|
||
behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto`
|
||
headers.
|
||
|
||
MCP servers may also be declared in a project-root `.mcp.json` using Claude
|
||
Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`,
|
||
`${VAR}` expansion). It is read after the TOML files and merged into
|
||
`[[plugins]]`; on a name collision `reasonix.toml` wins (it is the more explicit,
|
||
Reasonix-specific source). This lets a server already configured for Claude work in
|
||
Reasonix unchanged.
|
||
|
||
MCP startup has a separate lifecycle from an individual tool call. A caller
|
||
waits briefly for cold startup, while the shared launch/authorization/
|
||
`initialize`/`tools/list` sequence may continue in the background up to
|
||
`mcp_startup_timeout_seconds` (default `30`). A per-server
|
||
`startup_timeout_seconds` overrides that cap. MCP call timeouts begin only after
|
||
the connection is ready.
|
||
|
||
```json
|
||
{ "mcpServers": {
|
||
"stripe": { "type": "http", "url": "https://mcp.stripe.com",
|
||
"headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
|
||
} }
|
||
```
|
||
|
||
`[sandbox]` is the *enforcement* layer beneath permissions (which are *policy*).
|
||
Phase 0 confines the file-writing built-ins (`write_file`, `edit_file`,
|
||
`multi_edit`, `move_file`) to `workspace_root` (default cwd), the Reasonix user
|
||
config dir, plus `allow_write`: a write whose target — resolved to an absolute,
|
||
symlink-free path so a symlinked dir or `..` cannot tunnel out — falls outside
|
||
every root is refused, and the error is fed back to the model. Confinement is on
|
||
by default (root = cwd), so edits stay in the project while the agent can still
|
||
update its own global config. `forbid_read` lists files or directories the agent should
|
||
not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansion
|
||
and should be absolute, or use `${HOME}` for home-relative secrets such as
|
||
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
|
||
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS and bubblewrap on
|
||
Linux): each command is allowed to write only
|
||
the same roots plus platform-specific command temp/cache roots, denied reads
|
||
under `forbid_read`, and allowed to reach the network only when
|
||
`network = true`.
|
||
**Windows status:** Reasonix does not ship an OS-level Bash sandbox on Windows.
|
||
The effective mode is fixed to `off`; an older config containing
|
||
`bash = "enforce"` remains readable but resolves to `off`, `reasonix doctor`
|
||
reports the ignored value, and the desktop control is read-only. Bash therefore
|
||
runs unconfined on Windows. The in-process file tools continue to enforce
|
||
`workspace_root`, `allow_write`, and `forbid_read`.
|
||
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
|
||
instead of running unconfined. Install the platform sandbox backend
|
||
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
|
||
behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9).
|
||
|
||
## 6. Error Handling
|
||
|
||
- Library code wraps with `fmt.Errorf("...: %w", err)` and returns; it never
|
||
prints or calls `os.Exit`.
|
||
- Only `cli` / `main` decide exit codes and user-facing messages.
|
||
- Tool execution errors are fed back to the model, not fatal.
|
||
- Network layer should apply bounded exponential backoff on 429 / 5xx
|
||
(interface reserved; implementation may follow).
|
||
|
||
## 7. Code Style
|
||
|
||
- `gofmt` + `go vet` must be clean; package names lowercase; exported
|
||
identifiers documented; comments explain *why*, not *what*.
|
||
- No premature generalization. Prefer clear and direct.
|
||
|
||
## 8. Distribution
|
||
|
||
- Build: `CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(VERSION)" -o reasonix ./cmd/reasonix`
|
||
- Cross matrix: `darwin|linux|windows` × `amd64|arm64`.
|
||
- Version injected via ldflags (`git describe --tags --always`).
|
||
- Install: prebuilt binary / `go install` / future `brew tap`.
|
||
|
||
## 9. Roadmap (not in current scope)
|
||
|
||
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
|
||
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
|
||
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
|
||
Remaining: the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
|
||
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
|
||
command just fails and the model adapts), which completes the "allow inside the
|
||
box, prompt at its edge" model. With this in place, "always allow" rule
|
||
persistence becomes optional rather than load-bearing.
|
||
- MCP long tail (deferred deliberately): `headersHelper` auth for remote
|
||
servers; the remaining `.mcp.json` scopes
|
||
(local / user — project scope shipped, see §5); tool-search deferral;
|
||
`list_changed` live updates; channels / elicitation / roots; plugins that
|
||
provide *providers*, not just tools.
|
||
- An Anthropic-native provider `kind` (native prompt-cache control), proving the
|
||
registry generalises beyond one wire format.
|
||
- "Always allow" persistence writing learned rules back to project config; a
|
||
per-session permission override flag for `reasonix run`.
|