Files
hermes-agent/agent/runtime_session_lifecycle.py
Teknium 792330dc91 fix(hooks): a blocked prompt is stored only as the withheld placeholder
A pre_llm_call veto kept the typed text as the user row's content and put
the placeholder only in api_content, which only the main-loop wire replay
reads. Compaction (summary request and handoff), session_search, external
memory providers and the routed background-review digest all read content,
so the vetoed prompt reached a model at the next compaction or search.

- The placeholder is now the row's content and the persist override; the
  typed text lives only in display_metadata.withheld_prompt, which the CLI
  resume recap, TUI/Desktop session history and the dashboard render.
- A row the TUI/Desktop wrote at submit time is rewritten in place
  (set_user_message_content gains an optional display_metadata, also on the
  managed-worker store op).
- The hook now runs before turn-start compaction and a blocked turn skips
  it: that summary request quotes recent user turns, this one included.
- Memory-provider transcript hand-offs drop the display-only key.
2026-10-31 22:59:20 -07:00

94 lines
4.7 KiB
Python

"""Explicit lifecycle methods for the assignment-bound worker store.
The owner reserves identity before construction. Closing the local store remains
separate from ending its session and settling execution (including deferred work).
"""
class RuntimeSessionLifecycleMixin:
def create_session(self, session_id, source, *, model=None, model_config=None,
system_prompt=None, user_id=None, session_key=None, chat_id=None,
chat_type=None, thread_id=None, parent_session_id=None, cwd=None,
profile_name=None, git_repo_root=None, origin_json=None, display_name=None):
self._session(session_id)
payload = dict(source=source, model=model, model_config=model_config, system_prompt=system_prompt,
user_id=user_id, session_key=session_key, chat_id=chat_id, chat_type=chat_type,
thread_id=thread_id, parent_session_id=parent_session_id, cwd=cwd,
profile_name=profile_name, git_repo_root=git_repo_root,
origin_json=origin_json, display_name=display_name)
return self._apply('session.create', payload)['value']
def set_session_title(self, session_id, title):
self._session(session_id)
return self._title_result(self._apply('session.title', {'title': title, 'source': 'user'}))
def set_auto_title(self, session_id, title, *, source):
if source not in ('derived', 'llm'):
raise ValueError(f'invalid automatic title source: {source!r}')
self._session(session_id)
return self._title_result(self._apply('session.title', {'title': title, 'source': source}))
@staticmethod
def _title_result(result):
if result.get('error'):
raise ValueError(result['error'])
return result['value']
def get_session_title_source(self, session_id):
row = self.get_session(session_id)
return row.get('title_source') if row.get('title') is not None else None
def set_session_title_source(self, session_id, source):
if source not in ('user', 'derived', 'llm'):
raise ValueError(f'invalid title source: {source!r}')
self._session(session_id)
return self._apply('session.title_source', {'source': source})['value']
def get_next_title_in_lineage(self, base_title):
return self._apply('session.next_title', {'base_title': base_title})['value']
def touch_session_activity(self, session_id, ts=None, *, description=None, provenance=None):
self._session(session_id)
self._apply('session.activity', {'ts': ts, 'description': description, 'provenance': provenance})
def clear_session_activity_labels(self, session_id):
self._session(session_id)
self._apply('session.activity_clear', {})
def update_session_billing_route(self, session_id, *, provider, base_url, billing_mode=None):
self._session(session_id)
# Every preceding usage call already owns a receipt. _apply refuses a
# pending or failed delta, so a route switch cannot overtake accounting.
self._apply('session.billing_route', {'provider': provider, 'base_url': base_url,
'billing_mode': billing_mode})
def set_latest_user_api_content(self, session_id, content, api_content):
self._session(session_id)
return self._apply('session.api_content', {'content': content, 'api_content': api_content})['value']
# The turn prologue's row-addressed backfills (agent/turn_context.py) for a user row another
# writer materialized first (in-place preflight compaction, an early flush, a submit-time row).
def set_message_api_content(self, session_id, row_id, content, api_content):
self._session(session_id)
return self._apply('session.message_api_content',
{'row_id': row_id, 'content': content, 'api_content': api_content})['value']
def set_user_message_content(self, session_id, row_id, content, display_metadata=None):
self._session(session_id)
payload = {'row_id': row_id, 'content': content}
if display_metadata is not None:
payload['display_metadata'] = display_metadata
return self._apply('session.user_content', payload)['value']
def end_session(self, session_id, end_reason):
self._session(session_id)
self._apply('session.end', {'end_reason': end_reason})
def session_lifecycle_statuses(self, session_ids):
ids = [sid for sid in (session_ids or []) if sid]
for sid in ids:
self._session(sid)
if not ids:
return {}
return {self.scope['session_id']: self._apply('session.lifecycle', {})['value']}