Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions amplifier_foundation/bundle/_dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions docs/BUNDLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions tests/test_bundle_routing_field.py
Original file line number Diff line number Diff line change
@@ -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
Loading