From 41fbef88a5357dcbdc93f44e7d7065798f73ecd9 Mon Sep 17 00:00:00 2001 From: amplifier-lane Date: Wed, 2 Sep 2026 17:20:06 -0700 Subject: [PATCH] feat(tool-delegate): record routing-matrix provenance on spawn telemetry `delegate:agent_spawned` recorded WHICH provider_preferences a delegation resolved to, but not WHICH matrix file produced them. A user file in ~/.amplifier/routing/ silently outranks the bundle's own same-named matrix, so a surprising resolution in the event stream was indistinguishable from a shadowed matrix, a shipped-matrix change, or no routing at all. Adds an optional `routing_matrix` key (matrix_name / matrix_path / matrix_source / shadowed_paths) READ FROM the model_role_resolver capability's published attributes -- hooks-routing publishes them, and its own docstring names "a spawn-time telemetry payload" as the intended consumer. Nothing here re-derives matrix precedence; a second implementation of that precedence is the drift this avoids. Captured at the one site that actually consults the resolver (execute()), threaded to the emit site as a keyword-only arg, so the recorded identity is the strategy that produced THESE preferences and cannot drift from it. Additive and omitted when unknown: - Consumers ignoring the field are unaffected: no existing key's name, type or value changes. - Absent means UNKNOWN, never "no shadowing". Every capture on disk today lacks the key; an analyzer treating absence as a negative assertion would silently clear exactly the shadowed sessions this exists to catch. - No model_role, an explicit provider_preferences pin, an agent-level default, no routing bundle, or a resolver that reports no source all leave the payload byte-identical to before. Same key added to `delegate:model_role_unresolved`, where "which matrix failed to serve this role" is the first question asked. 17 new tests; verified non-vacuous (4 fail with the injection removed). --- modules/tool-delegate/README.md | 43 ++ .../__init__.py | 117 ++++- .../test_delegate_spawn_matrix_provenance.py | 428 ++++++++++++++++++ 3 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 modules/tool-delegate/tests/test_delegate_spawn_matrix_provenance.py diff --git a/modules/tool-delegate/README.md b/modules/tool-delegate/README.md index 4aa9f99..391e23f 100644 --- a/modules/tool-delegate/README.md +++ b/modules/tool-delegate/README.md @@ -265,6 +265,49 @@ so the gap is loud, never silent. warn-only -> S2 generous enforcement -> S3 target enforcement) before setting a production default. +## Matrix provenance on spawn telemetry + +`delegate:agent_spawned` records the `provider_preferences` a delegation +resolved to. It now also records **which routing matrix file produced them**, +under an optional `routing_matrix` key: + +```json +"routing_matrix": { + "matrix_name": "anthropic", + "matrix_path": "/home/u/.amplifier/routing/anthropic.yaml", + "matrix_source": "user", + "shadowed_paths": ["/opt/bundles/routing-matrix/routing/anthropic.yaml"] +} +``` + +A user file in `~/.amplifier/routing/` silently outranks the bundle's own +same-named matrix, so without this a surprising resolution in the event stream +is indistinguishable from a shadowed matrix, a shipped-matrix change, or no +routing at all. The values are **read from** the `model_role_resolver` +capability's published `matrix_path` / `matrix_source` / `shadowed_paths` +attributes (hooks-routing publishes them); nothing here re-derives matrix +precedence. + +The same key is added to `delegate:model_role_unresolved`, where "which matrix +file failed to serve this role" is the first question asked. + +### Reading it correctly + +| Situation | `routing_matrix` | +|---|---| +| Resolver produced the preferences and reports a source | present | +| No `model_role` (no routing requested) | **absent** | +| Explicit `provider_preferences` pin, or agent-level default | **absent** — the matrix never saw them | +| No routing bundle installed | **absent** | +| Resolver is a non-matrix strategy, or an older routing bundle | **absent** | + +**Absent means UNKNOWN, never "no shadowing."** Read it with +`payload.get("routing_matrix")`. Every capture recorded before this field +existed lacks the key, so an analyzer that treats absence as a negative +assertion would silently clear exactly the shadowed sessions the field exists +to catch. The field is purely additive: no existing key's name, type, or value +changed, and consumers that ignore it are unaffected. + ## Note This module is recommended over `tool-task` for new development due to its enhanced context control and bug fixes. diff --git a/modules/tool-delegate/amplifier_module_tool_delegate/__init__.py b/modules/tool-delegate/amplifier_module_tool_delegate/__init__.py index c3d71b6..0cd03ec 100644 --- a/modules/tool-delegate/amplifier_module_tool_delegate/__init__.py +++ b/modules/tool-delegate/amplifier_module_tool_delegate/__init__.py @@ -185,6 +185,73 @@ def _return_contract_event_fields(contract: dict[str, Any]) -> dict[str, Any]: } +def _matrix_provenance(resolver: Any) -> dict[str, Any] | None: + """Read matrix identity off a ``model_role_resolver`` capability. + + WHY THIS EXISTS. ``delegate:agent_spawned`` records the + ``provider_preferences`` a delegation resolved to, but not WHICH + routing-matrix file produced them. A user file in ``~/.amplifier/routing/`` + silently outranks the bundle's own same-named matrix, so a surprising + resolution in the event stream is indistinguishable from a shadowed + matrix, a shipped-matrix change, or no matrix at all. Two prior + investigations read the shipped file, reasoned about a matrix that was + not in effect, and reached confidently wrong mechanisms. + + CONSUMED, NOT RE-DERIVED. ``matrix_path`` / ``matrix_source`` / + ``shadowed_paths`` are published by the routing bundle on the capability + object this tool already holds (see hooks-routing's ``resolver_class`` + docstring, which names "a spawn-time telemetry payload" as the intended + consumer). Nothing here re-implements matrix precedence; a second + implementation of that precedence is exactly the drift this reads + published state to avoid. + + OPTIONAL BY CONTRACT. Every attribute is optional: the capability is + duck-typed and an alternate strategy (cost-aware, latency-aware) may + register under the same key without any notion of a "matrix file", as + may an older routing bundle predating these attributes. Absent is NOT + "no shadowing" -- it is "this strategy does not report a source", so + this returns ``None`` rather than a dict of nulls, and the caller omits + the key entirely. Values are type-guarded rather than trusted. + + Returns: + A dict with ``matrix_name`` / ``matrix_path`` / ``matrix_source`` / + ``shadowed_paths``, or ``None`` when the resolver reports no source + at all (absent attributes, all-``None`` values, or a resolver that + is itself ``None``). + """ + if resolver is None: + return None + + def _str_or_none(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + name = _str_or_none(getattr(resolver, "name", None)) + path = _str_or_none(getattr(resolver, "matrix_path", None)) + source = _str_or_none(getattr(resolver, "matrix_source", None)) + + raw_shadowed = getattr(resolver, "shadowed_paths", None) + shadowed: list[str] = [] + # str is itself a sequence -- iterating one yields characters, which + # would silently produce a list of single letters instead of failing. + if isinstance(raw_shadowed, (list, tuple)): + shadowed = [p for p in (_str_or_none(p) for p in raw_shadowed) if p] + + # A resolver that reports no file identity at all contributes nothing a + # forensic reader can act on. Emitting {"matrix_path": None, ...} would + # look like a positive statement ("we checked, there is no shadowing"); + # returning None keeps the key off the payload entirely, which reads + # correctly as "unknown". + if path is None and source is None and not shadowed: + return None + + return { + "matrix_name": name, + "matrix_path": path, + "matrix_source": source, + "shadowed_paths": shadowed, + } + + # Matches a fenced ```json ... ``` block, tolerant of ```JSON, surrounding # indentation, and trailing whitespace on the fence lines. The closing fence # must be alone on its own line so short "```" substrings inside the JSON @@ -1335,12 +1402,23 @@ async def execute(self, input: dict) -> ToolResult: # under the same key. We duck-type against the contract: # async def resolve(model_role) -> list[ProviderPreference] raw_model_role = input.get("model_role", "").strip() + # Matrix provenance for the spawn telemetry record. Captured HERE, + # at the one site that actually consults the resolver, rather than + # re-fetched at the emit site: this records the identity of the + # strategy that produced THIS delegation's preferences, and cannot + # drift from it if the capability is swapped mid-session. Stays + # None on every path where the matrix did not produce the + # preferences (explicit provider_preferences pin, agent-level + # defaults, no model_role at all) -- claiming a matrix produced + # preferences it never saw would be worse than saying nothing. + routing_matrix: dict[str, Any] | None = None if raw_model_role and provider_preferences is None: resolver = ( self.coordinator.get_capability("model_role_resolver") if hasattr(self.coordinator, "get_capability") else None ) + routing_matrix = _matrix_provenance(resolver) if resolver is None: logger.warning( "model_role '%s' specified but no model_role_resolver " @@ -1384,6 +1462,16 @@ async def execute(self, input: dict) -> ToolResult: "agent": agent_name, "resolver": resolver_name, "fallback_behavior": "session_default", + # Same additive/omitted-when-unknown contract + # as delegate:agent_spawned below. "Which + # matrix file failed to serve this role" is + # the first question asked of this event, and + # a shadowing user file is a leading cause. + **( + {"routing_matrix": routing_matrix} + if routing_matrix + else {} + ), }, ) @@ -1520,6 +1608,7 @@ async def execute(self, input: dict) -> ToolResult: raw_model_role=raw_model_role, agents=agents, call_budget_override=call_budget_override, + routing_matrix=routing_matrix, ) def _resolve_call_budget( @@ -1574,6 +1663,7 @@ async def _spawn_new_session( raw_model_role: str = "", agents: dict | None = None, call_budget_override: int | None = None, + routing_matrix: dict[str, Any] | None = None, ) -> ToolResult: """Spawn a new agent sub-session. @@ -1595,6 +1685,12 @@ async def _spawn_new_session( parallel_group_id: Parallel group ID (enriches event payloads) raw_model_role: Raw model role string for routing tracking agents: Agent config dict (defaults to coordinator.config["agents"]) + routing_matrix: Matrix provenance captured by execute() from the + ``model_role_resolver`` capability that produced + ``provider_preferences`` (see :func:`_matrix_provenance`). + ``None`` -- the default, and what every caller that does not + supply it gets -- omits the field from the emitted event + entirely, leaving the payload byte-identical to before. Returns: ToolResult with spawn outcome @@ -1642,7 +1738,23 @@ async def _spawn_new_session( # Get parent session parent_session = self.coordinator.session - # Emit delegate:agent_spawned event + # Emit delegate:agent_spawned event. + # + # `routing_matrix` is ADDITIVE and OMITTED when unknown -- see + # _matrix_provenance. Two backward-compatibility properties + # follow from that, both deliberate: + # + # 1. Consumers that ignore the field are unaffected: this is a + # dict payload, and an extra key changes nothing for a + # reader that does not look for it. No existing key's name, + # type, or value changes. + # 2. Analyzers reading OLD captures still work: they must read + # it with .get("routing_matrix"), and absent means UNKNOWN + # (this capture predates the field, or no matrix strategy + # reported a source) -- NOT "no shadowing". Every capture on + # disk today is in that state, so an analyzer that treats + # absence as a negative assertion would silently mis-clear + # exactly the shadowed sessions this field exists to catch. if hooks: await hooks.emit( "delegate:agent_spawned", @@ -1660,6 +1772,9 @@ async def _spawn_new_session( if provider_preferences else None ), + **( + {"routing_matrix": routing_matrix} if routing_matrix else {} + ), }, ) diff --git a/modules/tool-delegate/tests/test_delegate_spawn_matrix_provenance.py b/modules/tool-delegate/tests/test_delegate_spawn_matrix_provenance.py new file mode 100644 index 0000000..b1edade --- /dev/null +++ b/modules/tool-delegate/tests/test_delegate_spawn_matrix_provenance.py @@ -0,0 +1,428 @@ +"""Matrix provenance on spawn telemetry (``delegate:agent_spawned``). + +WHAT THIS PROTECTS. ``delegate:agent_spawned`` has always recorded the +``provider_preferences`` a delegation resolved to, but never WHICH routing +matrix file produced them. A user file in ``~/.amplifier/routing/`` silently +outranks the bundle's own same-named matrix, so a surprising resolution in +the event stream was indistinguishable from a shadowed matrix, a shipped +matrix change, or no routing at all. + +THE RESOLVER IS DUCK-TYPED AND ITS PROVENANCE ATTRIBUTES ARE OPTIONAL. +amplifier-foundation does not depend on any routing bundle -- that is the +point of the ``model_role_resolver`` capability -- so these tests use a +stand-in that mirrors, attribute for attribute, what hooks-routing's +``MatrixModelRoleResolver`` publishes: + + matrix_path: str | None -- WHICH FILE is actually running + matrix_source: str | None -- "user" | "bundle" + shadowed_paths: tuple[str, ...] -- every same-named file it outranked + +Absent/None means "this strategy does not report a source", NOT "no +shadowing" -- an alternate strategy (cost-aware, latency-aware) or an older +routing bundle has no notion of a matrix file at all. The tests below pin +that distinction, because collapsing it is precisely the failure that would +silently clear a shadowed session. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from amplifier_foundation.spawn_utils import ProviderPreference +from amplifier_module_tool_delegate import DelegateTool, _matrix_provenance + +# ============================================================================= +# Helpers +# ============================================================================= + +USER_MATRIX = "/home/u/.amplifier/routing/anthropic.yaml" +BUNDLE_MATRIX = "/opt/bundles/routing-matrix/routing/anthropic.yaml" + + +class _FakeMatrixResolver: + """Stand-in for hooks-routing's ``MatrixModelRoleResolver``. + + Mirrors the published attribute contract exactly. ``_unset`` sentinels + let a test model an OLDER routing bundle (or an alternate strategy) that + never defines these attributes at all -- distinct from defining them as + ``None``. + """ + + _UNSET = object() + + def __init__( + self, + *, + name: str = "anthropic", + matrix_path: Any = _UNSET, + matrix_source: Any = _UNSET, + shadowed_paths: Any = _UNSET, + resolves_to: list[ProviderPreference] | None = None, + ) -> None: + self.name = name + if matrix_path is not self._UNSET: + self.matrix_path = matrix_path + if matrix_source is not self._UNSET: + self.matrix_source = matrix_source + if shadowed_paths is not self._UNSET: + self.shadowed_paths = shadowed_paths + self._resolves_to = resolves_to if resolves_to is not None else [] + + async def resolve(self, model_role: str) -> list[ProviderPreference]: + return list(self._resolves_to) + + +def _shadowed_resolver(**kw: Any) -> _FakeMatrixResolver: + """A user file in ~/.amplifier/routing/ shadowing the shipped matrix.""" + return _FakeMatrixResolver( + matrix_path=USER_MATRIX, + matrix_source="user", + shadowed_paths=(BUNDLE_MATRIX,), + **kw, + ) + + +def _bundle_resolver(**kw: Any) -> _FakeMatrixResolver: + """The shipped matrix, nothing shadowed.""" + return _FakeMatrixResolver( + matrix_path=BUNDLE_MATRIX, + matrix_source="bundle", + shadowed_paths=(), + **kw, + ) + + +def _make_hooks() -> MagicMock: + hooks = MagicMock() + hooks.emit = AsyncMock() + return hooks + + +def _make_tool( + *, + hooks: MagicMock, + model_role_resolver: Any = None, + agents: dict | None = None, +) -> DelegateTool: + coordinator = MagicMock() + coordinator.session_id = "parent-session-123" + coordinator.config = {"agents": agents or {"test-agent": {"description": "t"}}} + coordinator.session_state = {} + + capabilities: dict = { + "session.spawn": AsyncMock( + return_value={ + "output": "done", + "session_id": "child-001", + "status": "success", + "turn_count": 1, + "metadata": {}, + } + ), + "session.resume": AsyncMock(return_value={}), + "self_delegation_depth": 0, + "model_role_resolver": model_role_resolver, + } + coordinator.get_capability = lambda name: capabilities.get(name) + + # coordinator.get("hooks") -> hooks; coordinator.get("providers") -> {} + coordinator.get = MagicMock( + side_effect=lambda key: hooks if key == "hooks" else None + ) + + parent_session = MagicMock() + parent_session.session_id = "parent-session-123" + parent_session.config = {"session": {"orchestrator": {}}} + coordinator.session = parent_session + + return DelegateTool( + coordinator, {"features": {}, "settings": {"exclude_tools": []}} + ) + + +def _emitted(hooks: MagicMock, event: str) -> list[dict]: + return [args[1] for args, _ in hooks.emit.call_args_list if args[0] == event] + + +async def _spawn(tool: DelegateTool, **overrides: Any) -> None: + payload: dict[str, Any] = { + "agent": "test-agent", + "instruction": "do a thing", + "context_depth": "none", + } + payload.update(overrides) + await tool.execute(payload) + + +# ============================================================================= +# _matrix_provenance -- the pure read-what-ell-publishes function +# ============================================================================= + + +class TestMatrixProvenanceReader: + def test_none_resolver_reports_nothing(self): + assert _matrix_provenance(None) is None + + def test_resolver_without_provenance_attrs_reports_nothing(self): + """An older routing bundle, or a non-matrix strategy. + + Must be None (-> key omitted), never a dict of nulls: a dict would + read as the positive claim "we looked, there is no shadowing". + """ + assert _matrix_provenance(_FakeMatrixResolver()) is None + + def test_explicit_none_attrs_report_nothing(self): + resolver = _FakeMatrixResolver( + matrix_path=None, matrix_source=None, shadowed_paths=() + ) + assert _matrix_provenance(resolver) is None + + def test_shadowed_resolver_is_read_verbatim(self): + assert _matrix_provenance(_shadowed_resolver()) == { + "matrix_name": "anthropic", + "matrix_path": USER_MATRIX, + "matrix_source": "user", + "shadowed_paths": [BUNDLE_MATRIX], + } + + def test_bundle_resolver_reports_empty_shadow_list(self): + assert _matrix_provenance(_bundle_resolver()) == { + "matrix_name": "anthropic", + "matrix_path": BUNDLE_MATRIX, + "matrix_source": "bundle", + "shadowed_paths": [], + } + + def test_string_shadowed_paths_is_not_iterated_character_wise(self): + """A str is a sequence -- iterating one yields letters, silently. + + A malformed strategy publishing a bare str must degrade to "no + shadowed paths reported", never to ['/', 'o', 'p', 't', ...]. + """ + resolver = _FakeMatrixResolver( + matrix_path=USER_MATRIX, + matrix_source="user", + shadowed_paths=BUNDLE_MATRIX, # a str, not a tuple + ) + result = _matrix_provenance(resolver) + assert result is not None + assert result["shadowed_paths"] == [] + + def test_non_string_entries_are_dropped_not_coerced(self): + resolver = _FakeMatrixResolver( + matrix_path=USER_MATRIX, + matrix_source="user", + shadowed_paths=(BUNDLE_MATRIX, None, 42, ""), + ) + result = _matrix_provenance(resolver) + assert result is not None + assert result["shadowed_paths"] == [BUNDLE_MATRIX] + + def test_shadowing_alone_is_enough_to_report(self): + """path/source absent but shadowing known -> still reportable.""" + resolver = _FakeMatrixResolver(shadowed_paths=(BUNDLE_MATRIX,)) + assert _matrix_provenance(resolver) == { + "matrix_name": "anthropic", + "matrix_path": None, + "matrix_source": None, + "shadowed_paths": [BUNDLE_MATRIX], + } + + +# ============================================================================= +# delegate:agent_spawned -- the spawn telemetry record +# ============================================================================= + + +class TestSpawnRecordsMatrixProvenance: + @pytest.mark.asyncio + async def test_spawn_records_the_matrix_that_produced_its_preferences(self): + prefs = [ProviderPreference(provider="anthropic", model="claude-haiku-3.5")] + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, model_role_resolver=_bundle_resolver(resolves_to=prefs) + ) + + await _spawn(tool, model_role="fast") + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + # The preferences and the file that produced them, in one record. + assert payload["provider_preferences"] == [p.to_dict() for p in prefs] + assert payload["routing_matrix"]["matrix_path"] == BUNDLE_MATRIX + assert payload["routing_matrix"]["matrix_source"] == "bundle" + assert payload["routing_matrix"]["matrix_name"] == "anthropic" + + @pytest.mark.asyncio + async def test_shadowed_load_records_the_shadowing_file_not_the_shipped_one(self): + """THE headline case. + + When a user file outranks the shipped matrix, the spawn record must + name the USER file as the matrix in effect. The shipped file appears + only as something that was shadowed -- never as the answer to "which + matrix produced these preferences". + """ + prefs = [ProviderPreference(provider="openai", model="gpt-5.6-sol")] + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, model_role_resolver=_shadowed_resolver(resolves_to=prefs) + ) + + await _spawn(tool, model_role="fast") + + matrix = _emitted(hooks, "delegate:agent_spawned")[0]["routing_matrix"] + assert matrix["matrix_path"] == USER_MATRIX, ( + "spawn recorded the shipped matrix while a user file was in effect" + ) + assert matrix["matrix_source"] == "user" + assert matrix["shadowed_paths"] == [BUNDLE_MATRIX] + # The shipped file must not be presented as the effective matrix. + assert matrix["matrix_path"] != BUNDLE_MATRIX + + @pytest.mark.asyncio + async def test_unresolved_role_still_records_which_matrix_failed(self): + """Silent-substitution event names the matrix that had no candidate.""" + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, model_role_resolver=_shadowed_resolver(resolves_to=[]) + ) + + await _spawn(tool, model_role="fast") + + payload = _emitted(hooks, "delegate:model_role_unresolved")[0] + assert payload["routing_matrix"]["matrix_path"] == USER_MATRIX + assert payload["routing_matrix"]["shadowed_paths"] == [BUNDLE_MATRIX] + + +# ============================================================================= +# Default behaviour byte-identical / backward compatibility +# ============================================================================= + +# Every key delegate:agent_spawned carried BEFORE this change. Pinned as a +# literal so a rename or drop fails here rather than silently breaking every +# analyzer reading the event stream. +_PRE_EXISTING_SPAWN_KEYS = { + "agent", + "sub_session_id", + "parent_session_id", + "context_depth", + "context_scope", + "tool_call_id", + "parallel_group_id", + "model_role", + "provider_preferences", +} + + +class TestDefaultBehaviourUnchanged: + @pytest.mark.asyncio + async def test_no_model_role_payload_is_byte_identical(self): + """The overwhelmingly common spawn: no routing requested at all.""" + hooks = _make_hooks() + tool = _make_tool(hooks=hooks, model_role_resolver=_shadowed_resolver()) + + await _spawn(tool) + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + assert set(payload) == _PRE_EXISTING_SPAWN_KEYS + assert "routing_matrix" not in payload + + @pytest.mark.asyncio + async def test_no_routing_bundle_installed_payload_is_byte_identical(self): + hooks = _make_hooks() + tool = _make_tool(hooks=hooks, model_role_resolver=None) + + await _spawn(tool, model_role="fast") + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + assert set(payload) == _PRE_EXISTING_SPAWN_KEYS + + @pytest.mark.asyncio + async def test_resolver_without_provenance_payload_is_byte_identical(self): + """An older routing bundle predating the published attributes.""" + prefs = [ProviderPreference(provider="anthropic", model="claude-haiku-3.5")] + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, + model_role_resolver=_FakeMatrixResolver(resolves_to=prefs), + ) + + await _spawn(tool, model_role="fast") + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + assert set(payload) == _PRE_EXISTING_SPAWN_KEYS + assert payload["provider_preferences"] == [p.to_dict() for p in prefs] + + @pytest.mark.asyncio + async def test_explicit_pin_records_no_matrix(self): + """An explicit provider_preferences pin never consults the matrix. + + Recording a matrix here would be a lie: the resolver was not asked, + so no matrix produced these preferences. + """ + hooks = _make_hooks() + tool = _make_tool(hooks=hooks, model_role_resolver=_shadowed_resolver()) + + await _spawn( + tool, + model_role="fast", + provider_preferences=[{"provider": "openai", "model": "gpt-5.6-terra"}], + ) + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + assert "routing_matrix" not in payload + assert payload["provider_preferences"] == [ + ProviderPreference(provider="openai", model="gpt-5.6-terra").to_dict() + ] + + @pytest.mark.asyncio + async def test_pre_existing_keys_are_unchanged_when_matrix_is_recorded(self): + """Additive means additive: no existing key's name or value moves.""" + prefs = [ProviderPreference(provider="anthropic", model="claude-haiku-3.5")] + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, model_role_resolver=_shadowed_resolver(resolves_to=prefs) + ) + + await _spawn(tool, model_role="fast") + + payload = _emitted(hooks, "delegate:agent_spawned")[0] + assert set(payload) == _PRE_EXISTING_SPAWN_KEYS | {"routing_matrix"} + assert payload["agent"] == "test-agent" + assert payload["model_role"] == "fast" + assert payload["provider_preferences"] == [p.to_dict() for p in prefs] + + @pytest.mark.asyncio + async def test_old_capture_reader_pattern_still_works(self): + """An analyzer reading OLD captures must not crash and must not + conclude 'no shadowing' from an absent field. + + Simulates the two capture generations side by side: every capture on + disk today lacks the key entirely. + """ + old_capture_payload = { + "agent": "explorer", + "model_role": "fast", + "provider_preferences": [{"provider": "openai", "model": "gpt-5.6-sol"}], + } + + hooks = _make_hooks() + tool = _make_tool( + hooks=hooks, + model_role_resolver=_shadowed_resolver( + resolves_to=[ProviderPreference(provider="openai", model="gpt-5.6-sol")] + ), + ) + await _spawn(tool, model_role="fast") + new_capture_payload = _emitted(hooks, "delegate:agent_spawned")[0] + + def matrix_of(payload: dict) -> str: + """The correct reader: absent -> UNKNOWN, never 'not shadowed'.""" + matrix = payload.get("routing_matrix") + if matrix is None: + return "unknown" + return "shadowed" if matrix["shadowed_paths"] else "clean" + + assert matrix_of(old_capture_payload) == "unknown" + assert matrix_of(new_capture_payload) == "shadowed"