mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(runtime): preserve resolved fork metadata
This commit is contained in:
parent
f39c88befb
commit
97e9c64664
4 changed files with 198 additions and 6 deletions
|
|
@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
|||
"api_key": parent_runtime.get("api_key") or None,
|
||||
"base_url": parent_runtime.get("base_url") or None,
|
||||
"api_mode": parent_api_mode,
|
||||
"credential_pool": getattr(agent, "_credential_pool", None),
|
||||
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
|
||||
"max_tokens": getattr(agent, "max_tokens", None),
|
||||
"command": getattr(agent, "acp_command", None),
|
||||
"args": list(getattr(agent, "acp_args", []) or []),
|
||||
"routed": False,
|
||||
}
|
||||
try:
|
||||
|
|
@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
|||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": task_model,
|
||||
"model": rp.get("model") or task_model,
|
||||
"api_key": rp.get("api_key"),
|
||||
"base_url": rp.get("base_url"),
|
||||
"api_mode": rp.get("api_mode"),
|
||||
"credential_pool": rp.get("credential_pool"),
|
||||
"request_overrides": dict(rp.get("request_overrides") or {}),
|
||||
"max_tokens": rp.get("max_output_tokens"),
|
||||
"command": rp.get("command"),
|
||||
"args": list(rp.get("args") or []),
|
||||
"routed": True,
|
||||
}
|
||||
except Exception as e:
|
||||
|
|
@ -680,6 +690,12 @@ def _run_review_in_thread(
|
|||
# Match parent's toolset config so ``tools[]`` is byte-identical
|
||||
# in the request body — Anthropic's cache key includes it.
|
||||
# (The runtime whitelist below still restricts dispatch.)
|
||||
_fork_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_rt.get("max_tokens"), int):
|
||||
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
|
||||
if isinstance(_rt.get("command"), str) and _rt["command"]:
|
||||
_fork_kwargs["acp_command"] = _rt["command"]
|
||||
_fork_kwargs["acp_args"] = _rt.get("args") or []
|
||||
review_agent = AIAgent(
|
||||
model=_rt.get("model") or agent.model,
|
||||
max_iterations=16,
|
||||
|
|
@ -689,11 +705,13 @@ def _run_review_in_thread(
|
|||
api_mode=_rt.get("api_mode"),
|
||||
base_url=_rt.get("base_url") or None,
|
||||
api_key=_rt.get("api_key") or None,
|
||||
credential_pool=getattr(agent, "_credential_pool", None),
|
||||
credential_pool=_rt.get("credential_pool"),
|
||||
request_overrides=_rt.get("request_overrides") or {},
|
||||
parent_session_id=agent.session_id,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
skip_memory=True,
|
||||
**_fork_kwargs,
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
review_agent._memory_write_context = "background_review"
|
||||
|
|
|
|||
|
|
@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
|
|||
|
||||
|
||||
class _ReviewRuntimeBinding(NamedTuple):
|
||||
"""Provider/model for the curator review fork plus optional per-slot overrides."""
|
||||
"""Provider/model for the curator review fork plus per-slot overrides."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
explicit_api_key: Optional[str]
|
||||
explicit_base_url: Optional[str]
|
||||
request_overrides: Dict[str, Any]
|
||||
|
||||
|
||||
def _merge_request_overrides(
|
||||
runtime_overrides: Any,
|
||||
slot_extra_body: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge resolver metadata with task-local request body fields."""
|
||||
merged = dict(runtime_overrides or {})
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
extra_body = dict(merged.get("extra_body") or {})
|
||||
extra_body.update(slot_extra_body)
|
||||
merged["extra_body"] = extra_body
|
||||
return merged
|
||||
|
||||
|
||||
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||
|
|
@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
|||
_task_model,
|
||||
_strip_aux_credential(_cur_task.get("api_key")),
|
||||
_strip_aux_credential(_cur_task.get("base_url")),
|
||||
_merge_request_overrides({}, _cur_task.get("extra_body")),
|
||||
)
|
||||
|
||||
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
|
||||
|
|
@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
|||
str(_legacy_model),
|
||||
_strip_aux_credential(_legacy.get("api_key")),
|
||||
_strip_aux_credential(_legacy.get("base_url")),
|
||||
_merge_request_overrides({}, _legacy.get("extra_body")),
|
||||
)
|
||||
|
||||
# 3. Fall through to the main chat model
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
|
||||
|
||||
|
||||
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
|
||||
|
|
@ -1851,7 +1867,10 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
_api_mode = None
|
||||
_resolved_provider = None
|
||||
_credential_pool = None
|
||||
_request_overrides = None
|
||||
_request_overrides: Dict[str, Any] = {}
|
||||
_max_tokens = None
|
||||
_acp_command = None
|
||||
_acp_args = None
|
||||
_model_name = ""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
|
@ -1870,7 +1889,15 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
_api_mode = _rp.get("api_mode")
|
||||
_resolved_provider = _rp.get("provider") or _provider
|
||||
_credential_pool = _rp.get("credential_pool")
|
||||
_request_overrides = _rp.get("request_overrides")
|
||||
_request_overrides = _merge_request_overrides(
|
||||
_rp.get("request_overrides"),
|
||||
_binding.request_overrides.get("extra_body"),
|
||||
)
|
||||
_max_tokens = _rp.get("max_output_tokens")
|
||||
_acp_command = _rp.get("command")
|
||||
_acp_args = list(_rp.get("args") or [])
|
||||
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
|
||||
_model_name = _rp["model"].strip()
|
||||
except Exception as e:
|
||||
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
|
||||
|
||||
|
|
@ -1879,6 +1906,12 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
|
||||
review_agent = None
|
||||
try:
|
||||
_agent_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_max_tokens, int):
|
||||
_agent_kwargs["max_tokens"] = _max_tokens
|
||||
if isinstance(_acp_command, str) and _acp_command:
|
||||
_agent_kwargs["acp_command"] = _acp_command
|
||||
_agent_kwargs["acp_args"] = _acp_args or []
|
||||
review_agent = AIAgent(
|
||||
model=_model_name,
|
||||
provider=_resolved_provider,
|
||||
|
|
@ -1887,6 +1920,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
api_mode=_api_mode,
|
||||
credential_pool=_credential_pool,
|
||||
request_overrides=_request_overrides,
|
||||
**_agent_kwargs,
|
||||
# Umbrella-building over a large skill collection is worth a
|
||||
# high iteration ceiling — the pass typically takes 50-100
|
||||
# API calls against hundreds of candidate skills. The
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,25 @@ def test_review_runtime_strips_blank_aux_credentials(curator_env):
|
|||
assert binding.explicit_base_url is None
|
||||
|
||||
|
||||
def test_review_runtime_carries_auxiliary_extra_body(curator_env):
|
||||
curator = curator_env["curator"]
|
||||
cfg = {
|
||||
"auxiliary": {
|
||||
"curator": {
|
||||
"provider": "custom",
|
||||
"model": "local-mini",
|
||||
"extra_body": {"slot_flag": "slot-value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
binding = curator._resolve_review_runtime(cfg)
|
||||
|
||||
assert binding.request_overrides == {
|
||||
"extra_body": {"slot_flag": "slot-value"}
|
||||
}
|
||||
|
||||
|
||||
def test_review_runtime_ignores_auxiliary_credentials_when_using_main(curator_env):
|
||||
"""Falling through to main model must not pick up stray auxiliary.curator secrets."""
|
||||
curator = curator_env["curator"]
|
||||
|
|
@ -1324,3 +1343,102 @@ def test_review_fork_forwards_runtime_pool_and_overrides(curator_env, monkeypatc
|
|||
assert meta.get("error") is None, meta.get("error")
|
||||
assert captured["kwargs"]["credential_pool"] is fake_pool
|
||||
assert captured["kwargs"]["request_overrides"] == fake_overrides
|
||||
|
||||
|
||||
def test_review_fork_uses_runtime_model_and_output_cap(curator_env, monkeypatch):
|
||||
curator = curator_env["curator"]
|
||||
import importlib
|
||||
importlib.reload(curator)
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"provider": "custom:gateway", "default": "gateway"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **_kwargs: {
|
||||
"provider": "custom",
|
||||
"model": "real-model-id",
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://gateway.example/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"max_output_tokens": 1234,
|
||||
},
|
||||
)
|
||||
|
||||
class _StubAgent:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
self._session_messages = []
|
||||
|
||||
def run_conversation(self, **_kwargs):
|
||||
return {"final_response": "ok"}
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("run_agent.AIAgent", _StubAgent)
|
||||
result = curator._run_llm_review("review")
|
||||
|
||||
assert result["error"] is None
|
||||
assert captured["model"] == "real-model-id"
|
||||
assert captured["max_tokens"] == 1234
|
||||
|
||||
|
||||
def test_review_fork_merges_slot_extra_body_over_runtime(curator_env, monkeypatch):
|
||||
curator = curator_env["curator"]
|
||||
import importlib
|
||||
importlib.reload(curator)
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"auxiliary": {
|
||||
"curator": {
|
||||
"provider": "custom:gateway",
|
||||
"model": "gateway",
|
||||
"extra_body": {"shared": "slot", "slot_only": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **_kwargs: {
|
||||
"provider": "custom",
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://gateway.example/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"request_overrides": {
|
||||
"extra_body": {"shared": "runtime", "runtime_only": True},
|
||||
"service_tier": "priority",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
class _StubAgent:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
self._session_messages = []
|
||||
|
||||
def run_conversation(self, **_kwargs):
|
||||
return {"final_response": "ok"}
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("run_agent.AIAgent", _StubAgent)
|
||||
|
||||
result = curator._run_llm_review("review")
|
||||
|
||||
assert result["error"] is None
|
||||
assert captured["request_overrides"] == {
|
||||
"extra_body": {
|
||||
"shared": "slot",
|
||||
"runtime_only": True,
|
||||
"slot_only": True,
|
||||
},
|
||||
"service_tier": "priority",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Covers the two behaviors this change adds:
|
|||
|
||||
Pure-function / config-driven; no live model calls.
|
||||
"""
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent import background_review as br
|
||||
|
|
@ -28,6 +29,9 @@ class _FakeAgent:
|
|||
def __init__(self, provider="openai-codex", model="gpt-5.5"):
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self._credential_pool: Any = None
|
||||
self.request_overrides = {}
|
||||
self.max_tokens: int | None = None
|
||||
|
||||
def _current_main_runtime(self):
|
||||
return {
|
||||
|
|
@ -56,6 +60,9 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials():
|
|||
fake_rp = {
|
||||
"provider": "openrouter", "api_key": "or-key",
|
||||
"base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions",
|
||||
"credential_pool": "routed-pool",
|
||||
"request_overrides": {"extra_body": {"store": False}},
|
||||
"max_output_tokens": 2048,
|
||||
}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_rp):
|
||||
|
|
@ -64,6 +71,21 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials():
|
|||
assert rt["provider"] == "openrouter"
|
||||
assert rt["model"] == "google/gemini-3-flash-preview"
|
||||
assert rt["api_key"] == "or-key"
|
||||
assert rt["credential_pool"] == "routed-pool"
|
||||
assert rt["request_overrides"] == {"extra_body": {"store": False}}
|
||||
assert rt["max_tokens"] == 2048
|
||||
|
||||
|
||||
def test_unrouted_runtime_keeps_parent_pool_and_overrides():
|
||||
agent = _FakeAgent()
|
||||
agent._credential_pool = "parent-pool"
|
||||
agent.request_overrides = {"service_tier": "priority"}
|
||||
agent.max_tokens = 4096
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
rt = br._resolve_review_runtime(agent)
|
||||
assert rt["credential_pool"] == "parent-pool"
|
||||
assert rt["request_overrides"] == {"service_tier": "priority"}
|
||||
assert rt["max_tokens"] == 4096
|
||||
|
||||
|
||||
def test_routing_same_model_as_parent_is_not_routed():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue