From 2f1085ec0bbfcfe1bcb4fe515cec5acb10f12465 Mon Sep 17 00:00:00 2001 From: Ayush Nangia Date: Fri, 28 Aug 2026 18:02:14 +0530 Subject: [PATCH] fix(delegation): reject malformed child fallback chains --- tests/tools/test_delegate_fallback_matrix.py | 67 +++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_delegate_fallback_matrix.py b/tests/tools/test_delegate_fallback_matrix.py index a8bda5afcc..24f560c41d 100644 --- a/tests/tools/test_delegate_fallback_matrix.py +++ b/tests/tools/test_delegate_fallback_matrix.py @@ -99,6 +99,29 @@ class TestResolveChildFallbackChainMatrix(unittest.TestCase): """Malformed config logs and falls back pin-aware: None when pinned (never reintroduce the silent drag through the error path), parent chain otherwise — extends #80421's log-and-inherit contract.""" + for malformed in ( + "not-a-list", + [{"provider": "deepseek"}], + ["not-a-mapping"], + ): + with self.subTest(malformed=malformed): + self.assertIsNone( + _resolve_child_fallback_chain( + _parent(list(PARENT_CHAIN)), + {"fallback_providers": malformed}, + pinned=True, + ) + ) + self.assertEqual( + _resolve_child_fallback_chain( + _parent(list(PARENT_CHAIN)), + {"fallback_providers": malformed}, + pinned=False, + ), + PARENT_CHAIN, + ) + + def test_normalizer_failure_uses_pin_aware_fallback(self): with patch( "hermes_cli.fallback_config.get_fallback_chain", side_effect=TypeError("boom"), @@ -106,14 +129,14 @@ class TestResolveChildFallbackChainMatrix(unittest.TestCase): self.assertIsNone( _resolve_child_fallback_chain( _parent(list(PARENT_CHAIN)), - {"fallback_providers": "not-a-list"}, + {"fallback_providers": list(DECLARED_CHAIN)}, pinned=True, ) ) self.assertEqual( _resolve_child_fallback_chain( _parent(list(PARENT_CHAIN)), - {"fallback_providers": "not-a-list"}, + {"fallback_providers": list(DECLARED_CHAIN)}, pinned=False, ), PARENT_CHAIN, @@ -200,5 +223,45 @@ class TestBuildChildAgentWiring(unittest.TestCase): self.assertEqual(kwargs["fallback_model"], PARENT_CHAIN) +def test_declared_chain_flows_through_real_profile_config_loader( + tmp_path, monkeypatch +): + """The public key must survive DEFAULT_CONFIG/profile loading without + patching ``_load_config`` and reach the child constructor.""" + import yaml + + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False) + token = set_hermes_home_override(tmp_path) + try: + (tmp_path / "config.yaml").write_text( + yaml.safe_dump( + {"delegation": {"fallback_providers": list(DECLARED_CHAIN)}} + ), + encoding="utf-8", + ) + with patch("run_agent.AIAgent") as mock_agent: + mock_agent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="real config loader", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=_parent(list(PARENT_CHAIN)), + task_count=1, + ) + finally: + reset_hermes_home_override(token) + + child_kwargs = mock_agent.call_args.kwargs + assert child_kwargs["fallback_model"] == DECLARED_CHAIN + + if __name__ == "__main__": unittest.main()