Merge branch 'simp/r3-37-C' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 23:13:06 -07:00
5 changed files with 283 additions and 397 deletions
+92 -141
View File
@@ -1,23 +1,16 @@
"""Auto-continue: resume a turn killed by a process/machine death, plus queued-prompt drain and busy-submit handling.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Auto-continue: resume a turn killed by a process/machine death, plus queued-prompt drain and
busy-submit handling. Bodies are rebound onto server.py's globals at install time
(method_ctx.bind_module), so they reference server.py globals bare."""
from __future__ import annotations
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
# A concluded turn (success, handled error, interrupt) clears its durable marker (turn_marker.py)
# in _run_prompt_submit's finally; only a process death leaves it behind, so a marker at
# session.resume proves the turn never finished AND the client never saw a terminal frame. Fresh:
# re-submit automatically (as the messaging gateway does). Stale: clear it and let the partial
# transcript speak.
from .method_ctx import bind_module
# A concluded turn (success, handled error, interrupt) clears its durable marker (turn_marker.py) in
# _run_prompt_submit's finally; only a process death leaves it behind, so a marker at session.resume
# proves the turn never finished AND the client never saw a terminal frame. Fresh: re-submit
# automatically (as the messaging gateway does). Stale: clear it and let the partial transcript speak.
_AUTO_CONTINUE_ENABLED_DEFAULT = True
_AUTO_CONTINUE_FRESHNESS_MINUTES_DEFAULT = 15
_AUTO_CONTINUE_MAX_ATTEMPTS_DEFAULT = 2
@@ -27,8 +20,7 @@ def _auto_continue_config() -> tuple[bool, float, int]:
"""(enabled, freshness window in seconds, max attempts) from config.yaml."""
desktop = _load_cfg().get("desktop")
cfg = desktop.get("auto_continue") if isinstance(desktop, dict) else None
if not isinstance(cfg, dict):
cfg = {}
cfg = cfg if isinstance(cfg, dict) else {}
try:
minutes = float(cfg.get("freshness_minutes", _AUTO_CONTINUE_FRESHNESS_MINUTES_DEFAULT))
except (TypeError, ValueError):
@@ -42,18 +34,14 @@ def _auto_continue_config() -> tuple[bool, float, int]:
def _session_home(session: dict) -> Path:
"""The HERMES_HOME the session's durable state lives in (profile-aware)."""
profile_home = session.get("profile_home")
return Path(profile_home) if profile_home else Path(_hermes_home)
return Path(session.get("profile_home") or _hermes_home)
def _retire_turn_marker(session: dict, *keys: str) -> None:
"""Drop the crash marker for a turn whose outcome is about to reach the client.
Called right before the terminal frame, not at turn-thread end: post-turn work (titles, memory
sync, goal hooks) outlives the client's answer, and quitting in that window would leave a marker
that re-runs a finished turn on next launch. Extra ``keys`` cover a session_key that compression
rotated mid-turn.
"""
"""Drop the crash marker for a turn whose outcome is about to reach the client. Called right before
the terminal frame, not at turn-thread end: post-turn work (titles, memory sync, goal hooks) outlives
the client's answer, and quitting in that window would leave a marker that re-runs a finished turn
on next launch. Extra ``keys`` cover a session_key that compression rotated mid-turn."""
home = _session_home(session)
for key in dict.fromkeys((*keys, str(session.get("session_key") or ""))):
if key:
@@ -61,8 +49,8 @@ def _retire_turn_marker(session: dict, *keys: str) -> None:
def _auto_continue_note(prompt: str) -> str:
# Same opening as the gateway's recovery notes (transcript tooling recognizes both). The prompt
# is embedded: a hard crash persists nothing else of the turn.
# Same opening as the gateway's recovery notes (transcript tooling recognizes both). The prompt is
# embedded: a hard crash persists nothing else of the turn.
return (
f"{_AUTO_CONTINUE_NOTE_PREFIX} — the app or its backend process "
"stopped before the turn could finish. Some of the work may already "
@@ -84,15 +72,12 @@ def _ac_dispatch_failed(what: str, exc: BaseException) -> None:
def _maybe_schedule_auto_continue(sid: str, session: dict, session_key: str) -> dict | None:
"""Kick off a continuation turn for a crash-interrupted session.
Called from session.resume's cold paths once the live record is registered. Returns a descriptor
for the resume payload when scheduled, else None. The turn runs on a background thread after the
deferred agent build via the normal _run_prompt_submit path, so the client that just resumed
streams it live.
"""
# Hosted room turns are recovered by their durable task/lease state machine; generic
# auto-continue would bypass its execution generation and duplicate work.
"""Kick off a continuation turn for a crash-interrupted session (session.resume cold paths, once the
live record is registered). Returns a descriptor for the resume payload when scheduled, else None.
The turn runs on a background thread after the deferred agent build via the normal
_run_prompt_submit path, so the client that just resumed streams it live."""
# Hosted room turns are recovered by their durable task/lease state machine; generic auto-continue
# would bypass its execution generation and duplicate work.
if session.get("source") == "bot_room":
return None
home = _session_home(session)
@@ -130,17 +115,17 @@ def _maybe_schedule_auto_continue(sid: str, session: dict, session_key: str) ->
return
session["running"] = True
session["last_active"] = time.time()
# Ownership admission BEFORE message.start: a sibling backend sharing this HERMES_HOME may
# have written the marker and still be mid-turn. Leave the marker so a later resume retries
# once the owner finishes or dies.
# Ownership admission BEFORE message.start: a sibling backend sharing this HERMES_HOME may have
# written the marker and still be mid-turn. Leave the marker so a later resume retries once the
# owner finishes or dies.
if _ensure_active_session_slot(sid, session) is not None:
logger.info("auto-continue for %s refused: session has another live owner", session_key)
_ac_release_turn(session, unschedule=True)
return
with session["history_lock"]:
# Marker inputs read back by _run_prompt_submit: count the attempt (crash breaker) and
# re-record the ORIGINAL prompt (no nested notes). Set here, not at schedule time, so a
# bail above leaves nothing for a racing user turn.
# re-record the ORIGINAL prompt (no nested notes). Set here, not at schedule time, so a bail
# above leaves nothing for a racing user turn.
session["_auto_continue_attempt"] = attempt
session["_auto_continue_prompt"] = marker["prompt"]
try:
@@ -152,28 +137,28 @@ def _maybe_schedule_auto_continue(sid: str, session: dict, session_key: str) ->
_ac_release_turn(session)
threading.Thread(target=kickoff, daemon=True).start()
logger.info(
"auto-continue scheduled for session %s (attempt %d, interrupted %.0fs ago)", session_key, attempt, age
)
logger.info("auto-continue scheduled for session %s (attempt %d, interrupted %.0fs ago)", session_key, attempt, age)
return {"attempt": attempt, "interrupted_at": marker["started_at"]}
def _enqueue_prompt(session: dict, text: Any, transport: Any, image_paths: list[str] | None = None) -> None:
"""Stash a message to run as the very next turn once the live one ends.
def _ac_inflight_original(session: dict) -> str:
turn = session.get("inflight_turn")
return str(turn.get("user") or "").strip() if isinstance(turn, dict) else ""
Text-only arrivals share a slot and merge losslessly (like the consecutive-user merge in
``repair_message_sequence``); image-bearing ones stay separate envelopes so attachment
ownership/chronology survive. ``transport`` is pinned so the drained turn streams back to its
sender even if the session transport is rebound.
"""
def _enqueue_prompt(session: dict, text: Any, transport: Any, image_paths: list[str] | None = None) -> None:
"""Stash a message to run as the very next turn once the live one ends. Text-only arrivals share a
slot and merge losslessly (like the consecutive-user merge in ``repair_message_sequence``);
image-bearing ones stay separate envelopes so attachment ownership/chronology survive.
``transport`` is pinned so the drained turn streams back to its sender even if the session
transport is rebound."""
image_paths = list(image_paths or [])
# Scrub live-turn self-duplicates first so the text merge below can't glue
# "{original}\n\n{later}" and re-fire the original after a correction settles.
# Scrub live-turn self-duplicates first so the text merge below can't glue "{original}\n\n{later}"
# and re-fire the original after a correction settles.
_drop_queued_duplicates_of_inflight_user(session)
# Never queue a text-only self-copy of the live prompt: draining it would restart it.
if not image_paths and isinstance(text, str):
turn = session.get("inflight_turn")
original = str(turn.get("user") or "").strip() if isinstance(turn, dict) else ""
original = _ac_inflight_original(session)
if original and text.strip() == original:
return
queued = {"text": text, "transport": transport}
@@ -193,12 +178,10 @@ def _enqueue_prompt(session: dict, text: Any, transport: Any, image_paths: list[
def _sanitize_queued_entry_vs_inflight_user(entry: Any, original: str) -> dict | None:
"""Drop (``None``) or rewrite a queue envelope that re-carries the live user text.
Text-only self-duplicates are dropped; a merged slot ``"{original}\\n\\n{later}"`` is rewritten
to ``later`` so the correction survives without re-firing the original. Image-bearing envelopes
are left alone (chronology is load-bearing).
"""
"""Drop (``None``) or rewrite a queue envelope that re-carries the live user text: text-only
self-duplicates are dropped; a merged slot ``"{original}\\n\\n{later}"`` is rewritten to ``later`` so
the correction survives without re-firing the original. Image-bearing envelopes are left alone
(chronology is load-bearing)."""
if not isinstance(entry, dict):
return None
text = entry.get("text")
@@ -217,14 +200,11 @@ def _sanitize_queued_entry_vs_inflight_user(entry: Any, original: str) -> dict |
def _drop_queued_duplicates_of_inflight_user(session: dict) -> None:
"""Remove server-queue copies of the live turn's original user text.
A mid-turn ``prompt.submit`` of the same text can be queued while redirect is unavailable
(build window, tool boundary); after a later redirect it must not drain and restart the original
as a fresh turn. Unrelated follow-ups stay.
"""
turn = session.get("inflight_turn")
original = str(turn.get("user") or "").strip() if isinstance(turn, dict) else ""
"""Remove server-queue copies of the live turn's original user text. A mid-turn ``prompt.submit`` of
the same text can be queued while redirect is unavailable (build window, tool boundary); after a
later redirect it must not drain and restart the original as a fresh turn. Unrelated follow-ups
stay."""
original = _ac_inflight_original(session)
if not original:
return
head = session.get("queued_prompt")
@@ -243,12 +223,10 @@ def _ac_set_queue(session: dict, entries: list) -> None:
def _interrupt_busy_session(sid: str, session: dict, agent: Any) -> None:
"""Interrupt a busy turn on a worker thread, never under ``history_lock``.
Some providers can't apply ``interrupt()`` until a blocking tool/network call returns; doing it
inline stalled ``session.resume`` and the queued prompt. At most one interrupt worker per session
so repeated steering can't leak threads.
"""
"""Interrupt a busy turn on a worker thread, never under ``history_lock``: some providers can't apply
``interrupt()`` until a blocking tool/network call returns, and doing it inline stalled
``session.resume`` and the queued prompt. At most one interrupt worker per session so repeated
steering can't leak threads."""
use_agent = agent is not None and hasattr(agent, "interrupt")
if not use_agent and not _session_uses_compute_host(session):
return
@@ -274,8 +252,8 @@ def _interrupt_busy_session(sid: str, session: dict, agent: Any) -> None:
def _ac_try_correction(rid, session: dict, agent: Any, method: str, plain_text: str, status: str) -> dict | None:
"""Apply ``agent.<method>(plain_text)`` (steer/redirect); on acceptance record the correction, scrub
stale self-duplicates so the live turn's original text is not re-fired after settle, and return
the ``status`` reply. None → caller falls through to the queue path."""
stale self-duplicates so the live turn's original text is not re-fired after settle, and return the
``status`` reply. None → caller falls through to the queue path."""
try:
if not getattr(agent, method)(plain_text):
return None
@@ -290,24 +268,19 @@ def _ac_try_correction(rid, session: dict, agent: Any, method: str, plain_text:
def _handle_busy_submit(rid, sid: str, session: dict, text: Any, transport: Any, queued: bool = False) -> dict | None:
"""Apply ``display.busy_input_mode`` to a prompt that lands mid-turn instead of rejecting it with
``session busy`` (rejection made clients busy-retry and silently drop sends when teardown
outlived their deadline).
Modes: ``interrupt`` (default) → redirect the live turn, falling back to hard interrupt + queue
for older agents; ``queue`` → queue only; ``steer`` → inject after the current atomic action.
``queued=True`` (client queue drain) forces queue mode: a "run after" message must NEVER become a
live-turn correction, even when the drain loses the settle race against a still-unwinding turn.
"""
``session busy`` (rejection made clients busy-retry and silently drop sends when teardown outlived
their deadline). Modes: ``interrupt`` (default) → redirect the live turn, falling back to hard
interrupt + queue for older agents; ``queue`` → queue only; ``steer`` → inject after the current
atomic action. ``queued=True`` (client queue drain) forces queue mode: a "run after" message must
NEVER become a live-turn correction, even when the drain loses the settle race."""
mode = "queue" if queued else _load_busy_input_mode()
agent = session.get("agent")
with session["history_lock"]:
if not session.get("running"):
# Turn ended since prompt.submit's busy check; caller retries on the idle session.
return None
return None # turn ended since prompt.submit's busy check; caller retries on the idle session
image_paths = list(session.get("attached_images", []))
if image_paths:
# Claim now so a later paste isn't consumed by this prompt when the turn yields.
session["attached_images"] = []
session["attached_images"] = [] # claim now so a later paste isn't consumed when the turn yields
text_only = not image_paths and _is_text_only_busy_payload(text)
plain_text = _coerce_message_text(text).strip() if text_only else ""
if plain_text and agent is not None:
@@ -315,13 +288,9 @@ def _handle_busy_submit(rid, sid: str, session: dict, text: Any, transport: Any,
resp = _ac_try_correction(rid, session, agent, "steer", plain_text, "steered")
if resp is not None:
return resp
# Text-only corrections redirect in place when supported; media payloads and older agents
# fall through to the proven interrupt + queue path.
if (
mode == "interrupt"
and getattr(agent, "_supports_active_turn_redirect", False) is True
and hasattr(agent, "redirect")
):
# Text-only corrections redirect in place when supported; media payloads and older agents fall
# through to the proven interrupt + queue path.
if mode == "interrupt" and getattr(agent, "_supports_active_turn_redirect", False) is True and hasattr(agent, "redirect"):
resp = _ac_try_correction(rid, session, agent, "redirect", plain_text, "redirected")
if resp is not None:
return resp
@@ -334,21 +303,18 @@ def _handle_busy_submit(rid, sid: str, session: dict, text: Any, transport: Any,
return None
_enqueue_prompt(session, text, transport, image_paths=image_paths)
session["last_active"] = time.time()
# Attachments need their own model invocation: queue without cancelling so the user gets both
# results in order. ``steer`` must NEVER escalate to a hard interrupt: it would kill the live
# turn AND drop ``AIAgent._pending_steer``, destroying earlier accepted steers; steer
# fall-throughs stay FIFO-queued.
# Attachments need their own model invocation: queue without cancelling so the user gets both results
# in order. ``steer`` must NEVER escalate to a hard interrupt: it would kill the live turn AND drop
# ``AIAgent._pending_steer``, destroying earlier accepted steers; steer fall-throughs stay FIFO-queued.
if mode == "interrupt" and not image_paths:
_interrupt_busy_session(sid, session, agent)
return _ok(rid, {"status": "queued"})
def _drain_queued_prompt(rid, sid: str, session: dict) -> bool:
"""Fire a queued next-turn prompt if one is waiting and the session is idle.
True when dispatched: the caller skips lower-priority follow-ups this cycle (the user's message
wins). Claim-under-lock like the goal-continuation re-fire.
"""
"""Fire a queued next-turn prompt if one is waiting and the session is idle. True when dispatched:
the caller skips lower-priority follow-ups this cycle (the user's message wins). Claim-under-lock
like the goal-continuation re-fire."""
with session["history_lock"]:
if session.get("_closing"):
return False
@@ -405,10 +371,8 @@ def _inflight_snapshot(session: dict) -> dict | None:
turn = session.get("inflight_turn")
if not isinstance(turn, dict):
return None
user = str(turn.get("user") or "").strip()
assistant = str(turn.get("assistant") or "")
streaming = bool(turn.get("streaming"))
error = str(turn.get("error") or "").strip()
user, assistant = str(turn.get("user") or "").strip(), str(turn.get("assistant") or "")
streaming, error = bool(turn.get("streaming")), str(turn.get("error") or "").strip()
if not user and not assistant and not streaming and not error:
return None
snapshot = {"assistant": assistant, "streaming": streaming, "user": user}
@@ -418,17 +382,15 @@ def _inflight_snapshot(session: dict) -> dict | None:
for i, c in enumerate(turn.get("corrections") or []) if str(c).strip()
]
if correction_pairs:
# Mid-turn redirects alongside (not over) the original prompt so resume can rebuild every
# user bubble; offsets only when every correction has one so clients can trust the pairing.
# Mid-turn redirects alongside (not over) the original prompt so resume can rebuild every user
# bubble; offsets only when every correction has one so clients can trust the pairing.
snapshot["corrections"] = [c for c, _ in correction_pairs]
if all(isinstance(offset, int) and offset >= 0 for _, offset in correction_pairs):
snapshot["correction_offsets"] = [int(offset) for _, offset in correction_pairs] # type: ignore[arg-type]
if error:
# Retained failed turn (_fail_inflight_turn): a resuming client must rebuild the failed
# bubble, not render the partial text as a healthy reply.
snapshot["error"] = error
snapshot["status"] = str(turn.get("status") or "error")
snapshot["recoverable"] = bool(turn.get("recoverable"))
# Retained failed turn (_fail_inflight_turn): a resuming client must rebuild the failed bubble,
# not render the partial text as a healthy reply.
snapshot.update(error=error, status=str(turn.get("status") or "error"), recoverable=bool(turn.get("recoverable")))
surface = turn.get("error_surface")
if isinstance(surface, dict) and surface:
snapshot["error_surface"] = surface
@@ -440,13 +402,11 @@ def _emit_terminal_turn_error(
) -> None:
"""Close a failed turn with the same ``status: "error"`` ``message.complete`` frame as
``_run_prompt_submit``'s returned-error path, retaining the turn via ``_fail_inflight_turn`` so a
client that missed the frame recovers it from ``session.resume``'s ``inflight``. Callers that
know the failing layer pass ``error_surface``; exception callers leave it None and it is
classified here.
"""
client that missed the frame recovers it from ``session.resume``'s ``inflight``. Callers that know
the failing layer pass ``error_surface``; exception callers leave it None and it is classified here
({layer, code, retryable} so the desktop can say "Provider error" / "Gateway error"; advisory,
never raises)."""
agent = session.get("agent")
# {layer, code, retryable} descriptor so the desktop can say "Provider error" / "Gateway error"
# with matching recovery actions. Advisory: never raises.
if error_surface is None and isinstance(error, BaseException):
try:
from agent.error_surface import build_error_surface_from_exception
@@ -463,31 +423,24 @@ def _emit_terminal_turn_error(
partial = str(turn.get("assistant") or "")
cols = int(session.get("cols", 80))
text = partial or f"Error: {message}"
payload = {
"text": text,
"usage": _get_usage(agent) if agent is not None else {},
"status": "error",
"error": message,
"recoverable": True,
}
if error_surface:
payload["error_surface"] = error_surface
if partial:
payload["partial"] = True
try:
rendered = render_message(text, cols)
except Exception:
rendered = ""
if rendered:
payload["rendered"] = rendered
payload = {
"text": text, "usage": _get_usage(agent) if agent is not None else {}, "status": "error",
"error": message, "recoverable": True,
**({"error_surface": error_surface} if error_surface else {}),
**({"partial": True} if partial else {}), **({"rendered": rendered} if rendered else {}),
}
if retire_marker:
_retire_turn_marker(session)
_emit("message.complete", sid, payload)
def _restore_agent_history_after_turn_error(session: dict, agent) -> bool:
"""Keep a failed turn's working transcript: ``AIAgent`` persists its messages independently, so
after a raise the next prompt must see them, not the pre-turn snapshot."""
"""Keep a failed turn's working transcript: ``AIAgent`` persists its messages independently, so after
a raise the next prompt must see them, not the pre-turn snapshot."""
agent_messages = getattr(agent, "_session_messages", None)
if not isinstance(agent_messages, list):
return False
@@ -501,9 +454,7 @@ def _queued_prompt_snapshot(session: dict) -> dict | None:
"""The accepted next-turn prompt without its transport handle, for the live-session projection
(Desktop may reconnect while it is still queued)."""
queued = session.get("queued_prompt")
if not isinstance(queued, dict):
return None
user = _inflight_text(queued.get("text"))
user = _inflight_text(queued.get("text")) if isinstance(queued, dict) else ""
return {"user": user} if user else None
+27 -75
View File
@@ -9,9 +9,7 @@ from __future__ import annotations
import contextlib
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
from .method_ctx import bind_module
def _tui_compression_config_signature(cfg: dict | None) -> tuple:
@@ -78,36 +76,31 @@ _COMPRESSION_INT_KEYS = (
def _apply_live_compression_config(agent: Any, cfg: dict | None) -> None:
"""Update a live session's compressor from current config.yaml, preserving the agent object,
session identity, history and callbacks. Recomputes the trigger from the ratio threshold, then
applies ``compression.threshold_tokens`` so raising/lowering/clearing the cap lands next preflight.
"""Update a live session's compressor from current config.yaml, preserving the agent object, session
identity, history and callbacks. Recomputes the trigger from the ratio threshold, then applies
``compression.threshold_tokens`` so raising/lowering/clearing the cap lands next preflight.
Every adopted key has UNSET semantics: a removed key restores the normalized default (or the
model-derived value) through the construction path's own derivation (ctor signature defaults,
Codex autoraise, deferred context-length re-inference). Acting only on PRESENT keys would leave
stale values active forever.
model-derived value) through the construction path's own derivation (ctor signature defaults, Codex
autoraise, deferred context-length re-inference). Acting only on PRESENT keys would leave stale
values active forever.
"""
cfg = cfg if isinstance(cfg, dict) else {}
compression_raw = cfg.get("compression")
compression = compression_raw if isinstance(compression_raw, dict) else {}
compression = cfg.get("compression") if isinstance(cfg.get("compression"), dict) else {}
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
enabled_raw = compression.get("enabled", True)
agent.compression_enabled = enabled_raw if isinstance(enabled_raw, bool) else str(enabled_raw).lower() in {"true", "1", "yes"}
agent.codex_responses_native_compaction = is_truthy_value(compression.get("codex_responses_native", False))
native_threshold_raw = compression.get("codex_responses_compact_threshold", 200_000)
try:
if isinstance(native_threshold_raw, bool):
raise ValueError
native_threshold = int(native_threshold_raw)
if native_threshold <= 0:
if isinstance(native_threshold_raw, bool) or native_threshold <= 0:
raise ValueError
except (TypeError, ValueError):
logger.warning("Invalid compression.codex_responses_compact_threshold=%r; using 200000.", native_threshold_raw)
native_threshold = 200_000
agent.codex_responses_compact_threshold = native_threshold
# Absence restores the agent_init/config default (0 = disabled).
with contextlib.suppress(TypeError, ValueError):
agent.compression_idle_compact_after_seconds = max(0, int(compression.get("idle_compact_after_seconds", 0) or 0))
@@ -115,30 +108,22 @@ def _apply_live_compression_config(agent: Any, cfg: dict | None) -> None:
cc = getattr(agent, "context_compressor", None)
if cc is None:
return
# tail_mode: unknown/absent values land on the ctor default ("lean"), matching agent_init.
default_tail = str(_compressor_ctor_default("tail_mode", "lean"))
mode = str(compression.get("tail_mode", default_tail) or default_tail).strip().lower()
cc.tail_mode = mode if mode in ("legacy", "lean") else default_tail
for key, fallback, min_value in _COMPRESSION_INT_KEYS:
default = int(_compressor_ctor_default(key, fallback))
raw = compression.get(key, default)
try:
value = default if raw is None else int(raw)
except (TypeError, ValueError):
continue
setattr(cc, key, max(min_value, value))
with contextlib.suppress(TypeError, ValueError):
setattr(cc, key, max(min_value, default if raw is None else int(raw)))
with contextlib.suppress(TypeError, ValueError):
ratio_raw = compression.get("target_ratio", _compressor_ctor_default("summary_target_ratio", 0.20))
cc.summary_target_ratio = max(0.10, min(float(ratio_raw), 0.80))
# Absent or invalid shape (agent_init treats both as empty): stale overrides must stop steering.
raw_thresholds = compression.get("model_thresholds")
cc.model_thresholds = {
str(k): float(v)
for k, v in raw_thresholds.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
str(k): float(v) for k, v in raw_thresholds.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
} if isinstance(raw_thresholds, dict) else {}
# threshold: present value wins; absence derives via the agent_init resolution (default + autoraise).
@@ -148,23 +133,17 @@ def _apply_live_compression_config(agent: Any, cfg: dict | None) -> None:
pct = float(compression["threshold"])
if pct is None:
pct = _derived_default_threshold_percent(agent, compression)
with contextlib.suppress(TypeError, ValueError):
cc._config_threshold_percent = pct
cc._configured_threshold_percent = pct
base = pct
model_thresholds = getattr(cc, "model_thresholds", None) or {}
if model_thresholds:
from agent.context_compressor import resolve_model_threshold
cc._config_threshold_percent = cc._configured_threshold_percent = pct
base = pct
if cc.model_thresholds:
from agent.context_compressor import resolve_model_threshold
base = resolve_model_threshold(getattr(agent, "model", "") or "", model_thresholds, pct)
cc._base_threshold_percent = base
if hasattr(cc, "_effective_threshold_percent"):
try:
cc.threshold_percent = cc._effective_threshold_percent(cc.context_length, base)
except Exception:
cc.threshold_percent = pct
else:
cc.threshold_percent = pct
base = resolve_model_threshold(getattr(agent, "model", "") or "", cc.model_thresholds, pct)
cc._base_threshold_percent = base
try:
cc.threshold_percent = cc._effective_threshold_percent(cc.context_length, base)
except Exception:
cc.threshold_percent = pct
raw_ctx = model_cfg.get("context_length")
if raw_ctx is not None:
@@ -177,40 +156,13 @@ def _apply_live_compression_config(agent: Any, cfg: dict | None) -> None:
with contextlib.suppress(Exception):
cc.context_length = new_ctx
elif getattr(cc, "_config_context_length", None) is not None:
# model.context_length removed: drop the override and force re-inference from model metadata
# on next access (construction's deferred resolution); re-applies the small-context floor too.
cc._config_context_length = None
cc._resolved_context_length = None
coerce_cap = getattr(cc, "_coerce_threshold_tokens_cap", None)
if callable(coerce_cap):
cc.threshold_tokens_cap = coerce_cap(compression.get("threshold_tokens"))
elif "threshold_tokens" in compression:
try:
cap = int(compression.get("threshold_tokens"))
cc.threshold_tokens_cap = cap if cap > 0 else None
except (TypeError, ValueError):
cc.threshold_tokens_cap = None
else:
cc.threshold_tokens_cap = None
# model.context_length removed: drop the override and force re-inference from model metadata on
# next access (construction's deferred resolution); re-applies the small-context floor too.
cc._config_context_length = cc._resolved_context_length = None
cc.threshold_tokens_cap = cc._coerce_threshold_tokens_cap(compression.get("threshold_tokens"))
# Invalidate the cached trigger so the next preflight re-derives from percent/window, then the cap.
if hasattr(cc, "_threshold_tokens"):
cc._threshold_tokens = None
if hasattr(cc, "_tail_token_budget"):
cc._tail_token_budget = None
elif hasattr(cc, "_apply_threshold_tokens_cap"):
compute = getattr(cc, "_compute_threshold_tokens", None)
if callable(compute):
cc.threshold_tokens = compute(
getattr(cc, "context_length", 0) or 0, getattr(cc, "threshold_percent", 0.5), getattr(cc, "max_tokens", None)
)
cc._apply_threshold_tokens_cap()
else:
cap = getattr(cc, "threshold_tokens_cap", None)
current = getattr(cc, "threshold_tokens", None)
if cap and current:
cc.threshold_tokens = min(int(current), int(cap))
cc._threshold_tokens = cc._tail_token_budget = None
def _sync_agent_compression_with_config(sid: str, session: dict) -> None:
+141 -153
View File
@@ -7,9 +7,7 @@ from __future__ import annotations
import contextlib
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
from .method_ctx import bind_module
def _notif_locked_sessions(fn, default):
@@ -58,16 +56,16 @@ def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) ->
return False
if _notif_locked_sessions(lambda ss: evt_ui_sid in ss and not ss[evt_ui_sid].get("_finalized"), False):
return True
# Exact UI tab gone: fall through to durable session_key routing so a
# resumed continuation with the same key/lineage can still claim it.
# Exact UI tab gone: fall through to durable session_key routing so a resumed
# continuation with the same key/lineage can still claim it.
evt_key = str(evt.get("session_key") or "")
if not evt_key:
return False
current_keys = _notif_current_keys(sid, session)
# Compression can rotate AIAgent.session_id while the detached child is still running: map
# the event's original key to its continuation tip so it reaches the live session instead of
# becoming an orphan any poller may consume. A live continuation wins over the compressed
# parent, else a stale parent tab could consume the event before the current conversation.
# Compression can rotate AIAgent.session_id while the detached child is still running: map the
# event's original key to its continuation tip so it reaches the live session instead of becoming
# an orphan any poller may consume. A live continuation wins over the compressed parent, else a
# stale parent tab could consume the event before the current conversation.
resolved_key = _notif_resolve_event_key(evt_key)
if resolved_key != evt_key:
if resolved_key in current_keys:
@@ -85,9 +83,9 @@ def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) ->
def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool:
"""True iff *this* session PROVABLY owns ``evt``: positive mirror of
``_notification_event_belongs_elsewhere`` minus its orphan-adoption fallback (UI origin is
this live session, or ``session_key`` raw/compression-resolved matches this session). Fail-
closed gate for every addressed notification — "not provably elsewhere" is NOT enough."""
``_notification_event_belongs_elsewhere`` minus its orphan-adoption fallback (UI origin is this
live session, or ``session_key`` raw/compression-resolved matches). Fail-closed gate for every
addressed notification — "not provably elsewhere" is NOT enough."""
if session.get("_finalized"):
return False
if str(evt.get("origin_ui_session_id") or "") == str(sid or ""):
@@ -106,27 +104,28 @@ def _notification_event_requires_owner(evt: dict) -> bool:
)
# Extra dedup fields per event type. Completions are terminal (one-shot per process session); watch
# events are not — one process can match patterns many times, so their content is part of the key.
_DEDUP_EXTRA_FIELDS = {
"watch_match": ("command", "pattern", "output", "suppressed", "message_id"),
"watch_disabled": ("command", "message", "suppressed"),
"watch_overflow_": ("command", "message", "suppressed"), # prefix match
}
def _notification_event_dedup_key(evt: dict) -> tuple:
"""UI-emission identity for a process notification event. Completions are terminal (one-shot
per process session); watch events are not — one process can match patterns many times, so
include event content to avoid suppressing later distinct matches."""
"""UI-emission identity for a process notification event."""
evt_type = evt.get("type", "completion")
evt_sid = evt.get("session_id", "")
if evt_type == "watch_match":
return (evt_sid, evt_type, evt.get("command", ""), evt.get("pattern", ""), evt.get("output", ""),
evt.get("suppressed", 0), evt.get("message_id", ""))
if evt_type.startswith("watch_overflow_") or evt_type == "watch_disabled":
return (evt_sid, evt_type, evt.get("command", ""), evt.get("message", ""), evt.get("suppressed", 0))
if evt_type == "async_delegation":
# No process session_id: without this every completion keys as
# ("", "async_delegation") and the second one is suppressed forever.
# No process session_id: without this every completion keys as ("", "async_delegation")
# and the second one is suppressed forever.
return (evt.get("delegation_id", ""), evt_type)
return (evt_sid, evt_type)
extra = _DEDUP_EXTRA_FIELDS.get("watch_overflow_" if evt_type.startswith("watch_overflow_") else evt_type, ())
return (evt.get("session_id", ""), evt_type, *(evt.get(f, 0 if f == "suppressed" else "") for f in extra))
# Mirror gateway/kanban_watchers.py TERMINAL_KINDS: claim silent kinds (archived/unblocked) too so
# the cursor advances past them and they can't wedge a later completed/blocked event behind an
# unclaimed row.
# Mirror gateway/kanban_watchers.py TERMINAL_KINDS: claim silent kinds (archived/unblocked) too so the
# cursor advances past them and they can't wedge a later completed/blocked event behind an unclaimed row.
_KANBAN_NOTIFY_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out", "status", "archived", "unblocked")
_KANBAN_POLL_SECONDS = 5.0
_LOOP_POLL_SECONDS = 5.0
@@ -161,10 +160,14 @@ def _notif_submit(rid: str, sid: str, session: dict, text: str, what: str, **kwa
raise
def _notif_loop_status(sid: str, text: str) -> None:
_emit("status.update", sid, {"kind": "loop", "text": text})
def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None:
"""Fire a due /loop wakeup for an idle TUI/Desktop/dashboard session (per-session poller,
coarse cadence). Claims the session under history_lock (running=True) before dispatching so
a racing user prompt wins cleanly; the turn dispatcher's post-turn hook completes the tick."""
"""Fire a due /loop wakeup for an idle TUI/Desktop/dashboard session (per-session poller, coarse
cadence). Claims the session (running=True) before dispatching so a racing user prompt wins
cleanly; the turn dispatcher's post-turn hook completes the tick."""
try:
from hermes_cli.loops import LoopManager, goal_blocks_loop_tick
except Exception:
@@ -179,16 +182,15 @@ def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None:
if not wakeup:
_notif_release_turn(session)
return
tick_no = mgr.state.ticks_fired if mgr.state else "?"
rid = f"__loop__{int(time.time() * 1000)}"
try:
_emit("status.update", sid, {"kind": "loop", "text": f"↻ /loop wakeup #{tick_no} firing…"})
_notif_loop_status(sid, f"↻ /loop wakeup #{mgr.state.ticks_fired if mgr.state else '?'} firing…")
if not wakeup.lstrip().startswith("/"):
_emit("message.start", sid)
_run_prompt_submit(rid, sid, session, wakeup)
return
# Slash-command loop: route through the slash pipeline, not the model. No model reply
# to evaluate — complete immediately.
# Slash-command loop: route through the slash pipeline, not the model. No model reply to
# evaluate — complete immediately.
_notif_release_turn(session)
try:
parts = wakeup.lstrip()[1:].split(None, 1)
@@ -198,10 +200,10 @@ def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None:
payload = (resp or {}).get("result") or {}
out = str(payload.get("output") or "").strip()
if out:
_emit("status.update", sid, {"kind": "loop", "text": out})
_notif_loop_status(sid, out)
if payload.get("type") == "send" and payload.get("message"):
# Command resolves to a prompt (skill command etc.) — run it as a normal turn;
# the post-turn hook completes the tick.
# Command resolves to a prompt (skill command etc.) — run it as a normal turn; the
# post-turn hook completes the tick.
if not _notif_claim_turn(session):
mgr.abandon_tick()
return
@@ -212,7 +214,7 @@ def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None:
pass
decision = mgr.complete_tick("")
if decision.get("message"):
_emit("status.update", sid, {"kind": "loop", "text": decision["message"]})
_notif_loop_status(sid, decision["message"])
except Exception as exc:
_notif_log_failure("loop wakeup dispatch failed", exc)
_notif_release_turn(session)
@@ -268,14 +270,49 @@ def _format_kanban_event_text(sub: dict, task, ev, board_slug: str) -> Optional[
return f"{glyph} {board_tag}{tag}Kanban {task_id}{fmt(task, getattr(ev, 'payload', None) or {}, title)}"
def _kb_board_key(_kb, board_meta) -> tuple[str, str]:
"""(slug, resolved DB identity) — multiple slugs can point at one DB when HERMES_KANBAN_DB pins it."""
slug = (board_meta or {}).get("slug") or _kb.DEFAULT_BOARD
db_path = (board_meta or {}).get("db_path")
try:
return slug, str(Path(db_path).expanduser().resolve() if db_path else _kb.kanban_db_path(slug).resolve())
except Exception:
return slug, f"slug:{slug}"
def _kb_poll_board(_kb, conn, slug: str, session_key: str) -> list:
"""Claim + format this session's unseen events on one open board connection."""
try:
subs = _kb.list_notify_subs(conn)
except Exception:
return []
texts: list = []
for sub in subs:
if (sub.get("platform") or "").lower() != "tui" or sub.get("chat_id") != session_key:
continue
sub_ident = dict(
task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"], thread_id=sub.get("thread_id") or ""
)
_old, _new, events = _kb.claim_unseen_events_for_sub(conn, kinds=_KANBAN_NOTIFY_KINDS, **sub_ident)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
texts.extend(t for t in (_format_kanban_event_text(sub, task, ev, slug) for ev in events) if t)
# Unsubscribe only on archive: ``done`` is reversible in review/controller flows, so keeping the
# sub lets a later reopen notify the same session. The claimed cursor prevents replay.
if task and getattr(task, "status", "") == "archived":
with contextlib.suppress(Exception):
_kb.remove_notify_sub(conn, **sub_ident)
return texts
def _collect_kanban_notifications(session: dict) -> list:
"""Claim unseen terminal kanban events for this TUI session's subscriptions.
``kanban_create`` auto-subscribes TUI/desktop sessions with ``platform="tui"`` and
``chat_id=HERMES_SESSION_KEY``; there is no "tui" messaging adapter, so this poller is the
delivery path. Same atomic cursor-claim (``claim_unseen_events_for_sub``) as the gateway
notifier, so a sub is delivered exactly once even if a gateway and a TUI poll the same board
DB. Returns formatted notification texts (may be empty)."""
``chat_id=HERMES_SESSION_KEY``; there is no "tui" messaging adapter, so this poller is the delivery
path. Same atomic cursor-claim (``claim_unseen_events_for_sub``) as the gateway notifier, so a sub
is delivered exactly once even if a gateway and a TUI poll the same board DB."""
session_key = str(session.get("session_key") or "")
if not session_key or session.get("_finalized"):
return []
@@ -283,7 +320,6 @@ def _collect_kanban_notifications(session: dict) -> list:
from hermes_cli import kanban_db as _kb
except Exception:
return []
texts: list = []
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
@@ -291,22 +327,15 @@ def _collect_kanban_notifications(session: dict) -> list:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
except Exception:
return []
# Poll each resolved DB path once — multiple slugs can point at the same DB when
# HERMES_KANBAN_DB pins the board path (same guard as the gateway).
texts: list = []
seen_db_paths: set = set()
for board_meta in boards:
slug = (board_meta or {}).get("slug") or _kb.DEFAULT_BOARD
db_path = (board_meta or {}).get("db_path")
try:
resolved = str(Path(db_path).expanduser().resolve() if db_path else _kb.kanban_db_path(slug).resolve())
except Exception:
resolved = f"slug:{slug}"
slug, resolved = _kb_board_key(_kb, board_meta)
if resolved in seen_db_paths:
continue
seen_db_paths.add(resolved)
# One poller per live session: don't open this board writable unless it has a subscription
# owned by this exact session. If the read-only probe fails (locked/corrupt DB), preserve
# delivery and fall through.
# One poller per live session: don't open this board writable unless it has a subscription owned
# by this exact session. If the read-only probe fails (locked/corrupt DB), preserve delivery.
try:
if _kb.count_notify_subs(board=slug, platform="tui", chat_id=session_key) == 0:
continue
@@ -317,37 +346,15 @@ def _collect_kanban_notifications(session: dict) -> list:
except Exception:
continue
try:
try:
subs = _kb.list_notify_subs(conn)
except Exception:
continue
for sub in subs:
if (sub.get("platform") or "").lower() != "tui" or sub.get("chat_id") != session_key:
continue
sub_ident = dict(
task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
)
_old, _new, events = _kb.claim_unseen_events_for_sub(conn, kinds=_KANBAN_NOTIFY_KINDS, **sub_ident)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
texts.extend(t for t in (_format_kanban_event_text(sub, task, ev, slug) for ev in events) if t)
# Unsubscribe only on archive: ``done`` is reversible in review/controller flows, so
# keeping the sub lets a later reopen notify the same session. The claimed cursor
# prevents replay.
if task and getattr(task, "status", "") == "archived":
with contextlib.suppress(Exception):
_kb.remove_notify_sub(conn, **sub_ident)
texts.extend(_kb_poll_board(_kb, conn, slug, session_key))
finally:
conn.close()
return texts
def _notif_poll_kanban(sid: str, session: dict) -> None:
"""One kanban poll: emit new texts, buffer them, and run the buffered batch as a turn if idle.
Events are cursor-claimed (never re-queued), so they are buffered until the session is idle
instead of dropping the agent turn."""
"""One kanban poll: emit new texts, buffer them, and run the buffered batch as a turn if idle. Events
are cursor-claimed (never re-queued), so they wait in the buffer instead of dropping the agent turn."""
try:
_kanban_texts = _collect_kanban_notifications(session)
except Exception as _kb_exc:
@@ -357,27 +364,20 @@ def _notif_poll_kanban(sid: str, session: dict) -> None:
_emit("status.update", sid, {"kind": "process", "text": _kb_text})
if _kanban_texts:
session.setdefault("_kanban_pending", []).extend(_kanban_texts)
_pending = session.get("_kanban_pending") or []
if not _pending:
if not session.get("_kanban_pending") or not _notif_claim_turn(session):
return
_batch: list = []
with session["history_lock"]:
if not session.get("running"):
session["running"] = True
_batch = list(_pending)
session["_kanban_pending"] = []
if _batch:
with contextlib.suppress(Exception):
_notif_submit(
f"__notif__{int(time.time() * 1000)}", sid, session, "\n".join(_batch),
"kanban notification dispatch failed",
)
_batch, session["_kanban_pending"] = list(session.get("_kanban_pending") or []), []
with contextlib.suppress(Exception):
_notif_submit(
f"__notif__{int(time.time() * 1000)}", sid, session, "\n".join(_batch), "kanban notification dispatch failed"
)
def _notif_render_event(sid: str, evt: dict, emitted: set, process_registry, format_process_notification) -> Optional[str]:
"""Format ``evt`` and emit its status.update once; None means skip the event (consumed
completion or unformattable). Re-queued completions would otherwise re-emit every 0.5s while
the session is busy, while distinct watch_match events from one process must stay visible."""
"""Format ``evt`` and emit its status.update once; None means skip the event (consumed completion
or unformattable). Re-queued completions would otherwise re-emit every 0.5s while the session is
busy, while distinct watch_match events from one process must stay visible."""
if evt.get("type") == "completion" and process_registry.is_completion_consumed(evt.get("session_id", "")):
return None
text = format_process_notification(evt)
@@ -412,8 +412,8 @@ def _notif_dispatch_event(sid: str, session: dict, evt: dict, text: str) -> None
def _notif_classify_event(sid: str, session: dict, evt: dict) -> str:
"""'foreign' (another live session owns it), 'unowned' (addressed but unprovable — never adopt
an orphan), or 'mine' (ours, or an ownerless legacy notification kept process-global)."""
"""'foreign' (another live session owns it), 'unowned' (addressed but unprovable — never adopt an
orphan), or 'mine' (ours, or an ownerless legacy notification kept process-global)."""
if _notification_event_belongs_elsewhere(sid, session, evt):
return "foreign"
if _notification_event_requires_owner(evt) and not _session_owns_notification_event(sid, session, evt):
@@ -422,15 +422,15 @@ def _notif_classify_event(sid: str, session: dict, evt: dict) -> str:
def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred) -> bool:
"""Route one dequeued event. ``deferred`` is None in the live loop (foreign events go straight
back on the queue) and the shutdown-drain list otherwise (foreign + orphaned delegation
payloads are set aside for a resume). Returns False when the drain must stop (session busy)."""
"""Route one dequeued event. ``deferred`` is None in the live loop (foreign events go straight back
on the queue) and the shutdown-drain list otherwise (foreign + orphaned delegation payloads are set
aside for a resume). Returns False when the drain must stop (session busy)."""
queue = registry.completion_queue
owner = _notif_classify_event(sid, session, evt)
if owner == "foreign":
if deferred is None:
# Leave foreign events for their owner — otherwise a process started in session A
# surfaces its completion in whichever poller wakes first.
# Leave foreign events for their owner — otherwise a process started in session A surfaces
# its completion in whichever poller wakes first.
queue.put(evt)
time.sleep(0.1)
else:
@@ -459,8 +459,8 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred) ->
queue.put(evt)
if deferred is not None:
return False
# Back off: the re-queued event keeps the queue non-empty, so without a sleep this loop
# spins at 100% CPU while busy.
# Back off: the re-queued event keeps the queue non-empty, so without a sleep this loop spins
# at 100% CPU while busy.
time.sleep(0.25)
return True
_notif_dispatch_event(sid, session, evt, text)
@@ -470,10 +470,10 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred) ->
def _notification_poller_loop(stop_event: threading.Event, sid: str, session: dict) -> None:
"""Poll completion_queue and dispatch notifications autonomously (daemon thread started by
_init_session()): emit a status.update (kind=process), then chain an agent turn via
_run_prompt_submit if the session is idle. The queue is process-global: each poller requeues
events owned by another live session and drops addressed events whose owner is gone;
ownerless legacy notifications remain global. Also polls ``kanban_notify_subs`` every
``_KANBAN_POLL_SECONDS`` — the delivery path for platform="tui" rows."""
_run_prompt_submit if the session is idle. The queue is process-global: each poller requeues events
owned by another live session and drops addressed events whose owner is gone; ownerless legacy
notifications remain global. Also polls ``kanban_notify_subs`` every ``_KANBAN_POLL_SECONDS`` — the
delivery path for platform="tui" rows."""
from tools.process_registry import process_registry, format_process_notification
queue = process_registry.completion_queue
@@ -481,8 +481,8 @@ def _notification_poller_loop(stop_event: threading.Event, sid: str, session: di
_last_kanban_poll = _last_loop_poll = 0.0
while not stop_event.is_set() and not session.get("_finalized"):
_now = time.monotonic()
# /loop wakeup driver: fire a due tick for THIS session while idle (same claim-under-lock
# as kanban dispatch). An active non-parked /goal owns the idle boundary and defers it.
# /loop wakeup driver: fire a due tick for THIS session while idle (same claim-under-lock as
# kanban dispatch). An active non-parked /goal owns the idle boundary and defers it.
if _now - _last_loop_poll >= _LOOP_POLL_SECONDS:
_last_loop_poll = _now
try:
@@ -497,7 +497,6 @@ def _notification_poller_loop(stop_event: threading.Event, sid: str, session: di
except Exception:
continue
_notif_handle_event(sid, session, evt, _emitted, process_registry, format_process_notification, None)
# Drain remaining events after the stop signal so nothing is lost on shutdown; foreign and
# orphaned-delegation events are handed back to the shared queue afterwards.
deferred: list = []
@@ -529,11 +528,17 @@ def _async_delegation_display_metadata(evt: dict) -> dict:
return metadata
def _wire_agent_terminal_output() -> None:
"""Idempotently route background-process output (`agent.terminal.output` chunks) and
`process_registry.request_close_terminal` (`terminal.close`, drops a tab without killing the
process) to the window owning the process (its gateway session), keyed by process id.
`_emit` is `_stdout_lock`-guarded, so the registry's reader threads may call it."""
_desktop_ui_wired = False
def _wire_desktop_sinks() -> None:
"""Idempotently wire process-registry and desktop-tool sinks to renderer events. Background-process
output (`agent.terminal.output` chunks) and `process_registry.request_close_terminal`
(`terminal.close`, drops a tab without killing the process) route to the window owning the process
(its gateway session), keyed by process id; desktop-only tools (open_preview, close_preview,
focus_pane) hand back the turn's ``HERMES_UI_SESSION_ID`` as ``sid`` so the event routes to the
window that asked. `_emit` is `_stdout_lock`-guarded, so registry reader / tool threads may call it."""
global _desktop_ui_wired
from tools.process_registry import process_registry
def _owner_sid_for_process(session) -> str:
@@ -541,52 +546,35 @@ def _wire_agent_terminal_output() -> None:
if not session_key:
return ""
with _sessions_lock:
for sid, tui_session in _sessions.items():
if str(tui_session.get("session_key") or "") == session_key:
return sid
return ""
def _emit_agent_terminal_close(session, process_id):
# session may be None (process already finished/pruned) — the tab can still linger and
# be closed; route to the owning window when we can.
_emit("terminal.close", _owner_sid_for_process(session) if session is not None else "", {"process_id": process_id})
return next((sid for sid, s in _sessions.items() if str(s.get("session_key") or "") == session_key), "")
if getattr(process_registry, "on_output", None) is None:
process_registry.on_output = lambda session, chunk: _emit(
"agent.terminal.output", _owner_sid_for_process(session), {"process_id": session.id, "chunk": chunk}
)
if getattr(process_registry, "on_close", None) is None:
process_registry.on_close = _emit_agent_terminal_close
# session may be None (process already finished/pruned) — the tab can still linger and be closed.
process_registry.on_close = lambda session, process_id: _emit(
"terminal.close", _owner_sid_for_process(session) if session is not None else "", {"process_id": process_id}
)
if not _desktop_ui_wired:
try:
from tools import desktop_ui
except Exception:
return
desktop_ui.set_emitter(lambda sid, event, payload: _emit(event, sid, payload))
_desktop_ui_wired = True
_desktop_ui_wired = False
def _wire_desktop_ui() -> None:
"""Bridge desktop-only tools (open_preview, close_preview, focus_pane) to renderer events.
Idempotent. The tool hands back the turn's ``HERMES_UI_SESSION_ID`` as ``sid`` so the event
routes to the window that asked (``_emit`` is ``_stdout_lock``-guarded; tool thread may call it)."""
global _desktop_ui_wired
if _desktop_ui_wired:
return
try:
from tools import desktop_ui
except Exception:
return
desktop_ui.set_emitter(lambda sid, event, payload: _emit(event, sid, payload))
_desktop_ui_wired = True
# (stop_event, thread) for every poller started in this process, pruned of dead threads on each
# spawn. Test teardowns reap leaked pollers through it: an unjoined poller steals events off the
# process-global completion_queue mid-assertion in a LATER test.
# (stop_event, thread) for every poller started in this process, pruned of dead threads on each spawn.
# Test teardowns reap leaked pollers through it: an unjoined poller steals events off the process-global
# completion_queue mid-assertion in a LATER test.
_notification_pollers: list = []
def _start_notification_poller(sid: str, session: dict) -> threading.Event:
"""Start the background notification poller for a TUI session (thread name is greppable)."""
_wire_agent_terminal_output()
_wire_desktop_ui()
_wire_desktop_sinks()
stop = threading.Event()
t = threading.Thread(
target=_notification_poller_loop, args=(stop, sid, session), daemon=True, name=f"tui-notif-poller-{sid}"
@@ -607,10 +595,10 @@ def _hud_surface_note(session: dict) -> str:
def _prepend_note(run_message: Any, note: str) -> Any:
"""Prefix a per-turn note onto the MODEL INPUT, leaving the prompt alone. Everything the model
must know about the turn that the user did not type (interrupted reply, reactions, surface)
arrives this way; persist_user_message keeps the clean prompt, so no scaffolding reaches the
transcript, and annotating the NEW turn never rewrites a sent message — cached prefix survives."""
"""Prefix a per-turn note onto the MODEL INPUT, leaving the prompt alone. Everything the model must
know about the turn that the user did not type (interrupted reply, reactions, surface) arrives this
way; persist_user_message keeps the clean prompt, so no scaffolding reaches the transcript, and
annotating the NEW turn never rewrites a sent message — cached prefix survives."""
if not note:
return run_message
if isinstance(run_message, str):
+19 -19
View File
@@ -14,6 +14,7 @@ rides in the prompt text as a JSON object; any other prompt falls back to env /
from __future__ import annotations
import contextlib
import json
import os
import threading
@@ -23,6 +24,21 @@ from typing import Any, Callable, Optional
from tui_gateway._env import env_float as _env_float, env_int as _env_int
# Per-turn intensity spec: (key, caster, default source). ``chunk`` is pure-Python ops per
# interrupt-check chunk (small enough that an interrupt lands within ms, large enough to stay hot on
# the GIL); ``delta_interval_s`` is the streamed-delta cadence (each delta is a loop wakeup
# marshalling a frame); ``tokens_per_delta`` drives the 100K+-token heavy-turn proxy; ``sleep_s`` is
# an optional per-chunk sleep for a mixed regime (0 = pure burn; --dry-run uses a short duration, NOT
# a sleep, so it still exercises the real seam).
_SPEC_FIELDS = (
("duration_s", float, lambda: _env_float("HERMES_ISO_CERTIFY_DURATION_S", 8.0)),
("chunk", int, lambda: _env_int("HERMES_ISO_CERTIFY_CHUNK", 20_000)),
("delta_interval_s", float, lambda: _env_float("HERMES_ISO_CERTIFY_DELTA_S", 0.05)),
("tokens_per_delta", int, lambda: _env_int("HERMES_ISO_CERTIFY_TPD", 512)),
("sleep_s", float, lambda: 0.0),
)
def synth_turn_armed() -> bool:
"""True when the synthetic-turn test seam is armed via env."""
return os.environ.get("HERMES_ISO_CERTIFY_SYNTH_TURN") == "1"
@@ -74,26 +90,10 @@ class SyntheticHeavyAgent:
def _parse_spec(message: Any) -> dict[str, Any]:
spec: dict[str, Any] = {}
if isinstance(message, str) and message.strip().startswith("{"):
try:
with contextlib.suppress(ValueError, TypeError):
parsed = json.loads(message.strip())
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
spec = parsed
return {
# Wall-clock seconds of GIL-holding compute.
"duration_s": float(spec.get("duration_s", _env_float("HERMES_ISO_CERTIFY_DURATION_S", 8.0))),
# Pure-Python ops per interrupt-check chunk: small enough that an interrupt lands within
# ms, large enough to stay hot on the GIL.
"chunk": int(spec.get("chunk", _env_int("HERMES_ISO_CERTIFY_CHUNK", 20_000))),
# Streamed-delta cadence: each delta is a loop wakeup marshalling a frame.
"delta_interval_s": float(spec.get("delta_interval_s", _env_float("HERMES_ISO_CERTIFY_DELTA_S", 0.05))),
# Notional output tokens per delta (drives the 100K+-token heavy-turn proxy).
"tokens_per_delta": int(spec.get("tokens_per_delta", _env_int("HERMES_ISO_CERTIFY_TPD", 512))),
# Optional per-chunk sleep for a mixed regime (0 = pure burn). --dry-run uses a short
# duration, NOT a sleep, so it still exercises the real seam.
"sleep_s": float(spec.get("sleep_s", 0.0)),
}
spec = parsed if isinstance(parsed, dict) else {}
return {key: cast(spec.get(key, default())) for key, cast, default in _SPEC_FIELDS}
def run_conversation(
self,
+4 -9
View File
@@ -120,14 +120,9 @@ def read_turn_marker(home: Path | str, session_key: str) -> dict[str, Any] | Non
try:
with _lock:
entry = _load(_marker_path(home)).get(session_key)
prompt = str(entry.get("prompt") or "") if isinstance(entry, dict) else ""
if not prompt.strip():
return None
return {"attempts": max(0, int(entry.get("attempts") or 0)), "prompt": prompt, "started_at": _started_at(entry)}
except Exception:
return None
if not isinstance(entry, dict):
return None
prompt = str(entry.get("prompt") or "")
if not prompt.strip():
return None
try:
return {"attempts": max(0, int(entry.get("attempts") or 0)), "prompt": prompt, "started_at": _started_at(entry)}
except (TypeError, ValueError):
return None