diff --git a/tests/tools/test_delegate_capability_inheritance.py b/tests/tools/test_delegate_capability_inheritance.py new file mode 100644 index 0000000000..922dd3cc3b --- /dev/null +++ b/tests/tools/test_delegate_capability_inheritance.py @@ -0,0 +1,52 @@ +"""Subagent capability inheritance (follow-up to #94036/#97292). + +The trusted-proxy capability map is endpoint-scoped trust: children inherit +it only on the parent's exact route; provider- or endpoint-changing +delegation overrides stay default-deny. +""" + +from types import SimpleNamespace + +from tools.delegate_tool import _inherit_parent_capabilities + + +def _parent(capabilities): + return SimpleNamespace( + provider="custom:proxy", + base_url="https://trusted-proxy.corp/v1", + capabilities=capabilities, + ) + + +def test_same_route_child_inherits_capability_map(): + parent = _parent({"openai_native_compaction": True}) + assert _inherit_parent_capabilities(parent, None, None) == { + "openai_native_compaction": True + } + + +def test_provider_override_stays_default_deny(): + parent = _parent({"openai_native_compaction": True}) + assert _inherit_parent_capabilities(parent, "openai", None) is None + + +def test_base_url_override_stays_default_deny(): + parent = _parent({"openai_native_compaction": True}) + assert ( + _inherit_parent_capabilities(parent, None, "https://other.example/v1") + is None + ) + + +def test_non_dict_parent_capabilities_yield_none(): + parent = _parent(None) + assert _inherit_parent_capabilities(parent, None, None) is None + + +def test_inherited_map_is_sanitized_to_str_bool(): + parent = _parent( + {"openai_native_compaction": True, "bad": "yes", 3: True, "n": 0} + ) + assert _inherit_parent_capabilities(parent, None, None) == { + "openai_native_compaction": True + } diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f38e35b124..342cf62847 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1569,6 +1569,30 @@ def _normalized_runtime_url(value: Any) -> str: return str(value or "").strip().rstrip("/") +def _inherit_parent_capabilities( + parent_agent, override_provider, override_base_url +) -> Optional[dict]: + """Return the parent's endpoint-trust capability map for a child, or None. + + The trusted-proxy capability map (``agent.capabilities``, e.g. + ``openai_native_compaction`` from a custom_providers entry) is a trust + decision scoped to one provider+endpoint. A child inherits it ONLY when + it runs against the parent's exact route — any delegation override that + changes provider or base_url stays DEFAULT-DENY, matching the /model + switch posture (#94036/#97292). + """ + if override_provider or override_base_url: + return None + parent_caps = getattr(parent_agent, "capabilities", None) + if not isinstance(parent_caps, dict): + return None + return { + key: value + for key, value in parent_caps.items() + if isinstance(key, str) and isinstance(value, bool) + } + + def _inherit_parent_base_url(parent_agent, fallback_base_url: Optional[str]) -> Optional[str]: """Return the base URL the parent is actually calling, not a stale attribute. @@ -1786,6 +1810,16 @@ def _build_child_agent( if not override_base_url: effective_base_url = _inherit_parent_base_url(parent_agent, effective_base_url) effective_api_key = override_api_key or parent_api_key + # Same-class follow-up to #94036/#97292: the trusted-proxy capability map + # (`agent.capabilities`, e.g. ``openai_native_compaction`` from a + # custom_providers entry) is an endpoint-scoped trust decision. Children + # inherit it ONLY when they run against the parent's exact provider and + # base_url — a provider- or endpoint-changing delegation override stays + # DEFAULT-DENY, matching the /model switch posture. Without this, a child + # on the same trusted proxy silently falls back to local summarization. + child_capabilities = _inherit_parent_capabilities( + parent_agent, override_provider, override_base_url + ) # Bug #20558 / PR #20563: api_mode must NOT be inherited when the child uses a # different provider than the parent — each provider has its own API surface # (e.g. MiniMax uses anthropic_messages, DeepSeek uses chat_completions). @@ -1967,6 +2001,7 @@ def _build_child_agent( api_key=effective_api_key, model=effective_model, provider=effective_provider, + capabilities=child_capabilities, api_mode=effective_api_mode, acp_command=effective_acp_command, acp_args=effective_acp_args,