diff --git a/amplifier_foundation/bundle/_dataclass.py b/amplifier_foundation/bundle/_dataclass.py index e4de9b2..adc291d 100644 --- a/amplifier_foundation/bundle/_dataclass.py +++ b/amplifier_foundation/bundle/_dataclass.py @@ -56,6 +56,9 @@ class Bundle: When absent (the default), the YAML file's own directory is used. Only single-level relative paths are supported; 3+ level nesting is out of scope. session: Session config (orchestrator, context). + routing: Optional routing configuration (e.g. ``{'matrix': 'openai'}``). + Opaque to foundation; interpreted by the host application. Acts as + a DEFAULT -- user/project settings override it. providers: List of provider configs. tools: List of tool configs. hooks: List of hook configs. @@ -98,6 +101,7 @@ class Bundle: spawn: dict[str, Any] = field( default_factory=dict ) # Spawn config (exclude_tools, etc.) + routing: dict[str, Any] = field(default_factory=dict) # Resources agents: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -132,6 +136,8 @@ def __post_init__(self) -> None: self._pending_context = {} if self.origins is None: self.origins = {} + if self.routing is None: + self.routing = {} def compose(self, *others: Bundle) -> Bundle: """Compose this bundle with others (later overrides earlier). @@ -186,6 +192,7 @@ def compose(self, *others: Bundle) -> Bundle: tools=list(self.tools), hooks=list(self.hooks), spawn=dict(self.spawn), + routing=dict(self.routing), agents=dict(self.agents), context=initial_context, _pending_context=initial_pending_context, @@ -235,6 +242,10 @@ def compose(self, *others: Bundle) -> Bundle: # Spawn config: deep merge (later overrides) result.spawn = deep_merge(result.spawn, other.spawn) + # Routing: deep merge (later overrides). Opaque passthrough -- + # foundation does not interpret matrix/overrides keys. + result.routing = deep_merge(result.routing, other.routing) + # Module lists: merge by module ID result.providers = merge_module_lists(result.providers, other.providers) result.tools = merge_module_lists(result.tools, other.tools) @@ -298,6 +309,13 @@ def to_mount_plan(self) -> dict[str, Any]: if self.spawn: mount_plan["spawn"] = dict(self.spawn) + # NOTE: self.routing is deliberately NOT included in the mount plan. + # The mount plan is the kernel-facing surface; routing is app-layer + # policy (which provider/model a role maps to). Adding it here would + # leak policy toward the kernel. Host apps read bundle.routing + # directly off the Bundle/PreparedBundle to apply their own routing + # policy on top of it -- do not "fix" this by adding it back. + return mount_plan async def prepare( @@ -770,6 +788,13 @@ def from_dict(cls, data: dict[str, Any], base_path: Path | None = None) -> Bundl data.get("context", {}), base_path ) + # Routing is opaque passthrough: foundation stores and merges the dict, + # it does not validate or interpret matrix/overrides keys. Non-dict + # values (e.g. a bare string) coerce to an empty dict rather than + # raising, since foundation has no opinion on the schema. + _routing = data.get("routing") or {} + routing = _routing if isinstance(_routing, dict) else {} + return cls( name=bundle_name, version=bundle_meta.get("version", "1.0.0"), @@ -781,6 +806,7 @@ def from_dict(cls, data: dict[str, Any], base_path: Path | None = None) -> Bundl tools=tools, hooks=hooks, spawn=data.get("spawn", {}), + routing=routing, agents=_parse_agents(data.get("agents", {}), base_path), context=resolved_context, _pending_context=pending_context, diff --git a/docs/BUNDLE_GUIDE.md b/docs/BUNDLE_GUIDE.md index b5ba5d2..290bed2 100644 --- a/docs/BUNDLE_GUIDE.md +++ b/docs/BUNDLE_GUIDE.md @@ -1237,6 +1237,16 @@ spawn: # OR use explicit list: # tools: [tool-a, tool-b] # Agents get ONLY these tools +# Optional default routing matrix. Opaque to foundation -- it stores and +# deep-merges this dict but does not interpret matrix/overrides. This is a +# DEFAULT: user/project settings' own `routing:` block always wins. Omit +# this key entirely and behavior is unchanged from today. +routing: + matrix: openai + overrides: + coding: + model: gpt-5 + # Declare which agents this bundle PROVIDES (a mapping value). # Not to be confused with the same key in an *agent's* frontmatter, where a # string or list value declares which agents that agent may DELEGATE TO -- @@ -1292,6 +1302,8 @@ includes: - Later bundles override earlier ones - `session`: deep-merged (nested dicts merged recursively, later wins for scalars) - `spawn`: deep-merged (later overrides earlier) +- `routing`: deep-merged (later overrides earlier); this is a DEFAULT -- any + `routing:` set in user/project settings overrides whatever a bundle declares - `providers`, `tools`, `hooks`: merged by module ID (configs for same module are deep-merged) - `agents`: merged by agent name (later wins) - `context`: accumulates with namespace prefix (each bundle contributes without collision) diff --git a/tests/test_bundle_routing_field.py b/tests/test_bundle_routing_field.py new file mode 100644 index 0000000..8eb139a --- /dev/null +++ b/tests/test_bundle_routing_field.py @@ -0,0 +1,91 @@ +"""Tests for the Bundle.routing field (opaque passthrough for bundle-declared +default routing matrix configuration). + +Foundation stores and merges this dict; it does not interpret or validate +its contents (e.g. `matrix`, `overrides`). A separate app-cli PR consumes it. +""" + +from amplifier_foundation.bundle import Bundle + + +class TestBundleRoutingFromDict: + """Tests for Bundle.from_dict's handling of the routing field.""" + + def test_from_dict_no_routing_key_yields_empty_dict(self) -> None: + """A bundle dict with no 'routing' key produces bundle.routing == {}. + + This is the single most important property: existing bundles with + no routing: key must behave byte-identically to today. + """ + data = {"bundle": {"name": "test"}} + bundle = Bundle.from_dict(data) + assert bundle.routing == {} + + def test_from_dict_reads_routing_matrix_and_overrides(self) -> None: + """routing: {matrix, overrides} is read through unchanged (opaque).""" + data = { + "bundle": {"name": "test"}, + "routing": { + "matrix": "openai", + "overrides": {"coding": {"model": "gpt-5"}}, + }, + } + bundle = Bundle.from_dict(data) + assert bundle.routing == { + "matrix": "openai", + "overrides": {"coding": {"model": "gpt-5"}}, + } + + def test_from_dict_non_dict_routing_coerces_to_empty(self) -> None: + """A malformed 'routing' value (bare string) coerces to {} instead + of raising -- foundation does not validate routing semantics.""" + data = {"bundle": {"name": "test"}, "routing": "openai"} + bundle = Bundle.from_dict(data) + assert bundle.routing == {} + + +class TestBundleRoutingCompose: + """Tests for Bundle.compose's handling of the routing field.""" + + def test_compose_overlay_routing_wins_over_base(self) -> None: + """Later bundle's routing scalar values win over the base's.""" + base = Bundle(name="base", routing={"matrix": "anthropic"}) + overlay = Bundle(name="overlay", routing={"matrix": "openai"}) + result = base.compose(overlay) + assert result.routing["matrix"] == "openai" + + def test_compose_deep_merge_preserves_base_matrix_when_overlay_sets_only_overrides( + self, + ) -> None: + """An overlay declaring only 'overrides' keeps the base's 'matrix' + (deep merge, not replace).""" + base = Bundle( + name="base", + routing={"matrix": "anthropic", "overrides": {"coding": {"x": 1}}}, + ) + overlay = Bundle( + name="overlay", + routing={"overrides": {"coding": {"y": 2}}}, + ) + result = base.compose(overlay) + assert result.routing["matrix"] == "anthropic" + assert result.routing["overrides"] == {"coding": {"x": 1, "y": 2}} + + def test_compose_base_routing_survives_overlay_without_routing(self) -> None: + """An overlay bundle with no routing at all does not clobber the + base's routing config.""" + base = Bundle(name="base", routing={"matrix": "anthropic"}) + overlay = Bundle(name="overlay") + result = base.compose(overlay) + assert result.routing == {"matrix": "anthropic"} + + +class TestBundleRoutingMountPlan: + """Tests for Bundle.to_mount_plan's handling of the routing field.""" + + def test_to_mount_plan_omits_routing(self) -> None: + """routing is deliberately absent from the mount plan -- it is + app-layer policy, not a kernel-facing concern.""" + bundle = Bundle(name="test", routing={"matrix": "anthropic"}) + plan = bundle.to_mount_plan() + assert "routing" not in plan