Skip to content

fix!: every limit on asked work is a settings.toml key with a sensible default, and rig says what it changes - #2995

Merged
gold-silver-copper merged 4 commits into
mainfrom
fix/no-hidden-limits
Nov 3, 2026
Merged

gold-silver-copper merged 4 commits into
mainfrom
fix/no-hidden-limits

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Every limit rig sets on the work it is asked to do is now a key in its plugin's own table of RIG_HOME/settings.toml, with a default that protects a run nobody watches (rig -p, helpers, subagents), and every refusal at a limit names its key. A limit is deleted only where an outside tool does exactly the same job (| head -c, </dev/null). The memory guards stay fixed with no key (the 16 MiB whole-file read, the 5 MiB image, 64 MiB of piped input, the 64 MiB pipe backlog, the 1 MiB line split), as do 2 remote operations per host and the 300 s Claude login wait; "Choices left to the user" lists them with why. Facts (a model's window and output limit, a protocol's maximum, a buffer that only changes how output is streamed) stay as mechanism. And where rig could not do what was asked as written, it now says so instead of quietly doing something else.

The owner's direction, in two steps on 2026-11-02:

  • First: "all limits must be configurable … i dont want caps rig must be limitless", and answer 4a (a RIG_HOME/settings.toml table that doesn't read refuses to start).
  • Then, revising answers 3c, 6 and 7 for this PR: "we should have sensible defaults, that can be controlled from a configuration file … a model retrying 10000 tool calls isnt great default behavior." Nothing is hard-coded, every limit is a key with a protective default, and a limit is deleted only when an outside tool does exactly the same job.

Switching a limit off (my call, the same for every key): no key has an "off" value. Each takes any number, with no upper clamp, so a user who wants no practical limit sets a large one. 0 does not read where it would mean nothing at all (timeouts, tasks at once, answer tokens, unavailable calls, MCP waits), which refuses the start naming the key. TOML has no null, and one rule is easier to state than per-key sentinels.

What changes for a user

Limits that were hard-coded, now keys with the same protective defaults (each refusal names its key)

[retry]                       # read by the harness's RunPlugin into the kernel's RetryPolicy
max_retries = 4               # retries of a failed model call in a row before the turn fails
max_wait_secs = 60            # the longest Retry-After to wait for

[tools]                       # RunPlugin, into the kernel's UnavailableCalls
max_unavailable_calls = 3     # calls of one tool the model doesn't have, in a turn, before it fails

[helpers]                     # HelpersPlugin
timeout_secs = 600            # each helper, unless the call gives its own `timeout` (no upper clamp; 0 refused)
at_once = 4                   # a call's tasks running together; the rest wait their turn, none refused

[steel]                       # SteelPlugin
max_seconds = 60              # execution, not counting waits on agents and tools
max_calls = 1000              # host function calls
max_output_bytes = 65536      # what a program prints, held in memory until it ends

[compaction]                  # CompactionPlugin
answer_tokens = 32000         # unset by default: the model's own output limit; set it to ask for less

Refusals: it failed 5 times in a row (`[retry] max_retries` is 4), the model kept calling `x`, which is not one of its tools (`[tools] max_unavailable_calls` is 3), too many host function calls: 1000 at most (`[steel] max_calls`), it timed out after 600 s (the call's `timeout`, else `[helpers] timeout_secs`), no answer within 30 s (`[mcp] connect_timeout_secs`).

What else changes

  • helper: no per-call task count (was at most 8): a call may give any number of tasks, which run at_once at a time, and a call's own timeout has no upper clamp (was 3600 s). timeout: 0 is refused.
  • shell: a command is no longer killed after 10 MB of output (| head -c does that). Past the cut, the output still goes to the spill file and only its first and last lines stay in memory.
  • A turn's answer may take the model's whole output limit (was 64 000 tokens). For a model whose output limit the catalog doesn't know, rig asks for none, so the server's default holds.
  • A keep_recent you set is kept as set. Only the default (20 000 tokens) is held to a quarter of the model's input limit.
  • rig -p "question" with piped stdin reads it until it closes (was: gave up after 1 s and sent the prompt alone); </dev/null gives none.
  • The TUI's prompt history keeps every distinct prompt (was the newest 500).
  • Compaction's own calls retry as [retry] says (they stopped after a second timeout).

Nothing changes silently (the closed list of 15 sites in the study's PR 1; 14 fixed, 1 dropped with evidence)

# Before After
1 helper timeout: 0 became 1 s, 5000 became 3600 s no upper clamp; 0 refused; with none, [helpers] timeout_secs (600)
2 a helper tool the run's --tools leaves out was dropped from the child refused: "`read` is not one of this run's tools (`--tools`)"
3 a failed helper's stderr: its last 5 lines, no marker "[N earlier lines of stderr dropped]" before them
4 an agent re-narrowed to its parent's or the run's tools, without a word a warning on that agent, saying which: "Lost shell, write: an agent above it does not have them." or "Lost shell: this run's tools leave it out." Not for --tools as the run starts, which the user gave
5 keep_recent silently capped at ¼ of the window a value you set is used as set (see above)
6 a task subagent on another model, given no effort, got that model's default unsaid task's answer says so; run_steel's description and AgentSpec::effort's doc state the rule
7 search skipped files over 16 MB or not UTF-8 and said "No matches" "[skipped N files: larger than 16 MB, not a regular file, or not text; shell with grep -a searches them]"
8 a shell spill file that couldn't be made or written: silent the cut line says "the whole output could not be kept, as …"
9 shell timeout_secs: 0 became 1 s refused
10 read offset: 0 and list depth: 0 became 1 refused
11 piped input that failed to read was dropped; bad UTF-8 was replaced the run is refused (exit 2) with the error, or the byte where the text stops being UTF-8
12 the TUI drew only a tool result's text blocks JSON is drawn as JSON, and images are counted: [1 image]
13 ACP ignored an MCP server of an unknown kind dropped: the _ arm is unreachable. The only other variant of acp::McpServer (v1 schema 1.5.0) is behind the crate's unstable_mcp_over_acp feature, which no crate here enables, and an unknown type fails to deserialize before this code. No test can build one
14 a RIG_HOME/settings.toml table that doesn't read: a warning, and every key of that table at its default the start is refused (exit 2), naming the file, the table and the key; a project's .rig/settings.toml keeps its warning. A [mcp] wait of 0 now fails to read too, instead of becoming 1
15 a resumed session whose directory is gone ran in the caller's directory without a word a startup warning names both directories (a warning, so rig -p shows it too)

Also from the design review: a helper child's answer longer than 64 KB was lost (its JSON line came in pieces and was parsed piece by piece); lines are now put back together first. A refused run's reason now reaches rig -p --json's exit.error (it said "the run ended"), so a helper parent sees why its child was refused. A refused run holds the agent loop until it exits, so no work a restored session left starts, and a bad settings file is refused before rig -p waits for piped input. A compaction that gave up names why (Not retrying: … (`[retry] max_retries` is 1)).

API changes

RETRY_POLICY becomes a resource (rig-ecs). The kernel reads Res<RetryPolicy>, with the same defaults; rig's RunPlugin fills it from [retry]. Likewise the new rig_ecs::tools::UnavailableCalls from [tools].

// before: a const every crate read
pub const RETRY_POLICY: RetryPolicy = RetryPolicy::DEFAULT;               // rig_ecs::turn
let verdict = RETRY_POLICY.verdict(report, retries, now);                 // turn.rs, judge_failures
format!("retry {attempt}/{}", rig_ecs::turn::RETRY_POLICY.max_retries)    // activity.rs, compaction.rs

// after
#[derive(Resource)] pub struct RetryPolicy { pub max_retries: u32, pub max_asked_wait: Duration, .. }   // DEFAULT: 4, 60 s
fn judge_failures(.., policy: Res<RetryPolicy>, ..) { let verdict = policy.verdict(report, retries, now); }
fn show_status(.., policy: Res<RetryPolicy>) { format!("retry {attempt}/{}", policy.max_retries) }
app.insert_resource(RetryPolicy { max_retries: 0, ..RetryPolicy::DEFAULT });   // a test that gives up at once
#[derive(Resource)] pub struct UnavailableCalls(pub NonZeroU32);                 // rig_ecs::tools, default 3

CompactionPolicy (rig-memory):

// before
pub keep_recent: usize,                       // default 20_000, then silently ≤ input_limit / 4
// after
pub keep_recent: Option<usize>,               // None: 20_000 within input_limit / 4; Some(n): n
pub answer_tokens: Option<NonZeroU64>,               // None: the model's output limit within its window, else the server's default
pub fn keep_recent(&self, spec: Option<&ModelSpec>) -> usize

Smaller items

// removed
rig_basics::helpers::AT_ONCE                       // `[helpers] at_once`
rig_tui::PromptHistory::KEPT                       // every distinct prompt is kept
rig_memory::TURN_OUTPUT                            // the model's limit, or the server's default
// changed
rig_mcp_client::McpSettings { first_retry_secs: NonZeroU64, connect_timeout_secs: NonZeroU64, .. }   // was u64, 0 became 1
// added
rig_tools::search::Found { skipped: usize, .. }    // serde default 0, not written when 0: saved searches load and save as before
rig_harness::host::launcher::Refused(pub String)   // was a unit struct; now carries why, for a front that says so itself
rig_harness_protocol::SessionDir::saved_directory()
rig_harness_protocol::env::WARNING                 // RIG_WARNING: a startup warning from the launcher

rig-steel now depends on rig-harness, to read [steel] through add_setting. Its README said it "builds on rig-ecs alone, so any rig-ecs app can add it"; an app still can, and gets rig-harness with it.

Every limit, row by row

Rows are the class (a) and (c) rows of the two inventories (~/code/notes/rig/agency-2026-11-01/inventory-*.md, ids in the first column), plus limits they missed (id +). path:line is at 6335613, the main this PR started from; #2993 moved plugins/rig-basics/src/redact.rs a few lines since. Outcomes: deleted, setting (key and default), mechanism (a fact, or a buffer that only changes how output is streamed or spilled), bounded default (rig itself fails without it; listed again under "Choices left to the user"), kept (ordinary tool design, one word of reason).

Id Limit Where (main) Outcome Reason / setting
B10 8 tasks per helper call plugins/rig-basics/src/helpers.rs:219 deleted a call gives any number of tasks; they queue at_once at a time, none refused
B11 4 helpers at once helpers.rs:34, :276 setting [helpers] at_once, default 4; tasks past it wait their turn (a pool, no longer whole batches)
B12 helper timeout 600 s default, clamped to 1–3600 s helpers.rs:230 setting [helpers] timeout_secs, default 600; a call's own timeout has no upper clamp; 0 refused
B13 helper failure keeps last 5 stderr lines helpers.rs:359 kept excerpt; now says how many earlier lines it dropped
+ helper JSON line read in 64 KiB pieces (a longer answer was lost) helpers.rs:365 deleted lines are reassembled before parsing
T17 shell command killed past 10 MiB of output crates/rig-tools/src/shell.rs:28 deleted | head -c in the command; output past the cut is spilled, only its ends are held. A host that turns the cut off (ShellOutput::set(None)) holds all of it, as it asked
3 3 calls to an unavailable tool fail the turn crates/rig-ecs/src/tools.rs:117-119, :628 setting [tools] max_unavailable_calls, default 3 (per name, per turn, as on main); the failure names the key
1 4 retries of a failed model call crates/rig-ecs/src/turn.rs:85, retry.rs:26-31 setting [retry] max_retries, default 4
2 give up past a 60 s provider-asked wait crates/rig-ecs/src/retry.rs:45-49 setting [retry] max_wait_secs, default 60
B37 compaction stops retrying after a second timeout plugins/rig-basics/src/compaction.rs:1419-1431 deleted follows [retry] like any model call, and names it when it gives up
G2 Steel 60 s execution time plugins/rig-steel/src/lib.rs:75 setting [steel] max_seconds, default 60
G3 Steel 1000 host calls plugins/rig-steel/src/program.rs:25 setting [steel] max_calls, default 1000
G4 Steel output 64 KiB plugins/rig-steel/src/lib.rs:82 setting, bounded default [steel] max_output_bytes, default 65536: prints are held in memory until the program ends. Printed output, the value and a failure are each cut on their own, so printing never hides how it ended
16, B34 answer budget 64 000 tokens crates/rig-memory/src/tokens.rs:348, compaction.rs:1014 setting [compaction] answer_tokens (0 doesn't read), default none: the model's output limit within its window, an explicit limit held to it too; for a model whose output limit the catalog doesn't know, none, so the server's default holds. TURN_OUTPUT is deleted
B30 keep_recent capped at ¼ of the input limit crates/rig-memory/src/compaction.rs:379 deleted for a set value a keep_recent the user sets is kept as set, and a compaction it leaves with nothing older to summarize names it; the default (20 000, at most ¼ of the input limit) adapts to the model
B18 prompt with stdin: wait 1 s for piped input, then go alone plugins/rig-basics/src/print.rs:383 deleted stdin is read until it closes; </dev/null for none
G24 TUI prompt history trimmed to 500 plugins/rig-tui/src/history.rs:64 deleted every distinct prompt is kept; repeats are still compacted
+ MCP connect_timeout_secs, first_retry_secs 0 silently 1 plugins/rig-mcp-client/src/lib.rs:511, :514 setting (exists) 0 doesn't read, so the start is refused naming the key; the timeout's message names [mcp] connect_timeout_secs (default 30 s, dead-peer detection)
26, B21 pipe backlog: reader counted gone past 64 MiB unread crates/rig-harness/src/host/pipes.rs:37 bounded default without it rig holds unbounded output for a reader that stopped reading
B20 pause work past 1 MiB unread pipes.rs:32 mechanism back-pressure; only changes when, not what
B16, B17, G10, + 64 MiB piped prompt, JSON request line, ACP line, MCP line print.rs:378, :381; plugins/rig-acp/src/lib.rs:43; plugins/rig-mcp-client/src/lib.rs:284 bounded default owner decision 4 (i10): one documented constant per stream, refused out loud
T1, T36 16 MiB whole-file read (read, edit, search, checkpoint) crates/rig-tools/src/lib.rs:89 bounded default the file is read whole into memory; read refuses out loud, search now counts what it skipped
T6 image of at most 5 MiB base64 crates/rig-tools/src/attach.rs:23 bounded default an image the provider refuses stays in the conversation and fails every later request
T20 a line with no newline split at 1 MiB shell.rs:392 bounded default one line would otherwise be held whole in memory
14, 27, 28, B22, B23, T25, T26, L2, L3, G18, + exit, drain, stop and kill graces (1 s, 500 ms, 2 s, 100 ms, 15 s, 1 s, 5 s; protocol STOP_GRACE 5 s) turn.rs:898, pipes.rs:40, signals.rs:34, process.rs:205, :210, src/launcher/run/stop.rs:16-19, plugins/rig-acp/src/router.rs:42, crates/rig-harness-protocol/src/lib.rs:614 mechanism how a stop is carried out; Esc and signals are the user's
+ the model-list fetch nobody waits for: 2 retries, at most 60 s of asked wait plugins/rig-basics/src/login/offered.rs:47-57 mechanism a background fetch; owner decision 2 bounds it
L1, G11, B39, + build self-description 10 s, ACP handshake 60 s, model list 10 s, Claude login HTTP 30 s and lock 60 s src/launcher/build.rs:536, plugins/rig-acp/src/session.rs:50, plugins/rig-basics/src/login/offered.rs:47, plugins/rig-claude-cloak/src/auth.rs:165, :216 mechanism dead-peer detection on rig's own processes or a server, not a bound on asked work; owner decision 2 asked for the model-list timeout
+ Claude login waits 300 s for the browser plugins/rig-claude-cloak/src/auth.rs:454 kept not verifiable without a Claude login (the run's rules forbid one); "Choices left to the user"
T27 remote file operations: 2 at once per host crates/rig-tools/src/remote.rs:62 kept queues, never refuses; a server that drops bursts of new handshakes fails writes ambiguously; "Choices left to the user"
T28 remote read retried once remote.rs:99 mechanism transport recovery
B40 model list 8 MiB / 1024 models crates/rig-core/src/client/chatgpt_models.rs:31 bounded default owner decision 2
20, B32, B35, B36 compaction recovery: overflow halves the summary input, summaries need ¼ worth, 1/16 answer share, 64 re-reads crates/rig-memory/src/compaction.rs:950-955, plugins/rig-basics/src/compaction.rs:305, :696-1008 mechanism how a recovery chooses its step, never what the user may ask
17 4096-token margin under the window tokens.rs:352 mechanism the estimate's error
18, 19 compaction reserve, summary and snippet sizes, keep_asked compaction.rs:859-864 setting (exists) [compaction]
L4, L5, G13 one build per home, one launcher per session, ACP session exclusivity src/launcher/locks.rs:98, src/launcher/run.rs:630, plugins/rig-acp/src/router.rs:307 mechanism locks that keep data whole
L8 tool process groups end with the run src/launcher/run.rs:320 mechanism owner decision 1 (i5); setsid keeps one
G22, G23, G25, G26, B47, B48 TUI preview 64 KiB, 200 notices, 50 completions, 6400 marks; activity 64 deliveries; diagnostics 100 plugins/rig-tui/src/pick.rs:32, view.rs:33, complete.rs:26, markdown.rs:57, plugins/rig-basics/src/activity.rs:20, telemetry/diagnostics.rs:12 mechanism display retention; the session log keeps everything
B49, L11, B24, + excerpts: rebuild 40 lines, build 40 lines, recalled input 10 lines, helper title 60 chars, inspect 16 KiB, redaction 8 chars / 16 KiB plugins/rig-basics/src/reload/rebuild.rs:16, src/launcher/build.rs:63, print.rs:1114, helpers.rs:305, plugins/rig-inspect/src/lib.rs:69, plugins/rig-basics/src/redact.rs:51, :54 kept excerpt; the whole is named or spilled
T2, T3, T4, T7, T15 read 2000 lines / 50 KiB, 2000-char lines, attach 32 KiB, list 2000 / 50 KiB crates/rig-tools/src/lib.rs:85-92, attach.rs:26, list.rs:194 kept paging
T8, T9, T10, T11 search 200 matches, 1000 names, 300-char hits, one hit per line search.rs:21-24, :375 kept narrowing
T34, T35 edit summaries and unread-path summaries edit.rs:32, fs.rs:228 kept summary
T33 edit refuses an ambiguous match, atomic batch edit.rs:488 kept correctness
T5, T13, T14, T31 text tools read regular UTF-8 files; listings skip ignored, hidden, symlinks; mutations refuse directories workspace.rs:445, :194, :631, fs.rs:84 kept tool design
T22, T29, T37 fresh sh -c with null stdin; no move across machines; overlays run no commands shell.rs:163, remote.rs:944, overlay.rs:256 kept tool design
6, 8, B2, G7 a child gets only its parent's tools; a child's model must call tools crates/rig-ecs/src/agent.rs:388-408, model.rs:93-96 kept owner decision 6; a model that can't call tools can't be an agent
11, G9, G20 image to a model that reads none sent as its name; Steel and ACP show images as [image] inbox.rs:776, program.rs:387, translate.rs:159 kept model fact / no image channel there
23, B28, B14 project settings only for declared tables; remote hosts user-only; approval denies first settings.rs:199, coding_tools/remote.rs:15, approval.rs:41 kept the user's safety defaults (b)
24, B27, B9 a WorkDir confines file tools; helpers start inside the caller's directory per_call.rs:296-308, helpers.rs:164 kept confinement the user chose
B3–B7 task adds message/wait; who may message whom; deadlock refusals; helpers lend startup tools subagents/spawn.rs:250, tools.rs:246-425, helpers.rs:81 kept protocol
B42–B46 skill and instruction discovery rules skills.rs:143-332, agents_md.rs:43 kept layout
B50 checkpoints are per run checkpoint.rs:189 kept design (b)
G1, G5, G6, G8 Steel sandbox, send to its own agents, no recursion, a reply read once program.rs:30-378, harness.rs:335 kept sandbox / protocol
G12, G15, G17, G19 ACP: one directory, closed questions without forms, commands it doesn't advertise, no SSE router.rs:749, questions.rs:20, session.rs:768, :396 kept protocol
L6, L9, L10, L18, L19 resume uses the saved directory; RIG_SECRET_* stripped; own target dir; plugin names; debug builds src/launcher/run.rs:286, build.rs:203, :221, plugin.rs:352, build.rs:146 kept launcher design
+ Steel's 16 MiB native stack, task pools 2–4 threads (blocking work has its own threads), symlink hops 40, diff time 100 ms, chunk sizes, MCP backoff 60 s, keys per frame lib.rs:71, crates/rig-harness/src/lib.rs:248, fs.rs:169, :375, lib.rs:151, process.rs:213, mcp lib.rs:73, input.rs:43 mechanism stacks, pools and frames

Capability changes

Capability Before After
helper tasks per call / at once / timeout at most 8 / 4 in batches / 600 s, clamped to 3600 any number / [helpers] at_once (4), queued / [helpers] timeout_secs (600), a call's own unclamped
A shell command that prints a lot killed past 10 MB runs to its end; | head -c bounds it
A model looping on a tool it lacks turn failed on the third call the same, [tools] max_unavailable_calls (3) changes it
A failing provider 4 retries, waits ≤ 60 s the same, [retry] changes both
A Steel program ≤ 60 s, ≤ 1000 calls the same, [steel] changes both
A turn's answer ≤ 64 000 tokens the model's whole output limit; [compaction] answer_tokens asks for less
keep_recent above ¼ of the window silently ¼ as set
rig -p "…" with slow piped input went alone after 1 s waits for the input; </dev/null for none
Prompt history newest 500 every distinct prompt
A misspelled or wrong-typed key in a settings.toml table that whole table at defaults, with a warning rig doesn't start until it's fixed (project files still only warn; a misspelled table name still starts with an error notice)

The owner approved these (quotes above). Nothing else that removes a capability is in the diff.

Choices left to the user

These stay as they are; each needs the owner's word to change.

  • [tools] max_unavailable_calls counts per turn, not "in a row". The owner's decision said "3 in a row"; this keeps main's count of one name's calls per turn (see "Decisions made alone"). Counting strictly in a row is a one-line reset in refuse_calls.
  • Bounded defaults. Each is kept because, without it, rig itself fails:
    • [steel] max_output_bytes 64 KiB: what a program prints is held in memory until it ends;
    • the 64 MiB pipe backlog: rig would hold unbounded output for a reader that stopped reading;
    • 64 MiB piped prompt, request and ACP/MCP lines: owner decision 4;
    • the 16 MiB whole-file read: the file is read whole into memory;
    • the 1 MiB unfinished-line split: one line would be held whole;
    • the 5 MiB image: an image the provider refuses stays in the conversation and fails every later request.
  • Remote file operations, 2 at a time per host. They queue and never refuse. A server that drops bursts of new SSH handshakes would otherwise fail writes ambiguously. ControlMaster is the outside way to remove the need.
  • Claude login's 300 s browser wait. It can't be checked without a Claude login, which this run may not use.
  • Dead-peer timeouts (build self-description 10 s, ACP handshake 60 s, model list 10 s, Claude login HTTP 30 s). They detect that a process or server rig itself depends on stopped answering. They aren't bounds on work the user asked for.
  • rig-steel now depends on rig-harness. It needs this to read [steel]. An app built on rig-ecs alone can still add the plugin, but it now pulls in rig-harness.

Decisions made alone

  • Durable setting keys: [retry] max_retries, max_wait_secs; [tools] max_unavailable_calls; [helpers] timeout_secs, at_once; [steel] max_seconds, max_calls, max_output_bytes; [compaction] answer_tokens.
  • [retry] and [tools] are read by the harness's RunPlugin, the plugin that owns the run contract. The kernel only reads its resources, whose own defaults are the same, so an app on rig-ecs alone keeps them.
  • [tools] max_unavailable_calls counts as main did: calls of one name per turn, also with successful calls between them. The owner's decision said "3 in a row"; counting per turn stops the same loop as soon or sooner (a model that alternates a missing tool with a real one is stopped too) and keeps main's tested count. If "in a row" is meant literally, it is a reset on any other call, in refuse_calls.
  • [helpers] at_once is a pool: a call's tasks start as earlier ones end, rather than in whole batches of four as on main.
  • No key has an "off" value (see the top): any number, no upper clamp; 0 does not read where it would mean nothing at all.
  • Steel cuts what a program printed, its value and its failure each on its own at max_output_bytes.
  • The default answer budget is the model's output limit, but never more than its window; with no known output limit, rig asks for none.
  • A refused run records its exit reason (code 2) at once, so no other exit, such as the end of --json input, ends it first; the agent loop stays held, the exit is written directly as run.rs's exit_unstarted does, and the turns its session restored are left to the next run (CarryOn), as on a signal before the start.
  • The settings refusal runs in LauncherPlugin::finish, before any plugin's finish waits on stdin, and again in cleanup for a table a plugin reads in its own finish. A refused start says its settings problem once on stderr (not again as a startup notice; it stays in the session's log).
  • Item 13 is dropped with evidence (table above).
  • Item 5 changed from "say it in the notice" to "honour a set value", after both design critics showed the ¼ cap is rig's own policy.

Line deltas

Measured with git diff --numstat origin/main...HEAD at 62b621c (main at 4908f42, after #2994), files under tests/ or named tests.rs counted as tests, .md/.txt/.toml/Cargo.lock as docs and manifests:

  • Production: +1,020 / −457, net +563.
  • Tests: +1,037 / −148, net +889.
  • Docs and manifests: +64 / −31.
  • In all, 2,757 changed lines, over the wave's 1,500-line flag. It stays one PR: the settings, their defaults and the silent sites share the same files, and each review fix touched the code it reviewed.
  • No inline test modules; the repo keeps tests in sibling files.
  • Public items in the touched files: 906 → 908.
    • Removed: AT_ONCE, RETRY_POLICY, PromptHistory::KEPT, TURN_OUTPUT.
    • Added: UnavailableCalls, CompactionPolicy::keep_recent() and answer_tokens, Found::skipped, saved_directory, env::WARNING; Refused now carries its text.

Target revised. The prompt expected production lines to go down net. Measured: +563.

  • Why: the owner's second decision keeps every limit, as a setting with a default, instead of deleting most of them, so few lines go away. Each limit gains a settings struct and its read ([retry], [tools], [helpers], [steel]), the 14 silent sites add their refusals and warnings, and the review rounds added fixes, each a defect a reviewer showed in the code: a refused start that waited on stdin, ran a frame, stopped restored turns or exited 0 first; a helper answer over 64 KB that was lost; compaction failures that dropped the reason or didn't name keep_recent; a request moved to a smaller model keeping the first model's output limit; and notices that rig -p hid. (Not measured per part.)
  • Cost: none in behaviour.

Testing

Local, at 62b621c, on main 4908f42:

  • cargo nextest run --profile local --test-threads 4 on rig-ecs, rig-harness, rig-mcp-client, rig-basics and rig (lib, bins and tests): 1677 passed. That commit makes a settings.toml that is not TOML refuse in one line naming the line and column (toml's own text drew the spot over several lines), puts a space in the retry give-up and MCP timeout messages ("N s"), and rewords docs.
  • Earlier heads, whose later commits change only messages, docs and the not-TOML problem: at 6bad28c the changed packages (rig-ecs, rig-memory, rig-harness, rig-basics, rig-steel, rig-mcp-client, rig-tools, rig-tui) passed 1054 tests, and the launcher with corpus-base and -shared 188; at 041741f every touched package and its corpus users (adding rig-harness-protocol, rig and corpus-fh) passed 2298, and the helper tests (19) again at 799badc.
  • cargo clippy --all-targets on the same packages: clean. cargo doc --no-deps with RUSTDOCFLAGS=-D warnings: clean.
  • cargo xtask verify --affected was not run whole. The lockfile changed (rig-steel's new dependency), which selects the workspace; CI runs it.

Every silent site has a test that fails on main. For each limit, a test shows the default holds and the key changes it:

  • [retry]: by default a transient failure gives up after 4 retries and past a 60 s asked wait, naming the key; a policy set higher retries on (retry/tests.rs); the table sets the policy (run/tests.rs).
  • [tools] max_unavailable_calls: the third call of a missing tool fails the turn, naming the key (kernel_only.rs); set to 10, the turn goes on (a_higher_unavailable_call_limit_lets_the_turn_go_on); the table reads, 0 does not.
  • [helpers]: by default exactly four children run at once and the rest wait their turn, any number of tasks taken, a call's timeout: 7200 accepted (side_by_side_helpers_run_four_at_a_time_…); at_once = 10 runs ten together; a call without timeout gets timeout_secs (600), and the timeout message names it; 0 does not read for either key, and timeout: 0 is refused. Also a 200 KB answer read whole and the stderr marker.
  • [steel]: 1000 calls by default and as many as max_calls says; max_seconds stops a program naming the key; the table sets both (defaults 60 and 1000); a failure kept past the output cut.
  • Deleted for good, each with a test: a shell command printing 12 MB runs to its end; stdin piped in after 1.5 s is read; every distinct prompt is kept in history; a set keep_recent is kept; the answer budget is the model's limit (answer_tokens sets less, 0 does not read).
  • A zero MCP wait does not read.

Live, in the real app (ChatGPT login, chatgpt/gpt-6.1-sol, effort low). At 62b621c, [helpers] then a broken [steel header: rig: …/home/settings.toml: not TOML at line 3, column 7: unclosed table, expected `]`; fix the file and start again., exit 2; [helpers] at_once = 0: rig: …/home/settings.toml [helpers]: invalid value: integer `0`, expected a nonzero usize in `at_once`; fix the file and start again., exit 2. The first checks ran on the branch's first head; the later ones on heads since rebased away (9812596, 41c827e), and the last, [compaction] answer_tokens = 0, on 7f55d5f:

  • Item 14: [steel] max_callz = 2 in RIG_HOME/settings.toml:
    rig: …/home/settings.toml [steel]: unknown field `max_callz`, expected one of `max_seconds`, `max_calls`, `max_output_bytes`; fix the file and start again.
    EXIT=2
    
    At 7f55d5f, [compaction] answer_tokens = 0: rig: …/settings.toml [compaction]: answer_tokens: invalid value: integer `0`, expected a nonzero u64; fix the file and start again., exit 2. At 41c827e, [retry] max_retriez = 2 refuses both printf x | rig -p hi and rig --json </dev/null with exit 2. At 9812596, with [retry] max_retries = "four" and input piped in, said once:
    rig: …/home/settings.toml [retry]: invalid type: string "four", expected u32 in `max_retries`; fix the file and start again.
    EXIT=2
    
  • A setting's refusal: [steel] max_calls = 1, then a program with two call-tools:
    call-tool: too many host function calls: 1 at most (`[steel] max_calls`)
    
  • Item 12: read red.png in the TUI:
     read red.png
       …/work/red.png: a PNG image of 1 KB
       [1 image]
    
  • helper with 10 tasks and "timeout": 7200: accepted, 10 of 10 helpers answered., in seconds. Nothing was left to stop. Again at cfbfec5, on top of feat(usage,helpers,budget)!: one agent tree's spend is counted, and an opt-in budget holds #2994, each with its spend note ([helper session …, 2.2k tokens]), and at 9bddd90 under the new defaults (4 at a time, the rest queued): 10 of 10 helpers answered.
  • printf 'ok \377' | rig -p --json hi: exit 2, and the exit line carries "error":"stdin: what was piped in is not UTF-8 text from byte 3 on: pipe in text".
  • Item 15: a session made in work/gone, the directory removed, then rig -r <id> -p … from work:
    rig: The session's directory …/work/gone is gone, so it runs in …/work.
    

Not verified live:

  • the new retry behaviour against a real outage (unit and stage tests only);
  • Steel's time limit;
  • answer_tokens;
  • the narrowing warning (item 4) and task's effort line (item 6): tests only;
  • Windows: no CI job builds or tests it, so nothing here was checked on Windows;
  • macOS: CI's macOS lane runs only the subscription and some process and signal tests, not the changed shell, helper, print or Steel tests;
  • anything with a Claude login.

Try it:

cd ~/code/rigs/wt-i17-no-hidden-limits && cargo run --bin rig -- -m chatgpt/gpt-6.1-sol

Then ask for a helper call with ten tasks. To see a setting's refusal, add [steel] with max_calls = 1 to ~/.rig/settings.toml (with rig_steel::SteelPlugin enabled) and run a program with two call-tools. Misspell the key, and rig refuses to start, naming it.

Rebased onto #2994 (i18)

Both PRs edit plugins/rig-basics/src/helpers.rs. This PR is on top of i18's usage recording: its caps are gone from that version, and a child's stdout line is put back together before i18's parser reads it (a usage or exit line past 64 KB was read in pieces). i18's test a_helper_a_stopped_call_never_started_spent_nothing (four running, a fifth refused its start after the stop) passes unchanged in meaning, with [helpers] at_once at its default of 4.

Not in this PR

i18 owns per-tree spend, helper usage recording, the budget plugin, and deleting corpus cost_budget and subagent_policy. i18 merged first as #2994; this PR is rebased on it ("Rebased onto #2994" above).

Follow-ups

  • Checkpoint scans skip files over 16 MiB without saying so (crates/rig-tools/src/checkpoint.rs:312).
  • Process pipe read errors become EOF (crates/rig-tools/src/process.rs:318).
  • A request moved to a model with a larger output limit, as a fallback moves it, keeps the limit rig fitted for the first (plugins/rig-basics/src/compaction.rs fit_output; as on main).

Changelog

  • (rig-basics) [breaking] helper's limits are [helpers] at_once (4, the rest queue) and timeout_secs (600); a call takes any number of tasks and its own timeout has no upper clamp; timeout: 0 is refused. AT_ONCE is gone.
  • (rig-tools) [breaking] shell no longer kills a command after 10 MB of output; timeout_secs: 0, read's offset: 0 and list's depth: 0 are refused; search counts the files it skipped (Found::skipped); a spill file that fails says so.
  • (rig-ecs) [breaking] the retry policy is the RetryPolicy resource (was turn::RETRY_POLICY), and the calls to a missing tool that fail a turn are UnavailableCalls, both with main's defaults; an agent narrowed to its parent's or the run's tools is warned which it lost.
  • (rig-harness) [breaking] a RIG_HOME/settings.toml table that doesn't read refuses the start (exit 2), naming file, table and key; new settings [retry] max_retries and max_wait_secs and [tools] max_unavailable_calls; a refused run holds the agent loop and says why in --json's exit.error.
  • (rig-steel) [breaking] [steel] max_seconds (60), max_calls (1000) and max_output_bytes (64 KiB); a refusal names its key, and what a program printed no longer pushes out how it ended. rig-steel now depends on rig-harness.
  • (rig-memory) [breaking] CompactionPolicy::keep_recent is an Option: a set value is kept as set; answer_tokens sets a turn's answer budget, by default the model's output limit within its window.
  • (rig-basics) [breaking] rig -p reads piped stdin until it closes, and refuses input that fails to read or isn't UTF-8; compaction retries follow [retry]; task says when a subagent's effort is its model's default.
  • (rig-mcp-client) [breaking] [mcp] connect_timeout_secs and first_retry_secs are NonZeroU64: 0 refuses the start.
  • (rig-tui) [breaking] tool results show JSON blocks and count images; prompt history keeps every distinct prompt (PromptHistory::KEPT is gone).
  • (rig) a resumed session whose directory is gone warns, naming both directories.

Migration

  • rig_ecs::turn::RETRY_POLICY → Res<rig_ecs::retry::RetryPolicy>, same fields and defaults.
  • CompactionPolicy { keep_recent: 20_000, .. } → keep_recent: None for the old behaviour (20 000, at most a quarter of the input limit); Some(n) is kept as given. Read the value in effect with policy.keep_recent(Some(spec)).
  • [compaction] answer_tokens = 64000 comes close to the old budget, but is also sent to a model whose window and output limit are both unknown (main sent none), and an explicit request limit is now held to the model's output limit, with a warning.
  • McpSettings { connect_timeout_secs: 30, .. } → NonZeroU64.
  • rig_basics::helpers::AT_ONCE is replaced by the [helpers] at_once setting (default 4). rig_tui::PromptHistory::KEPT and rig_memory::TURN_OUTPUT are gone, with no replacement.
  • CompactionPolicy gains answer_tokens and rig_tools::search::Found gains skipped: code that builds them field by field adds them (..Default::default() keeps working).
  • rig_harness::host::launcher::Refused carries the refusal's text: Refused(why).
  • Scripts that ran rig -p "…" with an open, silent stdin now wait for it to close: give </dev/null.
  • A settings file with a typo that rig used to start with now refuses to start, naming the key.

@gold-silver-copper gold-silver-copper changed the title fix!: rig has no caps of its own, and says what it changes fix!: every limit is a settings.toml key with a sensible default, and rig says what it changes Nov 3, 2026
…unavailable calls, [steel] 60 s / 1000, [helpers] 600 s / 4 at once
@gold-silver-copper gold-silver-copper changed the title fix!: every limit is a settings.toml key with a sensible default, and rig says what it changes fix!: every limit on asked work is a settings.toml key with a sensible default, and rig says what it changes Nov 3, 2026
@gold-silver-copper
gold-silver-copper added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 125417d Nov 3, 2026
16 checks passed
@gold-silver-copper
gold-silver-copper deleted the fix/no-hidden-limits branch November 3, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant