Files
hermes-agent/tests/tools/test_delegate_fallback_matrix.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

245 lines
8.8 KiB
Python

"""Pin x delegation.fallback_providers decision table for child agents (#80450, #65038)."""
from unittest.mock import MagicMock, patch
import pytest
from tools.delegate_tool import _build_child_agent
from tools.delegate_tool_config import _resolve_child_fallback_chain
from tests.tools.test_delegate import _make_mock_parent
PARENT_CHAIN = [
{"provider": "openrouter", "model": "gpt-4o-mini", "api_key": "sk-or-parent"}
]
DECLARED_CHAIN = [
{"provider": "deepseek", "model": "deepseek-chat", "api_key": "sk-ds-child"}
]
def _parent(chain=None):
parent = _make_mock_parent(depth=0)
parent._fallback_chain = chain
return parent
# (pinned, declared fallback_providers, expected) — the six cells plus the malformed edges.
@pytest.mark.parametrize(
("pinned", "declared", "expected"),
[
(True, "absent", None), # #80450: a pinned child fails loudly, never reroutes
(True, None, None),
(True, [], None),
(True, DECLARED_CHAIN, DECLARED_CHAIN),
(False, "absent", PARENT_CHAIN), # historical default preserved
(False, None, PARENT_CHAIN),
(False, [], None), # explicit [] disables fallback
(False, DECLARED_CHAIN, DECLARED_CHAIN), # #65038: delegation.fallback_providers reaches the child
(True, "not-a-list", None),
(False, "not-a-list", PARENT_CHAIN),
(True, [{"provider": "deepseek"}], None),
(False, [{"provider": "deepseek"}], PARENT_CHAIN),
(False, [{"provider": "deepseek", "model": "x"}, {"provider": "deepseek"}],
[{"provider": "deepseek", "model": "x"}]), # a valid route survives a malformed neighbour
],
)
def test_child_fallback_chain_matrix(pinned, declared, expected):
cfg = {} if declared == "absent" else {"fallback_providers": declared}
assert _resolve_child_fallback_chain(_parent(list(PARENT_CHAIN)), cfg, pinned=pinned) == expected
def _spawn_kwargs(parent, cfg, **overrides):
model = overrides.pop("model", None)
with patch("tools.delegate_tool._load_config", return_value=cfg), patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()
_build_child_agent(task_index=0, goal="matrix wiring", context=None, toolsets=None, model=model,
max_iterations=10, parent_agent=parent, task_count=1, **overrides)
return MockAgent.call_args[1]
@pytest.mark.parametrize(
("overrides", "expected"),
[
({}, PARENT_CHAIN), # unpinned inherits
({"model": "deepseek-chat"}, None), # model-only pin (#80450 model arm)
({"override_provider": "minimax", "override_base_url": "https://api.minimax.example/v1",
"override_api_key": "sk-mm"}, None), # provider pin
],
)
def test_pin_is_derived_from_provider_base_url_or_model(overrides, expected):
assert _spawn_kwargs(_parent(list(PARENT_CHAIN)), {}, **overrides)["fallback_model"] == expected
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 hermes_yaml as 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
def test_explicit_empty_chain_survives_real_profile_config_loader(tmp_path, monkeypatch):
"""An explicit [] remains an authoritative disable after config loading."""
import hermes_yaml as 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": []}}),
encoding="utf-8",
)
with patch("run_agent.AIAgent") as mock_agent:
mock_agent.return_value = MagicMock()
_build_child_agent(
task_index=0,
goal="explicit disable",
context=None,
toolsets=None,
model=None,
max_iterations=10,
parent_agent=_parent(list(PARENT_CHAIN)),
task_count=1,
)
finally:
reset_hermes_home_override(token)
assert mock_agent.call_args.kwargs["fallback_model"] is None
def test_pinned_review_does_not_borrow_general_worker_chain(tmp_path, monkeypatch):
"""The public /review route owns its fallback policy as well as its model."""
import hermes_yaml as yaml
from agent.review_engine import start_review
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
(tmp_path / "config.yaml").write_text(
yaml.safe_dump(
{
"delegation": {
"fallback_providers": [
{"provider": "deepseek", "model": "worker-fallback"}
]
},
"auxiliary": {
"review": {
"provider": "custom",
"model": "review-model",
"base_url": "http://127.0.0.1:18479/v1",
"api_key": "test-only",
}
},
}
),
encoding="utf-8",
)
parent = _parent(list(PARENT_CHAIN))
parent.session_id = "review-80479-parent"
captured = {}
class ReachedConstructor(RuntimeError):
pass
def capture(**kwargs):
captured.update(kwargs)
raise ReachedConstructor()
token = set_hermes_home_override(tmp_path)
try:
with patch("run_agent.AIAgent", side_effect=capture):
with pytest.raises(ReachedConstructor):
start_review(
parent,
[{"role": "user", "content": "Check the last result"}],
)
finally:
reset_hermes_home_override(token)
assert captured["model"] == "review-model"
assert captured["base_url"] == "http://127.0.0.1:18479/v1"
assert captured["fallback_model"] is None
def test_declared_child_chain_activates_on_primary_failure():
"""The selected chain is accepted by the real fallback activation rail."""
from agent.error_classifier import FailoverReason
from run_agent import AIAgent
chain = _resolve_child_fallback_chain(
_parent(list(PARENT_CHAIN)),
{"fallback_providers": list(DECLARED_CHAIN)},
pinned=True,
)
with (
patch("model_tools.get_tool_definitions", return_value=[]),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
child = AIAgent(
api_key="primary-test-key",
base_url="https://primary.example/v1",
model="primary-model",
provider="custom",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
fallback_model=chain,
)
fallback_client = MagicMock()
fallback_client.base_url = "https://fallback.example/v1"
fallback_client.api_key = "fallback-test-key"
with (
patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(fallback_client, "deepseek-chat"),
),
patch(
"hermes_cli.model_normalize.normalize_model_for_provider",
side_effect=lambda model, _provider: model,
),
):
assert child._try_activate_fallback(FailoverReason.rate_limit) is True
assert child.model == "deepseek-chat"
assert child.provider == "deepseek"
if __name__ == "__main__":
unittest.main()