"""Hermes voice profile — the read/recall agent (REQ-004/005/008). This is the ONLY bridge to Hermes/Qwen. It enforces the voice contract: reasoning **none**, tools = ``session_search`` only, bounded working context, multi-turn history. It never writes files, runs shell, or executes code (MUST-NOT-003). """ from __future__ import annotations import asyncio from dataclasses import dataclass, field from typing import AsyncIterator @dataclass class VoiceTurn: text: str # Populated when the voice profile performed a session_search call, so the # caller can log which session(s) were recalled (REQ-008). searched: str | None = None # --------------------------------------------------------------------------- # Voice profile — enforced, not aspirational (REQ-004 / AC-004) # --------------------------------------------------------------------------- class VoiceProfile: """Assertions live here so a bad config is caught at startup (AC-004).""" def __init__(self, reasoning: str, tools: list[str]): self.reasoning = reasoning self.tools = tools self._assert() def _assert(self) -> None: assert self.reasoning == "none", ( "Voice profile MUST be reasoning-none (REQ-004)." ) assert "session_search" in self.tools, ( "Voice profile requires the session_search tool (REQ-008)." ) # No write/exec tools are allowed (MUST-NOT-003). forbidden = {"terminal", "shell", "write_file", "edit_file", "code_execution"} leaked = forbidden & set(self.tools) assert not leaked, f"Voice profile leaked exec/write tools: {leaked}" # --------------------------------------------------------------------------- # Session — multi-turn, bounded context (REQ-005 / Phase 17) # --------------------------------------------------------------------------- class VoiceSession: def __init__(self, profile: VoiceProfile, max_turns: int = 12): self.profile = profile self.max_turns = max_turns self._history: list[tuple[str, str]] = [] # (user, assistant) def push(self, user: str, assistant: str) -> None: self._history.append((user, assistant)) # Bounded: keep only the most recent N turns (Phase 17). if len(self._history) > self.max_turns: self._history = self._history[-self.max_turns :] @property def history(self) -> list[tuple[str, str]]: return list(self._history) class HermesClient: """Transport to the local Qwen voice profile. ``mock`` is deterministic (tests). ``openai_http`` talks to a local Hermes/Qwen OpenAI-compatible endpoint (OQ-002 decides the exact surface). """ def __init__(self, cfg, profile: VoiceProfile): self.cfg = cfg self.profile = profile self._session: VoiceSession | None = None def session(self) -> VoiceSession: if self._session is None: self._session = VoiceSession( self.profile, self.cfg.max_history_turns ) return self._session async def respond( self, user_text: str, *, session_search_fn=None ) -> AsyncIterator[str]: """Yield the assistant answer as text chunks. ``session_search_fn(query)`` is injected by the caller (the session layer) so the profile can recall other sessions without this module importing the session-search implementation (keeps the transport testable). The mock transport never needs it. """ if self.cfg.transport == "mock": answer = f"รับทราบ: {user_text}" for ch in _chunks(answer, 8): yield ch await asyncio.sleep(0) return # openai_http — real path (kept minimal; OQ-002 finalizes surface). import httpx # lazy msgs = [ {"role": "system", "content": "You are the user's voice companion."}, *[ m for pair in self.session().history for m in ( {"role": "user", "content": pair[0]}, {"role": "assistant", "content": pair[1]}, ) ], {"role": "user", "content": user_text}, ] async with httpx.AsyncClient() as client: async with client.stream( "POST", f"{self.cfg.base_url}/chat/completions", json={ "messages": msgs, "stream": True, "reasoning": self.profile.reasoning, "tools": self.profile.tools, }, headers={"Authorization": f"Bearer {self.cfg.api_key}"}, ) as r: r.raise_for_status() async for line in r.aiter_lines(): if line.startswith("data: "): yield line[6:] def _chunks(s: str, n: int) -> list[str]: return [s[i : i + n] for i in range(0, len(s), n)] or [""]