From c5ff9007616a412ed409f471c29985b09ca0600c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:26:44 +0530 Subject: [PATCH] fix: resolve the 33 F821 undefined names outside tui_gateway / feishu / godmode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of `ruff check . --select F821 --target-version py311`: 2,234 hits. 2,201 are left alone on purpose: tui_gateway (2,169; bind_module rebinds bodies onto server.py globals, all names verified to resolve there), the Feishu adapter (27; globals().update() SDK binding) and the godmode script (5; dead standalone script). The other 33 were all genuine defects. No lint config change; no TYPE_CHECKING escape hatches — every annotation names a real, imported type; ty on the touched files: 0 new diagnostics. - gateway/slash_commands.py: HISTORY_UNREADABLE never imported after #102117 → NameError on the /btw error branch (same one-liner as #102952). - gateway/platforms/whatsapp_common.py: `-> Path` return annotation with no Path import (the body uses `_Path`). Never raised at runtime thanks to `from __future__ import annotations`, but `typing.get_type_hints()` and ty both fail on it. - gateway/run.py: ActivityProvenance imported at module level (agent.session_activity has no gateway deps); stringly annotation and the lazy in-function import are gone. - tools/patch_parser.py: PatchResult imported at module level; real return annotation. The "avoid circular import" lazy import guarded a cycle that does not exist (file_operations_common never imports patch_parser). - gateway/platforms/helpers.py: base.py imports helpers at module level, so MessageEvent cannot be named here; TextBatchAggregator only reads .text and .source, so it is typed by a BatchableEvent Protocol that MessageEvent satisfies structurally. - tools/mcp_tool_sampling.py: mcp_tool imports this module, so MCPServerTask cannot be named here; ElicitationHandler only reads owner._pending_call_context, typed by an ElicitationOwner Protocol. - plugins/platforms/sms/adapter.py: aiohttp is an optional dep ([messaging] extra) → module-level try/except ImportError binding `aiohttp = web = None`, the pattern the homeassistant / webhook / whatsapp_cloud adapters already use. Retires three lazy in-function imports and the `_aiohttp_available()` wrapper; `_handle_webhook` typed `web.Request -> web.Response`. - plugins/platforms/teams/summary_writer.py: plain module-level `import httpx` — httpx is a hard core dependency (pyproject `httpx[socks]==0.28.1`), so the lazy import and the "imported on every CLI start" docstring premise were both wrong (plugin discovery never imports this module; it is reached only via the Teams adapter / meeting pipeline). Tests: - tests/hermes_cli/test_config.py: a test body orphaned by the wave-1 prune (6b81590c55) sat inside the class as dead code with self/tmp_path unbound — header restored, so the v11→12 custom_providers migration is covered. - tests/tools/test_mcp_tool.py: @staticmethod recursing on `self` in the win32 branch; call portalocker directly. - tests/test_background_review_list_shapes.py: main() still ran 3 pruned tests. - tests/agent/test_cursor_optimizations_parity.py: bench() used names only imported inside a sibling test. - GatewayRunner / FeishuAdapter / Dict / Optional: missing imports. --- gateway/platforms/helpers.py | 15 ++++++-- gateway/platforms/whatsapp_common.py | 4 +-- gateway/run.py | 4 +-- gateway/slash_commands.py | 2 +- plugins/platforms/sms/adapter.py | 36 ++++++++----------- plugins/platforms/teams/summary_writer.py | 5 ++- ..._credential_pool_anthropic_refresh_race.py | 2 ++ .../agent/test_cursor_optimizations_parity.py | 2 ++ tests/e2e/conftest.py | 4 +++ tests/gateway/test_feishu.py | 5 ++- tests/gateway/test_telegram_audio_vs_voice.py | 5 +++ tests/gateway/test_video_context_note.py | 5 +++ tests/hermes_cli/test_config.py | 21 +++++++++++ tests/test_background_review_list_shapes.py | 3 -- tests/tools/test_computer_use.py | 2 +- tests/tools/test_mcp_tool.py | 2 +- tools/mcp_tool_sampling.py | 14 ++++++-- tools/patch_parser.py | 5 +-- 18 files changed, 95 insertions(+), 41 deletions(-) diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py index 32717f83c5..ae0cb72555 100644 --- a/gateway/platforms/helpers.py +++ b/gateway/platforms/helpers.py @@ -2,11 +2,14 @@ stripping, thread participation tracking, GFM table → bullets, mention-pattern compilation, and fence-aware markdown chunking.""" +from __future__ import annotations + import json import logging import re import time from pathlib import Path +from typing import Any, Protocol from utils import atomic_json_write @@ -522,6 +525,14 @@ from typing import TYPE_CHECKING # noqa: F401,E402 import asyncio # noqa: F401,E402 import asyncio # noqa: F401,E402 +class BatchableEvent(Protocol): + """What TextBatchAggregator needs from an inbound event. gateway.platforms.base.MessageEvent + satisfies it; base imports this module at import time, so the concrete class cannot be named here.""" + + text: str + source: Any + + class TextBatchAggregator: """Aggregates rapid-fire text events into single messages. @@ -554,14 +565,14 @@ class TextBatchAggregator: self._batch_delay = batch_delay self._split_delay = split_delay self._split_threshold = split_threshold - self._pending: Dict[str, "MessageEvent"] = {} + self._pending: Dict[str, BatchableEvent] = {} self._pending_tasks: Dict[str, asyncio.Task] = {} def is_enabled(self) -> bool: """Return True if batching is active (delay > 0).""" return self._batch_delay > 0 - def enqueue(self, event: "MessageEvent", key: str) -> None: + def enqueue(self, event: BatchableEvent, key: str) -> None: """Add *event* to the pending batch for *key*.""" chunk_len = len(event.text or "") existing = self._pending.get(key) diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index bd3c306280..9442805501 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -15,6 +15,7 @@ import json import logging import os import re +from pathlib import Path from typing import Any, Dict, Optional from gateway.platforms._shared import get_scoped_secret as _get_wsecret @@ -310,9 +311,8 @@ def resolve_whatsapp_bridge_dir() -> Path: """Bridge directory for CLI and adapter. A read-only install tree (e.g. Docker /opt/hermes) is mirrored to HERMES_HOME so npm install works.""" import shutil - from pathlib import Path as _Path from hermes_constants import get_hermes_home - install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" + install_bridge = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" hermes_home_bridge = get_hermes_home() / "scripts" / "whatsapp-bridge" try: (install_bridge / ".write_test").touch() diff --git a/gateway/run.py b/gateway/run.py index 1ccb18b541..0e2faabbd0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -39,6 +39,7 @@ from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX from agent.interrupt_compat import request_hard_interrupt from agent.turn_context import compression_made_progress from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get +from agent.session_activity import ActivityProvenance from hermes_cli.fallback_config import get_fallback_chain # Per-session AIAgent cache bounds (agents are heavy); see _enforce_agent_cache_cap/_session_housekeeping_watcher. @@ -941,7 +942,7 @@ def _float_env(name: str, default: float) -> float: def _stamp_hygiene_compression_provenance( - agent: Any, desc: str, provenance: "ActivityProvenance", debug_label: str) -> None: + agent: Any, desc: str, provenance: ActivityProvenance, debug_label: str) -> None: """Best-effort activity provenance stamp for hygiene compression transitions.""" try: agent._touch_activity(desc, provenance=provenance) @@ -4189,7 +4190,6 @@ class GatewayRunner( See #15654, #9051. """ if interrupt_depth == 0: - from agent.session_activity import ActivityProvenance agent._last_activity_ts = time.time() agent._last_activity_desc = "starting new turn (cached)" agent._last_activity_provenance = ActivityProvenance.UNKNOWN diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index bc7aaabb66..4673315d07 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -27,7 +27,7 @@ from gateway.session_transcript import TranscriptReadError from gateway.slash_commands_goals import GatewayGoalCommandsMixin from gateway.slash_commands_model import GatewayModelCommandsMixin from gateway.slash_commands_session import GatewaySessionCommandsMixin -from gateway.slash_commands_status import GatewayStatusCommandsMixin +from gateway.slash_commands_status import HISTORY_UNREADABLE, GatewayStatusCommandsMixin from hermes_cli.config import atomic_config_write, cfg_get from utils import atomic_json_write, is_truthy_value diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py index 93fb0b5ea2..c4ef780084 100644 --- a/plugins/platforms/sms/adapter.py +++ b/plugins/platforms/sms/adapter.py @@ -9,6 +9,8 @@ validation — required), SMS_INSECURE_NO_SIGNATURE (true disables validation SMS_ALLOWED_USERS (comma-separated E.164), SMS_ALLOW_ALL_USERS, SMS_HOME_CHANNEL (cron). """ +from __future__ import annotations + import asyncio import base64 import hashlib @@ -20,10 +22,18 @@ import urllib.parse from typing import Any, Dict, Optional from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult +from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendResult +from gateway.platforms.event import MessageEvent, MessageType from gateway.platforms.helpers import redact_phone, strip_markdown from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +try: + import aiohttp + from aiohttp import web +except ImportError: # optional ([messaging] extra) + aiohttp = None # type: ignore[assignment] + web = None # type: ignore[assignment] + logger = logging.getLogger(__name__) TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts" @@ -36,8 +46,6 @@ _EMPTY_TWIML = '' def _twiml_response(status: int = 200): """Empty TwiML reply — replies go out via the REST API, never inline TwiML.""" - from aiohttp import web - return web.Response(text=_EMPTY_TWIML, content_type="application/xml", status=status) @@ -54,8 +62,6 @@ def _messages_endpoint(account_sid: str, auth_token: str) -> tuple: def _twilio_form(from_number: str, to_number: str, body: str): """Twilio Messages.json form payload (aiohttp FormData).""" - import aiohttp - form_data = aiohttp.FormData() form_data.add_field("From", from_number) form_data.add_field("To", to_number) @@ -64,22 +70,12 @@ def _twilio_form(from_number: str, to_number: str, body: str): def _new_session(**kwargs): - import aiohttp - return aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **kwargs) -def _aiohttp_available() -> bool: - try: - import aiohttp # noqa: F401 - except ImportError: - return False - return True - - def check_sms_requirements() -> bool: """Check if SMS adapter dependencies are available.""" - return _aiohttp_available() and bool( + return aiohttp is not None and bool( _get_scoped_secret("TWILIO_ACCOUNT_SID") and _get_scoped_secret("TWILIO_AUTH_TOKEN")) @@ -97,13 +93,11 @@ class SmsAdapter(BasePlatformAdapter): self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip() self._runner = None - self._http_session: Optional["aiohttp.ClientSession"] = None + self._http_session: Optional[aiohttp.ClientSession] = None # -- Lifecycle ----------------------------------------------------------- async def connect(self, *, is_reconnect: bool = False) -> bool: - from aiohttp import web - insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true" fatal = None if not self._from_number: @@ -228,7 +222,7 @@ class SmsAdapter(BasePlatformAdapter): # -- Inbound webhook ----------------------------------------------------- - async def _handle_webhook(self, request) -> "aiohttp.web.Response": + async def _handle_webhook(self, request: web.Request) -> web.Response: try: content_length = request.content_length if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES: @@ -298,7 +292,7 @@ def _strip_markdown_for_sms(message: str) -> str: async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): """Out-of-process SMS delivery via the Twilio REST API (standalone_sender_fn contract).""" auth_token = getattr(pconfig, "api_key", None) or _get_scoped_secret("TWILIO_AUTH_TOKEN", "") - if not _aiohttp_available(): + if not aiohttp is not None: return {"error": "aiohttp not installed. Run: pip install aiohttp"} account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") from_number = os.getenv("TWILIO_PHONE_NUMBER", "") diff --git a/plugins/platforms/teams/summary_writer.py b/plugins/platforms/teams/summary_writer.py index ae43fe7fa1..bfeceda460 100644 --- a/plugins/platforms/teams/summary_writer.py +++ b/plugins/platforms/teams/summary_writer.py @@ -1,8 +1,6 @@ """Pipeline-facing Teams outbound delivery (meeting-summary writer). Lives inside the Teams platform plugin so the meeting pipeline reuses one Teams -integration surface. httpx is imported lazily: plugin discovery imports this -module on every CLI start, but only ``incoming_webhook`` delivery needs it. """ from __future__ import annotations @@ -15,6 +13,8 @@ from urllib.parse import quote from gateway.config import PlatformConfig from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +import httpx + def _parse_bool(value: Any, *, default: bool = False) -> bool: if isinstance(value, bool): @@ -90,7 +90,6 @@ class TeamsSummaryWriter: return merged async def _write_summary_via_incoming_webhook(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]: - import httpx # lazy — see module docstring webhook_url = str(config.get("incoming_webhook_url") or "").strip() if not webhook_url: raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.") diff --git a/tests/agent/test_credential_pool_anthropic_refresh_race.py b/tests/agent/test_credential_pool_anthropic_refresh_race.py index 4953a37fa3..58534a5002 100644 --- a/tests/agent/test_credential_pool_anthropic_refresh_race.py +++ b/tests/agent/test_credential_pool_anthropic_refresh_race.py @@ -30,6 +30,8 @@ Claude Code witness lives in ``test_anthropic_oauth_stress.py``. from __future__ import annotations +from typing import Dict + import threading import time from dataclasses import replace as dc_replace diff --git a/tests/agent/test_cursor_optimizations_parity.py b/tests/agent/test_cursor_optimizations_parity.py index fad6187f3d..839e566a3f 100644 --- a/tests/agent/test_cursor_optimizations_parity.py +++ b/tests/agent/test_cursor_optimizations_parity.py @@ -231,6 +231,8 @@ def bench(): # persist scan: fully-flushed list, old full walk vs bounded skip import run_agent as ra + from agent.context_compressor import _DB_PERSISTED_MARKER + from agent.session_persistence import _is_ephemeral_scaffolding flushed = copy.deepcopy(msgs) for m in flushed: if isinstance(m, dict): diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 682054f842..f8585878dc 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -12,12 +12,16 @@ No LLM, no real platform connections. import asyncio import sys import uuid +from typing import TYPE_CHECKING from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +if TYPE_CHECKING: + from gateway.run import GatewayRunner + from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent, SendResult from gateway.session import SessionEntry, SessionSource, build_session_key diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 2d1783e051..f5f94c2fb7 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -10,11 +10,14 @@ import unittest from collections import OrderedDict from pathlib import Path from types import SimpleNamespace -from typing import Dict +from typing import TYPE_CHECKING, Dict from unittest.mock import AsyncMock, Mock, patch from gateway.platforms.base import ProcessingOutcome +if TYPE_CHECKING: + from plugins.platforms.feishu.adapter import FeishuAdapter + try: import lark_oapi _HAS_LARK_OAPI = True diff --git a/tests/gateway/test_telegram_audio_vs_voice.py b/tests/gateway/test_telegram_audio_vs_voice.py index 7f807e3560..b9e54c5935 100644 --- a/tests/gateway/test_telegram_audio_vs_voice.py +++ b/tests/gateway/test_telegram_audio_vs_voice.py @@ -20,6 +20,11 @@ from gateway.config import GatewayConfig, Platform from gateway.platforms.base import MessageEvent, MessageType from gateway.session import SessionSource +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from gateway.run import GatewayRunner + def _make_runner(stt_enabled: bool = True) -> "GatewayRunner": # type: ignore[name-defined] from gateway.run import GatewayRunner diff --git a/tests/gateway/test_video_context_note.py b/tests/gateway/test_video_context_note.py index 19ca1dba8e..f0cd84575c 100644 --- a/tests/gateway/test_video_context_note.py +++ b/tests/gateway/test_video_context_note.py @@ -8,6 +8,11 @@ from gateway.config import GatewayConfig, Platform from gateway.platforms.base import MessageEvent, MessageType from gateway.session import SessionSource +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from gateway.run import GatewayRunner + def _make_runner() -> "GatewayRunner": # type: ignore[name-defined] from gateway.run import GatewayRunner diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 3ed68bba14..a664da184e 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1022,6 +1022,27 @@ class TestCustomProviderCompatibility: run_migrations(current_ver, results, quiet=True) return results + def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 11, + "model": {"default": "openai/gpt-5.4", "provider": "openrouter"}, + "custom_providers": [ + { + "name": "OpenAI Direct", + "base_url": "https://api.openai.com/v1", + "api_key": "test-key", + "api_mode": "codex_responses", + "model": "gpt-5-mini", + } + ], + "fallback_providers": [{"provider": "openai-direct", "model": "gpt-5-mini"}], + } + ), + encoding="utf-8", + ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._run_ladder(11) diff --git a/tests/test_background_review_list_shapes.py b/tests/test_background_review_list_shapes.py index ada4c151d9..cc22d2825a 100644 --- a/tests/test_background_review_list_shapes.py +++ b/tests/test_background_review_list_shapes.py @@ -298,9 +298,6 @@ def test_e_call_does_not_unwind_module_callables(): def main(): runner = TestRunner() - runner.run("a_change_as_list_does_not_crash", test_a_change_as_list_does_not_crash) - runner.run("a_change_as_int_does_not_crash", test_a_change_as_int_does_not_crash) - runner.run("b_operations_as_string_treated_as_empty", test_b_operations_as_string_treated_as_empty) runner.run("b_operations_as_none_treated_as_empty", test_b_operations_as_none_treated_as_empty) runner.run("c_operations_contains_non_dict_entries", test_c_operations_contains_non_dict_entries) runner.run("d_detail_non_dict_replaced_with_empty", test_d_detail_non_dict_replaced_with_empty) diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 50d20c91ad..9e39e502e7 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -7,7 +7,7 @@ import json import os import sys from pathlib import Path -from typing import Any, Dict, List, cast +from typing import Any, Dict, List, Optional, cast from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 6e0dece041..6df52dc48a 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -2979,7 +2979,7 @@ class TestMCPDiscoveryCrossProcessLock: if sys.platform == "win32": import portalocker - self._lock_exclusive(fh) + portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB) else: import fcntl diff --git a/tools/mcp_tool_sampling.py b/tools/mcp_tool_sampling.py index 2196ac0f02..fa19a5be77 100644 --- a/tools/mcp_tool_sampling.py +++ b/tools/mcp_tool_sampling.py @@ -1,11 +1,14 @@ """MCP client-side handlers for server-initiated requests: sampling (sampling/createMessage, text and tool-use results) and elicitation.""" +from __future__ import annotations + import asyncio import json import logging import time -from typing import Callable, List, Optional +from contextvars import Context +from typing import Callable, List, Optional, Protocol from tools.mcp_tool_common import _MISSING, _exc_str, _safe_numeric, _sanitize_error, mcp_field, _core from tools.mcp_tool_schema import _normalize_mcp_input_schema @@ -239,6 +242,13 @@ def _format_elicitation_schema_summary(schema: dict, server_name: str) -> str: return "\n".join(lines) +class ElicitationOwner(Protocol): + """The server task an ElicitationHandler belongs to (tools.mcp_tool.MCPServerTask, which imports this + module, so it cannot be named here). Only the captured agent contextvars are read.""" + + _pending_call_context: Optional[Context] + + class ElicitationHandler: """``elicitation_callback`` for one MCP server. Form-mode routes through Hermes' approval system (CLI, TUI, Telegram, ...); URL-mode is declined. Fail-closed: any timeout, exception or unexpected @@ -250,7 +260,7 @@ class ElicitationHandler: # consent answer -> (ElicitResult action, metric); anything else declines. _ANSWER_RESULTS = {"accept": ("accept", "accepted"), "cancel": ("cancel", "errors")} - def __init__(self, server_name: str, config: dict, owner: Optional["MCPServerTask"] = None): + def __init__(self, server_name: str, config: dict, owner: Optional[ElicitationOwner] = None): self.server_name = server_name # 5 min mirrors the gateway approval default so async surfaces (Telegram, Slack) can respond. self.timeout = _safe_numeric(config.get("timeout", 300), 300, float) diff --git a/tools/patch_parser.py b/tools/patch_parser.py index 994e3c1c56..3c9c1c3f37 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -11,6 +11,8 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any, Callable, Dict, List, Optional, Tuple +from tools.file_operations_common import PatchResult + class OperationType(Enum): ADD = "add" @@ -250,11 +252,10 @@ def _unified_diff(path: str, old: str, new: Optional[str]) -> str: fromfile=f"a/{path}", tofile="/dev/null" if new is None else f"b/{path}")) -def apply_v4a_operations(operations: List[PatchOperation], file_ops: Any) -> 'PatchResult': +def apply_v4a_operations(operations: List[PatchOperation], file_ops: Any) -> PatchResult: """Two-phase: validate everything, then apply (atomic on validation failure). A phase-2 failure (validate/apply race) carries a ``git diff`` note since state may be inconsistent. ``file_ops`` needs read_file_raw/write_file/delete_file/move_file.""" - from tools.file_operations_common import PatchResult # avoid circular import def _bullets(errs: List[str]) -> str: return "\n".join(f" • {e}" for e in errs)