Files
hermes-agent/gateway/session_hosted_rpc.py
Teknium ef7984f056 fix(groups): a gateway-owned room says which tool a working member is in
groups.state member_activity carried tool/progress_at (1cd4f83) only for the
legacy in-process room path: the map is fed by
tui_gateway.hosted_room_member_activity.project_room_member_activity, which
sees tui_gateway session frames. A gateway-owned (canonical) room runs its
member turns on the gateway's canonical local-session path, whose frames go
SessionAuthority.publish_execution -> the member's SessionEvents stream and
never reach that projection. So the room showed "scout is working... 6s" while
Scout sat in a terminal tool.

Live diagnosis (real Electron + multiplexed gateway, mock inference): one
process (`hermes_cli.main --profile default gateway run`) answered groups.state
(CanonicalHostedRoomService.status), ran the member turn
(mute_notification_reply False, so tool callbacks are wired), published
tool.start/tool.complete name=terminal for the hidden member session, and
answered HostedRoomAuthorityRPC.info for it every active poll. Not a process
split and not a mute gate: the frames just never reach the room's map.

Design: the member side reports, the owner side records, over the info poll
the room driver already makes during a turn (no new polling, and the member
never opens the room owner's database, as session_hosted_transport requires):

- SessionEvents keeps the running execution's open tool calls and last frame
  time, fed by publish_execution after its generation fence (late callbacks
  from another generation move nothing).
- HostedRoomAuthorityRPC.info adds `tool` (newest open call, absent between
  tools) and `progress_at` for its current started hosted task. The same
  answer travels the private owner socket for another local profile's member.
- The driver hands every info answer to a new task_progress callback beside
  pending_action; HostedRoomService records it in the existing bounded map
  only when the info names the same task, and forgets the entry when the
  task's terminal is published. status() merges it as before.

The 1cd4f83 in-process feed stays: the legacy HostedRoomServerRPC path runs
turns in the process answering groups.state, its frames reach the projection
directly, and its info carries no progress fields, so the two feeds never
compete. No wire contract changes: RoomMemberActivity already declares
tool/progress_at and the hosted info verb is internal to the private socket.
2026-11-03 10:13:09 -08:00

344 lines
19 KiB
Python

"""Private hosted-room producer over the gateway's canonical local-session FIFO.
One instance belongs to a durable room/member/profile binding. The service supplies
its principal and current-membership check; neither is taken from RPC payloads.
"""
from __future__ import annotations
import asyncio
from dataclasses import asdict, replace
from functools import partial
import hashlib
import json
from gateway.hosted_room_driver import TaskIdentity
from gateway.owner_identity import same_owner
from gateway.session_contract import SessionRef, Submission
from hermes_state_runtime import RuntimeStoreError, list_session_admissions
_RESULTLESS_OUTCOMES = frozenset({'interrupted', 'cancelled'})
# Encoded receipts per ``results`` read; under the 512 KiB private-socket response line.
_RESULTS_BUDGET_BYTES = 384 * 1024
class HostedRoomAuthorityRPC:
accepts_attachments = True # submit resolves event-bound bytes per member
def __init__(self, authority, loop, *, room_id, member_id, profile, principal,
authorize, authorize_write=None, timeout=30):
self.authority, self.loop = authority, loop
self.room_id, self.member_id, self.profile = room_id, member_id, profile
self.principal, self.authorizer, self.timeout = principal, authorize, timeout
self.authorize_write = authorize_write
self.callbacks = {}
binding = json.dumps([room_id, member_id, profile], separators=(',', ':'))
self.creation_id = 'hosted:' + hashlib.sha256(binding.encode()).hexdigest()
from gateway.session_local_recovery import local_identity
self.ref = SessionRef(authority.profile_id, local_identity(
authority.profile_id, principal.subject, self.creation_id))
def _call(self, operation, **params):
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is self.loop:
raise RuntimeStoreError('invalid_params')
if not self.loop.is_running():
raise RuntimeStoreError('runtime_draining')
future = asyncio.run_coroutine_threadsafe(self._dispatch(operation, params), self.loop)
# A timeout is ambiguous: never cancel an admission or claim not_admitted.
return future.result(self.timeout)
async def _dispatch(self, operation, params):
from gateway.session_authorities import owner_scope
with owner_scope(self.authority):
return await self._dispatch_owned(operation, params)
async def _dispatch_owned(self, operation, params):
if (params.get('profile', self.profile) != self.profile
or self.principal.profile_id != self.authority.profile_id):
raise RuntimeStoreError('profile_mismatch')
if (params.get('source', 'bot_room') != 'bot_room'
or params.get('session_id', self.ref.session_id) != self.ref.session_id
or params.get('title', f'Group: {self.room_id}') != f'Group: {self.room_id}'):
raise RuntimeStoreError('permission_denied')
task, generation = params.get('task'), params.get('execution_generation')
if operation == 'submit' and (not isinstance(task, TaskIdentity)
or task.room_id != self.room_id or type(generation) is not int or generation < 1):
raise RuntimeStoreError('invalid_params')
if self.authorizer(operation, task, generation) is not True:
raise RuntimeStoreError('permission_denied')
return await getattr(self, '_' + operation)(params)
async def _resolve_exact(self, params):
if self.authority.db.get_session(self.ref.session_id) is None:
return None
return await self._resume(params)
async def _create(self, params):
if self.authority.db.get_session(self.ref.session_id) is not None:
return await self._resume(params)
from gateway.run import _load_gateway_config, _resolve_gateway_model
from gateway.session_policy import build_policy
from gateway.session_local import stage_local_session
from gateway.session_local_recovery import restore_local_session
from gateway.session_runtime_workers import tracked_write
from hermes_state_local import commit_local_session
from agent.skill_utils import parse_config_string_list
private = {}
config = _load_gateway_config()
# A room has no clarification reply (group controls expose approvals only), so a member
# turn that called clarify would block for clarify_timeout with its question unseen. As
# for cron, freeze it out; the member asks its question in the room reply instead.
agent = dict(config.get('agent') or {})
agent['disabled_toolsets'] = sorted(
set(parse_config_string_list(agent.get('disabled_toolsets'))) | {'clarify'})
policy = build_policy({'source': 'gui'}, {**config, 'agent': agent}, private_secrets=private)
policy = replace(policy, source='bot_room', platform='bot_room',
model=policy.model or _resolve_gateway_model(policy.config()),
toolsets=tuple(sorted(set(policy.toolsets) | {'bot_room'})))
staged = stage_local_session(self.authority, self.principal, {'request_id': self.creation_id},
trusted_policy=policy, trusted_secrets=private)
# Creation commit and label run off the owner loop (N90); a concurrent duplicate converges.
if isinstance(staged, dict):
await tracked_write(self.authority, partial(
commit_local_session, self.authority.db, epoch=self.authority.epoch, receipt=staged))
restore_local_session(self.authority, staged['session_id'])
from hermes_state_runtime import _epoch
def label(conn):
_epoch(conn, self.authority.epoch)
conn.execute('UPDATE sessions SET title=?, hidden=1 WHERE id=?',
(f'Group: {self.room_id}', self.ref.session_id))
await tracked_write(self.authority, partial(self.authority.db._execute_write, label))
return await self._resume(params)
async def _resume(self, params):
await self.authority.resolve(self.principal, self.ref)
return {'session_id': self.ref.session_id, 'title': f'Group: {self.room_id}'}
def _rows(self):
self.authority.authorize(self.principal, self.ref, 'session:read')
rows = list_session_admissions(self.authority.db, session_id=self.ref.session_id, pending_only=False)
result = []
for row in rows:
if not same_owner(row['principal_id'], self.principal.subject) or not row['request_id'].startswith('hosted:'):
continue
try:
task, generation = json.loads(row['request_id'][7:])
identity = TaskIdentity(**task)
if identity.room_id != self.room_id or type(generation) is not int:
raise ValueError('invalid hosted identity')
except (TypeError, ValueError, KeyError) as exc:
raise RuntimeStoreError('storage_unavailable') from exc
result.append((row, identity, generation))
return result
def _terminal(self, row, task, generation):
from gateway.session_results import admission_result
saved = admission_result(self.authority.db, row['admission_id'])
# Unknown discard and queued cancellation never ran: no result exists to recover.
if saved is None and row['outcome'] not in _RESULTLESS_OUTCOMES:
raise RuntimeStoreError('storage_unavailable')
value = saved['result'] if saved is not None else {}
status = {'completed': 'settled', 'interrupted': 'cancelled', 'cancelled': 'cancelled'}.get(row['outcome'], 'failed')
receipt = {'status': status, 'text': value.get('final_response', ''),
'message_id': row['admission_id'], 'settlement_id': row['admission_id'],
'task_id': task.task_id, 'execution_generation': generation}
callback = self.callbacks.pop(row['admission_id'], None)
if callback is not None:
callback(receipt)
return receipt
async def _submit(self, params):
task, generation = params['task'], params['execution_generation']
request_id = 'hosted:' + json.dumps([asdict(task), generation], sort_keys=True, separators=(',', ':'))
# Refuse unknown before submit: submit itself schedules the queue on retries.
rows = self._rows()
if any(row['status'] == 'unknown' for row, _, _ in rows):
raise RuntimeStoreError('unknown_execution')
from gateway.session_hosted_attachments import prepare_before_submit, submission_payload
from gateway.session_ingress_media import capture_lease
# One lease from capture through the admission commit: documents ride in the prompt text,
# so submit's own lease does not hold them, and an older row's release could unlink a
# shared content-addressed capture in between.
with capture_lease():
payload = await asyncio.to_thread(prepare_before_submit, partial(
submission_payload, self, params['prompt'], params.get('attachments')))
authorize_write = self.authorize_write
receipt = await self.authority.submit(self.principal, Submission(
request_id, self.ref, payload, 'queue'),
_authorize_write=(lambda conn: authorize_write(conn, task, generation))
if authorize_write is not None else None)
self.callbacks[receipt.admission_id] = params['on_terminal']
if receipt.status in {'queued', 'started'}:
waiter = self.authority.waiters.get(receipt.admission_id)
if waiter is None:
waiter = self.loop.create_future()
self.authority.waiters[receipt.admission_id] = waiter
waiter.add_done_callback(lambda done: self._completed(receipt.admission_id, done))
if receipt.status == 'terminal':
for row, identity, gen in self._rows():
if row['admission_id'] == receipt.admission_id:
self._terminal(row, identity, gen)
return asdict(receipt)
def _completed(self, admission_id, future):
if future.cancelled() or admission_id not in self.callbacks:
return
if future.exception() is not None:
# A paused FIFO remains queued/unknown; polling or an explicit
# resolution will publish its eventual durable terminal outcome.
return
if self.authorizer('terminal', None, None) is not True:
self.callbacks.pop(admission_id, None)
return
for row, task, generation in self._rows():
if row['admission_id'] == admission_id and row['status'] == 'terminal':
self._terminal(row, task, generation)
async def _history(self, params):
from gateway.session_local_recovery import local_history, prepare_history
# Authorize (restoring a cold room session) before the read names its live entry, and again after
# the await (``_rows``): a delete in between answers not_found, never a KeyError.
self.authority.authorize(self.principal, self.ref, 'session:read')
prepared = await prepare_history(self.authority, self.ref)
rows = self._rows()
history = self.authority.unless_deleted(
self.ref, lambda: list(local_history(self.authority, self.ref, prepared)))
for row, task, generation in rows:
if row['status'] == 'terminal':
receipt = self._terminal(row, task, generation)
history.append({**receipt, 'role': 'assistant', 'content': receipt['text']})
return history
async def _results(self, params):
"""Terminal receipts only, never the transcript: one bounded response line.
``admission_ids`` narrows the read to the caller's pending callbacks. Each text
keeps just over the driver's persisted bound, so its own truncation (and notice)
is unchanged; newest receipts win the byte budget and the rest follow next poll.
"""
wanted = params.get('admission_ids')
if wanted is not None and (not isinstance(wanted, list) or len(wanted) > 256
or not all(isinstance(i, str) and i for i in wanted)):
raise RuntimeStoreError('invalid_params')
from tui_gateway.hosted_room_driver import MAX_TERMINAL_TEXT_BYTES
receipts, used = [], 0
for row, task, generation in reversed(self._rows()):
if row['status'] != 'terminal' or (wanted is not None and row['admission_id'] not in wanted):
continue
receipt = self._terminal(row, task, generation)
raw = receipt['text'].encode('utf-8')
if len(raw) > MAX_TERMINAL_TEXT_BYTES:
receipt['text'] = raw[:MAX_TERMINAL_TEXT_BYTES + 4].decode('utf-8', 'ignore')
used += len(json.dumps(receipt))
if receipts and used > _RESULTS_BUDGET_BYTES:
break
receipts.append(receipt)
return receipts[::-1]
async def _info(self, params):
rows = self._rows()
for row, task, generation in rows:
if row['status'] == 'terminal':
self._terminal(row, task, generation)
current = next(((row, task, generation) for row, task, generation in rows if row['status'] in {'started', 'unknown', 'queued'}), None)
result = {'active': current is not None and current[0]['status'] != 'unknown',
'task_id': current[1].task_id if current else None,
'execution_generation': current[2] if current else None,
'status': current[0]['status'] if current else 'idle'}
live = self.authority.sessions.get(self.ref.session_id)
if current is not None and current[0]['status'] == 'started' and live is not None:
# The member's current tool and last frame, for the room's "scout · terminal · 7s".
result.update(live.event_stream.progress_for(current[0]['generation']))
snapshot = await self.authority.attach(self.principal, self.ref, history=False) # prompts only
if snapshot.prompts:
prompt = next((p for p in snapshot.prompts if p.get('kind') == 'approval'), None)
if prompt:
result.update(status='waiting_for_approval', pending_approval={
**prompt, 'request_id': prompt['prompt_id'],
'choices': [c for c in prompt['choices'] if c in {'once', 'deny'}]})
return result
async def _interrupt(self, params):
rows = self._rows()
current = next(((row, task) for row, task, _ in rows if row['status'] in {'started', 'unknown', 'queued'}), None)
if current is None or current[1].task_id != params['expected_task_id']:
raise RuntimeStoreError('stale_generation')
row, _ = current
if row['status'] == 'unknown':
raise RuntimeStoreError('unknown_execution')
if row['status'] == 'queued':
row = await self.authority.cancel_or_claimed(self.principal, self.ref, row['admission_id'])
if row is not None:
await self.authority.interrupt(self.principal, self.ref, row['generation'])
return {'interrupted': True, 'status': 'interrupted'}
async def _discard(self, params):
generation = params['execution_generation']
if type(generation) is not int or generation < 1:
raise RuntimeStoreError('invalid_params')
matches = [(row, task) for row, task, hosted_generation in self._rows()
if (row['status'] == 'unknown' or (row['status'] == 'terminal' and row['outcome'] == 'interrupted'))
and task.task_id == params['expected_task_id']
and hosted_generation == generation]
if len(matches) != 1:
raise RuntimeStoreError('stale_generation')
row, task = matches[0]
# The public fence is hosted; the canonical CAS uses its own generation.
if row['status'] == 'unknown':
await self.authority.resolve_unknown(
self.principal, self.ref, row['admission_id'], row['generation'])
return {'discarded': True, 'status': 'cancelled', 'task_id': task.task_id,
'execution_generation': generation}
async def _approve(self, params):
if params['choice'] not in {'once', 'deny'}:
raise RuntimeStoreError('invalid_params')
snapshot = await self.authority.attach(self.principal, self.ref, history=False) # prompts only
prompt = next((p for p in snapshot.prompts if p.get('prompt_id') == params['request_id']
and p.get('kind') == 'approval'), None)
if prompt is None:
raise RuntimeStoreError('stale_generation')
return await self.authority.respond(self.principal, self.ref,
snapshot.handle.execution_generation, params['request_id'], {'choice': params['choice']})
def resolve_exact(self, *, profile, title, source):
return self._call('resolve_exact', profile=profile, title=title, source=source)
def create(self, *, profile, title, source):
return self._call('create', profile=profile, title=title, source=source)
def resume(self, *, profile, session_id, source):
return self._call('resume', profile=profile, session_id=session_id, source=source)
def submit(self, *, profile, session_id, prompt, source, task, execution_generation, on_terminal,
member_id=None, attachments=None):
# One producer per member binding: a task for another member reached the wrong producer.
if member_id is not None and member_id != self.member_id:
raise RuntimeStoreError('permission_denied')
return self._call('submit', profile=profile, session_id=session_id, source=source,
prompt=prompt, task=task, execution_generation=execution_generation, on_terminal=on_terminal,
attachments=attachments)
def history(self, *, profile, session_id, source):
return self._call('history', profile=profile, session_id=session_id, source=source)
def results(self, *, profile, session_id, source, admission_ids=None):
return self._call('results', profile=profile, session_id=session_id, source=source,
admission_ids=admission_ids)
def info(self, *, profile, session_id, source):
return self._call('info', profile=profile, session_id=session_id, source=source)
def interrupt(self, *, profile, session_id, source, expected_task_id):
return self._call('interrupt', profile=profile, session_id=session_id, source=source, expected_task_id=expected_task_id)
def discard(self, *, profile, session_id, source, expected_task_id, execution_generation):
return self._call('discard', profile=profile, session_id=session_id, source=source,
expected_task_id=expected_task_id, execution_generation=execution_generation)
def approve(self, *, session_id, request_id, choice):
return self._call('approve', session_id=session_id, request_id=request_id, choice=choice)