diff --git a/agent/file_safety.py b/agent/file_safety.py index 09da46caf..356277c14 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -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"), diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8c33a383e..c3c40d848 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/run_agent.py b/run_agent.py index 6770f568c..dd97f9f19 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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( diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index d4019e1d5..5406b5688 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -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`` diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 9742f0ac6..7a744a960 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -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) diff --git a/tests/tools/test_file_state_registry.py b/tests/tools/test_file_state_registry.py index 6038036ae..ff4698af6 100644 --- a/tests/tools/test_file_state_registry.py +++ b/tests/tools/test_file_state_registry.py @@ -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() diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 570ef5b21..223f43405 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -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 diff --git a/tools/file_tools.py b/tools/file_tools.py index 609506c05..cc60d1745 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -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) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 565dbfca0..44cc86be1 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -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)