Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # cli.py # hermes_cli/main.py # run_agent.py # tests/hermes_cli/test_cmd_update.py # tools/mcp_tool.py # web/src/lib/gatewayClient.ts
This commit is contained in:
@@ -106,9 +106,9 @@ class TestContinuationLogicBranching:
|
||||
def test_all_three_api_modes_hit_continuation_branch(self, api_mode):
|
||||
# The guard in run_agent.py is:
|
||||
# if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"):
|
||||
assert api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages")
|
||||
assert api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}
|
||||
|
||||
def test_codex_responses_still_excluded(self):
|
||||
# codex_responses has its own truncation path (not continuation-based)
|
||||
# and should NOT be routed through the shared block.
|
||||
assert "codex_responses" not in ("chat_completions", "bedrock_converse", "anthropic_messages")
|
||||
assert "codex_responses" not in {"chat_completions", "bedrock_converse", "anthropic_messages"}
|
||||
|
||||
@@ -73,15 +73,20 @@ class TestAgentLoopSourceStillHasCarveOut:
|
||||
revert that happens to leave the test file intact."""
|
||||
|
||||
def test_run_agent_excludes_jsondecodeerror_from_local_validation(self):
|
||||
import run_agent
|
||||
import inspect
|
||||
src = inspect.getsource(run_agent)
|
||||
from agent import conversation_loop
|
||||
# The agent loop body lives in agent/conversation_loop.py after
|
||||
# the run_agent.py refactor. Assert the carve-out is present in
|
||||
# the extracted module specifically — if it ever moves back or
|
||||
# disappears, this fails loudly rather than silently passing
|
||||
# against a non-existent inline replica.
|
||||
src = inspect.getsource(conversation_loop)
|
||||
# The predicate we care about must reference json.JSONDecodeError
|
||||
# in its exclusion tuple. We check for the specific co-occurrence
|
||||
# rather than the literal string so harmless reformatting doesn't
|
||||
# break us.
|
||||
assert "is_local_validation_error" in src
|
||||
assert "JSONDecodeError" in src, (
|
||||
"run_agent.py must carve out json.JSONDecodeError from the "
|
||||
"is_local_validation_error classification — see #14782."
|
||||
"agent/conversation_loop.py must carve out json.JSONDecodeError "
|
||||
"from the is_local_validation_error classification — see #14782."
|
||||
)
|
||||
|
||||
@@ -120,10 +120,22 @@ def test_production_code_contains_hydration_block():
|
||||
"""Smoke test: confirm the hydration code is actually wired into
|
||||
run_conversation(). If someone deletes it, tests above still pass
|
||||
against the inline replica — this fails them awake.
|
||||
|
||||
After the run_agent.py refactor the agent-loop body lives in
|
||||
``agent/conversation_loop.py`` and uses ``agent.X`` rather than
|
||||
``self.X``. Assert the block is present in the extracted module
|
||||
specifically — if it ever drifts back into run_agent.py or
|
||||
disappears entirely, this guard fails loudly.
|
||||
"""
|
||||
from pathlib import Path
|
||||
src = Path(__file__).resolve().parents[2] / "run_agent.py"
|
||||
content = src.read_text(encoding="utf-8")
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
cl_path = repo / "agent" / "conversation_loop.py"
|
||||
src_cl = cl_path.read_text(encoding="utf-8")
|
||||
# Anchor on the unique comment + the modulo line.
|
||||
assert "Hydrate per-session nudge counters from persisted history" in content
|
||||
assert "self._turns_since_memory = prior_user_turns % self._memory_nudge_interval" in content
|
||||
assert "Hydrate per-session nudge counters from persisted history" in src_cl, (
|
||||
f"Hydration comment missing from {cl_path}"
|
||||
)
|
||||
assert (
|
||||
"agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval"
|
||||
in src_cl
|
||||
), f"Hydration modulo assignment missing from {cl_path}"
|
||||
|
||||
@@ -254,8 +254,12 @@ class TestDeveloperRoleSwap:
|
||||
assert messages[0]["role"] == "system"
|
||||
|
||||
def test_developer_role_via_nous_portal(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
|
||||
agent.model = "gpt-5"
|
||||
agent = _make_agent(
|
||||
monkeypatch,
|
||||
"nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
model="gpt-5",
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "hi"},
|
||||
@@ -346,14 +350,24 @@ class TestBuildApiKwargsAIGateway:
|
||||
class TestBuildApiKwargsNousPortal:
|
||||
def test_includes_nous_product_tags(self, monkeypatch):
|
||||
from agent.portal_tags import nous_portal_tags
|
||||
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
|
||||
agent = _make_agent(
|
||||
monkeypatch,
|
||||
"nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
model="gpt-5",
|
||||
)
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
extra = kwargs.get("extra_body", {})
|
||||
assert extra.get("tags") == nous_portal_tags()
|
||||
|
||||
def test_uses_chat_completions_format(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
|
||||
agent = _make_agent(
|
||||
monkeypatch,
|
||||
"nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
model="gpt-5",
|
||||
)
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "messages" in kwargs
|
||||
|
||||
@@ -2282,9 +2282,11 @@ class TestMcpParallelToolBatch:
|
||||
def test_mcp_tools_parallel_when_server_opted_in(self):
|
||||
"""MCP tools from a parallel-safe server can run concurrently."""
|
||||
from run_agent import _should_parallelize_tool_batch
|
||||
from tools.mcp_tool import _parallel_safe_servers, _lock
|
||||
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("github")
|
||||
_mcp_tool_server_names["mcp_github_list_repos"] = "github"
|
||||
_mcp_tool_server_names["mcp_github_search_code"] = "github"
|
||||
try:
|
||||
tc1 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c1")
|
||||
tc2 = _mock_tool_call(name="mcp_github_search_code", arguments='{"q":"test"}', call_id="c2")
|
||||
@@ -2292,13 +2294,16 @@ class TestMcpParallelToolBatch:
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("github")
|
||||
_mcp_tool_server_names.pop("mcp_github_list_repos", None)
|
||||
_mcp_tool_server_names.pop("mcp_github_search_code", None)
|
||||
|
||||
def test_mixed_mcp_and_builtin_parallel(self):
|
||||
"""MCP parallel tools mixed with built-in parallel-safe tools."""
|
||||
from run_agent import _should_parallelize_tool_batch
|
||||
from tools.mcp_tool import _parallel_safe_servers, _lock
|
||||
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("docs")
|
||||
_mcp_tool_server_names["mcp_docs_search"] = "docs"
|
||||
try:
|
||||
tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1")
|
||||
tc2 = _mock_tool_call(name="web_search", arguments='{"query":"test"}', call_id="c2")
|
||||
@@ -2306,14 +2311,17 @@ class TestMcpParallelToolBatch:
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("docs")
|
||||
_mcp_tool_server_names.pop("mcp_docs_search", None)
|
||||
|
||||
def test_mixed_parallel_and_serial_mcp_servers(self):
|
||||
"""One parallel MCP server + one non-parallel MCP server = sequential."""
|
||||
from run_agent import _should_parallelize_tool_batch
|
||||
from tools.mcp_tool import _parallel_safe_servers, _lock
|
||||
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("docs")
|
||||
# "github" is NOT in _parallel_safe_servers
|
||||
_mcp_tool_server_names["mcp_docs_search"] = "docs"
|
||||
_mcp_tool_server_names["mcp_github_list_repos"] = "github"
|
||||
try:
|
||||
tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1")
|
||||
tc2 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c2")
|
||||
@@ -2321,6 +2329,8 @@ class TestMcpParallelToolBatch:
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("docs")
|
||||
_mcp_tool_server_names.pop("mcp_docs_search", None)
|
||||
_mcp_tool_server_names.pop("mcp_github_list_repos", None)
|
||||
|
||||
|
||||
class TestHandleMaxIterations:
|
||||
@@ -3657,7 +3667,7 @@ class TestNousCredentialRefresh:
|
||||
|
||||
assert ok is True
|
||||
assert closed["value"] is True
|
||||
assert captured["force_mint"] is True
|
||||
assert captured["inference_auth_mode"] == "legacy"
|
||||
assert rebuilt["kwargs"]["api_key"] == "new-nous-key"
|
||||
assert (
|
||||
rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1"
|
||||
@@ -4832,23 +4842,26 @@ class TestAnthropicInterruptHandler:
|
||||
def test_interruptible_has_anthropic_branch(self):
|
||||
"""The interrupt handler must check api_mode == 'anthropic_messages'."""
|
||||
import inspect
|
||||
source = inspect.getsource(AIAgent._interruptible_api_call)
|
||||
from agent.chat_completion_helpers import interruptible_api_call
|
||||
source = inspect.getsource(interruptible_api_call)
|
||||
assert "anthropic_messages" in source, \
|
||||
"_interruptible_api_call must handle Anthropic interrupt (api_mode check)"
|
||||
"interruptible_api_call must handle Anthropic interrupt (api_mode check)"
|
||||
|
||||
def test_interruptible_rebuilds_anthropic_client(self):
|
||||
"""After interrupting, the Anthropic client should be rebuilt."""
|
||||
import inspect
|
||||
source = inspect.getsource(AIAgent._interruptible_api_call)
|
||||
from agent.chat_completion_helpers import interruptible_api_call
|
||||
source = inspect.getsource(interruptible_api_call)
|
||||
assert "build_anthropic_client" in source, \
|
||||
"_interruptible_api_call must rebuild Anthropic client after interrupt"
|
||||
"interruptible_api_call must rebuild Anthropic client after interrupt"
|
||||
|
||||
def test_streaming_has_anthropic_branch(self):
|
||||
"""_streaming_api_call must also handle Anthropic interrupt."""
|
||||
import inspect
|
||||
source = inspect.getsource(AIAgent._interruptible_streaming_api_call)
|
||||
from agent.chat_completion_helpers import interruptible_streaming_api_call
|
||||
source = inspect.getsource(interruptible_streaming_api_call)
|
||||
assert "anthropic_messages" in source, \
|
||||
"_streaming_api_call must handle Anthropic interrupt"
|
||||
"interruptible_streaming_api_call must handle Anthropic interrupt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5257,14 +5270,20 @@ class TestMemoryNudgeCounterPersistence:
|
||||
def test_counters_not_reset_in_preamble(self):
|
||||
"""The run_conversation preamble must not zero the nudge counters."""
|
||||
import inspect
|
||||
src = inspect.getsource(AIAgent.run_conversation)
|
||||
from agent.conversation_loop import run_conversation as _rc
|
||||
src = inspect.getsource(_rc)
|
||||
# The preamble resets many fields (retry counts, budget, etc.)
|
||||
# before the main loop. Find that reset block and verify our
|
||||
# counters aren't in it. The reset block ends at iteration_budget.
|
||||
preamble_end = src.index("self.iteration_budget = IterationBudget")
|
||||
# The extracted body uses ``agent.X`` (not ``self.X``). Anchor
|
||||
# exactly on ``agent.iteration_budget = IterationBudget`` so an
|
||||
# unrelated identifier ending in ``iteration_budget`` (e.g.
|
||||
# ``_iteration_budget`` or ``shared_iteration_budget``) can't
|
||||
# match the boundary.
|
||||
preamble_end = src.index("agent.iteration_budget = IterationBudget")
|
||||
preamble = src[:preamble_end]
|
||||
assert "self._turns_since_memory = 0" not in preamble
|
||||
assert "self._iters_since_skill = 0" not in preamble
|
||||
assert "agent._turns_since_memory = 0" not in preamble
|
||||
assert "agent._iters_since_skill = 0" not in preamble
|
||||
|
||||
|
||||
class TestDeadRetryCode:
|
||||
@@ -5272,7 +5291,8 @@ class TestDeadRetryCode:
|
||||
|
||||
def test_no_unreachable_max_retries_after_backoff(self):
|
||||
import inspect
|
||||
source = inspect.getsource(AIAgent.run_conversation)
|
||||
from agent.conversation_loop import run_conversation as _rc
|
||||
source = inspect.getsource(_rc)
|
||||
occurrences = source.count("if retry_count >= max_retries:")
|
||||
assert occurrences == 2, (
|
||||
f"Expected 2 occurrences of 'if retry_count >= max_retries:' "
|
||||
@@ -5310,7 +5330,8 @@ class TestMemoryContextSanitization:
|
||||
a literal <memory-context> tag we don't silently delete their text.
|
||||
The streaming scrubber + plugin-side scrub cover real leak paths."""
|
||||
import inspect
|
||||
src = inspect.getsource(AIAgent.run_conversation)
|
||||
from agent.conversation_loop import run_conversation as _rc
|
||||
src = inspect.getsource(_rc)
|
||||
assert "sanitize_context(user_message)" not in src
|
||||
assert "sanitize_context(persist_user_message)" not in src
|
||||
|
||||
@@ -5346,7 +5367,8 @@ class TestMemoryProviderTurnStart:
|
||||
def test_on_turn_start_called_before_prefetch(self):
|
||||
"""Source-level check: on_turn_start appears before prefetch_all in run_conversation."""
|
||||
import inspect
|
||||
src = inspect.getsource(AIAgent.run_conversation)
|
||||
from agent.conversation_loop import run_conversation as _rc
|
||||
src = inspect.getsource(_rc)
|
||||
# Find the actual method calls, not comments
|
||||
idx_turn_start = src.index(".on_turn_start(")
|
||||
idx_prefetch = src.index(".prefetch_all(")
|
||||
@@ -5356,7 +5378,10 @@ class TestMemoryProviderTurnStart:
|
||||
)
|
||||
|
||||
def test_on_turn_start_uses_user_turn_count(self):
|
||||
"""Source-level check: on_turn_start receives self._user_turn_count."""
|
||||
"""Source-level check: on_turn_start receives the user_turn_count."""
|
||||
import inspect
|
||||
src = inspect.getsource(AIAgent.run_conversation)
|
||||
assert "on_turn_start(self._user_turn_count" in src
|
||||
from agent.conversation_loop import run_conversation as _rc
|
||||
src = inspect.getsource(_rc)
|
||||
# The extracted body uses ``agent.X`` rather than ``self.X``;
|
||||
# assert the extracted-form spelling directly.
|
||||
assert "on_turn_start(agent._user_turn_count" in src
|
||||
|
||||
@@ -152,19 +152,28 @@ def test_run_agent_concurrent_executor_wraps_submit_with_copy_context():
|
||||
import inspect
|
||||
|
||||
import run_agent
|
||||
from agent import tool_executor as tool_executor_module
|
||||
|
||||
src_path = inspect.getsourcefile(run_agent)
|
||||
assert src_path is not None
|
||||
tree = ast.parse(open(src_path, encoding="utf-8").read())
|
||||
# Source for both modules — the concurrent-executor body lives in
|
||||
# ``agent/tool_executor.py`` after the run_agent.py refactor (PR
|
||||
# following #16660). Search both so this guard keeps firing
|
||||
# regardless of where the call site lives.
|
||||
sources = []
|
||||
for mod in (run_agent, tool_executor_module):
|
||||
src_path = inspect.getsourcefile(mod)
|
||||
assert src_path is not None
|
||||
sources.append((src_path, open(src_path, encoding="utf-8").read()))
|
||||
|
||||
submit_calls_in_agent: list[ast.Call] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
# Match executor.submit(...) style calls.
|
||||
if isinstance(func, ast.Attribute) and func.attr == "submit":
|
||||
submit_calls_in_agent.append(node)
|
||||
for _src_path, src_text in sources:
|
||||
tree = ast.parse(src_text)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
# Match executor.submit(...) style calls.
|
||||
if isinstance(func, ast.Attribute) and func.attr == "submit":
|
||||
submit_calls_in_agent.append(node)
|
||||
|
||||
# Filter to the submit call inside the concurrent tool executor —
|
||||
# identifiable by passing `_run_tool` as its target. Other submit()
|
||||
|
||||
Reference in New Issue
Block a user