diff --git a/cron/scheduler_prompt.py b/cron/scheduler_prompt.py index 0a04263351..a2f57e35e4 100644 --- a/cron/scheduler_prompt.py +++ b/cron/scheduler_prompt.py @@ -277,7 +277,8 @@ def _scan_assembled_cron_prompt( Since cron runs non-interactively (auto-approves tool calls), a malicious skill carrying an injection payload bypassed every gate. See #3968. """ - from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled + from tools.cronjob_tools import _scan_cron_prompt + from tools.cronjob_prompt_scan import _scan_cron_skill_assembled if has_skills or has_injected_data: # The cleaned (sanitized) prompt is what actually runs. assembled, scan_error = _scan_cron_skill_assembled(assembled) diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 5c1adfef0e..fc356f608c 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -1458,7 +1458,8 @@ class GatewayNotificationsMixin: (``display.background_process_notifications``): concise (default one-liner; failures append the output tail) / all (running updates + final raw) / result (final raw) / error (final raw if exit != 0) / off.""" - from tools.process_registry import format_process_notification, process_registry + from tools.process_registry import process_registry + from tools.process_registry_notifications import format_process_notification session_id = watcher["session_id"] interval = watcher["check_interval"] platform_name = watcher.get("platform", "") diff --git a/tests/agent/test_context_compressor_zero_user_provenance.py b/tests/agent/test_context_compressor_zero_user_provenance.py index 7a5bd12568..5e600ea703 100644 --- a/tests/agent/test_context_compressor_zero_user_provenance.py +++ b/tests/agent/test_context_compressor_zero_user_provenance.py @@ -21,7 +21,7 @@ from agent.conversation_compression import ( compress_context, ) from hermes_state import SessionDB -from tools.process_registry import format_process_notification +from tools.process_registry_notifications import format_process_notification from tools.todo_tool import TODO_INJECTION_HEADER diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 5329c39d28..52a6e82f69 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -14,7 +14,8 @@ from gateway.platforms.base import SendResult from gateway.platforms.base import MessageEvent, MessageType from gateway.platforms import weixin from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter -from tools.send_message_tool import _parse_target_ref, _send_to_platform +from tools.send_message_tool import _send_to_platform +from tools.send_message_targets import _parse_target_ref def _make_adapter() -> WeixinAdapter: diff --git a/tests/tools/test_accretion_caps.py b/tests/tools/test_accretion_caps.py index 73e3fc77fe..f82a6d3096 100644 --- a/tests/tools/test_accretion_caps.py +++ b/tests/tools/test_accretion_caps.py @@ -4,7 +4,7 @@ Both structures are process-lifetime singletons that previously grew unbounded in long-running CLI / gateway sessions: - file_tools._read_tracker[task_id] + file_tools_read_tracking._read_tracker[task_id] ├─ read_history (set) — one entry per unique (path, offset, limit) ├─ dedup (dict) — one entry per unique (path, offset, limit) └─ read_timestamps (dict) — one entry per unique resolved path @@ -21,11 +21,11 @@ These tests pin the new caps + prune hooks. class TestReadTrackerCaps: def setup_method(self): - from tools import file_tools + from tools import file_tools_read_tracking as rt # Clean slate per test. - with file_tools._read_tracker_lock: - file_tools._read_tracker.clear() + with rt._read_tracker_lock: + rt._read_tracker.clear() def test_read_history_capped(self, monkeypatch): """read_history set is bounded by _READ_HISTORY_CAP.""" @@ -40,7 +40,7 @@ class TestReadTrackerCaps: "dedup": {}, "read_timestamps": {}, } - ft._cap_read_tracker_data(task_data) + rt._cap_read_tracker_data(task_data) assert len(task_data["read_history"]) == 10 @@ -59,8 +59,8 @@ class TestReadTrackerCaps: p.write_text(f"content {i}\n" * 10) ft.read_file_tool(path=str(p), task_id="long-session") - with ft._read_tracker_lock: - td = ft._read_tracker["long-session"] + with rt._read_tracker_lock: + td = rt._read_tracker["long-session"] assert len(td["read_history"]) <= 3 assert len(td["dedup"]) <= 3 # read_timestamps is populated lazily (via setdefault) only diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index edb150e794..5fc37773f1 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -17,7 +17,8 @@ import time import pytest from tools import async_delegation as ad -from tools.process_registry import process_registry, format_process_notification +from tools.process_registry import process_registry +from tools.process_registry_notifications import format_process_notification @pytest.fixture(autouse=True) diff --git a/tests/tools/test_binary_document_write_guard.py b/tests/tools/test_binary_document_write_guard.py index dff0cefd3a..2db45db70e 100644 --- a/tests/tools/test_binary_document_write_guard.py +++ b/tests/tools/test_binary_document_write_guard.py @@ -14,11 +14,8 @@ from tools.binary_extensions import ( has_opaque_document_extension, is_pdf_path, ) -from tools.file_tools import ( - _check_binary_document_write, - patch_tool, - write_file_tool, -) +from tools.file_tools import patch_tool, write_file_tool +from tools.file_tools_write_guards import _check_binary_document_write def _make_minimal_docx(path: Path) -> None: diff --git a/tests/tools/test_cron_prompt_injection.py b/tests/tools/test_cron_prompt_injection.py index ff200b71ae..ca599d8fa5 100644 --- a/tests/tools/test_cron_prompt_injection.py +++ b/tests/tools/test_cron_prompt_injection.py @@ -42,7 +42,7 @@ class TestInvisibleUnicodeParity: def test_cron_set_matches_canonical(self): """Invariant: the cron-local set IS the canonical install-time set.""" - from tools.cronjob_tools import _CRON_INVISIBLE_CHARS + from tools.cronjob_prompt_scan import _CRON_INVISIBLE_CHARS from tools.threat_patterns import INVISIBLE_CHARS assert _CRON_INVISIBLE_CHARS == INVISIBLE_CHARS diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 12aa49c80a..5e57014657 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -105,7 +105,7 @@ class TestScanCronPrompt: # Skill-assembled cron prompt scanning (looser pattern set) # ========================================================================= -from tools.cronjob_tools import _scan_cron_skill_assembled # noqa: E402 +from tools.cronjob_prompt_scan import _scan_cron_skill_assembled # noqa: E402 class TestScanCronSkillAssembled: diff --git a/tests/tools/test_delegate_control_actions.py b/tests/tools/test_delegate_control_actions.py index 318529decc..6f9553f5dc 100644 --- a/tests/tools/test_delegate_control_actions.py +++ b/tests/tools/test_delegate_control_actions.py @@ -691,7 +691,8 @@ def test_attribution_line_uses_owner_task_id(monkeypatch): """format_process_notification resolves attribution from owner_task_id when task_id is a collapsed container key (surface flag on).""" import hermes_cli.config as _cfg - from tools.process_registry import ProcessRegistry, format_process_notification + from tools.process_registry import ProcessRegistry + from tools.process_registry_notifications import format_process_notification monkeypatch.setattr( _cfg, @@ -719,7 +720,7 @@ def test_attribution_line_uses_owner_task_id(monkeypatch): def test_completion_notification_trims_subagent_output_wall(): - from tools.process_registry import format_process_notification + from tools.process_registry_notifications import format_process_notification parent = _StubParentWithSession("sess-attr-4") child = _StubChild(parent) @@ -745,7 +746,7 @@ def test_completion_notification_trims_subagent_output_wall(): def test_parent_owned_process_notification_unchanged(): """Processes NOT started by a subagent keep the exact legacy shape.""" - from tools.process_registry import format_process_notification + from tools.process_registry_notifications import format_process_notification text = format_process_notification( { diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py index ea7f8e56eb..81e19832d1 100644 --- a/tests/tools/test_file_read_guards.py +++ b/tests/tools/test_file_read_guards.py @@ -18,10 +18,10 @@ from tools.file_tools import ( read_file_tool, write_file_tool, _is_blocked_device, - _READ_DEDUP_STATUS_MESSAGE, _DEFAULT_MAX_READ_CHARS, - _read_tracker, ) +from tools.file_tools_write_guards import _READ_DEDUP_STATUS_MESSAGE +from tools.file_tools_read_tracking import _read_tracker from tools.file_tools_read_tracking import ( _invalidate_dedup_for_path, notify_other_tool_call, diff --git a/tests/tools/test_file_staleness.py b/tests/tools/test_file_staleness.py index 66e7b608a7..afe0c1d724 100644 --- a/tests/tools/test_file_staleness.py +++ b/tests/tools/test_file_staleness.py @@ -17,13 +17,8 @@ from types import SimpleNamespace from unittest.mock import patch, MagicMock from tools import file_state -from tools.file_tools import ( - read_file_tool, - write_file_tool, - patch_tool, - _check_file_staleness, - _read_tracker, -) +from tools.file_tools import read_file_tool, write_file_tool, patch_tool +from tools.file_tools_read_tracking import _check_file_staleness, _read_tracker # --------------------------------------------------------------------------- @@ -218,7 +213,7 @@ class TestCheckFileStalenessHelper(unittest.TestCase): def test_returns_none_when_stat_fails(self): - from tools.file_tools import _read_tracker, _read_tracker_lock + from tools.file_tools_read_tracking import _read_tracker, _read_tracker_lock with _read_tracker_lock: _read_tracker["t1"] = { "last_key": None, "consecutive": 0, diff --git a/tests/tools/test_file_state_registry.py b/tests/tools/test_file_state_registry.py index adc11dd67f..29a1945f9c 100644 --- a/tests/tools/test_file_state_registry.py +++ b/tests/tools/test_file_state_registry.py @@ -161,16 +161,16 @@ class FileStateRegistryUnitTests(unittest.TestCase): task_id = "finished-task" file_state.record_read(task_id, p) - from tools import file_tools + from tools import file_tools_read_tracking as rt - file_tools._read_tracker[task_id] = {"dedup": {}} - file_tools._patch_failure_tracker[task_id] = {p: 2} + rt._read_tracker[task_id] = {"dedup": {}} + rt._patch_failure_tracker[task_id] = {p: 2} clear_file_ops_cache(task_id) self.assertEqual(file_state.known_reads(task_id), []) - self.assertNotIn(task_id, file_tools._read_tracker) - self.assertNotIn(task_id, file_tools._patch_failure_tracker) + self.assertNotIn(task_id, rt._read_tracker) + self.assertNotIn(task_id, rt._patch_failure_tracker) def test_kill_switch_env_var(self): diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index bd5ac0ffc9..762b6d7fda 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -387,7 +387,7 @@ class TestSearchHints: def setup_method(self): """Clear read/search tracker between tests to avoid cross-test state.""" - from tools.file_tools import _read_tracker + from tools.file_tools_read_tracking import _read_tracker _read_tracker.clear() @patch("tools.file_tools._get_file_ops") @@ -467,7 +467,7 @@ class TestSensitivePathCheck: def test_macos_private_var_carveouts(self): """macOS temp dirs under /private/var must not be blanket-blocked, while the genuinely-sensitive /private/var subtrees still are.""" - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path # $TMPDIR / /tmp / /var/folders realpath into these on macOS. assert _check_sensitive_path("/private/var/folders/xy/T/tmp.txt") is None @@ -718,22 +718,24 @@ class TestDedupInvalidationTaskResolution: # The task resolves the relative path into the workspace; the default # task (the old buggy resolution) would resolve into proc. - correct = str(ft._resolve_path_for_task("data.txt", task_id)) - buggy = str(ft._resolve_path_for_task("data.txt")) + from tools.file_tools_paths import _resolve_path_for_task + from tools.file_tools_read_tracking import _read_tracker + correct = str(_resolve_path_for_task("data.txt", task_id)) + buggy = str(_resolve_path_for_task("data.txt")) assert correct != buggy, "test precondition: cwds must diverge" # Populate the dedup cache via a real read. ft.read_file_tool("data.txt", task_id=task_id) - keys = [k[0] for k in ft._read_tracker.get(task_id, {}).get("dedup", {})] + keys = [k[0] for k in _read_tracker.get(task_id, {}).get("dedup", {})] assert correct in keys, keys # Invalidate as write_file_tool does; the entry must be gone. from tools.file_tools_read_tracking import _invalidate_dedup_for_path _invalidate_dedup_for_path("data.txt", task_id) - remaining = [k[0] for k in ft._read_tracker.get(task_id, {}).get("dedup", {})] + remaining = [k[0] for k in _read_tracker.get(task_id, {}).get("dedup", {})] assert correct not in remaining, remaining - ft._read_tracker.pop(task_id, None) + _read_tracker.pop(task_id, None) # --------------------------------------------------------------------------- @@ -758,7 +760,8 @@ class TestNotFoundCache: mock_ops.read_file.return_value = result_obj mock_get.return_value = mock_ops - from tools.file_tools import read_file_tool, _read_tracker + from tools.file_tools import read_file_tool + from tools.file_tools_read_tracking import _read_tracker # Use a unique task_id so we don't collide with other tests. tid = "neg-cache-read-1" _read_tracker.pop(tid, None) @@ -786,7 +789,8 @@ class TestNotFoundCache: mock_ops.read_file.return_value = result_obj mock_get.return_value = mock_ops - from tools.file_tools import read_file_tool, _read_tracker + from tools.file_tools import read_file_tool + from tools.file_tools_read_tracking import _read_tracker for tid in ("neg-cache-iso-A", "neg-cache-iso-B"): _read_tracker.pop(tid, None) @@ -805,7 +809,8 @@ class TestNotFoundCache: mock_ops.read_file.return_value = result_obj mock_get.return_value = mock_ops - from tools.file_tools import read_file_tool, _read_tracker + from tools.file_tools import read_file_tool + from tools.file_tools_read_tracking import _read_tracker tid = "neg-cache-success-only" _read_tracker.pop(tid, None) @@ -827,7 +832,8 @@ class TestNotFoundCache: mock_ops.search.return_value = result_obj mock_get.return_value = mock_ops - from tools.file_tools import search_tool, _read_tracker + from tools.file_tools import search_tool + from tools.file_tools_read_tracking import _read_tracker tid = "neg-cache-search-3" _read_tracker.pop(tid, None) @@ -863,7 +869,8 @@ class TestNotFoundCache: mock_get.return_value = mock_ops - from tools.file_tools import read_file_tool, search_tool, _read_tracker + from tools.file_tools import read_file_tool, search_tool + from tools.file_tools_read_tracking import _read_tracker tid = "neg-cache-namespace-4" _read_tracker.pop(tid, None) @@ -895,7 +902,8 @@ class TestNotFoundCache: mock_ops.write_file.return_value = write_result_obj mock_get.return_value = mock_ops - from tools.file_tools import read_file_tool, write_file_tool, _read_tracker + from tools.file_tools import read_file_tool, write_file_tool + from tools.file_tools_read_tracking import _read_tracker tid = "neg-cache-write-invalidate-5" _read_tracker.pop(tid, None) @@ -913,13 +921,9 @@ class TestNotFoundCache: def test_not_found_ttl_expires(self): # A cache entry older than _NOT_FOUND_TTL_SECONDS must be discarded. - from tools.file_tools import ( - _check_not_found_cache, - _record_not_found, - _read_tracker, - ) - from tools.file_tools_read_tracking import _NOT_FOUND_TTL_SECONDS - import tools.file_tools as ft + from tools.file_tools_read_tracking import ( + _NOT_FOUND_TTL_SECONDS, _check_not_found_cache, _read_tracker, _read_tracker_lock, + _record_not_found) tid = "neg-cache-ttl-6" _read_tracker.pop(tid, None) @@ -928,15 +932,15 @@ class TestNotFoundCache: assert _check_not_found_cache("read", "/tmp/ttl-test", tid) is not None # Backdate the entry past the TTL. - with ft._read_tracker_lock: + with _read_tracker_lock: entry = _read_tracker[tid]["not_found"][("read", "/tmp/ttl-test")] - ft._read_tracker[tid]["not_found"][("read", "/tmp/ttl-test")] = ( + _read_tracker[tid]["not_found"][("read", "/tmp/ttl-test")] = ( entry[0] - _NOT_FOUND_TTL_SECONDS - 1.0, entry[1], ) # Stale entry: cache miss, also evicted. assert _check_not_found_cache("read", "/tmp/ttl-test", tid) is None - with ft._read_tracker_lock: + with _read_tracker_lock: assert ("read", "/tmp/ttl-test") not in _read_tracker[tid].get("not_found", {}) def test_out_of_band_creation_defeats_cached_miss(self, tmp_path): @@ -944,11 +948,7 @@ class TestNotFoundCache: by a terminal command or any external process, NOT write_file_tool — must be served for real on the next read. The agent pattern 'check for file → create it → read it' breaks otherwise.""" - from tools.file_tools import ( - _check_not_found_cache, - _record_not_found, - _read_tracker, - ) + from tools.file_tools_read_tracking import _check_not_found_cache, _record_not_found, _read_tracker tid = "neg-cache-oob-read" _read_tracker.pop(tid, None) @@ -972,11 +972,7 @@ class TestNotFoundCache: def test_out_of_band_creation_defeats_cached_search_miss(self, tmp_path): """Same contract for search roots: creating a file under a previously-missing directory must defeat the cached 'Path not found'.""" - from tools.file_tools import ( - _check_not_found_cache, - _record_not_found, - _read_tracker, - ) + from tools.file_tools_read_tracking import _check_not_found_cache, _record_not_found, _read_tracker tid = "neg-cache-oob-search" _read_tracker.pop(tid, None) @@ -995,11 +991,7 @@ class TestNotFoundCache: def test_notify_other_tool_call_clears_not_found(self): """Belt-and-suspenders: any non-read tool (terminal etc.) invalidates the task's negative cache via the dispatcher's notify hook.""" - from tools.file_tools import ( - _check_not_found_cache, - _record_not_found, - _read_tracker, - ) + from tools.file_tools_read_tracking import _check_not_found_cache, _record_not_found, _read_tracker from tools.file_tools_read_tracking import notify_other_tool_call tid = "neg-cache-notify" diff --git a/tests/tools/test_file_tools_cwd_resolution.py b/tests/tools/test_file_tools_cwd_resolution.py index 006cbecd8a..b91cc5e929 100644 --- a/tests/tools/test_file_tools_cwd_resolution.py +++ b/tests/tools/test_file_tools_cwd_resolution.py @@ -53,7 +53,7 @@ def test_relative_terminal_cwd_anchors_to_absolute_not_process_cwd(_isolated_cwd # Poison config: literal relative '.' monkeypatch.setenv("TERMINAL_CWD", ".") - resolved = ft._resolve_path_for_task("target.py", task_id="default") + resolved = ftp._resolve_path_for_task("target.py", task_id="default") assert resolved.is_absolute(), f"resolution base leaked a relative path: {resolved}" # The exact anchor for a bare '.' is the process cwd resolved to absolute — @@ -75,7 +75,7 @@ def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkey monkeypatch.setenv("TERMINAL_CWD", ".") terminal_tool.record_session_cwd("default", str(workspace)) - resolved = ft._resolve_path_for_task("target.py", task_id="default") + resolved = ftp._resolve_path_for_task("target.py", task_id="default") assert resolved == (workspace / "target.py") @@ -85,7 +85,7 @@ def test_absolute_terminal_cwd_used_verbatim(_isolated_cwd, monkeypatch): workspace, decoy = _isolated_cwd monkeypatch.setenv("TERMINAL_CWD", str(workspace)) - resolved = ft._resolve_path_for_task("target.py", task_id="default") + resolved = ftp._resolve_path_for_task("target.py", task_id="default") assert resolved == (workspace / "target.py") @@ -106,7 +106,7 @@ def test_container_absolute_input_path_does_not_follow_host_symlink(tmp_path, mo monkeypatch.setattr(terminal_tool, "_active_environments", {}) container_path = container_mount / "oilsands-sim" / "README.md" - resolved = ft._resolve_path_for_task(str(container_path), task_id="default") + resolved = ftp._resolve_path_for_task(str(container_path), task_id="default") assert resolved == container_path assert resolved != (host_project / "oilsands-sim" / "README.md") @@ -129,7 +129,7 @@ def test_container_relative_path_keeps_container_cwd_symlink(tmp_path, monkeypat monkeypatch.setattr(terminal_tool, "_active_environments", {}) terminal_tool.record_session_cwd("default", str(container_mount)) - resolved = ft._resolve_path_for_task("oilsands-sim/README.md", task_id="default") + resolved = ftp._resolve_path_for_task("oilsands-sim/README.md", task_id="default") assert resolved == container_mount / "oilsands-sim" / "README.md" assert resolved != host_project / "oilsands-sim" / "README.md" @@ -145,7 +145,7 @@ def test_resolution_base_always_absolute_no_terminal_cwd(_isolated_cwd, monkeypa workspace, decoy = _isolated_cwd monkeypatch.delenv("TERMINAL_CWD", raising=False) - resolved = ft._resolve_path_for_task("target.py", task_id="default") + resolved = ftp._resolve_path_for_task("target.py", task_id="default") assert resolved.is_absolute() assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve()) @@ -163,7 +163,7 @@ def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monke terminal_tool.record_session_cwd("default", str(workspace)) resolved_in_decoy = decoy / "target.py" - warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default") + warn = ftp._path_resolution_warning("target.py", resolved_in_decoy, task_id="default") assert warn is not None assert "OUTSIDE the active workspace" in warn @@ -194,9 +194,9 @@ def test_warning_fires_from_terminal_cwd_when_registry_empty(_isolated_cwd, monk # Relative path that escapes the worktree into the decoy/main checkout. escaping = os.path.relpath(str(decoy / "target.py"), str(workspace)) - resolved = ft._resolve_path_for_task(escaping, task_id="default") + resolved = ftp._resolve_path_for_task(escaping, task_id="default") - warn = ft._path_resolution_warning(escaping, resolved, task_id="default") + warn = ftp._path_resolution_warning(escaping, resolved, task_id="default") assert warn is not None assert "OUTSIDE the active workspace" in warn @@ -251,7 +251,7 @@ def test_unregistered_session_never_inherits_another_sessions_record( ): """Session C: no record, no override. Must NOT inherit A's or B's cwd.""" wt_a, wt_b, main = _two_worktree_sessions - resolved = ft._resolve_path_for_task("target.py", task_id="sess-c") + resolved = ftp._resolve_path_for_task("target.py", task_id="sess-c") assert not str(resolved).startswith(str(wt_a)) assert not str(resolved).startswith(str(wt_b)) assert resolved == (main / "target.py").resolve() diff --git a/tests/tools/test_file_tools_tilde_profile.py b/tests/tools/test_file_tools_tilde_profile.py index 23510b1f9a..f7cb64b18b 100644 --- a/tests/tools/test_file_tools_tilde_profile.py +++ b/tests/tools/test_file_tools_tilde_profile.py @@ -20,7 +20,7 @@ from unittest.mock import patch import pytest -import tools.file_tools as ft +import tools.file_tools_paths as ft import tools.terminal_tool as terminal_tool diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index 32fae33054..ad5c7f2e12 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -233,27 +233,27 @@ class TestCheckSensitivePathMacOSBypass: """Verify _check_sensitive_path blocks /private/etc paths (issue #8734).""" def test_etc_hosts_blocked(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/etc/hosts") is not None def test_private_etc_hosts_blocked(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/private/etc/hosts") is not None def test_private_etc_ssh_config_blocked(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/private/etc/ssh/sshd_config") is not None def test_private_var_blocked(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/private/var/db/something") is not None def test_boot_still_blocked(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/boot/grub/grub.cfg") is not None def test_safe_path_allowed(self): - from tools.file_tools import _check_sensitive_path + from tools.file_tools_write_guards import _check_sensitive_path assert _check_sensitive_path("/tmp/safe_file.txt") is None @@ -447,6 +447,7 @@ class TestProtectedInstructionFiles: def test_prompts_even_under_yolo(self, tmp_path, approvals, monkeypatch): """The whole point: auto-approve/yolo must NOT bypass this gate.""" import tools.approval as A + from tools import approval_context monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", True) target = tmp_path / "AGENTS.md" approvals["answer"] = "deny" @@ -638,8 +639,9 @@ class TestProtectedInstructionFiles: def test_gateway_notify_resolve_once_allows(self, tmp_path): import tools.approval as A + from tools import approval_context session_key = "protected-files-test-session" - token = A.set_current_session_key(session_key) + token = approval_context.set_current_session_key(session_key) try: def notify(approval_data): # Buttons must not offer persistent scopes for this gate. @@ -655,7 +657,7 @@ class TestProtectedInstructionFiles: finally: A.unregister_gateway_notify(session_key) finally: - A.reset_current_session_key(token) + approval_context.reset_current_session_key(token) def test_gateway_payload_renders_only_once_and_deny(self, tmp_path): """End-to-end: what this gate emits, a TUI/desktop client can render. @@ -666,10 +668,11 @@ class TestProtectedInstructionFiles: the two layers together is what catches that drift. """ import tools.approval as A + from tools import approval_context from tui_gateway.server import _approval_request_payload session_key = "protected-files-payload-session" - token = A.set_current_session_key(session_key) + token = approval_context.set_current_session_key(session_key) rendered = {} try: def notify(approval_data): @@ -682,7 +685,7 @@ class TestProtectedInstructionFiles: finally: A.unregister_gateway_notify(session_key) finally: - A.reset_current_session_key(token) + approval_context.reset_current_session_key(token) assert rendered["choices"] == ["once", "deny"] diff --git a/tests/tools/test_line_ending_preservation.py b/tests/tools/test_line_ending_preservation.py index 75281dd0d3..71bbcca4b4 100644 --- a/tests/tools/test_line_ending_preservation.py +++ b/tests/tools/test_line_ending_preservation.py @@ -32,7 +32,8 @@ def hermes_home(monkeypatch, tmp_path): # returns the stale cwd from this test's ops and breaks tests like # test_resolve_path that rely on TERMINAL_CWD env var. try: - from tools.file_tools import clear_file_ops_cache, _read_tracker_lock, _read_tracker + from tools.file_tools import clear_file_ops_cache + from tools.file_tools_read_tracking import _read_tracker_lock, _read_tracker clear_file_ops_cache() with _read_tracker_lock: _read_tracker.clear() diff --git a/tests/tools/test_media_caption_split.py b/tests/tools/test_media_caption_split.py index 89501b41a5..21fc82185a 100644 --- a/tests/tools/test_media_caption_split.py +++ b/tests/tools/test_media_caption_split.py @@ -11,11 +11,8 @@ ride on the media bubble as a native caption. This test pins that contract so the platforms can't diverge. """ -from tools.send_message_tool import ( - _DEFAULT_CAPTION_LIMIT, - _TELEGRAM_CAPTION_LIMIT, - _media_caption_split, -) +from tools.send_message_tool import _DEFAULT_CAPTION_LIMIT, _media_caption_split +from tools.send_message_senders import _TELEGRAM_CAPTION_LIMIT def test_single_image_short_text_becomes_caption(): diff --git a/tests/tools/test_patch_failure_tracking.py b/tests/tools/test_patch_failure_tracking.py index 30e8f54553..a8848adf92 100644 --- a/tests/tools/test_patch_failure_tracking.py +++ b/tests/tools/test_patch_failure_tracking.py @@ -24,7 +24,8 @@ def hermes_home(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(home)) yield home try: - from tools.file_tools import clear_file_ops_cache, _read_tracker_lock, _read_tracker + from tools.file_tools import clear_file_ops_cache + from tools.file_tools_read_tracking import _read_tracker_lock, _read_tracker clear_file_ops_cache() with _read_tracker_lock: _read_tracker.clear() @@ -42,7 +43,7 @@ def hermes_home(monkeypatch, tmp_path): def fresh_tracker(): """Reset the module-level tracker before each test so the count starts at zero regardless of prior test order.""" - from tools.file_tools import _patch_failure_tracker, _patch_failure_lock + from tools.file_tools_read_tracking import _patch_failure_tracker, _patch_failure_lock with _patch_failure_lock: _patch_failure_tracker.clear() diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 83e329455a..2c09bc1413 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -1284,7 +1284,7 @@ class TestProcessToolHandler: # format_process_notification + drain_notifications (shared helpers) # ========================================================================= -from tools.process_registry import format_process_notification +from tools.process_registry_notifications import format_process_notification def test_drain_notifications_completion_callback_exception_fails_closed(registry): @@ -2687,7 +2687,7 @@ def _patch_delegation_config( def _format_async(evt) -> str: - from tools.process_registry import format_process_notification + from tools.process_registry_notifications import format_process_notification text = format_process_notification(evt) assert text is not None, "format_process_notification returned None" diff --git a/tests/tools/test_read_loop_detection.py b/tests/tools/test_read_loop_detection.py index 22ea380bb6..3e0451c937 100644 --- a/tests/tools/test_read_loop_detection.py +++ b/tests/tools/test_read_loop_detection.py @@ -19,11 +19,8 @@ import json import unittest from unittest.mock import patch, MagicMock -from tools.file_tools import ( - read_file_tool, - search_tool, - _read_tracker, -) +from tools.file_tools import read_file_tool, search_tool +from tools.file_tools_read_tracking import _read_tracker from tools.file_tools_read_tracking import notify_other_tool_call diff --git a/tests/tools/test_send_message_target_parse.py b/tests/tools/test_send_message_target_parse.py index 1d629ae1df..8b92ce2bf0 100644 --- a/tests/tools/test_send_message_target_parse.py +++ b/tests/tools/test_send_message_target_parse.py @@ -10,7 +10,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, patch from gateway.config import Platform -from tools.send_message_tool import _parse_target_ref, _send_to_platform, send_message_tool +from tools.send_message_tool import _send_to_platform, send_message_tool +from tools.send_message_targets import _parse_target_ref def _run_async_immediately(coro): diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 0632fb3acb..6da8895f6e 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -27,7 +27,6 @@ def _reset_signal_scheduler(): from gateway.config import Platform from tools.send_message_tool import ( - _parse_target_ref, _resolve_slack_user_target, _send_matrix_via_adapter, _send_signal, @@ -35,6 +34,7 @@ from tools.send_message_tool import ( _send_to_platform, send_message_tool, ) +from tools.send_message_targets import _parse_target_ref # Discord helpers moved to the plugin in #24325. Import from the new path # and provide a thin ``_send_discord(token, ...)`` shim that mirrors the # pre-migration signature so the existing test bodies keep working. diff --git a/tests/tools/test_session_cwd_store.py b/tests/tools/test_session_cwd_store.py index 8a72c5c86e..e30abfa954 100644 --- a/tests/tools/test_session_cwd_store.py +++ b/tests/tools/test_session_cwd_store.py @@ -100,6 +100,7 @@ class TestFileToolsReadTheRecord: def test_two_sessions_resolve_into_their_own_recorded_cwds(self, tmp_path, monkeypatch): import tools.file_tools as ft + import tools.file_tools_paths as ftp wt_a = tmp_path / "wt_a" wt_b = tmp_path / "wt_b" @@ -115,13 +116,14 @@ class TestFileToolsReadTheRecord: tt.record_session_cwd("sess-a", str(wt_a)) tt.record_session_cwd("sess-b", str(wt_b)) - assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py") - assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (wt_b / "f.py") + assert ftp._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py") + assert ftp._resolve_path_for_task("f.py", task_id="sess-b") == (wt_b / "f.py") def test_record_beats_foreign_env_cwd_without_ownership_metadata(self, tmp_path, monkeypatch): """The leak-A scenario, solved structurally: the shared env's cwd is never consulted for path resolution — only the session's own record.""" import tools.file_tools as ft + import tools.file_tools_paths as ftp wt_a = tmp_path / "wt_a" wt_b = tmp_path / "wt_b" @@ -137,7 +139,7 @@ class TestFileToolsReadTheRecord: monkeypatch.setattr(tt, "_active_environments", {"default": _Env()}) tt.record_session_cwd("sess-a", str(wt_a)) - resolved = ft._resolve_path_for_task("f.py", task_id="sess-a") + resolved = ftp._resolve_path_for_task("f.py", task_id="sess-a") assert resolved == (wt_a / "f.py") assert not str(resolved).startswith(str(wt_b)) @@ -166,6 +168,7 @@ class TestReapedEnvFallbackIsFillOnly: def _reap(self, monkeypatch, tmp_path, task_id, stale_cwd): import tools.file_tools as ft + import tools.file_tools_paths as ftp class _StaleFileOps: cwd = stale_cwd diff --git a/tests/tools/test_watch_patterns.py b/tests/tools/test_watch_patterns.py index 3d07a7b6ad..7884963069 100644 --- a/tests/tools/test_watch_patterns.py +++ b/tests/tools/test_watch_patterns.py @@ -445,7 +445,7 @@ class TestOverflowNotificationFormatting: to the completion formatter as a phantom 'process exited (exit code ?)'.""" def test_overflow_tripped_formats_message(self): - from tools.process_registry import format_process_notification + from tools.process_registry_notifications import format_process_notification evt = { "type": "watch_overflow_tripped", @@ -457,7 +457,7 @@ class TestOverflowNotificationFormatting: assert "exit code" not in out def test_overflow_released_formats_message(self): - from tools.process_registry import format_process_notification + from tools.process_registry_notifications import format_process_notification evt = { "type": "watch_overflow_released", diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3350e56799..685bf9ad95 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -41,11 +41,8 @@ from cron.jobs import ( resolve_job_ref, resume_job, update_job) -from tools.cronjob_prompt_scan import ( # noqa: F401 (re-exported; tests/scheduler import via this module) - _CRON_INVISIBLE_CHARS, - _scan_cron_prompt, - _scan_cron_skill_assembled) -from tools.cronjob_job_args import ( # noqa: F401 (re-exported; tests/scheduler import via this module) +from tools.cronjob_prompt_scan import _scan_cron_prompt +from tools.cronjob_job_args import ( _apply_continuity, _canonical_skills, _clean_str_list, @@ -379,7 +376,7 @@ def _background_session_key(session_id: Optional[str]) -> str: """Routing key for a detached completion, captured on THIS thread (contextvars don't cross the pool). Empty string = no durable consumer.""" try: - from tools.approval import get_current_session_key + from tools.approval_context import get_current_session_key session_key = get_current_session_key(default="") except Exception: session_key = "" diff --git a/tools/file_tools_read_tracking.py b/tools/file_tools_read_tracking.py index 92063efe7b..46a66e7f1a 100644 --- a/tools/file_tools_read_tracking.py +++ b/tools/file_tools_read_tracking.py @@ -1,7 +1,7 @@ """Per-task read/search bookkeeping for the file tools. -Process-lifetime state behind read_file/search_files/write_file/patch; -``tools.file_tools`` re-imports every name here. Per task_id ``_read_tracker`` +Process-lifetime state behind read_file/search_files/write_file/patch. +Per task_id ``_read_tracker`` stores: ``last_key``/``consecutive`` (loop detection; reset by any OTHER tool call), ``read_history`` (diagnostics), ``dedup`` (key -> mtime; survives context compression), ``dedup_generation_reads`` (keys whose full content was served since diff --git a/tools/file_tools_write_guards.py b/tools/file_tools_write_guards.py index 39b61e7632..a7b63431c7 100644 --- a/tools/file_tools_write_guards.py +++ b/tools/file_tools_write_guards.py @@ -1,7 +1,7 @@ """Write-side safety guards for write_file / patch. Every guard returns ``None`` when the write may proceed, else an error string -the tool returns verbatim. ``tools.file_tools`` re-imports every name here. +the tool returns verbatim. Guards, in the order the tools apply them: ``_check_sensitive_path`` (hard deny), ``_check_binary_document_write``, ``_check_protected_instruction_write`` (ALWAYS ask), ``_check_approval_required_write`` (normal gate), @@ -198,12 +198,15 @@ def _request_protected_instruction_approval(reasons: list[str], task_id: str = " try: import tools.approval as _approval + from tools.approval_context import get_current_session_key + from tools.approval_gateway_wait import _await_gateway_decision + from tools.approval_prompt import prompt_dangerous_approval except Exception: return blocked.format(why=_APPROVAL_UNAVAILABLE) # Gateway surface: block on the button round-trip when a notify callback # is registered for this session. One-operation only — no scope buttons. - session_key = _approval.get_current_session_key() + session_key = get_current_session_key() try: with _approval._lock: notify_cb = _approval._gateway_notify_cbs.get(session_key) @@ -218,7 +221,7 @@ def _request_protected_instruction_approval(reasons: list[str], task_id: str = " "description": description, "allow_permanent": False, "allow_session": False} - decision = _approval._await_gateway_decision(session_key, notify_cb, approval_data, surface="gateway") + decision = _await_gateway_decision(session_key, notify_cb, approval_data, surface="gateway") if decision.get("notify_failed"): return blocked.format(why="requires approval but the approval request could not be delivered.") choice, timed = decision.get("choice"), not decision.get("resolved") @@ -233,7 +236,7 @@ def _request_protected_instruction_approval(reasons: list[str], task_id: str = " # No human channel (script, cron, background thread): fail closed — # auto-approving here would recreate the persistence vector. return blocked.format(why=_NO_HUMAN) - choice = _approval.prompt_dangerous_approval( + choice = prompt_dangerous_approval( display, description, allow_permanent=False, allow_session=False, approval_callback=callback) timed = choice == "timeout" # Any tapped scope is a one-operation grant; nothing is persisted. diff --git a/tools/process_registry.py b/tools/process_registry.py index b44998e182..50ec32044e 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -31,6 +31,7 @@ from typing import Any, Dict, List, Optional from hermes_cli.config import get_hermes_home from agent.redact import redact_sensitive_text +from tools.process_registry_notifications import format_process_notification logger = logging.getLogger(__name__) @@ -1968,16 +1969,6 @@ class ProcessRegistry: process_registry = ProcessRegistry() -# Notification rendering lives in tools.process_registry_notifications; re-exported so -# `from tools.process_registry import format_process_notification` and -# `patch("tools.process_registry._x")` keep resolving. -from tools.process_registry_notifications import ( # noqa: F401,E402 - _delegation_attribution_line, _delegation_config, _delegation_model_not_found, - _delegation_model_not_found_notice, _format_age, _format_async_delegation, - _model_not_found_patterns, format_process_notification, -) - - # --- the "process_manage" tool schema + handler ----------------------------------- from tools.registry import registry, tool_error @@ -2056,7 +2047,7 @@ def _list_processes(task_id) -> dict: session_key = "" with suppress(Exception): # See #29177. - from tools.approval import get_current_session_key + from tools.approval_context import get_current_session_key session_key = get_current_session_key(default="") or "" return {"processes": [ _redact_process_result(p) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 8a4ae6c8c0..0a1b08ce2d 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -11,15 +11,12 @@ from agent.secret_scope import get_secret logger = logging.getLogger(__name__) -# Re-exported: tests and sibling modules import these via tools.send_message_tool. -from tools.send_message_targets import ( # noqa: F401 - _HOME_CHANNEL_ENV_OVERRIDES, _SLACK_USER_ID_RE, _parse_target_ref, resolve_send_target) -from tools.send_message_senders import ( # noqa: F401 - _AUDIO_EXTS, _DEFAULT_CAPTION_LIMIT, _IMAGE_EXTS, _NO_DELIVERABLE, _TELEGRAM_CAPTION_LIMIT, - _VIDEO_EXTS, _VOICE_EXTS, _adapter_media_method, _error, _live_adapter, _media_caption_split, - _plugin_standalone_sender, _registry_standalone_send, _resolve_slack_user_target, _sanitize_error_text, - _send_bluebubbles, _send_matrix_via_adapter, _send_qqbot, _send_signal, _send_telegram, _send_weixin, - _send_yuanbao) +from tools.send_message_targets import _HOME_CHANNEL_ENV_OVERRIDES, _SLACK_USER_ID_RE, resolve_send_target +from tools.send_message_senders import ( + _AUDIO_EXTS, _DEFAULT_CAPTION_LIMIT, _IMAGE_EXTS, _NO_DELIVERABLE, _VIDEO_EXTS, _VOICE_EXTS, + _adapter_media_method, _error, _live_adapter, _media_caption_split, _plugin_standalone_sender, + _registry_standalone_send, _resolve_slack_user_target, _sanitize_error_text, _send_bluebubbles, + _send_matrix_via_adapter, _send_qqbot, _send_signal, _send_telegram, _send_weixin, _send_yuanbao) from tools.registry import tool_error # NOTE: ``send_message`` is intentionally NOT registered as an agent-callable model tool diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 10965da960..6dd31b2910 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -408,7 +408,8 @@ def _notification_poller_loop(stop_event: threading.Event, sid: str, session: di subscriptions and delivers terminal task events the same way (status.update + agent turn) — the delivery path tools/kanban_tools.py documents for platform="tui" rows (issue #59890). """ - from tools.process_registry import process_registry, format_process_notification + from tools.process_registry import process_registry + from tools.process_registry_notifications import format_process_notification queue = process_registry.completion_queue emitted: set = set() # dedup re-queued events so one completion isn't emitted 50 times while busy handle = lambda evt, deferred: _notif_handle_event( # noqa: E731