From 9faaa292b460ad8d8f46e79f907b972edeca9e50 Mon Sep 17 00:00:00 2001 From: nftpoetrist Date: Sun, 3 May 2026 11:35:12 +0300 Subject: [PATCH] fix(delegate): inherit parent fallback_chain in _build_child_agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_child_agent constructed child AIAgents without passing fallback_model, leaving _fallback_chain=[] for every subagent. When a subagent hit a rate-limit or credential exhaustion the runtime fallback check (run_agent.py:7486 / 12267) found an empty chain and failed immediately — even though the parent agent was configured with fallback_providers and would have recovered. The cron scheduler already propagates fallback_model correctly (scheduler.py:1038). Fix closes the parity gap by reading the parent's _fallback_chain (the normalised list form accepted by AIAgent's fallback_model parameter) and threading it through. Empty chains coerce to None so AIAgent initialises _fallback_chain=[] as usual rather than iterating an empty list. --- tests/tools/test_delegate.py | 47 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 7 ++++++ 2 files changed, 54 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 1806a7e60f..089c46da09 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -2403,5 +2403,52 @@ class TestSubagentApprovalCallback(unittest.TestCase): self.assertIsNone(_get_approval_callback()) +class TestFallbackModelInheritance(unittest.TestCase): + """Subagents must inherit the parent's fallback provider chain.""" + + def test_child_inherits_fallback_chain(self): + """_build_child_agent passes parent._fallback_chain as fallback_model.""" + parent = _make_mock_parent(depth=0) + fallback_entry = {"provider": "openrouter", "model": "gpt-4o-mini", "api_key": "sk-or-x"} + parent._fallback_chain = [fallback_entry] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="test fallback inheritance", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["fallback_model"], [fallback_entry]) + + def test_child_gets_no_fallback_when_parent_chain_empty(self): + """When parent._fallback_chain is empty, fallback_model is None.""" + parent = _make_mock_parent(depth=0) + parent._fallback_chain = [] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="test no fallback", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertIsNone(kwargs["fallback_model"]) + + if __name__ == "__main__": unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 844e7bdfb0..55c8ad31c4 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1026,6 +1026,12 @@ def _build_child_agent( except Exception as exc: logger.debug("Could not load delegation reasoning_effort: %s", exc) + # Inherit the parent's fallback provider chain so subagents can recover + # from rate-limits and credential exhaustion exactly like the top-level + # agent does. _fallback_chain is a list accepted by AIAgent's + # fallback_model parameter (which handles both list and dict forms). + parent_fallback = getattr(parent_agent, "_fallback_chain", None) or None + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1038,6 +1044,7 @@ def _build_child_agent( max_tokens=getattr(parent_agent, "max_tokens", None), reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), + fallback_model=parent_fallback, enabled_toolsets=child_toolsets, quiet_mode=True, ephemeral_system_prompt=child_prompt,