Merge PR #7785 into stage/0924-o

This commit is contained in:
nesquena-hermes committed 2026-09-24 19:15:12 +00:00
commit 15ac01e2c3
6 files changed
+848 -57

No files matched your search

+309 -53
View File
@@ -47,6 +47,21 @@ _STREAM_RUN_IDS: dict[str, str] = {}
_STREAM_RUN_LIFECYCLE: dict[str, dict[str, Any]] = {}
_STREAM_RUN_STARTING_CONDITION = threading.Condition()
GATEWAY_RUN_ID_WAIT_TIMEOUT = 5.0
# stream_id -> (base_url, api_key) of the Gateway the stream's run lives on, for Stop
# and approval replies; a reattached run's Gateway need not be the process profile's.
_STREAM_ENDPOINTS: dict[str, tuple[str, str]] = {}
def gateway_run_endpoint(run_id: str) -> tuple[str, str]:
"""URL and key of the Gateway that owns run_id; the process Gateway if no live stream holds it."""
run_id = str(run_id or "").strip()
with _STREAM_RUN_STARTING_CONDITION:
for stream_id, mapped in list(_STREAM_RUN_IDS.items()):
if run_id and mapped == run_id and stream_id in _STREAM_ENDPOINTS:
return _STREAM_ENDPOINTS[stream_id]
from api.config import get_config
return _gateway_base_url(get_config()), _gateway_api_key()
def _mark_gateway_run_starting(stream_id: str) -> None:
@@ -565,24 +580,66 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
}
def _relay_gateway_run_approval(session_id, run_id, payload, base_url, api_key, *, put_gateway_event) -> None:
"""Auto-approve or surface one runs-API approval request as a WebUI approval card."""
approval_data = _gateway_runs_approval_event(payload)
if not approval_data:
return
approval_data["run_id"] = run_id
from api.config import gateway_supports_approval_identity_v1
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
approval_data["_gateway_agent_identity_v1"] = identity_v1
auto_approved, head, total = _settle_gateway_run_approval(
session_id,
approval_data,
base_url,
api_key,
)
if not auto_approved:
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
def _admit_gateway_run(url_runs, headers, run_body, stream_id) -> str:
"""POST /v1/runs; the same stream and body return the originally admitted run id."""
req = urllib.request.Request(
url_runs,
data=json.dumps(run_body).encode("utf-8"),
# Durable run record on the gateway: GET /v1/runs/{id} survives either side restarting.
headers={**headers, "Idempotency-Key": f"webui-{stream_id}"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
run_data = json.loads(resp.read(65536))
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
if not run_id:
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
return run_id
def _gateway_run_headers(session_id, api_key) -> dict:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Hermes-Session-Id": session_id,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
return headers
def _run_gateway_runs_api_streaming(
session_id, msg_text, model, workspace, stream_id,
base_url, api_key, prefill_messages, body_extras,
*, put_gateway_event, cancel_event,
attachments=None, cfg=None, session=None,
active_provider: str = "",
on_run_id=None,
):
"""Submit via POST /v1/runs and relay SSE events including approval."""
try:
url_runs = f"{base_url.rstrip('/')}/v1/runs"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Hermes-Session-Id": session_id,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
headers = _gateway_run_headers(session_id, api_key)
message_content: Any = str(msg_text or "")
if attachments:
try:
@@ -635,24 +692,19 @@ def _run_gateway_runs_api_streaming(
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
if conversation_history:
run_body["conversation_history"] = conversation_history
req = urllib.request.Request(
url_runs,
data=json.dumps(run_body).encode("utf-8"),
headers=headers,
method="POST",
)
update_active_run(stream_id, phase="gateway-request")
with urllib.request.urlopen(req, timeout=30) as resp:
run_data = json.loads(resp.read(65536))
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
if not run_id:
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
# Persist the exact body first: a restart before the run id is saved replays this admission.
if on_run_id is not None:
on_run_id("", request=run_body)
run_id = _admit_gateway_run(url_runs, headers, run_body, stream_id)
except Exception:
_finish_gateway_run_starting(stream_id)
raise
usage: dict = {}
_publish_gateway_run_id(stream_id, run_id)
if on_run_id is not None:
on_run_id(run_id)
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
headers_sse = dict(headers)
@@ -683,22 +735,10 @@ def _run_gateway_runs_api_streaming(
continue
payload_event = str(payload.get("event") or payload.get("type") or sse_event).strip() or "message"
if payload_event == "approval.request":
approval_data = _gateway_runs_approval_event(payload)
if approval_data:
approval_data["run_id"] = run_id
from api.config import gateway_supports_approval_identity_v1
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
approval_data["_gateway_agent_identity_v1"] = identity_v1
auto_approved, head, total = _settle_gateway_run_approval(
session_id,
approval_data,
base_url,
api_key,
)
if auto_approved:
sse_event = "message"
continue
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
_relay_gateway_run_approval(
session_id, run_id, payload, base_url, api_key,
put_gateway_event=put_gateway_event,
)
sse_event = "message"
continue
if payload_event in {"tool.started", "tool.completed", "reasoning.available"}:
@@ -795,11 +835,7 @@ def stop_gateway_run(run_id: str) -> bool:
run_id = str(run_id or "").strip()
if not run_id:
return False
from api.config import get_config
cfg = get_config()
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
base_url, api_key = gateway_run_endpoint(run_id)
headers = {"Accept": "application/json", "Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
@@ -824,6 +860,180 @@ def stop_gateway_run(run_id: str) -> bool:
return False
_GATEWAY_RUN_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted"})
GATEWAY_REATTACH_POLL_INTERVAL = 2.0
# Consecutive unreachable polls tolerated before the reattached turn is failed (~5 min at 2s).
GATEWAY_REATTACH_MAX_POLL_FAILURES = 150
_REATTACH_SCAN_HEAD_BYTES = 16 * 1024
def _record_gateway_run(session_id: str, stream_id: str, run_id: str, request=None, **extra) -> None:
"""Persist the run id (or, before admission, the exact request) so a restarted WebUI can reattach."""
try:
with _get_session_agent_lock(session_id):
session = get_session(session_id)
if not _stream_writeback_is_current(session, stream_id):
return
session.gateway_run = {"run_id": run_id, "stream_id": stream_id, **extra}
if not run_id:
session.gateway_run["request"] = request
session.save(touch_updated_at=False)
except Exception:
logger.warning("Failed to persist gateway run %s for session %s", run_id, session_id, exc_info=True)
if not run_id:
raise # never admit a run a restart could not recover
def _get_gateway_run_status(base_url: str, api_key: str, run_id: str) -> dict:
headers = {"Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url.rstrip('/')}/v1/runs/{urllib.parse.quote(run_id, safe='')}",
headers=headers,
method="GET",
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read(1024 * 1024) or b"{}")
return payload if isinstance(payload, dict) else {}
def _await_gateway_run_result(
session_id, stream_id, run_id, base_url, api_key,
*, put_gateway_event, cancel_event,
):
"""Poll a run started by a previous WebUI process; same return contract as _run_gateway_runs_api_streaming."""
from api.route_approvals import settle_gateway_pending_run
_publish_gateway_run_id(stream_id, run_id)
update_active_run(stream_id, phase="gateway-reattached")
surfaced_approval_ids: set[str] = set()
failures = 0
while True:
if cancel_event.is_set():
put_gateway_event("cancel", {"message": "Cancelled by user"})
return None, {}
try:
status = _get_gateway_run_status(base_url, api_key, run_id)
failures = 0
except (urllib.error.URLError, OSError, ValueError) as exc:
code = getattr(exc, "code", None)
if code == 404:
raise RuntimeError("Gateway no longer has this run; its result could not be recovered after the WebUI restart.") from exc
if code in (401, 403):
raise RuntimeError(f"Gateway rejected the WebUI credentials (HTTP {code}) while reattaching to a run after the restart.") from exc
failures += 1
if failures >= GATEWAY_REATTACH_MAX_POLL_FAILURES:
raise RuntimeError("Gateway became unreachable while waiting for a run that outlived the WebUI restart.") from exc
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
continue
state = str(status.get("status") or "").strip().lower()
approval = status.get("approval")
if state == "waiting_for_approval" and isinstance(approval, dict):
approval_key = str(approval.get("approval_id") or approval.get("id") or approval.get("timestamp") or "")
if approval_key not in surfaced_approval_ids:
surfaced_approval_ids.add(approval_key)
_relay_gateway_run_approval(
session_id, run_id, approval, base_url, api_key, put_gateway_event=put_gateway_event,
)
if state in _GATEWAY_RUN_TERMINAL_STATUSES:
settle_gateway_pending_run(session_id, run_id, reason=f"Gateway run {state} before approval resolution")
if state == "cancelled":
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
return None, {}
if state != "completed":
raise RuntimeError(str(status.get("error") or f"Gateway run {state}"))
output = str(status.get("output") or "")
if output and stream_id in STREAM_PARTIAL_TEXT:
STREAM_PARTIAL_TEXT[stream_id] = output
return output, {k: v for k, v in _gateway_stream_usage(status).items() if v}
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
def resume_gateway_runs_after_restart() -> list[str]:
"""Reattach pending turns whose gateway run outlived the previous WebUI process; never raises.
Call before serving so stale-pending repair does not mark these turns interrupted.
"""
from api import models as _models
try:
candidates = _sidecars_with_active_stream(_models.SESSION_DIR)
except Exception:
logger.warning("gateway reattach: could not scan session sidecars", exc_info=True)
return []
resumed: list[str] = []
for sid in candidates:
try:
if _resume_gateway_run_for_session(_models.Session.load_metadata_only(sid)):
resumed.append(sid)
except Exception:
logger.warning("gateway reattach failed for session %s", sid, exc_info=True)
if resumed:
logger.info("Reattached %d gateway run(s) that outlived the previous WebUI process", len(resumed))
return resumed
def _sidecars_with_active_stream(session_dir) -> list[str]:
"""Ids of sidecars (not the index, which can lag a save) whose header shows an active stream."""
ids = []
for path in sorted(session_dir.glob("*.json")):
if path.name.startswith("_"):
continue
try:
with path.open("rb") as fp:
head = fp.read(_REATTACH_SCAN_HEAD_BYTES)
except OSError:
continue
if b'"active_stream_id": null' not in head:
ids.append(path.stem)
return ids
def _gateway_endpoint_for_profile(profile_name) -> tuple[str, str]:
"""URL and key of the session's own profile, root included, never the process-active profile."""
from api import profiles as _profiles
from api.config import get_config_for_profile_home
home = _profiles.get_hermes_home_for_profile(str(profile_name or "").strip())
environ = {k: v for k, v in os.environ.items() if k not in _profiles._loaded_profile_env_keys}
environ.update(_profiles.filter_runtime_env_for_gateway_parity(_profiles.get_profile_runtime_env(home)))
return _gateway_base_url(get_config_for_profile_home(home), environ), _gateway_api_key(environ)
def _resume_gateway_run_for_session(session) -> bool:
from api.config import create_stream_channel, register_session_writeback_owner, register_stream_owner
run = (session.gateway_run if session is not None else None) or {}
stream_id = str(run.get("stream_id") or "")
# A persisted run is the authority whatever the process default backend is.
if not stream_id or not (run.get("run_id") or run.get("request")) or session.active_stream_id != stream_id:
return False
sid = session.session_id
endpoint = _gateway_endpoint_for_profile(session.profile)
with STREAMS_LOCK:
if stream_id in STREAMS:
return False
STREAMS[stream_id] = create_stream_channel()
register_stream_owner(stream_id, sid)
register_session_writeback_owner(sid, stream_id)
_mark_gateway_run_starting(stream_id)
threading.Thread(
target=_run_gateway_chat_streaming,
args=(sid, session.pending_user_message or "", session.model, session.workspace,
stream_id, list(session.pending_attachments or [])),
kwargs={
"model_provider": session.model_provider,
"goal_related": bool(run.get("goal_related")),
"regeneration": bool(run.get("regeneration")),
"reattach_run": run,
"reattach_endpoint": endpoint,
},
name=f"gateway-reattach-{stream_id[:12]}",
daemon=True,
).start()
return True
def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, model_provider, terminal_error):
from api.streaming import (
_classify_provider_error,
@@ -847,6 +1057,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
turn_duration = _terminal_turn_duration(session)
_materialize_pending_user_turn_before_error(session)
session.active_stream_id = None
session.gateway_run = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
@@ -890,14 +1101,32 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
return error_payload
def _settle_gateway_cancelled_turn(session_id, stream_id) -> None:
"""Keep the prompt and a cancel marker when the run ended cancelled and Stop did not already settle it."""
from api.streaming import _persist_cancelled_turn
with _get_session_agent_lock(session_id):
session = get_session(session_id)
if not _stream_writeback_is_current(session, stream_id):
return
_persist_cancelled_turn(session, message="Cancelled by gateway")
session.gateway_run = None
session.save()
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
if not _stream_writeback_is_current(session, stream_id):
# Cancel clears active_stream_id eagerly; still drop this stream's run record.
if session is not None and (session.gateway_run or {}).get("stream_id") == stream_id:
session.gateway_run = None
session.save(touch_updated_at=False)
return
session.active_stream_id = None
session.gateway_run = None
session.pending_user_message = None
session.pending_attachments = None
session.pending_started_at = None
@@ -927,6 +1156,8 @@ def _run_gateway_chat_streaming(
model_provider=None,
goal_related=False,
regeneration=False,
reattach_run=None,
reattach_endpoint=None,
):
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
@@ -1013,8 +1244,9 @@ def _run_gateway_chat_streaming(
model=model,
model_provider=model_provider,
)
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
base_url, api_key = reattach_endpoint or (_gateway_base_url(cfg), _gateway_api_key())
with _STREAM_RUN_STARTING_CONDITION:
_STREAM_ENDPOINTS[stream_id] = (base_url, api_key)
try:
from api.config import _main_model_request_overrides
_gw_overrides = _main_model_request_overrides(
@@ -1025,7 +1257,7 @@ def _run_gateway_chat_streaming(
except Exception:
_gw_overrides = {}
_runs_api_enabled = _gateway_use_runs_api_enabled(cfg)
_use_runs_api = _runs_api_enabled and gateway_supports_approval(base_url, api_key)
_use_runs_api = bool(reattach_run) or (_runs_api_enabled and gateway_supports_approval(base_url, api_key))
if not _use_runs_api and runs_api_pending_marked:
_finish_gateway_run_starting(stream_id, result="fallback")
runs_api_pending_marked = False
@@ -1075,17 +1307,37 @@ def _run_gateway_chat_streaming(
body_extras["reasoning_effort"] = reasoning_effort
if _gw_overrides.get("service_tier"):
body_extras["service_tier"] = _gw_overrides["service_tier"]
record_run = lambda run_id, request=None: _record_gateway_run(
session_id, stream_id, run_id, request=request,
regeneration=bool(regeneration), goal_related=bool(goal_related),
)
try:
final_text, usage = _run_gateway_runs_api_streaming(
session_id, msg_text, model, workspace, stream_id,
base_url, api_key, prefill_messages, body_extras,
put_gateway_event=put_gateway_event,
cancel_event=cancel_event,
attachments=attachments,
cfg=cfg,
session=s,
active_provider=(model_provider or ""),
)
if reattach_run:
run_id = str(reattach_run.get("run_id") or "").strip()
if not run_id:
# Crashed between admission and saving the id: the same key returns the original run.
run_id = _admit_gateway_run(
f"{base_url.rstrip('/')}/v1/runs", _gateway_run_headers(session_id, api_key),
reattach_run["request"], stream_id,
)
record_run(run_id)
final_text, usage = _await_gateway_run_result(
session_id, stream_id, run_id, base_url, api_key,
put_gateway_event=put_gateway_event,
cancel_event=cancel_event,
)
else:
final_text, usage = _run_gateway_runs_api_streaming(
session_id, msg_text, model, workspace, stream_id,
base_url, api_key, prefill_messages, body_extras,
put_gateway_event=put_gateway_event,
cancel_event=cancel_event,
attachments=attachments,
cfg=cfg,
session=s,
active_provider=(model_provider or ""),
on_run_id=record_run,
)
except Exception as exc:
error_payload = _settle_gateway_terminal_error(
session_id,
@@ -1100,6 +1352,7 @@ def _run_gateway_chat_streaming(
put_gateway_event("apperror", error_payload)
return
if final_text is None:
_settle_gateway_cancelled_turn(session_id, stream_id)
return
else:
# Legacy gateway path: emit unsupported approval notice once per session,
@@ -1387,6 +1640,7 @@ def _run_gateway_chat_streaming(
if len(current_display_rows) == 1:
current_display_rows[0]["timestamp"] = user_msg["timestamp"]
s.active_stream_id = None
s.gateway_run = None
s.pending_user_message = None
s.pending_attachments = None
s.pending_started_at = None
@@ -1537,6 +1791,8 @@ def _run_gateway_chat_streaming(
if runs_api_pending_marked and gateway_run_id_pending(stream_id):
_finish_gateway_run_starting(stream_id)
_clear_gateway_run_starting(stream_id)
with _STREAM_RUN_STARTING_CONDITION:
_STREAM_ENDPOINTS.pop(stream_id, None)
unregister_stream_owner(stream_id)
unregister_active_run(stream_id)
# Release the writeback-owner entry the route layer registered for this
+3
View File
@@ -1422,6 +1422,7 @@ class Session:
process_wakeup_pause=None,
share_token=None,
share_created_at=None,
gateway_run=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -1536,6 +1537,7 @@ class Session:
self.process_wakeup_pause = process_wakeup_pause if isinstance(process_wakeup_pause, dict) else {}
self.share_token = str(share_token).strip() if share_token else None
self.share_created_at = share_created_at
self.gateway_run = gateway_run if isinstance(gateway_run, dict) else None
# #5854: a compact fingerprint of anchor_activity_scenes ({scene_key:
# updated_at}) persisted BEFORE the messages array so the sidebar-poll
# freshness check can compare scene freshness without parsing the full
@@ -1616,6 +1618,7 @@ class Session:
'enabled_toolsets', 'composer_draft',
'process_wakeup_pause',
'share_token', 'share_created_at',
'gateway_run',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
# #5854: message_count and a compact anchor-scene fingerprint go in the
+3 -4
View File
@@ -27098,8 +27098,8 @@ def _relay_gateway_run_approval(
Both the approval-card endpoint and the ordinary session-YOLO endpoint use
this chokepoint so one tab cannot retire another tab's parked remote run.
"""
from api.config import gateway_supports_approval_identity_v1, get_config as _get_config
from api.gateway_chat import _gateway_api_key, _gateway_base_url
from api.config import gateway_supports_approval_identity_v1
from api.gateway_chat import gateway_run_endpoint
from api.runner_client import HttpRunnerClient, RunnerClientError
run_id = str(mirror.get("run_id") or "").strip()
@@ -27141,8 +27141,7 @@ def _relay_gateway_run_approval(
enable_yolo=enable_yolo,
)
base_url = _gateway_base_url(_get_config())
api_key = _gateway_api_key()
base_url, api_key = gateway_run_endpoint(run_id)
identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and (
gateway_supports_approval_identity_v1(base_url, api_key)
)