Skip to content

fix(spawn_utils): one answer to "which provider instance is anthropic?" (67u) - #355

Merged
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/67u-named-delegate-matrix-bypass
Sep 3, 2026
Merged

fix(spawn_utils): one answer to "which provider instance is anthropic?" (67u)#355
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/67u-named-delegate-matrix-bypass

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

Item: model_performance-67u"why does an EXPLICITLY-NAMED delegate bypass matrix resolution when an organic one does not?"
Spend: $0.00 against a $0 authority. Code read + pure-function reproduction; no API calls, no DTU, no probe re-runs.
Full write-up: docs/lanes/67u-named-delegate-matrix-bypass/DONE-NOTE.md.


Answer to the item: there is no bypass

tool-delegate has exactly one resolver call site, guarded on a model_role that is read only from the tool input:

1625:  raw_model_role = input.get("model_role", "").strip()
1636:  if raw_model_role and provider_preferences is None:
1650:      resolved = await resolver.resolve(raw_model_role)

The agent's own declared model_role is never consulted there — the sole agent-level fallback is provider_preferences:

1819:  # Apply agent-level default provider_preferences if caller didn't specify
1821:      agent_cfg = agents.get(agent_name, {})
1822:      agent_default_prefs = agent_cfg.get("provider_preferences", [])

grep 'agent_cfg\|agents\.get' returns only that pair plus two return-contract sites. So the "organic" and "named" paths are the same code; they differ only in whether the calling model put model_role in the tool arguments. The probe's prompt fully specified the call ("delegate to agent X with instruction Y"), the model emitted exactly those two arguments, the guard was false, and the child took the documented, opt-out session-default fall-through (:1699-1712, strict_model_role).

Corroborated independently by effort: anthropic.yaml's reasoning carries reasoning_effort: high and a promotion does carry a candidate's config (spawn_utils.py:767-773), yet the child's session:config shows the root's xhigh — so no promotion occurred.

The agent-frontmatter model_role is resolved elsewhere — hooks-routing writes preferences into agent configs at session:start (amplifier-app-cli/session_spawner.py:568-575). Out of this repo; reported, not chased, per the item's scope-out.

All three reproductions verdict (b) INTENDED, argued from code. The economy row (Anthropic globs → OpenAI models) is ordered-candidate fallback authored into economy.yaml itself: both children landed on candidate #2 of their own role (reasoningopenai: gpt-?.?-terra*, codingopenai: gpt-?.?-luna*). Reproduced as a pure function. That row is also the one that proves per-role resolution ran — a session default gives both children the same model; these differ from each other and from the root.

No shipped routing decision is wrong today on this axis. The organic path is what real workloads use, h7n proved it healthy, and it is untouched here.


The defect this code read did find (what this PR actually changes)

Three helpers in spawn_utils.py gave three answers to "which mounted instance does the bare module type anthropic mean?":

helper rule line
_find_provider_instance highest priority :601-617
_find_provider_index first declared :636-645
_build_provider_lookup last declared (dict last-write-wins) :660-673

apply_provider_preferences_with_resolution uses two of them in one pass: resolves the candidate's model glob against the instance the first picks (:430), then promotes the index the third returns (:859-888).

Measured on the eval-harness roster (10 mounts, 2 module types, cell forced to priority 0):

_build_provider_lookup["anthropic"] -> 'fable'    prio 7   (promoted)
_find_provider_instance("anthropic")-> 'opus'     prio 0   (model list read)
_build_provider_lookup["openai"]    -> 'luna-max' prio 9   (promoted)
_find_provider_instance("openai")   -> 'sol'      prio 2   (model list read)

The model resolved from one instance's list is written as default_model onto a different instance, and that one is promoted to priority 0 — carrying its own base_url, long-context and cache-retention settings. Silently: the model name still looks right.

Only fires with ≥2 instances of one provider module — which is exactly the shape a routing matrix asks for (a matrix addresses providers by bare module type; distinct id:s exist "for routing-matrix disambiguation", :585). Not the default single-instance install.

Fix: one rule via a shared _provider_priority()highest priority wins, ties by declaration order; an explicit instance id beats a module-type key. Applied to all three helpers.

Review notes

  • Single-instance plans are unchanged — pinned by test_single_instance_plans_are_unchanged.
  • Behaviour change to eyeball: a multi-instance plan that sets no priority previously resolved a bare type to the last declared instance and now resolves to the first — which is what _find_provider_instance already did, so this removes a disagreement rather than inventing a rule.
  • _find_provider_index has no production caller today (definition + tests only). Aligned anyway: leaving a third disagreeing rule in the same file is how this gets re-filed.

Tests

tests/test_named_delegate_matrix_67u.py — 15 tests: 4 characterization (intended named-delegate behaviour — do not "fix" these), 8 fail-before defect tests, 3 economy-fallback reproductions.

Placed in tests/ rather than modules/tool-delegate/tests/ because CI runs pytest tests/ only; pyproject.toml sets pythonpath = ["modules/tool-delegate"], so tests/ imports DelegateTool directly and these actually run in CI. (The modules/tool-delegate/tests CI exclusion is a known gap, noted, not fixed here.)

Full suite, repo root:

baseline @ 18efe87 : 1924 passed, 1 skipped, 1 warning in 20.17s
this branch        : 1939 passed, 1 skipped, 1 warning in 19.21s     (+15, 0 regressions)

Fail-before, reverting only spawn_utils.py:

7 failed, 8 passed
AssertionError: assert 'fable' == 'opus'

ruff check / ruff format --check clean on both touched files. The one repo-wide ruff error (F401 ParsedURI in amplifier_foundation/updates/__init__.py) is pre-existing on 18efe87 — verified by stashing this branch's changes — and left alone.


