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:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user