mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Wake-ups for kanban notifications and background delegation completions were injected via handle_message() using a build_session_key()-derived key, which can never match the raw X-Hermes-Session-Id key that api_server sessions run under — so the wake landed in a session nobody was reading. On top of that, ApiServerAdapter.send() reports failure without raising, and that was treated as a successful delivery, so the notify cursor advanced past events that were permanently lost; and background delegation was forced synchronous on api_server since there was no way to wake the session afterward. Fix: route wake-ups for non-push adapters through a self-post to /v1/chat/completions with the original session id, treat non-raising send failures as failures (rewind instead of advancing the cursor), and re-enable background delegation whenever a session id is available to wake. The origin session id is captured from the request-scoped api_server chat_id binding rather than HERMES_SESSION_ID: constructing a child agent calls set_current_session_id() with the subagent's internal id, clobbering that variable right before dispatch would read it and misrouting the wake into the subagent's own session. Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
190 lines
7.1 KiB
Python
190 lines
7.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_origin_helper_survives_child_session_clobber(monkeypatch):
|
|
"""set_current_session_id (child agent construction) rewrites the
|
|
HERMES_SESSION_ID ContextVar + env, but the request-scoped chat_id
|
|
binding is untouched — the helper must keep returning the spawner's id."""
|
|
from gateway.session_context import set_current_session_id
|
|
from tools.async_delegation import _current_origin_session_id
|
|
|
|
set_session_vars(platform="api_server", chat_id="raw-origin-1")
|
|
assert _current_origin_session_id() == "raw-origin-1"
|
|
|
|
set_current_session_id("20260715_child2") # the clobber
|
|
assert _current_origin_session_id() == "raw-origin-1"
|
|
|
|
|
|
def test_origin_helper_empty_on_push_platforms(monkeypatch):
|
|
"""On push platforms chat_id identifies a chat, not a session — the
|
|
helper must yield empty rather than misroute a wake there."""
|
|
from tools.async_delegation import _current_origin_session_id
|
|
|
|
set_session_vars(platform="telegram", chat_id="123456789")
|
|
assert _current_origin_session_id() == ""
|
|
|
|
|
|
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()
|