Merge origin/master into PR 5253

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank Song 2026-06-30 14:28:43 +08:00
commit 97992e72ad
5 changed files with 268 additions and 4 deletions

View file

@ -35,6 +35,8 @@
### Fixed
- **The `/goal` loop now continues after a turn on the gateway chat backend, not just the local one.** When WebUI chat is routed through the Hermes Gateway backend, the gateway worker persisted the final assistant turn and ended the stream *before* the goal judge ran, so a standing `/goal` silently stopped after one turn. The gateway worker now evaluates the goal locally after the turn settles (the same `evaluate_goal_after_turn` path the local backend uses) and emits the existing `goal` / `goal_continue` events, so the browser continues the goal exactly as it does on the local backend. Evaluation only runs when the turn is goal-related and a goal is active, so ordinary gateway turns are unaffected. Thanks @rodboev. (#5251, fixes #5092)
- **Session-scoped API endpoints now enforce the active profile on every request-supplied session ID.** On a multi-profile box, endpoints that accept a `session_id` (in the query string or the JSON/multipart body) — session read/duplicate/rename/delete, file operations, uploads, and chat start — looked up the session without confirming it belongs to the request's active profile, so a caller could reference another profile's session by id. A central preflight now rejects a request-supplied session id that isn't visible to the active profile (404), wired across all the session verbs plus the upload and chat-start paths; stream IDs are authorized through a synchronously-registered owner map so the check doesn't depend on worker-startup timing. Single-profile / no-auth deployments and same-profile access are unchanged. Thanks @starship-s. (#5198)
- **The file manager now enforces the active profile when resolving a session's workspace.** When multiple Hermes profiles share a box, a session-scoped file operation looked up the session by id without checking which profile owns it, so a session belonging to a *different* profile could expose its workspace path to the currently active profile. The session-for-file-ops resolver now rejects a session whose owning profile doesn't match the request's active profile (raising the same not-found that the file routes already translate to a 404), failing closed. Same-profile access, single-profile/no-auth deployments, and the external-session (state.db) fallback are all unchanged — only genuine cross-profile lookups are now denied. Thanks @Hinotoi-agent. (#5179)

View file

@ -12,6 +12,8 @@ from typing import Any
from api.config import (
CANCEL_FLAGS,
PENDING_GOAL_CONTINUATION,
STREAM_GOAL_RELATED,
STREAMS,
STREAMS_LOCK,
STREAM_LAST_EVENT_ID,
@ -503,6 +505,7 @@ def _run_gateway_chat_streaming(
attachments=None,
*,
model_provider=None,
goal_related=False,
):
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
@ -886,6 +889,55 @@ def _run_gateway_chat_streaming(
s.model = model
s.model_provider = model_provider
s.save()
try:
from api.goals import evaluate_goal_after_turn, has_active_goal
from api.profiles import get_hermes_home_for_profile
profile_home = get_hermes_home_for_profile(getattr(s, "profile", None))
if goal_related and has_active_goal(session_id, profile_home=profile_home):
put_gateway_event("goal", {
"session_id": session_id,
"state": "evaluating",
"message": "Evaluating goal progress…",
"message_key": "goal_evaluating_progress",
})
decision = evaluate_goal_after_turn(
session_id,
assistant_text,
user_initiated=True,
profile_home=profile_home,
) or {}
goal_message = str(decision.get("message") or "").strip()
if goal_message:
put_gateway_event("goal", {
"session_id": session_id,
"state": "continuing" if decision.get("should_continue") else "idle",
"message": goal_message,
"message_key": decision.get("message_key") or (
"goal_continuing" if goal_message else ""
),
"message_args": decision.get("message_args") or [],
"decision": decision,
})
if decision.get("should_continue"):
continuation_prompt = str(decision.get("continuation_prompt") or "").strip()
if continuation_prompt:
PENDING_GOAL_CONTINUATION.add(session_id)
put_gateway_event("goal_continue", {
"session_id": session_id,
"continuation_prompt": continuation_prompt,
"text": continuation_prompt,
"message": goal_message,
"message_key": decision.get("message_key") or "goal_continuing",
"message_args": decision.get("message_args") or [],
"decision": decision,
})
except Exception as goal_exc:
logger.debug(
"Gateway goal continuation hook failed for session %s: %s",
session_id,
goal_exc,
)
from api.streaming import _session_payload_with_full_messages
gateway_session_payload = _session_payload_with_full_messages(s, tool_calls=[])
put_gateway_event("done", {"session": redact_session_data(gateway_session_payload), "usage": usage})
@ -917,6 +969,7 @@ def _run_gateway_chat_streaming(
_cleanup_gateway_pending_mirror(session_id)
with STREAMS_LOCK:
CANCEL_FLAGS.pop(stream_id, None)
STREAM_GOAL_RELATED.pop(stream_id, None)
STREAM_PARTIAL_TEXT.pop(stream_id, None)
STREAM_REASONING_TEXT.pop(stream_id, None)
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)

View file

@ -17624,9 +17624,7 @@ def _start_chat_stream_for_session(
diag.stage("worker_thread_start") if diag else None
backend_is_gateway = webui_gateway_chat_enabled(get_config())
worker_target = _run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
worker_kwargs = {"model_provider": model_provider}
if not backend_is_gateway:
worker_kwargs["goal_related"] = goal_related
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
if moa_config and not backend_is_gateway:
worker_kwargs["moa_config"] = moa_config
thr = threading.Thread(

View file

@ -2,6 +2,7 @@
import io
import json
import threading
from pathlib import Path
from types import SimpleNamespace
@ -376,6 +377,62 @@ def test_routes_register_goal_endpoint_and_kickoff_stream():
assert "_start_chat_stream_for_session" in ROUTES_PY
def test_chat_start_forwards_goal_related_to_gateway_worker(monkeypatch, tmp_path):
from api import routes
import api.turn_journal as turn_journal
class FakeSession:
session_id = "sid-goal-related-gateway"
active_stream_id = None
pending_started_at = 0.0
title = "Goal Chat"
profile = "default"
captured = {}
class FakeThread:
def __init__(self, *, target, args, kwargs, daemon):
captured["target"] = target
captured["args"] = args
captured["kwargs"] = kwargs
captured["daemon"] = daemon
def start(self):
captured["started"] = True
def fake_prepare(session, **kwargs):
session.pending_started_at = 123.0
session.title = "Goal Chat"
monkeypatch.setattr(routes, "_get_session_agent_lock", lambda *args, **kwargs: threading.Lock())
monkeypatch.setattr(routes, "_active_stream_blocks_chat_start", lambda *args, **kwargs: False)
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *args, **kwargs: None)
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare)
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda *args, **kwargs: False)
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None)
monkeypatch.setattr(routes, "set_last_workspace", lambda *args, **kwargs: None)
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *args, **kwargs: {})
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *args, **kwargs: True)
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
monkeypatch.setattr(routes.uuid, "uuid4", lambda: SimpleNamespace(hex="goal-stream-id"))
response = routes._start_chat_stream_for_session(
FakeSession(),
msg="continue the goal",
attachments=[],
workspace=str(tmp_path),
model="gpt-5.5",
model_provider="openai-codex",
goal_related=True,
)
assert response["stream_id"] == "goal-stream-id"
assert captured["target"] is routes._run_gateway_chat_streaming
assert captured["kwargs"]["goal_related"] is True
assert captured["kwargs"]["model_provider"] == "openai-codex"
assert captured["started"] is True
def test_streaming_post_turn_goal_hook_surfaces_and_continues():
assert "evaluate_goal_after_turn" in STREAMING_PY
assert "put('goal'" in STREAMING_PY

