review-fix(comments): restore lost rationale in the 7 files the sweep had to skip (docstring/comment-only)

This commit is contained in:
Teknium
2026-09-03 10:40:25 -07:00
parent fd333a67cd
commit d98c48b410
7 changed files with 168 additions and 12 deletions
+4 -1
View File
@@ -412,7 +412,10 @@ def coding_compact_skill_categories(*, platform: Optional[str] = None, cwd: Opti
def _git(cwd: Path, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure. bounded_git_probe
bounds post-kill cleanup on Windows — plain ``subprocess.run(timeout=...)`` deadlocked
when a killed git left a suspended descendant holding the pipe handles."""
when a killed git left a suspended descendant holding the pipe handles.
See #66037.
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
+13 -2
View File
@@ -44,7 +44,13 @@ class StatusOutputMixin:
def _should_emit_quiet_tool_messages(self) -> bool:
"""True when quiet-mode tool summaries should print directly (CLI, no callback owns rendering);
``suppress_status_output`` always wins so ``[tool]``/``[done]`` never land in captured stdout."""
``suppress_status_output`` always wins so ``[tool]``/``[done]`` never land in captured stdout.
``suppress_status_output`` (the strict machine-readable mode used by ``hermes chat -Q``) always
wins: those flows neutralize the rendering callbacks, and without this gate the "no callback owns
rendering" fallback would print ``[tool]``/``[done]`` spinner lines into the captured stdout it
exists to keep clean (#93220).
"""
if getattr(self, "suppress_status_output", False):
return False
return self.quiet_mode and not self.tool_progress_callback and getattr(self, "platform", "") == "cli"
@@ -92,7 +98,12 @@ class StatusOutputMixin:
))
def _warn_uncompressed_context_overflow(self, preflight_tokens: int, context_length: int) -> None:
"""Deduped warning when uncompressed context exceeds the model limit; points the user at /compact."""
"""Deduped warning when uncompressed context exceeds the model limit; points the user at /compact.
When compression is explicitly disabled (compression.enabled: false), long sessions can grow past
the model context window with no compression to shrink them (#89297). Surface an actionable warning
so the user knows to run /compact or enable compression.
"""
_warn_key = ("uncompressed_ctx_overflow", context_length)
if getattr(self, "_last_ctx_overflow_warn", None) != _warn_key:
self._last_ctx_overflow_warn = _warn_key
+46 -3
View File
@@ -185,7 +185,17 @@ def _session_start_like(agent: Any, now: Any) -> Any:
lineage-root session id's embedded stamp (compaction rotates ids, each with
its own mint time), the current session id's stamp, ``agent.session_start``,
then ``now``. Stamps are box-local wall-clock: attach that zone first, then
convert to ``now``'s zone so the date matches the per-turn clock."""
convert to ``now``'s zone so the date matches the per-turn clock.
0. the LINEAGE-ROOT session id's embedded timestamp — compaction can rotate the session id, and each
rotated id embeds its OWN mint time, so after months of compactions rung 1 alone would quietly re-birth
the conversation at its latest rotation. Walking to the lineage root (same walk as
``_conversation_root_id``) recovers the ORIGINAL birth stamp — a Bot Mode forever-chat keeps knowing
when it was first born, across every compaction (maintainer-directed, #98426); 1. the timestamp embedded
in ``session_id`` (``YYYYMMDD_HHMMSS_...``) — immutable for the life of the session, so the line is
byte-stable across every rebuild boundary (preserving prefix-cache KV); 2. 3. ``now`` (initial/legacy
build without either).
"""
from datetime import datetime
def _to_display_tz(dt: Any) -> Any:
if dt.tzinfo is None:
@@ -221,7 +231,17 @@ def _agent_home(agent: Any) -> Optional[Path]:
A bound HERMES_HOME ContextVar override wins (the gateway multiplexes
profiles over one shared session DB and binds the home per turn); else the
parent of ``_session_db.db_path`` — ground truth on threads that lost the
ContextVar, where ambient resolution would leak the launch profile."""
ContextVar, where ambient resolution would leak the launch profile.
1. Surfaces that multiplex several profiles over ONE shared session DB (the messaging gateway:
``gateway/run.py`` hands every agent the launch-home ``state.db`` and binds the profile home per turn
via ``_profile_runtime_scope`` + ``copy_context``) would otherwise have the db-derived launch home STOMP
the correctly-bound profile — inverting the leak this helper exists to fix (found by @kshitijk4poor's
post-merge probe on #86313). 2. Fallback: the home containing the agent's ``_session_db.db_path``
(``<home>/state.db``) — ground truth on threads that lost the ContextVar (ContextVars don't propagate
into ``threading.Thread``), where the unbound build previously fell back to the launch home and leaked
the default profile's skills/identity into a bot prompt.
"""
try:
from hermes_constants import get_hermes_home_override
override = get_hermes_home_override()
@@ -348,6 +368,11 @@ def _active_profile_line(agent: Any) -> str:
# A non-default name is only returned when the resolved home is ALREADY
# <root>/profiles/<name>, so the profile home is the session home itself.
profile_home = str(_agent_home_path) if _agent_home_path is not None else str(get_hermes_home())
# A non-default name is only ever returned when the resolved home is ALREADY <root>/profiles/<name> —
# that is exactly how both _profile_name_for_home() and _resolve_active_profile_name() derive it. So the
# profile home is the session home itself; appending /profiles/<name> again doubled it (#72894). The
# default profile's data sits at the ROOT (get_default_hermes_root()), which in ambient profile mode is
# NOT get_hermes_home().
default_root = get_default_hermes_root()
return (
f"Active Hermes profile: {active_profile}. This session reads "
@@ -419,6 +444,13 @@ def _timestamp_line(agent: Any) -> str:
_zone_suffix = f" ({', '.join(_bits)})" if _bits else ""
_start = _session_start_like(agent, now)
timestamp_line = f"Conversation started: {_start.strftime('%A, %B %d, %Y')}{_zone_suffix}"
# Second line (maintainer design, salvaging #96224's anchor): long-lived sessions — Bot Mode
# forever-chats, messenger channels people never close — span many days and many compactions. A lone
# birth date leads the model to believe it is still living in that old day. The prompt is rebuilt at
# every compaction boundary, so stamp the rebuild day too: 'started' stays anchored and byte-stable, 'as
# of' refreshes exactly when the cache prefix is already being invalidated (compaction), so the added
# line costs no extra cache churn. Same-day sessions skip the second line entirely — nothing to correct,
# and the single-line shape stays byte-identical for the day (prefix-cache safe).
if now.strftime("%Y%m%d") != _start.strftime("%Y%m%d"):
timestamp_line += (f"\nToday's date (as of the last context rebuild): {now.strftime('%A, %B %d, %Y')} "
"— trust this over the start date for what day it is now; query tools for exact time.")
@@ -439,6 +471,9 @@ def _memory_parts(agent: Any) -> List[str]:
block = agent._memory_store.format_for_system_prompt(kind) if enabled else None
if block:
parts.append(block)
# External memory provider system prompt block (additive to built-in). Gated on the same check
# ``inject_memory_provider_tools`` uses so we never advertise provider tools that the agent's toolset
# configuration has already gated off (#81014).
if agent._memory_manager:
try:
from agent.memory_manager import memory_provider_tools_exposed as _mem_exposed
@@ -621,7 +656,15 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
def invalidate_system_prompt(agent: Any) -> None:
"""Force a rebuild on the next turn (after compression): reload memory from
disk and clear the frozen plugin snapshot (previous bytes stashed as the
fail-open fallback) so plugins re-render at the same boundary."""
fail-open fallback) so plugins re-render at the same boundary.
Called after context compression events. Also reloads memory from disk so the rebuilt prompt captures
any writes from this session, and clears the frozen plugin-section snapshot so plugins re-render at the
same boundary (maintainer-directed, #95681 arc): a plugin section is just another prompt block carrying
state — freezing it while memory, skills, and guidance refresh would recreate the stale-block disease
inside plugin-land. The previous bytes are stashed so a plugin whose render RAISES falls back to its
last good section instead of vanishing (fail-open guard, not a freeze).
"""
agent._cached_system_prompt = None
agent._cached_system_prompt_static = None
if hasattr(agent, "_plugin_system_prompt_sections_snapshot"):
+28 -2
View File
@@ -25,6 +25,7 @@ FailureCallback = Callable[[str, BaseException], None]
TitleCallback = Callable[[str, str], None]
# () -> bool, called right before the LLM request; False skips (e.g. the user switched models and
# the request would reload one the runtime already evicted).
# Validation callback: () -> bool. See #19027.
RuntimeValidator = Callable[[], bool]
# Text budget handed to the model (Claude Code / OpenClaw converged on 1000).
@@ -32,6 +33,10 @@ MAX_TITLE_INPUT_CHARS = 1000
# Cap on the instant derived title; a raw fragment reads worse the longer it runs.
MAX_DERIVED_TITLE_CHARS = 48
# Answer-shaped guard: a tiny model sometimes answers instead of titling; longer is rejected, not truncated.
# Upper bound on accepted title word count. Titling is a 3-7 word task; a small tiny-model sometimes ignores
# the task and answers the user's message instead — that answer must never become the session title (see the
# answer-shaped output guard in generate_title; port of can1357/oh-my-pi#7306). 12 leaves headroom for
# legitimate wordy titles while excluding full-sentence answers.
_MAX_TITLE_WORDS = 12
_TITLE_PROMPT_TEMPLATE = (
@@ -78,6 +83,11 @@ _MACHINE_PREFIXES = (
"[CONTEXT COMPACTION", LEGACY_SUMMARY_PREFIX, "[Runtime note:", "[System note:", "[SYSTEM]",
# tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX (keep in sync); persisted as role="user" because
# strict providers reject a non-first system message.
# Model-switch marker from tui_gateway.server._append_model_switch_marker. It is persisted with
# role="user" (strict OpenAI-compatible providers reject a system message that is not first — #48338),
# so without this entry it looks like a real opening turn: switching models before the first real
# message titled the session "[System: The active model for this chat has…" instead of the user's actual
# question.
"[System: The active model for this chat has changed to ",
)
@@ -227,7 +237,12 @@ def generate_title(
runtime_validator: Optional[RuntimeValidator] = None,
) -> Optional[str]:
"""Title from the opening message alone (waiting for the assistant made this slow and bought
nothing). ``runtime_validator`` runs right before the request; False skips silently."""
nothing). ``runtime_validator`` runs right before the request; False skips silently.
If it returns False (e.g. the user's model was switched since the background thread captured its runtime
snapshot), the call is skipped silently — no request is sent, so a stale title request can't reload a
model the runtime already unloaded (#19027).
"""
if not _auto_title_enabled():
logger.debug("Auto-title skipped: auxiliary.title_generation.enabled=false")
return None
@@ -254,6 +269,11 @@ def generate_title(
extra_body={"response_format": _TITLE_RESPONSE_FORMAT},
)
title = _clean_title(_extract_title_text(response.choices[0].message.content or ""))
# Answer-shaped output guard: titling is a 3-7 word task, so a title with many words is a model that
# ignored the task and answered the user's message instead ("I don't have context on X — that's not
# something I recognize..."). Truncating would store half an assistant blob as the session title,
# which is still an assistant blob — reject instead so the caller retries on the next exchange
# (maybe_auto_title fires for the first two exchanges). Port of can1357/oh-my-pi#7306.
if title is not None and len(title.split()) > _MAX_TITLE_WORDS:
# Answer-shaped output: reject (not truncate) so the caller retries next exchange.
logger.debug("Rejecting answer-shaped title output (%d words > %d)", len(title.split()), _MAX_TITLE_WORDS)
@@ -283,7 +303,11 @@ def _persist_session_title(session_db, session_id, title, *, source, dedupe=True
transaction, so a manual ``/title`` is never overwritten); None when a higher authority held the row.
``ValueError`` = unique-title index collision → append ``#N`` via ``get_next_title_in_lineage``;
``dedupe=False`` re-raises instead (the derived title is on the critical path, collides constantly
on "hi", and the model replaces it a second later anyway)."""
on "hi", and the model replaces it a second later anyway).
``ValueError`` means the name is taken by an unrelated session (the unique-title index); rather than
leave the session untitled (#50537), append a ``#N`` suffix via ``get_next_title_in_lineage``.
"""
auto_fn = getattr(session_db, "set_auto_title", None)
def _set(candidate):
@@ -348,6 +372,8 @@ def auto_title_session(
with suppress(Exception):
conversation_id = session_db.get_conversation_root(session_id) or session_id
set_conversation_context(conversation_id)
# Same for the accounting context, so the title call's token usage is recorded against this session
# (task='title_generation', #23270).
set_accounting_context(session_db, session_id)
title, source = generate_title(
user_message, failure_callback=failure_callback, main_runtime=main_runtime, runtime_validator=runtime_validator,
+50 -3
View File
@@ -93,7 +93,13 @@ def _ensure_file_checkpoint(agent, function_name: str, function_args: dict, effe
def _budget_for_agent(agent) -> BudgetConfig:
"""Tool-result BudgetConfig scaled to the agent's context window. Unknown length goes
through ``budget_for_context_window(None)`` (not DEFAULT_BUDGET) so the MCP threshold
override still applies."""
override still applies.
Large-context models keep the historical 100K/200K char defaults; small models (e.g. a 65K-token local
model switched into mid-session) get a budget proportional to their window so a single large tool result
can't push the request past the model's limit (#23767). Falls back to the default budget when the
context length isn't resolvable.
"""
try:
ctx = getattr(getattr(agent, "context_compressor", None), "context_length", None)
return budget_for_context_window(int(ctx) if ctx else None)
@@ -114,10 +120,22 @@ def _authorization_gate_lock_timeout() -> float:
"""Authorization-lock bound = ``tools.approval.human_wait_ceiling`` (approval timeout +
margin, capped so it can't overflow Lock.acquire): never break serialization while a
prompt is answerable, never let a wedged holder park workers forever. Deliberately NOT
min()'d with the fallback so the gate never gives up early."""
min()'d with the fallback so the gate never gives up early.
Delegates to ``tools.approval.human_wait_ceiling`` — the same bound that clamps a human-wait window's
deadline contribution — so the two can't drift. Long enough that serialization is never broken while a
legitimate approval prompt is still answerable; short enough that a wedged holder (hanging
``pre_tool_call`` plugin, dead approval client) cannot park other workers forever (#79719). Resolved
once per gate (per batch), so a mid-process ``approvals.timeout`` change applies from the next batch.
"""
try:
from tools.approval import human_wait_ceiling
# human_wait_ceiling is platform-safety-capped (agent/deadline.py MAX_SAFE_TIMEOUT_S): a huge
# approvals.timeout can no longer overflow Lock.acquire's time_t on macOS (#83220). Deliberately NOT
# min()'d with _AUTHORIZATION_GATE_LOCK_TIMEOUT_S — the gate must never give up while a legitimate
# approval prompt is still answerable (#79719), so a configured approvals.timeout above 360s must
# extend the gate.
return human_wait_ceiling()
except Exception:
return _AUTHORIZATION_GATE_LOCK_TIMEOUT_S
@@ -208,7 +226,12 @@ def _ra():
def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool:
"""Shutdown-race predicate; ``tools.interpreter_shutdown`` knows both CPython message variants."""
"""Shutdown-race predicate; ``tools.interpreter_shutdown`` knows both CPython message variants.
Delegates so all sites (cron delivery, conversation-loop retry, tool submission) recognize both CPython
shutdown-message variants instead of each matching its own substring (the bug class behind
#55924/#58720).
"""
from tools.interpreter_shutdown import interpreter_shutting_down
return interpreter_shutting_down(exc)
@@ -431,6 +454,17 @@ class _ConcurrentToolAuthorizationGate:
the batch behind a wedged plugin/approval client. Exclusion is measured at the SOURCE
of the human wait (``tools.approval.human_wait_seconds``), NOT as gate residency —
residency-based exclusion let a wedged plugin keep the deadline from ever firing.
Serialization keeps concurrent approval prompts from interleaving on the user's screen. The acquire is
BOUNDED: a worker wedged inside the gate (a hanging ``pre_tool_call`` plugin, or an approval round-trip
to a client that went away) must not park every other worker forever. On expiry the worker runs its
prompt unserialized — worst case is interleaved prompts, strictly better than permanent starvation (same
tradeoff as the start-order gate, #79705).
Gate residency is arbitrary code — using it as the exclusion signal let a wedged plugin grow the
exclusion 1:1 with wall clock, keeping the batch deadline's ``remaining`` constant so it never fired and
the turn hung forever (#79719). A wedged plugin now contributes nothing to the exclusion and the batch
times out normally, while a genuine approval wait (which can legitimately exceed any fixed bound) is
still excluded in full.
"""
def __init__(self, *, lock_timeout: float | None = None, session_key: str | None = None) -> None:
@@ -462,6 +496,13 @@ class _ConcurrentToolAuthorizationGate:
def run(self, callback):
if not self._serialization_lock.acquire(timeout=self._lock_timeout):
# Deterministic failure (bad command, non-MCP URL, 401/403): every retry hits the same wall.
# Park immediately instead of burning the retry ladder and spamming N identical warnings
# (#65673). Auth failures park here too rather than returning. Returning ends the run task, and
# with it the only listener on ``_reconnect_event`` — so a 401 on the very first connect left
# the server unrevivable for the life of the process, even after the user re-authenticated with
# ``hermes mcp login``. Parking keeps the task alive so the 300s self-probe (and an explicit
# /mcp refresh) can pick up fresh tokens.
logger.warning(
"authorization gate lock not acquired after %.1fs "
"(holder wedged in a pre_tool_call plugin or approval "
@@ -538,6 +579,10 @@ def _run_with_activity_heartbeat(agent, function_name: str, fn):
"""Run ``fn()`` under the activity heartbeat; covers both executor paths."""
stop = threading.Event()
thread = threading.Thread(
# Keep the gateway turn-inactivity watchdog from abandoning a turn whose tool call runs silently for
# longer than the inactivity timeout (#84491): stamp activity periodically while the tool is in
# flight, not just at start/completion. Both the sequential and the concurrent paths funnel through
# here, so a single heartbeat covers every tool.
target=_run_tool_activity_heartbeat,
args=(agent, stop, f"tool running: {function_name}"),
kwargs={"interval": _TOOL_ACTIVITY_HEARTBEAT_INTERVAL_S},
@@ -1107,6 +1152,8 @@ class _ConcurrentBatch:
abandoned at the gate (the main thread already wrote this slot; emitting would
double-report the tool_call_id)."""
agent = self.agent
# Approval/sudo callbacks (thread-local) and the agent turn's ContextVars are propagated by
# propagate_context_to_thread() at the submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
start = time.time()
blocked = dispatched = False
try:
+11
View File
@@ -289,6 +289,11 @@ class ToolCallGuardrailController:
self._halt_decision: ToolGuardrailDecision | None = None
# Identical-call streak: CONSECUTIVE identical (tool, args, result) calls; any different call or
# result resets it, so re-reads after edits and varied polling are never flagged.
# Identical-call loop-breaker state (agent.stall_guards): tracks the CONSECUTIVE streak of identical
# (tool, canonical args) calls whose results were also identical. Per-turn, like everything else
# here. NOTE: open PR #85352 (patrykkopycinski) tracks no-progress loops ACROSS turns via a
# detection window — a different mechanism from this per-turn consecutive streak. Coordinate future
# work there.
self._identical_streak_sig: ToolCallSignature | None = None
self._identical_streak_result_hash: str = ""
self._identical_streak_count: int = 0
@@ -353,6 +358,12 @@ class ToolCallGuardrailController:
# same_tool_failure counts DIFFERENT args on one tool; for failure-tolerant
# tools a run of distinct red commands is diagnosis, not a loop — warn, never halt.
if (
# Hard-stop widening (#89069 / #100849 bundle): the per-turn no-progress BLOCK above only
# covers tools in idempotent_tools, so a model replaying the same successful
# `terminal`/`skill_view` call with a byte-identical result ran until the iteration budget.
# The consecutive-identical streak is tool-agnostic; when hard stops are enabled, halt at
# the same idempotent_no_progress threshold. Pollers stay exempt (an unchanged poll is
# progress).
self.config.hard_stop_enabled
and tool_name not in FAILURE_TOLERANT_TOOL_NAMES
and same_count >= self.config.same_tool_failure_halt_after
+16 -1
View File
@@ -87,6 +87,11 @@ def _guard_credential_read(host_target: Path, src: str) -> None:
"""Shared credential-read guard: refuse secret-bearing files (.env, auth.json) with a specific
error. Guard import is best-effort; a real block always propagates."""
try:
# Shared credential-read guard (agent.file_safety, #57698): refuse secret-bearing files (.env,
# auth.json, ...) with an intentional, specific error instead of relying on the magic-byte sniff to
# reject them incidentally. Same chokepoint the image-gen/video-gen provider plugins enforce on
# model-supplied local paths. Import is best-effort (guard unavailability must not break image
# loading); a real block always propagates.
from agent.file_safety import raise_if_read_blocked
except Exception: # noqa: BLE001 — guard unavailable: proceed
return
@@ -190,7 +195,12 @@ def _get_active_env(task_id: Optional[str]):
def _ensure_container_env(task_id: Optional[str]) -> None:
"""Lazily bring up the sandbox before an in-sandbox read (vision may be a session's first
action). Best-effort: failure leaves the env absent and the caller hits the fail-closed error."""
action). Best-effort: failure leaves the env absent and the caller hits the fail-closed error.
Unlike the terminal tool, vision never triggered environment creation, so a session whose first action
is ``vision_analyze`` on a container-only path under a non-local backend found no active env and failed
— until a terminal command happened to create one (issue #62825).
"""
if not task_id:
return
try:
@@ -208,8 +218,13 @@ async def _resolve_container_fallback(
Cold-start retry: under Docker the first exec against a fresh container can fail (empty
pipe) while a second succeeds. On final failure the container's output is folded into the
error so "no such file" / "permission denied" / "never came up" are distinguishable.
We retry once with a short delay before giving up, so callers don't see "could not read inside the
sandbox" on a file that is verifiably readable on the immediate retry. See #76566.
"""
import shlex
# Bring the sandbox up on demand: without this, the first vision_analyze of a session (before any
# terminal command) has no active env to read from under a non-local backend (issue #62825).
_ensure_container_env(ctx.task_id)
env = _get_active_env(ctx.task_id)
if env is None: