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:]
|
||||
|
||||
|
||||
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")
|
||||
async def get_session_messages(
|
||||
session_id: str,
|
||||
@ -64,8 +91,11 @@ MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
|
||||
'''
|
||||
|
||||
MESSAGES_AFTER = ''' messages = [
|
||||
*db.get_messages(sid),
|
||||
*_run_activity_messages(sid),
|
||||
_present_activity_message(item)
|
||||
for item in [
|
||||
*db.get_messages(sid),
|
||||
*_run_activity_messages(sid),
|
||||
]
|
||||
]
|
||||
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
|
||||
total_messages = len(messages)
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
"""An ended reviewer must not strand the live view away from its parent."""
|
||||
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user