Files
hermes-agent/hermes_state_worker_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

289 lines
15 KiB
Python

"""Closed worker lifecycle handlers, called only inside the receipt transaction."""
import json
import time
from hermes_state_runtime import RuntimeStoreError
_IDENTITY_FIELDS = frozenset({
'user_id', 'session_key', 'chat_id', 'chat_type', 'thread_id',
'parent_session_id', 'cwd', 'profile_name', 'git_repo_root', 'origin_json', 'display_name',
})
_CREATE_FIELDS = _IDENTITY_FIELDS | {'source', 'model', 'model_config', 'system_prompt'}
_CONSTRUCTOR_CONFIG = frozenset({'max_iterations', 'max_tokens', 'reasoning_config', 'yolo_mode'})
def worker_create(db, conn, session_id, payload):
if set(payload) - _CREATE_FIELDS or 'source' not in payload:
raise RuntimeStoreError('invalid_params')
row = conn.execute('SELECT * FROM sessions WHERE id=?', (session_id,)).fetchone()
# A worker cannot mint a row or supply unreserved routing, lineage or cwd.
if row is None or payload['source'] != row['source']:
raise RuntimeStoreError('permission_denied')
for key in _IDENTITY_FIELDS:
value = payload.get(key)
if value is not None and value != row[key]:
raise RuntimeStoreError('permission_denied')
for key in _CREATE_FIELDS - {'model_config'}:
if payload.get(key) is not None and not isinstance(payload[key], str):
raise RuntimeStoreError('invalid_params')
config = payload.get('model_config')
if config is not None and (not isinstance(config, dict) or set(config) - _CONSTRUCTOR_CONFIG):
raise RuntimeStoreError('invalid_params')
insert_session_row_in_transaction(db, conn, session_id, **payload)
if config:
# An owner-reserved child row already holds its lineage marker, so the keep-existing upsert
# would drop these facts; merge them beside it (keys are the constructor set only).
conn.execute("UPDATE sessions SET model_config=json_patch(model_config,?) WHERE id=? "
"AND json_type(model_config,'$._delegate_from') IS NOT NULL", (json.dumps(config), session_id))
return {'value': session_id}
def worker_end(db, conn, session_id, payload):
if set(payload) != {'end_reason'} or not isinstance(payload['end_reason'], str) or not payload['end_reason']:
raise RuntimeStoreError('invalid_params')
db._end_and_bump(conn,
'UPDATE sessions SET ended_at=?,end_reason=? WHERE id=? AND ended_at IS NULL',
(time.time(), payload['end_reason'], session_id), session_id, payload['end_reason'])
return {'value': None}
def worker_lifecycle(db, conn, session_id, payload):
from hermes_state_sessions import classify_session_status
if payload:
raise RuntimeStoreError('invalid_params')
row = conn.execute("SELECT role,tool_calls IS NOT NULL AS has_tool_calls,finish_reason "
"FROM messages WHERE session_id=? ORDER BY id DESC LIMIT 1", (session_id,)).fetchone()
value = classify_session_status(role=row['role'], has_tool_calls=bool(row['has_tool_calls']),
finish_reason=row['finish_reason']) if row else 'empty'
return {'value': value}
def worker_title(db, conn, session_id, payload):
if (set(payload) != {'title', 'source'} or payload['source'] not in ('user', 'derived', 'llm')
or not (payload['title'] is None or isinstance(payload['title'], str))):
raise RuntimeStoreError('invalid_params')
try:
value = bool(db._set_session_title_in_transaction(
conn, session_id, payload['title'], source=payload['source']))
except ValueError:
# Expected validation/conflict outcomes must not poison the retry journal.
# Keep candidate authority in the receipt, not an in-memory worker claim.
return {'value': False, 'error': 'Title unavailable or invalid', 'title_candidate': payload['title']}
return {'value': value}
def worker_title_source(db, conn, session_id, payload):
if set(payload) != {'source'} or payload['source'] not in ('user', 'derived', 'llm'):
raise RuntimeStoreError('invalid_params')
return {'value': conn.execute(
'UPDATE sessions SET title_source=? WHERE id=? AND title IS NOT NULL',
(payload['source'], session_id)).rowcount > 0}
def worker_next_title(db, conn, session_id, payload):
from hermes_state_titles import _NUMBERED_TITLE_RE
from hermes_state_common import escape_like
if set(payload) != {'base_title'} or not isinstance(payload['base_title'], str):
raise RuntimeStoreError('invalid_params')
title = conn.execute('SELECT title FROM sessions WHERE id=?', (session_id,)).fetchone()[0]
# Only an assigned title or a receipted title attempt authorizes dedupe.
# Concurrent activity/usage receipts must not invalidate that title attempt.
candidate = payload['base_title']
attempted = conn.execute(
"SELECT 1 FROM worker_receipts r JOIN worker_executions w ON w.execution_id=r.execution_id "
"WHERE w.session_id=? AND w.status='running' "
"AND json_extract(r.result_json,'$.title_candidate')=? LIMIT 1",
(session_id, candidate)).fetchone()
if candidate != title and attempted is None:
raise RuntimeStoreError('permission_denied')
match = _NUMBERED_TITLE_RE.match(candidate)
base = match.group(1) if match else candidate
rows = conn.execute("SELECT title FROM sessions WHERE title=? OR title LIKE ? ESCAPE '\\'",
(base, f'{escape_like(base)} #%')).fetchall()
if not rows:
return {'value': base}
numbers = [int(m.group(2)) for m in (_NUMBERED_TITLE_RE.match(row['title']) for row in rows) if m]
return {'value': f'{base} #{max([1, *numbers]) + 1}'}
def worker_activity(db, conn, session_id, payload):
import math
from agent.session_activity import bound_activity_description, normalize_activity_provenance
if (set(payload) != {'ts', 'description', 'provenance'}
or any(payload[k] is not None and not isinstance(payload[k], str)
for k in ('description', 'provenance'))):
raise RuntimeStoreError('invalid_params')
when = payload['ts']
if when is not None and (type(when) not in (float, int) or not math.isfinite(when)):
raise RuntimeStoreError('invalid_params')
when = time.time() if when is None else float(when)
conn.execute('UPDATE sessions SET last_activity_at=?,last_activity_description=?,last_activity_provenance=? '
'WHERE id=? AND (last_activity_at IS NULL OR last_activity_at<?)',
(when, bound_activity_description(payload['description']),
normalize_activity_provenance(payload['provenance']).value, session_id, when))
return {'value': None}
def worker_activity_clear(db, conn, session_id, payload):
from agent.session_activity import ActivityProvenance
if payload:
raise RuntimeStoreError('invalid_params')
conn.execute('UPDATE sessions SET last_activity_description=?,last_activity_provenance=? WHERE id=?',
('', ActivityProvenance.UNKNOWN.value, session_id))
return {'value': None}
def worker_billing_route(db, conn, session_id, payload):
if (set(payload) != {'provider', 'base_url', 'billing_mode'}
or any(payload[k] is not None and not isinstance(payload[k], str) for k in payload)):
raise RuntimeStoreError('invalid_params')
# Same contract as SessionDB.update_session_billing_route: route writers never touch the
# stored prompt; only session.prompt replaces the frozen snapshot.
conn.execute('UPDATE sessions SET billing_provider=?,billing_base_url=?,billing_mode=COALESCE(?,billing_mode) '
'WHERE id=?', (payload['provider'], payload['base_url'], payload['billing_mode'], session_id))
return {'value': None}
def worker_api_content(db, conn, session_id, payload):
from hermes_state_messages import _scrub_surrogates
if set(payload) != {'content', 'api_content'} or not isinstance(payload['api_content'], str):
raise RuntimeStoreError('invalid_params')
value = conn.execute(
"UPDATE messages SET api_content=? WHERE id=(SELECT id FROM messages WHERE session_id=? "
"AND role='user' AND active=1 ORDER BY id DESC LIMIT 1) AND content IS ?",
(_scrub_surrogates(payload['api_content']), session_id, db._encode_content(payload['content']))).rowcount
return {'value': value}
def _row_id(payload):
row_id = payload.get('row_id')
if type(row_id) is not int or row_id <= 0:
raise RuntimeStoreError('invalid_params')
return row_id
def worker_message_api_content(db, conn, session_id, payload):
"""Row-addressed sidecar backfill, same guards as ``SessionDB.set_message_api_content``."""
from hermes_state_messages import _scrub_surrogates
if set(payload) != {'row_id', 'content', 'api_content'} or not isinstance(payload['api_content'], str):
raise RuntimeStoreError('invalid_params')
return {'value': conn.execute(
"UPDATE messages SET api_content=? WHERE id=? AND session_id=? AND role='user' AND active=1 AND content IS ?",
(_scrub_surrogates(payload['api_content']), _row_id(payload), session_id,
db._encode_content(payload['content']))).rowcount}
def worker_user_content(db, conn, session_id, payload):
"""Rewrite ONE active user row of this session, same guards as ``SessionDB.set_user_message_content``."""
if set(payload) - {'display_metadata'} != {'row_id', 'content'}:
raise RuntimeStoreError('invalid_params')
if 'display_metadata' not in payload:
return {'value': conn.execute(
"UPDATE messages SET content=? WHERE id=? AND session_id=? AND role='user' AND active=1",
(db._encode_content(payload['content']), _row_id(payload), session_id)).rowcount}
return {'value': conn.execute(
"UPDATE messages SET content=?, api_content=NULL, display_metadata=? "
"WHERE id=? AND session_id=? AND role='user' AND active=1",
(db._encode_content(payload['content']), db._encode_display_metadata(payload['display_metadata']),
_row_id(payload), session_id)).rowcount}
WORKER_LIFECYCLE_HANDLERS = {
'session.title': worker_title,
'session.title_source': worker_title_source,
'session.next_title': worker_next_title,
'session.activity': worker_activity,
'session.activity_clear': worker_activity_clear,
'session.billing_route': worker_billing_route,
'session.api_content': worker_api_content,
'session.message_api_content': worker_message_api_content,
'session.user_content': worker_user_content,
'session.create': worker_create,
'session.end': worker_end,
'session.lifecycle': worker_lifecycle,
}
def insert_session_row_in_transaction(
self, conn, 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,
transport_profile=None):
"""Connection-taking body of the keep-existing constructor upsert.
The ordinary constructor should delegate here too. Worker identity is
validated before entry; this helper never opens or commits a DB.
"""
from hermes_state_sessions import _UPSERT_KEEP_EXISTING_SQL
from hermes_state_mutation_retirement import RETIRED_PREFIX
# A delayed constructor/accounting backfill must not recreate a deleted session
# beside its durable tombstone (the exact delete retry would then report a stale
# success while a fresh delete collides with the existing marker).
if (conn.execute('SELECT 1 FROM sessions WHERE id=?', (session_id,)).fetchone() is None
and conn.execute('SELECT 1 FROM state_meta WHERE key=?', (RETIRED_PREFIX + session_id,)).fetchone()):
raise RuntimeStoreError('not_found')
if not (profile_name or "").strip():
profile_name = self._own_profile_name()
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
conn.execute(
"""INSERT INTO sessions (
id, source, created_source, user_id, session_key, chat_id, chat_type, thread_id,
model, model_config, system_prompt, system_prompt_hash,
parent_session_id, cwd, profile_name, transport_profile, git_repo_root,
origin_json, display_name, started_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
source = CASE
WHEN sessions.source = 'unknown'
THEN COALESCE(excluded.source, 'unknown')
ELSE sessions.source
END,
model = COALESCE(sessions.model, excluded.model),
model_config = CASE
WHEN excluded.model_config IS NOT NULL
AND json_type(
sessions.model_config, '$._reset_from'
) IS NOT NULL
AND json_remove(
sessions.model_config, '$._reset_from'
) = '{}'
THEN json_set(
excluded.model_config,
'$._reset_from',
json_extract(
sessions.model_config, '$._reset_from'
)
)
ELSE COALESCE(
sessions.model_config, excluded.model_config
)
END,
system_prompt_hash = COALESCE(
sessions.system_prompt_hash,
excluded.system_prompt_hash
),
system_prompt = CASE
WHEN sessions.system_prompt_hash IS NULL
AND excluded.system_prompt_hash IS NOT NULL
THEN NULL
ELSE sessions.system_prompt
END,
""" + _UPSERT_KEEP_EXISTING_SQL,
(
session_id, source, source, user_id, session_key, chat_id, chat_type, thread_id, model,
json.dumps(model_config) if model_config else None, system_prompt_hash,
parent_session_id, cwd, profile_name, transport_profile, git_repo_root, origin_json,
display_name, time.time(),
),
)
if system_prompt_hash is not None:
self._delete_unreferenced_system_prompts(conn)
if parent_session_id:
self._inherit_parent_session_metadata(conn, session_id)
# The upsert may have just filled a compression tip's created_source (update_token_counts).
from hermes_state_compression import stamp_compression_ancestors
stamp_compression_ancestors(conn, session_id)