hermes: show recorded tool activity in resumed chats

This commit is contained in:
jenkins 2026-09-13 14:25:25 -05:00
parent 364d1b3503
commit 17d70759e0
2 changed files with 81 additions and 2 deletions

View File

@ -51,6 +51,33 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict
return entries[-1_000:] return entries[-1_000:]
def _present_activity_message(message: Dict[str, Any]) -> Dict[str, Any]:
"""Give a persisted tool-only assistant turn an honest visible summary.
Hermes deliberately stores a blank assistant ``content`` field when the
turn only invokes tools. The tool call records are retained separately,
but a transcript card with neither prose nor a summary looks like missing
history. This projection leaves the stored message intact and names only
the already-recorded tool functions.
"""
item = dict(message)
if item.get("role") != "assistant" or str(item.get("content") or "").strip():
return item
calls = item.get("tool_calls")
if not isinstance(calls, list):
return item
names: List[str] = []
for call in calls:
function = call.get("function") if isinstance(call, dict) else None
name = function.get("name") if isinstance(function, dict) else None
if isinstance(name, str) and name.strip():
names.append(name.strip()[:80])
if names:
item["content"] = "Calling " + ", ".join(names[:8]) + "."
item["observed"] = True
return item
@app.get("/api/sessions/{session_id}/messages") @app.get("/api/sessions/{session_id}/messages")
async def get_session_messages( async def get_session_messages(
session_id: str, session_id: str,
@ -64,8 +91,11 @@ MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
''' '''
MESSAGES_AFTER = ''' messages = [ MESSAGES_AFTER = ''' messages = [
*db.get_messages(sid), _present_activity_message(item)
*_run_activity_messages(sid), for item in [
*db.get_messages(sid),
*_run_activity_messages(sid),
]
] ]
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0)) messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
total_messages = len(messages) total_messages = len(messages)

View File

@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import importlib.util import importlib.util
import json import json
import sqlite3 import sqlite3
import textwrap
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -290,6 +292,53 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
assert 'item[0].get("ended_at") is None' in patched assert 'item[0].get("ended_at") is None' in patched
def test_activity_projection_preserves_user_text_and_labels_tool_only_turns():
"""A saved tool invocation cannot become a blank resumed-chat card."""
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
spec = importlib.util.spec_from_file_location("patch_web_activity_projection", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
helper = module.HELPER_REPLACEMENT.split('@app.get("/api/sessions/', 1)[0]
namespace: dict[str, object] = {}
exec("from typing import Any, Dict, List\n" + helper, namespace)
present = namespace["_present_activity_message"]
first_user = {"role": "user", "content": "Please inspect the saved work."}
second_user = {"role": "user", "content": "Continue from the recorded findings."}
assert present(first_user) == first_user
assert present(second_user) == second_user
tool_only = {
"role": "assistant",
"content": None,
# This is the parsed API shape for the serialized database column.
"tool_calls": [{"function": {"name": "terminal"}}],
}
projected = present(tool_only)
assert projected["content"] == "Calling terminal."
assert projected["observed"] is True
assert tool_only["content"] is None
class FakeDB:
def get_messages(self, session_id):
assert session_id == "resume-fixture"
return [first_user, tool_only, second_user]
response_source = "async def project_messages(db, sid, limit=None):\n" + textwrap.indent(
textwrap.dedent(module.MESSAGES_AFTER), " "
)
exec(response_source, namespace)
namespace["_run_activity_messages"] = lambda _session_id: []
payload = asyncio.run(namespace["project_messages"](FakeDB(), "resume-fixture"))
assert [item["content"] for item in payload["messages"]] == [
"Please inspect the saved work.",
"Calling terminal.",
"Continue from the recorded findings.",
]
assert payload["total_messages"] == 3
def test_web_session_lineage_returns_to_resumed_parent(): def test_web_session_lineage_returns_to_resumed_parent():
"""An ended reviewer must not strand the live view away from its parent.""" """An ended reviewer must not strand the live view away from its parent."""
module_path = HERMES / "scripts" / "patch_web_session_activity.py" module_path = HERMES / "scripts" / "patch_web_session_activity.py"