NousResearch/hermes-agent

WebUI streaming messages lost on session switch; related API transcript change

Cluster 34703. Data as of

Nothing open on GitHub. Fix merged in #34804 on 29 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #34804. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #34804. That is separate from the fix event.
Still open
Nothing open on GitHub.

Two members are duplicate reports of a WebUI bug where assistant streaming messages ending in a colon appear to be lost when switching session pages. A third member concerns emitting a per-turn transcript on run.completed in the api_server, and its relationship to the WebUI reports is not established by the titles.

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.

  • #34804 fix(api_server): emit per-turn transcript on run.completed (#34703)

    merged to main

    files: gateway/platforms/api_server.py, tests/gateway/test_session_api.py, website/docs/user-guide/features/code-execution.md

    Show the diff
    • gateway/platforms/api_server.py
      @@ -1605,6 +1605,7 @@ async def _run_and_signal() -> None:
                       )
                       final_response = result.get("final_response", "") if isinstance(result, dict) else ""
                       effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
      +                turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
                       await queue.put(_event_payload("assistant.completed", {
                           "session_id": effective_session_id,
                           "message_id": message_id,
      @@ -1617,6 +1618,7 @@ async def _run_and_signal() -> None:
                           "session_id": effective_session_id,
                           "message_id": message_id,
                           "completed": True,
      +                    "messages": turn_messages,
                           "usage": usage,
                       }))
                   except Exception as exc:
      @@ -3329,6 +3331,44 @@ def _response_messages_turn_start_index(
                   return len(prior)
               return 0
       
      +    @classmethod
      +    def _turn_transcript_messages(
      +        cls,
      +        conversation_history: List[Dict[str, Any]],
      +        user_message: Any,
      +        result: Dict[str, Any],
      +    ) -> List[Dict[str, Any]]:
      +        """Return this turn's assistant/tool messages in client-safe shape.
      +
      +        The streaming SSE contract delivers all assistant text as
      +        ``assistant.delta`` events under one ``message_id`` interleaved with
      +        ``tool.*`` events, and a single ``assistant.completed`` carrying only
      +        the final reply.  A client that accumulates deltas into one buffer
      +        cannot reconstruct *intermediate* assistant text segments that preceded
      +        tool calls — so when the page is re-opened mid/post-stream those
      +        segments appear lost, even though state.db persisted them correctly.
      +
      +        Emitting the authoritative per-turn transcript on ``run.completed`` lets
      +        any SSE consumer reconcile its live view against ground truth without a
      +        separate ``GET /messages`` round-trip.  Purely additive: clients that
      +        ignore the field are unaffected.  Refs #34703.
      +        """
      +        agent_messages = result.get("messages") if isinstance(result, dict) else None
      +        if not isinstance(agent_messages, list) or not agent_messages:
      +            return []
      +        start = cls._response_messages_turn_start_index(
      +            conversation_history, user_message, result
      +        )
      +        turn = agent_messages[start:]
      +        out: List[Dict[str, Any]] = []
      +        for msg in turn:
      +            if not isinstance(msg, dict):
      +                continue
      +            if msg.get("role") not in {"assistant", "tool"}:
      +                continue
      +            out.append(cls._message_response(msg))
      +        return out
      +
           @staticmethod
           def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
               """
    • tests/gateway/test_session_api.py
      @@ -268,6 +268,75 @@ async def fake_run(**kwargs):
           assert "event: done" in body
       
       
      [redacted:email]
      +async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
      +    """run.completed must include the full interleaved turn transcript so a
      +    client that lost intermediate (pre-tool-call) assistant text from the live
      +    delta stream can reconcile without a separate /messages fetch. Refs #34703.
      +    """
      +    import json as _json
      +
      +    session_id = session_db.create_session("transcript-session", "api_server")
      +
      +    async def fake_run(**kwargs):
      +        # Stream the intermediate planning text the way a real turn would.
      +        kwargs["stream_delta_callback"]("Let me search for that:")
      +        kwargs["stream_delta_callback"]("Here is the summary.")
      +        result = {
      +            "final_response": "Here is the summary.",
      +            "session_id": session_id,
      +            "messages": [
      +                {"role": "user", "content": "search then summarize"},
      +                {
      +                    "role": "assistant",
      +                    "content": "Let me search for that:",
      +                    "tool_calls": [
      +                        {
      +                            "id": "call_1",
      +                            "type": "function",
      +                            "function": {"name": "web_search", "arguments": "{}"},
      +                        }
      +                    ],
      +                },
      +                {"role": "tool", "content": "results", "tool_call_id": "call_1", "tool_name": "web_search"},
      +                {"role": "assistant", "content": "Here is the summary."},
      +            ],
      +        }
      +        return result, {"total_tokens": 6}
      +
      +    app = _create_session_app(adapter)
      +    with patch.object(adapter, "_run_agent", side_effect=fake_run):
      +        async with TestClient(TestServer(app)) as cli:
      +            resp = await cli.post(
      +                f"/api/sessions/{session_id}/chat/stream",
      +                json={"message": "search then summarize"},
      +            )
      +            assert resp.status == 200
      +            body = await resp.text()
      +
      +    # Pull the run.completed event payload out of the SSE body.
      +    run_completed_payload = None
      +    for block in body.split("\n\n"):
      +        if "event: run.completed" in block:
      +            for line in block.splitlines():
      +                if line.startswith("data: "):
      +                    run_completed_payload = _json.loads(line[len("data: "):])
      +            break
      +    assert run_completed_payload is not None, body
      +    messages = run_completed_payload.get("messages")
      +    assert isinstance(messages, list) and messages, run_completed_payload
      +
      +    # The colon-ended intermediate text that preceded the tool call must be present.
      +    contents = [m.get("content") for m in messages]
      +    assert "Let me search for that:" in contents
      +    assert "Here is the summary." in contents
      +    # No prior-turn user message should leak into the per-turn slice.
      +    assert all(m.get("role") in ("assistant", "tool") for m in messages)
      +    # The tool call is preserved alongside the intermediate text.
      +    assert any(m.get("tool_calls") for m in messages)
      +
      +
      +
       @pytest.mark.asyncio
       async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):
           app = _create_session_app(auth_adapter)
    • website/docs/user-guide/features/code-execution.md
      @@ -219,6 +219,62 @@ terminal:
       
       See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.
       
      +### `HERMES_*` variables in the child
      +
      +The child process receives only a small, fixed set of operational `HERMES_*`
      +variables by exact name:
      +
      +- `HERMES_HOME`
      +- `HERMES_PROFILE`
      +- `HERMES_CONFIG`
      +- `HERMES_ENV`
      +
      +(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes
      +injects explicitly so the RPC channel works).
      +
      +:::note Behavior change
      +Earlier versions passed **any** variable whose name began with `HERMES_`
      +through to the child. That broad prefix was removed for security hardening: it
      +could leak `HERMES_*`-named configuration that doesn't match a secret substring
      +(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`
      +endpoint) into arbitrary sandboxed code.
      +
      +If an `execute_code` script — or a repo/plugin module it imports at import time
      +— relied on a `HERMES_*` variable outside the four operational names above, it
      +will now find that variable **unset** in the child. The drop is intentional,
      +not a bug.
      +:::
      +
      +**Workaround — opt the variable back in explicitly.** Both routes pass the
      +variable through `execute_code` *and* `terminal` children, and neither weakens
      +the secret-stripping guarantee (Hermes-managed provider credentials can never
      +be re-allowed this way):
      +
      +1. **Per-machine, in `config.yaml`** — add the exact variable name to the
      +   passthrough allowlist:
      +
      +   ```yaml
      +   terminal:
      +     env_passthrough:
      +       - HERMES_KANBAN_DB
      +       - HERMES_BASE_URL
      +   ```
      +
      +2. **Per-skill, in the skill's frontmatter** — declare it so it is registered
      +   automatically whenever that skill is loaded:
      +
      +   ```yaml
      +   required_environment_variables:
      +     - HERMES_KANBAN_DB
      +   ```
      +
      +**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`
      +variables, Hermes emits a one-line `debug` log naming them and pointing at the
      +`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level
      +DEBUG`, or check `~/.hermes/logs/agent.log`) and look for
      +`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves
      +as though a `HERMES_*` variable is missing.
      +
       Hermes always writes the script and the auto-generated `hermes_tools.py` RPC stub into a temp staging directory that is cleaned up after execution. In `strict` mode the script also *runs* there; in `project` mode it runs in the session's working directory (the staging directory stays on `PYTHONPATH` so imports still resolve). The child process runs in its own process group so it can be cleanly killed on timeout or interruption.
       
       ## execute_code vs terminal
Closed issues · 2
  • Closed#34703[Bug]: WebUI: assistant colon-ended streaming messages lost when switching session pages
  • Closed#34705[Bug]: WebUI: assistant colon-ended streaming messages lost when switching session pages
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.45: 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

Merged PRs
#34804
Closed issues
2
Attempts
0 PRs closed without merging
Fix link checked
29 May 2026

For your agent

The same facts, typed.