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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4478 feat(api-server): add X-Hermes-Session-Id header for session continuity

    merged to main

    files: gateway/platforms/api_server.py, tests/gateway/test_api_server.py

    Show 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

    merged to main

    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
  • Closed#3438[Feature]: Persist /v1/chat/completions sessions so Open WebUI chats appear in `hermes sessions list`
  • Closed#58518WebUI resume list can hide active CLI sessions before final output
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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#4478, #7127
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
1 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.