Merge branch 'simp/r3-07-F_guard_stream' into simp/r3-07
This commit is contained in:
+15
-35
@@ -1,57 +1,44 @@
|
||||
"""Cheap content-sanity checks for the truncated-response continuation path.
|
||||
|
||||
A model in a degenerate repetition loop can spend its ENTIRE output budget
|
||||
echoing one fragment; the ``finish_reason=length`` continuation path would
|
||||
then stitch that fragment into the final response with a "continue" nudge
|
||||
(one incident: a 60k-char turn delivered as 31 Discord messages). These
|
||||
helpers detect repetition-dominated fragments BEFORE the nudge so the turn
|
||||
aborts with a clear error (like the ``_thinking_exhausted`` guard).
|
||||
|
||||
Deliberately conservative: only LONG verbatim repeats (60+ chars) covering a
|
||||
majority of the fragment trip the guard, so ordinary truncations (a sentence
|
||||
cut mid-word, a repeated heading, similar code lines) are never blocked.
|
||||
A model in a degenerate repetition loop can spend its ENTIRE output budget echoing one fragment;
|
||||
the ``finish_reason=length`` continuation would then stitch it into the final response with a
|
||||
"continue" nudge (one incident: a 60k-char turn delivered as 31 Discord messages). This detects
|
||||
repetition-dominated fragments BEFORE the nudge so the turn aborts with a clear error. Deliberately
|
||||
conservative: only LONG verbatim repeats (60+ chars) covering a majority of the fragment trip it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
|
||||
# Below this length the check doesn't run: short truncations trivially
|
||||
# contain repeated tokens and are legitimately continued.
|
||||
MIN_FRAGMENT_LENGTH = 400
|
||||
|
||||
# Exact-repeat window; a verbatim repeat this long is far beyond ordinary
|
||||
# phrasing reuse (citations, headings, similar code).
|
||||
# Exact-repeat window; far beyond ordinary phrasing reuse (citations, headings, similar code).
|
||||
_REPEAT_WINDOW = 60
|
||||
|
||||
# A window repeating at least this often is a signal even for short fragments.
|
||||
_MIN_REPEAT_COUNT = 5
|
||||
|
||||
# "Repetition-dominated" = repeated windows cover at least this fraction.
|
||||
_DOMINANCE_RATIO = 0.5
|
||||
|
||||
|
||||
def is_repetition_dominated(text: str) -> bool:
|
||||
"""True when a single 60+ char substring recurs often enough to cover at
|
||||
least half of ``text`` — the signature of a repetition loop, where
|
||||
continuing would only stitch in more repeats.
|
||||
|
||||
Fail-open: False for non-string / empty / short inputs.
|
||||
"""
|
||||
"""True when a single 60+ char substring recurs often enough to cover at least half
|
||||
of ``text`` — the signature of a repetition loop. Fail-open for non-string/short input."""
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
n = len(text)
|
||||
if n < MIN_FRAGMENT_LENGTH:
|
||||
return False
|
||||
|
||||
# Fast path: one normalized line duplicated enough to cover half the
|
||||
# fragment (the most common echo shape). Cheap, no big allocations.
|
||||
# Fast path: one normalized line duplicated enough to cover half the fragment (the common echo shape).
|
||||
if _line_repetition_dominated(text, n):
|
||||
return True
|
||||
|
||||
# General path: fixed-size windows sliding one char at a time, catching
|
||||
# loops that don't align to line boundaries. A window must appear
|
||||
# ``needed`` times to cover >= _DOMINANCE_RATIO (and >= _MIN_REPEAT_COUNT).
|
||||
# General path: fixed-size windows sliding one char at a time, catching loops that
|
||||
# don't align to line boundaries. A window must appear ``needed`` times to cover
|
||||
# >= _DOMINANCE_RATIO (and >= _MIN_REPEAT_COUNT).
|
||||
window = _REPEAT_WINDOW
|
||||
needed = max(_MIN_REPEAT_COUNT, math.ceil(n * _DOMINANCE_RATIO / window))
|
||||
counts: dict[str, int] = {}
|
||||
@@ -66,12 +53,5 @@ def is_repetition_dominated(text: str) -> bool:
|
||||
|
||||
def _line_repetition_dominated(text: str, n: int) -> bool:
|
||||
"""True when a single normalized line covers half the fragment via repeats."""
|
||||
counts: dict[str, int] = {}
|
||||
for line in text.splitlines():
|
||||
norm = line.strip()
|
||||
if norm:
|
||||
counts[norm] = counts.get(norm, 0) + 1
|
||||
return any(
|
||||
c >= _MIN_REPEAT_COUNT and c * len(line) >= n * _DOMINANCE_RATIO
|
||||
for line, c in counts.items()
|
||||
)
|
||||
counts = Counter(norm for norm in (line.strip() for line in text.splitlines()) if norm)
|
||||
return any(c >= _MIN_REPEAT_COUNT and c * len(line) >= n * _DOMINANCE_RATIO for line, c in counts.items())
|
||||
|
||||
+89
-174
@@ -1,7 +1,7 @@
|
||||
"""User-facing status / warning / notice plumbing for ``AIAgent``.
|
||||
|
||||
Safe printing, quiet-mode gating, deduped context-overflow warnings, and the buffered retry chatter that
|
||||
is shown only when every retry/fallback is exhausted.
|
||||
Safe printing, quiet-mode gating, deduped context-overflow warnings, and the buffered retry
|
||||
chatter that is shown only when every retry/fallback is exhausted.
|
||||
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
|
||||
"""
|
||||
import logging
|
||||
@@ -17,125 +17,82 @@ class StatusOutputMixin:
|
||||
"""Status/warning/notice emission and retry-chatter buffering (see module docstring)."""
|
||||
|
||||
def _safe_print(self, *args, **kwargs):
|
||||
"""Print that silently handles broken pipes / closed stdout.
|
||||
|
||||
Headless stdout (systemd, Docker, nohup) can vanish mid-session and a raw ``print()`` would crash cron
|
||||
jobs.
|
||||
Routes through ``self._print_fn`` so the CLI can inject an ANSI-aware renderer.
|
||||
"""
|
||||
"""Print that swallows broken pipes / closed stdout (headless stdout can vanish mid-session);
|
||||
routes through ``self._print_fn`` so the CLI can inject an ANSI-aware renderer."""
|
||||
try:
|
||||
fn = self._print_fn or print
|
||||
fn(*args, **kwargs)
|
||||
(self._print_fn or print)(*args, **kwargs)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _vprint(self, *args, force: bool = False, **kwargs):
|
||||
"""Verbose print — suppressed when actively streaming tokens.
|
||||
|
||||
``force=True`` always shows (errors/warnings). Printing is allowed during tool execution (no tokens
|
||||
streaming), muted after the main response (``_mute_post_response``), and fully suppressed under
|
||||
``suppress_status_output`` (machine-readable ``hermes chat -q``).
|
||||
"""
|
||||
"""Verbose print — suppressed while tokens are streaming (allowed during tool execution) and after
|
||||
the main response; ``force=True`` bypasses both. ``suppress_status_output`` (``hermes chat -q``) wins."""
|
||||
if getattr(self, "suppress_status_output", False):
|
||||
return
|
||||
if not force and getattr(self, "_mute_post_response", False):
|
||||
return
|
||||
if not force and self._has_stream_consumers() and not self._executing_tools:
|
||||
return
|
||||
self._safe_print(*args, **kwargs)
|
||||
if force or not (getattr(self, "_mute_post_response", False) or (self._has_stream_consumers() and not self._executing_tools)):
|
||||
self._safe_print(*args, **kwargs)
|
||||
|
||||
def _should_start_quiet_spinner(self) -> bool:
|
||||
"""Return True when quiet-mode spinner output has a safe sink.
|
||||
|
||||
A raw spinner falling back to ``sys.stdout`` can corrupt protocol streams (ACP JSON-RPC); allow it
|
||||
only when output is rerouted via ``_print_fn`` or stdout is a real TTY.
|
||||
"""
|
||||
"""True when quiet-mode spinner output has a safe sink (``_print_fn`` or a real TTY); a raw spinner
|
||||
on a non-TTY stdout can corrupt protocol streams (ACP JSON-RPC)."""
|
||||
if self._print_fn is not None:
|
||||
return True
|
||||
stream = getattr(sys, "stdout", None)
|
||||
if stream is None:
|
||||
return False
|
||||
try:
|
||||
return bool(stream.isatty())
|
||||
return bool(sys.stdout.isatty())
|
||||
except (AttributeError, ValueError, OSError):
|
||||
return False
|
||||
|
||||
def _should_emit_quiet_tool_messages(self) -> bool:
|
||||
"""Return True when quiet-mode tool summaries should print directly.
|
||||
|
||||
The CLI wants compact hints when no callback owns rendering; embedded callers expect true silence.
|
||||
``suppress_status_output`` always wins so ``[tool]``/``[done]`` lines never land in captured stdout.
|
||||
"""
|
||||
"""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."""
|
||||
if getattr(self, "suppress_status_output", False):
|
||||
return False
|
||||
return (
|
||||
self.quiet_mode
|
||||
and not self.tool_progress_callback
|
||||
and getattr(self, "platform", "") == "cli"
|
||||
)
|
||||
return self.quiet_mode and not self.tool_progress_callback and getattr(self, "platform", "") == "cli"
|
||||
|
||||
def _call_callback(self, name: str, *args, origin: str) -> None:
|
||||
"""Invoke ``self.<name>(*args)`` if set, swallowing errors — a driver callback must never break the loop."""
|
||||
cb = getattr(self, name, None)
|
||||
if cb:
|
||||
try:
|
||||
cb(*args)
|
||||
except Exception:
|
||||
logger.debug("%s error in %s", name, origin, exc_info=True)
|
||||
|
||||
def _emit_status_kind(self, kind: str, message: str, *, origin: str) -> None:
|
||||
"""Print to the CLI (``_vprint(force=True)``) and forward to ``status_callback(kind, message)``.
|
||||
|
||||
Never raises — status output must not interrupt the retry/fallback logic.
|
||||
"""
|
||||
"""Print to the CLI (``_vprint(force=True)``) and forward to ``status_callback(kind, message)``. Never raises."""
|
||||
try:
|
||||
self._vprint(f"{self.log_prefix}{message}", force=True)
|
||||
except Exception:
|
||||
pass
|
||||
if self.status_callback:
|
||||
try:
|
||||
self.status_callback(kind, message)
|
||||
except Exception:
|
||||
logger.debug("status_callback error in %s", origin, exc_info=True)
|
||||
self._call_callback("status_callback", kind, message, origin=origin)
|
||||
|
||||
def _emit_status(self, message: str) -> None:
|
||||
"""Emit a lifecycle status message (CLI + gateway ``status_callback``)."""
|
||||
self._emit_status_kind("lifecycle", message, origin="_emit_status")
|
||||
|
||||
def _emit_warning(self, message: str) -> None:
|
||||
"""Emit a user-visible warning for degraded side paths (aux compression, memory flushes) where the
|
||||
turn continues but the user must know."""
|
||||
"""Emit a user-visible warning for degraded side paths where the turn continues but the user must know."""
|
||||
self._emit_status_kind("warn", message, origin="_emit_warning")
|
||||
|
||||
def _warn_context_overflow_blocked(
|
||||
self, reason: str, preflight_tokens: int, threshold_tokens: int
|
||||
) -> None:
|
||||
"""Warn (deduped) when context is over the compression threshold but compression is blocked.
|
||||
|
||||
Without this the session grows until the provider hard limit with no explanation. Dedup is on the
|
||||
block
|
||||
*kind* (``cooldown`` / ``ineffective``), not the countdown string; cleared by
|
||||
``_clear_context_overflow_warn``.
|
||||
"""
|
||||
def _warn_context_overflow_blocked(self, reason: str, preflight_tokens: int, threshold_tokens: int) -> None:
|
||||
"""Warn (deduped on the block *kind* — ``cooldown`` / ``ineffective`` — not the countdown string;
|
||||
cleared by ``_clear_context_overflow_warn``) when context is over the threshold but compression is blocked."""
|
||||
_warn_kind = (reason or "unknown").split(":", 1)[0]
|
||||
_warn_key = ("ctx_overflow_blocked", _warn_kind)
|
||||
if getattr(self, "_last_ctx_overflow_warn", None) != _warn_key:
|
||||
self._last_ctx_overflow_warn = _warn_key
|
||||
from agent.conversation_compression import (
|
||||
CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE,
|
||||
)
|
||||
# cooldown + anti-thrash (ineffective) are both "compression blocked".
|
||||
if _warn_kind in ("cooldown", "ineffective"):
|
||||
self._touch_activity(
|
||||
f"compression blocked ({reason})",
|
||||
provenance=ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
|
||||
)
|
||||
self._emit_warning(
|
||||
CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE.format(
|
||||
tokens=preflight_tokens,
|
||||
threshold=threshold_tokens,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
if getattr(self, "_last_ctx_overflow_warn", None) == _warn_key:
|
||||
return
|
||||
self._last_ctx_overflow_warn = _warn_key
|
||||
from agent.conversation_compression import CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE
|
||||
|
||||
def _warn_uncompressed_context_overflow(
|
||||
self, preflight_tokens: int, context_length: int
|
||||
) -> None:
|
||||
"""Surface a deduped warning when uncompressed context exceeds the model limit.
|
||||
# cooldown + anti-thrash (ineffective) are both "compression blocked".
|
||||
if _warn_kind in ("cooldown", "ineffective"):
|
||||
self._touch_activity(f"compression blocked ({reason})", provenance=ActivityProvenance.AGENT_COMPRESSION_COOLDOWN)
|
||||
self._emit_warning(CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE.format(
|
||||
tokens=preflight_tokens, threshold=threshold_tokens, reason=reason,
|
||||
))
|
||||
|
||||
With compression disabled the session can outgrow the window silently; point the user at /compact.
|
||||
"""
|
||||
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."""
|
||||
_warn_key = ("uncompressed_ctx_overflow", context_length)
|
||||
if getattr(self, "_last_ctx_overflow_warn", None) != _warn_key:
|
||||
self._last_ctx_overflow_warn = _warn_key
|
||||
@@ -151,57 +108,32 @@ class StatusOutputMixin:
|
||||
self._last_ctx_overflow_warn = None
|
||||
|
||||
def _emit_notice(self, notice) -> None:
|
||||
"""Fire a structured ``AgentNotice`` to the active driver (TUI / CLI).
|
||||
|
||||
Driver-agnostic; swallows callback errors — a notice must never break the agent loop.
|
||||
"""
|
||||
if self.notice_callback:
|
||||
try:
|
||||
self.notice_callback(notice)
|
||||
except Exception:
|
||||
logger.debug("notice_callback error in _emit_notice", exc_info=True)
|
||||
"""Fire a structured ``AgentNotice`` to the active driver (TUI / CLI)."""
|
||||
self._call_callback("notice_callback", notice, origin="_emit_notice")
|
||||
|
||||
def _emit_notice_clear(self, key: str) -> None:
|
||||
"""Clear a previously-fired sticky notice by ``key`` (e.g. on recovery)."""
|
||||
if self.notice_clear_callback:
|
||||
try:
|
||||
self.notice_clear_callback(key)
|
||||
except Exception:
|
||||
logger.debug("notice_clear_callback error in _emit_notice_clear", exc_info=True)
|
||||
self._call_callback("notice_clear_callback", key, origin="_emit_notice_clear")
|
||||
|
||||
def _emit_wait_notice(self, text: str) -> None:
|
||||
"""Surface a live wait-state explanation on every driver.
|
||||
|
||||
Rewrites the live status line (CLI spinner via ``thinking_callback``, TUI/Desktop ``thinking.delta``,
|
||||
gateway ``_touch_activity`` description) so long provider waits are not an anonymous spinner. Never
|
||||
raises.
|
||||
"""
|
||||
"""Rewrite the live status line (CLI spinner, TUI ``thinking.delta``, gateway activity)
|
||||
so long provider waits are not an anonymous spinner."""
|
||||
self._touch_activity(text)
|
||||
_thinking_cb = getattr(self, "thinking_callback", None)
|
||||
if _thinking_cb:
|
||||
try:
|
||||
_thinking_cb(text)
|
||||
except Exception:
|
||||
logger.debug("thinking_callback error in _emit_wait_notice", exc_info=True)
|
||||
self._call_callback("thinking_callback", text, origin="_emit_wait_notice")
|
||||
|
||||
# ── Buffered retry/fallback status ──
|
||||
# Retry chatter is buffered and shown only when every retry/fallback is exhausted; dropped on
|
||||
# success. Backend logs are unaffected (every site still logs).
|
||||
# ── Buffered retry/fallback status: shown only when every retry/fallback is exhausted, dropped on
|
||||
# success. Backend logs are unaffected (every site still logs). ──
|
||||
|
||||
def _buffer_retry_message(self, kind: str, message: str) -> None:
|
||||
"""Buffer a retry/fallback line as ``(kind, text)`` until we know whether the turn recovered.
|
||||
|
||||
``kind`` is ``"status"`` (replays via ``_emit_status``), ``"vprint"`` (``_vprint(force=True)``) or
|
||||
``"warn"`` (``_emit_warning``). Never breaks the retry loop on a buffer hiccup.
|
||||
``"warn"`` (``_emit_warning``).
|
||||
"""
|
||||
try:
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if buf is None:
|
||||
buf = []
|
||||
self._retry_status_buffer = buf
|
||||
buf.append((kind, message))
|
||||
except Exception:
|
||||
pass
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if buf is None:
|
||||
buf = self._retry_status_buffer = []
|
||||
buf.append((kind, message))
|
||||
|
||||
def _buffer_status(self, message: str) -> None:
|
||||
self._buffer_retry_message("status", message)
|
||||
@@ -211,59 +143,42 @@ class StatusOutputMixin:
|
||||
|
||||
def _clear_status_buffer(self) -> None:
|
||||
"""Drop buffered retry messages — call on successful recovery."""
|
||||
try:
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if buf:
|
||||
buf.clear()
|
||||
except Exception:
|
||||
pass
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if buf:
|
||||
buf.clear()
|
||||
|
||||
def _emit_pending_fallback_notice(self) -> None:
|
||||
"""Surface the one-shot fallback-switch notice on successful recovery.
|
||||
|
||||
A provider switch is durable state operators must see, unlike the transient retry chatter
|
||||
``_clear_status_buffer`` drops. Emitted exactly once, then cleared; on terminal failure the buffered
|
||||
switch line is flushed instead (``_flush_status_buffer``).
|
||||
"""
|
||||
try:
|
||||
notice = getattr(self, "_pending_fallback_notice", None)
|
||||
if notice:
|
||||
# Clear before emitting so a (swallowed) callback error can't
|
||||
# leave the notice set for a stale re-emit on a later turn.
|
||||
self._pending_fallback_notice = None
|
||||
notices = notice if isinstance(notice, list) else [notice]
|
||||
for item in notices:
|
||||
try:
|
||||
self._emit_status(str(item))
|
||||
except Exception:
|
||||
# A single surface callback failure must not hide later
|
||||
# switches from the same fallback chain.
|
||||
continue
|
||||
except Exception:
|
||||
# Never break the conversation loop on a notice hiccup.
|
||||
pass
|
||||
"""Surface the one-shot fallback-switch notice on successful recovery: a provider switch is durable
|
||||
state operators must see, unlike the retry chatter ``_clear_status_buffer`` drops. Emitted once, then
|
||||
cleared; on terminal failure the buffered switch line is flushed instead (``_flush_status_buffer``)."""
|
||||
notice = getattr(self, "_pending_fallback_notice", None)
|
||||
if not notice:
|
||||
return
|
||||
# Clear before emitting so a (swallowed) callback error can't leave a stale re-emit.
|
||||
self._pending_fallback_notice = None
|
||||
for item in notice if isinstance(notice, list) else [notice]:
|
||||
try:
|
||||
self._emit_status(str(item))
|
||||
except Exception:
|
||||
# One surface failure must not hide later switches from the same chain.
|
||||
continue
|
||||
|
||||
def _flush_status_buffer(self) -> None:
|
||||
"""Emit buffered retry messages — call on terminal failure so the user sees what was tried."""
|
||||
try:
|
||||
# The buffered trace already carries the switch line; drop the one-shot notice to avoid a stale
|
||||
# duplicate.
|
||||
self._pending_fallback_notice = None
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if not buf:
|
||||
return
|
||||
# Drain first so a callback exception doesn't double-emit.
|
||||
messages = list(buf)
|
||||
buf.clear()
|
||||
for kind, msg in messages:
|
||||
try:
|
||||
if kind == "status":
|
||||
self._emit_status(msg)
|
||||
elif kind == "warn":
|
||||
self._emit_warning(msg)
|
||||
else:
|
||||
self._vprint(f"{self.log_prefix}{msg}", force=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# The buffered trace already carries the switch line; drop the one-shot notice.
|
||||
self._pending_fallback_notice = None
|
||||
buf = getattr(self, "_retry_status_buffer", None)
|
||||
if not buf:
|
||||
return
|
||||
# Drain first so a callback exception doesn't double-emit.
|
||||
messages = list(buf)
|
||||
buf.clear()
|
||||
replay = {"status": self._emit_status, "warn": self._emit_warning}
|
||||
for kind, msg in messages:
|
||||
try:
|
||||
if kind in replay:
|
||||
replay[kind](msg)
|
||||
else:
|
||||
self._vprint(f"{self.log_prefix}{msg}", force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+152
-303
@@ -19,278 +19,196 @@ logger = logging.getLogger("run_agent")
|
||||
class StreamDeliveryMixin:
|
||||
"""Stream ownership, delta/reasoning hook fan-out and interim-text dedup (see module docstring)."""
|
||||
|
||||
@staticmethod
|
||||
def _call_quietly(cb, *args) -> bool:
|
||||
"""Call ``cb(*args)`` if set, swallowing errors; True when it ran without raising."""
|
||||
if cb is None:
|
||||
return False
|
||||
try:
|
||||
cb(*args)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _deliver_to_stream_callbacks(self, text: str) -> bool:
|
||||
"""Send ``text`` to the display + TTS delta callbacks; True if at least one accepted it."""
|
||||
results = [self._call_quietly(cb, text) for cb in (self.stream_delta_callback, self._stream_callback)]
|
||||
return any(results)
|
||||
|
||||
def _enqueue_stream_hook(self, event: str, *, label: str | None = None, **fields: Any) -> None:
|
||||
"""Best-effort plugin stream hook enqueue; never raises into the stream path."""
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook
|
||||
|
||||
enqueue_plugin_stream_hook(event, **self._stream_hook_base_payload(), **fields)
|
||||
except Exception:
|
||||
logger.debug("%s plugin hook enqueue failed", label or event, exc_info=True)
|
||||
|
||||
def _reset_stream_delivery_tracking(self) -> None:
|
||||
"""Reset tracking for text delivered during the current model response."""
|
||||
# Flush the think scrubber's benign partial-tag tail first (#17924), then the context scrubber —
|
||||
# order matters, the think output feeds the context scrubber.
|
||||
"""Reset tracking for text delivered during the current model response.
|
||||
|
||||
Flushes the think scrubber's benign tail first, routed through the context scrubber (a span
|
||||
straddling the boundary must still be caught), then the context scrubber's own tail.
|
||||
"""
|
||||
think_scrubber = getattr(self, "_stream_think_scrubber", None)
|
||||
ctx_scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
|
||||
def deliver(tail: str) -> None:
|
||||
if tail:
|
||||
self._deliver_to_stream_callbacks(tail)
|
||||
self._record_streamed_assistant_text(tail)
|
||||
|
||||
if think_scrubber is not None:
|
||||
think_tail = think_scrubber.flush()
|
||||
if think_tail:
|
||||
# Route the tail through the context scrubber so a span straddling the boundary is caught.
|
||||
ctx_scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if ctx_scrubber is not None:
|
||||
think_tail = ctx_scrubber.feed(think_tail)
|
||||
if think_tail:
|
||||
callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(think_tail)
|
||||
except Exception:
|
||||
pass
|
||||
self._record_streamed_assistant_text(think_tail)
|
||||
# Flush the context scrubber's benign tail before clearing; mid-span, flush() drops orphaned content.
|
||||
scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if scrubber is not None:
|
||||
tail = scrubber.flush()
|
||||
if tail:
|
||||
callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(tail)
|
||||
except Exception:
|
||||
pass
|
||||
self._record_streamed_assistant_text(tail)
|
||||
deliver(ctx_scrubber.feed(think_tail) if think_tail and ctx_scrubber is not None else think_tail)
|
||||
if ctx_scrubber is not None:
|
||||
deliver(ctx_scrubber.flush())
|
||||
self._current_streamed_assistant_text = ""
|
||||
|
||||
def _record_streamed_assistant_text(self, text: str) -> None:
|
||||
"""Accumulate visible assistant text emitted through stream callbacks."""
|
||||
# Single-writer guard (#65991): a superseded stream must not pollute the accumulated text, even
|
||||
# when reached directly via the tool-suppressed path.
|
||||
if self._stream_writer_superseded():
|
||||
return
|
||||
if isinstance(text, str) and text:
|
||||
self._current_streamed_assistant_text = (
|
||||
getattr(self, "_current_streamed_assistant_text", "") + text
|
||||
)
|
||||
"""Accumulate visible assistant text emitted through stream callbacks (superseded writers excluded)."""
|
||||
if isinstance(text, str) and text and not self._stream_writer_superseded():
|
||||
self._current_streamed_assistant_text = getattr(self, "_current_streamed_assistant_text", "") + text
|
||||
|
||||
@staticmethod
|
||||
def _normalize_interim_visible_text(text: str) -> str:
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
return re.sub(r"\s+", " ", text).strip() if isinstance(text, str) else ""
|
||||
|
||||
def _interim_content_was_streamed(self, content: str) -> bool:
|
||||
visible_content = self._normalize_interim_visible_text(
|
||||
self._strip_think_blocks(content or "")
|
||||
)
|
||||
if not visible_content:
|
||||
return False
|
||||
visible_content = self._normalize_interim_visible_text(self._strip_think_blocks(content or ""))
|
||||
streamed = self._normalize_interim_visible_text(
|
||||
self._strip_think_blocks(getattr(self, "_current_streamed_assistant_text", "") or "")
|
||||
)
|
||||
# Prefix match, not equality: the final may be streamed text plus a trailing delta, or the stream
|
||||
# partial. The reverse (streamed longer) is NOT matched — it could suppress a needed resend (#65919).
|
||||
return bool(streamed) and visible_content.startswith(streamed)
|
||||
# Prefix match, not equality: the final may be streamed text plus a trailing delta. The
|
||||
# reverse (streamed longer) is NOT matched — it could suppress a needed resend.
|
||||
return bool(visible_content and streamed) and visible_content.startswith(streamed)
|
||||
|
||||
def _extract_codex_interim_visible_parts(
|
||||
self,
|
||||
assistant_msg: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""Extract visible Codex commentary as one string per message item.
|
||||
def _extract_codex_interim_visible_parts(self, assistant_msg: Dict[str, Any]) -> List[str]:
|
||||
"""Visible Codex commentary (``phase=commentary`` items), one string per message item.
|
||||
|
||||
Codex keeps mid-turn narration as ``phase=commentary`` items while the final answer stays in
|
||||
``content``;
|
||||
non-streaming gateways need it via the interim callback. ``phase=analysis`` stays hidden (scratchpad).
|
||||
``phase=analysis`` stays hidden (scratchpad); with ``display.show_commentary=false``
|
||||
commentary stays on the reasoning channel.
|
||||
"""
|
||||
if not getattr(self, "show_commentary", True):
|
||||
# display.show_commentary=false — commentary stays on the
|
||||
# reasoning channel (pre-commentary-channel behavior).
|
||||
return []
|
||||
items = assistant_msg.get("codex_message_items")
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
items = assistant_msg.get("codex_message_items") if getattr(self, "show_commentary", True) else None
|
||||
messages: List[str] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
for item in items if isinstance(items, list) else ():
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
if item.get("type") != "message":
|
||||
phase, content_parts = item.get("phase"), item.get("content")
|
||||
if not isinstance(phase, str) or phase.strip().lower() != "commentary" or not isinstance(content_parts, list):
|
||||
continue
|
||||
phase = item.get("phase")
|
||||
if not isinstance(phase, str) or phase.strip().lower() != "commentary":
|
||||
continue
|
||||
content_parts = item.get("content")
|
||||
if not isinstance(content_parts, list):
|
||||
continue
|
||||
item_parts: List[str] = []
|
||||
for part in content_parts:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") != "output_text":
|
||||
continue
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
item_parts.append(text)
|
||||
visible = "".join(item_parts).strip()
|
||||
if visible:
|
||||
visible = self._strip_think_blocks(visible).strip()
|
||||
visible = redact_sensitive_text(visible)
|
||||
visible = "".join(
|
||||
part["text"] for part in content_parts
|
||||
if isinstance(part, dict) and part.get("type") == "output_text"
|
||||
and isinstance(part.get("text"), str) and part["text"].strip()
|
||||
).strip()
|
||||
visible = self._visible_commentary(visible)
|
||||
if visible:
|
||||
messages.append(visible)
|
||||
return messages
|
||||
|
||||
def _visible_commentary(self, text: str) -> str:
|
||||
"""Think-stripped, redacted commentary text ("" when nothing visible remains)."""
|
||||
visible = self._strip_think_blocks(text).strip()
|
||||
return redact_sensitive_text(visible) if visible else visible
|
||||
|
||||
def _extract_codex_interim_visible_text(self, assistant_msg: Dict[str, Any]) -> str:
|
||||
"""Extract all visible Codex commentary for comparison/fallback."""
|
||||
return "\n\n".join(
|
||||
self._extract_codex_interim_visible_parts(assistant_msg)
|
||||
).strip()
|
||||
"""All visible Codex commentary joined, for comparison/fallback."""
|
||||
return "\n\n".join(self._extract_codex_interim_visible_parts(assistant_msg)).strip()
|
||||
|
||||
def _interim_assistant_visible_text(self, assistant_msg: Dict[str, Any]) -> str:
|
||||
"""Return the exact assistant text eligible for interim delivery.
|
||||
|
||||
Prefers structured Codex commentary over top-level content — a response can hold commentary AND a
|
||||
partial final answer while tools are pending, and treating content as progress leaks the answer early.
|
||||
Content may be a parts list, so flatten before stripping reasoning.
|
||||
"""
|
||||
visible = self._extract_codex_interim_visible_text(assistant_msg)
|
||||
if visible:
|
||||
return visible
|
||||
content = assistant_msg.get("content")
|
||||
return self._strip_think_blocks(flatten_message_text(content)).strip()
|
||||
"""Assistant text eligible for interim delivery: structured Codex commentary first — a response can
|
||||
hold commentary AND a partial final answer while tools are pending, and treating content as
|
||||
progress leaks the answer early — else top-level content (may be a parts list)."""
|
||||
return (
|
||||
self._extract_codex_interim_visible_text(assistant_msg)
|
||||
or self._strip_think_blocks(flatten_message_text(assistant_msg.get("content"))).strip()
|
||||
)
|
||||
|
||||
def _interim_text_was_delivered(self, text: str) -> bool:
|
||||
normalized = self._normalize_interim_visible_text(text)
|
||||
if not normalized:
|
||||
return False
|
||||
return normalized in getattr(self, "_delivered_interim_texts", set())
|
||||
return bool(normalized) and normalized in getattr(self, "_delivered_interim_texts", set())
|
||||
|
||||
def _record_delivered_interim_text(self, text: str) -> None:
|
||||
normalized = self._normalize_interim_visible_text(text)
|
||||
if normalized:
|
||||
delivered = getattr(self, "_delivered_interim_texts", None)
|
||||
if not isinstance(delivered, set):
|
||||
delivered = set()
|
||||
self._delivered_interim_texts = delivered
|
||||
delivered.add(normalized)
|
||||
if not isinstance(getattr(self, "_delivered_interim_texts", None), set):
|
||||
self._delivered_interim_texts = set()
|
||||
self._delivered_interim_texts.add(normalized)
|
||||
|
||||
def _fire_streamed_codex_commentary(self, text: str) -> None:
|
||||
"""Deliver a completed live Codex commentary message immediately."""
|
||||
cb = getattr(self, "interim_assistant_callback", None)
|
||||
if cb is None or not isinstance(text, str):
|
||||
return
|
||||
visible = self._strip_think_blocks(text).strip()
|
||||
if visible:
|
||||
visible = redact_sensitive_text(visible)
|
||||
if not visible or visible == "(empty)" or self._interim_text_was_delivered(visible):
|
||||
return
|
||||
try:
|
||||
cb(visible, already_streamed=False)
|
||||
self._record_delivered_interim_text(visible)
|
||||
except Exception:
|
||||
logger.debug("interim_assistant_callback error", exc_info=True)
|
||||
|
||||
def _emit_interim_assistant_message(
|
||||
self, assistant_msg: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Surface a real mid-turn assistant commentary message to the UI layer.
|
||||
|
||||
Does NOT set ``_response_was_previewed`` — that means "the final response was shown"; setting it for
|
||||
narration would make the CLI suppress a different final summary (response-loss blocker).
|
||||
"""
|
||||
if not isinstance(assistant_msg, dict):
|
||||
return
|
||||
commentary_parts = self._extract_codex_interim_visible_parts(assistant_msg)
|
||||
undelivered_parts: List[str] = []
|
||||
pending_keys: set[str] = set()
|
||||
for part in commentary_parts:
|
||||
key = self._normalize_interim_visible_text(part)
|
||||
if (
|
||||
not key
|
||||
or key in pending_keys
|
||||
or self._interim_text_was_delivered(part)
|
||||
):
|
||||
continue
|
||||
pending_keys.add(key)
|
||||
undelivered_parts.append(part)
|
||||
visible = (
|
||||
"\n\n".join(undelivered_parts).strip()
|
||||
if commentary_parts
|
||||
else self._interim_assistant_visible_text(assistant_msg)
|
||||
)
|
||||
if (
|
||||
not visible
|
||||
or visible == "(empty)"
|
||||
or self._interim_text_was_delivered(visible)
|
||||
):
|
||||
return
|
||||
already_streamed = self._interim_content_was_streamed(visible)
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook
|
||||
|
||||
enqueue_plugin_stream_hook(
|
||||
"on_interim_message",
|
||||
turn_id=getattr(self, "_current_turn_id", "") or "",
|
||||
iteration=int(getattr(self, "_api_call_count", 0) or 0),
|
||||
session_id=self.session_id or "",
|
||||
model=self.model or "",
|
||||
provider=self.provider or "",
|
||||
surface=self.platform or "cli",
|
||||
text=visible,
|
||||
already_streamed=already_streamed,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("on_interim_message plugin hook enqueue failed", exc_info=True)
|
||||
def _deliver_interim(self, visible: str, *, already_streamed: bool, record: List[str]) -> None:
|
||||
"""Hand ``visible`` to ``interim_assistant_callback`` and mark ``record`` delivered; swallows callback errors."""
|
||||
cb = getattr(self, "interim_assistant_callback", None)
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
cb(visible, already_streamed=already_streamed)
|
||||
if undelivered_parts:
|
||||
for part in undelivered_parts:
|
||||
self._record_delivered_interim_text(part)
|
||||
else:
|
||||
self._record_delivered_interim_text(visible)
|
||||
for part in record:
|
||||
self._record_delivered_interim_text(part)
|
||||
except Exception:
|
||||
logger.debug("interim_assistant_callback error", exc_info=True)
|
||||
|
||||
def _ensure_stream_writer_state(self) -> None:
|
||||
"""Lazily create the single-writer guard fields.
|
||||
def _fire_streamed_codex_commentary(self, text: str) -> None:
|
||||
"""Deliver a completed live Codex commentary message immediately."""
|
||||
if getattr(self, "interim_assistant_callback", None) is None or not isinstance(text, str):
|
||||
return
|
||||
visible = self._visible_commentary(text)
|
||||
if not visible or visible == "(empty)" or self._interim_text_was_delivered(visible):
|
||||
return
|
||||
self._deliver_interim(visible, already_streamed=False, record=[visible])
|
||||
|
||||
Normally set in ``agent_init``; ``AIAgent.__new__``-built instances skip that path and must not crash.
|
||||
"""
|
||||
def _emit_interim_assistant_message(self, assistant_msg: Dict[str, Any]) -> None:
|
||||
"""Surface a real mid-turn assistant commentary message to the UI layer. Does NOT set
|
||||
``_response_was_previewed`` ("the final response was shown") — the CLI would then suppress a
|
||||
different final summary."""
|
||||
if not isinstance(assistant_msg, dict):
|
||||
return
|
||||
commentary_parts = self._extract_codex_interim_visible_parts(assistant_msg)
|
||||
# Dedup within this message and against earlier deliveries, first occurrence wins.
|
||||
pending: dict[str, str] = {}
|
||||
for part in commentary_parts:
|
||||
key = self._normalize_interim_visible_text(part)
|
||||
if key and key not in pending and not self._interim_text_was_delivered(part):
|
||||
pending[key] = part
|
||||
undelivered_parts = list(pending.values())
|
||||
visible = "\n\n".join(undelivered_parts).strip() if commentary_parts else self._interim_assistant_visible_text(assistant_msg)
|
||||
if not visible or visible == "(empty)" or self._interim_text_was_delivered(visible):
|
||||
return
|
||||
already_streamed = self._interim_content_was_streamed(visible)
|
||||
self._enqueue_stream_hook("on_interim_message", text=visible, already_streamed=already_streamed)
|
||||
self._deliver_interim(visible, already_streamed=already_streamed, record=undelivered_parts or [visible])
|
||||
|
||||
def _ensure_stream_writer_state(self) -> None:
|
||||
"""Lazily create the single-writer guard fields (``AIAgent.__new__``-built instances skip ``agent_init``)."""
|
||||
if getattr(self, "_stream_writer_lock", None) is None:
|
||||
self._stream_writer_lock = threading.Lock()
|
||||
if not hasattr(self, "_stream_writer_token"):
|
||||
self._stream_writer_token = 0
|
||||
if getattr(self, "_stream_writer_tls", None) is None:
|
||||
self._stream_writer_tls = threading.local()
|
||||
if not hasattr(self, "_stream_writer_dropped"):
|
||||
self._stream_writer_dropped = 0
|
||||
for attr in ("_stream_writer_token", "_stream_writer_dropped"):
|
||||
if not hasattr(self, attr):
|
||||
setattr(self, attr, 0)
|
||||
|
||||
def _claim_stream_writer(self) -> int:
|
||||
"""Claim exclusive ownership of the streaming delta sink for this stream attempt; returns its writer
|
||||
token.
|
||||
"""Claim exclusive ownership of the delta sink for this stream attempt; returns its writer token.
|
||||
|
||||
Every attempt (each provider path, each retry) claims right before consuming. Claiming bumps the
|
||||
shared token, so an earlier attempt still alive on another thread is superseded and its late chunks
|
||||
fenced out. Stored per-thread: a thread that never claimed is never a writer and can never be fenced.
|
||||
Every attempt (each provider path, each retry) claims right before consuming; claiming bumps
|
||||
the shared token, so an earlier attempt still alive on another thread is superseded and its
|
||||
late chunks fenced out. Stored per-thread: a thread that never claimed can never be fenced.
|
||||
"""
|
||||
self._ensure_stream_writer_state()
|
||||
with self._stream_writer_lock:
|
||||
self._stream_writer_token += 1
|
||||
token = self._stream_writer_token
|
||||
self._stream_writer_token = token = self._stream_writer_token + 1
|
||||
self._stream_writer_tls.token = token
|
||||
return token
|
||||
|
||||
def _stream_writer_is_current(self, token: int) -> bool:
|
||||
"""True when ``token`` is still the active writer (no newer attempt claimed since).
|
||||
|
||||
Lets a stream loop bail out the instant it is superseded.
|
||||
"""
|
||||
"""True when ``token`` is still the active writer, so a stream loop can bail the instant it is superseded."""
|
||||
return token == getattr(self, "_stream_writer_token", token)
|
||||
|
||||
def _stream_writer_superseded(self) -> bool:
|
||||
"""True when this thread claimed the sink but a newer attempt has since claimed it (stale writer, drop
|
||||
chunks).
|
||||
|
||||
A thread that never claimed (``token is None``) is never reported superseded.
|
||||
"""
|
||||
tls = getattr(self, "_stream_writer_tls", None)
|
||||
token = getattr(tls, "token", None) if tls is not None else None
|
||||
if token is None:
|
||||
return False
|
||||
return token != getattr(self, "_stream_writer_token", token)
|
||||
"""True when this thread claimed the sink but a newer attempt has since claimed it (never for a non-claimer)."""
|
||||
token = getattr(getattr(self, "_stream_writer_tls", None), "token", None)
|
||||
return token is not None and token != getattr(self, "_stream_writer_token", token)
|
||||
|
||||
def _note_dropped_stream_writer(self, where: str) -> None:
|
||||
"""Record + log that a superseded stream's delta was discarded."""
|
||||
@@ -319,126 +237,60 @@ class StreamDeliveryMixin:
|
||||
}
|
||||
|
||||
def _emit_stream_start(self) -> None:
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook
|
||||
|
||||
enqueue_plugin_stream_hook("on_stream_start", **self._stream_hook_base_payload())
|
||||
except Exception:
|
||||
logger.debug("on_stream_start plugin hook enqueue failed", exc_info=True)
|
||||
self._enqueue_stream_hook("on_stream_start")
|
||||
|
||||
def _emit_stream_end(self, *, final_text: str, finished: bool, error: str | None) -> None:
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook
|
||||
|
||||
enqueue_plugin_stream_hook(
|
||||
"on_stream_end",
|
||||
**self._stream_hook_base_payload(),
|
||||
final_text=final_text,
|
||||
finished=finished,
|
||||
error=error,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("on_stream_end plugin hook enqueue failed", exc_info=True)
|
||||
self._enqueue_stream_hook("on_stream_end", final_text=final_text, finished=finished, error=error)
|
||||
|
||||
def _fire_stream_delta(self, text: str) -> None:
|
||||
"""Fire all registered stream delta callbacks (display + TTS)."""
|
||||
# Single-writer guard (#65991): a superseded stream must not interleave
|
||||
# its tokens into the turn alongside the retry that replaced it.
|
||||
# A superseded stream must not interleave its tokens alongside the retry that replaced it.
|
||||
if self._stream_writer_superseded():
|
||||
self._note_dropped_stream_writer("_fire_stream_delta")
|
||||
return
|
||||
# Prepend one paragraph break before the first text delta after a tool iteration, without
|
||||
# One paragraph break before the first text delta after a tool iteration, without
|
||||
# stacking blank lines across back-to-back tool iterations.
|
||||
if getattr(self, "_stream_needs_break", False) and text and text.strip():
|
||||
prepended_break = bool(getattr(self, "_stream_needs_break", False) and text and text.strip())
|
||||
if prepended_break:
|
||||
self._stream_needs_break = False
|
||||
text = "\n\n" + text
|
||||
prepended_break = True
|
||||
else:
|
||||
prepended_break = False
|
||||
if isinstance(text, str):
|
||||
# Stateful scrubber (#17924): per-delta regex stripping destroyed downstream state machines when a
|
||||
# tag was split across deltas (MiniMax-M2.7 sends '<think>' separately).
|
||||
# Stateful scrubbers: per-delta regex stripping destroyed downstream state machines when a
|
||||
# tag was split across deltas; memory-context spans split across chunks must not leak to
|
||||
# the UI. Legacy callers lack the scrubber attributes and get the whole-string fallbacks.
|
||||
think_scrubber = getattr(self, "_stream_think_scrubber", None)
|
||||
if think_scrubber is not None:
|
||||
text = think_scrubber.feed(text or "")
|
||||
else:
|
||||
# Defensive: legacy callers without the scrubber attribute.
|
||||
text = self._strip_think_blocks(text or "")
|
||||
# Then feed through the stateful context scrubber so memory-context
|
||||
# spans split across chunks cannot leak to the UI (#5719).
|
||||
scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if scrubber is not None:
|
||||
text = scrubber.feed(text)
|
||||
else:
|
||||
# Defensive: legacy callers without the scrubber attribute.
|
||||
text = sanitize_context(text)
|
||||
text = think_scrubber.feed(text) if think_scrubber is not None else self._strip_think_blocks(text)
|
||||
text = scrubber.feed(text) if scrubber is not None else sanitize_context(text)
|
||||
# Only strip leading newlines on the first delta — mid-stream "\n" is legitimate markdown.
|
||||
if not prepended_break and not getattr(
|
||||
self, "_current_streamed_assistant_text", ""
|
||||
):
|
||||
if not prepended_break and not getattr(self, "_current_streamed_assistant_text", ""):
|
||||
text = text.lstrip("\n")
|
||||
if not text:
|
||||
return
|
||||
callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
|
||||
delivered = False
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(text)
|
||||
delivered = True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook
|
||||
|
||||
enqueue_plugin_stream_hook(
|
||||
"on_stream_delta",
|
||||
**self._stream_hook_base_payload(),
|
||||
delta=text,
|
||||
kind="text",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("on_stream_delta plugin hook enqueue failed", exc_info=True)
|
||||
delivered = self._deliver_to_stream_callbacks(text)
|
||||
self._enqueue_stream_hook("on_stream_delta", delta=text, kind="text")
|
||||
if delivered:
|
||||
self._record_streamed_assistant_text(text)
|
||||
|
||||
def _fire_reasoning_delta(self, text: str) -> None:
|
||||
"""Fire reasoning callback if registered."""
|
||||
# Single-writer guard (#65991): fence out a superseded stream's
|
||||
# reasoning deltas the same way as content deltas.
|
||||
"""Fire reasoning callback if registered; superseded writers are fenced like content deltas."""
|
||||
if self._stream_writer_superseded():
|
||||
self._note_dropped_stream_writer("_fire_reasoning_delta")
|
||||
return
|
||||
cb = self.reasoning_callback
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(text)
|
||||
except Exception:
|
||||
pass
|
||||
self._call_quietly(self.reasoning_callback, text)
|
||||
try:
|
||||
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook, stream_reasoning_deltas_enabled
|
||||
from agent.plugin_stream_hooks import stream_reasoning_deltas_enabled
|
||||
|
||||
if stream_reasoning_deltas_enabled():
|
||||
enqueue_plugin_stream_hook(
|
||||
"on_stream_delta",
|
||||
**self._stream_hook_base_payload(),
|
||||
delta=text,
|
||||
kind="reasoning",
|
||||
)
|
||||
enabled = stream_reasoning_deltas_enabled()
|
||||
except Exception:
|
||||
logger.debug("reasoning on_stream_delta plugin hook enqueue failed", exc_info=True)
|
||||
return
|
||||
if enabled:
|
||||
self._enqueue_stream_hook("on_stream_delta", label="reasoning on_stream_delta", delta=text, kind="reasoning")
|
||||
|
||||
def _fire_tool_gen_started(self, tool_name: str) -> None:
|
||||
"""Notify the display layer that the model is generating tool call arguments.
|
||||
|
||||
Fires once per tool name so the TUI can show a spinner while a large payload (e.g. a 45 KB write_file)
|
||||
streams.
|
||||
"""
|
||||
cb = self.tool_gen_callback
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(tool_name)
|
||||
except Exception:
|
||||
pass
|
||||
"""Notify the display layer that the model is generating tool call arguments (spinner for large payloads)."""
|
||||
self._call_quietly(self.tool_gen_callback, tool_name)
|
||||
|
||||
def _has_stream_consumers(self) -> bool:
|
||||
"""Return True if any streaming consumer is registered."""
|
||||
@@ -449,7 +301,4 @@ class StreamDeliveryMixin:
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("plugin stream hook consumer check failed", exc_info=True)
|
||||
return (
|
||||
self.stream_delta_callback is not None
|
||||
or getattr(self, "_stream_callback", None) is not None
|
||||
)
|
||||
return self.stream_delta_callback is not None or getattr(self, "_stream_callback", None) is not None
|
||||
|
||||
+60
-116
@@ -17,29 +17,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Lowercased upstream headers captured per attempt for post-hoc analysis.
|
||||
STREAM_DIAG_HEADERS = (
|
||||
"cf-ray",
|
||||
"cf-cache-status",
|
||||
"x-openrouter-provider",
|
||||
"x-openrouter-model",
|
||||
"x-openrouter-id",
|
||||
"x-request-id",
|
||||
"x-vercel-id",
|
||||
"via",
|
||||
"server",
|
||||
"x-forwarded-for",
|
||||
"cf-ray", "cf-cache-status", "x-openrouter-provider", "x-openrouter-model", "x-openrouter-id",
|
||||
"x-request-id", "x-vercel-id", "via", "server", "x-forwarded-for",
|
||||
)
|
||||
|
||||
|
||||
def stream_diag_init() -> Dict[str, Any]:
|
||||
"""Fresh per-attempt diagnostic dict; mutated in place by the streaming functions and read by the retry block."""
|
||||
return {
|
||||
"started_at": time.time(),
|
||||
"first_chunk_at": None,
|
||||
"chunks": 0,
|
||||
"bytes": 0,
|
||||
"headers": {},
|
||||
"http_status": None,
|
||||
}
|
||||
return {"started_at": time.time(), "first_chunk_at": None, "chunks": 0, "bytes": 0, "headers": {}, "http_status": None}
|
||||
|
||||
|
||||
def stream_diag_capture_response(agent: Any, diag: Dict[str, Any], http_response: Any) -> None:
|
||||
@@ -53,11 +38,9 @@ def stream_diag_capture_response(agent: Any, diag: Dict[str, Any], http_response
|
||||
try:
|
||||
headers = getattr(http_response, "headers", None) or {}
|
||||
captured: Dict[str, str] = {}
|
||||
# Per-agent override of the headers list (back-compat).
|
||||
for name in getattr(agent, "_STREAM_DIAG_HEADERS", STREAM_DIAG_HEADERS):
|
||||
for name in getattr(agent, "_STREAM_DIAG_HEADERS", STREAM_DIAG_HEADERS): # per-agent override (back-compat)
|
||||
try:
|
||||
val = headers.get(name)
|
||||
if val:
|
||||
if val := headers.get(name):
|
||||
captured[name] = str(val)[:120] # keep log lines bounded
|
||||
except Exception:
|
||||
continue
|
||||
@@ -67,48 +50,58 @@ def stream_diag_capture_response(agent: Any, diag: Dict[str, Any], http_response
|
||||
|
||||
|
||||
def flatten_exception_chain(error: BaseException) -> str:
|
||||
"""Compact ``Outer(msg) <- Inner(msg) <- ...`` rendering.
|
||||
|
||||
The OpenAI SDK wraps httpx errors so only the wrapper class is visible at the
|
||||
catch site; the inner RemoteProtocolError/ConnectError/ReadError says WHY the
|
||||
stream died. Walks ``__cause__`` then ``__context__`` (deduped, max 4 deep).
|
||||
"""
|
||||
"""Compact ``Outer(msg) <- Inner(msg) <- ...`` rendering, walking ``__cause__`` then ``__context__``
|
||||
(deduped, max 4 deep): the OpenAI SDK wraps httpx errors so only the wrapper class is visible at
|
||||
the catch site; the inner RemoteProtocolError/ConnectError/ReadError says WHY the stream died."""
|
||||
seen: List[BaseException] = []
|
||||
link: Optional[BaseException] = error
|
||||
while link is not None and len(seen) < 4:
|
||||
if link in seen:
|
||||
break
|
||||
while link is not None and len(seen) < 4 and link not in seen:
|
||||
seen.append(link)
|
||||
nxt = getattr(link, "__cause__", None) or getattr(link, "__context__", None)
|
||||
if nxt is None or nxt is link:
|
||||
break
|
||||
link = nxt
|
||||
parts: List[str] = []
|
||||
for e in seen:
|
||||
|
||||
def render(e: BaseException) -> str:
|
||||
msg = str(e).strip().replace("\n", " ")
|
||||
if len(msg) > 140:
|
||||
msg = msg[:140] + "…"
|
||||
parts.append(f"{type(e).__name__}({msg})" if msg else type(e).__name__)
|
||||
return " <- ".join(parts) if parts else type(error).__name__
|
||||
msg = msg[:140] + "…" if len(msg) > 140 else msg
|
||||
return f"{type(e).__name__}({msg})" if msg else type(e).__name__
|
||||
|
||||
return " <- ".join(render(e) for e in seen) if seen else type(error).__name__
|
||||
|
||||
|
||||
def _diag_fields(diag: Optional[Dict[str, Any]]) -> tuple:
|
||||
"""(http_status, bytes, chunks, elapsed, ttfb, upstream) for the retry log line; ``-`` when unknown."""
|
||||
_bytes = _chunks = 0
|
||||
_elapsed = 0.0
|
||||
_ttfb = _headers_repr = _http_status = "-"
|
||||
if isinstance(diag, dict):
|
||||
try:
|
||||
_now = time.time()
|
||||
_bytes = int(diag.get("bytes") or 0)
|
||||
_chunks = int(diag.get("chunks") or 0)
|
||||
_started = float(diag.get("started_at") or _now)
|
||||
_elapsed = max(0.0, _now - _started)
|
||||
_first = diag.get("first_chunk_at")
|
||||
if _first is not None:
|
||||
_ttfb = f"{max(0.0, float(_first) - _started):.2f}s"
|
||||
headers = diag.get("headers") or {}
|
||||
if isinstance(headers, dict) and headers:
|
||||
_headers_repr = " ".join(f"{k}={v}" for k, v in headers.items())
|
||||
if diag.get("http_status") is not None:
|
||||
_http_status = str(diag.get("http_status"))
|
||||
except Exception:
|
||||
pass
|
||||
return _http_status, _bytes, _chunks, _elapsed, _ttfb, _headers_repr
|
||||
|
||||
|
||||
def log_stream_retry(
|
||||
agent: Any,
|
||||
*,
|
||||
kind: str,
|
||||
error: BaseException,
|
||||
attempt: int,
|
||||
max_attempts: int,
|
||||
mid_tool_call: bool,
|
||||
diag: Optional[Dict[str, Any]] = None,
|
||||
agent: Any, *, kind: str, error: BaseException, attempt: int, max_attempts: int,
|
||||
mid_tool_call: bool, diag: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Structured WARNING to ``agent.log`` for a transient stream drop + retry.
|
||||
|
||||
Always logged regardless of UI verbosity (subagent retries no longer spam the
|
||||
parent's terminal but keep full detail here). With *diag*, also records upstream
|
||||
headers, HTTP status, bytes/chunks streamed, elapsed and TTFB on the dying attempt —
|
||||
enough to tell "one CF edge / downstream provider" from "random across runs".
|
||||
"""
|
||||
"""Structured WARNING to ``agent.log`` for a transient stream drop + retry, always logged regardless of
|
||||
UI verbosity. With *diag*, also records upstream headers, HTTP status, bytes/chunks, elapsed and TTFB on
|
||||
the dying attempt — enough to tell "one CF edge / downstream provider" from "random across runs"."""
|
||||
try:
|
||||
try:
|
||||
_summary = agent._summarize_api_error(error)
|
||||
@@ -121,53 +114,13 @@ def log_stream_retry(
|
||||
except Exception:
|
||||
_chain = type(error).__name__
|
||||
|
||||
_now = time.time()
|
||||
_bytes = 0
|
||||
_chunks = 0
|
||||
_elapsed = 0.0
|
||||
_ttfb = None
|
||||
_headers_repr = "-"
|
||||
_http_status = "-"
|
||||
if isinstance(diag, dict):
|
||||
try:
|
||||
_bytes = int(diag.get("bytes") or 0)
|
||||
_chunks = int(diag.get("chunks") or 0)
|
||||
_started = float(diag.get("started_at") or _now)
|
||||
_elapsed = max(0.0, _now - _started)
|
||||
_first = diag.get("first_chunk_at")
|
||||
if _first is not None:
|
||||
_ttfb = max(0.0, float(_first) - _started)
|
||||
headers = diag.get("headers") or {}
|
||||
if isinstance(headers, dict) and headers:
|
||||
_headers_repr = " ".join(f"{k}={v}" for k, v in headers.items())
|
||||
if diag.get("http_status") is not None:
|
||||
_http_status = str(diag.get("http_status"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(
|
||||
"Stream %s on attempt %s/%s — retrying. "
|
||||
"subagent_id=%s depth=%s provider=%s base_url=%s "
|
||||
"error_type=%s error=%s "
|
||||
"chain=%s "
|
||||
"http_status=%s bytes=%d chunks=%d elapsed=%.2fs ttfb=%s "
|
||||
"upstream=[%s]",
|
||||
kind,
|
||||
attempt,
|
||||
max_attempts,
|
||||
getattr(agent, "_subagent_id", None) or "-",
|
||||
getattr(agent, "_delegate_depth", 0),
|
||||
agent.provider or "-",
|
||||
agent.base_url or "-",
|
||||
type(error).__name__,
|
||||
_summary,
|
||||
_chain,
|
||||
_http_status,
|
||||
_bytes,
|
||||
_chunks,
|
||||
_elapsed,
|
||||
f"{_ttfb:.2f}s" if _ttfb is not None else "-",
|
||||
_headers_repr,
|
||||
"Stream %s on attempt %s/%s — retrying. subagent_id=%s depth=%s provider=%s base_url=%s "
|
||||
"error_type=%s error=%s chain=%s http_status=%s bytes=%d chunks=%d elapsed=%.2fs ttfb=%s upstream=[%s]",
|
||||
kind, attempt, max_attempts,
|
||||
getattr(agent, "_subagent_id", None) or "-", getattr(agent, "_delegate_depth", 0),
|
||||
agent.provider or "-", agent.base_url or "-",
|
||||
type(error).__name__, _summary, _chain, *_diag_fields(diag),
|
||||
extra={"mid_tool_call": mid_tool_call},
|
||||
)
|
||||
except Exception:
|
||||
@@ -175,32 +128,23 @@ def log_stream_retry(
|
||||
|
||||
|
||||
def emit_stream_drop(
|
||||
agent: Any,
|
||||
*,
|
||||
error: BaseException,
|
||||
attempt: int,
|
||||
max_attempts: int,
|
||||
mid_tool_call: bool,
|
||||
diag: Optional[Dict[str, Any]] = None,
|
||||
agent: Any, *, error: BaseException, attempt: int, max_attempts: int,
|
||||
mid_tool_call: bool, diag: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""One compact user-visible status line for a stream drop+retry, plus the full WARNING via log_stream_retry.
|
||||
|
||||
Subagent lines get a ``[subagent-N]`` prefix from ``log_prefix``. ``after Xs``
|
||||
distinguishes "couldn't connect" (0s) from "died mid-stream" (idle-kill / proxy timeout).
|
||||
"""
|
||||
``after Xs`` distinguishes "couldn't connect" (0s) from "died mid-stream" (idle-kill / proxy timeout)."""
|
||||
kind = "drop mid tool-call" if mid_tool_call else "drop"
|
||||
log_stream_retry(
|
||||
agent, kind=kind, error=error, attempt=attempt, max_attempts=max_attempts, mid_tool_call=mid_tool_call, diag=diag
|
||||
)
|
||||
provider = agent.provider or "provider"
|
||||
_suffix = ""
|
||||
if isinstance(diag, dict):
|
||||
try:
|
||||
started = diag.get("started_at")
|
||||
if started is not None:
|
||||
_suffix = f" after {max(0.0, time.time() - float(started)):.1f}s"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
started = diag.get("started_at") if isinstance(diag, dict) else None
|
||||
if started is not None:
|
||||
_suffix = f" after {max(0.0, time.time() - float(started)):.1f}s"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._buffer_status(
|
||||
f"⚠️ {provider} stream {kind} ({type(error).__name__}){_suffix} "
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Best-effort accessors for the single-writer stream fence.
|
||||
|
||||
The fence lives on ``AIAgent`` (``_claim_stream_writer`` / ``_stream_writer_is_current``)
|
||||
but is used from other streaming modules. Calling it directly would turn an
|
||||
*additive* safety net into a fatal AttributeError on a partially-updated checkout,
|
||||
hot-reloaded gateway, duck-typed agent, or test double (a cron job died this way).
|
||||
The fence may only drop a *provably* superseded stream, never the sole writer, so
|
||||
when it is unavailable or raises the correct degradation is "no fence": keep streaming.
|
||||
The fence lives on ``AIAgent`` (``_claim_stream_writer`` / ``_stream_writer_is_current``) but is
|
||||
used from other streaming modules. Calling it directly would turn an *additive* safety net into a
|
||||
fatal AttributeError on a partially-updated checkout, hot-reloaded gateway, duck-typed agent, or
|
||||
test double (a cron job died this way). The fence may only drop a *provably* superseded stream,
|
||||
never the sole writer, so when it is unavailable or raises the degradation is "no fence".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,23 +17,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def claim_stream_writer(agent: Any) -> int:
|
||||
"""Claim the delta sink for this stream attempt; ``0`` (never fenced) when the agent lacks the fence or the claim raised."""
|
||||
claim = getattr(agent, "_claim_stream_writer", None)
|
||||
if callable(claim):
|
||||
try:
|
||||
return int(claim())
|
||||
except Exception:
|
||||
logger.debug("stream single-writer: claim failed; proceeding unfenced", exc_info=True)
|
||||
return 0
|
||||
return _fence_call(agent, "_claim_stream_writer", int, 0, "claim failed; proceeding unfenced")
|
||||
|
||||
|
||||
def stream_writer_is_current(agent: Any, token: int) -> bool:
|
||||
"""True when ``token`` is still the active writer; a falsy token or a fence-less agent cannot prove supersession, so True."""
|
||||
if not token:
|
||||
return True
|
||||
is_current = getattr(agent, "_stream_writer_is_current", None)
|
||||
if callable(is_current):
|
||||
return _fence_call(agent, "_stream_writer_is_current", bool, True, "is_current check failed; treating as current", token)
|
||||
|
||||
|
||||
def _fence_call(agent: Any, name: str, cast, fallback, failure_note: str, *args):
|
||||
"""Call ``agent.<name>(*args)`` when it exists; ``fallback`` when missing or raising (logged at debug)."""
|
||||
fn = getattr(agent, name, None)
|
||||
if callable(fn):
|
||||
try:
|
||||
return bool(is_current(token))
|
||||
return cast(fn(*args))
|
||||
except Exception:
|
||||
logger.debug("stream single-writer: is_current check failed; treating as current", exc_info=True)
|
||||
return True
|
||||
logger.debug("stream single-writer: %s", failure_note, exc_info=True)
|
||||
return fallback
|
||||
|
||||
+66
-122
@@ -2,12 +2,9 @@
|
||||
|
||||
Companion to :mod:`agent.billing_view` — same fail-open philosophy (``logged_in=False``
|
||||
when not logged in / portal unreachable; never crash) and decimal money end-to-end.
|
||||
|
||||
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal: preview, then
|
||||
schedule a downgrade / cancellation / resume (chargeless) or apply an upgrade
|
||||
(charges the subscription card). The portal deep-link (``portal_url`` + ``org_id``)
|
||||
remains the fallback for an upgrade that needs 3DS / was declined. Until the NAS
|
||||
``GET /api/billing/subscription`` endpoint ships, 404s take the fail-open path.
|
||||
The TUI drives plan changes in-terminal (preview, then schedule a downgrade/cancel/resume
|
||||
or apply an upgrade); the portal deep-link (``portal_url`` + ``org_id``) is the fallback
|
||||
for an upgrade that needs 3DS / was declined. A 404 from NAS takes the fail-open path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,17 +20,10 @@ from agent.billing_view import OrgRoleCapability, fetch_portal_state, format_mon
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Parsed sub-structures ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentSubscription:
|
||||
"""The user's active subscription. ``None`` (not this object) = no plan.
|
||||
|
||||
NAS guarantees a present ``current`` is fully populated: ``tier_id`` /
|
||||
``tier_name`` / ``monthly_credits`` / ``cycle_ends_at`` are always set; only
|
||||
``credits_remaining`` and the cancel/downgrade fields are optional.
|
||||
"""
|
||||
"""Active subscription (``None``, not this object, = no plan). NAS guarantees ``tier_id`` /
|
||||
``tier_name`` / ``monthly_credits`` / ``cycle_ends_at`` are set; the rest are optional."""
|
||||
|
||||
tier_id: Optional[str] = None
|
||||
tier_name: Optional[str] = None
|
||||
@@ -48,12 +38,9 @@ class CurrentSubscription:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionTier:
|
||||
"""One row of the tier picker (mirrors NAS ``SubscriptionTierOption``).
|
||||
|
||||
``is_current`` = active plan (shown, not selectable); ``is_enabled=False`` = a
|
||||
grandfathered tier the user is on but can no longer select. ``tier_order`` sorts
|
||||
the picker and drives the upgrade-vs-downgrade hint.
|
||||
"""
|
||||
"""Tier-picker row (NAS ``SubscriptionTierOption``). ``is_current`` = active plan (shown, not
|
||||
selectable); ``is_enabled=False`` = grandfathered, no longer selectable; ``tier_order`` sorts
|
||||
the picker and drives the upgrade-vs-downgrade hint."""
|
||||
|
||||
tier_id: str
|
||||
name: str
|
||||
@@ -66,12 +53,9 @@ class SubscriptionTier:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionChangePreview:
|
||||
"""Parsed ``POST /api/billing/subscription/preview``.
|
||||
|
||||
``effect``: ``charge_now`` (upgrade; ``amount_due_now_cents`` is the prorated
|
||||
charge) · ``scheduled`` (downgrade / same-price change at ``effective_at``) ·
|
||||
``no_op`` (already on target) · ``blocked`` (commit refused; ``reason`` says why).
|
||||
"""
|
||||
"""Parsed ``POST /api/billing/subscription/preview``. ``effect``: ``charge_now`` (upgrade; prorated
|
||||
``amount_due_now_cents``) · ``scheduled`` (downgrade / same-price change at ``effective_at``) ·
|
||||
``no_op`` (already on target) · ``blocked`` (commit refused; ``reason`` says why)."""
|
||||
|
||||
effect: str
|
||||
reason: Optional[str] = None
|
||||
@@ -91,7 +75,7 @@ class SubscriptionState(OrgRoleCapability):
|
||||
|
||||
logged_in: bool
|
||||
org_name: Optional[str] = None
|
||||
org_id: Optional[str] = None # org.id from the NAS response
|
||||
org_id: Optional[str] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
||||
can_change_plan_raw: Optional[bool] = None
|
||||
context: str = "personal" # "personal" | "team"
|
||||
@@ -104,12 +88,13 @@ class SubscriptionState(OrgRoleCapability):
|
||||
# ── Payload parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _tier_id(raw: Any) -> Optional[str]:
|
||||
"""Real tier id of a NAS dict, else None ("no plan" is ``current: null``; junk is skipped)."""
|
||||
return (raw.get("tierId") or raw.get("id") or None) if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
|
||||
# "No plan" is wire-represented as current:null; a present current is a real
|
||||
# plan, so guard on a real tier id and return None otherwise.
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
tier_id = raw.get("tierId") or raw.get("id")
|
||||
tier_id = _tier_id(raw)
|
||||
if not tier_id:
|
||||
return None
|
||||
return CurrentSubscription(
|
||||
@@ -126,19 +111,12 @@ def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
|
||||
|
||||
|
||||
def _coalesce(*vals: Any) -> Any:
|
||||
"""First non-``None`` value. NAS sends ``0`` for the free tier's ``tierOrder`` /
|
||||
``dollarsPerMonth``, which a plain ``x or default`` would drop."""
|
||||
for v in vals:
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
"""First non-``None`` value (NAS sends ``0`` for the free tier, which ``x or default`` would drop)."""
|
||||
return next((v for v in vals if v is not None), None)
|
||||
|
||||
|
||||
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
|
||||
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
tier_id = raw.get("tierId") or raw.get("id")
|
||||
tier_id = _tier_id(raw)
|
||||
if not tier_id:
|
||||
return None
|
||||
return SubscriptionTier(
|
||||
@@ -152,9 +130,7 @@ def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
|
||||
)
|
||||
|
||||
|
||||
def subscription_change_preview_from_payload(
|
||||
payload: dict[str, Any],
|
||||
) -> SubscriptionChangePreview:
|
||||
def subscription_change_preview_from_payload(payload: dict[str, Any]) -> SubscriptionChangePreview:
|
||||
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
|
||||
effect = payload.get("effect")
|
||||
cents = payload.get("amountDueNowCents")
|
||||
@@ -172,18 +148,10 @@ def subscription_change_preview_from_payload(
|
||||
)
|
||||
|
||||
|
||||
def subscription_state_from_payload(
|
||||
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
||||
) -> SubscriptionState:
|
||||
def subscription_state_from_payload(payload: dict[str, Any], *, portal_url: Optional[str] = None) -> SubscriptionState:
|
||||
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
|
||||
org, can_change_plan_raw = parse_org_fields(payload)
|
||||
raw_context = payload.get("context")
|
||||
raw_tiers = payload.get("tiers")
|
||||
tiers = (
|
||||
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
|
||||
if isinstance(raw_tiers, list)
|
||||
else ()
|
||||
)
|
||||
raw_context, raw_tiers = payload.get("context"), payload.get("tiers")
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
org_name=org.get("name"),
|
||||
@@ -192,7 +160,7 @@ def subscription_state_from_payload(
|
||||
can_change_plan_raw=can_change_plan_raw,
|
||||
context=raw_context if raw_context in ("personal", "team") else "personal",
|
||||
current=_parse_current(payload.get("current")),
|
||||
tiers=tiers,
|
||||
tiers=tuple(filter(None, map(_parse_tier, raw_tiers))) if isinstance(raw_tiers, list) else (),
|
||||
portal_url=portal_url,
|
||||
)
|
||||
|
||||
@@ -201,44 +169,31 @@ def subscription_state_from_payload(
|
||||
|
||||
|
||||
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
|
||||
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open (see
|
||||
:func:`agent.billing_view.fetch_portal_state`).
|
||||
|
||||
``HERMES_DEV_SUBSCRIPTION_FIXTURE`` short-circuits to a fixture so every
|
||||
plan/cancel/downgrade/team/not-admin state is testable on CLI and TUI offline.
|
||||
"""
|
||||
"""Fetch + parse ``GET /api/billing/subscription``; fail-open like ``fetch_portal_state``.
|
||||
``HERMES_DEV_SUBSCRIPTION_FIXTURE`` short-circuits to a fixture so every state is testable offline."""
|
||||
fixture = dev_fixture_subscription_state()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
return fetch_portal_state(
|
||||
"get_subscription_state",
|
||||
"subscription",
|
||||
"get_subscription_state", "subscription",
|
||||
failed=lambda **kw: SubscriptionState(logged_in=False, **kw),
|
||||
parse=lambda payload, portal_url: subscription_state_from_payload(payload, portal_url=portal_url),
|
||||
portal_fallback=lambda base: base,
|
||||
timeout=timeout,
|
||||
log=logger,
|
||||
portal_fallback=lambda base: base, timeout=timeout, log=logger,
|
||||
)
|
||||
|
||||
|
||||
def subscription_manage_url(
|
||||
state: SubscriptionState, tier_id: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
|
||||
def subscription_manage_url(state: SubscriptionState, tier_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]`` (None if unresolvable).
|
||||
|
||||
Mirrors the TUI's ``buildManageUrl``: the target is NAS's OWN ``/manage-subscription``
|
||||
page (NOT the Stripe Billing Portal), which routes upgrade→Checkout /
|
||||
downgrade→scheduled internally. ``org_id`` pins the right account in multi-org
|
||||
situations. ``tier_id`` (the stable ``tiers[]`` id, never a name/slug) preselects
|
||||
the picked plan; the portal ignores an unknown tier, so it's appended
|
||||
unconditionally when picked. None when no portal URL is resolvable.
|
||||
Mirrors the TUI's ``buildManageUrl``: the target is NAS's OWN ``/manage-subscription`` page
|
||||
(NOT the Stripe Billing Portal). ``org_id`` pins the account in multi-org situations; ``tier_id``
|
||||
(the stable ``tiers[]`` id, never a name/slug) preselects the plan — the portal ignores an
|
||||
unknown tier, so it's appended unconditionally.
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
if not state.portal_url:
|
||||
return None
|
||||
try:
|
||||
parts = urlsplit(state.portal_url)
|
||||
parts = urlsplit(state.portal_url or "")
|
||||
except Exception:
|
||||
return None
|
||||
if parts.scheme not in ("http", "https") or not parts.netloc:
|
||||
@@ -260,8 +215,7 @@ def subscription_manage_url(
|
||||
|
||||
|
||||
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
|
||||
"""Enabled paid tiers other than the current plan, cheapest first (``tier_order > 0``
|
||||
— dropping to free is a cancellation, not a plan pick)."""
|
||||
"""Enabled paid tiers other than the current plan, cheapest first (dropping to free is a cancellation)."""
|
||||
return sorted(
|
||||
(t for t in (state.tiers or ()) if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0),
|
||||
key=lambda t: t.tier_order or 0,
|
||||
@@ -269,8 +223,7 @@ def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
|
||||
|
||||
|
||||
def format_tier_row(tier: SubscriptionTier) -> str:
|
||||
"""``name · $X/mo[ · $Y credits/mo]`` — thousands-grouped money (mirrors the TUI
|
||||
Free rows); the credits suffix appears ONLY when monthly credits are present and > 0."""
|
||||
"""``name · $X/mo[ · $Y credits/mo]`` (grouped money, like the TUI); credits suffix only when > 0."""
|
||||
row = f"{tier.name} · {format_money(tier.dollars_per_month, grouped=True)}/mo"
|
||||
mc = tier.monthly_credits
|
||||
if mc is not None and mc > 0:
|
||||
@@ -281,70 +234,61 @@ def format_tier_row(tier: SubscriptionTier) -> str:
|
||||
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
|
||||
"""True when ``tier_id`` ranks above the current plan by ``tier_order``. Prefers the
|
||||
active subscription's tier; falls back to the ``tiers[]`` ``is_current`` marker, else 0."""
|
||||
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
|
||||
tiers = state.tiers or ()
|
||||
orders = {t.tier_id: (t.tier_order or 0) for t in tiers}
|
||||
cur_id = state.current.tier_id if state.current else None
|
||||
if cur_id is not None and cur_id in orders:
|
||||
cur_order = orders[cur_id]
|
||||
else:
|
||||
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
|
||||
cur_order = orders[cur_id] if cur_id in orders else next((t.tier_order or 0 for t in tiers if t.is_current), 0)
|
||||
return orders.get(tier_id, 0) > cur_order
|
||||
|
||||
|
||||
# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ────
|
||||
# ── Dev fixtures (env-var driven, no live portal) ────────────────────────────
|
||||
|
||||
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
|
||||
|
||||
|
||||
def _dev_current(**over: Any) -> CurrentSubscription:
|
||||
base: dict[str, Any] = dict(
|
||||
tier_id="plus", tier_name="Plus", monthly_credits=Decimal("1000"),
|
||||
credits_remaining=Decimal("420"), cycle_ends_at="2026-07-01",
|
||||
)
|
||||
return CurrentSubscription(**{**base, **over})
|
||||
_DEV_TIER_SPECS = (("free", "Free", 0, "0", "0"), ("plus", "Plus", 1, "20", "1000"),
|
||||
("super", "Super", 2, "40", "3000"), ("ultra", "Ultra", 3, "80", "7000"))
|
||||
_DEV_FIXTURE_ALIASES = {"logged_out": "logged-out", "loggedout": "logged-out", "mid-tier": "mid",
|
||||
"top-tier": "top", "member": "not-admin"}
|
||||
|
||||
|
||||
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
|
||||
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
|
||||
specs = (("free", "Free", 0, "0", "0"), ("plus", "Plus", 1, "20", "1000"),
|
||||
("super", "Super", 2, "40", "3000"), ("ultra", "Ultra", 3, "80", "7000"))
|
||||
"""Sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
|
||||
return tuple(
|
||||
SubscriptionTier(
|
||||
tier_id=tid, name=name, tier_order=order, dollars_per_month=parse_money(dpm),
|
||||
monthly_credits=parse_money(mc), is_current=(tid == current_id), is_enabled=True,
|
||||
)
|
||||
for tid, name, order, dpm, mc in specs
|
||||
for tid, name, order, dpm, mc in _DEV_TIER_SPECS
|
||||
)
|
||||
|
||||
|
||||
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
|
||||
"""``HERMES_DEV_SUBSCRIPTION_FIXTURE`` -> fixture :class:`SubscriptionState`; None when unset.
|
||||
def _dev_plan(tier_id: str, remaining: str, **over: Any) -> dict[str, Any]:
|
||||
"""``current`` + ``tiers`` fixture fields for being on ``tier_id`` with ``remaining`` credits left."""
|
||||
tid, name, _order, _dpm, mc = next(spec for spec in _DEV_TIER_SPECS if spec[0] == tier_id)
|
||||
current = CurrentSubscription(
|
||||
tier_id=tid, tier_name=name, monthly_credits=Decimal(mc), credits_remaining=Decimal(remaining),
|
||||
cycle_ends_at="2026-07-01", **over,
|
||||
)
|
||||
return dict(current=current, tiers=_dev_tiers(tid))
|
||||
|
||||
``free | mid | top | not-admin | downgrade | cancel | team | logged-out``. Unknown
|
||||
name → logged-out with ``error`` so the misconfiguration is visible.
|
||||
"""
|
||||
|
||||
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
|
||||
"""``HERMES_DEV_SUBSCRIPTION_FIXTURE`` (``free | mid | top | not-admin | downgrade | cancel | team |
|
||||
logged-out``) -> fixture state; None when unset; unknown name → logged-out with ``error`` set."""
|
||||
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
name = {"logged_out": "logged-out", "loggedout": "logged-out", "mid-tier": "mid",
|
||||
"top-tier": "top", "member": "not-admin"}.get(name, name)
|
||||
name = _DEV_FIXTURE_ALIASES.get(name, name)
|
||||
if name == "logged-out":
|
||||
return SubscriptionState(logged_in=False)
|
||||
|
||||
common = dict(logged_in=True, org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
|
||||
plus = dict(current=_dev_current(), tiers=_dev_tiers("plus"))
|
||||
states: dict[str, dict[str, Any]] = {
|
||||
"free": dict(current=None, tiers=_dev_tiers(None)),
|
||||
"mid": plus,
|
||||
"top": dict(
|
||||
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
|
||||
tiers=_dev_tiers("ultra"),
|
||||
),
|
||||
"not-admin": {**plus, "role": "MEMBER"},
|
||||
"downgrade": dict(
|
||||
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
|
||||
tiers=_dev_tiers("super"),
|
||||
),
|
||||
"cancel": dict(current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), tiers=_dev_tiers("plus")),
|
||||
"mid": _dev_plan("plus", "420"),
|
||||
"top": _dev_plan("ultra", "5000"),
|
||||
"not-admin": {**_dev_plan("plus", "420"), "role": "MEMBER"},
|
||||
"downgrade": _dev_plan("super", "1500", pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
|
||||
"cancel": _dev_plan("plus", "420", cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
|
||||
"team": dict(context="team", current=None, org_name="Acme Engineering", org_id="org_eng"),
|
||||
}
|
||||
if name not in states:
|
||||
|
||||
+67
-160
@@ -1,32 +1,16 @@
|
||||
"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text.
|
||||
|
||||
The regex ``run_agent._strip_think_blocks`` is correct for a complete string but,
|
||||
run per-delta, erases an opening ``<think>`` that arrives alone in one delta, so
|
||||
downstream state machines never see the open tag and leak reasoning. This class
|
||||
centralises tag suppression upstream: partial tags at delta boundaries are held
|
||||
back until resolved, and ``flush()`` releases held-back prose that was not a tag.
|
||||
|
||||
Usage::
|
||||
|
||||
scrubber = StreamingThinkScrubber()
|
||||
for delta in stream:
|
||||
visible = scrubber.feed(delta)
|
||||
if visible:
|
||||
emit(visible)
|
||||
tail = scrubber.flush() # at end of stream
|
||||
|
||||
Call ``reset()`` at the top of each turn so an interrupted block cannot taint
|
||||
the next turn. Tags handled (case-insensitive): ``<think>``, ``<thinking>``,
|
||||
``<reasoning>``, ``<thought>``, ``<REASONING_SCRATCHPAD>``.
|
||||
|
||||
Boundary rule: an opening tag only starts a block at a block boundary (stream
|
||||
start, after a newline, or with only whitespace emitted on the current line), so
|
||||
prose that *mentions* ``<think>`` is not suppressed. Closed pairs
|
||||
(``<think>X</think>``) are always suppressed — a closed pair is intentional.
|
||||
The regex ``_strip_think_blocks`` is correct for a complete string but, run per-delta, erases an
|
||||
opening ``<think>`` that arrives alone, so downstream state machines leak reasoning. This class
|
||||
holds partial tags at delta boundaries until resolved; ``flush()`` releases held-back prose that
|
||||
was not a tag; ``reset()`` at the top of each turn. An open tag only starts a block at a block
|
||||
boundary (stream start / after a newline / whitespace-only line so far), so prose that *mentions*
|
||||
``<think>`` is not suppressed; closed pairs are always suppressed (intentional).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
__all__ = ["StreamingThinkScrubber"]
|
||||
@@ -35,24 +19,22 @@ __all__ = ["StreamingThinkScrubber"]
|
||||
class StreamingThinkScrubber:
|
||||
"""Stateful scrubber for streaming reasoning/thinking blocks.
|
||||
|
||||
State: ``_in_block`` (inside an open block; text discarded), ``_buf``
|
||||
(held-back partial-tag tail), ``_last_emitted_ended_newline`` (True iff the
|
||||
last emission ended with ``\\n`` or nothing has been emitted yet — decides
|
||||
whether an open tag at buffer position 0 sits at a block boundary).
|
||||
State: ``_in_block`` (inside an open block; text discarded), ``_buf`` (held-back partial-tag
|
||||
tail), ``_last_emitted_ended_newline`` (True iff the last emission ended with ``\\n`` or nothing
|
||||
was emitted yet — decides whether an open tag at buffer position 0 sits at a block boundary).
|
||||
"""
|
||||
|
||||
_OPEN_TAG_NAMES: Tuple[str, ...] = (
|
||||
"think",
|
||||
"thinking",
|
||||
"reasoning",
|
||||
"thought",
|
||||
"REASONING_SCRATCHPAD",
|
||||
)
|
||||
_OPEN_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD")
|
||||
|
||||
# Literal tag strings so the hot path does string ops, not regex per feed().
|
||||
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES)
|
||||
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name}>" for name in _OPEN_TAG_NAMES)
|
||||
_MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS)
|
||||
# Lowercased literal tags so the hot path does string ops, not regex per feed().
|
||||
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in _OPEN_TAG_NAMES)
|
||||
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name.lower()}>" for name in _OPEN_TAG_NAMES)
|
||||
_ALL_TAGS: Tuple[str, ...] = _OPEN_TAGS + _CLOSE_TAGS
|
||||
_MAX_TAG_LEN: int = max(len(tag) for tag in _ALL_TAGS)
|
||||
# Orphan close tag plus trailing whitespace (matches _strip_think_blocks case 3).
|
||||
_ORPHAN_CLOSE_RE = re.compile(
|
||||
"(?:" + "|".join(re.escape(t) for t in _CLOSE_TAGS) + r")[ \t\n\r]*", re.IGNORECASE
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reset()
|
||||
@@ -65,18 +47,13 @@ class StreamingThinkScrubber:
|
||||
|
||||
def _emit(self, out: list[str], text: str) -> None:
|
||||
"""Append visible prose to *out* (orphan close tags stripped) and track the newline flag."""
|
||||
text = self._strip_orphan_close_tags(text)
|
||||
if text:
|
||||
text = self._strip_orphan_close_tags(text)
|
||||
if text:
|
||||
out.append(text)
|
||||
self._last_emitted_ended_newline = text.endswith("\n")
|
||||
out.append(text)
|
||||
self._last_emitted_ended_newline = text.endswith("\n")
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Feed one delta; return the scrubbed visible portion.
|
||||
|
||||
Returns "" when the whole delta is reasoning content or is held back
|
||||
pending resolution of a partial tag at the boundary.
|
||||
"""
|
||||
"""Feed one delta; return the scrubbed visible portion ("" when it is all reasoning or held back)."""
|
||||
if not text:
|
||||
return ""
|
||||
buf = self._buf + text
|
||||
@@ -88,17 +65,15 @@ class StreamingThinkScrubber:
|
||||
close_idx, close_len = self._find_first_tag(buf, self._CLOSE_TAGS)
|
||||
if close_idx == -1:
|
||||
# No close yet: hold back a possible partial close-tag prefix, drop the rest.
|
||||
held = self._max_partial_suffix(buf, self._CLOSE_TAGS)
|
||||
self._buf = buf[-held:] if held else ""
|
||||
return "".join(out)
|
||||
self._hold_partial(buf, self._CLOSE_TAGS)
|
||||
break
|
||||
buf = buf[close_idx + close_len:]
|
||||
self._in_block = False
|
||||
continue
|
||||
|
||||
# Priority 1: closed <tag>X</tag> pair anywhere (no boundary gating —
|
||||
# even inline pairs are almost certainly leaked reasoning).
|
||||
# Priority 2: unterminated open tag at a block boundary (gated so
|
||||
# prose that mentions '<think>' isn't over-stripped). Earliest wins.
|
||||
# Priority 1: closed <tag>X</tag> pair anywhere (even inline pairs are almost
|
||||
# certainly leaked reasoning). Priority 2: unterminated open tag at a block
|
||||
# boundary (gated so prose mentioning '<think>' isn't over-stripped). Earliest wins.
|
||||
pair = self._find_earliest_closed_pair(buf)
|
||||
open_idx, open_len = self._find_open_at_boundary(buf, out)
|
||||
if pair is not None and (open_idx == -1 or pair[0] <= open_idx):
|
||||
@@ -113,28 +88,22 @@ class StreamingThinkScrubber:
|
||||
|
||||
# No resolvable tag: hold back any partial-tag prefix at the tail
|
||||
# so a tag split across deltas isn't missed, then emit the rest.
|
||||
held = max(
|
||||
self._max_partial_suffix(buf, self._OPEN_TAGS),
|
||||
self._max_partial_suffix(buf, self._CLOSE_TAGS),
|
||||
)
|
||||
if held:
|
||||
self._emit(out, buf[:-held])
|
||||
self._buf = buf[-held:]
|
||||
else:
|
||||
self._emit(out, buf)
|
||||
return "".join(out)
|
||||
self._emit(out, self._hold_partial(buf, self._ALL_TAGS))
|
||||
break
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def flush(self) -> str:
|
||||
"""End-of-stream flush.
|
||||
def _hold_partial(self, buf: str, tags: Tuple[str, ...]) -> str:
|
||||
"""Move a trailing partial-tag prefix of *buf* into ``_buf``; return the remainder."""
|
||||
held = self._max_partial_suffix(buf, tags)
|
||||
self._buf = buf[-held:] if held else ""
|
||||
return buf[:-held] if held else buf
|
||||
|
||||
Inside an unterminated block the held-back content is discarded (leaking
|
||||
partial reasoning is worse than a truncated answer); otherwise the
|
||||
held-back tail is emitted verbatim. Always resets the boundary flag:
|
||||
intra-turn retries flush then stream again without ``reset()``, and a
|
||||
stale False flag made the new stream's opening ``<think>`` look mid-line.
|
||||
"""
|
||||
def flush(self) -> str:
|
||||
"""End-of-stream flush: inside an unterminated block the held-back content is discarded (leaking
|
||||
partial reasoning is worse than a truncated answer), otherwise the tail is emitted verbatim.
|
||||
Always resets the boundary flag — intra-turn retries flush then stream again without ``reset()``,
|
||||
and a stale False flag made the new stream's opening ``<think>`` look mid-line."""
|
||||
tail = "" if self._in_block else self._buf
|
||||
self._buf = ""
|
||||
self._in_block = False
|
||||
@@ -147,116 +116,54 @@ class StreamingThinkScrubber:
|
||||
def _find_first_tag(buf: str, tags: Tuple[str, ...]) -> Tuple[int, int]:
|
||||
"""Return (earliest_index, tag_length) over *tags* (case-insensitive), or (-1, 0)."""
|
||||
buf_lower = buf.lower()
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
for tag in tags:
|
||||
idx = buf_lower.find(tag.lower())
|
||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||
best_idx = idx
|
||||
best_len = len(tag)
|
||||
return best_idx, best_len
|
||||
hits = [(idx, len(tag)) for tag in tags if (idx := buf_lower.find(tag)) != -1]
|
||||
return min(hits) if hits else (-1, 0)
|
||||
|
||||
def _find_earliest_closed_pair(self, buf: str):
|
||||
"""Return (start_idx, end_idx) of the earliest ``<tag>...</tag>`` pair, else None.
|
||||
|
||||
Case-insensitive and non-greedy (closest close after the open wins),
|
||||
matching ``_strip_think_blocks`` case 1; the earliest open tag wins.
|
||||
"""
|
||||
"""(start_idx, end_idx) of the earliest ``<tag>...</tag>`` pair (non-greedy, case-insensitive), else None."""
|
||||
buf_lower = buf.lower()
|
||||
best: "tuple[int, int] | None" = None
|
||||
pairs = []
|
||||
for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS):
|
||||
open_lower = open_tag.lower()
|
||||
close_lower = close_tag.lower()
|
||||
open_idx = buf_lower.find(open_lower)
|
||||
if open_idx == -1:
|
||||
continue
|
||||
close_idx = buf_lower.find(close_lower, open_idx + len(open_lower))
|
||||
if close_idx == -1:
|
||||
continue
|
||||
if best is None or open_idx < best[0]:
|
||||
best = (open_idx, close_idx + len(close_lower))
|
||||
return best
|
||||
open_idx = buf_lower.find(open_tag)
|
||||
close_idx = buf_lower.find(close_tag, open_idx + len(open_tag)) if open_idx != -1 else -1
|
||||
if close_idx != -1:
|
||||
pairs.append((open_idx, close_idx + len(close_tag)))
|
||||
return min(pairs) if pairs else None
|
||||
|
||||
def _find_open_at_boundary(self, buf: str, already_emitted: list[str]) -> Tuple[int, int]:
|
||||
"""Return the earliest block-boundary open-tag (idx, len), or (-1, 0)."""
|
||||
buf_lower = buf.lower()
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
hits = []
|
||||
for tag in self._OPEN_TAGS:
|
||||
tag_lower = tag.lower()
|
||||
search_start = 0
|
||||
while True:
|
||||
idx = buf_lower.find(tag_lower, search_start)
|
||||
if idx == -1:
|
||||
break
|
||||
if self._is_block_boundary(buf, idx, already_emitted):
|
||||
if best_idx == -1 or idx < best_idx:
|
||||
best_idx = idx
|
||||
best_len = len(tag)
|
||||
break # first boundary hit for this tag is enough
|
||||
search_start = idx + 1
|
||||
return best_idx, best_len
|
||||
idx = buf_lower.find(tag)
|
||||
while idx != -1 and not self._is_block_boundary(buf, idx, already_emitted):
|
||||
idx = buf_lower.find(tag, idx + 1)
|
||||
if idx != -1:
|
||||
hits.append((idx, len(tag)))
|
||||
return min(hits) if hits else (-1, 0)
|
||||
|
||||
def _is_block_boundary(self, buf: str, idx: int, already_emitted: list[str]) -> bool:
|
||||
"""True iff position *idx* in *buf* is a block boundary.
|
||||
|
||||
Boundary = position 0 with the prior emission ending in a newline (or
|
||||
nothing emitted yet), or any position whose preceding text on the current
|
||||
line is whitespace-only (when no newline precedes it in *buf*, the prior
|
||||
emission must also have ended with a newline).
|
||||
"""
|
||||
prior_newline = (
|
||||
already_emitted[-1].endswith("\n") if already_emitted else self._last_emitted_ended_newline
|
||||
)
|
||||
"""True iff *idx* is a block boundary: position 0 after a newline-terminated (or no) prior emission,
|
||||
or any position whose preceding text on the current line is whitespace-only (when no newline
|
||||
precedes it in *buf*, the prior emission must also have ended with a newline)."""
|
||||
prior_newline = already_emitted[-1].endswith("\n") if already_emitted else self._last_emitted_ended_newline
|
||||
if idx == 0:
|
||||
return prior_newline
|
||||
preceding = buf[:idx]
|
||||
last_nl = preceding.rfind("\n")
|
||||
if last_nl == -1:
|
||||
return prior_newline and preceding.strip() == ""
|
||||
return preceding[last_nl + 1:].strip() == ""
|
||||
return (prior_newline if last_nl == -1 else True) and preceding[last_nl + 1:].strip() == ""
|
||||
|
||||
@classmethod
|
||||
def _max_partial_suffix(cls, buf: str, tags: Tuple[str, ...]) -> int:
|
||||
"""Longest buf-suffix that is a strict prefix of any tag (case-insensitive).
|
||||
|
||||
Full-length matches are real tags handled elsewhere, not held-back partials.
|
||||
"""
|
||||
if not buf:
|
||||
return 0
|
||||
"""Longest buf-suffix that is a strict prefix of any tag (full matches are real tags, handled elsewhere)."""
|
||||
buf_lower = buf.lower()
|
||||
max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1)
|
||||
for i in range(max_check, 0, -1):
|
||||
for i in range(min(len(buf_lower), cls._MAX_TAG_LEN - 1), 0, -1):
|
||||
suffix = buf_lower[-i:]
|
||||
for tag in tags:
|
||||
tag_lower = tag.lower()
|
||||
if len(tag_lower) > i and tag_lower.startswith(suffix):
|
||||
return i
|
||||
if any(len(tag) > i and tag.startswith(suffix) for tag in tags):
|
||||
return i
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _strip_orphan_close_tags(cls, text: str) -> str:
|
||||
"""Remove close tags with no matching open (always noise) plus trailing whitespace."""
|
||||
if "</" not in text:
|
||||
return text
|
||||
text_lower = text.lower()
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
matched = False
|
||||
if text_lower[i:i + 2] == "</":
|
||||
for tag in cls._CLOSE_TAGS:
|
||||
tag_lower = tag.lower()
|
||||
tag_len = len(tag_lower)
|
||||
if text_lower[i:i + tag_len] == tag_lower:
|
||||
# Skip the tag and trailing whitespace (matches _strip_think_blocks case 3).
|
||||
j = i + tag_len
|
||||
while j < len(text) and text[j] in " \t\n\r":
|
||||
j += 1
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
out.append(text[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
return cls._ORPHAN_CLOSE_RE.sub("", text) if "</" in text else text
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Thread-scoped stdout/stderr silencing for background worker threads.
|
||||
|
||||
``contextlib.redirect_stdout`` reassigns the *process-global* stream, so a
|
||||
daemon worker silencing itself also silences every other thread (gateway
|
||||
event loop included) for the duration. This module installs a per-thread
|
||||
routing proxy as ``sys.stdout``/``sys.stderr``: silenced threads write to a
|
||||
sink, everyone else passes through to the original stream. Installed once,
|
||||
idempotently, and never uninstalled (that would race other threads mid-write).
|
||||
``contextlib.redirect_stdout`` reassigns the *process-global* stream, so a daemon worker
|
||||
silencing itself also silences every other thread (gateway event loop included). This
|
||||
module installs a per-thread routing proxy as ``sys.stdout``/``sys.stderr``: silenced
|
||||
threads write to a sink, everyone else passes through to the original stream. Installed
|
||||
once, idempotently, and never uninstalled (that would race other threads mid-write).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,9 +44,7 @@ class _ThreadRoutingStream:
|
||||
self._state = state
|
||||
|
||||
def _target(self) -> TextIO:
|
||||
if self._state.silenced.get(threading.get_ident(), 0) > 0:
|
||||
return self._state.sink
|
||||
return self._passthrough
|
||||
return self._state.sink if self._state.silenced.get(threading.get_ident(), 0) > 0 else self._passthrough
|
||||
|
||||
def silence(self, ident: int) -> None:
|
||||
with self._state.lock:
|
||||
@@ -61,24 +58,21 @@ class _ThreadRoutingStream:
|
||||
else:
|
||||
self._state.silenced.pop(ident, None)
|
||||
|
||||
def write(self, data): # type: ignore[no-untyped-def]
|
||||
def _forward(self, name: str, fallback, *args): # type: ignore[no-untyped-def]
|
||||
"""Call ``name`` on the current target; a dead target yields ``fallback(*args)`` instead of raising."""
|
||||
try:
|
||||
return self._target().write(data)
|
||||
return getattr(self._target(), name)(*args)
|
||||
except Exception:
|
||||
return len(data) if isinstance(data, str) else 0
|
||||
return fallback(*args)
|
||||
|
||||
def write(self, data): # type: ignore[no-untyped-def]
|
||||
return self._forward("write", lambda d: len(d) if isinstance(d, str) else 0, data)
|
||||
|
||||
def flush(self): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
return self._target().flush()
|
||||
except Exception:
|
||||
return None
|
||||
return self._forward("flush", lambda: None)
|
||||
|
||||
def writelines(self, lines): # type: ignore[no-untyped-def]
|
||||
target = self._target()
|
||||
try:
|
||||
return target.writelines(lines)
|
||||
except Exception:
|
||||
return None
|
||||
return self._forward("writelines", lambda _l: None, lines)
|
||||
|
||||
def isatty(self) -> bool:
|
||||
try:
|
||||
@@ -111,15 +105,12 @@ def _ensure_installed(attr: str, passthrough: TextIO) -> "_ThreadRoutingStream":
|
||||
passthrough = current if current is not None else passthrough
|
||||
sink = _sinks.get(attr)
|
||||
if sink is None or sink.closed:
|
||||
sink = open(os.devnull, "w", encoding="utf-8")
|
||||
_sinks[attr] = sink
|
||||
sink = _sinks[attr] = open(os.devnull, "w", encoding="utf-8")
|
||||
state = _routing_states.get(attr)
|
||||
if state is None or state.sink is not sink:
|
||||
state = _RoutingState(sink)
|
||||
_routing_states[attr] = state
|
||||
proxy = _ThreadRoutingStream(passthrough, state)
|
||||
state = _routing_states[attr] = _RoutingState(sink)
|
||||
proxy = _installed[attr] = _ThreadRoutingStream(passthrough, state)
|
||||
setattr(sys, attr, proxy)
|
||||
_installed[attr] = proxy
|
||||
return proxy
|
||||
|
||||
|
||||
@@ -127,12 +118,11 @@ def _ensure_installed(attr: str, passthrough: TextIO) -> "_ThreadRoutingStream":
|
||||
def thread_scoped_silence() -> Iterator[None]:
|
||||
"""Silence ``stdout``/``stderr`` for the *current thread only*."""
|
||||
ident = threading.get_ident()
|
||||
out_proxy = _ensure_installed("stdout", sys.__stdout__ or sys.stdout)
|
||||
err_proxy = _ensure_installed("stderr", sys.__stderr__ or sys.stderr)
|
||||
out_proxy.silence(ident)
|
||||
err_proxy.silence(ident)
|
||||
proxies = (_ensure_installed("stdout", sys.__stdout__ or sys.stdout), _ensure_installed("stderr", sys.__stderr__ or sys.stderr))
|
||||
for proxy in proxies:
|
||||
proxy.silence(ident)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
out_proxy.unsilence(ident)
|
||||
err_proxy.unsilence(ident)
|
||||
for proxy in proxies:
|
||||
proxy.unsilence(ident)
|
||||
|
||||
+217
-378
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from typing import Any, Mapping
|
||||
|
||||
from utils import safe_json_loads
|
||||
@@ -17,12 +17,11 @@ from agent.tool_result_classification import file_mutation_result_landed
|
||||
|
||||
|
||||
IDEMPOTENT_TOOL_NAMES = frozenset({
|
||||
"read_file", "search_files", "web_search", "web_extract", "session_search", "skill_view",
|
||||
"skills_list", "browser_snapshot", "browser_console", "browser_get_images",
|
||||
"mcp_filesystem_read_file", "mcp_filesystem_read_text_file",
|
||||
"mcp_filesystem_read_multiple_files", "mcp_filesystem_list_directory",
|
||||
"mcp_filesystem_list_directory_with_sizes", "mcp_filesystem_directory_tree",
|
||||
"mcp_filesystem_get_file_info", "mcp_filesystem_search_files",
|
||||
"read_file", "search_files", "web_search", "web_extract", "session_search", "skill_view", "skills_list",
|
||||
"browser_snapshot", "browser_console", "browser_get_images", "mcp_filesystem_read_file",
|
||||
"mcp_filesystem_read_text_file", "mcp_filesystem_read_multiple_files", "mcp_filesystem_list_directory",
|
||||
"mcp_filesystem_list_directory_with_sizes", "mcp_filesystem_directory_tree", "mcp_filesystem_get_file_info",
|
||||
"mcp_filesystem_search_files",
|
||||
})
|
||||
|
||||
MUTATING_TOOL_NAMES = frozenset({
|
||||
@@ -31,55 +30,83 @@ MUTATING_TOOL_NAMES = frozenset({
|
||||
"send_message", "cronjob_manage", "delegate_task", "process_manage",
|
||||
})
|
||||
|
||||
# Tools legitimately re-invoked with identical args while waiting on external
|
||||
# progress (pollers). The identical-call NOTICE never fires for these.
|
||||
# Pollers: legitimately re-invoked with identical args; the identical-call NOTICE never fires.
|
||||
STALL_GUARD_REPEATABLE_TOOLS = frozenset({"process_manage"})
|
||||
# Poller naming conventions on generated / MCP surfaces (``<vendor>_get_result``).
|
||||
_STALL_GUARD_REPEATABLE_SUFFIXES = ("_get_result", "_poll")
|
||||
|
||||
# Notice fires on the Nth consecutive identical (tool, args, result) call; 3
|
||||
# tolerates one legitimate double-check while catching observed re-issue loops.
|
||||
_STALL_GUARD_REPEATABLE_SUFFIXES = ("_get_result", "_poll") # generated / MCP poller conventions
|
||||
# Nth consecutive identical (tool, args, result) call that fires the notice; 3 tolerates one double-check.
|
||||
STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3
|
||||
|
||||
# Result-reference stubbing: from the 2nd consecutive identical call whose fresh
|
||||
# result is byte-identical, the duplicate payload is replaced by a reference
|
||||
# stub. Results under this size aren't worth stubbing; errors are never stubbed.
|
||||
# From the 2nd byte-identical repeat the duplicate payload becomes a reference stub; smaller results
|
||||
# aren't worth it, errors never are. The args preview keeps WHAT was called if compression evicts the original.
|
||||
IDENTICAL_RESULT_STUB_MIN_CHARS = 512
|
||||
# Canonical-args preview kept in the stub so the model still knows WHAT the call
|
||||
# was if compression later evicts the referenced result.
|
||||
_RESULT_STUB_ARGS_PREVIEW_CHARS = 120
|
||||
|
||||
# Tools whose "failure" is normal work output (red test run, empty grep, page
|
||||
# timeout). same_tool_failure (DIFFERENT commands) never halts these; only an
|
||||
# exact-args replay with no intervening change, or an identical-result streak, can.
|
||||
# Tools whose "failure" is normal work output (red test run, empty grep, page timeout).
|
||||
# same_tool_failure (DIFFERENT commands) never halts these; only an exact-args replay with
|
||||
# no intervening change, or an identical-result streak, can.
|
||||
FAILURE_TOLERANT_TOOL_NAMES = frozenset({
|
||||
"terminal", "execute_code", "process_manage", "process", "browser_navigate", "web_extract",
|
||||
})
|
||||
|
||||
# A successful call to one of these marks progress for every failing signature
|
||||
# still counted this turn: the next retry is a new experiment (edit -> re-run), not a replay.
|
||||
# A successful call to one of these marks progress for every failing signature still counted
|
||||
# this turn: the next retry is a new experiment (edit -> re-run), not a replay.
|
||||
PROGRESS_RESET_TOOL_NAMES = frozenset({
|
||||
"write_file", "patch", "terminal", "execute_code", "browser_click", "browser_type",
|
||||
"browser_press", "browser_navigate", "process_manage", "process", "delegate_task",
|
||||
"send_message", "cronjob", "cronjob_manage", "todo", "todo_list", "memory", "skill_manage",
|
||||
"write_file", "patch", "terminal", "execute_code", "browser_click", "browser_type", "browser_press",
|
||||
"browser_navigate", "process_manage", "process", "delegate_task", "send_message", "cronjob",
|
||||
"cronjob_manage", "todo", "todo_list", "memory", "skill_manage",
|
||||
})
|
||||
|
||||
_BOOL_FIELDS = ("warnings_enabled", "hard_stop_enabled", "non_interactive_hard_stop_enabled")
|
||||
# Threshold field -> (nested section, nested key). The flat legacy key is the field name itself.
|
||||
_THRESHOLD_SOURCES: dict[str, tuple[str, str]] = {
|
||||
"exact_failure_warn_after": ("warn_after", "exact_failure"),
|
||||
"same_tool_failure_warn_after": ("warn_after", "same_tool_failure"),
|
||||
"no_progress_warn_after": ("warn_after", "idempotent_no_progress"),
|
||||
"exact_failure_block_after": ("hard_stop_after", "exact_failure"),
|
||||
"same_tool_failure_halt_after": ("hard_stop_after", "same_tool_failure"),
|
||||
"no_progress_block_after": ("hard_stop_after", "idempotent_no_progress"),
|
||||
}
|
||||
|
||||
# Per-turn caps on runaway-prone tools (counters reset in reset_for_turn).
|
||||
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
|
||||
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
|
||||
|
||||
# Interactive surfaces plus bounded supervised task loops (subagent stopped by its parent;
|
||||
# api_server has a live client) doing real edit -> re-run work keep the warn-only default.
|
||||
_ATTENDED_PLATFORMS = frozenset({"cli", "tui", "desktop", "acp", "subagent", "api_server"})
|
||||
|
||||
|
||||
def is_stall_guard_repeatable(tool_name: str) -> bool:
|
||||
"""Whether a tool is exempt from the identical-call loop notice."""
|
||||
return tool_name in STALL_GUARD_REPEATABLE_TOOLS or tool_name.endswith(
|
||||
_STALL_GUARD_REPEATABLE_SUFFIXES
|
||||
)
|
||||
return tool_name in STALL_GUARD_REPEATABLE_TOOLS or tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES)
|
||||
|
||||
|
||||
def _is_non_interactive_platform(platform: str | None) -> bool:
|
||||
"""True for gateway/cron sessions where tool loops are unattended."""
|
||||
if not isinstance(platform, str) or not platform.strip():
|
||||
return False
|
||||
return platform.strip().lower() not in _ATTENDED_PLATFORMS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopCapConfig:
|
||||
"""Per-turn hard ceilings on web_search calls / subagent spawns; count total calls (not
|
||||
repeats), fire regardless of ``hard_stop_enabled``; ``0`` disables a cap."""
|
||||
|
||||
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
return cls(**{f.name: _int_at_least(data.get(f.name), f.default, 0) for f in fields(cls)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallGuardrailConfig:
|
||||
"""Thresholds for per-turn tool-call loop detection.
|
||||
|
||||
Warnings never prevent execution. Hard stops are opt-in for interactive
|
||||
platforms but default on for unattended gateway/cron platforms where nobody
|
||||
can interrupt a model that ignores loop warnings.
|
||||
"""
|
||||
"""Thresholds for per-turn tool-call loop detection. Warnings never prevent execution; hard
|
||||
stops are opt-in on interactive platforms, default on for unattended gateway/cron platforms."""
|
||||
|
||||
warnings_enabled: bool = True
|
||||
hard_stop_enabled: bool = False
|
||||
@@ -92,108 +119,32 @@ class ToolCallGuardrailConfig:
|
||||
no_progress_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
|
||||
loop_caps: LoopCapConfig = field(default_factory=LoopCapConfig)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls,
|
||||
data: Mapping[str, Any] | None,
|
||||
*,
|
||||
platform: str | None = None,
|
||||
cls, data: Mapping[str, Any] | None, *, platform: str | None = None,
|
||||
) -> "ToolCallGuardrailConfig":
|
||||
"""Build config from the `tool_loop_guardrails` config.yaml section.
|
||||
|
||||
Nested ``warn_after`` / ``hard_stop_after`` keys win over the flat legacy keys.
|
||||
"""
|
||||
"""Build config from `tool_loop_guardrails`; nested ``warn_after`` / ``hard_stop_after`` win over flat legacy keys."""
|
||||
if not isinstance(data, Mapping):
|
||||
data = {}
|
||||
d = cls()
|
||||
hard_stop_enabled = _as_bool(data.get("hard_stop_enabled"), d.hard_stop_enabled)
|
||||
non_interactive_hard_stop_enabled = _as_bool(
|
||||
data.get("non_interactive_hard_stop_enabled"), d.non_interactive_hard_stop_enabled,
|
||||
)
|
||||
if _is_non_interactive_platform(platform) and non_interactive_hard_stop_enabled:
|
||||
hard_stop_enabled = True
|
||||
flags = {name: _as_bool(data.get(name), getattr(d, name)) for name in _BOOL_FIELDS}
|
||||
if flags["non_interactive_hard_stop_enabled"] and _is_non_interactive_platform(platform):
|
||||
flags["hard_stop_enabled"] = True
|
||||
|
||||
thresholds: dict[str, int] = {}
|
||||
for field_name, (section_name, key) in _THRESHOLD_SOURCES.items():
|
||||
def threshold(name: str, section_name: str, key: str) -> int:
|
||||
section = data.get(section_name)
|
||||
if not isinstance(section, Mapping):
|
||||
section = {}
|
||||
thresholds[field_name] = _int_at_least(
|
||||
section.get(key, data.get(field_name)), getattr(d, field_name), 1,
|
||||
)
|
||||
nested = section.get(key, data.get(name)) if isinstance(section, Mapping) else data.get(name)
|
||||
return _int_at_least(nested, getattr(d, name), 1)
|
||||
|
||||
return cls(
|
||||
warnings_enabled=_as_bool(data.get("warnings_enabled"), d.warnings_enabled),
|
||||
hard_stop_enabled=hard_stop_enabled,
|
||||
non_interactive_hard_stop_enabled=non_interactive_hard_stop_enabled,
|
||||
loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")),
|
||||
**thresholds,
|
||||
)
|
||||
|
||||
|
||||
# Threshold field -> (nested section, nested key). The flat legacy key is the field name itself.
|
||||
_THRESHOLD_SOURCES: dict[str, tuple[str, str]] = {
|
||||
"exact_failure_warn_after": ("warn_after", "exact_failure"),
|
||||
"same_tool_failure_warn_after": ("warn_after", "same_tool_failure"),
|
||||
"no_progress_warn_after": ("warn_after", "idempotent_no_progress"),
|
||||
"exact_failure_block_after": ("hard_stop_after", "exact_failure"),
|
||||
"same_tool_failure_halt_after": ("hard_stop_after", "same_tool_failure"),
|
||||
"no_progress_block_after": ("hard_stop_after", "idempotent_no_progress"),
|
||||
}
|
||||
|
||||
|
||||
# Per-turn caps on runaway-prone tools; counters reset in reset_for_turn at the
|
||||
# start of every agent loop, so the limit is per turn, not per session. Dozens
|
||||
# of searches / subagent spawns in one loop is already pathological.
|
||||
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
|
||||
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopCapConfig:
|
||||
"""Per-turn hard ceilings on web_search calls / subagent spawns.
|
||||
|
||||
Unlike the loop detector (keyed on repeated identical/failing calls) these
|
||||
count total calls within the turn and fire regardless of
|
||||
``hard_stop_enabled``. ``0`` disables a cap.
|
||||
"""
|
||||
|
||||
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
defaults = cls()
|
||||
return cls(
|
||||
max_web_searches=_int_at_least(data.get("max_web_searches"), defaults.max_web_searches, 0),
|
||||
max_subagents=_int_at_least(data.get("max_subagents"), defaults.max_subagents, 0),
|
||||
)
|
||||
|
||||
|
||||
_INTERACTIVE_PLATFORMS = frozenset({"cli", "tui", "desktop", "acp"})
|
||||
# Not chat gateways, but bounded supervised task loops (a subagent is stopped by
|
||||
# its parent; api_server has a live client). Both do real edit -> re-run work,
|
||||
# so they keep the interactive warn-only default.
|
||||
_SUPERVISED_TASK_PLATFORMS = frozenset({"subagent", "api_server"})
|
||||
|
||||
|
||||
def _is_non_interactive_platform(platform: str | None) -> bool:
|
||||
"""True for gateway/cron sessions where tool loops are unattended."""
|
||||
if not isinstance(platform, str) or not platform.strip():
|
||||
return False
|
||||
key = platform.strip().lower()
|
||||
return key not in _INTERACTIVE_PLATFORMS and key not in _SUPERVISED_TASK_PLATFORMS
|
||||
thresholds = {name: threshold(name, *src) for name, src in _THRESHOLD_SOURCES.items()}
|
||||
return cls(loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), **flags, **thresholds)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdenticalCallObservation:
|
||||
"""Outcome of observing one completed call: ``notice`` is appended after the
|
||||
result, ``stub`` replaces a byte-identical duplicate result. Both may be set."""
|
||||
"""``notice`` is appended after the result, ``stub`` replaces a byte-identical duplicate result."""
|
||||
|
||||
notice: str | None = None
|
||||
stub: str | None = None
|
||||
@@ -212,7 +163,7 @@ class ToolCallSignature:
|
||||
|
||||
def to_metadata(self) -> dict[str, str]:
|
||||
"""Return public metadata without raw argument values."""
|
||||
return {"tool_name": self.tool_name, "args_hash": self.args_hash}
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -235,15 +186,9 @@ class ToolGuardrailDecision:
|
||||
return self.action in {"block", "halt"}
|
||||
|
||||
def to_metadata(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"action": self.action,
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"tool_name": self.tool_name,
|
||||
"count": self.count,
|
||||
}
|
||||
if self.signature is not None:
|
||||
data["signature"] = self.signature.to_metadata()
|
||||
data = asdict(self)
|
||||
if data["signature"] is None:
|
||||
del data["signature"]
|
||||
return data
|
||||
|
||||
|
||||
@@ -259,32 +204,73 @@ def canonical_tool_args(args: Mapping[str, Any]) -> str:
|
||||
|
||||
|
||||
def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
|
||||
"""Fallback classifier used only when callers don't pass ``failed``.
|
||||
|
||||
Mirrors ``agent.display._detect_tool_failure`` exactly so the guardrail
|
||||
never disagrees with the CLI's user-visible ``[error]`` tag.
|
||||
"""
|
||||
"""Fallback classifier used only when callers don't pass ``failed``; mirrors
|
||||
``agent.display._detect_tool_failure`` so the guardrail never disagrees with the CLI's ``[error]`` tag."""
|
||||
if result is None or file_mutation_result_landed(tool_name, result):
|
||||
return False, ""
|
||||
|
||||
if tool_name == "terminal":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict):
|
||||
exit_code = data.get("exit_code")
|
||||
if exit_code is not None and exit_code != 0:
|
||||
return True, f" [exit {exit_code}]"
|
||||
return False, ""
|
||||
exit_code = data.get("exit_code") if isinstance(data, dict) else None
|
||||
return (True, f" [exit {exit_code}]") if exit_code is not None and exit_code != 0 else (False, "")
|
||||
|
||||
if tool_name == "memory":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict) and data.get("success") is False and "exceed the limit" in data.get("error", ""):
|
||||
return True, " [full]"
|
||||
|
||||
lower = result[:500].lower()
|
||||
if '"error"' in lower or '"failed"' in lower or result.startswith("Error"):
|
||||
return True, " [error]"
|
||||
return (True, " [error]") if '"error"' in lower or '"failed"' in lower or result.startswith("Error") else (False, "")
|
||||
|
||||
return False, ""
|
||||
|
||||
# Guardrail verdict text injected into the conversation, keyed by decision code.
|
||||
# ``same_tool_failure_warning`` is built by _tool_failure_recovery_hint (tool-specific).
|
||||
_DECISION_MESSAGES: dict[str, str] = {
|
||||
"repeated_exact_failure_block": (
|
||||
"Blocked {tool_name}: the same tool call failed {count} times with identical arguments. "
|
||||
"Stop retrying it unchanged; change strategy or explain the blocker."
|
||||
),
|
||||
"idempotent_no_progress_block": (
|
||||
"Blocked {tool_name}: this read-only call returned the same result {count} times. "
|
||||
"Stop repeating it unchanged; use the result already provided or try a different query."
|
||||
),
|
||||
"same_tool_failure_halt": (
|
||||
"Stopped {tool_name}: it failed {count} times this turn. "
|
||||
"Stop retrying the same failing tool path and choose a different approach."
|
||||
),
|
||||
"repeated_exact_failure_warning": (
|
||||
"{tool_name} has failed {count} times with identical arguments. This looks like a loop; "
|
||||
"inspect the error and change strategy instead of retrying it unchanged."
|
||||
),
|
||||
"idempotent_no_progress_warning": (
|
||||
"{tool_name} returned the same result {count} times. Use the result already provided "
|
||||
"or change the query instead of repeating it unchanged."
|
||||
),
|
||||
"identical_call_streak_halt": (
|
||||
"Stopped {tool_name}: the same call with identical arguments returned the same result "
|
||||
"{count} times in a row. Stop repeating it unchanged; use the result already provided or change strategy."
|
||||
),
|
||||
"loop_web_search_cap": (
|
||||
"Blocked web_search: this turn has already made {cap} web searches, the per-turn limit. "
|
||||
"This looks like a runaway search loop. Work with the results you already have and give the user your answer."
|
||||
),
|
||||
"loop_subagent_cap": (
|
||||
"Blocked delegate_task: this turn has already spawned {count} subagents (limit {cap}). "
|
||||
"This looks like a runaway delegation loop. Finish the work with the results you have and answer the user."
|
||||
),
|
||||
}
|
||||
|
||||
_IDENTICAL_CALL_NOTICE = (
|
||||
"[hermes note: this is the {ordinal} consecutive identical call to "
|
||||
"{tool_name} with identical arguments returning the same result. "
|
||||
"Do not repeat it — change arguments, use a different tool, or "
|
||||
"proceed with what you have.]"
|
||||
)
|
||||
|
||||
# tool -> (LoopCapConfig field, controller counter attribute, decision code)
|
||||
_LOOP_CAPS: dict[str, tuple[str, str, str]] = {
|
||||
"web_search": ("max_web_searches", "_turn_web_search_count", "loop_web_search_cap"),
|
||||
"delegate_task": ("max_subagents", "_turn_subagent_count", "loop_subagent_cap"),
|
||||
}
|
||||
|
||||
|
||||
class ToolCallGuardrailController:
|
||||
@@ -301,16 +287,13 @@ class ToolCallGuardrailController:
|
||||
self._progress_since_failure: dict[ToolCallSignature, bool] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
# Identical-call streak: CONSECUTIVE identical (tool, args) calls with
|
||||
# identical results. Any different call or result resets it, so re-reads
|
||||
# after edits and varied polling are never flagged.
|
||||
# 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.
|
||||
self._identical_streak_sig: ToolCallSignature | None = None
|
||||
self._identical_streak_result_hash: str = ""
|
||||
self._identical_streak_count: int = 0
|
||||
# tool_call_id of the streak's FIRST call, so a stub can point at the full payload.
|
||||
self._identical_streak_first_call_id: str = ""
|
||||
# tool_call_id -> spillover path, so a stub referencing a result that
|
||||
# entered context only as a persisted-output preview can't dangle.
|
||||
# tool_call_id -> spillover path, so a stub referencing a persisted-output preview can't dangle.
|
||||
self._persisted_result_paths: dict[str, str] = {}
|
||||
self._turn_web_search_count = 0
|
||||
self._turn_subagent_count = 0
|
||||
@@ -319,16 +302,17 @@ class ToolCallGuardrailController:
|
||||
def halt_decision(self) -> ToolGuardrailDecision | None:
|
||||
return self._halt_decision
|
||||
|
||||
def _halt(
|
||||
self, action: str, code: str, message: str,
|
||||
tool_name: str, count: int, signature: ToolCallSignature,
|
||||
def _decide(
|
||||
self, action: str, code: str, tool_name: str, count: int, signature: ToolCallSignature,
|
||||
*, message: str | None = None, **fmt: Any,
|
||||
) -> ToolGuardrailDecision:
|
||||
"""Build a block/halt decision and record it as the turn's halt decision."""
|
||||
self._halt_decision = ToolGuardrailDecision(
|
||||
action=action, code=code, message=message,
|
||||
tool_name=tool_name, count=count, signature=signature,
|
||||
)
|
||||
return self._halt_decision
|
||||
"""Build a warn/block/halt decision; block/halt is also recorded as the turn's halt decision."""
|
||||
if message is None:
|
||||
message = _DECISION_MESSAGES[code].format(tool_name=tool_name, count=count, **fmt)
|
||||
decision = ToolGuardrailDecision(action, code, message, tool_name, count, signature)
|
||||
if decision.should_halt:
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
|
||||
args = _coerce_args(args)
|
||||
@@ -337,116 +321,59 @@ class ToolCallGuardrailController:
|
||||
|
||||
# Loop caps apply regardless of hard_stop_enabled (which only governs the detector).
|
||||
cap_block = self._check_loop_cap(tool_name, args, signature)
|
||||
if cap_block is not None:
|
||||
return cap_block
|
||||
|
||||
if not self.config.hard_stop_enabled:
|
||||
return allow
|
||||
|
||||
exact_count = self._exact_failure_counts.get(signature, 0)
|
||||
if self._progress_since_failure.get(signature):
|
||||
# Something landed since this call last failed — let it run; the
|
||||
# streak restarts in after_call if it fails again.
|
||||
exact_count = 0
|
||||
if cap_block is not None or not self.config.hard_stop_enabled:
|
||||
return cap_block or allow
|
||||
# A mutation since this call last failed makes the retry a new experiment.
|
||||
exact_count = 0 if self._progress_since_failure.get(signature) else self._exact_failure_counts.get(signature, 0)
|
||||
if exact_count >= self.config.exact_failure_block_after:
|
||||
return self._halt(
|
||||
"block", "repeated_exact_failure_block",
|
||||
f"Blocked {tool_name}: the same tool call failed {exact_count} "
|
||||
"times with identical arguments. Stop retrying it unchanged; "
|
||||
"change strategy or explain the blocker.",
|
||||
tool_name, exact_count, signature,
|
||||
)
|
||||
|
||||
if self._is_idempotent(tool_name):
|
||||
record = self._no_progress.get(signature)
|
||||
if record is not None and record[1] >= self.config.no_progress_block_after:
|
||||
repeat_count = record[1]
|
||||
return self._halt(
|
||||
"block", "idempotent_no_progress_block",
|
||||
f"Blocked {tool_name}: this read-only call returned the same "
|
||||
f"result {repeat_count} times. Stop repeating it unchanged; "
|
||||
"use the result already provided or try a different query.",
|
||||
tool_name, repeat_count, signature,
|
||||
)
|
||||
|
||||
return self._decide("block", "repeated_exact_failure_block", tool_name, exact_count, signature)
|
||||
record = self._no_progress.get(signature) if self._is_idempotent(tool_name) else None
|
||||
if record is not None and record[1] >= self.config.no_progress_block_after:
|
||||
return self._decide("block", "idempotent_no_progress_block", tool_name, record[1], signature)
|
||||
return allow
|
||||
|
||||
def after_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
*,
|
||||
failed: bool | None = None,
|
||||
self, tool_name: str, args: Mapping[str, Any] | None, result: str | None,
|
||||
*, failed: bool | None = None,
|
||||
) -> ToolGuardrailDecision:
|
||||
args = _coerce_args(args)
|
||||
signature = ToolCallSignature.from_call(tool_name, args)
|
||||
if failed is None:
|
||||
failed, _ = classify_tool_failure(tool_name, result)
|
||||
|
||||
def warn(code: str, message: str, count: int) -> ToolGuardrailDecision:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn", code=code, message=message,
|
||||
tool_name=tool_name, count=count, signature=signature,
|
||||
)
|
||||
warnings = self.config.warnings_enabled
|
||||
|
||||
if failed:
|
||||
# An identical failing call is only a REPLAY if nothing landed in
|
||||
# between; a mutation since the last identical failure makes the
|
||||
# retry a new experiment, so the exact-args streak restarts.
|
||||
# An identical failing call is only a REPLAY if nothing landed in between;
|
||||
# a mutation since the last identical failure restarts the exact-args streak.
|
||||
if self._progress_since_failure.pop(signature, False):
|
||||
self._exact_failure_counts.pop(signature, None)
|
||||
exact_count = self._exact_failure_counts.get(signature, 0) + 1
|
||||
self._exact_failure_counts[signature] = exact_count
|
||||
exact_count = self._exact_failure_counts[signature] = self._exact_failure_counts.get(signature, 0) + 1
|
||||
same_count = self._same_tool_failure_counts[tool_name] = self._same_tool_failure_counts.get(tool_name, 0) + 1
|
||||
self._no_progress.pop(signature, None)
|
||||
|
||||
same_count = self._same_tool_failure_counts.get(tool_name, 0) + 1
|
||||
self._same_tool_failure_counts[tool_name] = same_count
|
||||
|
||||
# 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 (exact-args replay still applies).
|
||||
# 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 (
|
||||
self.config.hard_stop_enabled
|
||||
and tool_name not in FAILURE_TOLERANT_TOOL_NAMES
|
||||
and same_count >= self.config.same_tool_failure_halt_after
|
||||
):
|
||||
return self._halt(
|
||||
"halt", "same_tool_failure_halt",
|
||||
f"Stopped {tool_name}: it failed {same_count} times this turn. "
|
||||
"Stop retrying the same failing tool path and choose a different approach.",
|
||||
tool_name, same_count, signature,
|
||||
return self._decide("halt", "same_tool_failure_halt", tool_name, same_count, signature)
|
||||
if warnings and exact_count >= self.config.exact_failure_warn_after:
|
||||
return self._decide("warn", "repeated_exact_failure_warning", tool_name, exact_count, signature)
|
||||
if warnings and same_count >= self.config.same_tool_failure_warn_after:
|
||||
return self._decide(
|
||||
"warn", "same_tool_failure_warning", tool_name, same_count, signature,
|
||||
message=_tool_failure_recovery_hint(tool_name, same_count),
|
||||
)
|
||||
|
||||
if self.config.warnings_enabled and exact_count >= self.config.exact_failure_warn_after:
|
||||
return warn(
|
||||
"repeated_exact_failure_warning",
|
||||
f"{tool_name} has failed {exact_count} times with identical arguments. "
|
||||
"This looks like a loop; inspect the error and change strategy "
|
||||
"instead of retrying it unchanged.",
|
||||
exact_count,
|
||||
)
|
||||
|
||||
if self.config.warnings_enabled and same_count >= self.config.same_tool_failure_warn_after:
|
||||
return warn(
|
||||
"same_tool_failure_warning",
|
||||
_tool_failure_recovery_hint(tool_name, same_count),
|
||||
same_count,
|
||||
)
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=exact_count, signature=signature)
|
||||
|
||||
self._exact_failure_counts.pop(signature, None)
|
||||
self._same_tool_failure_counts.pop(tool_name, None)
|
||||
|
||||
# A successful mutation is progress for every failing signature still
|
||||
# counted this turn (next identical retry runs against changed state).
|
||||
# Pure loops never mutate between attempts, so the replay detector keeps its teeth.
|
||||
# A successful mutation is progress for every failing signature still counted
|
||||
# this turn. Pure loops never mutate between attempts, so the replay detector keeps its teeth.
|
||||
if tool_name in PROGRESS_RESET_TOOL_NAMES or file_mutation_result_landed(tool_name, result):
|
||||
for sig in list(self._exact_failure_counts):
|
||||
self._progress_since_failure[sig] = True
|
||||
self._progress_since_failure.update(dict.fromkeys(self._exact_failure_counts, True))
|
||||
self._same_tool_failure_counts.clear()
|
||||
|
||||
if not self._is_idempotent(tool_name):
|
||||
self._no_progress.pop(signature, None)
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
@@ -455,51 +382,29 @@ class ToolCallGuardrailController:
|
||||
previous = self._no_progress.get(signature)
|
||||
repeat_count = previous[1] + 1 if previous is not None and previous[0] == result_hash else 1
|
||||
self._no_progress[signature] = (result_hash, repeat_count)
|
||||
|
||||
if self.config.warnings_enabled and repeat_count >= self.config.no_progress_warn_after:
|
||||
return warn(
|
||||
"idempotent_no_progress_warning",
|
||||
f"{tool_name} returned the same result {repeat_count} times. "
|
||||
"Use the result already provided or change the query instead of "
|
||||
"repeating it unchanged.",
|
||||
repeat_count,
|
||||
)
|
||||
|
||||
if warnings and repeat_count >= self.config.no_progress_warn_after:
|
||||
return self._decide("warn", "idempotent_no_progress_warning", tool_name, repeat_count, signature)
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=repeat_count, signature=signature)
|
||||
|
||||
def _is_idempotent(self, tool_name: str) -> bool:
|
||||
return tool_name not in self.config.mutating_tools and tool_name in self.config.idempotent_tools
|
||||
|
||||
def observe_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
*,
|
||||
tool_call_id: str = "",
|
||||
failed: bool = False,
|
||||
) -> "IdenticalCallObservation":
|
||||
self, tool_name: str, args: Mapping[str, Any] | None, result: str | None,
|
||||
*, tool_call_id: str = "", failed: bool = False,
|
||||
) -> IdenticalCallObservation:
|
||||
"""Track consecutive identical calls; return notice + dedupe stub info.
|
||||
|
||||
``notice`` fires from the ``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th
|
||||
consecutive identical (tool, args, result) call; purely observational,
|
||||
pollers exempt. ``stub`` replaces the CURRENT result from the 2nd
|
||||
byte-identical repeat — the tool still executed, only the context
|
||||
representation is deduplicated, so polling semantics survive (a changed
|
||||
result flows through whole and resets the streak). Pollers are NOT
|
||||
exempt from stubbing: an unchanged poll is exactly when the stub saves
|
||||
the most. Short results, failed results and non-string results are never
|
||||
stubbed. Callers substitute at result construction time, which is cache-safe.
|
||||
``notice`` fires from the threshold-th consecutive identical (tool, args, result) call
|
||||
(observational, pollers exempt). ``stub`` replaces the CURRENT result from the 2nd byte-identical
|
||||
repeat — the tool still executed, only the context representation is deduplicated, so polling
|
||||
semantics survive; pollers are NOT exempt here since an unchanged poll is where it saves most.
|
||||
"""
|
||||
is_plain_str = isinstance(result, str)
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
result_hash = _result_hash(result) if is_plain_str else ""
|
||||
|
||||
if (
|
||||
is_plain_str
|
||||
and self._identical_streak_sig == signature
|
||||
and self._identical_streak_result_hash == result_hash
|
||||
):
|
||||
if is_plain_str and (signature, result_hash) == (self._identical_streak_sig, self._identical_streak_result_hash):
|
||||
self._identical_streak_count += 1
|
||||
else:
|
||||
# New streak; non-string (multimodal) results never form one.
|
||||
@@ -507,39 +412,20 @@ class ToolCallGuardrailController:
|
||||
self._identical_streak_result_hash = result_hash
|
||||
self._identical_streak_count = 1 if is_plain_str else 0
|
||||
self._identical_streak_first_call_id = tool_call_id or ""
|
||||
|
||||
count = self._identical_streak_count
|
||||
|
||||
notice = None
|
||||
if not is_stall_guard_repeatable(tool_name) and count >= STALL_GUARD_IDENTICAL_CALL_THRESHOLD:
|
||||
ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}"
|
||||
notice = (
|
||||
f"[hermes note: this is the {ordinal} consecutive identical call to "
|
||||
f"{tool_name} with identical arguments returning the same result. "
|
||||
"Do not repeat it — change arguments, use a different tool, or "
|
||||
"proceed with what you have.]"
|
||||
)
|
||||
# The no-progress BLOCK in before_call only covers idempotent_tools; this
|
||||
# streak is tool-agnostic, so with hard stops on, halt at the same threshold
|
||||
# (a model replaying a successful `terminal` call otherwise runs to the budget).
|
||||
if (
|
||||
self.config.hard_stop_enabled
|
||||
and count >= self.config.no_progress_block_after
|
||||
and self._halt_decision is None
|
||||
):
|
||||
self._halt(
|
||||
"halt", "identical_call_streak_halt",
|
||||
f"Stopped {tool_name}: the same call with identical arguments "
|
||||
f"returned the same result {count} times in a row. Stop "
|
||||
"repeating it unchanged; use the result already provided or "
|
||||
"change strategy.",
|
||||
tool_name, count, signature,
|
||||
)
|
||||
notice = _IDENTICAL_CALL_NOTICE.format(ordinal=_ordinal(count), tool_name=tool_name)
|
||||
# The no-progress BLOCK in before_call only covers idempotent_tools; this streak
|
||||
# is tool-agnostic, so with hard stops on, halt at the same threshold (a model
|
||||
# replaying a successful `terminal` call otherwise runs to the budget).
|
||||
if self.config.hard_stop_enabled and count >= self.config.no_progress_block_after and self._halt_decision is None:
|
||||
self._decide("halt", "identical_call_streak_halt", tool_name, count, signature)
|
||||
|
||||
stub = None
|
||||
if is_plain_str and count >= 2 and not failed and len(result) >= IDENTICAL_RESULT_STUB_MIN_CHARS:
|
||||
stub = self._build_result_reference_stub(tool_name, args)
|
||||
|
||||
return IdenticalCallObservation(notice=notice, stub=stub)
|
||||
|
||||
def record_persisted_result(self, tool_call_id: str, file_path: str) -> None:
|
||||
@@ -549,10 +435,7 @@ class ToolCallGuardrailController:
|
||||
|
||||
def _build_result_reference_stub(self, tool_name: str, args: Mapping[str, Any] | None) -> str:
|
||||
"""Reference stub for a byte-identical duplicate result (tool + args preview)."""
|
||||
try:
|
||||
args_preview = canonical_tool_args(_coerce_args(args))
|
||||
except TypeError:
|
||||
args_preview = "{}"
|
||||
args_preview = canonical_tool_args(_coerce_args(args))
|
||||
if len(args_preview) > _RESULT_STUB_ARGS_PREVIEW_CHARS:
|
||||
args_preview = args_preview[:_RESULT_STUB_ARGS_PREVIEW_CHARS] + "…"
|
||||
first_id = self._identical_streak_first_call_id
|
||||
@@ -564,62 +447,29 @@ class ToolCallGuardrailController:
|
||||
)
|
||||
spill_path = self._persisted_result_paths.get(first_id) if first_id else None
|
||||
if spill_path:
|
||||
stub += (
|
||||
f"\n[The referenced result was persisted to: {spill_path} — "
|
||||
"page through it with read_file if you need the full content.]"
|
||||
)
|
||||
stub += f"\n[The referenced result was persisted to: {spill_path} — page through it with read_file if you need the full content.]"
|
||||
return stub
|
||||
|
||||
def _check_loop_cap(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any],
|
||||
signature: ToolCallSignature,
|
||||
self, tool_name: str, args: Mapping[str, Any], signature: ToolCallSignature,
|
||||
) -> ToolGuardrailDecision | None:
|
||||
"""Block once a per-turn cap is reached, else advance the counter and return None.
|
||||
|
||||
A cap of 0 disables that limit. Blocking happens BEFORE the call when
|
||||
the count is already at the cap, so the (cap+1)-th call is refused.
|
||||
"""
|
||||
caps = self.config.loop_caps
|
||||
if tool_name == "web_search":
|
||||
cap, count, increment = caps.max_web_searches, self._turn_web_search_count, 1
|
||||
code, message = "loop_web_search_cap", (
|
||||
f"Blocked web_search: this turn has already made {cap} "
|
||||
"web searches, the per-turn limit. This looks like a "
|
||||
"runaway search loop. Work with the results you already "
|
||||
"have and give the user your answer."
|
||||
)
|
||||
elif tool_name == "delegate_task":
|
||||
cap, count = caps.max_subagents, self._turn_subagent_count
|
||||
# Control actions (list/steer/stop) spawn nothing and must keep working after the cap is hit.
|
||||
increment = _subagent_spawn_count(args) if cap else 0
|
||||
if increment == 0:
|
||||
return None
|
||||
code, message = "loop_subagent_cap", (
|
||||
f"Blocked delegate_task: this turn has already spawned "
|
||||
f"{count} subagents (limit {cap}). "
|
||||
"This looks like a runaway delegation loop. Finish the "
|
||||
"work with the results you have and answer the user."
|
||||
)
|
||||
else:
|
||||
"""Block once a per-turn cap is reached (BEFORE the call, so the (cap+1)-th is refused), else advance
|
||||
the counter and return None. delegate_task control actions spawn nothing and keep working after the cap."""
|
||||
spec = _LOOP_CAPS.get(tool_name)
|
||||
if spec is None:
|
||||
return None
|
||||
|
||||
if cap and count >= cap:
|
||||
return self._halt("block", code, message, tool_name, count, signature)
|
||||
if tool_name == "web_search":
|
||||
self._turn_web_search_count += increment
|
||||
else:
|
||||
self._turn_subagent_count += increment
|
||||
cap_field, count_attr, code = spec
|
||||
cap, count = getattr(self.config.loop_caps, cap_field), getattr(self, count_attr)
|
||||
increment = 1 if tool_name == "web_search" else (_subagent_spawn_count(args) if cap else 0)
|
||||
if increment and cap and count >= cap:
|
||||
return self._decide("block", code, tool_name, count, signature, cap=cap)
|
||||
setattr(self, count_attr, count + increment)
|
||||
return None
|
||||
|
||||
|
||||
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
|
||||
"""Build a synthetic role=tool content string for a blocked tool call."""
|
||||
return json.dumps(
|
||||
{"error": decision.message, "guardrail": decision.to_metadata()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps({"error": decision.message, "guardrail": decision.to_metadata()}, ensure_ascii=False)
|
||||
|
||||
|
||||
def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> str:
|
||||
@@ -650,42 +500,32 @@ def _tool_failure_recovery_hint(tool_name: str, count: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _ordinal(count: int) -> str:
|
||||
return f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}"
|
||||
|
||||
|
||||
def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
return args if isinstance(args, Mapping) else {}
|
||||
|
||||
|
||||
def _result_hash(result: str | None) -> str:
|
||||
parsed = safe_json_loads(result or "")
|
||||
if parsed is not None:
|
||||
try:
|
||||
canonical = _canonical_json(parsed)
|
||||
except TypeError:
|
||||
canonical = str(parsed)
|
||||
else:
|
||||
canonical = result or ""
|
||||
return _sha256(canonical)
|
||||
return _sha256(_canonical_json(parsed) if parsed is not None else (result or ""))
|
||||
|
||||
|
||||
_BOOL_WORDS = {w: True for w in ("1", "true", "yes", "on", "enabled")} | {w: False for w in ("0", "false", "no", "off", "disabled")}
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
if isinstance(value, (bool, int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return _BOOL_WORDS.get(value.strip().lower(), default)
|
||||
return default
|
||||
|
||||
|
||||
def _int_at_least(value: Any, default: int, minimum: int) -> int:
|
||||
"""Int parser: junk/None/below-minimum fall back to default (caps use minimum 0 so 0 = disabled)."""
|
||||
if value is None:
|
||||
return default
|
||||
"""junk/None/below-minimum fall back to default (caps use minimum 0 so 0 = disabled)."""
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
@@ -694,8 +534,7 @@ def _int_at_least(value: Any, default: int, minimum: int) -> int:
|
||||
|
||||
|
||||
def _subagent_spawn_count(args: Mapping[str, Any]) -> int:
|
||||
"""Subagents one delegate_task call spawns: batch size when ``tasks`` is a
|
||||
non-empty list, else 1; control actions (list/steer/stop) spawn 0."""
|
||||
"""Subagents one delegate_task call spawns: ``len(tasks)`` for a non-empty batch, else 1; control actions 0."""
|
||||
if str(args.get("action") or "").strip().lower() in ("list", "steer", "stop"):
|
||||
return 0
|
||||
tasks = args.get("tasks")
|
||||
|
||||
Reference in New Issue
Block a user