Repository navigation
fix!: every limit on asked work is a settings.toml key with a sensible default, and rig says what it changes - #2995
Merged
Merged
Conversation
gold-silver-copper
force-pushed
the
fix/no-hidden-limits
branch
from
November 2, 2026 23:12
2800473 to
7a18a02
Compare
gold-silver-copper
force-pushed
the
fix/no-hidden-limits
branch
from
November 3, 2026 01:37
09dbba4 to
cfbfec5
Compare
gold-silver-copper
force-pushed
the
fix/no-hidden-limits
branch
from
November 3, 2026 02:14
9bddd90 to
041741f
Compare
…unavailable calls, [steel] 60 s / 1000, [helpers] 600 s / 4 at once
gold-silver-copper
force-pushed
the
fix/no-hidden-limits
branch
from
November 3, 2026 02:40
041741f to
799badc
Compare
…eep KEEP_RECENT private
8 of 11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
RIG_HOME/settings.tomltable that doesn't read refuses to start).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)
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 runat_onceat a time, and a call's owntimeouthas no upper clamp (was 3600 s).timeout: 0is refused.shell: a command is no longer killed after 10 MB of output (| head -cdoes that). Past the cut, the output still goes to the spill file and only its first and last lines stay in memory.keep_recentyou 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/nullgives none.[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)
helpertimeout: 0became 1 s, 5000 became 3600 s[helpers] timeout_secs(600)helpertool the run's--toolsleaves out was dropped from the child"`read` is not one of this run's tools (`--tools`)"shell,write: an agent above it does not have them." or "Lostshell: this run's tools leave it out." Not for--toolsas the run starts, which the user gavekeep_recentsilently capped at ¼ of the windowtasksubagent on another model, given no effort, got that model's default unsaidtask's answer says so;run_steel's description andAgentSpec::effort's doc state the rulesearchskipped files over 16 MB or not UTF-8 and said "No matches"shellwithgrep -asearches them]"shelltimeout_secs: 0became 1 sreadoffset: 0andlistdepth: 0became 1[1 image]_arm is unreachable. The only other variant ofacp::McpServer(v1 schema 1.5.0) is behind the crate'sunstable_mcp_over_acpfeature, which no crate here enables, and an unknowntypefails to deserialize before this code. No test can build oneRIG_HOME/settings.tomltable that doesn't read: a warning, and every key of that table at its default.rig/settings.tomlkeeps its warning. A[mcp]wait of 0 now fails to read too, instead of becoming 1rig -pshows it too)Also from the design review: a
helperchild'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 reachesrig -p --json'sexit.error(it said "the run ended"), so ahelperparent 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 beforerig -pwaits for piped input. A compaction that gave up names why (Not retrying: … (`[retry] max_retries` is 1)).API changes
RETRY_POLICYbecomes a resource (rig-ecs). The kernel readsRes<RetryPolicy>, with the same defaults; rig'sRunPluginfills it from[retry]. Likewise the newrig_ecs::tools::UnavailableCallsfrom[tools].CompactionPolicy(rig-memory):Smaller items
rig-steelnow depends onrig-harness, to read[steel]throughadd_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:lineis at 6335613, the main this PR started from; #2993 movedplugins/rig-basics/src/redact.rsa 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).helpercallplugins/rig-basics/src/helpers.rs:219at_onceat a time, none refusedhelpers.rs:34,:276[helpers] at_once, default 4; tasks past it wait their turn (a pool, no longer whole batches)helpers.rs:230[helpers] timeout_secs, default 600; a call's owntimeouthas no upper clamp; 0 refusedhelpers.rs:359helpers.rs:365crates/rig-tools/src/shell.rs:28| head -cin 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 askedcrates/rig-ecs/src/tools.rs:117-119,:628[tools] max_unavailable_calls, default 3 (per name, per turn, as on main); the failure names the keycrates/rig-ecs/src/turn.rs:85,retry.rs:26-31[retry] max_retries, default 4crates/rig-ecs/src/retry.rs:45-49[retry] max_wait_secs, default 60plugins/rig-basics/src/compaction.rs:1419-1431[retry]like any model call, and names it when it gives upplugins/rig-steel/src/lib.rs:75[steel] max_seconds, default 60plugins/rig-steel/src/program.rs:25[steel] max_calls, default 1000plugins/rig-steel/src/lib.rs:82[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 endedcrates/rig-memory/src/tokens.rs:348,compaction.rs:1014[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_OUTPUTis deletedkeep_recentcapped at ¼ of the input limitcrates/rig-memory/src/compaction.rs:379keep_recentthe 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 modelplugins/rig-basics/src/print.rs:383</dev/nullfor noneplugins/rig-tui/src/history.rs:64connect_timeout_secs,first_retry_secs0 silently 1plugins/rig-mcp-client/src/lib.rs:511,:514[mcp] connect_timeout_secs(default 30 s, dead-peer detection)crates/rig-harness/src/host/pipes.rs:37pipes.rs:32print.rs:378,:381;plugins/rig-acp/src/lib.rs:43;plugins/rig-mcp-client/src/lib.rs:284crates/rig-tools/src/lib.rs:89readrefuses out loud, search now counts what it skippedcrates/rig-tools/src/attach.rs:23shell.rs:392STOP_GRACE5 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:614plugins/rig-basics/src/login/offered.rs:47-57src/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,:216plugins/rig-claude-cloak/src/auth.rs:454crates/rig-tools/src/remote.rs:62remote.rs:99crates/rig-core/src/client/chatgpt_models.rs:31crates/rig-memory/src/compaction.rs:950-955,plugins/rig-basics/src/compaction.rs:305,:696-1008tokens.rs:352keep_askedcompaction.rs:859-864[compaction]src/launcher/locks.rs:98,src/launcher/run.rs:630,plugins/rig-acp/src/router.rs:307src/launcher/run.rs:320setsidkeeps oneplugins/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:12plugins/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,:54crates/rig-tools/src/lib.rs:85-92,attach.rs:26,list.rs:194search.rs:21-24,:375edit.rs:32,fs.rs:228edit.rs:488workspace.rs:445,:194,:631,fs.rs:84sh -cwith null stdin; no move across machines; overlays run no commandsshell.rs:163,remote.rs:944,overlay.rs:256crates/rig-ecs/src/agent.rs:388-408,model.rs:93-96[image]inbox.rs:776,program.rs:387,translate.rs:159settings.rs:199,coding_tools/remote.rs:15,approval.rs:41WorkDirconfines file tools; helpers start inside the caller's directoryper_call.rs:296-308,helpers.rs:164taskaddsmessage/wait; who may message whom; deadlock refusals; helpers lend startup toolssubagents/spawn.rs:250,tools.rs:246-425,helpers.rs:81skills.rs:143-332,agents_md.rs:43checkpoint.rs:189sendto its own agents, no recursion, a reply read onceprogram.rs:30-378,harness.rs:335router.rs:749,questions.rs:20,session.rs:768,:396RIG_SECRET_*stripped; own target dir; plugin names; debug buildssrc/launcher/run.rs:286,build.rs:203,:221,plugin.rs:352,build.rs:146lib.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:43Capability changes
helpertasks per call / at once / timeout[helpers] at_once(4), queued /[helpers] timeout_secs(600), a call's own unclamped| head -cbounds it[tools] max_unavailable_calls(3) changes it[retry]changes both[steel]changes both[compaction] answer_tokensasks for lesskeep_recentabove ¼ of the windowrig -p "…"with slow piped input</dev/nullfor nonesettings.tomltableThe 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_callscounts 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 inrefuse_calls.[steel] max_output_bytes64 KiB: what a program prints is held in memory until it ends;ControlMasteris the outside way to remove the need.rig-steelnow depends onrig-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
[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'sRunPlugin, 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_callscounts 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, inrefuse_calls.[helpers] at_onceis a pool: a call's tasks start as earlier ones end, rather than in whole batches of four as on main.max_output_bytes.--jsoninput, ends it first; the agent loop stays held, the exit is written directly asrun.rs'sexit_unstarteddoes, and the turns its session restored are left to the next run (CarryOn), as on a signal before the start.LauncherPlugin::finish, before any plugin'sfinishwaits on stdin, and again incleanupfor a table a plugin reads in its ownfinish. A refused start says its settings problem once on stderr (not again as a startup notice; it stays in the session's log).Line deltas
Measured with
git diff --numstat origin/main...HEADat 62b621c (main at 4908f42, after #2994), files undertests/or namedtests.rscounted as tests,.md/.txt/.toml/Cargo.lockas docs and manifests:AT_ONCE,RETRY_POLICY,PromptHistory::KEPT,TURN_OUTPUT.UnavailableCalls,CompactionPolicy::keep_recent()andanswer_tokens,Found::skipped,saved_directory,env::WARNING;Refusednow carries its text.Target revised. The prompt expected production lines to go down net. Measured: +563.
[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 namekeep_recent; a request moved to a smaller model keeping the first model's output limit; and notices thatrig -phid. (Not measured per part.)Testing
Local, at 62b621c, on main 4908f42:
cargo nextest run --profile local --test-threads 4on rig-ecs, rig-harness, rig-mcp-client, rig-basics and rig (lib, bins and tests): 1677 passed. That commit makes asettings.tomlthat 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.cargo clippy --all-targetson the same packages: clean.cargo doc --no-depswithRUSTDOCFLAGS=-D warnings: clean.cargo xtask verify --affectedwas 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'stimeout: 7200accepted (side_by_side_helpers_run_four_at_a_time_…);at_once = 10runs ten together; a call withouttimeoutgetstimeout_secs(600), and the timeout message names it; 0 does not read for either key, andtimeout: 0is refused. Also a 200 KB answer read whole and the stderr marker.[steel]: 1000 calls by default and as many asmax_callssays;max_secondsstops a program naming the key; the table sets both (defaults 60 and 1000); a failure kept past the output cut.keep_recentis kept; the answer budget is the model's limit (answer_tokenssets less, 0 does not read).Live, in the real app (ChatGPT login,
chatgpt/gpt-6.1-sol, effort low). At 62b621c,[helpers]then a broken[steelheader: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:[steel] max_callz = 2inRIG_HOME/settings.toml:[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 = 2refuses bothprintf x | rig -p hiandrig --json </dev/nullwith exit 2. At 9812596, with[retry] max_retries = "four"and input piped in, said once:[steel] max_calls = 1, then a program with twocall-tools:read red.pngin the TUI:helperwith 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".work/gone, the directory removed, thenrig -r <id> -p …fromwork:Not verified live:
answer_tokens;task's effort line (item 6): tests only;Try it:
Then ask for a
helpercall with ten tasks. To see a setting's refusal, add[steel]withmax_calls = 1to~/.rig/settings.toml(withrig_steel::SteelPluginenabled) and run a program with twocall-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 (ausageorexitline past 64 KB was read in pieces). i18's testa_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_onceat its default of 4.Not in this PR
i18 owns per-tree spend, helper usage recording, the budget plugin, and deleting corpus
cost_budgetandsubagent_policy. i18 merged first as #2994; this PR is rebased on it ("Rebased onto #2994" above).Follow-ups
crates/rig-tools/src/checkpoint.rs:312).crates/rig-tools/src/process.rs:318).plugins/rig-basics/src/compaction.rsfit_output; as on main).Changelog
helper's limits are[helpers] at_once(4, the rest queue) andtimeout_secs(600); a call takes any number of tasks and its owntimeouthas no upper clamp;timeout: 0is refused.AT_ONCEis gone.shellno longer kills a command after 10 MB of output;timeout_secs: 0,read'soffset: 0andlist'sdepth: 0are refused;searchcounts the files it skipped (Found::skipped); a spill file that fails says so.RetryPolicyresource (wasturn::RETRY_POLICY), and the calls to a missing tool that fail a turn areUnavailableCalls, both with main's defaults; an agent narrowed to its parent's or the run's tools is warned which it lost.RIG_HOME/settings.tomltable that doesn't read refuses the start (exit 2), naming file, table and key; new settings[retry] max_retriesandmax_wait_secsand[tools] max_unavailable_calls; a refused run holds the agent loop and says why in--json'sexit.error.[steel] max_seconds(60),max_calls(1000) andmax_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.CompactionPolicy::keep_recentis anOption: a set value is kept as set;answer_tokenssets a turn's answer budget, by default the model's output limit within its window.rig -preads piped stdin until it closes, and refuses input that fails to read or isn't UTF-8; compaction retries follow[retry];tasksays when a subagent's effort is its model's default.[mcp] connect_timeout_secsandfirst_retry_secsareNonZeroU64: 0 refuses the start.PromptHistory::KEPTis gone).Migration
rig_ecs::turn::RETRY_POLICY→Res<rig_ecs::retry::RetryPolicy>, same fields and defaults.CompactionPolicy { keep_recent: 20_000, .. }→keep_recent: Nonefor the old behaviour (20 000, at most a quarter of the input limit);Some(n)is kept as given. Read the value in effect withpolicy.keep_recent(Some(spec)).[compaction] answer_tokens = 64000comes 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_ONCEis replaced by the[helpers] at_oncesetting (default 4).rig_tui::PromptHistory::KEPTandrig_memory::TURN_OUTPUTare gone, with no replacement.CompactionPolicygainsanswer_tokensandrig_tools::search::Foundgainsskipped: code that builds them field by field adds them (..Default::default()keeps working).rig_harness::host::launcher::Refusedcarries the refusal's text:Refused(why).rig -p "…"with an open, silent stdin now wait for it to close: give</dev/null.