mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(gateway): deliver kanban/delegate wake-ups to api_server sessions
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
This commit is contained in:
parent
185b08a2eb
commit
246eacea7b
10 changed files with 979 additions and 7 deletions
|
|
@ -412,10 +412,46 @@ class GatewayKanbanWatchersMixin:
|
|||
sub["task_id"], sub["platform"],
|
||||
sub["chat_id"], sub.get("thread_id") or "",
|
||||
)
|
||||
# Adapters with no push channel (the API server —
|
||||
# ``supports_async_delivery = False``) can NEVER
|
||||
# satisfy a text-send: ``send()`` always reports
|
||||
# SendResult(success=False) by design (see
|
||||
# ApiServerAdapter.send()). Treating that as a
|
||||
# delivery failure would rewind/drop the subscription
|
||||
# forever and — because the wake dispatch below lives
|
||||
# in this loop's ``else`` clause — would also make the
|
||||
# wake-on-completion path (the actual fix for the
|
||||
# api_server wrong-session bug) unreachable. So for
|
||||
# non-push adapters, skip the doomed send attempt
|
||||
# entirely: there is nothing to text-notify, the
|
||||
# creator is woken via the self-post below instead.
|
||||
from gateway.wake import adapter_supports_push
|
||||
|
||||
if not adapter_supports_push(adapter):
|
||||
logger.debug(
|
||||
"kanban notifier: adapter %s has no push "
|
||||
"channel; skipping text ping for %s, relying "
|
||||
"on wake self-post instead",
|
||||
platform_str, sub["task_id"],
|
||||
)
|
||||
sub_fail_counts.pop(sub_key, None)
|
||||
continue
|
||||
try:
|
||||
await adapter.send(
|
||||
_send_res = await adapter.send(
|
||||
sub["chat_id"], msg, metadata=metadata,
|
||||
)
|
||||
# A SendResult(success=False) without an exception
|
||||
# (returned by push-capable adapters on a genuine
|
||||
# transient failure) must count as a FAILED
|
||||
# delivery — otherwise the cursor advances and the
|
||||
# event is permanently lost. Adapters returning
|
||||
# None (or anything non-SendResult shaped) keep
|
||||
# the legacy "no exception == delivered" contract.
|
||||
if getattr(_send_res, "success", True) is False:
|
||||
raise RuntimeError(
|
||||
"adapter send() reported failure: "
|
||||
f"{getattr(_send_res, 'error', None) or 'unknown error'}"
|
||||
)
|
||||
logger.debug(
|
||||
"kanban notifier: delivered %s event for %s to %s/%s on board %s",
|
||||
kind, sub["task_id"], platform_str, sub["chat_id"], board_slug,
|
||||
|
|
@ -513,7 +549,7 @@ class GatewayKanbanWatchersMixin:
|
|||
board=board_slug,
|
||||
)
|
||||
from gateway.session import SessionSource
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.wake import deliver_wake
|
||||
# KNOWN LIMITATION (tracked follow-up): the
|
||||
# subscription row does not persist the
|
||||
# creator's chat_type, and it is not carried
|
||||
|
|
@ -539,13 +575,22 @@ class GatewayKanbanWatchersMixin:
|
|||
user_id=sub.get("user_id"),
|
||||
profile=sub_profile or None,
|
||||
)
|
||||
_synth_event = MessageEvent(
|
||||
# deliver_wake preserves the synthetic
|
||||
# MessageEvent/handle_message path for
|
||||
# push-capable adapters, and self-POSTs
|
||||
# /v1/chat/completions with the task's RAW
|
||||
# session id for the stateless API server —
|
||||
# handle_message there would run the wake
|
||||
# under a build_session_key()-derived key
|
||||
# that never matches the raw
|
||||
# X-Hermes-Session-Id session real turns
|
||||
# run under (wrong-session wake bug).
|
||||
await deliver_wake(
|
||||
adapter,
|
||||
text=_synth,
|
||||
message_type=MessageType.TEXT,
|
||||
session_id=_session_key,
|
||||
source=_source,
|
||||
internal=True,
|
||||
)
|
||||
await adapter.handle_message(_synth_event)
|
||||
logger.info(
|
||||
"kanban notifier: woke agent for %s on %s/%s profile=%s events=%s",
|
||||
sub["task_id"], platform_str, sub["chat_id"], sub_profile or "default", _wake_kinds,
|
||||
|
|
|
|||
|
|
@ -17447,6 +17447,43 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
"""
|
||||
source = self._build_process_event_source(evt)
|
||||
if not source:
|
||||
# API-server-originated sessions bind a RAW session key (the
|
||||
# X-Hermes-Session-Id value — see _bind_api_server_session), not a
|
||||
# structured ``agent:main:...`` key, so _build_process_event_source
|
||||
# cannot derive routing metadata from it and returns None above.
|
||||
# Recover the raw session id and wake the real session via the API
|
||||
# server's own /v1/chat/completions entry point instead of
|
||||
# dropping the event.
|
||||
raw_sid = str(evt.get("origin_session_id") or "").strip()
|
||||
if not raw_sid:
|
||||
_sk = str(evt.get("session_key") or "").strip()
|
||||
if _sk and _parse_session_key(_sk) is None:
|
||||
raw_sid = _sk
|
||||
if raw_sid:
|
||||
adapter = self.adapters.get(Platform.API_SERVER)
|
||||
from gateway.wake import adapter_supports_push, deliver_wake
|
||||
if adapter is not None and not adapter_supports_push(adapter):
|
||||
try:
|
||||
logger.info(
|
||||
"Watch pattern notification — waking api_server "
|
||||
"session %s via self-post",
|
||||
raw_sid,
|
||||
)
|
||||
await deliver_wake(adapter, text=synth_text, session_id=raw_sid)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Watch notification self-post wake failed for "
|
||||
"session %s: %s",
|
||||
raw_sid, e,
|
||||
)
|
||||
return False
|
||||
logger.warning(
|
||||
"Dropping watch notification for raw session %s: no "
|
||||
"api_server adapter to self-post through",
|
||||
raw_sid,
|
||||
)
|
||||
return None
|
||||
logger.warning(
|
||||
"Dropping watch notification with no routing metadata for process %s",
|
||||
evt.get("session_id", "unknown"),
|
||||
|
|
@ -17460,6 +17497,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
break
|
||||
if not adapter:
|
||||
return None
|
||||
from gateway.wake import adapter_supports_push as _wake_push_ok
|
||||
if not _wake_push_ok(adapter):
|
||||
# Non-push adapter (api_server) resolved WITH routing metadata:
|
||||
# its chat_id is the raw session id (see _bind_api_server_session,
|
||||
# which binds chat_id = session_id). handle_message would run the
|
||||
# wake under a build_session_key()-derived key that never matches
|
||||
# the raw X-Hermes-Session-Id session — self-post instead.
|
||||
from gateway.wake import deliver_wake
|
||||
raw_sid = str(evt.get("origin_session_id") or "").strip() or str(source.chat_id or "")
|
||||
try:
|
||||
logger.info(
|
||||
"Watch pattern notification — waking api_server session "
|
||||
"%s via self-post",
|
||||
raw_sid,
|
||||
)
|
||||
await deliver_wake(adapter, text=synth_text, session_id=raw_sid)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Watch notification self-post wake failed for session "
|
||||
"%s: %s",
|
||||
raw_sid, e,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
metadata = {}
|
||||
parent_session_id = str(evt.get("parent_session_id") or "").strip()
|
||||
|
|
|
|||
184
gateway/wake.py
Normal file
184
gateway/wake.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Wake an existing agent session from a background completion event.
|
||||
|
||||
Two delivery strategies, selected by the target adapter's
|
||||
``supports_async_delivery`` capability flag:
|
||||
|
||||
* Push-capable adapters (telegram, discord, plugin platforms, ...): inject a
|
||||
synthetic ``MessageEvent(internal=True)`` through ``adapter.handle_message``
|
||||
— the pre-existing wake path, preserved exactly.
|
||||
|
||||
* Stateless request/response adapters (the API server,
|
||||
``supports_async_delivery = False``): ``handle_message`` would run the wake
|
||||
turn under a ``build_session_key()``-derived key
|
||||
(``agent:main:api_server:group:<sid>``) that NEVER matches the raw
|
||||
``X-Hermes-Session-Id`` key real gateway/HQ turns run under
|
||||
(``_bind_api_server_session``), so the wake lands in a parallel, invisible
|
||||
session. Instead we self-POST ``/v1/chat/completions`` on the in-pod API
|
||||
server with the raw session id in the ``X-Hermes-Session-Id`` header — the
|
||||
exact entry point real turns use — so the wake turn resumes the REAL
|
||||
session, with full history, and its result is visible the next time the
|
||||
client polls/reopens the conversation.
|
||||
|
||||
Failures RAISE (after bounded retries on transient errors) so callers can
|
||||
rewind cursors / retry instead of silently losing the event.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A wake self-post runs the entire agent turn synchronously (stream=false);
|
||||
# generous ceiling so long tool-using turns aren't killed mid-flight.
|
||||
WAKE_TURN_TIMEOUT_SECONDS = 600.0
|
||||
|
||||
# Backoff delays between retries on transient failures (429 concurrency cap,
|
||||
# connection errors). The API server has no per-session lock — concurrent
|
||||
# turns on one session are last-writer-wins — but it DOES enforce a global
|
||||
# max_concurrent_runs cap via HTTP 429, which is worth waiting out.
|
||||
_RETRY_DELAYS_SECONDS = (2.0, 5.0, 10.0)
|
||||
|
||||
|
||||
def adapter_supports_push(adapter: Any) -> bool:
|
||||
"""Whether this adapter can push a message to the user after a turn ends.
|
||||
|
||||
Mirrors ``gateway.session_context.async_delivery_supported`` but reads the
|
||||
capability off the adapter class (``supports_async_delivery``) instead of
|
||||
the request-scoped contextvar — background watchers run outside any bound
|
||||
session context. Adapters that don't declare the flag are push-capable.
|
||||
"""
|
||||
return bool(getattr(adapter, "supports_async_delivery", True))
|
||||
|
||||
|
||||
async def deliver_wake(
|
||||
adapter: Any,
|
||||
*,
|
||||
text: str,
|
||||
session_id: str = "",
|
||||
source: Any = None,
|
||||
) -> None:
|
||||
"""Deliver a wake turn to the session behind ``adapter``.
|
||||
|
||||
``session_id`` is the RAW session id (the ``X-Hermes-Session-Id`` value /
|
||||
``state.db`` key) — required for non-push adapters. ``source`` is the
|
||||
``SessionSource`` used to build the synthetic event — required for
|
||||
push-capable adapters.
|
||||
|
||||
Raises on failure (bad arguments, exhausted retries, HTTP error) so the
|
||||
caller can rewind/retry instead of treating the wake as delivered.
|
||||
"""
|
||||
if adapter_supports_push(adapter):
|
||||
if source is None:
|
||||
raise ValueError(
|
||||
"deliver_wake: push-capable adapter requires a SessionSource"
|
||||
)
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
|
||||
synth_event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
await adapter.handle_message(synth_event)
|
||||
return
|
||||
|
||||
if not session_id:
|
||||
raise ValueError(
|
||||
"deliver_wake: non-push adapter (supports_async_delivery=False) "
|
||||
"requires the raw session id to self-post the wake turn"
|
||||
)
|
||||
await _self_post_chat_completion(adapter, text=text, session_id=session_id)
|
||||
|
||||
|
||||
async def _self_post_chat_completion(
|
||||
adapter: Any, *, text: str, session_id: str
|
||||
) -> None:
|
||||
"""POST the wake text to the in-pod API server as a normal session turn.
|
||||
|
||||
Uses the adapter's own bind host/port/key (``ApiServerAdapter.__init__``).
|
||||
Session continuation via ``X-Hermes-Session-Id`` is 403-gated on
|
||||
``API_SERVER_KEY`` being configured, so a missing key is a hard error —
|
||||
raise loudly rather than run the wake in a fresh fingerprint-derived
|
||||
session nobody is looking at.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
host = str(getattr(adapter, "_host", "") or "127.0.0.1")
|
||||
if host in ("0.0.0.0", "::", "*"):
|
||||
# Wildcard bind address — connect over loopback.
|
||||
host = "127.0.0.1"
|
||||
port = int(getattr(adapter, "_port", 0) or 8642)
|
||||
api_key = str(getattr(adapter, "_api_key", "") or "")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"wake self-post requires API_SERVER_KEY: session continuation via "
|
||||
"X-Hermes-Session-Id is rejected (403) on an unauthenticated API "
|
||||
"server, so the wake cannot reach the target session"
|
||||
)
|
||||
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]" # bare IPv6 literal
|
||||
url = f"http://{host}:{port}/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
payload = {
|
||||
"model": str(getattr(adapter, "_model_name", "") or "hermes-agent"),
|
||||
"messages": [{"role": "user", "content": text}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
last_err: Optional[BaseException] = None
|
||||
attempts = 1 + len(_RETRY_DELAYS_SECONDS)
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
await asyncio.sleep(_RETRY_DELAYS_SECONDS[attempt - 1])
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=WAKE_TURN_TIMEOUT_SECONDS)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||
async with http.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status == 429:
|
||||
# Global concurrency cap (max_concurrent_runs) —
|
||||
# transient; back off and retry.
|
||||
last_err = RuntimeError(
|
||||
f"wake self-post got HTTP 429 (concurrency cap) "
|
||||
f"for session {session_id}"
|
||||
)
|
||||
logger.warning(
|
||||
"%s; attempt %d/%d", last_err, attempt + 1, attempts
|
||||
)
|
||||
continue
|
||||
if resp.status >= 400:
|
||||
body = (await resp.text())[:300]
|
||||
# Non-transient (auth/validation) — fail immediately.
|
||||
raise RuntimeError(
|
||||
f"wake self-post failed for session {session_id}: "
|
||||
f"HTTP {resp.status}: {body}"
|
||||
)
|
||||
await resp.read()
|
||||
logger.info(
|
||||
"wake self-post delivered for session %s (attempt %d)",
|
||||
session_id,
|
||||
attempt + 1,
|
||||
)
|
||||
return
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
|
||||
last_err = exc
|
||||
logger.warning(
|
||||
"wake self-post transient failure for session %s "
|
||||
"(attempt %d/%d): %s",
|
||||
session_id,
|
||||
attempt + 1,
|
||||
attempts,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"wake self-post gave up for session {session_id} after "
|
||||
f"{attempts} attempts: {last_err}"
|
||||
) from last_err
|
||||
|
|
@ -621,3 +621,72 @@ def test_parse_session_key_too_short():
|
|||
def test_parse_session_key_wrong_prefix():
|
||||
assert _parse_session_key("cron:main:telegram:dm:123") is None
|
||||
assert _parse_session_key("agent:cron:telegram:dm:123") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_server (stateless) wake routing — gateway/wake.py self-post path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_watch_notification_raw_session_key_self_posts(monkeypatch, tmp_path):
|
||||
"""An event whose session_key is a RAW api_server session id (not an
|
||||
agent:main:... structured key) must wake the real session via the
|
||||
/v1/chat/completions self-post instead of being dropped for missing
|
||||
routing metadata."""
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
api_adapter = SimpleNamespace(
|
||||
supports_async_delivery=False,
|
||||
handle_message=AsyncMock(),
|
||||
_host="127.0.0.1", _port=8642, _api_key="k", _model_name="m",
|
||||
)
|
||||
runner.adapters[Platform.API_SERVER] = api_adapter
|
||||
|
||||
posts = []
|
||||
|
||||
async def fake_self_post(adapter, *, text, session_id):
|
||||
posts.append({"text": text, "session_id": session_id})
|
||||
|
||||
import gateway.wake as wake_mod
|
||||
monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post)
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_watch",
|
||||
"session_key": "raw-hq-session-id", # no agent:main:... structure
|
||||
}
|
||||
result = await runner._inject_watch_notification("[SYSTEM: subagent finished]", evt)
|
||||
|
||||
assert result is True
|
||||
api_adapter.handle_message.assert_not_awaited()
|
||||
assert posts == [
|
||||
{"text": "[SYSTEM: subagent finished]", "session_id": "raw-hq-session-id"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_watch_notification_origin_session_id_wins(monkeypatch, tmp_path):
|
||||
"""origin_session_id (stamped at dispatch time by async_delegation) takes
|
||||
precedence as the wake target."""
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
api_adapter = SimpleNamespace(
|
||||
supports_async_delivery=False,
|
||||
handle_message=AsyncMock(),
|
||||
_host="127.0.0.1", _port=8642, _api_key="k", _model_name="m",
|
||||
)
|
||||
runner.adapters[Platform.API_SERVER] = api_adapter
|
||||
|
||||
posts = []
|
||||
|
||||
async def fake_self_post(adapter, *, text, session_id):
|
||||
posts.append(session_id)
|
||||
|
||||
import gateway.wake as wake_mod
|
||||
monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post)
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_watch",
|
||||
"session_key": "",
|
||||
"origin_session_id": "raw-origin-sid",
|
||||
}
|
||||
result = await runner._inject_watch_notification("[SYSTEM: done]", evt)
|
||||
assert result is True
|
||||
assert posts == ["raw-origin-sid"]
|
||||
|
|
|
|||
154
tests/gateway/test_kanban_notifier_apiserver_wake.py
Normal file
154
tests/gateway/test_kanban_notifier_apiserver_wake.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Kanban notifier behavior on stateless (api_server) subscriptions.
|
||||
|
||||
Covers the wrong-session-wake / silent-loss fixes:
|
||||
* a SendResult(success=False) return (the API server's send() stub) rewinds
|
||||
the cursor instead of advancing past a never-delivered event;
|
||||
* api_server subscriptions wake the creator's REAL session via the
|
||||
/v1/chat/completions self-post (raw task.session_id), never via
|
||||
handle_message (which would run under a build_session_key()-derived key
|
||||
that never matches the raw X-Hermes-Session-Id session real turns use).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.run import GatewayRunner
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
class SoftFailAdapter:
|
||||
"""Push-capable adapter whose send() returns SendResult(success=False)
|
||||
WITHOUT raising — previously treated as delivered (event lost)."""
|
||||
|
||||
def __init__(self):
|
||||
self.attempts = 0
|
||||
|
||||
async def send(self, chat_id, text, metadata=None):
|
||||
self.attempts += 1
|
||||
return SendResult(success=False, error="soft failure")
|
||||
|
||||
|
||||
class ApiServerLikeAdapter:
|
||||
supports_async_delivery = False
|
||||
|
||||
def __init__(self):
|
||||
self._host = "127.0.0.1"
|
||||
self._port = 8642
|
||||
self._api_key = "k"
|
||||
self._model_name = "hermes"
|
||||
self.handle_message_calls = []
|
||||
self.send_calls = 0
|
||||
|
||||
async def send(self, chat_id, text, metadata=None):
|
||||
self.send_calls += 1
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="API server uses HTTP request/response, not send()",
|
||||
)
|
||||
|
||||
async def handle_message(self, event):
|
||||
self.handle_message_calls.append(event)
|
||||
|
||||
|
||||
async def _run_one_notifier_tick(monkeypatch, runner):
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(delay):
|
||||
if delay == 5:
|
||||
return None
|
||||
runner._running = False
|
||||
await real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
await runner._kanban_notifier_watcher(interval=1)
|
||||
|
||||
|
||||
def _make_runner(adapters):
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = adapters
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _create_completed_subscription(platform, chat_id, session_id=None):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(
|
||||
conn, title="notify once", assignee="worker", session_id=session_id,
|
||||
)
|
||||
kb.add_notify_sub(conn, task_id=tid, platform=platform, chat_id=chat_id)
|
||||
kb.complete_task(conn, tid, summary="done once")
|
||||
return tid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _unseen_terminal_events(tid, platform, chat_id):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
_, events = kb.unseen_events_for_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"],
|
||||
)
|
||||
return events
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sendresult_failure_rewinds_cursor(tmp_path, monkeypatch):
|
||||
"""SendResult(success=False) without an exception must count as a failed
|
||||
delivery — cursor rewound, event retried on the next tick. Previously the
|
||||
cursor advanced and the event was permanently lost."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "softfail.db"))
|
||||
kb.init_db()
|
||||
tid = _create_completed_subscription("telegram", "chat-1")
|
||||
|
||||
adapter = SoftFailAdapter()
|
||||
runner = _make_runner({Platform.TELEGRAM: adapter})
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
assert adapter.attempts >= 1
|
||||
assert [ev.kind for ev in _unseen_terminal_events(tid, "telegram", "chat-1")] == [
|
||||
"completed"
|
||||
]
|
||||
|
||||
|
||||
def test_apiserver_sub_wakes_real_session_via_self_post(tmp_path, monkeypatch):
|
||||
"""An api_server subscription wakes the creator's REAL session by
|
||||
self-posting with the task's raw session_id — never handle_message (which
|
||||
would run the wake under a build_session_key()-derived key that can't
|
||||
match the raw X-Hermes-Session-Id session)."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "apiserver.db"))
|
||||
kb.init_db()
|
||||
tid = _create_completed_subscription(
|
||||
"api_server", "raw-sid-123", session_id="raw-sid-123",
|
||||
)
|
||||
|
||||
posts = []
|
||||
|
||||
async def fake_self_post(adapter, *, text, session_id):
|
||||
posts.append({"text": text, "session_id": session_id})
|
||||
|
||||
import gateway.wake as wake_mod
|
||||
|
||||
monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post)
|
||||
|
||||
adapter = ApiServerLikeAdapter()
|
||||
runner = _make_runner({Platform.API_SERVER: adapter})
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
assert adapter.handle_message_calls == [], (
|
||||
"api_server wake must not go through handle_message (wrong-session bug)"
|
||||
)
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["session_id"] == "raw-sid-123"
|
||||
assert tid in posts[0]["text"]
|
||||
# The wake self-post IS the delivery on this path (no separate text-ping
|
||||
# fallback is attempted for stateless api_server subs) — cursor advances
|
||||
# once the wake succeeds.
|
||||
assert _unseen_terminal_events(tid, "api_server", "raw-sid-123") == []
|
||||
188
tests/gateway/test_wake_delivery.py
Normal file
188
tests/gateway/test_wake_delivery.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Tests for gateway/wake.py — background wake delivery.
|
||||
|
||||
Two strategies:
|
||||
* push-capable adapters keep the synthetic MessageEvent / handle_message path;
|
||||
* the stateless API server (supports_async_delivery=False) self-POSTs
|
||||
/v1/chat/completions with the RAW session id in X-Hermes-Session-Id, so the
|
||||
wake turn resumes the REAL session instead of a parallel invisible one
|
||||
keyed by build_session_key().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource
|
||||
from gateway.wake import deliver_wake, adapter_supports_push
|
||||
|
||||
|
||||
class PushAdapter:
|
||||
"""Default adapter shape — no supports_async_delivery attribute."""
|
||||
|
||||
def __init__(self):
|
||||
self.handled = []
|
||||
|
||||
async def handle_message(self, event):
|
||||
self.handled.append(event)
|
||||
|
||||
|
||||
class ApiServerLikeAdapter:
|
||||
supports_async_delivery = False
|
||||
|
||||
def __init__(self, host="0.0.0.0", port=0, key="test-key", model="hermes"):
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._api_key = key
|
||||
self._model_name = model
|
||||
|
||||
async def handle_message(self, event): # pragma: no cover — must NOT be hit
|
||||
raise AssertionError("non-push adapter must not receive handle_message wakes")
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="chat-1",
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
|
||||
def test_adapter_supports_push_default_true():
|
||||
assert adapter_supports_push(PushAdapter()) is True
|
||||
assert adapter_supports_push(ApiServerLikeAdapter()) is False
|
||||
|
||||
|
||||
def test_deliver_wake_push_adapter_uses_handle_message():
|
||||
adapter = PushAdapter()
|
||||
asyncio.run(deliver_wake(adapter, text="wake up", source=_source()))
|
||||
assert len(adapter.handled) == 1
|
||||
evt = adapter.handled[0]
|
||||
assert evt.text == "wake up"
|
||||
assert evt.internal is True
|
||||
assert evt.source.chat_id == "chat-1"
|
||||
|
||||
|
||||
def test_deliver_wake_push_adapter_requires_source():
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(deliver_wake(PushAdapter(), text="x", session_id="sid"))
|
||||
|
||||
|
||||
def test_deliver_wake_non_push_requires_session_id():
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(deliver_wake(ApiServerLikeAdapter(), text="x", source=_source()))
|
||||
|
||||
|
||||
def test_deliver_wake_non_push_requires_api_key():
|
||||
"""Session continuation is 403-gated on API_SERVER_KEY — a missing key
|
||||
must fail loudly instead of running the wake in a fresh session."""
|
||||
adapter = ApiServerLikeAdapter(key="")
|
||||
with pytest.raises(RuntimeError, match="API_SERVER_KEY"):
|
||||
asyncio.run(deliver_wake(adapter, text="x", session_id="raw-sid"))
|
||||
|
||||
|
||||
async def _serve(handler):
|
||||
"""Spin an in-process aiohttp server on an ephemeral loopback port."""
|
||||
from aiohttp import web
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/chat/completions", handler)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
return runner, port
|
||||
|
||||
|
||||
def test_deliver_wake_non_push_self_posts_raw_session_id(monkeypatch):
|
||||
"""The self-post carries the RAW session id header + bearer auth and a
|
||||
single user message with stream=false — the exact entry point real
|
||||
gateway turns use."""
|
||||
from aiohttp import web
|
||||
|
||||
seen = {}
|
||||
|
||||
async def handler(request):
|
||||
seen["session_id"] = request.headers.get("X-Hermes-Session-Id")
|
||||
seen["auth"] = request.headers.get("Authorization")
|
||||
seen["body"] = await request.json()
|
||||
return web.json_response({"choices": [{"message": {"content": "ok"}}]})
|
||||
|
||||
async def run():
|
||||
runner, port = await _serve(handler)
|
||||
try:
|
||||
adapter = ApiServerLikeAdapter(host="0.0.0.0", port=port, key="sekrit")
|
||||
await deliver_wake(adapter, text="task done — wake", session_id="raw-sid-42")
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
assert seen["session_id"] == "raw-sid-42"
|
||||
assert seen["auth"] == "Bearer sekrit"
|
||||
assert seen["body"]["stream"] is False
|
||||
assert seen["body"]["messages"] == [
|
||||
{"role": "user", "content": "task done — wake"}
|
||||
]
|
||||
|
||||
|
||||
def test_deliver_wake_retries_429_then_succeeds(monkeypatch):
|
||||
"""HTTP 429 (max_concurrent_runs cap) is transient — retried with backoff."""
|
||||
from aiohttp import web
|
||||
|
||||
import gateway.wake as wake_mod
|
||||
|
||||
monkeypatch.setattr(wake_mod, "_RETRY_DELAYS_SECONDS", (0.01, 0.01, 0.01))
|
||||
calls = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return web.json_response({"error": "busy"}, status=429)
|
||||
return web.json_response({"choices": []})
|
||||
|
||||
async def run():
|
||||
runner, port = await _serve(handler)
|
||||
try:
|
||||
adapter = ApiServerLikeAdapter(port=port)
|
||||
await deliver_wake(adapter, text="x", session_id="sid")
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_deliver_wake_raises_on_permanent_http_error(monkeypatch):
|
||||
"""Auth/validation errors (403/400) are permanent — raise immediately so
|
||||
the caller can rewind instead of treating the event as delivered."""
|
||||
from aiohttp import web
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
calls["n"] += 1
|
||||
return web.json_response({"error": "forbidden"}, status=403)
|
||||
|
||||
async def run():
|
||||
runner, port = await _serve(handler)
|
||||
try:
|
||||
adapter = ApiServerLikeAdapter(port=port)
|
||||
with pytest.raises(RuntimeError, match="HTTP 403"):
|
||||
await deliver_wake(adapter, text="x", session_id="sid")
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_deliver_wake_raises_after_exhausted_retries(monkeypatch):
|
||||
"""Connection failures raise after bounded retries — never silent."""
|
||||
import gateway.wake as wake_mod
|
||||
|
||||
monkeypatch.setattr(wake_mod, "_RETRY_DELAYS_SECONDS", (0.01,))
|
||||
# Nothing is listening on this port.
|
||||
adapter = ApiServerLikeAdapter(host="127.0.0.1", port=1, key="k")
|
||||
with pytest.raises(RuntimeError, match="gave up"):
|
||||
asyncio.run(deliver_wake(adapter, text="x", session_id="sid"))
|
||||
190
tests/tools/test_delegate_apiserver_background.py
Normal file
190
tests/tools/test_delegate_apiserver_background.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""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()
|
||||
|
|
@ -487,6 +487,34 @@ def _prune_completed_locked() -> None:
|
|||
_records.pop(rid, None)
|
||||
|
||||
|
||||
def _current_origin_session_id() -> str:
|
||||
"""Raw session id of the ORIGINATING api_server request, or ``""``.
|
||||
|
||||
The obvious source — ``HERMES_SESSION_ID`` via ``get_session_env`` — is
|
||||
NOT safe to read at dispatch time: constructing a child agent
|
||||
(``agent/agent_init.py``) calls ``set_current_session_id(child.session_id)``,
|
||||
clobbering that ContextVar *and* ``os.environ`` with the subagent's
|
||||
internal ``{timestamp}_{uuid}`` id moments before the dispatch code reads
|
||||
it, so the completion wake would self-post into the subagent's own
|
||||
(unread) session instead of the spawner's.
|
||||
|
||||
The request-scoped ``HERMES_SESSION_CHAT_ID`` binding survives child
|
||||
construction: ``_bind_api_server_session`` binds ``chat_id`` to the raw
|
||||
``X-Hermes-Session-Id``, and its only writer is ``set_session_vars`` —
|
||||
``set_current_session_id`` never touches it. Gate on the platform: on
|
||||
push platforms ``chat_id`` is a chat, not a session, so yield ``""``
|
||||
there.
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
if get_session_env("HERMES_SESSION_PLATFORM", "") != "api_server":
|
||||
return ""
|
||||
return get_session_env("HERMES_SESSION_CHAT_ID", "") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def dispatch_async_delegation(
|
||||
*,
|
||||
goal: str,
|
||||
|
|
@ -498,6 +526,7 @@ def dispatch_async_delegation(
|
|||
parent_session_id: Optional[str] = None,
|
||||
runner: Callable[[], Dict[str, Any]],
|
||||
origin_ui_session_id: str = "",
|
||||
origin_session_id: str = "",
|
||||
interrupt_fn: Optional[Callable[[], None]] = None,
|
||||
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
|
||||
) -> Dict[str, Any]:
|
||||
|
|
@ -546,6 +575,7 @@ def dispatch_async_delegation(
|
|||
"model": model,
|
||||
"session_key": session_key,
|
||||
"origin_ui_session_id": origin_ui_session_id,
|
||||
"origin_session_id": origin_session_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
"status": "running",
|
||||
"dispatched_at": dispatched_at,
|
||||
|
|
@ -666,6 +696,7 @@ def _push_completion_event(
|
|||
# session; empty string => CLI (single-session) path.
|
||||
"session_key": record.get("session_key", ""),
|
||||
"origin_ui_session_id": record.get("origin_ui_session_id", ""),
|
||||
"origin_session_id": record.get("origin_session_id", ""),
|
||||
"parent_session_id": record.get("parent_session_id"),
|
||||
"goal": record.get("goal", ""),
|
||||
"context": record.get("context"),
|
||||
|
|
@ -705,6 +736,7 @@ def dispatch_async_delegation_batch(
|
|||
parent_session_id: Optional[str] = None,
|
||||
runner: Callable[[], Dict[str, Any]],
|
||||
origin_ui_session_id: str = "",
|
||||
origin_session_id: str = "",
|
||||
interrupt_fn: Optional[Callable[[], None]] = None,
|
||||
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
|
||||
delegation_id: Optional[str] = None,
|
||||
|
|
@ -746,6 +778,7 @@ def dispatch_async_delegation_batch(
|
|||
"model": model,
|
||||
"session_key": session_key,
|
||||
"origin_ui_session_id": origin_ui_session_id,
|
||||
"origin_session_id": origin_session_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
"status": "running",
|
||||
"dispatched_at": dispatched_at,
|
||||
|
|
@ -846,6 +879,7 @@ def _finalize_batch(
|
|||
"delegation_id": delegation_id,
|
||||
"session_key": event_record.get("session_key", ""),
|
||||
"origin_ui_session_id": event_record.get("origin_ui_session_id", ""),
|
||||
"origin_session_id": event_record.get("origin_session_id", ""),
|
||||
"parent_session_id": event_record.get("parent_session_id"),
|
||||
"goal": event_record.get("goal", ""),
|
||||
"goals": event_record.get("goals"),
|
||||
|
|
|
|||
|
|
@ -2583,6 +2583,18 @@ def delegate_task(
|
|||
|
||||
_parent_tool_names = list(_model_tools._last_resolved_tool_names)
|
||||
|
||||
# Capture the ORIGINATING session's wake target BEFORE any child agent is
|
||||
# constructed: _build_child_agent() -> AIAgent() -> agent_init calls
|
||||
# set_current_session_id(child.session_id), which clobbers the
|
||||
# HERMES_SESSION_ID ContextVar and os.environ with the subagent's internal
|
||||
# id before the background-dispatch code below would read it. The
|
||||
# request-scoped chat_id binding (the raw X-Hermes-Session-Id on
|
||||
# api_server) is untouched by child construction, so read it here and
|
||||
# thread it through the dispatch.
|
||||
from tools.async_delegation import _current_origin_session_id
|
||||
|
||||
_origin_wake_sid = _current_origin_session_id()
|
||||
|
||||
# Build all child agents on the main thread (thread-safe construction)
|
||||
# Wrapped in try/finally so the global is always restored even if a
|
||||
# child build raises (otherwise _last_resolved_tool_names stays corrupted).
|
||||
|
|
@ -2921,6 +2933,30 @@ def delegate_task(
|
|||
_async_ok = async_delivery_supported()
|
||||
except Exception:
|
||||
_async_ok = True
|
||||
|
||||
_wake_sid = ""
|
||||
if not _async_ok:
|
||||
# The adapter itself cannot push, but if a raw session id is
|
||||
# bound (the API server always binds one — see
|
||||
# ApiServerAdapter._bind_api_server_session), gateway.wake can
|
||||
# still reach the session by self-POSTing /v1/chat/completions
|
||||
# with that id in X-Hermes-Session-Id once the batch completes.
|
||||
# Only fall back to forced-sync execution when there is truly no
|
||||
# session id to wake. Uses the origin captured before child
|
||||
# construction (see _origin_wake_sid above) — reading
|
||||
# HERMES_SESSION_ID here would return the subagent's internal id.
|
||||
_wake_sid = _origin_wake_sid
|
||||
if _wake_sid:
|
||||
logger.info(
|
||||
"delegate_task: async delivery unsupported on this "
|
||||
"session, but a session id is bound (%s) — dispatching "
|
||||
"in the background and waking the session via self-post "
|
||||
"when it completes instead of forcing synchronous "
|
||||
"execution.",
|
||||
_wake_sid,
|
||||
)
|
||||
_async_ok = True
|
||||
|
||||
if not _async_ok:
|
||||
logger.info(
|
||||
"delegate_task: async delivery unsupported on this session "
|
||||
|
|
@ -3013,6 +3049,7 @@ def delegate_task(
|
|||
model=creds["model"],
|
||||
session_key=_session_key,
|
||||
origin_ui_session_id=_origin_ui_session_id,
|
||||
origin_session_id=_wake_sid,
|
||||
parent_session_id=_parent_session_id,
|
||||
runner=_batch_runner,
|
||||
interrupt_fn=_batch_interrupt,
|
||||
|
|
|
|||
|
|
@ -1139,7 +1139,17 @@ def _handle_create(args: dict, **kw) -> str:
|
|||
# Stamp the originating session id when the agent loop runs under
|
||||
# ACP (which sets HERMES_SESSION_ID before invoking tools). NULL on
|
||||
# CLI / dashboard paths and on legacy hosts that don't set the env.
|
||||
session_id = args.get("session_id") or os.environ.get("HERMES_SESSION_ID")
|
||||
# Prefer the request-scoped api_server origin binding: HERMES_SESSION_ID
|
||||
# is clobbered with a subagent's internal id whenever a child agent is
|
||||
# constructed in-process (agent_init calls set_current_session_id), which
|
||||
# would stamp — and later wake — the wrong session.
|
||||
from tools.async_delegation import _current_origin_session_id
|
||||
|
||||
session_id = (
|
||||
args.get("session_id")
|
||||
or _current_origin_session_id()
|
||||
or os.environ.get("HERMES_SESSION_ID")
|
||||
)
|
||||
priority = args.get("priority")
|
||||
# Resolve workspace. Workspace sharing is always explicit: omitted fields
|
||||
# mean a fresh scratch workspace, even when a dispatcher-spawned worker
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue