Files
hermes-agent/agent/background_review.py
Teknium c408601937 refactor(agent/review): simplify curator, background_review, verify, insights, title and learning modules (-22% LOC)
Cluster: agent/{curator,curator_backup,background_review,review_engine,
review_idle_queue,insights,learning_graph,learning_graph_render,
learning_mutations,learn_prompt,verification_evidence,verification_stop,
verify_hooks,side_question,title_generator,turn_summary,
manual_compression_feedback,trajectory,moa_trace,trace_upload,verify/*}.
13662 -> 10693 LOC (-2969, -21.7%), behavior-neutral.

- Dead code: 27 private helpers with zero references removed
  (_auto_title_session, _resolve_review_model, _parse_make_targets,
  _filter_verifiable_paths, _find_subsequence, _is_under_root/_temp_dir,
  _merge_runs, learning_graph_render bucket/period/node helpers,
  _memories_dir/_memory_local_index/_node_detail, _cron_jobs_file,
  _retention_cutoff, _scope_for_args, _clean_token, _count_diff_lines,
  _ordered_verbs, _hermes_meta, _iter_skill_files).
- Unified helpers: _read_config_section (curator + curator_backup),
  _write_file/_write_json (4 curator report writers), _msg_text
  (background_review <- side_question), _report_failure/_notify_title
  (title_generator instant/auto paths), _is_under (verification_evidence),
  _scoped SQL pair builder + _query (insights), _optional_lock
  (background_review), verify.recipes table-driven detection.
- if/elif routing -> dict dispatch: side_question role labels,
  curator_backup summary bits, learning_graph_render buckets, insights
  section rendering, verify recipe pickers.
- Redundant defensive layers, single-use wrappers and verbose narrative
  comments collapsed; every non-obvious WHY/invariant kept in compact form.

Verification: parity.py (all REMOVED symbols zero-ref), import smoke for
every module + cli/run_agent/gateway.run/hermes_cli.main/
agent.conversation_loop/tui_gateway.server, old-vs-new fuzz parity on all
shared pure functions, SQL trace parity for insights and
verification_evidence, cluster tests 1354 passed / 0 failed (46 files).
2026-09-02 13:30:25 -07:00

1470 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Background memory/skill review — fork the agent to evaluate the turn.
After every turn ``AIAgent.run_conversation`` may spawn a daemon thread that
replays the conversation snapshot in a forked :class:`AIAgent` and asks
"should any skill/memory be saved or updated?". Writes go straight to the
memory + skill stores; the main conversation and prompt cache are never touched.
The fork inherits the parent's live runtime (provider, model, credentials,
cached system prompt) so it hits the same prefix cache, and runs under a
dispatch-side tool whitelist limited to memory/skill tools.
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
import threading
from contextlib import contextmanager
from typing import Any, Dict, Iterator, 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
@contextmanager
def _optional_lock(agent: Any, attr: str) -> Iterator[None]:
"""``with`` over a lock attribute that may be absent (direct test stubs)."""
lock = getattr(agent, attr, None)
if lock is None:
yield
return
with lock:
yield
def prepare_background_review_run(agent: Any) -> Optional[_BackgroundReviewRun]:
"""Install a unique run token on the parent before ``Thread.start()``."""
run = _BackgroundReviewRun()
try:
lock = getattr(agent, "_background_review_lock", None)
if lock is None:
lock = agent._background_review_lock = threading.Lock()
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
with _optional_lock(agent, "_background_review_lock"):
if 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 wedged abort hook cannot stall the live turn
(the bounded ``request_done`` wait in the canceller relies on this returning fast)."""
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: past the bounded deadline, warn and let the live turn
proceed — self-improvement work must never block a user-facing turn.
"""
with _optional_lock(agent, "_background_review_lock"):
run = getattr(agent, "_background_review_run", None)
legacy_agent = getattr(agent, "_background_review_agent", None)
if run is None:
if legacy_agent is not None:
_interrupt_background_review(legacy_agent)
return
review_agent = run.cancel()
if review_agent is not None:
_interrupt_background_review(review_agent)
if not run.request_done.wait(timeout=_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS):
logger.warning(
"Background review did not acknowledge cancellation within %.1fs; "
"proceeding with foreground live turn",
_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS,
)
# Aux-model routing: by default ("auto") the fork runs on the MAIN model and
# replays the full conversation as warm cache reads. When
# auxiliary.background_review.{provider,model} routes it to a DIFFERENT model
# the cache is cold anyway, so the fork replays a compact digest instead.
_REVIEW_MAX_ITERATIONS = 16
# Aggregate INPUT-token budget for one review fork (checked in conversation_loop's
# ``_review_input_budget_exhausted``). Request #1 replays the full snapshot as a
# warm cache read (both compression gates deferred until the first response);
# compaction then bounds each request, but nothing else caps the SUM across the
# tool loop. 2x the historical 300k foreground trigger. Override via
# ``auxiliary.background_review.max_input_tokens``; <= 0 disables.
_REVIEW_MAX_INPUT_TOKENS_DEFAULT = 600_000
def _task_block(cfg: Any) -> Dict[str, Any]:
"""``cfg["auxiliary"]["background_review"]`` as a dict (``{}`` on any shape mismatch)."""
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
task = aux.get("background_review", {})
return task if isinstance(task, dict) else {}
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 so the 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
return _task_block(load_config_readonly())
except Exception:
return {}
def _review_input_token_budget(
task_cfg: Optional[Dict[str, Any]] = None,
) -> Optional[int]:
"""Aggregate input-token budget for one review fork (None = unlimited; <= 0 disables)."""
raw = _background_review_task_config(task_cfg).get(
"max_input_tokens", _REVIEW_MAX_INPUT_TOKENS_DEFAULT
)
try:
budget = int(raw)
except (TypeError, ValueError):
budget = _REVIEW_MAX_INPUT_TOKENS_DEFAULT
return budget if budget > 0 else None
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 (``enabled=True``) so a broken
config never silently disables reviews — but WARN so the cost is visible.
"""
try:
from hermes_cli.config import load_config_readonly
from utils import is_truthy_value
task = _task_block(load_config_readonly())
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:
"""Whether automatic post-turn review may spawn (``enabled``, default true).
Explicit ``/refine`` (``focus`` set) bypasses this gate. Prefer
:func:`load_background_review_settings` at the spawn site so the block is
not re-read on the same turn.
"""
if task_cfg is None:
return load_background_review_settings()[0]
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
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): the parent's live runtime with
``routed=False`` (codex_app_server -> codex_responses downgrade applied).
When ``auxiliary.background_review.{provider,model}`` names a different
concrete model, 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, task_model, task_base_url, task_api_key = (
str(task.get(key, "")).strip() or None
for key in ("provider", "model", "base_url", "api_key")
)
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.
An agent-as-provider client shim that cannot carry Hermes tool calls back
declares ``SUPPORTS_HERMES_TOOL_CALLS = False`` (instance or class) and is
skipped — the fork would be a guaranteed no-op that still pays a full
spawn. Anything that doesn't say otherwise is assumed capable.
"""
client = getattr(agent, "client", None)
for candidate in (client, type(client) if client is not None else None):
supported = getattr(candidate, "SUPPORTS_HERMES_TOOL_CALLS", None)
if candidate is not None and 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 (extended so the kept run never
starts on a tool result) and collapses older turns into one synthetic
user-role digest, preserving role alternation. Never used on the
main-model path, where the 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:]
lines: List[str] = []
for m in msgs[:-len(keep)]:
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":
if m.get("tool_calls"):
names = [
(tc.get("function") or {}).get("name", "?")
for tc in m["tool_calls"]
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 prompts. AIAgent exposes them as class attributes
# (``_MEMORY_REVIEW_PROMPT`` etc.) for back-compat; the text lives here.
_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 _preview(text: str, limit: int) -> str:
return text[:limit] + ("…" if len(text) > limit else "")
# Memory op -> (glyph, which field carries the preview, preview length).
_MEMORY_OP_FORMATS: Dict[str, Tuple[str, str, int]] = {
"add": ("➕", "content", 120),
"replace": ("✏️", "content", 120),
"remove": ("➖", "old_text", 60),
}
def _memory_op_line(label: str, action: str, fields: Dict[str, str]) -> Optional[str]:
"""Verbose line for one memory add/replace/remove, or None when no preview text."""
fmt = _MEMORY_OP_FORMATS.get(action)
if fmt is None:
return None
glyph, field, limit = fmt
text = fields.get(field) or ""
return f"{label} {glyph} {_preview(text, limit)}" if text else None
def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str:
action = detail.get("action", "")
skill_name = detail.get("name", "")
# ``_change`` is free-form (wrapper MCP backends return lists/scalars).
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 = _preview(old_string, 80).replace("\n", " ")
new_preview = _preview(new_string, 80).replace("\n", " ")
return f"📝 Skill '{skill_name}' patched: \"{old_preview}\" → \"{new_preview}\""
if action == "create" and description:
return f"📝 Skill '{skill_name}' created: {description}"
if action == "edit" and description:
return f"📝 Skill '{skill_name}' rewritten: {description}"
return f"📝 {message}" if message else f"Skill {action}"
def _verbose_memory_lines(label: str, detail: Dict) -> List[str]:
# ``operations`` may be any JSON value; only a list of dicts is usable.
ops_raw = detail.get("operations")
operations: list = ops_raw if isinstance(ops_raw, list) else []
if operations:
lines = [
_memory_op_line(label, op.get("action", ""), op)
for op in operations
if isinstance(op, dict)
]
return [line for line in lines if line]
line = _memory_op_line(label, detail.get("action", ""), detail)
return [line or f"{label} updated"]
def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict]:
"""Map review-agent tool_call ids -> parsed call arguments for notify tools.
Result JSON only says "Entry added"; the call arguments carry action,
target and content previews. Restricting to notify tools keeps 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", ""),
}
return all_tool_call_ids, call_details
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.
Collects successful memory / skill-management tool results from the review
agent's messages, skipping tool messages already present in
``prior_snapshot`` so inherited results are not re-surfaced as fresh work.
``notification_mode``: ``off`` -> no actions; ``on`` -> generic
"Memory updated"/tool messages; ``verbose`` -> content previews from the
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)
elif isinstance(prior.get("content"), str):
existing_tool_contents.add(prior["content"])
all_tool_call_ids, call_details = _collect_review_call_details(review_messages)
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
# Wrapper MCP servers may return a top-level list/scalar; only dict
# payloads carry ``success``/``_change``.
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 and (
"created" in message_lower
or "updated" in message_lower
or (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:
if is_skill:
actions.append(_verbose_skill_line(data, detail, message))
else:
actions.extend(_verbose_memory_lines(label, detail))
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, ""}}
_USAGE_COUNTERS = (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"reasoning_tokens",
"api_calls",
)
def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]:
"""Snapshot in-memory usage counters from a review fork (pre-close)."""
usage: Dict[str, Any] = {
key: getattr(review_agent, key, None)
for key in ("model", "provider", "base_url")
}
for key in _USAGE_COUNTERS:
usage[key] = int(getattr(review_agent, f"session_{key}", 0) or 0)
usage["estimated_cost_usd"] = getattr(review_agent, "session_estimated_cost_usd", None)
return usage
def _record_review_usage_to_parent(
parent_agent: Any,
usage: Dict[str, Any],
) -> None:
"""Record a fork's usage against the parent session (best-effort, never raises).
The fork has ``_session_db = None`` so conversation_loop's DB-gated accounting
never sees its calls; route them through the aux-accounting chokepoint, which
writes only ``session_model_usage`` — never the transcript or ``sessions`` row.
"""
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
counts = {key: int(usage.get(key) or 0) for key in _USAGE_COUNTERS}
if not any(counts.values()):
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"),
estimated_cost_usd=usage.get("estimated_cost_usd"),
api_call_count=counts.pop("api_calls"),
**counts,
)
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`` / ``skill+memory``.
Prefix-based on the formats :func:`summarize_background_review_actions`
emits (``Skill …``, ``📝 Skill …``, ``Memory …``, ``User profile …``), so a
free-text line like ``Skipped: no skill worth saving`` stays ``none``.
"""
has_skill = has_memory = False
for action in actions or []:
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
return "+".join(
kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit
) or "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,
)
# OpenRouter provider-routing pins: prompt caches live per UPSTREAM provider,
# so a fork without the parent's pins can land on a different upstream and
# miss the warm cache even with byte-identical prompt/tools bytes.
_PROVIDER_PIN_ATTRS = (
"providers_allowed",
"providers_ignored",
"providers_order",
"provider_sort",
"provider_require_parameters",
"provider_data_collection",
)
def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]:
"""AIAgent kwargs that keep a SAME-model fork's request bytes identical to the parent's.
Only for the un-routed path: on a different model the cache is cold
anyway, and the parent's reasoning-effort vocabulary may be invalid for
the routed provider (OpenRouter forwards ``reasoning.effort`` unclamped;
codex_responses passes ``max``/``ultra`` through unmapped).
"""
kwargs: Dict[str, Any] = {
# Anthropic's cache key is namespaced by ``thinking`` presence.
"reasoning_config": getattr(agent, "reasoning_config", None),
# Gateway session context appended to the cached system prompt at
# API-call time; without it the effective system prompt diverges.
"ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None),
}
# Prefill sits right after the system message, so a parent with prefill
# would diverge at index 1. Deep copy: unicode-error recovery sanitizes
# prefill entries IN PLACE and must not rewrite the parent's bytes.
parent_prefill = copy.deepcopy(getattr(agent, "prefill_messages", None) or [])
if parent_prefill:
kwargs["prefill_messages"] = parent_prefill
for attr in _PROVIDER_PIN_ATTRS:
val = getattr(agent, attr, None)
if val:
kwargs[attr] = val
return kwargs
def _detach_fork_compression(review_agent: Any) -> None:
"""Detached in-memory compaction for a fork sharing the parent's session_id.
Disabling compression (the old guard against compacting the parent's live
session) removed the only bound on the review's snapshot. Persistence is
already off, so compaction can only rewrite the fork's transcript — but the
compressor's own SessionDB/session_id binding must be severed too, or
cooldown/streak counters land on the parent's row. Force in-place mode and
re-enable compression ONLY after the rebind succeeded (fail-closed); gates
stay deferred until the first response so request #1 is a warm cache read.
"""
bind = getattr(getattr(review_agent, "context_compressor", None), "bind_session_state", None)
detached = False
if callable(bind):
try:
# Plugin/third-party context engines may reject these kwargs; they
# own their persistence policy, so a failed rebind never aborts the review.
bind(session_db=None, session_id="")
detached = True
except Exception:
# FAIL-CLOSED: the compressor may still point at the parent's
# SessionDB; enabling compression would re-open the sibling race.
logger.warning(
"background-review compressor detachment failed; "
"keeping compression DISABLED on this review fork "
"(fail-closed, issue #93057 / #38727)",
exc_info=True,
)
review_agent.compression_in_place = True
review_agent.compression_enabled = detached
if detached:
review_agent._review_defer_compaction_before_first_response = True
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 (shared with ``/btw``).
Same runtime/credentials as the parent, byte-identical system prompt /
tools[] / reasoning config on the same-model path, shared session_id for
prefix warmth, full persistence detachment (no state.db writes, rotation,
or external memory providers; in-place-only compaction).
Returns ``(fork_agent, runtime_dict, routed)``; ``routed`` means a different
model (cache cold — replay a digest). The caller owns registration,
whitelisting, running, usage attribution and teardown.
"""
from run_agent import AIAgent # local: avoids a circular import at load
# Inherit the parent's live runtime: AIAgent.__init__'s env auto-resolution
# fails for OAuth-only providers, session-scoped creds and credential pools.
_rt = _resolve_review_runtime(agent, task_cfg)
_routed = bool(_rt.get("routed"))
_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 []
if not _routed:
_fork_kwargs.update(_same_model_parity_kwargs(agent))
# skip_memory=True: an external memory plugin scoped to the parent's
# session_id would leak the harness prompt into the user's real memory
# namespace; built-in MEMORY.md/USER.md state is re-bound below. Toolsets
# match the parent so ``tools[]`` is byte-identical (Anthropic's cache key
# includes it); the runtime whitelist restricts dispatch.
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 between-turns MCP refresh would add late-connecting MCP tools and
# break tools[] parity, so opt out.
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 (curator-takeover root cause): sharing the parent's
# session_id, the fork would otherwise write its harness turn into the REAL
# session, which the next live turn re-reads as a standing instruction.
review_agent._persist_disabled = True
review_agent._session_db = None
review_agent._session_json_enabled = False
# Fork status/warning emits go via _print_fn/status_callback, which bypass
# the stdout redirect — suppress them.
review_agent.suppress_status_output = True
# Same model only: share the warm cached system prompt (~26% cost cut; a
# rebuilt prompt misses the byte-exact prefix key) and pin session_start so
# any re-render (compression, plugin hooks) stays byte-identical.
if not _routed:
review_agent._cached_system_prompt = agent._cached_system_prompt
review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id
# Single-lifecycle fork sharing the live session_id: close() must not
# finalize the parent's still-active session row.
review_agent._end_session_on_close = False
_detach_fork_compression(review_agent)
# Compaction bounds a single request; this bounds the WHOLE review
# (checked in conversation_loop via _review_input_budget_exhausted).
review_agent._review_input_token_budget = _review_input_token_budget(task_cfg)
return review_agent, _rt, _routed
def _bg_review_auto_deny(command, description, **kwargs):
"""Non-interactive approval: dangerous-command guards resolve to "deny"
instead of input(), which would deadlock against the parent's TUI."""
logger.warning(
"Background review auto-denied dangerous command: %s (%s)",
command, description,
)
return "deny"
def _set_thread_approval_callback(callback: Any) -> None:
from tools.terminal_tool import set_approval_callback
try:
set_approval_callback(callback)
except Exception:
pass
def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None:
"""Add (``register=True``) or remove the fork on the PARENT's tracking slots:
``_background_review_agent`` (direct pointer the next live turn interrupts)
and ``_active_children`` (interrupt() fan-out). Removal is identity-scoped
and idempotent; both are best-effort for direct test stubs — the prepared
run token is the live-turn cancellation authority."""
if review_agent is None:
return
if hasattr(agent, "_background_review_agent"):
with _optional_lock(agent, "_background_review_lock"):
if register:
agent._background_review_agent = review_agent
elif agent._background_review_agent is review_agent:
agent._background_review_agent = None
if hasattr(agent, "_active_children"):
try:
with _optional_lock(agent, "_active_children_lock"):
if register:
agent._active_children.append(review_agent)
else:
agent._active_children.remove(review_agent)
except (ValueError, AttributeError):
if register:
raise
def _review_tool_whitelist(
review_agent: Any, task_cfg: Optional[Dict[str, Any]]
) -> Tuple[set, set]:
"""Return ``(whitelist, configured_extra_tools)`` for the review fork.
DISPATCH-side only: the advertised ``tools[]`` stays byte-identical to the
parent's, so prompt-cache parity is untouched.
"""
from model_tools import get_tool_definitions
# Gate the built-in memory tool on the profile's memory flags so a
# memory-disabled profile is never contaminated by the review LLM.
review_toolsets = ["skills"]
if review_agent._memory_enabled or review_agent._user_profile_enabled:
review_toolsets.insert(0, "memory")
whitelist = {
t["function"]["name"]
for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True)
}
# Read-only file tools: denying read_file/search_files caused a per-review
# denial storm that starved the loop (read_file also registers the read
# with the read-before-write guard). Write tools stay denied — autonomous
# maintenance goes through skill_manage's validation.
whitelist |= {"read_file", "search_files"}
# ``extra_tools`` admits named parent tools (e.g. a human-gated proposal
# tool). The whitelist can only admit, never advertise: a listed tool must
# already exist in the inherited schema.
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()
}
whitelist |= configured_extra_tools
except Exception:
logger.debug("background_review extra_tools parse failed", exc_info=True)
return whitelist, configured_extra_tools
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:
"""Daemon-thread worker: build the fork, run the prompt, surface the action
summary via ``agent._safe_print`` / ``background_review_callback``.
``review_run`` (from :func:`prepare_background_review_run`) cancelled before
the first provider call aborts without entering ``run_conversation()``."""
if review_run is not None and review_run.cancel_requested.is_set():
finish_background_review_run(agent, review_run)
return
_set_thread_approval_callback(_bg_review_auto_deny)
# A client that can't carry Hermes tool calls back would spawn a fork that
# cannot write anything. Checked BEFORE the thread-scoped silence so the
# warning is not swallowed; cheap check first so the normal path never
# resolves the runtime twice.
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", "?"),
)
_set_thread_approval_callback(None)
return
review_agent = None
review_messages: List[Dict] = []
review_usage: Dict[str, Any] = {}
def _finish_request_phase(agent_ref) -> None:
_track_review_fork(agent, agent_ref, register=False)
finish_background_review_run(agent, review_run)
def _release(agent_ref) -> None:
try:
agent_ref.release_clients()
except Exception:
pass
try:
# Silence stdout/stderr for THIS thread only: a process-global redirect
# would blank every other thread's console for the whole review.
with thread_scoped_silence():
review_agent, _rt, _routed = build_cache_parity_fork(
agent, task_cfg, max_iterations=_REVIEW_MAX_ITERATIONS
)
_track_review_fork(agent, review_agent, register=True)
from hermes_cli.plugins import (
set_thread_tool_whitelist,
clear_thread_tool_whitelist,
)
review_whitelist, configured_extra_tools = _review_tool_whitelist(
review_agent, task_cfg
)
extra_list = ", ".join(sorted(configured_extra_tools))
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."
+ (
" Configured extra tools also allowed: " + extra_list + "."
if configured_extra_tools
else ""
)
+ " 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:
if review_run is None or review_run.begin_request(review_agent):
# Routed -> digest (cache cold anyway); same model -> full
# snapshot (warm cache reads).
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: " + extra_list + "."
if configured_extra_tools
else ""
)
),
conversation_history=(
_digest_history(messages_snapshot) if _routed
else messages_snapshot
),
)
finally:
clear_thread_tool_whitelist()
# Attribute usage to the PARENT session. Snapshot BEFORE
# unregister/close so counters survive teardown, and in this
# finally so a fork that consumed tokens then raised is still
# attributed. The recorder never raises.
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.
review_messages = list(getattr(review_agent, "_session_messages", []))
# The fork shares the foreground session ID: close() /
# shutdown_memory_provider() are session-bound (close() kills that
# session's terminal processes), so release only this fork's clients.
_release(review_agent)
review_agent = None
# A buggy/legacy tool response shape must NOT take down the whole
# review (the outer except would discard every action the fork DID
# complete), so coerce to an empty list.
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 for the exception path (setup failures before the
# request-phase finally). Both cleanups are identity-scoped and
# idempotent; re-enter thread-scoped silence so cleanup output stays
# quiet without blanking other threads.
_finish_request_phase(review_agent)
if review_agent is not None:
try:
with thread_scoped_silence():
_release(review_agent)
except Exception:
pass
# Clear the approval callback so a recycled thread-id doesn't inherit it.
_set_thread_approval_callback(None)
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,
):
"""Return ``(target, prompt)``; the caller builds the ``threading.Thread`` so
test patches of ``run_agent.threading.Thread`` keep working.
``focus`` (``/refine [instructions]``) is appended to the chosen prompt;
automatic reviews pass ``None``. ``task_cfg`` is the pre-loaded
``auxiliary.background_review`` block; when omitted it is read once here.
"""
if task_cfg is None:
task_cfg = _background_review_task_config()
# Per-agent overrides (agent._MEMORY_REVIEW_PROMPT etc.) keep working.
name = (
"_COMBINED_REVIEW_PROMPT" if review_memory and review_skills
else "_MEMORY_REVIEW_PROMPT" if review_memory
else "_SKILL_REVIEW_PROMPT"
)
prompt = getattr(agent, name, globals()[name])
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",
]