Open thread handed on (not this repo)

Why hooks-routing produced n_prefs: 0 for 11 of 13 agents in that container — i.e. why layer B (agent-frontmatter model_role resolved at session:start) did not populate preferences — lives in amplifier-bundle-routing-matrix / amplifier-app-cli. Recorded as open rather than guessed; no fresh run was needed to answer this item, so none is being requested.

…c`?"

Investigating model_performance-67u ("why does an explicitly-named delegate
bypass matrix resolution?") produced two results.

1. THE NAMED-DELEGATE PATH IS NOT A BYPASS -- characterization tests.

   tool-delegate has exactly ONE resolver call site (__init__.py:1636),
   guarded on a `model_role` read only from the tool input (:1625). The
   agent's own declared model_role is never consulted there -- the sole
   agent-level fallback is `provider_preferences` (:1822). So a delegate
   call that names an agent and supplies no model_role resolves nothing and
   takes the documented, opt-out session-default fall-through (:1699-1712).
   The "organic" path is the same code with one extra tool argument.

   The agent-frontmatter model_role is resolved elsewhere -- hooks-routing
   writes preferences into agent configs at session:start (see app-cli
   session_spawner.py:568-575). Out of this repo; reported, not chased.

   Four characterization tests pin this so it stops being folklore. They
   assert INTENDED behaviour and pass before and after this change.

2. A REAL DEFECT, FIXED HERE -- fail-before tests.

   Three helpers answered "which mounted instance does the bare module type
   `anthropic` mean?" three different ways:

       _find_provider_instance  -> highest priority
       _find_provider_index     -> first declared
       _build_provider_lookup   -> last declared (dict last-write-wins)

   apply_provider_preferences_with_resolution uses TWO of them in one pass:
   it resolves the candidate's model glob against the instance the first
   picks, then promotes the index the third returns. On a 10-mount plan with
   2 module types (the eval-harness roster) those are different mounts, so
   the model resolved from instance A's model list was written onto instance
   B's config and B was promoted to priority 0 -- right model, wrong
   instance, and with it B's base_url / long-context / cache-retention
   settings. Silently: the model name still looks correct.

   This only fires with >=2 instances of one provider module, which is
   exactly the shape a routing matrix asks for (a matrix addresses providers
   by bare module type; distinct `id:`s exist for matrix disambiguation).

   Fix: one rule, shared via _provider_priority() -- highest priority wins,
   ties by declaration order, and an explicit instance `id` beats a
   module-type key. Single-instance plans are unchanged (pinned by test).
   Multi-instance plans with no `priority` set now resolve to the first
   declared instance rather than the last, which is what
   _find_provider_instance already did.

Suite: 1924 -> 1939 passed, 1 skipped, 0 regressions.
Reverting only spawn_utils.py: 7 failed, 8 passed.

Refs: model_performance-67u
@bkrabach
Brian Krabach (bkrabach) marked this pull request as ready for review September 3, 2026 11:47
@bkrabach

Copy link
Copy Markdown
Collaborator Author

Manager verification — two parts, judged separately. Merging.

Head ff423a9, base 18efe874 = current origin/main. CI green on all 7 jobs.

Part 1 — THE ANSWER (no code change): accepted, anchors re-checked in current source

claim verified
model_role read ONLY from tool input, :1625 raw_model_role = input.get("model_role", "").strip() — yes
ONE guarded resolver call site, :1636 if raw_model_role and provider_preferences is None: — yes
agent-level fallback reads provider_preferences, NOT model_role, :1822 agent_cfg.get("provider_preferences", []) — yes

I re-ran the grep rather than trusting it. model_role_resolver appears at :959 (the
enumerate-roles helper, unrelated) and :1638the only resolver call in the spawn path. The
only agent-config reads are :1821-1822 (preferences) and :2061/:2510 (return contract). So
"named" and "organic" really are the same code, differing only in whether the calling model put
model_role in the tool arguments. There is no bypass; the three reproductions are the
documented opt-out fall-through.

Part 2 — THE DEFECT FIX: fail-before reproduced against current main

67u's tests against 18efe874 with its source absent:

7 failed, 8 passed

The 7 failures are exactly the split-brain set (test_lookup_agrees_with_find_provider_instance,
test_lookup_picks_highest_priority_instance_not_last_declared,
test_promotion_lands_on_the_instance_that_resolved_the_glob, …). The 8 that pass are the Part-1
characterization tests — correct, since they pin behaviour that is already true.

Full suite: 1924 passed on main → 1939 passed on the head. +15 = exactly the new tests, zero regressions.

The behaviour change — stated plainly, because it is real

This does change which instance a bare module type resolves to, and I am merging anyway. On the
h7n roster, anthropic moves fable (prio 7, last-declared) → opus (prio 0), and openai moves
luna-max (prio 9) → sol (prio 2). That is the point: apply_provider_preferences_with_resolution
was resolving a candidate's model glob against one instance and promoting another in the same
pass. The new answer is the one _find_provider_instance already gave, so this removes a
disagreement rather than inventing a rule
— and the DONE-NOTE flags the no-priority case (last →
first declared) for review itself rather than burying it.

Blast radius, as the lane scopes it: only where ≥2 instances of one provider module are mounted AND a
matrix candidate addresses it by bare type AND the instances differ in more than model — the eval
harness and multi-account setups, not the default single-instance install. "When it fires the model
name still looks right, which is why it survived this long."

Aligning _find_provider_index too (no production caller today) is the right call: leaving a third
disagreeing rule in the same file is how this gets re-filed.

Squash + --admin (base-branch policy, as with #350/#353).

@bkrabach
Brian Krabach (bkrabach) merged commit 5d8db2f into main Sep 3, 2026
7 checks passed
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.

2 participants