Files
Teknium 1d62bdcd35 fix(gateway): carry the committed surface into managed worker turns
Canonical admission commits surface_v1 (HUD / live voice / voice_turn) and the
owner binds it for in-process execution, but the managed-worker bootstrap
dropped it and the child called run_conversation without the agent's one-turn
inputs. A voice-live delegation reached the model without the spoken contract
or its voice_context, and a committed voice turn ran on the main model instead
of auxiliary.voice_chat, while both receipts completed.

- _bootstrap: the committed surface rides the per-turn hydrated request as
  turn_v1.surface_v1 (the closed BOOTSTRAP_FIELDS set is unchanged).
- worker_turn_scope: re-validates it child-side with the admission validator
  (session_surface.restore_surface -> admit_surface round-trip; anything
  admission could not have committed is invalid_managed_worker_bootstrap) and
  binds surface_turn_scope beside finite_turn_scope.
- session_surface.arm_surface_turn: ONE helper sets agent._voice_turn_pending
  and agent._gateway_turn_context_notes; run_turn_runner (in-process) and
  session_kanban.run_worker_turns (child) both call it, unconditionally, so a
  reused agent never replays a stale note or route.

Probe (real daemon, real managed child, loopback model, fake HOME), turns
voice-live+voice_context / voice_turn / plain:
  r6-base managed: VOICE note=False ctx=False; SPOKEN model=main-model
  fixed managed:   VOICE note=True ctx=True;   SPOKEN model=voice-model;
                   PLAIN main-model, no note (worker_executions=3)
  in-process control identical to fixed managed on both trees.

Tests (red on r6-base sources, green after):
- test_surface_admission::test_managed_worker_turn_carries_the_committed_surface_to_the_model
  r6-base: AssertionError: VOICE_LIVE_TURN_NOTE ... in 'VOICE_TURN'
- test_managed_turn_contract::test_committed_surface_crosses_the_bootstrap_validated_and_one_turn_only
  r6-base: AttributeError: 'types.SimpleNamespace' object has no attribute '_voice_turn_pending'
Adjacent: 47 files, 253 tests passed, 0 failed. scripts/check ok.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5461749257
2026-10-08 19:08:59 -07:00

113 lines
4.8 KiB
Python

"""Per-turn client surface (floating HUD, live voice) carried through canonical admission.
A surface is a fact about ONE turn, never about the session: the same session alternates
between the app window, the HUD and a spoken delegation. It is committed with the admission
row and reaches the model as a note on the user message (api_content sidecar), so the
persisted user row stays the words the user typed and the frozen system prefix is untouched.
"""
from contextlib import contextmanager
from contextvars import ContextVar
from hermes_state_runtime import RuntimeStoreError
SURFACES = frozenset({'hud', 'voice-live'})
_VOICE_CONTEXT_LIMIT = 6000
_SUBMIT_FIELDS = ('surface', 'voice_context', 'interrupted', 'voice_turn')
_surface_turn = ContextVar('surface_turn', default=None)
def submit_surface_fields(params):
"""The raw per-turn surface fields of a ``prompt.submit`` request, forwarded to admission."""
return {key: params[key] for key in _SUBMIT_FIELDS if key in params}
def admit_surface(params):
"""Wire ``surface`` / ``voice_context`` / ``interrupted`` / ``voice_turn`` -> committed ``surface_v1``
(``{}`` when absent). ``voice_turn`` marks a spoken voice-mode turn: it runs on ``auxiliary.voice_chat``."""
surface, context, interrupted, voice_turn = (params.get(key) for key in _SUBMIT_FIELDS)
if surface is not None and surface not in SURFACES:
raise RuntimeStoreError('invalid_params')
# The spoken transcript only makes sense for a live-voice delegation; anywhere else it is
# a client smuggling model input past the persisted row.
if context is not None and (surface != 'voice-live' or not isinstance(context, str)):
raise RuntimeStoreError('invalid_params')
if any(flag is not None and type(flag) is not bool for flag in (interrupted, voice_turn)):
raise RuntimeStoreError('invalid_params')
committed = {}
if surface:
committed['surface'] = surface
if context:
committed['voice_context'] = context[:_VOICE_CONTEXT_LIMIT]
if interrupted:
committed['interrupted'] = True
if voice_turn:
committed['voice_turn'] = True
return {'surface_v1': committed} if committed else {}
def restore_surface(committed):
"""A committed ``surface_v1`` arriving across a process boundary (managed worker), re-checked by
the admission validator: only an object admission itself could have committed is accepted."""
if (not isinstance(committed, dict) or not committed or set(committed) - set(_SUBMIT_FIELDS)
or admit_surface(committed).get('surface_v1', {}) != committed):
raise RuntimeStoreError('invalid_params')
return committed
def _hud_note(committed, valid_tool_names):
from agent.prompt_builder import hud_surface_note
return hud_surface_note(valid_tool_names)
def _voice_live_note(committed, valid_tool_names):
from tools.voice_live import voice_live_turn_note
return voice_live_turn_note(committed.get('voice_context') or '')
_SURFACE_NOTES = {'hud': _hud_note, 'voice-live': _voice_live_note}
def surface_note(committed, valid_tool_names=None):
"""The model-bound note for a committed surface (``""`` for the plain app window): barge-in
first, then the HUD read-the-window-below prior or the spoken-delegation contract."""
notes = []
if committed.get('interrupted'):
from tools.tts_streaming import SPEECH_INTERRUPTED_NOTE
notes.append(SPEECH_INTERRUPTED_NOTE)
render = _SURFACE_NOTES.get(committed.get('surface'))
if render is not None:
notes.append(render(committed, valid_tool_names))
return '\n\n'.join(note for note in notes if note)
@contextmanager
def surface_turn_scope(committed):
token = _surface_turn.set(committed)
try:
yield
finally:
_surface_turn.reset(token)
def surface_voice_turn():
"""Whether the executing admission is a spoken voice turn (``auxiliary.voice_chat`` route)."""
return bool((_surface_turn.get() or {}).get('voice_turn'))
def surface_turn_note(agent):
"""The executing admission's surface note, gated on the tools this agent actually has."""
committed = _surface_turn.get()
if not committed:
return ''
return surface_note(committed, getattr(agent, 'valid_tool_names', None))
def arm_surface_turn(agent, notes=(), voice_turn=False):
"""Project the executing admission's surface onto the agent's one-turn inputs, the same way in
process and in a managed worker: the ``auxiliary.voice_chat`` marker and the must-deliver notes
that ride the current user message (never the system prompt). Assigned unconditionally so a
reused agent never replays a stale note or route."""
agent._voice_turn_pending = bool(voice_turn) or surface_voice_turn()
agent._gateway_turn_context_notes = '\n\n'.join(
note for note in (*notes, surface_turn_note(agent)) if note)