mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
"""delegate_task(background=true) on stateless API-server sessions.
|
|
|
|
Previously async_delivery_supported()=False forced SYNCHRONOUS execution for
|
|
every background dispatch on the API server, blocking the whole turn. Now
|
|
that background completions can wake the originating session via the
|
|
/v1/chat/completions self-post (gateway/wake.py), a session-continuable
|
|
turn (raw session id bound as the api_server chat_id) dispatches async; only
|
|
session-id-less one-shot requests keep the sync fallback.
|
|
|
|
The wake target must be captured from the request-scoped chat_id binding,
|
|
NOT from HERMES_SESSION_ID: constructing a child agent calls
|
|
set_current_session_id(child.session_id), clobbering the HERMES_SESSION_ID
|
|
ContextVar and os.environ with the subagent's internal id before the
|
|
dispatch code reads it — the fake child build below reproduces that clobber.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.session_context import set_session_vars
|
|
from tools.process_registry import process_registry
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_queue_and_context(monkeypatch):
|
|
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
|
|
while not process_registry.completion_queue.empty():
|
|
try:
|
|
process_registry.completion_queue.get_nowait()
|
|
except Exception:
|
|
break
|
|
yield
|
|
# Restore ContextVars to the pristine "never set" sentinel rather than
|
|
# clear_session_vars()'s explicit-"" state, which would mask env vars for
|
|
# unrelated tests running later in the same worker.
|
|
import gateway.session_context as sc
|
|
|
|
for var in sc._VAR_MAP.values():
|
|
var.set(sc._UNSET)
|
|
sc._SESSION_ASYNC_DELIVERY.set(sc._UNSET)
|
|
# set_current_session_id (invoked by the clobber-reproducing fake child
|
|
# build) writes os.environ directly — scrub it so it can't leak into
|
|
# other test modules.
|
|
import os
|
|
|
|
os.environ.pop("HERMES_SESSION_ID", None)
|
|
while not process_registry.completion_queue.empty():
|
|
try:
|
|
process_registry.completion_queue.get_nowait()
|
|
except Exception:
|
|
break
|
|
|
|
|
|
def _drain_one(timeout=5.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if not process_registry.completion_queue.empty():
|
|
return process_registry.completion_queue.get_nowait()
|
|
time.sleep(0.02)
|
|
return None
|
|
|
|
|
|
def _fake_parent():
|
|
parent = MagicMock()
|
|
parent._delegate_depth = 0
|
|
parent.session_id = "sess"
|
|
parent._interrupt_requested = False
|
|
parent._active_children = []
|
|
parent._active_children_lock = None
|
|
return parent
|
|
|
|
|
|
def _patch_delegate(monkeypatch):
|
|
import tools.delegate_tool as dt
|
|
|
|
fake_child = MagicMock()
|
|
fake_child._delegate_role = "leaf"
|
|
fake_child._subagent_id = "s1"
|
|
|
|
def fast_child(task_index, goal, child=None, parent_agent=None, **kw):
|
|
return {
|
|
"task_index": 0, "status": "completed", "summary": f"done: {goal}",
|
|
"api_calls": 1, "duration_seconds": 0.1, "model": "m",
|
|
"exit_reason": "completed",
|
|
}
|
|
|
|
creds = {
|
|
"model": "m", "provider": None, "base_url": None, "api_key": None,
|
|
"api_mode": None, "command": None, "args": None,
|
|
}
|
|
def clobbering_build_child(**kw):
|
|
# Reproduce what the real _build_child_agent -> AIAgent -> agent_init
|
|
# path does: it synchronizes the child's internal session id into the
|
|
# HERMES_SESSION_ID ContextVar + os.environ, clobbering the spawner's
|
|
# id ~milliseconds before delegate_tool dispatches the batch.
|
|
from gateway.session_context import set_current_session_id
|
|
|
|
set_current_session_id("20260715_child1")
|
|
return fake_child
|
|
|
|
monkeypatch.setattr(dt, "_build_child_agent", clobbering_build_child)
|
|
monkeypatch.setattr(dt, "_run_single_child", fast_child)
|
|
monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds)
|
|
return dt
|
|
|
|
|
|
def test_apiserver_session_with_id_dispatches_background(monkeypatch):
|
|
"""async_delivery=False + a raw session id (HERMES_SESSION_ID) →
|
|
background dispatch (the completion wakes the session via the
|
|
api_server self-post), NOT the forced-sync fallback."""
|
|
dt = _patch_delegate(monkeypatch)
|
|
monkeypatch.setenv("HERMES_SESSION_ID", "raw-sid-7")
|
|
set_session_vars(
|
|
platform="api_server",
|
|
chat_id="raw-sid-7",
|
|
session_key="raw-sid-7",
|
|
session_id="raw-sid-7",
|
|
async_delivery=False,
|
|
)
|
|
|
|
out = dt.delegate_task(
|
|
goal="bg on api_server", context="ctx",
|
|
background=True, parent_agent=_fake_parent(),
|
|
)
|
|
parsed = json.loads(out)
|
|
assert parsed["status"] == "dispatched", parsed
|
|
assert parsed["mode"] == "background"
|
|
|
|
evt = _drain_one()
|
|
assert evt is not None
|
|
assert evt["type"] == "async_delegation"
|
|
# The raw session id is stamped so the gateway drain can self-post the
|
|
# wake to the REAL session (session_key alone is the raw id here, which
|
|
# carries no parseable routing metadata). Crucially this is the SPAWNER's
|
|
# id, not the subagent-internal id the child build clobbered
|
|
# HERMES_SESSION_ID with (see clobbering_build_child).
|
|
assert evt["origin_session_id"] == "raw-sid-7"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _current_origin_session_id — the clobber-proof origin capture helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apiserver_session_without_id_stays_synchronous(monkeypatch):
|
|
"""No session id to wake → keep the sync fallback (a detached result
|
|
would never re-enter any conversation)."""
|
|
dt = _patch_delegate(monkeypatch)
|
|
set_session_vars(
|
|
platform="api_server",
|
|
chat_id="",
|
|
session_key="",
|
|
session_id="",
|
|
async_delivery=False,
|
|
)
|
|
|
|
out = dt.delegate_task(
|
|
goal="one-shot", context="ctx",
|
|
background=True, parent_agent=_fake_parent(),
|
|
)
|
|
parsed = json.loads(out)
|
|
assert parsed.get("status") != "dispatched", parsed
|
|
assert "SYNCHRONOUSLY" in parsed.get("note", "")
|
|
assert process_registry.completion_queue.empty()
|