refactor(agent/turn_explainers): explanation dispatch tables + shared display-flag reader
This commit is contained in:
+185
-242
@@ -1,9 +1,9 @@
|
||||
"""File-mutation verification footers and turn-completion explanations for ``AIAgent``.
|
||||
|
||||
The footer tells the model (and user) when a claimed file mutation did not land; the explainer
|
||||
summarises why a turn ended without a final answer.
|
||||
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
|
||||
summarises why a turn ended without a final answer. Every method resolves through ``AIAgent``'s MRO.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -14,6 +14,155 @@ from agent.tool_result_classification import (
|
||||
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, file_mutation_result_landed
|
||||
)
|
||||
|
||||
_NO_REPLY = "⚠️ No reply: "
|
||||
|
||||
# Exact ``turn_exit_reason`` → explanation body (prefixed with ``_NO_REPLY``).
|
||||
_EXIT_REASON_EXPLANATIONS: Dict[str, str] = {
|
||||
"empty_response_exhausted": (
|
||||
"the model returned empty content after retries and any "
|
||||
"fallback providers. Try `continue`, switch model/provider, "
|
||||
"or inspect the tool output above."
|
||||
),
|
||||
"all_retries_exhausted_no_response": (
|
||||
"all API retries were exhausted before a response was "
|
||||
"produced (provider errors / rate limits). Try `continue` "
|
||||
"or switch provider."
|
||||
),
|
||||
"partial_stream_recovery": (
|
||||
"streaming stopped early and only a partial response was "
|
||||
"recovered. Send `continue` to resume from where it stopped."
|
||||
),
|
||||
"fallback_prior_turn_content": (
|
||||
"no new content was produced this turn; showing recovered "
|
||||
"prior context. Send `continue` to retry."
|
||||
),
|
||||
"interrupted_during_api_call": (
|
||||
"the request was interrupted mid-call before a reply was "
|
||||
"received. Send `continue` to retry."
|
||||
),
|
||||
"budget_exhausted": (
|
||||
"the per-turn iteration/cost budget was exhausted before a "
|
||||
"final answer. Send `continue` to keep going."
|
||||
),
|
||||
"ollama_runtime_context_too_small": (
|
||||
"the local model's context window was too small to finish. "
|
||||
"Increase the context size or use a larger model."
|
||||
),
|
||||
"pending_tool_result": (
|
||||
"the turn stopped while a tool result was still pending and "
|
||||
"the model produced no follow-up text. Send `continue` to "
|
||||
"let it summarize."
|
||||
),
|
||||
}
|
||||
|
||||
# Parameterised reasons (``max_iterations_reached(3/3)`` …) matched by prefix.
|
||||
_EXIT_REASON_PREFIX_EXPLANATIONS = (
|
||||
("max_iterations_reached", (
|
||||
"the maximum tool-iteration limit was reached before a "
|
||||
"final answer. Send `continue` to keep going, or raise "
|
||||
"`max_iterations`."
|
||||
)),
|
||||
("error_near_max_iterations", (
|
||||
"an error occurred near the iteration limit before a final "
|
||||
"answer. Check the tool output above, then send `continue`."
|
||||
)),
|
||||
("repeated_outer_errors", (
|
||||
"the turn kept failing with repeated errors and was stopped "
|
||||
"early instead of retrying forever. Check the errors above, "
|
||||
"then send `continue` to retry."
|
||||
)),
|
||||
)
|
||||
|
||||
# ``session_persistence_failed`` refined by the classified cause (lock contention ≠ disk full).
|
||||
_PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = {
|
||||
"compression": (
|
||||
"the turn was stopped because another process was "
|
||||
"compressing this session. Your message should already be "
|
||||
"saved — please send it again after compression completes."
|
||||
),
|
||||
"compression_closed": (
|
||||
"the turn was stopped because this session was rotated "
|
||||
"by context compression and its live continuation could "
|
||||
"not be adopted. The storage itself is healthy — refresh "
|
||||
"the client (or start a new turn) so it picks up the new "
|
||||
"session id, then send your message again."
|
||||
),
|
||||
"turn_lease": (
|
||||
"the turn was stopped because another Hermes process "
|
||||
"took over this session. Your reply was not saved — wait "
|
||||
"for the other process to finish, then send your message "
|
||||
"again."
|
||||
),
|
||||
"locked": (
|
||||
"the turn was stopped because session storage was busy "
|
||||
"(another Hermes process was writing to the state "
|
||||
"database). Your message should already be saved — "
|
||||
"please send it again in a moment."
|
||||
),
|
||||
"replaced": (
|
||||
"the turn was stopped because the state database file "
|
||||
"was replaced underneath this process. Do not run "
|
||||
"`hermes doctor --fix` or in-place FTS repair — stop "
|
||||
"the process, restore the intended state.db, then "
|
||||
"restart. Unwritten messages were diverted to "
|
||||
"sessions/<session_id>.jsonl and, on the gateway, "
|
||||
"pending_messages/pending-*.json."
|
||||
),
|
||||
"corrupt": (
|
||||
"the turn was stopped because the state database "
|
||||
"reported structural corruption (the transcript would "
|
||||
"have been lost on restart). Freeing disk space will "
|
||||
"not help. Recovery options:\n"
|
||||
"1. Run `hermes doctor --fix`\n"
|
||||
"2. Salvage with: sqlite3 ~/.hermes/state.db \".recover\" "
|
||||
"(then replace state.db)\n"
|
||||
"3. Restore from a backup in ~/.hermes/backups/\n"
|
||||
"Then send your message again."
|
||||
),
|
||||
"disk": (
|
||||
"the turn was stopped because session storage could not "
|
||||
"be written (the transcript would have been lost on "
|
||||
"restart). This is often a full disk — free some space "
|
||||
"(or fix state.db permissions), then send your message "
|
||||
"again."
|
||||
),
|
||||
}
|
||||
_PERSISTENCE_DEFAULT_EXPLANATION = (
|
||||
"the turn was stopped because session storage could not be "
|
||||
"written (the transcript would have been lost on restart). "
|
||||
"Check the state database health (`hermes doctor`), then "
|
||||
"send your message again."
|
||||
)
|
||||
|
||||
|
||||
def _display_flag_enabled(agent, *, env_var: str, config_key: str, cache_attr: str) -> bool:
|
||||
"""``display.<config_key>`` (default True), cached per agent on ``cache_attr``.
|
||||
|
||||
``env_var`` overrides on every call and is never cached. Reads the persisted config.yaml
|
||||
so gateway and CLI share the setting; ``load_config`` is imported lazily (startup cycle,
|
||||
and tests patch it at ``hermes_cli.config``). Any failure → True (safe default: on)."""
|
||||
try:
|
||||
env = os.environ.get(env_var)
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
cached = getattr(agent, cache_attr, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
_cfg = _load_config() or {}
|
||||
except Exception:
|
||||
_cfg = {}
|
||||
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
||||
if isinstance(_display, dict) and config_key in _display:
|
||||
enabled = bool(_display.get(config_key))
|
||||
else:
|
||||
enabled = True
|
||||
setattr(agent, cache_attr, enabled)
|
||||
return enabled
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
class TurnExplainersMixin:
|
||||
"""File-mutation failure footer + turn-completion explainer (see module docstring)."""
|
||||
@@ -53,45 +202,24 @@ class TurnExplainersMixin:
|
||||
preview = _extract_error_preview(result)
|
||||
for path in targets:
|
||||
# Keep the FIRST error per path unless a later success replaces it.
|
||||
if path not in state:
|
||||
state[path] = {
|
||||
"tool": tool_name, "error_preview": preview
|
||||
}
|
||||
state.setdefault(path, {"tool": tool_name, "error_preview": preview})
|
||||
else:
|
||||
for path in targets:
|
||||
state.pop(path, None)
|
||||
|
||||
def _file_mutation_verifier_enabled(self) -> bool:
|
||||
"""Check whether the per-turn file-mutation verifier footer is on.
|
||||
"""``display.file_mutation_verifier`` / ``HERMES_FILE_MUTATION_VERIFIER`` (a patchable seam)."""
|
||||
return _display_flag_enabled(
|
||||
self, env_var="HERMES_FILE_MUTATION_VERIFIER", config_key="file_mutation_verifier",
|
||||
cache_attr="_file_mutation_verifier_enabled_cache",
|
||||
)
|
||||
|
||||
``display.file_mutation_verifier`` (default True), cached per agent; ``HERMES_FILE_MUTATION_VERIFIER``
|
||||
overrides on every call and is never cached. A method so tests can patch one seam.
|
||||
"""
|
||||
try:
|
||||
import os as _os
|
||||
env = _os.environ.get("HERMES_FILE_MUTATION_VERIFIER")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
cached = getattr(self, "_file_mutation_verifier_enabled_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Read from the persisted config.yaml so gateway and CLI share
|
||||
# the same setting. Import lazily to avoid a startup-time cycle.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
_cfg = _load_config() or {}
|
||||
except Exception:
|
||||
_cfg = {}
|
||||
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
||||
if isinstance(_display, dict) and "file_mutation_verifier" in _display:
|
||||
enabled = bool(_display.get("file_mutation_verifier"))
|
||||
else:
|
||||
enabled = True # safe default: verifier on
|
||||
self._file_mutation_verifier_enabled_cache = enabled
|
||||
return enabled
|
||||
except Exception:
|
||||
pass
|
||||
return True # safe default: verifier on
|
||||
def _turn_completion_explainer_enabled(self) -> bool:
|
||||
"""``display.turn_completion_explainer`` / ``HERMES_TURN_COMPLETION_EXPLAINER``."""
|
||||
return _display_flag_enabled(
|
||||
self, env_var="HERMES_TURN_COMPLETION_EXPLAINER", config_key="turn_completion_explainer",
|
||||
cache_attr="_turn_completion_explainer_enabled_cache",
|
||||
)
|
||||
|
||||
# Bare absolute / home / Windows-drive paths in a footer line. Mirrors the gateway's
|
||||
# extract_local_files detector so anything it WOULD auto-attach is backticked first (#35584).
|
||||
@@ -101,11 +229,9 @@ class TurnExplainersMixin:
|
||||
|
||||
@classmethod
|
||||
def _neutralize_footer_paths(cls, text: str) -> str:
|
||||
"""Wrap bare file paths in backticks so the gateway's ``extract_local_files`` never auto-attaches
|
||||
them.
|
||||
"""Backtick bare file paths so the gateway's ``extract_local_files`` never auto-attaches them.
|
||||
|
||||
The extractor skips paths inside inline-code spans. Already-backticked paths are left alone (no
|
||||
double-wrap).
|
||||
The extractor skips inline-code spans; already-backticked paths are left alone (no double-wrap).
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
@@ -115,10 +241,9 @@ class TurnExplainersMixin:
|
||||
def _format_file_mutation_failure_footer(cls, failed: Dict[str, Dict[str, Any]]) -> str:
|
||||
"""Render the per-turn failed-mutation dict as a user-facing footer.
|
||||
|
||||
Up to 10 paths with their first error preview, then an overflow count; empty string when nothing
|
||||
failed.
|
||||
Every path is backtick-wrapped via ``_neutralize_footer_paths`` so protected files cannot be auto-
|
||||
delivered.
|
||||
Up to 10 paths with their first error preview, then an overflow count; "" when nothing failed.
|
||||
Every path is backtick-wrapped via ``_neutralize_footer_paths`` so protected files cannot be
|
||||
auto-delivered.
|
||||
"""
|
||||
if not failed:
|
||||
return ""
|
||||
@@ -128,221 +253,39 @@ class TurnExplainersMixin:
|
||||
"wording above that may suggest otherwise. Run `git status` or "
|
||||
"`read_file` to confirm."
|
||||
]
|
||||
shown = 0
|
||||
for path, info in failed.items():
|
||||
if shown >= 10:
|
||||
break
|
||||
shown = list(failed.items())[:10]
|
||||
for path, info in shown:
|
||||
preview = (info.get("error_preview") or "").strip()
|
||||
tool = info.get("tool") or "patch"
|
||||
if preview:
|
||||
lines.append(f" • `{path}` — [{tool}] {preview}")
|
||||
else:
|
||||
lines.append(f" • `{path}` — [{tool}] failed")
|
||||
shown += 1
|
||||
remaining = len(failed) - shown
|
||||
lines.append(f" • `{path}` — [{tool}] {preview or 'failed'}")
|
||||
remaining = len(failed) - len(shown)
|
||||
if remaining > 0:
|
||||
lines.append(f" • … and {remaining} more")
|
||||
# Neutralize paths the preview echoed; the lookbehind prevents double-wrapping the bullet path.
|
||||
return cls._neutralize_footer_paths("\n".join(lines))
|
||||
|
||||
def _turn_completion_explainer_enabled(self) -> bool:
|
||||
"""Check whether the end-of-turn completion explainer footer is on.
|
||||
|
||||
``display.turn_completion_explainer`` (default True), cached per agent;
|
||||
``HERMES_TURN_COMPLETION_EXPLAINER``
|
||||
overrides on every call and is never cached. Mirrors ``_file_mutation_verifier_enabled``.
|
||||
"""
|
||||
try:
|
||||
import os as _os
|
||||
env = _os.environ.get("HERMES_TURN_COMPLETION_EXPLAINER")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
cached = getattr(self, "_turn_completion_explainer_enabled_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Read from the persisted config.yaml so gateway and CLI share
|
||||
# the same setting. Import lazily to avoid a startup-time cycle.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
_cfg = _load_config() or {}
|
||||
except Exception:
|
||||
_cfg = {}
|
||||
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
||||
if isinstance(_display, dict) and "turn_completion_explainer" in _display:
|
||||
enabled = bool(_display.get("turn_completion_explainer"))
|
||||
else:
|
||||
enabled = True # safe default: explainer on
|
||||
self._turn_completion_explainer_enabled_cache = enabled
|
||||
return enabled
|
||||
except Exception:
|
||||
pass
|
||||
return True # safe default: explainer on
|
||||
|
||||
@staticmethod
|
||||
def _format_turn_completion_explanation(
|
||||
turn_exit_reason: str, persistence_cause: Optional[str] = None
|
||||
) -> str:
|
||||
"""Render a user-facing explanation for an abnormal turn ending.
|
||||
"""User-facing explanation for an abnormal turn ending, or "" for normal / unknown reasons.
|
||||
|
||||
Maps ``turn_exit_reason`` to an actionable message so a turn with no usable reply is never silent.
|
||||
``persistence_cause`` refines ``session_persistence_failed`` wording (lock contention ≠ disk full).
|
||||
Returns "" for non-abnormal reasons so callers can concatenate unconditionally.
|
||||
``text_response(...)`` is the healthy terminal; unknown/diagnostic-only reasons (e.g.
|
||||
``guardrail_halt``, which surfaces its own message) are not second-guessed.
|
||||
"""
|
||||
if not turn_exit_reason:
|
||||
return ""
|
||||
reason = str(turn_exit_reason)
|
||||
|
||||
# Normal completion — stay quiet. ``text_response(...)`` is the
|
||||
# healthy terminal; anything that produced a real reply is fine.
|
||||
if reason.startswith("text_response"):
|
||||
return ""
|
||||
|
||||
prefix = "⚠️ No reply: "
|
||||
if reason == "empty_response_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the model returned empty content after retries and any "
|
||||
"fallback providers. Try `continue`, switch model/provider, "
|
||||
"or inspect the tool output above."
|
||||
body = _EXIT_REASON_EXPLANATIONS.get(reason)
|
||||
if body is None:
|
||||
for prefix, text in _EXIT_REASON_PREFIX_EXPLANATIONS:
|
||||
if reason.startswith(prefix):
|
||||
body = text
|
||||
break
|
||||
if body is None and reason == "session_persistence_failed":
|
||||
body = _PERSISTENCE_CAUSE_EXPLANATIONS.get(
|
||||
persistence_cause or "unknown", _PERSISTENCE_DEFAULT_EXPLANATION
|
||||
)
|
||||
if reason == "all_retries_exhausted_no_response":
|
||||
return (
|
||||
prefix
|
||||
+ "all API retries were exhausted before a response was "
|
||||
"produced (provider errors / rate limits). Try `continue` "
|
||||
"or switch provider."
|
||||
)
|
||||
if reason == "partial_stream_recovery":
|
||||
return (
|
||||
prefix
|
||||
+ "streaming stopped early and only a partial response was "
|
||||
"recovered. Send `continue` to resume from where it stopped."
|
||||
)
|
||||
if reason == "fallback_prior_turn_content":
|
||||
return (
|
||||
prefix
|
||||
+ "no new content was produced this turn; showing recovered "
|
||||
"prior context. Send `continue` to retry."
|
||||
)
|
||||
if reason == "interrupted_during_api_call":
|
||||
return (
|
||||
prefix
|
||||
+ "the request was interrupted mid-call before a reply was "
|
||||
"received. Send `continue` to retry."
|
||||
)
|
||||
if reason == "budget_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the per-turn iteration/cost budget was exhausted before a "
|
||||
"final answer. Send `continue` to keep going."
|
||||
)
|
||||
if reason == "ollama_runtime_context_too_small":
|
||||
return (
|
||||
prefix
|
||||
+ "the local model's context window was too small to finish. "
|
||||
"Increase the context size or use a larger model."
|
||||
)
|
||||
if reason.startswith("max_iterations_reached"):
|
||||
return (
|
||||
prefix
|
||||
+ "the maximum tool-iteration limit was reached before a "
|
||||
"final answer. Send `continue` to keep going, or raise "
|
||||
"`max_iterations`."
|
||||
)
|
||||
if reason.startswith("error_near_max_iterations"):
|
||||
return (
|
||||
prefix
|
||||
+ "an error occurred near the iteration limit before a final "
|
||||
"answer. Check the tool output above, then send `continue`."
|
||||
)
|
||||
if reason.startswith("repeated_outer_errors"):
|
||||
return (
|
||||
prefix
|
||||
+ "the turn kept failing with repeated errors and was stopped "
|
||||
"early instead of retrying forever. Check the errors above, "
|
||||
"then send `continue` to retry."
|
||||
)
|
||||
if reason == "pending_tool_result":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn stopped while a tool result was still pending and "
|
||||
"the model produced no follow-up text. Send `continue` to "
|
||||
"let it summarize."
|
||||
)
|
||||
if reason == "session_persistence_failed":
|
||||
cause = persistence_cause or "unknown"
|
||||
if cause == "compression":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because another process was "
|
||||
"compressing this session. Your message should already be "
|
||||
"saved — please send it again after compression completes."
|
||||
)
|
||||
if cause == "compression_closed":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because this session was rotated "
|
||||
"by context compression and its live continuation could "
|
||||
"not be adopted. The storage itself is healthy — refresh "
|
||||
"the client (or start a new turn) so it picks up the new "
|
||||
"session id, then send your message again."
|
||||
)
|
||||
if cause == "turn_lease":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because another Hermes process "
|
||||
"took over this session. Your reply was not saved — wait "
|
||||
"for the other process to finish, then send your message "
|
||||
"again."
|
||||
)
|
||||
if cause == "locked":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because session storage was busy "
|
||||
"(another Hermes process was writing to the state "
|
||||
"database). Your message should already be saved — "
|
||||
"please send it again in a moment."
|
||||
)
|
||||
if cause == "replaced":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because the state database file "
|
||||
"was replaced underneath this process. Do not run "
|
||||
"`hermes doctor --fix` or in-place FTS repair — stop "
|
||||
"the process, restore the intended state.db, then "
|
||||
"restart. Unwritten messages were diverted to "
|
||||
"sessions/<session_id>.jsonl and, on the gateway, "
|
||||
"pending_messages/pending-*.json."
|
||||
)
|
||||
if cause == "corrupt":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because the state database "
|
||||
"reported structural corruption (the transcript would "
|
||||
"have been lost on restart). Freeing disk space will "
|
||||
"not help. Recovery options:\n"
|
||||
"1. Run `hermes doctor --fix`\n"
|
||||
"2. Salvage with: sqlite3 ~/.hermes/state.db \".recover\" "
|
||||
"(then replace state.db)\n"
|
||||
"3. Restore from a backup in ~/.hermes/backups/\n"
|
||||
"Then send your message again."
|
||||
)
|
||||
if cause == "disk":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because session storage could not "
|
||||
"be written (the transcript would have been lost on "
|
||||
"restart). This is often a full disk — free some space "
|
||||
"(or fix state.db permissions), then send your message "
|
||||
"again."
|
||||
)
|
||||
return (
|
||||
prefix
|
||||
+ "the turn was stopped because session storage could not be "
|
||||
"written (the transcript would have been lost on restart). "
|
||||
"Check the state database health (`hermes doctor`), then "
|
||||
"send your message again."
|
||||
)
|
||||
# Unknown/diagnostic-only reasons (e.g. "unknown", guardrail_halt
|
||||
# which already surfaces its own message) — don't second-guess.
|
||||
return ""
|
||||
return _NO_REPLY + body if body else ""
|
||||
|
||||
Reference in New Issue
Block a user