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 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 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>`.
|
- 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.
|
- Complete multi-step tool calls fully before speaking your final response summary.
|
||||||
- Be strictly truthful about your findings and never invent fake file contents.
|
- Be strictly truthful about your findings and never invent fake file contents.
|
||||||
- The user's words reach you through speech recognition, so expect occasional
|
- The user's words reach you through speech recognition, so expect occasional
|
||||||
|
|||||||
+18
-36
@@ -26,7 +26,6 @@ from pipecat.frames.frames import (
|
|||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSSpeakFrame,
|
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ _NOISE_TRANSCRIPTS = {
|
|||||||
|
|
||||||
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||||||
SESSION_ID_REGEX = re.compile(r"\bsession_id:\s*([^\s]+)", re.IGNORECASE)
|
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:
|
def _strip_ansi(text: str) -> str:
|
||||||
@@ -53,23 +52,6 @@ def _strip_ansi(text: str) -> str:
|
|||||||
return ANSI_ESCAPE.sub("", text).strip()
|
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:
|
def _clean_spoken_text(text: str) -> str:
|
||||||
"""Clean text for speech output and truncate fake turn generations."""
|
"""Clean text for speech output and truncate fake turn generations."""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -165,15 +147,20 @@ class HermesLLM(FrameProcessor):
|
|||||||
self._use_server = use_server
|
self._use_server = use_server
|
||||||
self._session_renamed = False
|
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
|
# Persisted session ID
|
||||||
self._session_id: str | None = self._load_session_id()
|
self._session_id: str | None = self._load_session_id()
|
||||||
if self._session_id:
|
if self._session_id:
|
||||||
logger.info(f"Loaded existing Hermes session ID: {self._session_id}")
|
logger.info(f"Loaded existing Hermes session ID: {self._session_id}")
|
||||||
|
|
||||||
def _load_session_id(self) -> str | None:
|
def _load_session_id(self) -> str | None:
|
||||||
if SESSION_STATE_FILE.exists():
|
if self._session_state_file.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(SESSION_STATE_FILE.read_text())
|
data = json.loads(self._session_state_file.read_text())
|
||||||
sid = data.get("session_id")
|
sid = data.get("session_id")
|
||||||
if sid and isinstance(sid, str):
|
if sid and isinstance(sid, str):
|
||||||
return sid.strip()
|
return sid.strip()
|
||||||
@@ -183,13 +170,13 @@ class HermesLLM(FrameProcessor):
|
|||||||
|
|
||||||
def _save_session_id(self):
|
def _save_session_id(self):
|
||||||
try:
|
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:
|
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"
|
json.dumps({"session_id": self._session_id}, indent=2) + "\n"
|
||||||
)
|
)
|
||||||
elif SESSION_STATE_FILE.exists():
|
elif self._session_state_file.exists():
|
||||||
SESSION_STATE_FILE.unlink()
|
self._session_state_file.unlink()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not save Hermes session state: {e}")
|
logger.warning(f"Could not save Hermes session state: {e}")
|
||||||
|
|
||||||
@@ -312,6 +299,10 @@ class HermesLLM(FrameProcessor):
|
|||||||
else:
|
else:
|
||||||
await self._run_turn_cli(utterance, chunks)
|
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))
|
full_reply = _clean_spoken_text(" ".join(chunks))
|
||||||
if full_reply:
|
if full_reply:
|
||||||
self._history.append({"role": "assistant", "content": full_reply})
|
self._history.append({"role": "assistant", "content": full_reply})
|
||||||
@@ -359,9 +350,6 @@ class HermesLLM(FrameProcessor):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Hermes server error ({e}), falling back to CLI...")
|
logger.warning(f"Hermes server error ({e}), falling back to CLI...")
|
||||||
await self._run_turn_cli(utterance, chunks)
|
await self._run_turn_cli(utterance, chunks)
|
||||||
finally:
|
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
|
||||||
|
|
||||||
async def _run_turn_cli(self, utterance: str, chunks: list[str]):
|
async def _run_turn_cli(self, utterance: str, chunks: list[str]):
|
||||||
"""Run turn via Hermes CLI using persistent session tracking."""
|
"""Run turn via Hermes CLI using persistent session tracking."""
|
||||||
cmd = [self._cli_path, "chat", "-q", utterance, "-Q", "--source", "voice"]
|
cmd = [self._cli_path, "chat", "-q", utterance, "-Q", "--source", "voice"]
|
||||||
@@ -382,8 +370,6 @@ class HermesLLM(FrameProcessor):
|
|||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
spoken_tools = set()
|
|
||||||
|
|
||||||
async def _read_stderr(stream):
|
async def _read_stderr(stream):
|
||||||
while True:
|
while True:
|
||||||
line = await stream.readline()
|
line = await stream.readline()
|
||||||
@@ -403,10 +389,8 @@ class HermesLLM(FrameProcessor):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
phrase = _get_tool_spoken_phrase(cleaned)
|
# Keep tool progress visible in logs and the Companion Web UI, but
|
||||||
if phrase and phrase not in spoken_tools:
|
# do not send implementation details through the spoken channel.
|
||||||
spoken_tools.add(phrase)
|
|
||||||
await self.push_frame(TTSSpeakFrame(phrase))
|
|
||||||
|
|
||||||
stderr_task = asyncio.create_task(_read_stderr(proc.stderr))
|
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."
|
err_msg = "Sorry, I ran into an error generating a response."
|
||||||
chunks.append(err_msg)
|
chunks.append(err_msg)
|
||||||
await self.push_frame(LLMTextFrame(err_msg))
|
await self.push_frame(LLMTextFrame(err_msg))
|
||||||
finally:
|
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ MODEL_ALIASES = {
|
|||||||
"hermes": "hermes-3",
|
"hermes": "hermes-3",
|
||||||
"hermes3": "hermes-3",
|
"hermes3": "hermes-3",
|
||||||
"hermes-agent": "hermes-agent",
|
"hermes-agent": "hermes-agent",
|
||||||
|
"deepseek": "ollama-cloud/deepseek-v4-flash",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"claude": "claude-sonnet-4-6",
|
"claude": "claude-sonnet-4-6",
|
||||||
"opus": "claude-opus-4-6",
|
"opus": "claude-opus-4-6",
|
||||||
|
|||||||
Reference in New Issue
Block a user