Files
hermes-agent/gateway/turn_context.py
Teknium 6cc1efd8f9 feat(voice): auxiliary.voice_chat routes spoken voice turns to their own model
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:

- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
  voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
  preflight compaction were settled against the main model and undoes it before memory sync and
  background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
  one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
  recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
  delegations keep using the session model.
2026-10-07 01:35:11 -07:00

103 lines
4.9 KiB
Python

"""Per-turn context shared between ``GatewayRunner._run_agent_inner`` and ``TurnRunner``.
Each ex-closure local is a field, written once by ``_run_agent_inner`` while wiring the turn.
``message`` (ex-``nonlocal``) is the only rebindable field; other mutable state uses
single-element lists so mutation stays visible to the outer body.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional
@dataclass
class TurnContext:
# read-only turn identity / wiring
source: Any = None
reply_expected: Optional[bool] = None
# Scheduled heartbeats are proactive work, not replies to the source message that
# registered the watch. Their routine delivery surfaces stay quiet.
scheduled_heartbeat: bool = False
_run_still_current: Callable[[], bool] = None # type: ignore[assignment]
_live_status_adapter: Any = None
_live_status_mode: str = "off"
_thinking_enabled: bool = False
progress_mode: str = "off"
progress_grouping: str = "grouped"
tool_progress_enabled: bool = False
progress_queue: Any = None
log_queue: Any = None
# mutable single-element containers (shared with the outer body)
last_progress_msg: list = field(default_factory=lambda: [None])
last_tool: list = field(default_factory=lambda: [None])
last_was_terminal_block: list = field(default_factory=lambda: [False])
repeat_count: list = field(default_factory=lambda: [0])
long_tool_hint_fired: list = field(default_factory=lambda: [False])
agent_holder: list = field(default_factory=lambda: [None])
_LONG_TOOL_THRESHOLD_S: float = 30.0
_cleanup_progress: bool = False
_cleanup_msg_ids: List[str] = field(default_factory=list)
_progress_metadata: Optional[dict] = None
_progress_reply_to: Optional[Any] = None
message: Optional[str] = None # the only rebindable field
# turn parameters / config snapshots (read-only in run_sync)
history: Any = None
context_prompt: Optional[str] = None
channel_prompt: Optional[str] = None
session_id: Optional[str] = None
session_key: Optional[str] = None
run_generation: Optional[int] = None
process_task_id: str = ""
process_baseline: frozenset[str] = field(default_factory=frozenset)
_interrupt_depth: int = 0
event_message_id: Optional[str] = None
# Raw inbound platform id (not the event_message_id reply anchor); stamped on the user turn.
inbound_message_id: Optional[str] = None
moa_config: Optional[dict] = None
title_user_message: Optional[str] = None
persist_user_message: Optional[Any] = None
persist_user_timestamp: Optional[float] = None
# display_kind of the persisted user row for a self-injected turn; DB-only, never sent.
# "internal_notification" for async-delegation/background notifications (#82888).
persist_user_display_kind: Optional[str] = None
persist_user_display_metadata: Optional[dict] = None
user_config: Any = None
mute_notification_reply: bool = False
voice_turn: bool = False # a voice-note turn: runs on auxiliary.voice_chat
enabled_toolsets: Any = None
disabled_toolsets: Any = None
log_mode_enabled: bool = False
interim_assistant_messages_enabled: bool = False
needs_progress_queue: bool = False
AIAgent: Any = None
resolve_display_setting: Any = None
result_holder: list = field(default_factory=lambda: [None])
tools_holder: list = field(default_factory=lambda: [None])
stream_consumer_holder: list = field(default_factory=lambda: [None])
streaming_tts_consumer_holder: list = field(default_factory=lambda: [None])
# voice-ack wiring
_voice_ack_fired: list = field(default_factory=lambda: [False])
_voice_ack_guild: list = field(default_factory=lambda: [None])
_voice_ack_loop: Any = None
# hook / status bridge wiring
_loop_for_step: Any = None
_hooks_ref: Any = None
_status_adapter: Any = None
_status_chat_id: Any = None
_status_thread_metadata: Optional[dict] = None
# bound TurnRunner callbacks read via ctx
progress_callback: Optional[Callable] = None
voice_ack_callback: Optional[Callable] = None
_step_callback_sync: Optional[Callable] = None
_event_callback_sync: Optional[Callable] = None
_status_callback_sync: Optional[Callable] = None
# Slack-native task cards (opt-in); ID-bearing callbacks correlate start/complete by call ID
# --- Slack-native task-card progress (opt-in; #29483) ------------------ True when the Slack adapter's
# ``native_task_cards_enabled()`` opt-in is set for this turn's platform. The ID-bearing lifecycle
# callbacks are published by TurnRunner (like voice_ack_callback above) so tool starts and completions
# correlate by real tool-call ID instead of tool name.
_native_slack_task_cards: bool = False
native_tool_start_callback: Optional[Callable] = None
native_tool_complete_callback: Optional[Callable] = None