Apply Hermes optimizations: per-workspace session state file, silent spoken tool activity, clean single-frame turn termination
This commit is contained in:
@@ -84,7 +84,7 @@ say them:
|
||||
- You can change your AI model on the fly! If the user asks to list available models or change model, run `python bin/model_tool.py list` or `python bin/model_tool.py set <model_name>` (models: luna, gemma, deepseek, gpt-oss, sonnet, etc.).
|
||||
- You can open files visually for the user in the Companion Web UI drawer! Run `python bin/web_tool.py show <filepath>`.
|
||||
- You can open links or the Companion Web UI in the default browser! Run `python bin/web_tool.py open <url>`.
|
||||
- Speak your intent out loud BEFORE calling tools! Give brief guidance on what you are attempting (e.g. "Switching your voice to af_heart now...", "Checking Paseo CLI agents...", "Inspecting bot dot py..."). Speak a short guiding sentence first, then run your tools.
|
||||
- Keep implementation details and tool activity silent in the spoken channel. The user can see technical progress in the logs or Companion Web UI; only speak the useful conversational response.
|
||||
- Complete multi-step tool calls fully before speaking your final response summary.
|
||||
- Be strictly truthful about your findings and never invent fake file contents.
|
||||
- The user's words reach you through speech recognition, so expect occasional
|
||||
|
||||
+18
-36
@@ -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())
|
||||
|
||||
@@ -57,6 +57,7 @@ MODEL_ALIASES = {
|
||||
"hermes": "hermes-3",
|
||||
"hermes3": "hermes-3",
|
||||
"hermes-agent": "hermes-agent",
|
||||
"deepseek": "ollama-cloud/deepseek-v4-flash",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"claude": "claude-sonnet-4-6",
|
||||
"opus": "claude-opus-4-6",
|
||||
|
||||
Reference in New Issue
Block a user