fix: resolve the 33 F821 undefined names outside tui_gateway / feishu / godmode

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.
This commit is contained in:
kshitijk4poor
2026-09-04 21:26:44 +05:30
committed by kshitij
parent 233757037d
commit c5ff900761
18 changed files with 95 additions and 41 deletions
+13 -2
View File
@@ -2,11 +2,14 @@
stripping, thread participation tracking, GFM table → bullets, mention-pattern stripping, thread participation tracking, GFM table → bullets, mention-pattern
compilation, and fence-aware markdown chunking.""" compilation, and fence-aware markdown chunking."""
from __future__ import annotations
import json import json
import logging import logging
import re import re
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Protocol
from utils import atomic_json_write 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
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: class TextBatchAggregator:
"""Aggregates rapid-fire text events into single messages. """Aggregates rapid-fire text events into single messages.
@@ -554,14 +565,14 @@ class TextBatchAggregator:
self._batch_delay = batch_delay self._batch_delay = batch_delay
self._split_delay = split_delay self._split_delay = split_delay
self._split_threshold = split_threshold self._split_threshold = split_threshold
self._pending: Dict[str, "MessageEvent"] = {} self._pending: Dict[str, BatchableEvent] = {}
self._pending_tasks: Dict[str, asyncio.Task] = {} self._pending_tasks: Dict[str, asyncio.Task] = {}
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Return True if batching is active (delay > 0).""" """Return True if batching is active (delay > 0)."""
return self._batch_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*.""" """Add *event* to the pending batch for *key*."""
chunk_len = len(event.text or "") chunk_len = len(event.text or "")
existing = self._pending.get(key) existing = self._pending.get(key)
+2 -2
View File
@@ -15,6 +15,7 @@ import json
import logging import logging
import os import os
import re import re
from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from gateway.platforms._shared import get_scoped_secret as _get_wsecret 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 """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.""" /opt/hermes) is mirrored to HERMES_HOME so npm install works."""
import shutil import shutil
from pathlib import Path as _Path
from hermes_constants import get_hermes_home 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" hermes_home_bridge = get_hermes_home() / "scripts" / "whatsapp-bridge"
try: try:
(install_bridge / ".write_test").touch() (install_bridge / ".write_test").touch()
+2 -2
View File
@@ -39,6 +39,7 @@ from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
from agent.interrupt_compat import request_hard_interrupt from agent.interrupt_compat import request_hard_interrupt
from agent.turn_context import compression_made_progress from agent.turn_context import compression_made_progress
from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get 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 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. # 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( 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.""" """Best-effort activity provenance stamp for hygiene compression transitions."""
try: try:
agent._touch_activity(desc, provenance=provenance) agent._touch_activity(desc, provenance=provenance)
@@ -4189,7 +4190,6 @@ class GatewayRunner(
See #15654, #9051. See #15654, #9051.
""" """
if interrupt_depth == 0: if interrupt_depth == 0:
from agent.session_activity import ActivityProvenance
agent._last_activity_ts = time.time() agent._last_activity_ts = time.time()
agent._last_activity_desc = "starting new turn (cached)" agent._last_activity_desc = "starting new turn (cached)"
agent._last_activity_provenance = ActivityProvenance.UNKNOWN agent._last_activity_provenance = ActivityProvenance.UNKNOWN
+1 -1
View File
@@ -27,7 +27,7 @@ from gateway.session_transcript import TranscriptReadError
from gateway.slash_commands_goals import GatewayGoalCommandsMixin from gateway.slash_commands_goals import GatewayGoalCommandsMixin
from gateway.slash_commands_model import GatewayModelCommandsMixin from gateway.slash_commands_model import GatewayModelCommandsMixin
from gateway.slash_commands_session import GatewaySessionCommandsMixin 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 hermes_cli.config import atomic_config_write, cfg_get
from utils import atomic_json_write, is_truthy_value from utils import atomic_json_write, is_truthy_value
+15 -21
View File
@@ -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). SMS_ALLOWED_USERS (comma-separated E.164), SMS_ALLOW_ALL_USERS, SMS_HOME_CHANNEL (cron).
""" """
from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import hashlib import hashlib
@@ -20,10 +22,18 @@ import urllib.parse
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from gateway.config import Platform, PlatformConfig 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.helpers import redact_phone, strip_markdown
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret 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__) logger = logging.getLogger(__name__)
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts" TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
@@ -36,8 +46,6 @@ _EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
def _twiml_response(status: int = 200): def _twiml_response(status: int = 200):
"""Empty TwiML reply — replies go out via the REST API, never inline TwiML.""" """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) 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): def _twilio_form(from_number: str, to_number: str, body: str):
"""Twilio Messages.json form payload (aiohttp FormData).""" """Twilio Messages.json form payload (aiohttp FormData)."""
import aiohttp
form_data = aiohttp.FormData() form_data = aiohttp.FormData()
form_data.add_field("From", from_number) form_data.add_field("From", from_number)
form_data.add_field("To", to_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): def _new_session(**kwargs):
import aiohttp
return aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **kwargs) 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: def check_sms_requirements() -> bool:
"""Check if SMS adapter dependencies are available.""" """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")) _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_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip() self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip()
self._runner = None self._runner = None
self._http_session: Optional["aiohttp.ClientSession"] = None self._http_session: Optional[aiohttp.ClientSession] = None
# -- Lifecycle ----------------------------------------------------------- # -- Lifecycle -----------------------------------------------------------
async def connect(self, *, is_reconnect: bool = False) -> bool: async def connect(self, *, is_reconnect: bool = False) -> bool:
from aiohttp import web
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true" insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
fatal = None fatal = None
if not self._from_number: if not self._from_number:
@@ -228,7 +222,7 @@ class SmsAdapter(BasePlatformAdapter):
# -- Inbound webhook ----------------------------------------------------- # -- Inbound webhook -----------------------------------------------------
async def _handle_webhook(self, request) -> "aiohttp.web.Response": async def _handle_webhook(self, request: web.Request) -> web.Response:
try: try:
content_length = request.content_length content_length = request.content_length
if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES: 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): 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).""" """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", "") 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"} return {"error": "aiohttp not installed. Run: pip install aiohttp"}
account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "")
from_number = os.getenv("TWILIO_PHONE_NUMBER", "") from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
+2 -3
View File
@@ -1,8 +1,6 @@
"""Pipeline-facing Teams outbound delivery (meeting-summary writer). """Pipeline-facing Teams outbound delivery (meeting-summary writer).
Lives inside the Teams platform plugin so the meeting pipeline reuses one Teams 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 from __future__ import annotations
@@ -15,6 +13,8 @@ from urllib.parse import quote
from gateway.config import PlatformConfig from gateway.config import PlatformConfig
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
import httpx
def _parse_bool(value: Any, *, default: bool = False) -> bool: def _parse_bool(value: Any, *, default: bool = False) -> bool:
if isinstance(value, bool): if isinstance(value, bool):
@@ -90,7 +90,6 @@ class TeamsSummaryWriter:
return merged return merged
async def _write_summary_via_incoming_webhook(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]: 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() webhook_url = str(config.get("incoming_webhook_url") or "").strip()
if not webhook_url: if not webhook_url:
raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.") raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.")
@@ -30,6 +30,8 @@ Claude Code witness lives in ``test_anthropic_oauth_stress.py``.
from __future__ import annotations from __future__ import annotations
from typing import Dict
import threading import threading
import time import time
from dataclasses import replace as dc_replace from dataclasses import replace as dc_replace
@@ -231,6 +231,8 @@ def bench():
# persist scan: fully-flushed list, old full walk vs bounded skip # persist scan: fully-flushed list, old full walk vs bounded skip
import run_agent as ra 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) flushed = copy.deepcopy(msgs)
for m in flushed: for m in flushed:
if isinstance(m, dict): if isinstance(m, dict):
+4
View File
@@ -12,12 +12,16 @@ No LLM, no real platform connections.
import asyncio import asyncio
import sys import sys
import uuid import uuid
from typing import TYPE_CHECKING
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
if TYPE_CHECKING:
from gateway.run import GatewayRunner
from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SendResult from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key from gateway.session import SessionEntry, SessionSource, build_session_key
+4 -1
View File
@@ -10,11 +10,14 @@ import unittest
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Dict from typing import TYPE_CHECKING, Dict
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from gateway.platforms.base import ProcessingOutcome from gateway.platforms.base import ProcessingOutcome
if TYPE_CHECKING:
from plugins.platforms.feishu.adapter import FeishuAdapter
try: try:
import lark_oapi import lark_oapi
_HAS_LARK_OAPI = True _HAS_LARK_OAPI = True
@@ -20,6 +20,11 @@ from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import MessageEvent, MessageType from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource 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] def _make_runner(stt_enabled: bool = True) -> "GatewayRunner": # type: ignore[name-defined]
from gateway.run import GatewayRunner from gateway.run import GatewayRunner
+5
View File
@@ -8,6 +8,11 @@ from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import MessageEvent, MessageType from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource 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] def _make_runner() -> "GatewayRunner": # type: ignore[name-defined]
from gateway.run import GatewayRunner from gateway.run import GatewayRunner
+21
View File
@@ -1022,6 +1022,27 @@ class TestCustomProviderCompatibility:
run_migrations(current_ver, results, quiet=True) run_migrations(current_ver, results, quiet=True)
return results 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)}): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
self._run_ladder(11) self._run_ladder(11)
@@ -298,9 +298,6 @@ def test_e_call_does_not_unwind_module_callables():
def main(): def main():
runner = TestRunner() 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("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("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) runner.run("d_detail_non_dict_replaced_with_empty", test_d_detail_non_dict_replaced_with_empty)
+1 -1
View File
@@ -7,7 +7,7 @@ import json
import os import os
import sys import sys
from pathlib import Path 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 from unittest.mock import MagicMock, patch
import pytest import pytest
+1 -1
View File
@@ -2979,7 +2979,7 @@ class TestMCPDiscoveryCrossProcessLock:
if sys.platform == "win32": if sys.platform == "win32":
import portalocker import portalocker
self._lock_exclusive(fh) portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB)
else: else:
import fcntl import fcntl
+12 -2
View File
@@ -1,11 +1,14 @@
"""MCP client-side handlers for server-initiated requests: sampling """MCP client-side handlers for server-initiated requests: sampling
(sampling/createMessage, text and tool-use results) and elicitation.""" (sampling/createMessage, text and tool-use results) and elicitation."""
from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time 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_common import _MISSING, _exc_str, _safe_numeric, _sanitize_error, mcp_field, _core
from tools.mcp_tool_schema import _normalize_mcp_input_schema 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) 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: class ElicitationHandler:
"""``elicitation_callback`` for one MCP server. Form-mode routes through Hermes' approval system """``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 (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. # consent answer -> (ElicitResult action, metric); anything else declines.
_ANSWER_RESULTS = {"accept": ("accept", "accepted"), "cancel": ("cancel", "errors")} _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 self.server_name = server_name
# 5 min mirrors the gateway approval default so async surfaces (Telegram, Slack) can respond. # 5 min mirrors the gateway approval default so async surfaces (Telegram, Slack) can respond.
self.timeout = _safe_numeric(config.get("timeout", 300), 300, float) self.timeout = _safe_numeric(config.get("timeout", 300), 300, float)
+3 -2
View File
@@ -11,6 +11,8 @@ from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from tools.file_operations_common import PatchResult
class OperationType(Enum): class OperationType(Enum):
ADD = "add" 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}")) 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 """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. 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.""" ``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: def _bullets(errs: List[str]) -> str:
return "\n".join(f" • {e}" for e in errs) return "\n".join(f" • {e}" for e in errs)