hermes: show recorded tool activity in resumed chats
This commit is contained in:
parent
364d1b3503
commit
17d70759e0
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user