Apply Hermes optimizations: per-workspace session state file, silent spoken tool activity, clean single-frame turn termination

This commit is contained in:
Adolfo Reyna
2026-08-09 21:46:12 -04:00
parent 2f181ff4f1
commit 84e20f8d81
3 changed files with 20 additions and 37 deletions
+18 -36
View File
@@ -26,7 +26,6 @@ from pipecat.frames.frames import (
LLMFullResponseStartFrame,
LLMTextFrame,
StartFrame,
TTSSpeakFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@@ -44,7 +43,7 @@ _NOISE_TRANSCRIPTS = {
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
SESSION_ID_REGEX = re.compile(r"\bsession_id:\s*([^\s]+)", re.IGNORECASE)
SESSION_STATE_FILE = Path.home() / ".hermes" / "voice-agent-session.json"
def _strip_ansi(text: str) -> str:
@@ -53,23 +52,6 @@ def _strip_ansi(text: str) -> str:
return ANSI_ESCAPE.sub("", text).strip()
def _get_tool_spoken_phrase(detail: str) -> str | None:
d = detail.lower()
if "grep" in d or "glob" in d or "search" in d:
return "Searching the codebase."
elif "read" in d or "view" in d or "inspect" in d:
return "Inspecting project files."
elif "top" in d or "mem" in d or "ps " in d or "ram" in d:
return "Checking system memory."
elif "paseo" in d:
return "Checking Paseo CLI agents."
elif "python" in d or "sh " in d or "bash" in d or "$" in d:
return "Running shell command."
elif "patch" in d or "edit" in d or "write" in d:
return "Updating project files."
return None
def _clean_spoken_text(text: str) -> str:
"""Clean text for speech output and truncate fake turn generations."""
if not text:
@@ -165,15 +147,20 @@ class HermesLLM(FrameProcessor):
self._use_server = use_server
self._session_renamed = False
# Keep the conversation lineage with the workspace. A single global
# session file can make two voice-agent workspaces resume each other's
# Hermes conversations.
self._session_state_file = self._cwd / ".hermes-voice-session.json"
# Persisted session ID
self._session_id: str | None = self._load_session_id()
if self._session_id:
logger.info(f"Loaded existing Hermes session ID: {self._session_id}")
def _load_session_id(self) -> str | None:
if SESSION_STATE_FILE.exists():
if self._session_state_file.exists():
try:
data = json.loads(SESSION_STATE_FILE.read_text())
data = json.loads(self._session_state_file.read_text())
sid = data.get("session_id")
if sid and isinstance(sid, str):
return sid.strip()
@@ -183,13 +170,13 @@ class HermesLLM(FrameProcessor):
def _save_session_id(self):
try:
SESSION_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
self._session_state_file.parent.mkdir(parents=True, exist_ok=True)
if self._session_id:
SESSION_STATE_FILE.write_text(
self._session_state_file.write_text(
json.dumps({"session_id": self._session_id}, indent=2) + "\n"
)
elif SESSION_STATE_FILE.exists():
SESSION_STATE_FILE.unlink()
elif self._session_state_file.exists():
self._session_state_file.unlink()
except Exception as e:
logger.warning(f"Could not save Hermes session state: {e}")
@@ -312,6 +299,10 @@ class HermesLLM(FrameProcessor):
else:
await self._run_turn_cli(utterance, chunks)
# The transport-specific runners may fall back from one to the other;
# emit exactly one response terminator for the whole turn.
await self.push_frame(LLMFullResponseEndFrame())
full_reply = _clean_spoken_text(" ".join(chunks))
if full_reply:
self._history.append({"role": "assistant", "content": full_reply})
@@ -359,9 +350,6 @@ class HermesLLM(FrameProcessor):
except Exception as e:
logger.warning(f"Hermes server error ({e}), falling back to CLI...")
await self._run_turn_cli(utterance, chunks)
finally:
await self.push_frame(LLMFullResponseEndFrame())
async def _run_turn_cli(self, utterance: str, chunks: list[str]):
"""Run turn via Hermes CLI using persistent session tracking."""
cmd = [self._cli_path, "chat", "-q", utterance, "-Q", "--source", "voice"]
@@ -382,8 +370,6 @@ class HermesLLM(FrameProcessor):
env=env,
)
spoken_tools = set()
async def _read_stderr(stream):
while True:
line = await stream.readline()
@@ -403,10 +389,8 @@ class HermesLLM(FrameProcessor):
except Exception:
pass
phrase = _get_tool_spoken_phrase(cleaned)
if phrase and phrase not in spoken_tools:
spoken_tools.add(phrase)
await self.push_frame(TTSSpeakFrame(phrase))
# Keep tool progress visible in logs and the Companion Web UI, but
# do not send implementation details through the spoken channel.
stderr_task = asyncio.create_task(_read_stderr(proc.stderr))
@@ -456,5 +440,3 @@ class HermesLLM(FrameProcessor):
err_msg = "Sorry, I ran into an error generating a response."
chunks.append(err_msg)
await self.push_frame(LLMTextFrame(err_msg))
finally:
await self.push_frame(LLMFullResponseEndFrame())