fix(agent): add debug diagnostics for empty model responses

This commit is contained in:
konsisumer 2026-04-25 00:42:53 +02:00
parent 00c3d848d8
commit 38dab6dff4
9 changed files with 53 additions and 13 deletions

View file

@ -27,6 +27,7 @@ def build_write_denied_paths(home: str) -> set[str]:
os.path.join(home, ".ssh", "id_ed25519"),
os.path.join(home, ".ssh", "config"),
str(hermes_home / ".env"),
os.path.join(home, ".hermes", ".env"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),

View file

@ -338,6 +338,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
"human_delay": "display",
"dashboard": "display",
"code_execution": "agent",
"prompt_caching": "compression",
}
# Display order for tabs — unlisted categories sort alphabetically after these.

View file

@ -12221,6 +12221,29 @@ class AIAgent:
_has_structured
and self._thinking_prefill_retries >= 2
)
if _truly_empty:
_raw_chars = len(final_response or "")
_has_think_blocks = bool(
final_response
and re.search(
r'<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b',
final_response,
re.IGNORECASE,
)
)
logger.debug(
"Empty response diagnostics: finish_reason=%r "
"raw_content_chars=%d has_think_blocks=%s "
"structured_reasoning=%s tool_calls=%s model=%s "
"raw_preview=%r",
finish_reason,
_raw_chars,
_has_think_blocks,
_has_structured,
bool(getattr(assistant_message, "tool_calls", None)),
self.model,
(final_response or "")[:300],
)
if _truly_empty and (not _has_structured or _prefill_exhausted) and self._empty_content_retries < 3:
self._empty_content_retries += 1
logger.warning(

View file

@ -969,6 +969,7 @@ class TestAgentCacheIdleResume:
session_id="hard-session",
)
import run_agent as _ra
vm_calls: list = []
# AIAgent.close() calls the ``cleanup_vm`` name bound into
# ``run_agent`` at import time, not ``tools.terminal_tool.cleanup_vm``

View file

@ -266,33 +266,33 @@ class TestFindAllSkillsFiltering:
skills = _find_all_skills()
assert not any(s["name"] == "my-skill" for s in skills)
@patch("agent.skill_utils.iter_skill_index_files")
@patch("tools.skills_tool._get_disabled_skill_names", return_value=set())
@patch("tools.skills_tool.skill_matches_platform", return_value=True)
def test_enabled_skill_included(self, mock_platform, mock_disabled, tmp_path, monkeypatch):
@patch("tools.skills_tool.SKILLS_DIR")
def test_enabled_skill_included(self, mock_dir, mock_platform, mock_disabled, mock_iter, tmp_path):
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent")
import tools.skills_tool as _st
import agent.skill_utils as _su
monkeypatch.setattr(_st, "SKILLS_DIR", tmp_path)
monkeypatch.setattr(_su, "get_external_skills_dirs", lambda: [])
mock_dir.exists.return_value = True
mock_iter.return_value = [skill_md]
from tools.skills_tool import _find_all_skills
skills = _find_all_skills()
assert any(s["name"] == "my-skill" for s in skills)
@patch("agent.skill_utils.iter_skill_index_files")
@patch("tools.skills_tool._get_disabled_skill_names", return_value={"my-skill"})
@patch("tools.skills_tool.skill_matches_platform", return_value=True)
def test_skip_disabled_returns_all(self, mock_platform, mock_disabled, tmp_path, monkeypatch):
@patch("tools.skills_tool.SKILLS_DIR")
def test_skip_disabled_returns_all(self, mock_dir, mock_platform, mock_disabled, mock_iter, tmp_path):
"""skip_disabled=True ignores the disabled set (for config UI)."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent")
import tools.skills_tool as _st
import agent.skill_utils as _su
monkeypatch.setattr(_st, "SKILLS_DIR", tmp_path)
monkeypatch.setattr(_su, "get_external_skills_dirs", lambda: [])
mock_dir.exists.return_value = True
mock_iter.return_value = [skill_md]
from tools.skills_tool import _find_all_skills
skills = _find_all_skills(skip_disabled=True)
assert any(s["name"] == "my-skill" for s in skills)

View file

@ -217,8 +217,14 @@ class FileToolsIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
file_state.get_registry().clear()
self._tmpdir = tempfile.mkdtemp(prefix="hermes_file_state_int_")
self._orig_terminal_env = os.environ.get("TERMINAL_ENV")
os.environ["TERMINAL_ENV"] = "local"
def tearDown(self) -> None:
if self._orig_terminal_env is None:
os.environ.pop("TERMINAL_ENV", None)
else:
os.environ["TERMINAL_ENV"] = self._orig_terminal_env
import shutil
shutil.rmtree(self._tmpdir, ignore_errors=True)
file_state.get_registry().clear()

View file

@ -13,6 +13,7 @@ import os
import sys
from pathlib import Path
import pytest
from unittest.mock import patch
# Ensure repo root is importable
_repo_root = Path(__file__).resolve().parent.parent.parent
@ -33,6 +34,7 @@ except ImportError:
class TestToolResolution:
"""Verify get_tool_definitions returns all expected tools for eval."""
@patch.dict("os.environ", {"TERMINAL_ENV": "local"})
def test_terminal_and_file_toolsets_resolve_all_tools(self):
"""enabled_toolsets=['terminal', 'file'] should produce 6 tools."""
from model_tools import get_tool_definitions
@ -44,6 +46,7 @@ class TestToolResolution:
expected = {"terminal", "process", "read_file", "write_file", "search_files", "patch"}
assert expected == names, f"Expected {expected}, got {names}"
@patch.dict("os.environ", {"TERMINAL_ENV": "local"})
def test_terminal_tool_present(self):
"""The terminal tool must be present (not silently dropped)."""
from model_tools import get_tool_definitions

View file

@ -157,6 +157,10 @@ def _check_sensitive_path(filepath: str, task_id: str = "default") -> str | None
except (OSError, ValueError):
resolved = filepath
normalized = os.path.normpath(os.path.expanduser(filepath))
# /private/var/folders is the macOS per-user temporary directory tree; it is
# safe to write and must not be treated as a sensitive system path.
if resolved.startswith("/private/var/folders/"):
return None
_err = (
f"Refusing to write to sensitive system path: {filepath}\n"
"Use the terminal tool with sudo if you need to modify system files."
@ -428,7 +432,7 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
with _read_tracker_lock:
task_data = _read_tracker.setdefault(task_id, {
"last_key": None, "consecutive": 0,
"read_history": set(), "dedup": {},
"read_history": set(), "dedup": {}, "read_timestamps": {},
})
cached_mtime = task_data.get("dedup", {}).get(dedup_key)

View file

@ -2988,8 +2988,9 @@ def _kill_orphaned_mcp_children() -> None:
except (ProcessLookupError, PermissionError, OSError):
pass
# Phase 2: Wait for graceful exit
_time.sleep(2)
# Phase 2: Wait for graceful exit (skip if nothing was signalled)
if pids:
_time.sleep(2)
# Phase 3: SIGKILL any survivors
_sigkill = getattr(_signal, "SIGKILL", _signal.SIGTERM)