mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
fix(routing): preserve profile and delegation parity
This commit is contained in:
parent
3aeaf3755d
commit
a0032f5f92
6 changed files with 116 additions and 51 deletions
|
|
@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
||||
"""Build the validated provider-routing object shared by request paths."""
|
||||
preferences: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
preferences["order"] = agent.providers_order
|
||||
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if provider_sort:
|
||||
preferences["sort"] = provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
preferences["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
preferences["data_collection"] = agent.provider_data_collection
|
||||
return preferences
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
|
|
@ -801,21 +820,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
_omit_temp = False
|
||||
_fixed_temp = None
|
||||
|
||||
# Provider preferences (OpenRouter-style)
|
||||
_prefs: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
_prefs["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
_prefs["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
_prefs["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
_prefs["sort"] = _provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
_prefs["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
_prefs["data_collection"] = agent.provider_data_collection
|
||||
# Provider preferences (aggregator profile decides whether to emit them).
|
||||
_prefs = _provider_preferences_for_agent(agent)
|
||||
|
||||
# Anthropic-compatible max-output fallback (last resort only — applied in
|
||||
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
|
||||
|
|
@ -1690,42 +1696,32 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
|||
if _lm_reasoning_effort is not None:
|
||||
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
|
||||
|
||||
# Include provider routing preferences
|
||||
provider_preferences = {}
|
||||
if agent.providers_allowed:
|
||||
provider_preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
provider_preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
provider_preferences["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
provider_preferences["sort"] = _provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
provider_preferences["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
provider_preferences["data_collection"] = agent.provider_data_collection
|
||||
if provider_preferences:
|
||||
profile_provider_preferences = None
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
# Merge the profile's canonical body even when routing is unset:
|
||||
# profiles may always emit required metadata such as Portal tags.
|
||||
provider_preferences = _provider_preferences_for_agent(agent)
|
||||
profile_extra_body = {}
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
provider_profile = get_provider_profile(agent.provider)
|
||||
if provider_profile is not None:
|
||||
profile_extra_body = provider_profile.build_extra_body(
|
||||
provider_preferences=provider_preferences,
|
||||
)
|
||||
profile_provider_preferences = profile_extra_body.get("provider")
|
||||
except Exception:
|
||||
pass
|
||||
provider_profile = get_provider_profile(agent.provider)
|
||||
if provider_profile is not None:
|
||||
profile_extra_body = provider_profile.build_extra_body(
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
provider_preferences=provider_preferences or None,
|
||||
model=agent.model,
|
||||
base_url=agent.base_url,
|
||||
reasoning_config=agent.reasoning_config,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if profile_provider_preferences:
|
||||
summary_extra_body["provider"] = profile_provider_preferences
|
||||
elif (
|
||||
(agent.provider or "").strip().lower() == "openrouter"
|
||||
or agent._is_openrouter_url()
|
||||
):
|
||||
summary_extra_body["provider"] = provider_preferences
|
||||
if profile_extra_body:
|
||||
summary_extra_body.update(profile_extra_body)
|
||||
if provider_preferences and "provider" not in profile_extra_body and (
|
||||
(agent.provider or "").strip().lower() == "openrouter"
|
||||
or agent._is_openrouter_url()
|
||||
):
|
||||
summary_extra_body["provider"] = provider_preferences
|
||||
|
||||
# Pareto Code router plugin — model-gated. Same shape as
|
||||
# the main-loop emission so summary calls on
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ class TestNousProfile:
|
|||
from agent.portal_tags import nous_portal_tags
|
||||
|
||||
p = get_provider_profile("nous")
|
||||
assert p is not None
|
||||
preferences = {"only": ["deepseek"], "ignore": ["deepinfra"]}
|
||||
body = p.build_extra_body(provider_preferences=preferences)
|
||||
|
||||
|
|
|
|||
|
|
@ -3789,7 +3789,7 @@ class TestHandleMaxIterations:
|
|||
assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"]
|
||||
|
||||
def test_summary_keeps_provider_preferences_for_nous(self, agent):
|
||||
agent.base_url = "https://inference-api.nousresearch.com/v1"
|
||||
agent.base_url = "https://proxy.example.com/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
agent.provider = "nous"
|
||||
agent.providers_allowed = ["deepseek"]
|
||||
|
|
@ -3804,6 +3804,9 @@ class TestHandleMaxIterations:
|
|||
|
||||
assert result == "Summary"
|
||||
kwargs = agent.client.chat.completions.create.call_args.kwargs
|
||||
from agent.portal_tags import nous_portal_tags
|
||||
|
||||
assert kwargs["extra_body"]["tags"] == nous_portal_tags()
|
||||
assert kwargs["extra_body"]["provider"] == {
|
||||
"only": ["deepseek"],
|
||||
"ignore": ["deepinfra"],
|
||||
|
|
@ -3812,6 +3815,21 @@ class TestHandleMaxIterations:
|
|||
"data_collection": "deny",
|
||||
}
|
||||
|
||||
def test_summary_keeps_nous_profile_body_without_routing_preferences(self, agent):
|
||||
agent.base_url = "https://proxy.example.com/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
agent.provider = "nous"
|
||||
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
|
||||
agent._cached_system_prompt = "You are helpful."
|
||||
|
||||
result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60)
|
||||
|
||||
assert result == "Summary"
|
||||
kwargs = agent.client.chat.completions.create.call_args.kwargs
|
||||
from agent.portal_tags import nous_portal_tags
|
||||
|
||||
assert kwargs["extra_body"] == {"tags": nous_portal_tags()}
|
||||
|
||||
def test_summary_drops_invalid_provider_sort(self, agent):
|
||||
agent.base_url = "https://openrouter.ai/api/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,8 @@ class TestDelegationProviderIntegration(unittest.TestCase):
|
|||
parent.providers_ignored = ["openai/gpt-4o-mini"]
|
||||
parent.providers_order = ["google/gemini-2.5-pro"]
|
||||
parent.provider_sort = "price"
|
||||
parent.provider_require_parameters = True
|
||||
parent.provider_data_collection = "deny"
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
|
|
@ -1464,6 +1466,44 @@ class TestDelegationProviderIntegration(unittest.TestCase):
|
|||
self.assertIsNone(kwargs["providers_ignored"])
|
||||
self.assertIsNone(kwargs["providers_order"])
|
||||
self.assertIsNone(kwargs["provider_sort"])
|
||||
self.assertIs(kwargs["provider_require_parameters"], False)
|
||||
self.assertEqual(kwargs["provider_data_collection"], "")
|
||||
|
||||
@patch("tools.delegate_tool._load_config")
|
||||
@patch("tools.delegate_tool._resolve_delegation_credentials")
|
||||
def test_same_provider_inherits_all_routing_preferences(self, mock_creds, mock_cfg):
|
||||
mock_cfg.return_value = {"max_iterations": 45}
|
||||
mock_creds.return_value = {
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"api_key": None,
|
||||
"api_mode": None,
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
parent.provider = "nous"
|
||||
parent.providers_allowed = ["deepseek"]
|
||||
parent.providers_ignored = ["deepinfra"]
|
||||
parent.providers_order = ["anthropic"]
|
||||
parent.provider_sort = "throughput"
|
||||
parent.provider_require_parameters = True
|
||||
parent.provider_data_collection = "deny"
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done", "completed": True, "api_calls": 1
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
delegate_task(goal="Keep routing", parent_agent=parent)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertEqual(kwargs["providers_allowed"], ["deepseek"])
|
||||
self.assertEqual(kwargs["providers_ignored"], ["deepinfra"])
|
||||
self.assertEqual(kwargs["providers_order"], ["anthropic"])
|
||||
self.assertEqual(kwargs["provider_sort"], "throughput")
|
||||
self.assertIs(kwargs["provider_require_parameters"], True)
|
||||
self.assertEqual(kwargs["provider_data_collection"], "deny")
|
||||
|
||||
@patch("tools.delegate_tool._load_config")
|
||||
@patch("tools.delegate_tool._resolve_delegation_credentials")
|
||||
|
|
|
|||
|
|
@ -1288,12 +1288,20 @@ def _build_child_agent(
|
|||
child_providers_ignored = getattr(parent_agent, "providers_ignored", None)
|
||||
child_providers_order = getattr(parent_agent, "providers_order", None)
|
||||
child_provider_sort = getattr(parent_agent, "provider_sort", None)
|
||||
child_provider_require_parameters = getattr(
|
||||
parent_agent, "provider_require_parameters", False
|
||||
)
|
||||
child_provider_data_collection = getattr(
|
||||
parent_agent, "provider_data_collection", None
|
||||
) or ""
|
||||
child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None)
|
||||
if override_provider:
|
||||
child_providers_allowed = None
|
||||
child_providers_ignored = None
|
||||
child_providers_order = None
|
||||
child_provider_sort = None
|
||||
child_provider_require_parameters = False
|
||||
child_provider_data_collection = ""
|
||||
# Note: openrouter_min_coding_score is model-gated (only emitted on
|
||||
# openrouter/pareto-code), so we keep it inherited even when the
|
||||
# provider is overridden — it's a no-op on any other model.
|
||||
|
|
@ -1326,6 +1334,8 @@ def _build_child_agent(
|
|||
providers_ignored=child_providers_ignored,
|
||||
providers_order=child_providers_order,
|
||||
provider_sort=child_provider_sort,
|
||||
provider_require_parameters=child_provider_require_parameters,
|
||||
provider_data_collection=child_provider_data_collection,
|
||||
openrouter_min_coding_score=child_openrouter_min_coding_score,
|
||||
tool_progress_callback=child_progress_cb,
|
||||
iteration_budget=None, # fresh budget per subagent
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ provider_routing:
|
|||
|
||||
## How It Works
|
||||
|
||||
Provider routing preferences are passed to OpenRouter or Nous Portal via the `extra_body.provider` field on every API call. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) This applies to both:
|
||||
Provider routing preferences are passed to OpenRouter or Nous Portal on agent chat requests and iteration-limit summaries via the `extra_body.provider` field. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) Auxiliary tasks such as compression and title generation are configured independently under `auxiliary.<task>.extra_body`.
|
||||
|
||||
- **CLI mode** — configured in `~/.hermes/config.yaml`, loaded at startup
|
||||
- **Gateway mode** — same config file, loaded when the gateway starts
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue