Skip to content

fix(stella-cli): install the sub-agent pool ceiling that was documented but dead (#1849) - #1879

Open
macanderson wants to merge 2 commits into
mainfrom
fix/1849-subagent-pool-ceiling
Open

fix(stella-cli): install the sub-agent pool ceiling that was documented but dead (#1849)#1879
macanderson wants to merge 2 commits into
mainfrom
fix/1849-subagent-pool-ceiling

Conversation

@macanderson

@macanderson macanderson commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

DEFAULT_POOL_LIMIT_USD = 2.0 is documented as the bound that stops "a model looping on task" from quietly spending a session's budget on research. It bound nothing.

SessionSubAgents::new installs it — and install then calls with_pool_limit(pool_limit_usd), which replaces the guard wholesale. Every production installer reaches that through install_for_session, which passed None, and None means unlimited, not "nothing to override".

The failure compounds downward: carve(None, None) against an unlimited pool yields ceiling: None, so the children inherited nothing either. A session without --budget whose model wedged on delegation ran every child to max_steps with no dollar bound at any layer.

Change

One call site, routed through a named function:

- None,
+ session_pool_limit_usd(),

with_pool_limit's semantics are left alone rather than reinterpreted. A caller that genuinely wants no pool ceiling needs a way to say so, and redefining None as "keep the default" would remove that while leaving the identical trap one level up. The fix belongs at the call site — which now names its choice, because an unexplained literal there is exactly what went wrong.

Warn or stop — the maintainer's call, stated

The issue asks for both options on the record.

It stays Observed: crossing $2 warns, children keep running. That is this repository's standing posture (degradation warns, never disables), and the enforcing bound is elsewhere and unchanged — the parent's guard is the hard ceiling, via the spend ledger the engine drains at each step boundary, so a session that passed --budget already stops. Making the pool itself enforcing would add a second wall no caller asked for and no flag can raise.

Both readings are written into session_pool_limit_usd's doc comment, and switching is a one-line change to the mode install_for_session passes.

Witness

an_unbudgeted_session_still_installs_a_sub_agent_pool_ceiling asserts both halves, because the first alone is satisfiable by a ceiling that never reaches a child:

  1. the pool carries the ceiling — not an unbounded guard;
  2. a carve against it hands the child finite headroom and the session's mode.

The second is the half that made this invisible: an unlimited pool carves an unlimited child, so a ceiling nothing inherits is the same as no ceiling at all.

On the old behaviour:

test subagent::tests::an_unbudgeted_session_still_installs_a_sub_agent_pool_ceiling ... FAILED
assertion `left == right` failed: the documented default must be what a session actually installs

The dispatcher is built exactly as install_for_session builds it — same mode, same limit. Only the provider differs, because constructing the real one needs credentials a unit test has no business holding; that is the one gap and it is named rather than papered over.

$ cargo test -p stella-cli --bin stella
test result: ok. 1428 passed; 0 failed

Urgency

#1836 makes this worse if it lands first: concurrent siblings can carve in parallel, so an unbounded pool becomes an unbounded overshoot rather than a bounded one.

Closes #1849

Summary by Sourcery

Ensure deterministic tool schema ordering across MCP clients and reinstate the documented default sub-agent pool ceiling for unbudgeted sessions.

Bug Fixes:

  • Fix nondeterministic MCP tool advertisement order so prompt-cache keys are stable across processes.
  • Ensure unbudgeted sessions install the documented default sub-agent pool ceiling instead of leaving the pool unbounded.

Enhancements:

  • Clarify pool ceiling semantics and introduce a named helper for the default session sub-agent pool limit, making configuration intent explicit.

Tests:

  • Add tests verifying byte-identical tool schema serialization regardless of MCP server connection order and enforcing the sub-agent pool ceiling and inheritance for unbudgeted sessions.

Stella Test added 2 commits August 6, 2026 04:06
…t is byte-stable

`ToolRegistry::schemas` sorts by name for a stated reason: the list is
serialized verbatim at position 0 of the prompt prefix, prompt caching is a
byte-level prefix match, and HashMap iteration is per-process randomized. So
two processes share the tools+system cache entry only if they emit the same
bytes.

`McpToolSet::schemas` then concatenated after that sorted list without
re-sorting, which handed the guarantee straight back to `self.clients` order
and to which server finished connecting first. Within one process the order is
stable, so this is a CROSS-process miss — a restart inside the cache TTL, or
two stella processes in one workspace, is exactly the case the registry's sort
comment says it exists for.

The MCP segment is now sorted by its namespaced name, which makes the answer
independent of both client order and connection-completion order rather than
merely stable within a run. Namespaced names are unique by construction
(`routes` is keyed on them), so the order is total and no tie is left for the
sort to break arbitrarily.

Segments are preserved rather than flattened: native tools first, then MCP.
That order is a deliberate contract ("the base layer the MCP set augments"),
and sorting the whole list would have made the new test pass while silently
moving the native tools — so the witness asserts the segment boundary too.

The two sibling decorators were checked and need no change.
`CandidateMcpView::schemas` concatenates a sorted native list with a filtered
view of `inner.schemas()`, and filtering preserves relative order, so it
inherits this fix. `DiscoveryToolSet::discovery_schemas` builds a literal
`vec![]`, which is ordered by construction.

Witness: `schemas_are_byte_identical_whatever_order_the_servers_connected_in`
builds two sets from the same two servers registered in opposite orders and
compares the SERIALIZED schemas — bytes, because bytes are what the cache
matches on. It fails on the old code ("two processes that connected the same
servers in different orders must advertise the same bytes") and passes with
the sort.

This changes the advertised order once, which is a one-time prompt-cache
invalidation for sessions live across the upgrade. That is the cost of having
the property at all, and it is paid once rather than on every restart.

`cargo test -p stella-mcp` — 146 passed, 0 failed. The prompt-cache golden
fixtures (`cargo test -p stella-pipeline --test cache_correctness`) are
unaffected: 5 passed.

Closes #1848
…ed but dead

`DEFAULT_POOL_LIMIT_USD = 2.0` is documented as the bound that stops "a model
looping on `task`" from quietly spending a session's budget on research. It
bound nothing.

`SessionSubAgents::new` installs it, and `install` then calls
`with_pool_limit(pool_limit_usd)` — which REPLACES the guard wholesale. Every
production installer reached that through `install_for_session`, which passed
`None`, and `None` means unlimited rather than "nothing to override". So a
session without `--budget` whose model wedged on delegation ran every child to
`max_steps` with no dollar bound at any layer: the pool was unlimited, and
`carve(None, None)` against an unlimited pool yields `ceiling: None`, so the
children inherited nothing either.

`with_pool_limit`'s semantics are left alone rather than reinterpreted. A
caller that genuinely wants no pool ceiling needs a way to say so, and making
`None` mean "keep the default" would take that away while leaving the same
trap one level up. The fix belongs at the call site, which now names its
choice through `session_pool_limit_usd()` — a named function rather than a
literal, because a literal at the call site is exactly what went wrong.

It stays `Observed`, so crossing $2 warns and the children keep running. That
is this repository's standing posture — degradation warns, never disables —
and the enforcing bound is elsewhere and unchanged: the parent's guard is the
hard ceiling, via the spend ledger the engine drains at each step boundary, so
a session that passed `--budget` already stops. Making the pool itself
enforcing would add a second wall no caller asked for and no flag can raise.
The issue asks for both options to be stated; they are, in the function's own
doc comment, and switching is a one-line change to the mode
`install_for_session` passes.

Witness: `an_unbudgeted_session_still_installs_a_sub_agent_pool_ceiling`
asserts both halves, because the first alone is satisfiable by a ceiling that
never reaches a child — the pool carries the ceiling, AND a carve against it
hands the child finite headroom and the session's mode. It fails on the old
behaviour ("the documented default must be what a session actually installs").

The dispatcher is built exactly as `install_for_session` builds it, same mode
and same limit; only the provider differs, because constructing the real one
needs credentials a unit test has no business holding.

`cargo test -p stella-cli --bin stella` — 1428 passed, 0 failed.

Closes #1849

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Aug 6, 2026 11:13am

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Fixes the sub-agent pool ceiling so unbudgeted sessions actually install the documented DEFAULT_POOL_LIMIT_USD, clarifies pool-limit semantics, and makes MCP tool schema advertisement deterministic across server connection order with tests documenting both contracts.

Sequence diagram for installing sub-agent pool ceiling in sessions

sequenceDiagram
    actor User
    participant Cli
    participant SessionSubAgents

    User->>Cli: start_session
    Cli->>Cli: install_for_session(cfg, registry, BudgetMode::Observed, session_pool_limit_usd())
    Cli->>Cli: session_pool_limit_usd()
    Cli-->>Cli: Some(DEFAULT_POOL_LIMIT_USD)
    Cli->>SessionSubAgents: install(cfg, registry, BudgetMode::Observed, Some(DEFAULT_POOL_LIMIT_USD))
    SessionSubAgents->>SessionSubAgents: with_pool_limit(Some(DEFAULT_POOL_LIMIT_USD))
    SessionSubAgents-->>Cli: session with bounded sub-agent pool
Loading

Flow diagram for deterministic MCP tool schema advertisement

flowchart TD
    A[McpToolSet.schemas] --> B[collect native.schemas]
    B --> C[collect mcp ToolSchema into mcp]
    C --> D[mcp.sort_by name]
    D --> E[schemas.extend mcp]
    E --> F[return schemas]

    subgraph Segments
      B
      C
    end
Loading

File-Level Changes

Change Details Files
Make MCP tool schema advertisement deterministic and aligned with the native+MCP segment ordering contract for prompt caching.
  • Collect MCP tool schemas into a separate vector instead of appending directly to the main schemas list.
  • Sort the MCP schemas by their namespaced name to make ordering independent of client list and connection timing.
  • Extend the final schemas list by first including native tools, then the sorted MCP tools, preserving the segment contract.
  • Add an async test that asserts schemas serialization is byte-identical regardless of MCP server connection order and that the native segment precedes a sorted MCP segment.
crates/stella-mcp/src/toolset.rs
Ensure unbudgeted sessions install a finite sub-agent pool ceiling using DEFAULT_POOL_LIMIT_USD and that carved child pools inherit this ceiling and the session’s budget mode.
  • Add a test that verifies session_pool_limit_usd returns the documented default ceiling and that a SessionSubAgents pool and its carved child both carry this finite limit and Observed budget mode.
  • Use session_pool_limit_usd instead of None when installing sub-agent pools for sessions to avoid unbounded pools in unbudgeted sessions.
  • Document SessionSubAgents::with_pool_limit semantics so None is clearly defined as removing any ceiling rather than keeping a default.
  • Introduce session_pool_limit_usd helper to centralize the default ceiling choice, document why the pool uses Observed mode instead of enforcing, and make changing that policy a single call-site edit.
crates/stella-cli/src/subagent/tests.rs
crates/stella-cli/src/subagent.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#1849 Wire DEFAULT_POOL_LIMIT_USD as the default sub-agent pool ceiling in install_for_session, so that sessions without an explicit --budget still have a $2 ceiling, and the pool guard’s mode matches the parent session’s budget mode (Observed vs Enforced). The PR introduces session_pool_limit_usd() returning Some(DEFAULT_POOL_LIMIT_USD) and uses it in install_for_session, so unbudgeted sessions now get a $2 ceiling instead of None (unlimited). However, install_for_session still hard-codes BudgetMode::Observed instead of inheriting the parent session’s budget mode. The issue’s fix direction explicitly requested that the pool guard mode match the session’s mode; the PR intentionally keeps it Observed and only documents the alternative, so this part of the objective is not fully implemented.
#1849 State and document the maintainer’s choice between warning vs stopping at the pool ceiling, including both options in the PR so that switching behavior is straightforward.
#1849 Ensure that a child carved from the sub-agent pool inherits a finite dollar ceiling from the pool (rather than remaining unlimited), and add tests that verify the pool and carved children carry that ceiling.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

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.

cli: the sub-agent spend pool's documented $2 ceiling is unreachable — every production installer passes None

1 participant