1823 lines
86 KiB
Python
1823 lines
86 KiB
Python
"""Background memory/skill review — fork the agent to evaluate the turn.
|
||
|
||
After every turn, ``AIAgent.run_conversation`` may call
|
||
:func:`spawn_background_review` to fire off a daemon thread that replays
|
||
the conversation snapshot in a forked :class:`AIAgent` and asks itself
|
||
"should any skill/memory be saved or updated?". Writes go straight to
|
||
the memory + skill stores. Main conversation and prompt cache are never
|
||
touched.
|
||
|
||
The fork inherits the parent's live runtime (provider, model, base_url,
|
||
credentials, cached system prompt) so it hits the same prefix cache and
|
||
uses the same auth. It runs with a tool whitelist limited to memory and
|
||
skill management tools; everything else is denied at runtime.
|
||
|
||
See the ``hermes-agent-dev`` skill (``references/self-improvement-loop.md``)
|
||
for invariants and PR review criteria.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
import threading
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from agent.thread_scoped_output import thread_scoped_silence
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS = 2.0
|
||
|
||
|
||
class _BackgroundReviewRun:
|
||
"""Per-review cancellation and request-completion handshake."""
|
||
|
||
def __init__(self) -> None:
|
||
self.cancel_requested = threading.Event()
|
||
self.request_done = threading.Event()
|
||
self._lock = threading.Lock()
|
||
self._review_agent = None
|
||
self._request_finished = False
|
||
self._cancel_dispatched = False
|
||
|
||
def begin_request(self, review_agent: Any) -> bool:
|
||
"""Atomically admit the first provider-capable review phase."""
|
||
with self._lock:
|
||
if self.cancel_requested.is_set() or self._request_finished:
|
||
return False
|
||
self._review_agent = review_agent
|
||
return True
|
||
|
||
def cancel(self) -> Any:
|
||
"""Fence startup and return the running fork, if one was admitted."""
|
||
with self._lock:
|
||
self.cancel_requested.set()
|
||
if self._review_agent is not None and not self._cancel_dispatched:
|
||
self._cancel_dispatched = True
|
||
return self._review_agent
|
||
return None
|
||
|
||
def mark_request_finished(self) -> bool:
|
||
"""Latch request completion once; the caller publishes the event."""
|
||
with self._lock:
|
||
if self._request_finished:
|
||
return False
|
||
self._request_finished = True
|
||
self._review_agent = None
|
||
return True
|
||
|
||
|
||
def prepare_background_review_run(agent: Any) -> Optional[_BackgroundReviewRun]:
|
||
"""Install a unique run token on the parent before ``Thread.start()``."""
|
||
lock = getattr(agent, "_background_review_lock", None)
|
||
if lock is None:
|
||
try:
|
||
lock = threading.Lock()
|
||
agent._background_review_lock = lock
|
||
except (AttributeError, TypeError):
|
||
return None
|
||
|
||
run = _BackgroundReviewRun()
|
||
try:
|
||
with lock:
|
||
current = getattr(agent, "_background_review_run", None)
|
||
if current is not None and not current.request_done.is_set():
|
||
return None
|
||
agent._background_review_run = run
|
||
except (AttributeError, TypeError):
|
||
return None
|
||
return run
|
||
|
||
|
||
def finish_background_review_run(
|
||
agent: Any,
|
||
run: Optional[_BackgroundReviewRun],
|
||
) -> None:
|
||
"""Publish one run's request exit without clearing a successor (ABA-safe)."""
|
||
if run is None or not run.mark_request_finished():
|
||
return
|
||
|
||
lock = getattr(agent, "_background_review_lock", None)
|
||
if lock is not None:
|
||
with lock:
|
||
if getattr(agent, "_background_review_run", None) is run:
|
||
agent._background_review_run = None
|
||
elif getattr(agent, "_background_review_run", None) is run:
|
||
agent._background_review_run = None
|
||
run.request_done.set()
|
||
|
||
|
||
def _interrupt_background_review(review_agent: Any) -> None:
|
||
"""Request abort off-thread so a broken abort hook cannot stall foreground.
|
||
|
||
The bounded wait on ``request_done`` in
|
||
:func:`cancel_background_review_for_live_turn` is only effective if
|
||
``interrupt()`` returns quickly. Off-loading to a daemon thread ensures
|
||
a slow or wedged abort path cannot block the foreground turn (#84423).
|
||
"""
|
||
|
||
def _interrupt() -> None:
|
||
try:
|
||
from agent.interrupt_compat import request_hard_interrupt
|
||
|
||
request_hard_interrupt(
|
||
review_agent,
|
||
"superseded by a new live turn",
|
||
tool_reason="background review superseded",
|
||
)
|
||
except Exception:
|
||
logger.debug(
|
||
"Failed to cancel in-flight background review for a new turn",
|
||
exc_info=True,
|
||
)
|
||
|
||
try:
|
||
threading.Thread(
|
||
target=_interrupt,
|
||
daemon=True,
|
||
name="bg-review-cancel",
|
||
).start()
|
||
except Exception:
|
||
logger.debug(
|
||
"Failed to start background-review cancellation thread",
|
||
exc_info=True,
|
||
)
|
||
|
||
|
||
def cancel_background_review_for_live_turn(agent: Any) -> None:
|
||
"""Cancel the current review and await its request-phase acknowledgement.
|
||
|
||
Foreground priority is preserved: if the review does not acknowledge within
|
||
the bounded deadline, a warning is logged and the live turn proceeds
|
||
anyway. The review is non-critical self-improvement work and must never
|
||
block a user-facing turn (#84423).
|
||
"""
|
||
lock = getattr(agent, "_background_review_lock", None)
|
||
if lock is not None:
|
||
with lock:
|
||
run = getattr(agent, "_background_review_run", None)
|
||
legacy_agent = getattr(agent, "_background_review_agent", None)
|
||
else:
|
||
run = getattr(agent, "_background_review_run", None)
|
||
legacy_agent = getattr(agent, "_background_review_agent", None)
|
||
|
||
if run is None:
|
||
if legacy_agent is None:
|
||
return
|
||
_interrupt_background_review(legacy_agent)
|
||
return
|
||
|
||
review_agent = run.cancel()
|
||
if review_agent is not None:
|
||
_interrupt_background_review(review_agent)
|
||
|
||
acknowledged = run.request_done.wait(
|
||
timeout=_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS
|
||
)
|
||
if not acknowledged:
|
||
logger.warning(
|
||
"Background review did not acknowledge cancellation within %.1fs; "
|
||
"proceeding with foreground live turn",
|
||
_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Background-review aux-model selector + routed digest.
|
||
#
|
||
# The review fork runs on the MAIN model by default ("auto"), replaying the
|
||
# full conversation — already warm in the prompt cache, so cheap cache reads.
|
||
# Optimal and unchanged. A user can route the review to a different, cheaper
|
||
# model via auxiliary.background_review.{provider,model}. A different model
|
||
# cannot reuse the parent's cache (different key), so the fork is cold
|
||
# regardless — replaying the full transcript would just cold-write it. So when
|
||
# (and only when) routed to a different model, we replay a compact DIGEST to
|
||
# minimise cold-written tokens. Same model -> full replay; different model ->
|
||
# digest. That's the whole policy.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Historical hardcoded iteration budget for the review fork.
|
||
_REVIEW_MAX_ITERATIONS = 16
|
||
|
||
# Default aggregate INPUT-token budget for one review fork (#93057). The
|
||
# fork's first request replays the full snapshot — a warm prompt-cache read
|
||
# that is cheap and intended (cache parity), which is why both compression
|
||
# gates are deferred until the first provider response arrives
|
||
# (_review_fork_first_request_pending in agent/turn_context.py). After that,
|
||
# detached in-memory compaction bounds each request to roughly the
|
||
# compression threshold, but nothing capped the SUM across the review's tool
|
||
# loop: one production review made 8 requests replaying 1,487,951 input
|
||
# tokens total (four of them at 350k-384k). This budget caps the aggregate;
|
||
# the review tool loop stops before the provider call that would cross it
|
||
# (see ``_review_input_budget_exhausted`` in agent/conversation_loop.py).
|
||
# 2x the historical 300k foreground trigger keeps legitimate reviews
|
||
# comfortable while capping the pathological case. Override with
|
||
# ``auxiliary.background_review.max_input_tokens``; 0 or a negative value
|
||
# disables the cap (unbounded = pre-fix behavior).
|
||
_REVIEW_MAX_INPUT_TOKENS_DEFAULT = 600_000
|
||
|
||
|
||
def _background_review_task_config(
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Return ``auxiliary.background_review`` (or ``{}`` on any failure).
|
||
|
||
Pass ``task_cfg`` when the caller already loaded the block once so spawn /
|
||
resolve / prompt paths do not re-read config on every turn.
|
||
"""
|
||
if task_cfg is not None:
|
||
return task_cfg if isinstance(task_cfg, dict) else {}
|
||
try:
|
||
from hermes_cli.config import load_config_readonly
|
||
|
||
cfg = load_config_readonly()
|
||
except Exception:
|
||
return {}
|
||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||
task = aux.get("background_review", {})
|
||
return task if isinstance(task, dict) else {}
|
||
|
||
|
||
def _review_input_token_budget(
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
) -> Optional[int]:
|
||
"""Aggregate input-token budget for one review fork (None = unlimited).
|
||
|
||
Reads ``auxiliary.background_review.max_input_tokens``; falls back to
|
||
:data:`_REVIEW_MAX_INPUT_TOKENS_DEFAULT`. ``0`` or a negative value
|
||
disables the cap explicitly.
|
||
"""
|
||
task = _background_review_task_config(task_cfg)
|
||
raw = task.get("max_input_tokens", _REVIEW_MAX_INPUT_TOKENS_DEFAULT)
|
||
try:
|
||
budget = int(raw)
|
||
except (TypeError, ValueError):
|
||
budget = _REVIEW_MAX_INPUT_TOKENS_DEFAULT
|
||
if budget <= 0:
|
||
return None
|
||
return budget
|
||
|
||
|
||
def load_background_review_settings() -> tuple[bool, Dict[str, Any]]:
|
||
"""Single config read for the automatic-review gate + task block.
|
||
|
||
Returns ``(enabled, task_cfg)``. Fail-open on config errors (``enabled=True``)
|
||
so a broken config file does not silently disable reviews — but log at
|
||
WARNING so the cost-incurring path is visible.
|
||
"""
|
||
try:
|
||
from hermes_cli.config import load_config_readonly
|
||
from utils import is_truthy_value
|
||
|
||
cfg = load_config_readonly()
|
||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||
task = aux.get("background_review", {})
|
||
task = task if isinstance(task, dict) else {}
|
||
return is_truthy_value(task.get("enabled"), default=True), task
|
||
except Exception:
|
||
logger.warning(
|
||
"Failed to read background_review.enabled; leaving automatic "
|
||
"review enabled (fail-open)",
|
||
exc_info=True,
|
||
)
|
||
return True, {}
|
||
|
||
|
||
def is_background_review_enabled(
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
) -> bool:
|
||
"""Return whether automatic post-turn background review may spawn.
|
||
|
||
Controlled by ``auxiliary.background_review.enabled`` (default ``true``).
|
||
Explicit ``/refine`` (``focus`` set) bypasses this gate — same contract as
|
||
zeroing the nudge intervals, which stops automatic forks but leaves manual
|
||
refine working (issue #87250).
|
||
|
||
Prefer :func:`load_background_review_settings` at the spawn call site so
|
||
the task block is not re-read on the same turn.
|
||
"""
|
||
if task_cfg is not None:
|
||
try:
|
||
from utils import is_truthy_value
|
||
|
||
return is_truthy_value(task_cfg.get("enabled"), default=True)
|
||
except Exception:
|
||
logger.warning(
|
||
"Failed to interpret background_review.enabled; leaving "
|
||
"automatic review enabled (fail-open)",
|
||
exc_info=True,
|
||
)
|
||
return True
|
||
enabled, _ = load_background_review_settings()
|
||
return enabled
|
||
|
||
|
||
|
||
def _resolve_review_runtime(
|
||
agent: Any,
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Resolve provider/model/credentials for the review fork.
|
||
|
||
Default (auto / unset / same as parent): inherit the parent's live runtime
|
||
(with codex_app_server -> codex_responses downgrade). ``routed`` is False —
|
||
the fork uses the main model and the warm cache, exactly as before. When
|
||
``auxiliary.background_review.{provider,model}`` names a concrete model
|
||
different from the parent's, resolve that runtime and set ``routed=True``.
|
||
"""
|
||
parent_runtime = agent._current_main_runtime()
|
||
parent_api_mode = parent_runtime.get("api_mode") or None
|
||
if parent_api_mode == "codex_app_server":
|
||
parent_api_mode = "codex_responses"
|
||
parent = {
|
||
"provider": agent.provider,
|
||
"model": agent.model,
|
||
"api_key": parent_runtime.get("api_key") or None,
|
||
"base_url": parent_runtime.get("base_url") or None,
|
||
"api_mode": parent_api_mode,
|
||
"credential_pool": getattr(agent, "_credential_pool", None),
|
||
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
|
||
"max_tokens": getattr(agent, "max_tokens", None),
|
||
"command": getattr(agent, "acp_command", None),
|
||
"args": list(getattr(agent, "acp_args", []) or []),
|
||
"routed": False,
|
||
}
|
||
task = _background_review_task_config(task_cfg)
|
||
task_provider = (str(task.get("provider", "")).strip() or None)
|
||
task_model = (str(task.get("model", "")).strip() or None)
|
||
task_base_url = (str(task.get("base_url", "")).strip() or None)
|
||
task_api_key = (str(task.get("api_key", "")).strip() or None)
|
||
if not (task_provider and task_provider != "auto" and task_model):
|
||
return parent
|
||
if task_provider == (agent.provider or "") and task_model == (agent.model or ""):
|
||
return parent # same model/provider as parent -> not routed
|
||
try:
|
||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||
rp = resolve_runtime_provider(
|
||
requested=task_provider,
|
||
target_model=task_model,
|
||
explicit_api_key=task_api_key,
|
||
explicit_base_url=task_base_url,
|
||
)
|
||
return {
|
||
"provider": rp.get("provider") or task_provider,
|
||
"model": rp.get("model") or task_model,
|
||
"api_key": rp.get("api_key"),
|
||
"base_url": rp.get("base_url"),
|
||
"api_mode": rp.get("api_mode"),
|
||
"credential_pool": rp.get("credential_pool"),
|
||
"request_overrides": dict(rp.get("request_overrides") or {}),
|
||
"max_tokens": rp.get("max_output_tokens"),
|
||
"command": rp.get("command"),
|
||
"args": list(rp.get("args") or []),
|
||
"routed": True,
|
||
}
|
||
except Exception as e:
|
||
logger.debug("background-review aux routing failed (%s); using main model", e)
|
||
return parent
|
||
|
||
|
||
def _parent_can_emit_tool_calls(agent: Any) -> bool:
|
||
"""Whether a fork inheriting ``agent``'s runtime could act at all.
|
||
|
||
The review fork's entire job is to emit ``memory`` / ``skill_manage`` tool
|
||
calls. A provider that IS an autonomous agent reaches Hermes through a client
|
||
shim, and a shim that cannot carry Hermes tool calls back turns the fork into
|
||
a guaranteed no-op — one that still pays for a full agent spawn (a whole CLI
|
||
process, sometimes a JVM) on every review cadence. The in-tree ACP client CAN
|
||
carry them (it uses the text bridge in ``agent/acp_openai_bridge.py``); this
|
||
exists so a shim that can't declares ``SUPPORTS_HERMES_TOOL_CALLS = False``
|
||
and is skipped instead of burning a spawn. Anything that doesn't say
|
||
otherwise is assumed capable, so ordinary providers are unaffected.
|
||
"""
|
||
client = getattr(agent, "client", None)
|
||
for candidate in (client, type(client) if client is not None else None):
|
||
if candidate is None:
|
||
continue
|
||
supported = getattr(candidate, "SUPPORTS_HERMES_TOOL_CALLS", None)
|
||
if supported is not None:
|
||
return bool(supported)
|
||
return True
|
||
|
||
|
||
def _msg_text(m: Dict) -> str:
|
||
c = m.get("content")
|
||
if isinstance(c, str):
|
||
return c.strip()
|
||
if isinstance(c, list):
|
||
return " ".join(b.get("text", "") for b in c if isinstance(b, dict)).strip()
|
||
return ""
|
||
|
||
|
||
def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]:
|
||
"""Compact replay for the routed (different-model) path only.
|
||
|
||
Keeps the recent ``tail`` messages verbatim, collapses older turns into one
|
||
synthetic user-role digest, preserving role alternation. Used ONLY when
|
||
routed to a different model (cache cold regardless, so fewer cold-written
|
||
tokens is a pure win). Never on the main-model path (full replay stays warm).
|
||
"""
|
||
msgs = list(messages_snapshot or [])
|
||
if len(msgs) <= tail:
|
||
return msgs
|
||
keep = msgs[-tail:]
|
||
while keep and isinstance(keep[0], dict) and keep[0].get("role") == "tool":
|
||
tail += 1
|
||
if len(msgs) <= tail:
|
||
return msgs
|
||
keep = msgs[-tail:]
|
||
old = msgs[:-len(keep)]
|
||
lines: List[str] = []
|
||
for m in old:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
role = m.get("role")
|
||
text = _msg_text(m).replace("\n", " ")
|
||
if role == "user" and text:
|
||
lines.append(f"USER: {text[:300]}")
|
||
elif role == "assistant":
|
||
tcs = m.get("tool_calls") or []
|
||
if tcs:
|
||
names = [(tc.get("function") or {}).get("name", "?") for tc in tcs if isinstance(tc, dict)]
|
||
lines.append(f"ASSISTANT[tools: {', '.join(names)}]")
|
||
if text:
|
||
lines.append(f"ASSISTANT: {text[:200]}")
|
||
digest = {
|
||
"role": "user",
|
||
"content": (
|
||
"[Earlier conversation digest — older turns summarised to bound the "
|
||
"review's cold-write cost on the routed aux model. Recent turns "
|
||
"follow verbatim below.]\n" + "\n".join(lines)
|
||
),
|
||
}
|
||
return [digest] + keep
|
||
|
||
|
||
# Review-prompt strings — used by ``spawn_background_review_thread`` to build
|
||
# the user-message that the forked review agent receives. AIAgent exposes
|
||
# them as class attributes (``_MEMORY_REVIEW_PROMPT`` etc.) for back-compat;
|
||
# the actual text lives here so future edits are one-place.
|
||
_MEMORY_REVIEW_PROMPT = (
|
||
"Review the conversation above and consider saving to memory if appropriate.\n\n"
|
||
"Focus on:\n"
|
||
"1. Has the user revealed things about themselves — their persona, desires, "
|
||
"preferences, or personal details worth remembering?\n"
|
||
"2. Has the user expressed expectations about how you should behave, their work "
|
||
"style, or ways they want you to operate?\n\n"
|
||
"If something stands out, save it using the memory tool. "
|
||
"If nothing is worth saving, just say 'Nothing to save.' and stop."
|
||
)
|
||
|
||
_SKILL_REVIEW_PROMPT = (
|
||
"Review the conversation above and update the skill library. Be "
|
||
"ACTIVE — most sessions produce at least one skill update, even if "
|
||
"small. A pass that does nothing is a missed learning opportunity, "
|
||
"not a neutral outcome.\n\n"
|
||
"Target shape of the library: CLASS-LEVEL skills, each with a rich "
|
||
"SKILL.md and a `references/` directory for session-specific detail. "
|
||
"Not a long flat list of narrow one-session-one-skill entries. This "
|
||
"shapes HOW you update, not WHETHER you update.\n\n"
|
||
"Signals to look for (any one of these warrants action):\n"
|
||
" • User corrected your style, tone, format, legibility, or "
|
||
"verbosity. Frustration signals like 'stop doing X', 'this is too "
|
||
"verbose', 'don't format like this', 'why are you explaining', "
|
||
"'just give me the answer', 'you always do Y and I hate it', or an "
|
||
"explicit 'remember this' are FIRST-CLASS skill signals, not just "
|
||
"memory signals. Update the relevant skill(s) to embed the "
|
||
"preference so the next session starts already knowing.\n"
|
||
" • User corrected your workflow, approach, or sequence of steps. "
|
||
"Encode the correction as a pitfall or explicit step in the skill "
|
||
"that governs that class of task.\n"
|
||
" • Non-trivial technique, fix, workaround, debugging path, or "
|
||
"tool-usage pattern emerged that a future session would benefit "
|
||
"from. Capture it.\n"
|
||
" • A skill that got loaded or consulted this session turned out "
|
||
"to be wrong, missing a step, or outdated. Patch it NOW.\n\n"
|
||
"Preference order — prefer the earliest action that fits, but do "
|
||
"pick one when a signal above fired:\n"
|
||
" 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the "
|
||
"conversation for skills the user loaded via /skill-name or you "
|
||
"read via skill_view. If any of them covers the territory of the "
|
||
"new learning, PATCH that one first (re-load it with skill_view "
|
||
"during this review — see Read-before-write below). It is the "
|
||
"skill that was in play, so it's the right one to extend — but "
|
||
"only if it is "
|
||
"curator-managed. Bundled, hub, pinned, and user-owned skills are "
|
||
"off-limits to you no matter how relevant (see Protected skills "
|
||
"below); for those, fall through to the next option.\n"
|
||
" 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). "
|
||
"If no loaded skill fits but an existing class-level skill does, "
|
||
"patch it. Add a subsection, a pitfall, or broaden a trigger.\n"
|
||
" 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be "
|
||
"packaged with three kinds of support files — use the right "
|
||
"directory per kind:\n"
|
||
" • `references/<topic>.md` — session-specific detail (error "
|
||
"transcripts, reproduction recipes, provider quirks) AND "
|
||
"condensed knowledge banks: quoted research, API docs, external "
|
||
"authoritative excerpts, or domain notes you found while working "
|
||
"on the problem. Write it concise and for the value of the task, "
|
||
"not as a full mirror of upstream docs.\n"
|
||
" • `templates/<name>.<ext>` — starter files meant to be "
|
||
"copied and modified (boilerplate configs, scaffolding, a "
|
||
"known-good example the agent can `reproduce with modifications`).\n"
|
||
" • `scripts/<name>.<ext>` — statically re-runnable actions "
|
||
"the skill can invoke directly (verification scripts, fixture "
|
||
"generators, deterministic probes, anything the agent should run "
|
||
"rather than hand-type each time).\n"
|
||
" Add support files via skill_manage action=write_file with "
|
||
"file_path starting 'references/', 'templates/', or 'scripts/'. "
|
||
"The umbrella's SKILL.md should gain a one-line pointer to any "
|
||
"new support file so future agents know it exists.\n"
|
||
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing "
|
||
"skill covers the class. The name MUST be at the class level. "
|
||
"The name MUST NOT be a specific PR number, error string, feature "
|
||
"codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' "
|
||
"session artifact. If the proposed name only makes sense for "
|
||
"today's task, it's wrong — fall back to (1), (2), or (3).\n\n"
|
||
"Read-before-write (ENFORCED — skill_manage refuses otherwise): "
|
||
"before you patch or edit an existing skill's SKILL.md, call "
|
||
"skill_view(name) for that skill during this review. Before you "
|
||
"overwrite or remove an EXISTING supporting file, call "
|
||
"skill_view(name, file_path=...) for that exact file. Content "
|
||
"quoted earlier in the conversation transcript does NOT count — "
|
||
"the guard requires a fresh load within this review, and your "
|
||
"write must be based on what skill_view just returned. Creating "
|
||
"a brand-new skill or adding a NEW supporting file needs no "
|
||
"prior read. If a write is refused with a read-before-write "
|
||
"error, call skill_view for the named target once and retry the "
|
||
"write once; do not loop.\n\n"
|
||
"User-preference embedding (important): when the user expressed a "
|
||
"style/format/workflow preference, the update belongs in the "
|
||
"SKILL.md body, not just in memory. Memory captures 'who the user "
|
||
"is and what the current situation and state of your operations "
|
||
"are'; skills capture 'how to do this class of task for this "
|
||
"user'. When they complain about how you handled a task, the "
|
||
"skill that governs that task needs to carry the lesson.\n\n"
|
||
"If you notice two existing skills that overlap, note it in your "
|
||
"reply — the background curator handles consolidation at scale.\n\n"
|
||
"Protected skills (DO NOT edit these):\n"
|
||
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
|
||
" • Hub-installed skills (installed via 'hermes skills install').\n"
|
||
" • Skills in skills.external_dirs (externally owned).\n"
|
||
" • PINNED skills (marked via 'hermes curator pin'). You are an "
|
||
"autonomous no-user-present actor, so pin blocks your writes too — "
|
||
"content updates included. Only the user, in a foreground session, "
|
||
"can change a pinned skill.\n"
|
||
" • USER-OWNED skills — anything not curator-managed. A skill the "
|
||
"user hand-wrote, installed by URL, or asked a foreground agent to "
|
||
"create is theirs, not yours; your writes to it WILL be refused. "
|
||
"This includes skills that were loaded or consulted this session: "
|
||
"being in play does not make one yours to edit. If such a skill is "
|
||
"wrong or outdated, say so in your reply and recommend "
|
||
"'hermes curator adopt <name>' — do not try to patch it.\n"
|
||
"If the only skills that need updating are protected, say\n"
|
||
"'Nothing to save.' and stop.\n\n"
|
||
"Do NOT capture (these become persistent self-imposed constraints "
|
||
"that bite you later when the environment changes):\n"
|
||
" • Environment-dependent failures: missing binaries, fresh-install "
|
||
"errors, post-migration path mismatches, 'command not found', "
|
||
"unconfigured credentials, uninstalled packages. The user can fix "
|
||
"these — they are not durable rules.\n"
|
||
" • Negative claims about tools or features ('browser tools do not "
|
||
"work', 'X tool is broken', 'cannot use Y from execute_code'). These "
|
||
"harden into refusals the agent cites against itself for months "
|
||
"after the actual problem was fixed.\n"
|
||
" • Session-specific transient errors that resolved before the "
|
||
"conversation ended. If retrying worked, the lesson is the retry "
|
||
"pattern, not the original failure.\n"
|
||
" • One-off task narratives. A user asking 'summarize today's "
|
||
"market' or 'analyze this PR' is not a class of work that warrants "
|
||
"a skill.\n\n"
|
||
" • Unresolved failures: if the session ended WITHOUT actually "
|
||
"finding a working method — you tried several things, none worked, "
|
||
"and told the user to check manually — do NOT write those attempts "
|
||
"up as a 'reliable workflow' or 'recommended approach'. That presents "
|
||
"an untested sequence of failures as validated guidance a future "
|
||
"session will trust and repeat. Either say 'Nothing to save', or, "
|
||
"only if you are independently confident of a real working alternative "
|
||
"(not something you are merely guessing might work), capture ONLY that "
|
||
"alternative — never the dead ends, and never dressed up as best practice.\n\n"
|
||
"If a tool failed because of setup state, capture the FIX (install "
|
||
"command, config step, env var to set) under an existing setup or "
|
||
"troubleshooting skill — never 'this tool does not work' as a "
|
||
"standalone constraint.\n\n"
|
||
"'Nothing to save.' is a real option but should NOT be the "
|
||
"default. If the session ran smoothly with no corrections and "
|
||
"produced no new technique, just say 'Nothing to save.' and stop. "
|
||
"Otherwise, act."
|
||
)
|
||
|
||
_COMBINED_REVIEW_PROMPT = (
|
||
"Review the conversation above and update two things:\n\n"
|
||
"**Memory**: who the user is. Did the user reveal persona, "
|
||
"desires, preferences, personal details, or expectations about "
|
||
"how you should behave? Save facts about the user and durable "
|
||
"preferences with the memory tool.\n\n"
|
||
"**Skills**: how to do this class of task. Be ACTIVE — most "
|
||
"sessions produce at least one skill update. A pass that does "
|
||
"nothing is a missed learning opportunity, not a neutral outcome.\n\n"
|
||
"Target shape of the skill library: CLASS-LEVEL skills with a rich "
|
||
"SKILL.md and a `references/` directory for session-specific detail. "
|
||
"Not a long flat list of narrow one-session-one-skill entries.\n\n"
|
||
"Signals that warrant a skill update (any one is enough):\n"
|
||
" • User corrected your style, tone, format, legibility, "
|
||
"verbosity, or approach. Frustration is a FIRST-CLASS skill "
|
||
"signal, not just a memory signal. 'stop doing X', 'don't format "
|
||
"like this', 'I hate when you Y' — embed the lesson in the skill "
|
||
"that governs that task so the next session starts fixed.\n"
|
||
" • Non-trivial technique, fix, workaround, or debugging path "
|
||
"emerged.\n"
|
||
" • A skill that was loaded or consulted turned out wrong, "
|
||
"missing, or outdated — patch it now.\n\n"
|
||
"Preference order for skills — pick the earliest that fits:\n"
|
||
" 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were "
|
||
"loaded via /skill-name or skill_view in the conversation. If one "
|
||
"of them covers the learning, PATCH it first (re-load it with "
|
||
"skill_view during this review — see Read-before-write below). "
|
||
"It was in play; "
|
||
"it's the right place — provided it is curator-managed. Protected "
|
||
"and user-owned skills are off-limits however relevant; fall "
|
||
"through when one of those is the best fit.\n"
|
||
" 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to "
|
||
"find the right one). Patch it.\n"
|
||
" 3. ADD A SUPPORT FILE under an existing umbrella via "
|
||
"skill_manage action=write_file. Three kinds: "
|
||
"`references/<topic>.md` for session-specific detail OR condensed "
|
||
"knowledge banks (quoted research, API docs excerpts, domain "
|
||
"notes) written concise and task-focused; `templates/<name>.<ext>` "
|
||
"for starter files meant to be copied and modified; "
|
||
"`scripts/<name>.<ext>` for statically re-runnable actions "
|
||
"(verification, fixture generators, probes). Add a one-line "
|
||
"pointer in SKILL.md so future agents find them.\n"
|
||
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. "
|
||
"Name at the class level — NOT a PR number, error string, "
|
||
"codename, library-alone name, or 'fix-X / debug-Y' session "
|
||
"artifact. If the name only fits today's task, fall back to (1), "
|
||
"(2), or (3).\n\n"
|
||
"Read-before-write (ENFORCED — skill_manage refuses otherwise): "
|
||
"before patching or editing an existing skill's SKILL.md, call "
|
||
"skill_view(name) during this review; before overwriting or "
|
||
"removing an EXISTING supporting file, call skill_view(name, "
|
||
"file_path=...) for that exact file. Content quoted earlier in "
|
||
"the transcript does NOT count — base the write on what "
|
||
"skill_view just returned. New skills and NEW supporting files "
|
||
"need no prior read. On a read-before-write refusal: view the "
|
||
"named target once, retry the write once, do not loop.\n\n"
|
||
"User-preference embedding: when the user complains about how "
|
||
"you handled a task, update the skill that governs that task — "
|
||
"memory alone isn't enough. Memory says 'who the user is and "
|
||
"what the current situation and state of your operations are'; "
|
||
"skills say 'how to do this class of task for this user'. Both "
|
||
"should carry user-preference lessons when relevant.\n\n"
|
||
"If you notice overlapping existing skills, mention it — the "
|
||
"background curator handles consolidation.\n\n"
|
||
"Protected skills (DO NOT edit these):\n"
|
||
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
|
||
" • Hub-installed skills (installed via 'hermes skills install').\n"
|
||
" • Skills in skills.external_dirs (externally owned).\n"
|
||
" • PINNED skills (marked via 'hermes curator pin'). Pin blocks "
|
||
"autonomous writes entirely — content updates included — because no "
|
||
"user is present to consent. Only a foreground session can change one.\n"
|
||
" • USER-OWNED skills — anything not curator-managed (hand-written, "
|
||
"URL-installed, or created by a foreground agent at the user's "
|
||
"request). Your writes to these WILL be refused, including to skills "
|
||
"loaded or consulted this session. If one is wrong, say so in your "
|
||
"reply and recommend 'hermes curator adopt <name>' instead.\n"
|
||
"If the only skills that need updating are protected, say\n"
|
||
"'Nothing to save.' and stop.\n\n"
|
||
"Do NOT capture as skills (these become persistent self-imposed "
|
||
"constraints that bite you later when the environment changes):\n"
|
||
" • Environment-dependent failures: missing binaries, fresh-install "
|
||
"errors, post-migration path mismatches, 'command not found', "
|
||
"unconfigured credentials, uninstalled packages. The user can fix "
|
||
"these — they are not durable rules.\n"
|
||
" • Negative claims about tools or features ('browser tools do not "
|
||
"work', 'X tool is broken', 'cannot use Y from execute_code'). These "
|
||
"harden into refusals the agent cites against itself for months "
|
||
"after the actual problem was fixed.\n"
|
||
" • Session-specific transient errors that resolved before the "
|
||
"conversation ended. If retrying worked, the lesson is the retry "
|
||
"pattern, not the original failure.\n"
|
||
" • One-off task narratives. A user asking 'summarize today's "
|
||
"market' or 'analyze this PR' is not a class of work that warrants "
|
||
"a skill.\n\n"
|
||
" • Unresolved failures: if the session ended WITHOUT actually "
|
||
"finding a working method — you tried several things, none worked, "
|
||
"and told the user to check manually — do NOT write those attempts "
|
||
"up as a 'reliable workflow' or 'recommended approach'. That presents "
|
||
"an untested sequence of failures as validated guidance a future "
|
||
"session will trust and repeat. Either say 'Nothing to save', or, "
|
||
"only if you are independently confident of a real working alternative "
|
||
"(not something you are merely guessing might work), capture ONLY that "
|
||
"alternative — never the dead ends, and never dressed up as best practice.\n\n"
|
||
"If a tool failed because of setup state, capture the FIX (install "
|
||
"command, config step, env var to set) under an existing setup or "
|
||
"troubleshooting skill — never 'this tool does not work' as a "
|
||
"standalone constraint.\n\n"
|
||
"Act on whichever of the two dimensions has real signal. If "
|
||
"genuinely nothing stands out on either, say 'Nothing to save.' "
|
||
"and stop — but don't reach for that conclusion as a default."
|
||
)
|
||
|
||
|
||
|
||
def summarize_background_review_actions(
|
||
review_messages: List[Dict],
|
||
prior_snapshot: List[Dict],
|
||
notification_mode: str = "on",
|
||
) -> List[str]:
|
||
"""Build the human-facing action summary for a background review pass.
|
||
|
||
Walks the review agent's session messages and collects successful memory
|
||
and skill-management actions to surface to the user. Tool messages already
|
||
present in ``prior_snapshot`` are skipped so stale inherited results are
|
||
not re-surfaced as fresh background work (issue #14944).
|
||
|
||
``notification_mode`` controls display detail:
|
||
- ``off``: return no actions.
|
||
- ``on``: generic "Memory updated"/tool messages.
|
||
- ``verbose``: include compact content previews from tool-call arguments.
|
||
"""
|
||
mode = str(notification_mode or "on").lower()
|
||
if mode == "off":
|
||
return []
|
||
verbose = mode == "verbose"
|
||
|
||
existing_tool_call_ids = set()
|
||
existing_tool_contents = set()
|
||
for prior in prior_snapshot or []:
|
||
if not isinstance(prior, dict) or prior.get("role") != "tool":
|
||
continue
|
||
tcid = prior.get("tool_call_id")
|
||
if tcid:
|
||
existing_tool_call_ids.add(tcid)
|
||
else:
|
||
content = prior.get("content")
|
||
if isinstance(content, str):
|
||
existing_tool_contents.add(content)
|
||
|
||
# Map review-agent tool results back to the calls that produced them. The
|
||
# result JSON only says "Entry added"; the call arguments contain action,
|
||
# target, and content previews. Restricting to notify_tools also prevents
|
||
# helper tools from surfacing as memory work just because they succeeded.
|
||
notify_tools = {"memory", "skill_manage"}
|
||
all_tool_call_ids: set = set()
|
||
call_details: dict = {}
|
||
for msg in review_messages or []:
|
||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||
continue
|
||
for tc in msg.get("tool_calls", []) or []:
|
||
if not isinstance(tc, dict):
|
||
continue
|
||
fn = tc.get("function", {}) or {}
|
||
fn_name = fn.get("name", "")
|
||
tcid = tc.get("id")
|
||
if tcid:
|
||
all_tool_call_ids.add(tcid)
|
||
if fn_name not in notify_tools:
|
||
continue
|
||
try:
|
||
args = json.loads(fn.get("arguments", "{}"))
|
||
except (json.JSONDecodeError, TypeError):
|
||
args = {}
|
||
if tcid:
|
||
call_details[tcid] = {
|
||
"tool": fn_name,
|
||
"action": args.get("action", "?"),
|
||
"target": args.get("target", "memory"),
|
||
"content": args.get("content", ""),
|
||
"old_text": args.get("old_text", ""),
|
||
"operations": args.get("operations") or [],
|
||
"name": args.get("name", ""),
|
||
"old_string": args.get("old_string", ""),
|
||
"new_string": args.get("new_string", ""),
|
||
}
|
||
|
||
actions: List[str] = []
|
||
for msg in review_messages or []:
|
||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||
continue
|
||
tcid = msg.get("tool_call_id")
|
||
if tcid and tcid in existing_tool_call_ids:
|
||
continue
|
||
if not tcid:
|
||
content_str = msg.get("content")
|
||
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
||
continue
|
||
if tcid and all_tool_call_ids and tcid not in call_details:
|
||
continue
|
||
try:
|
||
data = json.loads(msg.get("content", "{}"))
|
||
except (json.JSONDecodeError, TypeError):
|
||
continue
|
||
# ``data`` may not be a dict — some memory/skill tool responses in
|
||
# older codepaths or wrapper MCP servers return a top-level JSON
|
||
# list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
|
||
# isinstance check below silently skips non-dict payloads, which
|
||
# is correct, but ``data.get("_change")`` further down can still
|
||
# hand back a list and break ``change.get("description", "")``.
|
||
# Defensively normalize everything through a dict-typed alias so
|
||
# the rest of the function can stay terse without per-call
|
||
# ``isinstance`` guards (#59437).
|
||
if not isinstance(data, dict) or not data.get("success"):
|
||
continue
|
||
message = data.get("message", "")
|
||
detail = call_details.get(tcid) or {}
|
||
if not isinstance(detail, dict):
|
||
detail = {}
|
||
target = data.get("target", "") or detail.get("target", "")
|
||
is_skill = detail.get("tool") == "skill_manage"
|
||
|
||
message_lower = message.lower()
|
||
if not verbose:
|
||
if "created" in message_lower:
|
||
actions.append(message)
|
||
continue
|
||
if "updated" in message_lower:
|
||
actions.append(message)
|
||
continue
|
||
if is_skill and "patched" in message_lower:
|
||
actions.append(message)
|
||
continue
|
||
|
||
if is_skill:
|
||
label = "Skill"
|
||
elif target:
|
||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||
else:
|
||
continue
|
||
|
||
if verbose:
|
||
action = detail.get("action", "")
|
||
content = detail.get("content", "")
|
||
old_text = detail.get("old_text", "")
|
||
skill_name = detail.get("name", "")
|
||
# ``operations`` may be anything callable put into the JSON
|
||
# arguments. Anything non-iterable that isn't a list[str]
|
||
# of dicts becomes unusable here, so coerce defensively.
|
||
ops_raw = detail.get("operations")
|
||
operations: list = (
|
||
ops_raw if isinstance(ops_raw, list) else []
|
||
)
|
||
max_preview = 120
|
||
if is_skill:
|
||
# ``_change`` is a free-form dict the skill tool leaves in
|
||
# the response. Older / wrapper MCP backends return it
|
||
# as a list, an int, or a JSON-shaped scalar — normalize
|
||
# to a dict so the .get() calls downstream don't
|
||
# AttributeError (#59437).
|
||
change_raw = data.get("_change")
|
||
change: dict = (
|
||
change_raw if isinstance(change_raw, dict) else {}
|
||
)
|
||
old_string = (
|
||
change.get("old", "") or detail.get("old_string", "")
|
||
)
|
||
new_string = (
|
||
change.get("new", "") or detail.get("new_string", "")
|
||
)
|
||
description = change.get("description", "")
|
||
if action == "patch" and (old_string or new_string):
|
||
old_preview = old_string[:80].replace("\n", " ") + (
|
||
"…" if len(old_string) > 80 else ""
|
||
)
|
||
new_preview = new_string[:80].replace("\n", " ") + (
|
||
"…" if len(new_string) > 80 else ""
|
||
)
|
||
actions.append(
|
||
f"📝 Skill '{skill_name}' patched: "
|
||
f"\"{old_preview}\" → \"{new_preview}\""
|
||
)
|
||
elif action == "create" and description:
|
||
actions.append(f"📝 Skill '{skill_name}' created: {description}")
|
||
elif action == "edit" and description:
|
||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||
else:
|
||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||
elif operations:
|
||
for op in operations:
|
||
# Each element must be a dict-of-fields; some
|
||
# legacy codepaths serialize the entry as a bare
|
||
# string and the message dict doesn't exist. Skip
|
||
# non-dict items defensively — they have no
|
||
# actionable fields anyway (#59437).
|
||
if not isinstance(op, dict):
|
||
continue
|
||
op_act = op.get("action", "")
|
||
op_content = (op.get("content") or "")
|
||
op_old = (op.get("old_text") or "")
|
||
if op_act == "add" and op_content:
|
||
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||
actions.append(f"{label} ➕ {preview}")
|
||
elif op_act == "replace" and op_content:
|
||
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||
actions.append(f"{label} ✏️ {preview}")
|
||
elif op_act == "remove" and op_old:
|
||
preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
|
||
actions.append(f"{label} ➖ {preview}")
|
||
elif action == "add" and content:
|
||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||
actions.append(f"{label} ➕ {preview}")
|
||
elif action == "replace" and content:
|
||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||
actions.append(f"{label} ✏️ {preview}")
|
||
elif action == "remove" and old_text:
|
||
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
|
||
actions.append(f"{label} ➖ {preview}")
|
||
else:
|
||
actions.append(f"{label} updated")
|
||
elif (
|
||
"added" in message_lower
|
||
or "replaced" in message_lower
|
||
or "removed" in message_lower
|
||
or "applied" in message_lower
|
||
or (target and "add" in message.lower())
|
||
or "Entry added" in message
|
||
):
|
||
actions.append(f"{label} updated")
|
||
return actions
|
||
|
||
|
||
def build_memory_write_metadata(
|
||
agent: Any,
|
||
*,
|
||
write_origin: Optional[str] = None,
|
||
execution_context: Optional[str] = None,
|
||
task_id: Optional[str] = None,
|
||
tool_call_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Build provenance metadata for external memory-provider mirrors."""
|
||
metadata: Dict[str, Any] = {
|
||
"write_origin": write_origin or getattr(agent, "_memory_write_origin", "assistant_tool"),
|
||
"execution_context": (
|
||
execution_context
|
||
or getattr(agent, "_memory_write_context", "foreground")
|
||
),
|
||
"session_id": agent.session_id or "",
|
||
"parent_session_id": agent._parent_session_id or "",
|
||
"platform": agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||
"tool_name": "memory",
|
||
}
|
||
if task_id:
|
||
metadata["task_id"] = task_id
|
||
if tool_call_id:
|
||
metadata["tool_call_id"] = tool_call_id
|
||
return {k: v for k, v in metadata.items() if v not in {None, ""}}
|
||
|
||
|
||
def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]:
|
||
"""Snapshot in-memory usage counters from a review fork (pre-close)."""
|
||
return {
|
||
"model": getattr(review_agent, "model", None),
|
||
"provider": getattr(review_agent, "provider", None),
|
||
"base_url": getattr(review_agent, "base_url", None),
|
||
"input_tokens": int(getattr(review_agent, "session_input_tokens", 0) or 0),
|
||
"output_tokens": int(getattr(review_agent, "session_output_tokens", 0) or 0),
|
||
"cache_read_tokens": int(
|
||
getattr(review_agent, "session_cache_read_tokens", 0) or 0
|
||
),
|
||
"cache_write_tokens": int(
|
||
getattr(review_agent, "session_cache_write_tokens", 0) or 0
|
||
),
|
||
"reasoning_tokens": int(
|
||
getattr(review_agent, "session_reasoning_tokens", 0) or 0
|
||
),
|
||
"api_calls": int(getattr(review_agent, "session_api_calls", 0) or 0),
|
||
"estimated_cost_usd": getattr(review_agent, "session_estimated_cost_usd", None),
|
||
}
|
||
|
||
|
||
def _record_review_usage_to_parent(
|
||
parent_agent: Any,
|
||
usage: Dict[str, Any],
|
||
) -> None:
|
||
"""Record a background-review fork's usage against the parent session.
|
||
|
||
Background-review forks run with ``_session_db = None`` for persistence
|
||
isolation (see the PERSISTENCE ISOLATION comment in
|
||
:func:`_run_review_in_thread`): the fork must never write its harness turn
|
||
into the user's real session. A side effect of that isolation is that the
|
||
fork's API calls — which the provider bills — were never recorded in
|
||
``session_model_usage``, because the accounting path in
|
||
``conversation_loop`` is gated on the DB handle. This hides the
|
||
background-review volume from billing analytics (issue #87250).
|
||
|
||
The fork still accumulates the same in-memory counters the main loop does
|
||
(``session_input_tokens`` etc.) and shares the parent's ``session_id``, so
|
||
its usage can be attributed to the parent session through the
|
||
aux-accounting chokepoint, which writes only ``session_model_usage`` —
|
||
never the transcript or the ``sessions`` summary row.
|
||
|
||
Best-effort by contract: accounting must never fail the review.
|
||
"""
|
||
try:
|
||
session_db = getattr(parent_agent, "_session_db", None)
|
||
session_id = getattr(parent_agent, "session_id", None)
|
||
if session_db is None or not session_id:
|
||
return
|
||
input_tokens = int(usage.get("input_tokens") or 0)
|
||
output_tokens = int(usage.get("output_tokens") or 0)
|
||
cache_read = int(usage.get("cache_read_tokens") or 0)
|
||
cache_write = int(usage.get("cache_write_tokens") or 0)
|
||
reasoning = int(usage.get("reasoning_tokens") or 0)
|
||
api_calls = int(usage.get("api_calls") or 0)
|
||
if not (
|
||
input_tokens
|
||
or output_tokens
|
||
or cache_read
|
||
or cache_write
|
||
or reasoning
|
||
or api_calls
|
||
):
|
||
return # fork made no successful API calls (e.g. failed at spawn)
|
||
session_db.record_auxiliary_usage(
|
||
session_id,
|
||
task="background_review",
|
||
model=usage.get("model"),
|
||
billing_provider=usage.get("provider"),
|
||
billing_base_url=usage.get("base_url"),
|
||
input_tokens=input_tokens,
|
||
output_tokens=output_tokens,
|
||
cache_read_tokens=cache_read,
|
||
cache_write_tokens=cache_write,
|
||
reasoning_tokens=reasoning,
|
||
estimated_cost_usd=usage.get("estimated_cost_usd"),
|
||
api_call_count=api_calls,
|
||
)
|
||
except Exception as e:
|
||
logger.debug(
|
||
"Background review usage recording failed (non-fatal): %s", e
|
||
)
|
||
|
||
|
||
def _classify_review_result(actions: List[str]) -> str:
|
||
"""Map a review action summary to ``none`` / ``skill`` / ``memory`` / both.
|
||
|
||
Matching is prefix-based on the formats
|
||
:func:`summarize_background_review_actions` emits
|
||
(``Skill …``, ``📝 Skill …``, ``Memory …``, ``User profile …``), not
|
||
free-text substring search — so a line like
|
||
``Skipped: no skill worth saving`` stays ``none``.
|
||
"""
|
||
if not actions:
|
||
return "none"
|
||
has_skill = False
|
||
has_memory = False
|
||
for action in actions:
|
||
text = str(action).lstrip()
|
||
if text.startswith("📝"):
|
||
text = text[1:].lstrip()
|
||
lower = text.lower()
|
||
if lower.startswith("skill"):
|
||
has_skill = True
|
||
elif lower.startswith("memory") or lower.startswith("user profile"):
|
||
has_memory = True
|
||
if has_skill and has_memory:
|
||
return "skill+memory"
|
||
if has_skill:
|
||
return "skill"
|
||
if has_memory:
|
||
return "memory"
|
||
return "none"
|
||
|
||
|
||
def _log_review_completion(usage: Dict[str, Any], result: str) -> None:
|
||
"""Emit a per-fork completion line so cost is visible where it is incurred."""
|
||
logger.info(
|
||
"Background review complete: thread=bg-review calls=%d in=%d out=%d "
|
||
"cache_read=%d result=%s",
|
||
int(usage.get("api_calls") or 0),
|
||
int(usage.get("input_tokens") or 0),
|
||
int(usage.get("output_tokens") or 0),
|
||
int(usage.get("cache_read_tokens") or 0),
|
||
result,
|
||
)
|
||
|
||
|
||
def build_cache_parity_fork(
|
||
agent: Any,
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
max_iterations: int,
|
||
write_origin: str = "background_review",
|
||
) -> Tuple[Any, Dict[str, Any], bool]:
|
||
"""Construct a detached AIAgent fork with warm prompt-cache parity.
|
||
|
||
This is the fork recipe the self-improvement background review uses,
|
||
extracted so other conversation-snapshot consumers (``/btw`` side
|
||
questions) get the identical cache-parity guarantees: same runtime and
|
||
credentials as the parent, byte-identical system prompt / tools[] /
|
||
reasoning config on the same-model path, shared session_id for prefix
|
||
warmth, and full persistence detachment (no state.db writes, no session
|
||
rotation, no external memory providers, in-place-only compaction).
|
||
|
||
Returns ``(fork_agent, runtime_dict, routed)`` where ``routed`` is True
|
||
when auxiliary config redirected the fork to a different model (cache
|
||
cold; callers should replay a digest instead of the full snapshot).
|
||
|
||
The caller keeps ownership of: registering the fork on the parent's
|
||
``_active_children`` / ``_background_review_agent`` slots, thread tool
|
||
whitelisting, running the conversation, usage attribution, and teardown
|
||
(``shutdown_memory_provider()`` + ``close()``).
|
||
"""
|
||
# Local import to avoid a hard circular dep at module load.
|
||
from run_agent import AIAgent
|
||
|
||
# Inherit the parent agent's live runtime (provider, model,
|
||
# base_url, api_key, api_mode) so the fork uses the exact
|
||
# same credentials the main turn is using. Without this,
|
||
# AIAgent.__init__ re-runs auto-resolution from env vars,
|
||
# which fails for OAuth-only providers, session-scoped
|
||
# creds, or credential-pool setups where the resolver can't
|
||
# reconstruct auth from scratch -- producing the spurious
|
||
# "No LLM provider configured" warning at end of turn.
|
||
# _resolve_review_runtime() returns the parent's live runtime by
|
||
# default (routed=False; main model, warm cache), or — when the user
|
||
# set auxiliary.background_review.{provider,model} to a different
|
||
# model — that model's runtime (routed=True). The codex_app_server
|
||
# -> codex_responses downgrade is applied inside the resolver.
|
||
_rt = _resolve_review_runtime(agent, task_cfg)
|
||
_routed = bool(_rt.get("routed"))
|
||
# skip_memory=True keeps the review fork from
|
||
# touching external memory plugins (honcho, mem0,
|
||
# supermemory, etc.). Without it, the fork's
|
||
# __init__ rebuilds its own _memory_manager from
|
||
# config, scoped to the parent's session_id, and
|
||
# run_conversation() then leaks the harness prompt
|
||
# into the user's real memory namespace via three
|
||
# ingestion sites: on_turn_start (cadence + turn
|
||
# message), prefetch_all (recall query), and
|
||
# sync_all (harness prompt + review output recorded
|
||
# as a (user, assistant) turn pair). Built-in
|
||
# MEMORY.md / USER.md state is re-bound from the
|
||
# parent below so memory(action="add") writes from
|
||
# the review still land on disk; the review just
|
||
# has zero side effects on external providers.
|
||
# Match parent's toolset config so ``tools[]`` is byte-identical
|
||
# in the request body — Anthropic's cache key includes it.
|
||
# (The runtime whitelist below still restricts dispatch.)
|
||
_fork_kwargs: Dict[str, Any] = {}
|
||
if isinstance(_rt.get("max_tokens"), int):
|
||
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
|
||
if isinstance(_rt.get("command"), str) and _rt["command"]:
|
||
_fork_kwargs["acp_command"] = _rt["command"]
|
||
_fork_kwargs["acp_args"] = _rt.get("args") or []
|
||
# Match parent's reasoning config so the fork's ``thinking`` /
|
||
# ``output_config`` are byte-identical in the request body —
|
||
# Anthropic's cache key is namespaced by ``thinking`` presence.
|
||
# Same-model path only: when routed to a different aux model the
|
||
# cache is cold regardless (parity buys nothing) and the parent's
|
||
# effort vocabulary may not be valid for the routed model/provider
|
||
# (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded
|
||
# unclamped; codex_responses passes ``max``/``ultra`` through
|
||
# unmapped except on gpt-5.6/xAI). Let the routed fork use
|
||
# provider defaults — matching the ``not _routed`` gate on
|
||
# _cached_system_prompt below.
|
||
if not _routed:
|
||
_fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)
|
||
# Gateway session context is appended to the parent's cached
|
||
# system prompt at API-call time through this field. Preserve
|
||
# it on same-model forks so the complete effective system
|
||
# prompt remains byte-identical and can reuse the warm prefix.
|
||
_fork_kwargs["ephemeral_system_prompt"] = getattr(
|
||
agent, "ephemeral_system_prompt", None
|
||
)
|
||
# Prefill messages are inserted immediately after the system
|
||
# message at API-call time (chat_completion_helpers.py /
|
||
# conversation_loop.py), so a parent with prefill configured
|
||
# (gateway prefill_messages_file) would otherwise diverge
|
||
# from the warm prefix at message index 1 — same bug class
|
||
# as the ephemeral prompt above, one position later.
|
||
# Deep copy: the unicode-error recovery path mutates
|
||
# prefill entries IN PLACE (_sanitize_messages_surrogates
|
||
# via conversation_loop), so sharing dicts would let a
|
||
# fork-side sanitize rewrite the parent's prefill bytes.
|
||
_parent_prefill = copy.deepcopy(
|
||
getattr(agent, "prefill_messages", None) or []
|
||
)
|
||
if _parent_prefill:
|
||
_fork_kwargs["prefill_messages"] = _parent_prefill
|
||
# OpenRouter provider-routing pins: prompt caches live per
|
||
# UPSTREAM provider, so a fork without the parent's pins can
|
||
# be routed to a different upstream and miss the warm cache
|
||
# even with byte-identical prompt/tools bytes.
|
||
for _pref_attr in (
|
||
"providers_allowed",
|
||
"providers_ignored",
|
||
"providers_order",
|
||
"provider_sort",
|
||
"provider_require_parameters",
|
||
"provider_data_collection",
|
||
):
|
||
_pref_val = getattr(agent, _pref_attr, None)
|
||
if _pref_val:
|
||
_fork_kwargs[_pref_attr] = _pref_val
|
||
review_agent = AIAgent(
|
||
model=_rt.get("model") or agent.model,
|
||
max_iterations=max_iterations,
|
||
quiet_mode=True,
|
||
platform=agent.platform,
|
||
provider=_rt.get("provider") or agent.provider,
|
||
api_mode=_rt.get("api_mode"),
|
||
base_url=_rt.get("base_url") or None,
|
||
api_key=_rt.get("api_key") or None,
|
||
credential_pool=_rt.get("credential_pool"),
|
||
request_overrides=_rt.get("request_overrides") or {},
|
||
parent_session_id=agent.session_id,
|
||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||
skip_memory=True,
|
||
**_fork_kwargs,
|
||
)
|
||
review_agent._memory_write_origin = write_origin
|
||
review_agent._memory_write_context = write_origin
|
||
# The review fork pins the parent's cached system prompt and keeps
|
||
# ``tools[]`` byte-identical to the parent so its outbound request
|
||
# hits the same provider cache prefix (see the toolset-parity note
|
||
# above). The between-turns MCP refresh in build_turn_context would
|
||
# add late-connecting MCP tools to this fork and break that parity,
|
||
# so opt the review fork out of it.
|
||
review_agent._skip_mcp_refresh = True
|
||
review_agent._memory_store = agent._memory_store
|
||
review_agent._memory_enabled = agent._memory_enabled
|
||
review_agent._user_profile_enabled = agent._user_profile_enabled
|
||
review_agent._memory_nudge_interval = 0
|
||
review_agent._skill_nudge_interval = 0
|
||
# PERSISTENCE ISOLATION (the curator-takeover root cause): the fork
|
||
# shares the parent's session_id (set below, for prompt-cache
|
||
# warmth), so without this it would write its harness turn ("Review
|
||
# the conversation above and update the skill library…") + its own
|
||
# response straight into the user's REAL session in state.db. On the
|
||
# user's next live turn the agent re-reads that injected user message
|
||
# as a standing instruction and "becomes" the curator, refusing the
|
||
# actual task. _persist_disabled hard-stops every DB write/lazy-open
|
||
# path (_flush_messages_to_session_db, _ensure_db_session,
|
||
# _get_session_db_for_recall); the review writes only to the skill
|
||
# and memory stores via its tools, which is all it needs.
|
||
review_agent._persist_disabled = True
|
||
review_agent._session_db = None
|
||
review_agent._session_json_enabled = False
|
||
# Suppress all status/warning emits from the fork so the
|
||
# user only sees the final successful-action summary.
|
||
# Without this, mid-review "Iteration budget exhausted",
|
||
# rate-limit retries, compression warnings, and other
|
||
# lifecycle messages bubble up through _emit_status ->
|
||
# _vprint and leak past the stdout redirect (they go via
|
||
# _print_fn/status_callback, which bypass sys.stdout).
|
||
review_agent.suppress_status_output = True
|
||
# Inherit the parent's cached system prompt verbatim so
|
||
# the review fork's outbound HTTP request hits the same
|
||
# Anthropic/OpenRouter prefix cache the parent warmed.
|
||
# Without this, the fork rebuilds the system prompt from
|
||
# scratch (fresh _hermes_now() timestamp, fresh
|
||
# session_id, narrower toolset → different skills_prompt)
|
||
# and the byte-exact prefix-cache key misses. See
|
||
# issue #25322 and PR #17276 for the full analysis +
|
||
# measured impact (~26% end-to-end cost reduction on
|
||
# Sonnet 4.5).
|
||
# Share the parent's warm cached system prompt ONLY when the review
|
||
# runs on the SAME model (not routed). When routed to a different
|
||
# model the parent's cached prompt is for the wrong model/cache key
|
||
# and would miss anyway, so let the routed fork build its own.
|
||
if not _routed:
|
||
review_agent._cached_system_prompt = agent._cached_system_prompt
|
||
# Defensive: pin session_start + session_id to the
|
||
# parent's so any code path that re-renders parts of
|
||
# the system prompt (compression, plugin hooks) still
|
||
# produces byte-identical output. The cached-prompt
|
||
# assignment above already short-circuits the normal
|
||
# rebuild path, but these pins guarantee parity even
|
||
# if a future code path bypasses the cache.
|
||
review_agent.session_start = agent.session_start
|
||
review_agent.session_id = agent.session_id
|
||
# The fork shares the parent's live session_id (pinned above for
|
||
# prefix-cache parity). It is single-lifecycle and calls close()
|
||
# right after this run_conversation(); without opting out, close()
|
||
# would finalize the parent's still-active session row mid
|
||
# conversation (the review fires every ~10 turns). Leave session
|
||
# finalization to the real owner (CLI close / gateway reset / cron).
|
||
review_agent._end_session_on_close = False
|
||
# DETACHED IN-MEMORY COMPACTION (issue #93057). The fork shares
|
||
# the parent's session_id (pinned above for prefix-cache parity),
|
||
# so the historical guard here was ``compression_enabled = False``:
|
||
# if the fork ran the ordinary compression path it could rotate /
|
||
# archive the parent's live session — the sibling-session race
|
||
# behind #38727. But disabling compaction was a proxy for
|
||
# detachment, and it removed the ONLY bound on the review's
|
||
# private snapshot: as the review performs tool calls, every
|
||
# follow-up provider request replayed the snapshot plus the
|
||
# growing review tool loop (350k-384k input tokens per request in
|
||
# production, 1.49M total across one 8-request review).
|
||
#
|
||
# The fix is detachment, not disablement:
|
||
# • Persistence is already off above (_persist_disabled /
|
||
# _session_db=None), so the commit site in compress_context
|
||
# (``if agent._session_db:``) skips every durable write and
|
||
# compaction can only ever rewrite the fork's private
|
||
# in-memory transcript.
|
||
# • The compressor's OWN session binding still needs severing:
|
||
# AIAgent.__init__ bound it to the parent's SessionDB and
|
||
# session_id before this function nulled the agent-level
|
||
# binding, so durable cooldown/streak/ineffective-count
|
||
# writes would otherwise land on the parent's row. Rebinding
|
||
# with session_db=None / session_id="" makes every
|
||
# compressor persist guard a no-op.
|
||
# • Force in-place mode (never rotation) even if the parent's
|
||
# config selected rotation, and re-enable compression ONLY
|
||
# after the rebind succeeds (fail-closed — see below). While
|
||
# enabled, both compression gates stay deferred until the
|
||
# fork's first provider response so request #1 replays the
|
||
# full snapshot as a warm cache read.
|
||
_review_compressor = getattr(review_agent, "context_compressor", None)
|
||
_bind_review_compressor = getattr(
|
||
_review_compressor, "bind_session_state", None
|
||
)
|
||
_review_compression_detached = False
|
||
if callable(_bind_review_compressor):
|
||
try:
|
||
# Plugin/third-party context engines may not accept these
|
||
# kwargs; they own their own persistence policy, so a
|
||
# failed rebind leaves the pre-existing flags in place
|
||
# and must never abort the review (same tolerance as the
|
||
# init-time binding in agent_init.py).
|
||
_bind_review_compressor(session_db=None, session_id="")
|
||
_review_compression_detached = True
|
||
except Exception:
|
||
# FAIL-CLOSED (adversarial review, #93057): if the rebind
|
||
# could not sever the engine's session binding, the
|
||
# compressor may still point at the parent's
|
||
# SessionDB/session_id. Enabling compression in that
|
||
# state would let durable cooldown/streak/ineffective-
|
||
# count writes land on the parent's row and re-open the
|
||
# #38727 sibling race. Keep the historical
|
||
# compression_enabled=False behavior instead and warn;
|
||
# the review still runs, bounded by the iteration cap
|
||
# and the aggregate input budget below.
|
||
logger.warning(
|
||
"background-review compressor detachment failed; "
|
||
"keeping compression DISABLED on this review fork "
|
||
"(fail-closed, issue #93057 / #38727)",
|
||
exc_info=True,
|
||
)
|
||
# Force in-place mode (never rotation) even if the parent's
|
||
# config selected rotation. Re-enable compression ONLY after the
|
||
# compressor's session binding was successfully severed; an
|
||
# engine without a bind hook keeps the historical disabled
|
||
# behavior as well.
|
||
review_agent.compression_in_place = True
|
||
review_agent.compression_enabled = _review_compression_detached
|
||
if _review_compression_detached:
|
||
# Warm-cache parity: the fork's FIRST provider request
|
||
# replays the parent's full snapshot as a warm prompt-cache
|
||
# read, so compaction must not rewrite the snapshot before
|
||
# that first request goes out. Defer both compression gates
|
||
# until the first provider response arrives (see
|
||
# _review_fork_first_request_pending in agent/turn_context.py
|
||
# and the pre-API gate in agent/conversation_loop.py); from
|
||
# the second request on, the fork's transcript is its own and
|
||
# compaction bounds it.
|
||
review_agent._review_defer_compaction_before_first_response = True
|
||
# Aggregate input budget: compaction bounds any single request;
|
||
# this bounds the WHOLE review. Iterations are already capped by
|
||
# _REVIEW_MAX_ITERATIONS. Checked in agent/conversation_loop.py
|
||
# via _review_input_budget_exhausted (issue #93057).
|
||
review_agent._review_input_token_budget = _review_input_token_budget(
|
||
task_cfg
|
||
)
|
||
return review_agent, _rt, _routed
|
||
|
||
|
||
def _run_review_in_thread(
|
||
agent: Any,
|
||
messages_snapshot: List[Dict],
|
||
prompt: str,
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
review_run: Optional[_BackgroundReviewRun] = None,
|
||
) -> None:
|
||
"""Worker function executed in the background-review daemon thread.
|
||
|
||
Spawns a forked ``AIAgent`` inheriting the parent's runtime, runs the
|
||
review prompt, and surfaces a compact action summary back to the user
|
||
via ``agent._safe_print`` and ``agent.background_review_callback``.
|
||
|
||
``review_run`` is the per-review cancellation token from
|
||
:func:`prepare_background_review_run`. If a live turn bumps the
|
||
cancel generation before this review reaches its first provider call,
|
||
the review aborts without entering ``run_conversation()`` (#84423).
|
||
"""
|
||
if review_run is not None and review_run.cancel_requested.is_set():
|
||
finish_background_review_run(agent, review_run)
|
||
return
|
||
|
||
# Local import to avoid a hard circular dep at module load.
|
||
from run_agent import AIAgent
|
||
from tools.terminal_tool import set_approval_callback as _set_approval_callback
|
||
|
||
# Install a non-interactive approval callback on this worker
|
||
# thread so any dangerous-command guard the review agent trips
|
||
# resolves to "deny" instead of falling back to input() -- which
|
||
# deadlocks against the parent's prompt_toolkit TUI (#15216).
|
||
# Same pattern as _subagent_auto_deny in tools/delegate_tool.py.
|
||
def _bg_review_auto_deny(command, description, **kwargs):
|
||
logger.warning(
|
||
"Background review auto-denied dangerous command: %s (%s)",
|
||
command, description,
|
||
)
|
||
return "deny"
|
||
try:
|
||
_set_approval_callback(_bg_review_auto_deny)
|
||
except Exception:
|
||
pass
|
||
|
||
# An agent-as-provider whose client can't carry Hermes tool calls back would
|
||
# produce a fork that spawns a whole agent and then cannot write anything.
|
||
# Don't spawn it — point at the override that does work. Checked BEFORE the
|
||
# thread-scoped silence below so the warning is not swallowed, and
|
||
# cheap-check-first so the normal path never resolves the runtime twice.
|
||
# Fixes the class, not one provider: any future agent-as-provider client
|
||
# inherits the guard.
|
||
if not _parent_can_emit_tool_calls(agent) and not bool(
|
||
_resolve_review_runtime(agent, task_cfg).get("routed")
|
||
):
|
||
logger.warning(
|
||
"Background review skipped: provider %r cannot emit Hermes tool calls, "
|
||
"so the review fork could not write memories or skills. Set "
|
||
"auxiliary.background_review.{provider,model} to route the review to "
|
||
"a normal model.",
|
||
getattr(agent, "provider", "?"),
|
||
)
|
||
try:
|
||
_set_approval_callback(None)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
review_agent = None
|
||
review_messages: List[Dict] = []
|
||
review_usage: Dict[str, Any] = {}
|
||
|
||
def _unregister_review_agent(agent_ref) -> None:
|
||
"""Idempotent: clears the review fork from both tracking slots.
|
||
Called from the run_conversation finally and the outer safety-net finally.
|
||
"""
|
||
if agent_ref is None:
|
||
return
|
||
if hasattr(agent, "_background_review_agent"):
|
||
_br_lock = getattr(agent, "_background_review_lock", None)
|
||
if _br_lock is not None:
|
||
with _br_lock:
|
||
if agent._background_review_agent is agent_ref:
|
||
agent._background_review_agent = None
|
||
elif agent._background_review_agent is agent_ref:
|
||
agent._background_review_agent = None
|
||
if hasattr(agent, "_active_children"):
|
||
try:
|
||
_ac_lock = getattr(agent, "_active_children_lock", None)
|
||
if _ac_lock is not None:
|
||
with _ac_lock:
|
||
agent._active_children.remove(agent_ref)
|
||
else:
|
||
agent._active_children.remove(agent_ref)
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
def _finish_request_phase(agent_ref) -> None:
|
||
_unregister_review_agent(agent_ref)
|
||
finish_background_review_run(agent, review_run)
|
||
|
||
try:
|
||
# Silence stdout/stderr for THIS worker thread only. A process-global
|
||
# ``contextlib.redirect_stdout(devnull)`` here would also blank
|
||
# ``sys.stdout``/``sys.stderr`` for every other thread — including a
|
||
# gateway event-loop thread driving a Telegram long-poll — for the full
|
||
# duration of the review (tens of seconds), swallowing their console
|
||
# output (#55769 / #55925). ``thread_scoped_silence`` routes only this
|
||
# thread's writes to devnull and leaves all other threads on the real
|
||
# streams.
|
||
with thread_scoped_silence():
|
||
review_agent, _rt, _routed = build_cache_parity_fork(
|
||
agent, task_cfg, max_iterations=_REVIEW_MAX_ITERATIONS
|
||
)
|
||
|
||
# Register this fork on the PARENT's _active_children (the same
|
||
# list interrupt() fans out to for subagent delegation) and
|
||
# _background_review_agent (a direct pointer the next live turn
|
||
# uses to interrupt an admitted request). The per-review run token
|
||
# separately fences startup and acknowledges request-phase exit.
|
||
# The legacy pointer/list remain best-effort for direct test stubs;
|
||
# a prepared run token is the live-turn cancellation authority.
|
||
if hasattr(agent, "_background_review_agent"):
|
||
_br_lock = getattr(agent, "_background_review_lock", None)
|
||
if _br_lock is not None:
|
||
with _br_lock:
|
||
agent._background_review_agent = review_agent
|
||
else:
|
||
agent._background_review_agent = review_agent
|
||
if hasattr(agent, "_active_children"):
|
||
_ac_lock = getattr(agent, "_active_children_lock", None)
|
||
if _ac_lock is not None:
|
||
with _ac_lock:
|
||
agent._active_children.append(review_agent)
|
||
else:
|
||
agent._active_children.append(review_agent)
|
||
|
||
from model_tools import get_tool_definitions
|
||
from hermes_cli.plugins import (
|
||
set_thread_tool_whitelist,
|
||
clear_thread_tool_whitelist,
|
||
)
|
||
|
||
# Gate the built-in memory tool on the profile's memory_enabled flag.
|
||
# Hardcoding ["memory", "skills"] granted the review LLM the MEMORY.md
|
||
# read/write tool even when a profile set memory_enabled: false,
|
||
# contaminating a memory-disabled profile (#54937 layer 2).
|
||
review_toolsets = ["skills"]
|
||
if review_agent._memory_enabled or review_agent._user_profile_enabled:
|
||
review_toolsets.insert(0, "memory")
|
||
review_whitelist = {
|
||
t["function"]["name"]
|
||
for t in get_tool_definitions(
|
||
enabled_toolsets=review_toolsets,
|
||
quiet_mode=True,
|
||
)
|
||
}
|
||
# Read-only file tools are whitelisted too (#61521, #39996): the
|
||
# model naturally reaches for read_file/search_files to inspect a
|
||
# skill before patching it. Denying them caused a per-review
|
||
# denial storm (~142 denials + ~204 read-before-write refusals
|
||
# over 2 days on one deployment) that starved the self-improvement
|
||
# loop — the model never loaded SKILL.md the way the
|
||
# read-before-write guard requires, so almost no patch landed.
|
||
# This is a DISPATCH-side change only: the advertised ``tools[]``
|
||
# stays byte-identical to the parent's, so prompt-cache parity is
|
||
# untouched. read_file registers the read with the
|
||
# read-before-write guard (tools/file_tools.py), so a
|
||
# read_file → skill_manage(patch) sequence now succeeds. Write
|
||
# tools (write_file/patch/terminal) stay denied — autonomous
|
||
# maintenance must go through skill_manage's validation, and the
|
||
# deny message below names that substitute so one denial
|
||
# redirects the model instead of a storm.
|
||
review_whitelist |= {"read_file", "search_files"}
|
||
# Profile-configured opt-in tools (#44672, salvage #82146 by
|
||
# @BrinShadewater): ``auxiliary.background_review.extra_tools``
|
||
# admits named parent tools to the review whitelist — e.g. a
|
||
# human-gated proposal tool or a memory-provider write surface.
|
||
# Default-empty; a listed tool must already exist in the parent's
|
||
# inherited schema (the whitelist can only admit, never advertise),
|
||
# and everything unlisted stays denied. Read from task_cfg (the
|
||
# auxiliary.background_review block already loaded for this spawn)
|
||
# so no extra config I/O happens per review.
|
||
configured_extra_tools: set = set()
|
||
try:
|
||
_extra_raw = _background_review_task_config(task_cfg).get(
|
||
"extra_tools", []
|
||
)
|
||
if isinstance(_extra_raw, list):
|
||
configured_extra_tools = {
|
||
name.strip()
|
||
for name in _extra_raw
|
||
if isinstance(name, str) and name.strip()
|
||
}
|
||
review_whitelist |= configured_extra_tools
|
||
except Exception:
|
||
logger.debug(
|
||
"background_review extra_tools parse failed", exc_info=True
|
||
)
|
||
_extra_deny_note = (
|
||
" Configured extra tools also allowed: "
|
||
+ ", ".join(sorted(configured_extra_tools)) + "."
|
||
if configured_extra_tools
|
||
else ""
|
||
)
|
||
set_thread_tool_whitelist(
|
||
review_whitelist,
|
||
deny_msg_fmt=(
|
||
"Background review denied non-whitelisted tool: "
|
||
"{tool_name}. Allowed here: skill_view/skills_list/"
|
||
"read_file/search_files to read, "
|
||
"skill_manage(action='patch'|...) to change skills, and "
|
||
"memory for notes." + _extra_deny_note
|
||
+ " Do not retry {tool_name}."
|
||
),
|
||
)
|
||
try:
|
||
from tools.skill_manager_tool import _reset_background_review_read_marks
|
||
|
||
_reset_background_review_read_marks()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
request_admitted = (
|
||
review_run is None or review_run.begin_request(review_agent)
|
||
)
|
||
if request_admitted:
|
||
# Routed to a different model -> replay a digest (cache is cold
|
||
# on that model anyway, so minimise cold-written tokens). Same
|
||
# model -> replay the full snapshot (warm cache reads).
|
||
_review_history = (
|
||
_digest_history(messages_snapshot) if _routed
|
||
else messages_snapshot
|
||
)
|
||
review_agent.run_conversation(
|
||
user_message=(
|
||
prompt
|
||
+ "\n\nYou can only call memory and skill "
|
||
"management tools. Other tools will be denied "
|
||
"at runtime — do not attempt them."
|
||
+ (
|
||
" Exception — these configured tools are "
|
||
"also allowed: "
|
||
+ ", ".join(sorted(configured_extra_tools))
|
||
+ "."
|
||
if configured_extra_tools
|
||
else ""
|
||
)
|
||
),
|
||
conversation_history=_review_history,
|
||
)
|
||
finally:
|
||
clear_thread_tool_whitelist()
|
||
# Attribute the review fork's usage to the PARENT session.
|
||
# Snapshot BEFORE unregister/close so counters survive teardown.
|
||
# Placed in this finally so a fork that consumed tokens and THEN
|
||
# raised is still attributed (issue #87250). Best-effort: the
|
||
# recorder never raises into the review thread.
|
||
if review_agent is not None:
|
||
review_usage.update(_snapshot_review_usage(review_agent))
|
||
_record_review_usage_to_parent(agent, review_usage)
|
||
# Publish completion as soon as the provider-capable phase has
|
||
# returned or startup cancellation has fenced it out.
|
||
_finish_request_phase(review_agent)
|
||
|
||
# Snapshot review actions before teardown. close() is allowed to
|
||
# clean per-session state, but the user-visible self-improvement
|
||
# summary still needs the completed review agent's tool results.
|
||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||
|
||
# The fork shares the foreground session ID for prompt-cache
|
||
# parity. Do not call close() or shutdown_memory_provider():
|
||
# both are session-bound lifecycle operations, and close() also
|
||
# kills registered terminal processes and cleans environments for
|
||
# that ID. Releasing only this fork's clients leaves the live
|
||
# session and its child processes untouched.
|
||
try:
|
||
review_agent.release_clients()
|
||
except Exception:
|
||
pass
|
||
review_agent = None
|
||
|
||
# Scan the review agent's messages for successful tool actions
|
||
# and surface a compact summary to the user. Tool messages
|
||
# already present in messages_snapshot must be skipped, since
|
||
# the review agent inherits that history and would otherwise
|
||
# re-surface stale "created"/"updated" messages from the prior
|
||
# conversation as if they just happened (issue #14944).
|
||
#
|
||
# Wrapped in try/except: a buggy/legacy tool response shape
|
||
# (e.g. ``_change`` returned as a list instead of a dict, #59437)
|
||
# must NOT take down the whole review with an AttributeError,
|
||
# since the caller's outer except logs only "Background
|
||
# memory/skill review failed" and discards every successful
|
||
# action the fork DID complete before the crash. Coerce an
|
||
# exception into an empty actions list so the partial valid
|
||
# actions from earlier in the messages are returned instead.
|
||
try:
|
||
actions = summarize_background_review_actions(
|
||
review_messages,
|
||
messages_snapshot,
|
||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"summarize_background_review_actions returned partial results "
|
||
"after exception (treating as empty); suppressing AttributeError "
|
||
"that previously aborted the entire review (#59437): %s",
|
||
e,
|
||
)
|
||
actions = []
|
||
|
||
_log_review_completion(
|
||
review_usage, _classify_review_result(actions)
|
||
)
|
||
|
||
if actions:
|
||
summary = " · ".join(dict.fromkeys(actions))
|
||
agent._safe_print(
|
||
f" 💾 Self-improvement review: {summary}"
|
||
)
|
||
_bg_cb = agent.background_review_callback
|
||
if _bg_cb:
|
||
try:
|
||
_bg_cb(
|
||
f"💾 Self-improvement review: {summary}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
except Exception as e:
|
||
logger.warning("Background memory/skill review failed: %s", e)
|
||
if review_usage:
|
||
_log_review_completion(review_usage, "error")
|
||
agent._emit_auxiliary_failure("background review", e)
|
||
finally:
|
||
# Safety-net cleanup for the exception path. Normal completion already
|
||
# released its clients inside the thread-scoped silence above. Re-enter
|
||
# the thread-scoped silence here so exception-path cleanup output stays
|
||
# quiet without blanking other threads' streams.
|
||
# Also a safety-net completion: covers exceptions raised during setup
|
||
# before the request-phase finally. Both tracking cleanup and the
|
||
# per-run completion publication are identity-scoped and idempotent.
|
||
_finish_request_phase(review_agent)
|
||
if review_agent is not None:
|
||
try:
|
||
with thread_scoped_silence():
|
||
try:
|
||
review_agent.release_clients()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
# Clear the approval callback on this bg-review thread so a
|
||
# recycled thread-id doesn't inherit a stale reference.
|
||
try:
|
||
_set_approval_callback(None)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def spawn_background_review_thread(
|
||
agent: Any,
|
||
messages_snapshot: List[Dict],
|
||
review_memory: bool = False,
|
||
review_skills: bool = False,
|
||
focus: Optional[str] = None,
|
||
task_cfg: Optional[Dict[str, Any]] = None,
|
||
review_run: Optional[_BackgroundReviewRun] = None,
|
||
):
|
||
"""Build the review thread target and prompt for a background review.
|
||
|
||
Returns a ``(target, prompt)`` tuple. The caller (``AIAgent._spawn_background_review``)
|
||
owns the actual ``threading.Thread`` construction so test-level patches
|
||
of ``run_agent.threading.Thread`` keep working.
|
||
|
||
``focus`` is optional user steering (the ``/refine [instructions]``
|
||
path): appended to the chosen review prompt so the fork prioritizes what
|
||
the user asked for while keeping the same guardrails. Automatic
|
||
post-turn reviews pass ``None`` — their prompts are byte-identical to
|
||
before this parameter existed.
|
||
|
||
``task_cfg`` is the already-loaded ``auxiliary.background_review`` block
|
||
from :func:`load_background_review_settings`. When omitted, config is
|
||
read once here and shared with the worker (aux routing) so a single
|
||
turn does not re-parse the config file.
|
||
"""
|
||
if task_cfg is None:
|
||
task_cfg = _background_review_task_config()
|
||
# Pick the right prompt based on which triggers fired. Allow per-agent
|
||
# override (the prompts moved to module-level constants but old code paths
|
||
# that set agent._MEMORY_REVIEW_PROMPT etc. directly keep working).
|
||
if review_memory and review_skills:
|
||
prompt = getattr(agent, "_COMBINED_REVIEW_PROMPT", _COMBINED_REVIEW_PROMPT)
|
||
elif review_memory:
|
||
prompt = getattr(agent, "_MEMORY_REVIEW_PROMPT", _MEMORY_REVIEW_PROMPT)
|
||
else:
|
||
prompt = getattr(agent, "_SKILL_REVIEW_PROMPT", _SKILL_REVIEW_PROMPT)
|
||
|
||
focus = (focus or "").strip()
|
||
if focus:
|
||
prompt = (
|
||
f"{prompt}\n\n"
|
||
f"The user explicitly requested this review with the following "
|
||
f"focus — prioritize it over the general instructions above:\n"
|
||
f"{focus}"
|
||
)
|
||
|
||
def _target() -> None:
|
||
_run_review_in_thread(
|
||
agent,
|
||
messages_snapshot,
|
||
prompt,
|
||
task_cfg=task_cfg,
|
||
review_run=review_run,
|
||
)
|
||
|
||
return _target, prompt
|
||
|
||
|
||
__all__ = [
|
||
"_MEMORY_REVIEW_PROMPT",
|
||
"_SKILL_REVIEW_PROMPT",
|
||
"_COMBINED_REVIEW_PROMPT",
|
||
"is_background_review_enabled",
|
||
"load_background_review_settings",
|
||
"spawn_background_review_thread",
|
||
"summarize_background_review_actions",
|
||
"build_memory_write_metadata",
|
||
]
|