API session persistence and cross-client visibility
NousResearch/hermes-agent · cluster 3438 · gateway
Data as of
Nothing open on GitHub. Fix merged in #4478. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #4478. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #7127. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address persisting chat sessions created via the /v1/chat/completions API endpoint and surfacing them consistently across clients (WebUI and CLI), including session ID headers and shared session storage.
Open issues0
Open PRs0
Fixes on record
-
#4478 feat(api-server): add X-Hermes-Session-Id header for session continuity
files:
gateway/platforms/api_server.py,tests/gateway/test_api_server.pyShow the diff
-
gateway/platforms/api_server.py@@ -2,7 +2,7 @@ OpenAI-compatible API server platform adapter. Exposes an HTTP server with endpoints: -- POST /v1/chat/completions — OpenAI Chat Completions format (stateless) +- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header) - POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id) - GET /v1/responses/{response_id} — Retrieve a stored response - DELETE /v1/responses/{response_id} — Delete a stored response @@ -300,6 +300,7 @@ def __init__(self, config: PlatformConfig): self._runner: Optional["web.AppRunner"] = None self._site: Optional["web.TCPSite"] = None self._response_store = ResponseStore() + self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity @staticmethod def _parse_cors_origins(value: Any) -> tuple[str, ...]: @@ -496,7 +497,23 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons status=400, ) - session_id = str(uuid.uuid4()) + # Allow caller to continue an existing session by passing X-Hermes-Session-Id. + # When provided, history is loaded from state.db instead of from the request body. + provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip() + if provided_session_id: + session_id = provided_session_id + try: + if self._session_db is None: + from hermes_state import SessionDB + self._session_db = SessionDB() + history = self._session_db.get_messages_as_conversation(session_id) + except Exception as e: + logger.warning("Failed to load session history for %s: %s", session_id, e) + history = [] + else: + session_id = str(uuid.uuid4()) + # history already set from request body above + completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}" model_name = body.get("model", "hermes-agent") created = int(time.time()) @@ -540,7 +557,7 @@ def _on_tool_progress(name, preview, args): return await self._write_sse_chat_completion( request, completion_id, model_name, created, _stream_q, - agent_task, agent_ref, + agent_task, agent_ref, session_id=session_id, ) # Non-streaming: run the agent (with optional Idempotency-Key) @@ -599,11 +616,11 @@ async def _compute_completion(): }, } - return web.json_response(response_data) + return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id}) async def _write_sse_chat_completion( self, request: "web.Request", completion_id: str, model: str, - created: int, stream_q, agent_task, agent_ref=None, + created: int, stream_q, agent_task, agent_ref=None, session_id: str = None, ) -> "web.StreamResponse": """Write real streaming SSE from agent's stream_delta_callback queue. @@ -620,6 +637,8 @@ async def _write_sse_chat_completion( cors = self._cors_headers_for_origin(origin) if origin else None if cors: sse_headers.update(cors) + if session_id: + sse_headers["X-Hermes-Session-Id"] = session_id response = web.StreamResponse(status=200, headers=sse_headers) await response.prepare(request) -
tests/gateway/test_api_server.py@@ -1576,3 +1576,110 @@ async def test_conversation_store_false_no_mapping(self, adapter): assert resp.status == 200 # Conversation mapping should NOT be set since store=false assert adapter._response_store.get_conversation("ephemeral-chat") is None + + +# --------------------------------------------------------------------------- +# X-Hermes-Session-Id header (session continuity) +# --------------------------------------------------------------------------- + + +class TestSessionIdHeader: + @pytest.mark.asyncio + async def test_new_session_response_includes_session_id_header(self, adapter): + """Without X-Hermes-Session-Id, a new session is created and returned in the header.""" + mock_result = {"final_response": "Hello!", "messages": [], "api_calls": 1} + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/chat/completions", + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]}, + ) + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Session-Id") is not None + + @pytest.mark.asyncio + async def test_provided_session_id_is_used_and_echoed(self, adapter): + """When X-Hermes-Session-Id is provided, it's passed to the agent and echoed in the response.""" + mock_result = {"final_response": "Continuing!", "messages": [], "api_calls": 1} + mock_db = MagicMock() + mock_db.get_messages_as_conversation.return_value = [ + {"role": "user", "content": "previous message"}, + {"role": "assistant", "content": "previous reply"}, + ] + adapter._session_db = mock_db + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Hermes-Session-Id": "my-session-123"}, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Continue"}]}, + ) + + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Session-Id") == "my-session-123" + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["session_id"] == "my-session-123" + + @pytest.mark.asyncio + async def test_provided_session_id_loads_history_from_db(self, adapter): + """When X-Hermes-Session-Id is provided, history comes from SessionDB not request body.""" + mock_result = {"final_response": "OK", "messages": [], "api_calls": 1} + db_history = [ + {"role": "user", "content": "stored message 1"}, + {"role": "assistant", "content": "stored reply 1"}, + ] + mock_db = MagicMock() + mock_db.get_messages_as_conversation.return_value = db_history + adapter._session_db = mock_db + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Hermes-Session-Id": "existing-session"}, + # Request body has different history — should be ignored + json={ + "model": "hermes-agent", + "messages": [ + {"role": "user", "content": "old msg from client"}, + {"role": "assistant", "content": "old reply from client"}, + {"role": "user", "content": "new question"}, + ], + }, + ) + + assert resp.status == 200 + call_kwargs = mock_run.call_args.kwargs + # History must come from DB, not from the request body + assert call_kwargs["conversation_history"] == db_history + assert call_kwargs["user_message"] == "new question" + + @pytest.mark.asyncio + async def test_db_failure_falls_back_to_empty_history(self, adapter): + """If SessionDB raises, history falls back to empty and request still succeeds.""" + mock_result = {"final_response": "OK", "messages": [], "api_calls": 1} + # Simulate DB failure: _session_db is None and SessionDB() constructor raises + adapter._session_db = None + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \ + patch("hermes_state.SessionDB", side_effect=Exception("DB unavailable")): + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Hermes-Session-Id": "some-session"}, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]}, + ) + + assert resp.status == 200 + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["conversation_history"] == [] + assert call_kwargs["session_id"] == "some-session"
-
-
#7127 fix(api-server): share one Docker container across all API conversations
files:
gateway/platforms/api_server.py,tests/gateway/test_api_server.py
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#3439 feat(api_server): persist /v1/chat/completions sessions via X-Chat-ID header · superseded by another PR →
#4478
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab