fix: forward gateway tool activity to webui
This commit is contained in:
parent
5ec136af3b
commit
b76a6dfedb
3 changed files with 122 additions and 2 deletions
|
|
@ -3,6 +3,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Gateway-backed browser chat now forwards Hermes Gateway `hermes.tool.progress` SSE events into WebUI's live tool/activity stream, so Gateway runs no longer appear idle while server-side tools are running.
|
||||
|
||||
## [v0.51.156] — 2026-05-28 — Release EB (stage-batch38 — 2-PR Tier B cleanup: WebUI request/runtime hardening + chat-start provider fallback)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -142,6 +142,28 @@ def _gateway_stream_usage(payload: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
|
||||
"""Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
|
||||
if not name or name.startswith("_"):
|
||||
return None
|
||||
status = str(payload.get("status") or "running").strip().lower()
|
||||
tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
|
||||
is_complete = status in {"completed", "complete", "success", "error", "failed"}
|
||||
event_payload = {
|
||||
"event_type": "tool.completed" if is_complete else "tool.started",
|
||||
"name": name,
|
||||
"preview": payload.get("label") or payload.get("preview"),
|
||||
"args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
|
||||
"is_error": status in {"error", "failed"},
|
||||
}
|
||||
if tid:
|
||||
event_payload["tid"] = str(tid)
|
||||
return ("tool_complete" if is_complete else "tool"), event_payload
|
||||
|
||||
|
||||
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
|
||||
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
|
||||
|
||||
|
|
@ -260,13 +282,20 @@ def _run_gateway_chat_streaming(
|
|||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
last_payload = {}
|
||||
sse_event = "message"
|
||||
with urllib.request.urlopen(req, timeout=600) as resp:
|
||||
for raw_line in resp:
|
||||
if cancel_event.is_set():
|
||||
put_gateway_event("cancel", {"message": "Cancelled by user"})
|
||||
return
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or not line.startswith("data:"):
|
||||
if not line:
|
||||
sse_event = "message"
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
sse_event = line[6:].strip() or "message"
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
|
|
@ -275,6 +304,32 @@ def _run_gateway_chat_streaming(
|
|||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if sse_event == "hermes.tool.progress":
|
||||
translated = _gateway_tool_progress_event(payload)
|
||||
if translated:
|
||||
event_name, event_payload = translated
|
||||
if stream_id in STREAM_LIVE_TOOL_CALLS:
|
||||
if event_name == "tool":
|
||||
STREAM_LIVE_TOOL_CALLS[stream_id].append({
|
||||
"name": event_payload.get("name"),
|
||||
"args": event_payload.get("args") or {},
|
||||
"done": False,
|
||||
**({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
|
||||
})
|
||||
else:
|
||||
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
|
||||
if shared_tc.get("done"):
|
||||
continue
|
||||
if (
|
||||
event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
|
||||
) or shared_tc.get("name") == event_payload.get("name"):
|
||||
shared_tc["done"] = True
|
||||
shared_tc["is_error"] = bool(event_payload.get("is_error"))
|
||||
break
|
||||
put_gateway_event(event_name, event_payload)
|
||||
update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
|
||||
sse_event = "message"
|
||||
continue
|
||||
last_payload = payload
|
||||
delta = _gateway_sse_delta(payload)
|
||||
if delta:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from api.gateway_chat import (
|
|||
_gateway_http_error_event,
|
||||
_gateway_sse_delta,
|
||||
_gateway_stream_usage,
|
||||
_gateway_tool_progress_event,
|
||||
gateway_chat_config_status,
|
||||
webui_chat_backend_mode,
|
||||
webui_gateway_chat_enabled,
|
||||
|
|
@ -96,6 +97,41 @@ def test_gateway_stream_usage_normalizes_token_names():
|
|||
assert _gateway_stream_usage({}) == {}
|
||||
|
||||
|
||||
def test_gateway_tool_progress_event_translates_gateway_lifecycle_payloads():
|
||||
assert _gateway_tool_progress_event(
|
||||
{
|
||||
"tool": "terminal",
|
||||
"label": "terminal: pytest",
|
||||
"toolCallId": "call-1",
|
||||
"status": "running",
|
||||
}
|
||||
) == (
|
||||
"tool",
|
||||
{
|
||||
"event_type": "tool.started",
|
||||
"name": "terminal",
|
||||
"preview": "terminal: pytest",
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
},
|
||||
)
|
||||
assert _gateway_tool_progress_event(
|
||||
{"tool": "terminal", "toolCallId": "call-1", "status": "completed"}
|
||||
) == (
|
||||
"tool_complete",
|
||||
{
|
||||
"event_type": "tool.completed",
|
||||
"name": "terminal",
|
||||
"preview": None,
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
},
|
||||
)
|
||||
assert _gateway_tool_progress_event({"tool": "_thinking", "status": "running"}) is None
|
||||
|
||||
|
||||
def test_gateway_http_401_reports_gateway_auth_not_provider_key():
|
||||
exc = urllib.error.HTTPError(
|
||||
"http://gateway.local/v1/chat/completions",
|
||||
|
|
@ -189,7 +225,11 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
|||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield b'event: hermes.tool.progress\n'
|
||||
yield b'data: {"tool":"terminal","label":"terminal: pytest","toolCallId":"call-1","status":"running"}\n\n'
|
||||
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
|
||||
yield b'event: hermes.tool.progress\n'
|
||||
yield b'data: {"tool":"terminal","toolCallId":"call-1","status":"completed"}\n\n'
|
||||
yield b'data: {"choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}\n\n'
|
||||
yield b'data: [DONE]\n\n'
|
||||
|
||||
|
|
@ -210,7 +250,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
|||
s.pending_attachments = []
|
||||
s.pending_started_at = 123
|
||||
s.save()
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
channel = create_stream_channel()
|
||||
subscriber = channel.subscribe()
|
||||
STREAMS[stream_id] = channel
|
||||
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
s.session_id,
|
||||
|
|
@ -234,6 +276,25 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
|||
assert captured["headers"]["X-hermes-session-id"] == s.session_id
|
||||
assert captured["headers"]["X-hermes-session-key"] == f"webui:{s.session_id}"
|
||||
assert '"stream": true' in captured["body"]
|
||||
events = []
|
||||
while not subscriber.empty():
|
||||
events.append(subscriber.get_nowait())
|
||||
assert ("tool", {
|
||||
"event_type": "tool.started",
|
||||
"name": "terminal",
|
||||
"preview": "terminal: pytest",
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
}) in events
|
||||
assert ("tool_complete", {
|
||||
"event_type": "tool.completed",
|
||||
"name": "terminal",
|
||||
"preview": None,
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
}) in events
|
||||
|
||||
|
||||
def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_path, monkeypatch):
|
||||
|
|
|
|||
Loading…
Reference in a new issue