View file

@ -9,7 +9,7 @@ import urllib.error
import api.gateway_chat as gateway_chat
import api.models as models
import api.streaming as streaming
from api.config import STREAMS, create_stream_channel
from api.config import PENDING_GOAL_CONTINUATION, STREAMS, create_stream_channel
from api.models import new_session
from api.gateway_chat import (
_gateway_http_error_event,
@ -490,6 +490,160 @@ def test_gateway_chat_worker_reads_reasoning_content_deltas_from_chat_completion
assert reasoning_events == ["Let me ", "think"]
def test_gateway_chat_worker_emits_goal_continue_for_goal_related_turn(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"goal "}}]}\n\n'
yield b'data: {"choices":[{"delta":{"content":"reply"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
from api import goals as webui_goals
monkeypatch.setattr(webui_goals, "has_active_goal", lambda *args, **kwargs: True)
monkeypatch.setattr(
webui_goals,
"evaluate_goal_after_turn",
lambda *args, **kwargs: {
"should_continue": True,
"continuation_prompt": "continue the goal",
"message": "Continuing goal",
"message_key": "goal_continuing",
"message_args": ["one step remains"],
},
)
s = new_session()
stream_id = "stream-gateway-goal-continue"
s.active_stream_id = stream_id
s.pending_user_message = "finish it"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
PENDING_GOAL_CONTINUATION.discard(s.session_id)
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"finish it",
"test-model",
str(tmp_path),
stream_id,
[],
goal_related=True,
)
saved = models.get_session(s.session_id)
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
event_names = [item[0] for item in events]
assert event_names.count("goal") == 2
assert "goal_continue" in event_names
assert "done" in event_names
assert "stream_end" in event_names
assert event_names.index("goal_continue") < event_names.index("done")
assert event_names.index("done") < event_names.index("stream_end")
assert s.session_id in PENDING_GOAL_CONTINUATION
goal_continue_event = next(item for item in events if item[0] == "goal_continue")
assert goal_continue_event[1]["continuation_prompt"] == "continue the goal"
assert goal_continue_event[1]["message"] == "Continuing goal"
assert goal_continue_event[1]["message_key"] == "goal_continuing"
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "goal reply"
def test_gateway_chat_worker_skips_goal_judge_for_non_goal_turn(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"plain reply"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
from api import goals as webui_goals
has_goal_calls = []
judge_calls = []
monkeypatch.setattr(
webui_goals,
"has_active_goal",
lambda *args, **kwargs: has_goal_calls.append((args, kwargs)) or True,
)
monkeypatch.setattr(
webui_goals,
"evaluate_goal_after_turn",
lambda *args, **kwargs: judge_calls.append((args, kwargs)),
)
s = new_session()
stream_id = "stream-gateway-no-goal"
s.active_stream_id = stream_id
s.pending_user_message = "plain turn"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
PENDING_GOAL_CONTINUATION.discard(s.session_id)
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"plain turn",
"test-model",
str(tmp_path),
stream_id,
[],
goal_related=False,
)
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
event_names = [item[0] for item in events]
assert "goal" not in event_names
assert "goal_continue" not in event_names
assert "done" in event_names
assert "stream_end" in event_names
assert has_goal_calls == []
assert judge_calls == []
assert s.session_id not in PENDING_GOAL_CONTINUATION
def test_gateway_chat_worker_normalizes_prefill_slice_before_system_prefix(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()