mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +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
|
|
@ -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