Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)

* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
This commit is contained in:
Teknium authored and GitHub committed 2026-09-28 10:21:41 -07:00
1 parent fe18e71863
commit a5bd246865
360 files changed
+36 -27747

No files matched your search

-6
View File
@@ -168,12 +168,6 @@ jobs:
- name: Require portable Bash shebangs
run: python scripts/check_bash_shebangs.py
# The Sep 2026 decomposition kept old import paths alive for external plugins
# (PLUGIN-COMPAT blocks, see COMPAT_MANIFEST.md). They are removed on schedule by
# reverting one commit, so in-tree code must never depend on them.
- name: Forbid in-tree use of plugin-compat pointers
run: python scripts/check_compat_pointers.py
# /tmp is not portable (Termux has none, native Windows has none, macOS aliases it to
# /private/tmp, Linux mounts it as a small tmpfs). Production code resolves scratch space
# through the scratch-dir helper; skills, docs and prompts must not teach the model a
+4 -8
View File
@@ -217,7 +217,7 @@ hermes-agent/
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains)
├── cron/ # jobs.py + scheduler.py (+ scheduler_*.py)
├── evals/ # Offline benchmarks (codebase_navigability/, compaction/, ...)
├── scripts/ # run_tests.sh, release.py, check_compat_pointers.py, ci/
├── scripts/ # run_tests.sh, release.py, ci/
├── website/ # Docusaurus docs (developer-guide/ holds the long-form area docs)
└── tests/ # Pytest suite (~39k tests / ~3.7k files, Sep 2026)
```
@@ -246,18 +246,14 @@ families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15
function so `monkeypatch.setattr(facade, "name", ...)` is the seam; a patch on the defining
module passes silently. Check the call site's binding before writing a patch target
(blind repointing to defining modules broke 130+ tests).
- **Compat pointers are OFF LIMITS in-tree.** Old import paths kept alive for external plugins
(`PLUGIN-COMPAT` blocks, `COMPAT_MANIFEST.md`, `compat_manifest.json`) must not be used by
in-tree code or tests; `scripts/check_compat_pointers.py` runs in CI, and
`-W error::hermes_cli.plugin_compat.HermesPluginCompatWarning` catches them in the suite.
They are removed 2026-09-14 by reverting one commit. Import from the defining module.
- **Don't recreate god files.** A file passing ~2,000 lines or a function passing ~300 lines /
cyclomatic complexity 30 is the signal to split along `<stem>_<topic>` FIRST, in its own
commit. New behaviour goes in a new or topical sibling — never appended to a facade.
- **No `if/elif` ladders ≥ 4 branches keyed on a name/kind** — use a dict/table → handler
(`_SLASH_DISPATCH` in `cli.py`, `_command_handler_table` in the gateway are the shape).
- **No re-export shims for internal moves** ("keep the old name importable"). Internal paths
are not API; external compat is handled ONCE by the compat layer, not per PR.
are not API: plugins build on `ctx` and the documented ABCs. The one-time Sep 2026
decomposition compat layer for external plugins has been removed; never reintroduce one.
- **Moving a symbol means fixing its docs in the same PR:** grep `website/docs`,
`skills/`, and every `AGENTS.md` for the old `path.py` + symbol (23 doc files went stale
after the refactor). `evals/codebase_navigability/static_metrics.py <tree> <label>` measures
@@ -513,7 +509,7 @@ extract, not to regex around it.
| `cli.py`, `hermes_cli/`, `main.py` | `hermes_cli/AGENTS.md` | CLI mixins, `_SLASH_DISPATCH`, slash registry, config system + loaders, skins, `hermes update` pipeline, profiles / multiplex |
| `gateway/` | `gateway/AGENTS.md` | Adapters, two message guards, streaming contract, background notifications, gateway vs desktop lifecycle, token locks, scoped secrets |
| `tools/`, `toolsets.py`, `model_tools.py` | `tools/AGENTS.md` | Adding tools, registry, toolsets, delegation, cross-tool references, backends |
| `plugins/`, `hermes_cli/plugins*.py` | `plugins/AGENTS.md` | Plugin kinds, native compat contract, in-tree policy, Sep-2026 compat window |
| `plugins/`, `hermes_cli/plugins*.py` | `plugins/AGENTS.md` | Plugin kinds, native compat contract, in-tree policy |
| `tui_gateway/`, `ui-tui/` | `tui_gateway/AGENTS.md` | Process model, JSON-RPC transport, key surfaces, slash flow, dev commands |
| `web/`, `hermes_cli/web_routers/` | `web/AGENTS.md` | Dashboard embeds the real TUI; what React may and may not rebuild |
| `apps/desktop/` | `apps/desktop/AGENTS.md`, `apps/desktop/src/AGENTS.md` | Desktop judgment guide; `serve` backend, slash palette curation, Bot Mode canonical chat |
-3857
View File
File diff suppressed because it is too large. Load diff
-11
View File
@@ -49,14 +49,3 @@ def build_auth_methods() -> list[Any]:
"Use this when Hermes has not been configured on this machine yet."),
))
return methods
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def has_provider() -> bool:
"""Return True if Hermes can resolve any runtime provider credentials."""
return detect_provider() is not None
# ---- END PLUGIN-COMPAT ----
-16
View File
@@ -238,19 +238,3 @@ def make_acp_edit_approval_requester(
return getattr(outcome, "outcome", None) == "selected" and getattr(outcome, "option_id", None) == "allow_once"
return _requester
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from concurrent.futures import TimeoutError as FutureTimeout # noqa: F401,E402
def clear_edit_approval_requester() -> None:
"""Clear the current requester; primarily used by tests."""
_EDIT_APPROVAL_REQUESTER.set(None)
def get_edit_approval_requester() -> EditApprovalRequester | None:
return _EDIT_APPROVAL_REQUESTER.get()
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -295,11 +295,3 @@ def make_step_cb(
_send_update(conn, session_id, loop, plan_update)
return _step
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-37
View File
@@ -1086,40 +1086,3 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
self.session_manager.save_session(session_id)
logger.info("Session %s: config option %s updated", session_id, config_id)
return SetSessionConfigOptionResponse(config_options=[])
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from acp.schema import AgentThoughtChunk # noqa: F401,E402
from acp.schema import AudioContentBlock # noqa: F401,E402
from acp.schema import AvailableCommand # noqa: F401,E402
from acp.schema import AvailableCommandsUpdate # noqa: F401,E402
from acp.schema import BlobResourceContents # noqa: F401,E402
from acp.schema import EmbeddedResourceContentBlock # noqa: F401,E402
from acp.schema import ImageContentBlock # noqa: F401,E402
from pathlib import Path # noqa: F401,E402
from acp.schema import ResourceContentBlock # noqa: F401,E402
from acp.schema import TextResourceContents # noqa: F401,E402
from acp.schema import UnstructuredCommandInput # noqa: F401,E402
import base64 # noqa: F401,E402
import json # noqa: F401,E402
from urllib.parse import unquote # noqa: F401,E402
from urllib.parse import urlparse # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'ACP_MAX_MODELS_PER_PROVIDER': ('acp_adapter.model_catalog', 'ACP_MAX_MODELS_PER_PROVIDER'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -575,11 +575,3 @@ class SessionManager:
# ACP stdio: stdout is protocol-only JSON-RPC; agent chatter goes to stderr.
agent._print_fn = _acp_stderr_print
return agent
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from threading import Lock # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-22
View File
@@ -2499,25 +2499,3 @@ def init_agent(
__all__ = ["init_agent"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'ToolGuardrailDecision': ('agent.tool_guardrails', 'ToolGuardrailDecision'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-27
View File
@@ -3577,30 +3577,3 @@ __all__ = [
"extract_api_error_context", "apply_pending_steer_to_tool_results", "_iter_pool_sockets",
"force_close_tcp_sockets",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def agent_runtime_owns_post_tool_hook(agent: Any, function_name: str) -> bool:
"""Return True when an agent-level tool path emits its own post hook."""
if function_name in AGENT_RUNTIME_POST_HOOK_TOOL_NAMES:
return True
if getattr(agent, "_context_engine_tool_names", None) and function_name in agent._context_engine_tool_names:
return True
memory_manager = getattr(agent, "_memory_manager", None)
return bool(memory_manager and memory_manager.has_tool(function_name))
def intent_ack_continuation_enabled(agent) -> bool:
"""Whether intent-ack continuation should fire at all for this turn.
The ``codex_ack_continuations < 2`` per-turn cap and the
``looks_like_codex_intermediate_ack`` detector are applied by the caller;
this only decides the on/off gate. Callers that also need to know whether
the workspace requirement applies should use ``intent_ack_continuation_mode``
directly (``"codex_only"`` ⇒ require_workspace=True, ``"all"`` ⇒ False).
"""
return intent_ack_continuation_mode(agent) != "off"
# ---- END PLUGIN-COMPAT ----
-44
View File
@@ -847,47 +847,3 @@ def create_anthropic_message(
"%sAnthropic Messages stream unavailable; falling back to messages.create(): %s", log_prefix, exc
)
return messages_api.create(**{k: v for k, v in api_kwargs.items() if k != "stream"})
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
from typing import Tuple # noqa: F401,E402
import copy # noqa: F401,E402
import json # noqa: F401,E402
import os # noqa: F401,E402
import platform # noqa: F401,E402
import secrets # noqa: F401,E402
import stat # noqa: F401,E402
from urllib.parse import urlparse # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'CredentialPersistError': ('agent.anthropic_credentials', 'CredentialPersistError'),
'base_url_host_matches': ('utils', 'base_url_host_matches'),
'base_url_hostname': ('utils', 'base_url_hostname'),
'claude_code_credentials_path': ('agent.anthropic_credentials', 'claude_code_credentials_path'),
'get_hermes_home': ('hermes_constants', 'get_hermes_home'),
'is_claude_code_token_valid': ('agent.anthropic_credentials', 'is_claude_code_token_valid'),
'is_rotation_consumed_uncommitted': ('agent.anthropic_credentials', 'is_rotation_consumed_uncommitted'),
'mark_rotation_consumed_uncommitted': ('agent.anthropic_credentials', 'mark_rotation_consumed_uncommitted'),
'read_claude_code_credentials': ('agent.anthropic_credentials', 'read_claude_code_credentials'),
'read_hermes_oauth_credentials': ('agent.anthropic_credentials', 'read_hermes_oauth_credentials'),
'refresh_anthropic_oauth_pure': ('agent.anthropic_credentials', 'refresh_anthropic_oauth_pure'),
'resolve_anthropic_token': ('agent.anthropic_credentials', 'resolve_anthropic_token'),
'run_hermes_oauth_login_pure': ('agent.anthropic_credentials', 'run_hermes_oauth_login_pure'),
'run_oauth_setup_token': ('agent.anthropic_credentials', 'run_oauth_setup_token'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -89,14 +89,3 @@ def record_aux_usage(
)
except Exception:
logger.debug("Aux usage recording failed (non-fatal)", exc_info=True)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def get_accounting_context() -> Optional[tuple]:
"""Return ``(session_db, session_id)`` for the active turn, or ``None``."""
return _accounting.get()
# ---- END PLUGIN-COMPAT ----
-29
View File
@@ -8224,32 +8224,3 @@ async def _async_call_llm_impl(
return await _drive_ladder_async(
_start_recovery_ladder(first_err, req, retry_kwargs, task=task, async_mode=True, route_info=route_info),
_perform)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
import copy # noqa: F401,E402
NOUS_EXTRA_BODY = _nous_extra_body()
def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Dict[str, Any]] = None):
"""Return (async_client, model_slug) for async consumers.
For standard providers returns (AsyncOpenAI, model). For Codex returns
(AsyncCodexAuxiliaryClient, model) which wraps the Responses API.
Returns (None, None) when no provider is available.
"""
provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
return resolve_provider_client(
provider,
model=model,
async_mode=True,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=main_runtime,
)
# ---- END PLUGIN-COMPAT ----
-21
View File
@@ -131,24 +131,3 @@ def should_skip_candidate(
True when it is the same backend as ``failed`` along the axis ``scope`` invalidated.
Every fallback/dedup/skip site must call this."""
return _SCOPE_PREDICATES.get(scope, same_deployment)(candidate, failed)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_REASON_SCOPES = {
"auth error": FailureScope.CREDENTIAL,
"payment error": FailureScope.CREDENTIAL,
"rate limit": FailureScope.MODEL,
"model incompatible with route": FailureScope.MODEL,
"invalid provider response": FailureScope.MODEL,
"connection error": FailureScope.MODEL,
"timeout": FailureScope.MODEL,
}
def classify_failure_scope(reason: Optional[str]) -> FailureScope:
"""Map a human-readable failure reason to the identity axis it kills."""
return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL)
# ---- END PLUGIN-COMPAT ----
-36
View File
@@ -1337,39 +1337,3 @@ __all__ = [
"_MEMORY_REVIEW_PROMPT", "_SKILL_REVIEW_PROMPT", "_COMBINED_REVIEW_PROMPT", "load_background_review_settings",
"spawn_background_review_thread", "summarize_background_review_actions", "build_memory_write_metadata",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
def is_background_review_enabled(
task_cfg: Optional[Dict[str, Any]] = None,
) -> bool:
"""Return whether automatic post-turn background review may spawn.
Controlled by ``auxiliary.background_review.enabled`` (default ``true``).
Explicit ``/refine`` (``focus`` set) bypasses this gate — same contract as
zeroing the nudge intervals, which stops automatic forks but leaves manual
refine working (issue #87250).
Prefer :func:`load_background_review_settings` at the spawn call site so
the task block is not re-read on the same turn.
"""
if task_cfg is not None:
try:
from utils import is_truthy_value
return is_truthy_value(task_cfg.get("enabled"), default=True)
except Exception:
logger.warning(
"Failed to interpret background_review.enabled; leaving "
"automatic review enabled (fail-open)",
exc_info=True,
)
return True
enabled, _ = load_background_review_settings()
return enabled
# ---- END PLUGIN-COMPAT ----
-111
View File
@@ -1282,114 +1282,3 @@ def _resolve_inference_profile_model_id(profile_arn: str, region: str = "") -> s
logger.debug("Inference profile resolution skipped for %s: %s", profile_arn, exc)
_inference_profile_model_cache[profile_arn] = resolved
return resolved
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
CONTEXT_OVERFLOW_PATTERNS = [
re.compile(r"ValidationException.*(?:input is too long|max input token|input token.*exceed)", re.IGNORECASE),
re.compile(r"ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)", re.IGNORECASE),
re.compile(r"ModelStreamErrorException.*(?:Input is too long|too many input tokens)", re.IGNORECASE),
]
OVERLOAD_PATTERNS = [
re.compile(r"ModelNotReadyException", re.IGNORECASE),
re.compile(r"ModelTimeoutException", re.IGNORECASE),
re.compile(r"InternalServerException", re.IGNORECASE),
]
THROTTLE_PATTERNS = [
re.compile(r"ThrottlingException", re.IGNORECASE),
re.compile(r"Too many concurrent requests", re.IGNORECASE),
re.compile(r"ServiceQuotaExceededException", re.IGNORECASE),
]
def call_converse_stream(
region: str,
model: str,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
max_tokens: Optional[int] = 4096,
temperature: Optional[float] = None,
top_p: Optional[float] = None,
stop_sequences: Optional[List[str]] = None,
guardrail_config: Optional[Dict] = None,
) -> SimpleNamespace:
"""Call Bedrock ConverseStream API and return an OpenAI-compatible response.
Consumes the full stream and returns the assembled response. For true
streaming with delta callbacks, use ``iter_converse_stream()`` instead.
"""
client = _get_bedrock_runtime_client(region)
kwargs = build_converse_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
stop_sequences=stop_sequences,
guardrail_config=guardrail_config,
)
try:
response = client.converse_stream(**kwargs)
except Exception as exc:
retry_kwargs = recover_from_cache_point_rejection(exc, kwargs)
if retry_kwargs is not None:
return normalize_converse_stream_events(
client.converse_stream(**retry_kwargs)
)
redacted_retry_kwargs = recover_from_redacted_reasoning_rejection(exc, kwargs)
if redacted_retry_kwargs is not None:
return normalize_converse_stream_events(
client.converse_stream(**redacted_retry_kwargs)
)
if is_streaming_access_denied_error(exc):
# IAM allows bedrock:InvokeModel but not
# InvokeModelWithResponseStream — permanent for this session.
# Fall back to the non-streaming converse() path.
logger.info(
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
"falling back to non-streaming converse().",
region, model,
)
return normalize_converse_response(client.converse(**kwargs))
if is_stale_connection_error(exc):
logger.warning(
"bedrock: stale-connection error on converse_stream(region=%s, "
"model=%s): %s — evicting cached client so the next call reconnects.",
region, model, type(exc).__name__,
)
invalidate_runtime_client(region)
raise
return normalize_converse_stream_events(response)
def is_context_overflow_error(error_message: str) -> bool:
"""Return True if the error indicates the input context was too large.
When this returns True, the agent should compress context and retry
rather than treating it as a fatal error.
"""
return any(p.search(error_message) for p in CONTEXT_OVERFLOW_PATTERNS)
def classify_bedrock_error(error_message: str) -> str:
"""Classify a Bedrock error for retry/failover decisions.
Returns:
- ``"context_overflow"`` — input too long, compress and retry
- ``"rate_limit"`` — throttled, backoff and retry
- ``"overloaded"`` — model temporarily unavailable, retry with delay
- ``"unknown"`` — unclassified error
"""
if is_context_overflow_error(error_message):
return "context_overflow"
if any(p.search(error_message) for p in THROTTLE_PATTERNS):
return "rate_limit"
if any(p.search(error_message) for p in OVERLOAD_PATTERNS):
return "overloaded"
return "unknown"
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -77,26 +77,3 @@ def read_streaming_error_body(
"bounded error-body read: capped at %d bytes (max=%d)", sum(len(c) for c in chunks), max_bytes,
)
return b"".join(chunks).decode("utf-8", errors="replace")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Optional # noqa: F401,E402
def read_error_body_or_default(
response: httpx.Response,
*,
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
) -> Optional[str]:
"""Like ``read_streaming_error_body`` but returns ``None`` on empty body.
Convenience for callers that distinguish "no body" from "empty string".
"""
text = read_streaming_error_body(
response, max_bytes=max_bytes, timeout_s=timeout_s
)
return text or None
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -55,12 +55,3 @@ class BrowserProvider(ProviderBase):
def emergency_cleanup(self, session_id: str) -> None:
"""Best-effort teardown from atexit / signal handlers. Must tolerate missing credentials and
network errors; must not raise."""
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Any # noqa: F401,E402
from typing import Optional # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-25
View File
@@ -59,28 +59,3 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
):
return provider
return None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-18
View File
@@ -1289,21 +1289,3 @@ __all__ = [
"run_codex_app_server_turn", "run_codex_stream",
"_consume_codex_event_stream", "make_codex_app_server_event_bridge",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):
"""Backward-compatible alias for the unified event-driven path.
Historically this was the fallback when the SDK's high-level
``responses.stream(...)`` helper raised on shape drift. The primary
path now does exactly what the fallback did, so this just forwards.
Kept as a public symbol because tests and a small number of call sites
still reference it by name.
"""
return run_codex_stream(agent, api_kwargs, client=client)
# ---- END PLUGIN-COMPAT ----
-32
View File
@@ -561,35 +561,3 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
if f.context_files:
lines.append(f"- Context files: {', '.join(f.context_files)}")
return "\n".join(lines)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def coding_system_blocks(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> list[str]:
"""Stable system-prompt blocks for the current posture (empty when general).
``model`` steers the brief's edit-format nudge toward the model's family.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_blocks()
_PROFILES: dict[str, ContextProfile] = {
GENERAL_PROFILE.name: GENERAL_PROFILE,
CODING_PROFILE.name: CODING_PROFILE,
}
def get_profile(name: str) -> ContextProfile:
"""Return a registered profile, falling back to ``general``."""
return _PROFILES.get(name, GENERAL_PROFILE)
# ---- END PLUGIN-COMPAT ----
-22
View File
@@ -5696,25 +5696,3 @@ def reference_handoff_would_drive_next_model_call(messages: Optional[List[Dict[s
def is_user_originated_turn(message: Any) -> bool:
"""True for human-authored user turns (not compaction scaffolding); dispatchers must use this, not a bare role check."""
return user_originated_turn_view(message) is not None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'tool_result_id_variants': ('agent.message_sanitization', 'tool_result_id_variants'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -4548,13 +4548,3 @@ __all__ = [
"compress_context",
"try_shrink_image_parts_in_messages",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
class CompressionExecutorSaturatedError(RuntimeError):
"""All compression pool slots are occupied; submission was refused."""
# ---- END PLUGIN-COMPAT ----
-62
View File
@@ -1771,65 +1771,3 @@ def _close_durable_failed_turn(agent, result: Any) -> None:
__all__ = ["run_conversation"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import os # noqa: F401,E402
import random # noqa: F401,E402
import ssl # noqa: F401,E402
import sys # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE': ('agent.conversation_compression', 'COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE'),
'COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE': ('agent.conversation_compression', 'COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE'),
'COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE': ('agent.conversation_compression', 'COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE'),
'COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE': ('agent.conversation_compression', 'COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE'),
'FailoverReason': ('agent.error_classifier', 'FailoverReason'),
'KawaiiSpinner': ('agent.display', 'KawaiiSpinner'),
'PARTIAL_STREAM_STUB_ID': ('hermes_constants', 'PARTIAL_STREAM_STUB_ID'),
'PRE_API_COMPRESSION_STATUS_TEMPLATE': ('agent.conversation_compression', 'PRE_API_COMPRESSION_STATUS_TEMPLATE'),
'adaptive_rate_limit_backoff': ('agent.retry_utils', 'adaptive_rate_limit_backoff'),
'anchored_context_tokens': ('agent.usage_anchor', 'anchored_context_tokens'),
'automatic_compaction_status_message': ('agent.context_engine', 'automatic_compaction_status_message'),
'capture_usage_anchor': ('agent.usage_anchor', 'capture_usage_anchor'),
'classify_api_error': ('agent.error_classifier', 'classify_api_error'),
'close_interrupted_tool_sequence': ('agent.message_sanitization', 'close_interrupted_tool_sequence'),
'coalesce_tool_call_id': ('agent.message_sanitization', 'coalesce_tool_call_id'),
'compose_user_api_content': ('agent.turn_context', 'compose_user_api_content'),
'compression_blocked_transiently': ('agent.conversation_compression', 'compression_blocked_transiently'),
'compression_skipped_due_to_lock': ('agent.conversation_compression', 'compression_skipped_due_to_lock'),
'context_compression_timed_out': ('agent.conversation_compression', 'context_compression_timed_out'),
'conversation_history_after_compression': ('agent.conversation_compression', 'conversation_history_after_compression'),
'env_var_enabled': ('utils', 'env_var_enabled'),
'estimate_messages_tokens_rough': ('agent.model_metadata', 'estimate_messages_tokens_rough'),
'estimate_request_tokens_rough': ('agent.model_metadata', 'estimate_request_tokens_rough'),
'estimate_usage_cost': ('agent.usage_pricing', 'estimate_usage_cost'),
'get_context_length_from_provider_error': ('agent.model_metadata', 'get_context_length_from_provider_error'),
'has_incomplete_scratchpad': ('agent.trajectory', 'has_incomplete_scratchpad'),
'is_output_cap_error': ('agent.model_metadata', 'is_output_cap_error'),
'is_repetition_dominated': ('agent.repetition_guard', 'is_repetition_dominated'),
'is_zai_coding_overload_error': ('agent.retry_utils', 'is_zai_coding_overload_error'),
'jittered_backoff': ('agent.retry_utils', 'jittered_backoff'),
'normalize_usage': ('agent.usage_pricing', 'normalize_usage'),
'parse_available_output_tokens_from_error': ('agent.model_metadata', 'parse_available_output_tokens_from_error'),
'reanchor_current_turn_user_idx': ('agent.turn_context', 'reanchor_current_turn_user_idx'),
'save_context_length': ('agent.model_metadata', 'save_context_length'),
'serialized_messages_bytes': ('agent.message_sanitization', 'serialized_messages_bytes'),
'splice_provider_projection': ('agent.provider_projection', 'splice_provider_projection'),
'zai_coding_overload_retry_ceiling': ('agent.retry_utils', 'zai_coding_overload_retry_ceiling'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -292,14 +292,3 @@ _REGISTRY: List[RemovalStep] = [
description="Custom provider config.yaml api_key field",
),
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def register(step: RemovalStep) -> RemovalStep:
_REGISTRY.append(step)
return step
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -1211,14 +1211,3 @@ def get_cute_tool_message(tool_name: str, args: dict, duration: float, result: s
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def get_friendly_tool_labels() -> bool:
"""Return whether friendly tool labels are enabled."""
return _friendly_tool_labels
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -219,12 +219,3 @@ def build_error_surface_from_exception(
except Exception: # pragma: no cover — never break the error path
logger.debug("error_surface: exception classification failed", exc_info=True)
return None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
LAYER_RUNTIME = "runtime"
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -136,11 +136,3 @@ def check_paused(component: str, logger: logging.Logger) -> bool:
component, suffix, sentinel_path(),
)
return True
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import os # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-83
View File
@@ -514,86 +514,3 @@ def get_container_mirror_warning(path: str, mirror_prefix: str | None = None) ->
"the real HERMES_HOME.",
"after explicit user direction, retry",
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
PROFILE_SCOPED_AREAS = ("skills", "plugins", "cron", "memories")
def classify_cross_profile_target(path: str) -> Optional[dict]:
"""Classify a write target as cross-profile if it lands in another
profile's scoped area (skills/plugins/cron/memories).
Returns ``None`` when the target is outside Hermes scope, or is inside
the ACTIVE profile, or doesn't hit a profile-scoped area. Otherwise
returns a dict with:
* ``active_profile``: name of the profile the agent is running as
* ``target_profile``: name of the profile the path belongs to
* ``area``: which scoped area (``"skills"``, ``"plugins"``, etc.)
* ``target_path``: the resolved path string
The caller decides what to do with the result — surface a warning to
the model, prompt the user, or (with explicit consent /
``cross_profile=True``) proceed anyway.
"""
try:
target = Path(os.path.expanduser(str(path))).resolve()
root_real = _hermes_root_path().resolve()
except (OSError, RuntimeError):
return None
target_profile: Optional[str] = None
area: Optional[str] = None
try:
rel = target.relative_to(root_real)
except ValueError:
return None
parts = rel.parts
if not parts:
return None
if parts[0] in PROFILE_SCOPED_AREAS:
# ``<root>/<area>/...`` → default profile.
target_profile = "default"
area = parts[0]
elif (
parts[0] == "profiles"
and len(parts) >= 3
and parts[2] in PROFILE_SCOPED_AREAS
):
# ``<root>/profiles/<name>/<area>/...`` → named profile.
target_profile = parts[1]
area = parts[2]
else:
return None
active_profile = _resolve_active_profile_name()
if target_profile == active_profile:
# In-profile write — not a cross-profile event.
return None
return {
"active_profile": active_profile,
"target_profile": target_profile,
"area": area,
"target_path": str(target),
}
def get_cross_profile_warning(path: str) -> Optional[str]:
"""RETIRED (maintainer decision): always returns ``None``.
The cross-profile write guard was removed — profiles were never
isolated (same OS user; the terminal tool writes anywhere), so the
block was ceremony that cost every schema real tokens and taught a
bypass arg. The system prompt's active-profile hint remains the only
steering; the classifier below survives for that hint and for
diagnostics. Kept as a stub so external callers/plugins fail soft.
"""
return None
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -129,13 +129,3 @@ def error_response(
"success": False, "image": None, "error": error, "error_type": error_type,
"model": model, "prompt": prompt, "aspect_ratio": aspect_ratio, "provider": provider,
}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import base64 # noqa: F401,E402
import datetime # noqa: F401,E402
import uuid # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-25
View File
@@ -45,28 +45,3 @@ def get_active_provider() -> Optional[ImageGenProvider]:
return available[0]
fal = snapshot.get("fal")
return fal if fal is not None and _available(fal) else None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -421,14 +421,3 @@ def render_frames(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, fr
"buckets": _bucket_rows(buckets, payload), "summary": build_summary(payload), "axis": axis_labels(payload),
"count": len(payload.get("nodes", [])), "cols": cols, "rows": rows,
}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
Grid = list # list[Row]
Run = list # [text, style, alpha, hex?]
# ---- END PLUGIN-COMPAT ----
-26
View File
@@ -166,29 +166,3 @@ __all__ = [
"log_server_unavailable", "log_timeout", "log_server_error", "log_spawn_failed", "log_skipped_broken", "log_root_excluded", "log_reaped",
"reset_announce_caches",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_announced_no_server: set = set() # keys: (server_id,)
def _announce_once(bucket: set, key: Tuple) -> bool:
"""Return True if *key* has not been announced for *bucket* yet.
Atomically marks the key as announced so concurrent callers
cannot both win the race and double-log.
"""
with _announce_lock:
if key in bucket:
return False
bucket.add(key)
return True
def log_no_server_configured(server_id: str) -> None:
"""No spawn recipe for this language. WARNING once."""
if _announce_once(_announced_no_server, (server_id,)):
_emit(server_id, logging.WARNING, "no server configured")
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -136,12 +136,3 @@ __all__ = [
"encode_message", "read_message", "make_request", "make_notification", "make_response",
"make_error_response", "classify_message",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
ERROR_REQUEST_CANCELLED = -32800
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -168,26 +168,3 @@ __all__ = [
"CronHealthSnapshot", "build_cron_health_snapshot", "classify_cron_error", "emit_execution_state",
"project_execution_event",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import hashlib # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'GatewayHealthSnapshot': ('agent.monitoring.gateway_health', 'GatewayHealthSnapshot'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -165,12 +165,3 @@ def reset_emitter_for_tests(emitter: Optional[MonitoringEmitter] = None) -> None
__all__ = ["MonitoringEmitter", "get_emitter", "emit", "reset_emitter_for_tests"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
TelemetryEmitter = MonitoringEmitter
# ---- END PLUGIN-COMPAT ----
-21
View File
@@ -329,24 +329,3 @@ __all__ = [
"GatewayMetric", "GatewayHealthSnapshot", "GatewayDiagnosticLogHandler",
"build_gateway_health_snapshot", "classify_gateway_error", "source_logger_for_export",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def redact_gateway_message(message: Any) -> str:
"""Redact gateway diagnostic free text for operator-owned export.
Single scrub path: everything goes through
``agent.monitoring.redaction.redact_for_export`` (unconditional
secrets + PII), then is length-bounded.
"""
try:
from agent.monitoring.redaction import redact_for_export
redacted = redact_for_export(str(message or "")) or ""
except Exception:
redacted = "[redaction-unavailable]"
return redacted[:500]
# ---- END PLUGIN-COMPAT ----
@@ -334,11 +334,3 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
__all__ = ["GatewayHealthExportRuntime", "start_gateway_health_export"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -185,26 +185,3 @@ def _has_exhausted_bucket_in_object(state: Any) -> bool:
if _is_exhausted(remaining, reset):
return True
return False
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import tempfile # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'atomic_replace': ('utils', 'atomic_replace'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -338,13 +338,3 @@ def _deliver(delivery: Dict[str, Any]) -> None:
"outbound webhook delivery failed after %d attempt(s) (event=%s target=%s): %s",
MAX_DELIVERY_ATTEMPTS, event, label, last_error,
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
from datetime import datetime # noqa: F401,E402
from datetime import timezone # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-21
View File
@@ -619,24 +619,3 @@ def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]:
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return errors, warnings, filled_states
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import io # noqa: F401,E402
import io # noqa: F401,E402
COLUMNS = max(count for _, _, count in ROW_SPECS)
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
ROWS = len(ROW_SPECS)
def atlas_to_webp_bytes(atlas) -> bytes:
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
buf = io.BytesIO()
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
return buf.getvalue()
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -1778,26 +1778,3 @@ def build_context_files_prompt(
return ""
return ("# Project Context\n\nThe following project context files have been loaded and should be followed:\n\n"
+ "\n".join(sections))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import List # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'org_id_of_path': ('agent.skill_utils', 'org_id_of_path'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-12
View File
@@ -56,15 +56,3 @@ def find_stable_prefix(content: str) -> Optional[str]:
if best is not None:
_prefixes.move_to_end(best) # after the scan: never mutate mid-iteration
return best
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def clear_stable_prefixes() -> None:
"""Test isolation helper."""
with _lock:
_prefixes.clear()
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -957,11 +957,3 @@ __all__ = [
"merge_mappings", "mint_proxy_token", "reload_proxy", "start_proxy", "stop_proxy",
"write_mappings", "write_proxy_config",
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import stat # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -224,12 +224,3 @@ def ox_alpha_reasoning_extras(reasoning_config: Optional[dict], model: Optional[
effort = requested_effort(reasoning_config)
clamped = clamp_effort(None if effort == "none" else effort, OX_ALPHA_EFFORTS, OX_ALPHA_OVERRIDES)
return ({}, {"reasoning_effort": clamped}) if clamped in OX_ALPHA_EFFORTS else ({}, {})
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
CODEX_RESPONSES_EFFORTS: tuple[str, ...] = CODEX_GPT56_EFFORTS
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -932,11 +932,3 @@ def _run_awaitable(
if _has_running_event_loop():
raise RuntimeError(loop_error)
return asyncio.run(value)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from dataclasses import dataclass # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-88
View File
@@ -1321,91 +1321,3 @@ def _reset_for_tests() -> None:
HOST_REGISTRY.shutdown_all()
_PLUGIN_CONFIGURATION.reset_for_tests()
_PROFILE_KEY_CACHE.clear()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from enum import auto # noqa: F401,E402
def emit_mark(
name: str,
*,
session_id: str,
data: Any = None,
metadata: Any = None,
) -> bool:
"""Emit a fail-open Relay mark under a Hermes session."""
runtime = get_runtime(create=False)
if runtime is None:
return False
try:
return runtime.emit_mark(
name,
{"session_id": session_id},
data=data,
metadata=metadata,
)
except Exception:
logger.warning("Hermes Relay mark failed: %s", name, exc_info=True)
return False
def ensure_session(*, session_id: str, **context: Any) -> RelaySession | None:
"""Create or return the shared Relay session used by Hermes core."""
runtime = get_runtime()
if runtime is None:
return None
try:
return runtime.ensure_session({"session_id": session_id, **context})
except Exception:
logger.warning("Hermes Relay session initialization failed", exc_info=True)
return None
def get_host(
*,
create: bool = True,
profile_key: str | None = None,
) -> RelayHost | None:
"""Return the explicit real or reduced-capability host for a profile."""
return HOST_REGISTRY.for_profile(profile_key, create=create)
def get_session_handle(session_id: str) -> Any:
"""Return the shared Relay handle for direct core instrumentation."""
runtime = get_runtime(create=False)
return None if runtime is None else runtime.get_session_handle(session_id)
def run_in_session(
session_id: str,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Run a scope, LLM, or tool API against a shared Hermes session."""
runtime = get_runtime()
if runtime is None:
raise RuntimeError("Hermes Relay runtime is unavailable")
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
if session is None:
raise RuntimeError("Hermes Relay session is unavailable")
return runtime.run_in_session(session, callback, *args, **kwargs)
async def run_in_session_async(
session_id: str,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Await a Relay operation inside a shared Hermes session context."""
runtime = get_runtime()
if runtime is None:
raise RuntimeError("Hermes Relay runtime is unavailable")
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
if session is None:
raise RuntimeError("Hermes Relay session is unavailable")
return await runtime.run_in_session_async(session, callback, *args, **kwargs)
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -104,12 +104,3 @@ def _run_awaitable(value: Any) -> Any:
return relay_llm._run_awaitable(
value, loop_error="Synchronous Hermes Relay tool execution cannot run on an active event-loop thread",
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import asyncio # noqa: F401,E402
import inspect # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -209,11 +209,3 @@ def _managed_server_idle() -> bool:
# Module singleton — one queue per process, like the load-progress watcher.
QUEUE = ReviewIdleQueue()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import List # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -168,26 +168,3 @@ class SecretCache(Generic[K]):
def clear(self, home_path: Optional[Path] = None) -> None:
self.memory.clear()
self.disk.clear(home_path)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'FetchResult': ('agent.secret_sources.base', 'FetchResult'),
'is_valid_env_name': ('agent.secret_sources.base', 'is_valid_env_name'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-111
View File
@@ -397,114 +397,3 @@ def clear_caches(home_path: Optional[Path] = None) -> None:
_reset_cache_for_tests = clear_caches
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import stat # noqa: F401,E402
def apply_bitwarden_secrets(
*,
enabled: bool,
access_token_env: str = "BWS_ACCESS_TOKEN",
project_id: str = "",
override_existing: bool = False,
cache_ttl_seconds: float = 300,
auto_install: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> FetchResult:
"""Pull secrets from BSM and set them on ``os.environ``.
This is the function ``load_hermes_dotenv()`` calls after the .env
files have loaded. It is intentionally defensive — any failure
returns a :class:`FetchResult` with ``error`` set; it never raises.
``server_url`` selects the Bitwarden region or self-hosted endpoint
(e.g. ``https://vault.bitwarden.eu`` for EU Cloud). Empty string
means use ``bws``'s default (US Cloud).
Parameters mirror the ``secrets.bitwarden.*`` config keys so the
caller can just splat the dict in.
"""
result = FetchResult()
if not enabled:
return result
access_token = os.environ.get(access_token_env, "").strip()
if not access_token:
result.error = (
f"secrets.bitwarden.enabled is true but {access_token_env} is "
"not set. Run `hermes secrets bitwarden setup`."
)
return result
if not project_id:
result.error = (
"secrets.bitwarden.project_id is empty. "
"Run `hermes secrets bitwarden setup`."
)
return result
binary = find_bws(install_if_missing=auto_install)
result.binary_path = binary
if binary is None:
result.error = (
"bws binary not available and auto-install is disabled. "
"Run `hermes secrets bitwarden setup` to install."
)
return result
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
binary=binary,
cache_ttl_seconds=cache_ttl_seconds,
server_url=server_url,
home_path=home_path,
encrypted_cache_enabled=encrypted_cache_enabled,
encrypted_cache_max_stale_seconds=encrypted_cache_max_stale_seconds,
)
except RuntimeError as exc:
result.error = str(exc)
return result
result.secrets = secrets
result.warnings.extend(warnings)
for key, value in secrets.items():
if key == access_token_env:
# Don't let BSM clobber the very token we used to fetch
# itself — that would be a footgun if someone stored the
# token as a BSM secret too.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
_PLUGIN_COMPAT_LAZY = {
'DiskCache': ('agent.secret_sources._cache', 'DiskCache'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-191
View File
@@ -190,194 +190,3 @@ class CommandSource(SecretSource):
return result
result.secrets = secrets
return result
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def apply_command_secrets(
*,
command: str,
override_existing: bool = False,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
the startup path goes through :class:`CommandSource` + the registry
orchestrator instead (which owns precedence and the environ writes).
"""
result = FetchResult()
command = (command or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command is "
"empty. Set the helper command in config.yaml."
)
return result
if _is_windows():
result.warnings.append(
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
# The list/enumerate path: run the helper exactly ONCE with an empty
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.warnings.append(
"helper command failed at startup; no secrets applied "
"(process env / .env values remain in effect)"
)
return result
secrets = _parse_dotenv_map(stdout)
result.secrets = secrets
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing applied at "
"startup (a bare-value helper still resolves single keys on demand)"
)
return result
for key, value in secrets.items():
if value.strip() == "":
# Whitespace-only placeholder entries are "no value" — applying
# them would flow into an Authorization header → guaranteed 401.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
# Process env / .env win — same precedence as bitwarden.
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
* a bare value (single secret): the whole trimmed stdout is the value.
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
``wanted_key``.
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
misroute guard and the base64-padding disambiguation.
"""
text = stdout.replace("\r\n", "\n")
lines = text.split("\n")
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
# is deterministic and never returns another key's value.
dotenv_lines = [
line
for line in (raw.strip() for raw in lines)
if line and not line.startswith("#") and _ENV_LINE.match(line)
]
for line in dotenv_lines:
m = _ENV_LINE.match(line)
assert m is not None # filtered above
if m.group(1) == wanted_key:
value = unquote_dotenv_value(m.group(2))
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
# value": it would otherwise flow into an Authorization header
# → guaranteed 401.
return value if value.strip() != "" else None
# 2. The output is a multi-key dotenv dump that does NOT contain the
# wanted key → None, rather than mis-returning an unrelated line as
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
# non-matching env-shaped line falls through to the bare-value
# branch, because a bare secret can itself match the KEY=VALUE shape
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
# misclassified as a dump.
if len(dotenv_lines) > 1:
return None
# 3. Otherwise treat the whole output as a single bare value (a per-key
# helper that printed just the secret). Trim first so whitespace-only
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
value = text.strip()
if value == "":
return None
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
# env-file`, or a grep that matched the wrong line) emitting
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
# OTHER key's value — into an Authorization header sent to the WANTED
# key's endpoint: cross-provider credential leakage, not just a 401.
# Disambiguation from a bare base64 secret: base64 padding only ever
# produces an env-shaped line whose "value" part is empty or all '='
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
# after a non-matching key means a misrouted dotenv entry → None.
env_shaped = _ENV_LINE.match(value)
if (
env_shaped
and env_shaped.group(1) != wanted_key
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
):
return None
return value
def get_command_secret(
*,
command: str,
key: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Optional[str]:
"""Resolve a single secret by running the helper with the key in
``HERMES_SECRET_KEY``. Returns None on any failure — never raises."""
command = (command or "").strip()
if not command:
return None
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
if stdout is None:
return None
return parse_secret_output(stdout, key)
def list_command_secrets(
*,
command: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Dict[str, str]:
"""Enumerate secrets by running the helper ONCE with an empty key.
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
a bare-value helper returns ``{}``. Never raises.
"""
command = (command or "").strip()
if not command:
return {}
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
return {}
return _parse_dotenv_map(stdout)
_PLUGIN_COMPAT_LAZY = {
'get_source_environment': ('agent.secret_sources.base', 'get_source_environment'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -335,26 +335,3 @@ def clear_caches(home_path: Optional[Path] = None) -> None:
_reset_cache_for_tests = clear_caches
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import hashlib # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'DiskCache': ('agent.secret_sources._cache', 'DiskCache'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -635,11 +635,3 @@ def run_once(spec: ShellHookSpec, kwargs: Dict[str, Any]) -> Dict[str, Any]:
result = _spawn(spec, _serialize_payload(spec.event, kwargs))
result["parsed"] = _evaluate_result(spec, result)
return result
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import shlex # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -204,11 +204,3 @@ def delete_bundle(name: str) -> Path:
def get_bundle(name: str) -> Optional[Dict[str, Any]]:
"""Look up a bundle by name (slug-normalized)."""
return get_skill_bundles().get(f"/{_slugify(name)}")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-17
View File
@@ -812,20 +812,3 @@ def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
def is_valid_namespace(candidate: Optional[str]) -> bool:
"""Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``)."""
return bool(candidate) and bool(_NAMESPACE_RE.match(candidate))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def get_scan_ordered_skills_dirs() -> List[Path]:
"""All skill dirs in precedence order: project → local → external.
First-wins name deduplication over this order gives project skills
priority over profile-local and external ones.
"""
dirs = list(get_project_skills_dirs())
dirs.extend(get_all_skills_dirs())
return dirs
# ---- END PLUGIN-COMPAT ----
-22
View File
@@ -871,25 +871,3 @@ def format_tools_for_system_message(agent: Any) -> str:
__all__ = ["build_system_prompt_parts", "build_system_prompt", "invalidate_system_prompt",
"platform_hint", "restore_plugin_prompt_sections", "format_tools_for_system_message"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'OPENAI_MODEL_EXECUTION_GUIDANCE': ('agent.prompt_builder', 'OPENAI_MODEL_EXECUTION_GUIDANCE'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-24
View File
@@ -81,27 +81,3 @@ def plugin_strip_env_keys() -> frozenset:
except Exception:
logger.debug("Terminal environment provider strip_env_keys raised", exc_info=True)
return frozenset(keys)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -289,11 +289,3 @@ def sync_flushed_message_markers(batch_msgs: List[Dict[str, Any]], batch_rows: L
written[key] = canonical[key]
elif key not in ("role", "content"):
written.pop(key, None)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Optional # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-24
View File
@@ -34,27 +34,3 @@ class TranscriptionProvider(CatalogProviderBase):
``prompt`` (from ``stt.prompt`` or a ``pre_transcription`` hook) as a vocabulary hint;
unknown keys must be ignored.
"""
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import List # noqa: F401,E402
import logging # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'logger': ('agent.i18n', 'logger'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-26
View File
@@ -43,29 +43,3 @@ _registry: ProviderRegistry[TranscriptionProvider] = ProviderRegistry(
on_builtin_collision=_warn_builtin_collision,
)
_registry.export(globals())
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
from typing import Optional # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -682,11 +682,3 @@ class ChatCompletionsTransport(ProviderTransport):
from agent.transports import register_transport # noqa: E402
register_transport("chat_completions", ChatCompletionsTransport)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -902,13 +902,3 @@ class ResponsesApiTransport(ProviderTransport):
from agent.transports import register_transport # noqa: E402
register_transport("codex_responses", ResponsesApiTransport)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
from typing import Tuple # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -378,12 +378,3 @@ def check_codex_binary(
if version < min_version:
return False, f"codex {have} is older than required {'.'.join(map(str, min_version))}. Run: npm i -g @openai/codex"
return True, have
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from dataclasses import field # noqa: F401,E402
import time # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-26
View File
@@ -43,29 +43,3 @@ _registry: ProviderRegistry[TTSProvider] = ProviderRegistry(
builtin_names=_BUILTIN_NAMES, on_builtin_collision=_warn_builtin_collision,
)
_registry.export(globals())
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
from typing import Optional # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-26
View File
@@ -1289,29 +1289,3 @@ def build_api_messages(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
return api_messages, effective_system
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'IDLE_COMPACTION_STATUS_TEMPLATE': ('agent.conversation_compression', 'IDLE_COMPACTION_STATUS_TEMPLATE'),
'PREFLIGHT_COMPRESSION_STATUS_TEMPLATE': ('agent.conversation_compression', 'PREFLIGHT_COMPRESSION_STATUS_TEMPLATE'),
'automatic_compaction_status_message': ('agent.context_engine', 'automatic_compaction_status_message'),
'compression_skipped_due_to_lock': ('agent.conversation_compression', 'compression_skipped_due_to_lock'),
'conversation_history_after_compression': ('agent.conversation_compression', 'conversation_history_after_compression'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -56,11 +56,3 @@ class TurnRetryState:
# A user correction cancelled the in-flight request: append a role-safe checkpoint +
# user message, rebuild the payload, and retry the same logical iteration.
restart_with_redirected_messages: bool = False
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from dataclasses import fields # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -728,12 +728,3 @@ def format_token_count_compact(value: int) -> str:
if "." in text:
text = text.rstrip("0").rstrip(".")
return f"{sign}{text}{suffix}"
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -284,13 +284,3 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
close = getattr(client, "close", None)
if callable(close):
close()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import base64 # noqa: F401,E402
import datetime # noqa: F401,E402
import uuid # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-25
View File
@@ -43,28 +43,3 @@ def get_active_provider() -> Optional[VideoGenProvider]:
if is_available_safe(p, logger, "video_gen provider %s.is_available() raised %s")
]
return available[0] if len(available) == 1 else None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -99,11 +99,3 @@ class WebSearchProvider(ProviderBase):
raise NotImplementedError(
f"{self.name} does not support extract (override supports_extract)"
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Optional # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-25
View File
@@ -198,28 +198,3 @@ def get_active_search_provider() -> Optional[WebSearchProvider]:
def get_active_extract_provider() -> Optional[WebSearchProvider]:
"""Resolve the currently-active web extract provider."""
return _resolve(_configured_backend("extract"), capability="extract")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import List # noqa: F401,E402
import threading # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-67
View File
@@ -380,10 +380,6 @@ import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-pro
import { bundledPayload, installIdForRoot, type PayloadInfo } from './payload-backend'
import { petOverlayClickThrough } from './pet-overlay'
import { placePetOverlay, registerPetOverlayIpc } from './pet-overlay-ipc'
import {
pendingNotice as pendingPluginCompatNotice,
recordDismissed as recordPluginCompatDismissed
} from './plugin-compat-notice'
import {
buildRegistryProfileRoutes,
isLocalEnumerationFailure,
@@ -6733,65 +6729,6 @@ function getAppIconPath() {
}
}
// One-time modal for plugins importing pre-decomposition module paths (see
// electron/plugin-compat-notice.ts). The backend writes the report during plugin
// discovery; we show each distinct report exactly once and remember the dismissal
// in userData so the user is never nagged twice about the same set of plugins.
let pluginCompatNoticeShown = false
async function showPluginCompatNoticeOnce() {
if (pluginCompatNoticeShown) {
return
}
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
let notice
try {
notice = pendingPluginCompatNotice(HERMES_HOME, app.getPath('userData'))
} catch (err) {
rememberLog(`[plugins] compat notice check failed: ${err.message}`)
return
}
if (!notice) {
return
}
pluginCompatNoticeShown = true
rememberLog(`[plugins] compat notice shown (${notice.key})`)
try {
// 'OK' is the default and cancel so a stray Enter/Escape never navigates;
// 'Open Plugins' rides the existing deep-link channel (hermes://open/…),
// which the renderer already maps to its hash router.
const { response } = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: notice.title,
message: notice.message,
detail: notice.detail,
buttons: ['Open Plugins', 'OK'],
defaultId: 1,
cancelId: 1,
noLink: true
})
if (response === 0) {
handleDeepLink(`${HERMES_PROTOCOL}://open/capabilities?tab=plugins`)
}
} finally {
try {
recordPluginCompatDismissed(app.getPath('userData'), notice.key)
} catch (err) {
rememberLog(`[plugins] could not persist compat notice dismissal: ${err.message}`)
}
}
}
function sendOpenUpdatesRequested() {
// The renderer mounts its open-updates listener in the same effect pass that
// signals deep-link readiness. Before that (e.g. a boot-time dialog answered
@@ -13357,10 +13294,6 @@ async function runHermesStart({ supervisorRecovery = false }: { supervisorRecove
// accumulated count of the resolved episode.
bootstrapRepairAttempt = 0
// The backend's plugin discovery just ran and refreshed HERMES_HOME/.plugin-compat-report.json.
// Surface it once (per distinct set of affected plugins) after the window is up; never block boot.
setTimeout(() => void showPluginCompatNoticeOnce(), 1500)
return {
baseUrl,
mode: 'local',
@@ -1,81 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { DISMISSED_FILE, pendingNotice, recordDismissed, REPORT_FILE, reportKey } from './plugin-compat-notice'
function tmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-compat-'))
}
const REPORT = {
removal_date: '2026-09-14',
in_effect: false,
plugins: {
alpha: [
{
file: '__init__.py',
line: 3,
old: 'tools.web_tools.prefers_gateway',
new: 'tools.tool_backend_helpers.prefers_gateway'
}
],
beta: [
{ file: 'a.py', line: 1, old: 'hermes_cli.kanban_db.connect', new: 'hermes_cli.kanban_db_connect.connect' },
{
file: 'a.py',
line: 9,
old: 'hermes_cli.kanban_db.connect_closing',
new: 'hermes_cli.kanban_db_connect.connect_closing'
}
]
},
lines: [
'2 plugins use import paths that stop working on 2026-09-14 (10 days): alpha (1), beta (2)',
'Details: hermes plugins compat'
]
}
test('no report file → no notice', () => {
assert.equal(pendingNotice(tmp(), tmp()), null)
})
test('dismissal is remembered for the same report and forgotten for a different one', () => {
const home = tmp()
const userData = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const first = pendingNotice(home, userData)
assert.ok(first)
recordDismissed(userData, first.key)
assert.ok(fs.existsSync(path.join(userData, DISMISSED_FILE)))
assert.equal(pendingNotice(home, userData), null, 'same report must not show twice')
// a third affected plugin is new information
const grown = {
...REPORT,
plugins: { ...REPORT.plugins, gamma: [{ file: 'g.py', line: 1, old: 'x.y', new: 'z.y' }] }
}
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(grown))
const second = pendingNotice(home, userData)
assert.ok(second)
assert.notEqual(second.key, first.key)
// the date passing (plugins now disabled) is new information too
const disabled = { ...REPORT, in_effect: true }
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
const third = pendingNotice(home, userData)
assert.ok(third)
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
})
test('empty or malformed report is ignored', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify({ ...REPORT, plugins: {} }))
assert.equal(pendingNotice(home, tmp()), null)
fs.writeFileSync(path.join(home, REPORT_FILE), '{not json')
assert.equal(pendingNotice(home, tmp()), null)
})
@@ -1,143 +0,0 @@
/**
* One-time Desktop notice for plugins that import pre-decomposition module paths (PR #102117).
*
* The Python side (hermes_cli/plugin_compat.py) statically scans the user's enabled external plugins on
* every CLI/gateway/TUI start and writes HERMES_HOME/.plugin-compat-report.json when any plugin imports a
* path scheduled for removal on 2026-09-14 (deleting the file when none do). Desktop reads that file at
* boot and shows ONE modal, then records the dismissal in userData so the same set of affected plugins is
* never shown again. A *different* set (a new affected plugin, or the removal date passing so the plugins
* are now disabled) is a new message and shows once more.
*
* Pure module: no Electron imports, so it is unit-testable; main.ts owns the dialog.
*/
import fs from 'fs'
import path from 'path'
export const REPORT_FILE = '.plugin-compat-report.json'
export const DISMISSED_FILE = 'plugin-compat-dismissed.json'
export interface PluginCompatHit {
file: string
line: number
old: string
new: string
}
export interface PluginCompatReport {
removal_date: string
in_effect: boolean
written_at?: string
plugins: Record<string, PluginCompatHit[]>
lines: [string, string]
}
/** Stable identity of a report: which plugins, how many hits each, and whether removal is in effect. */
export function reportKey(report: PluginCompatReport): string {
const parts = Object.keys(report.plugins)
.sort()
.map(name => `${name}:${report.plugins[name].length}`)
return `${report.in_effect ? 'disabled' : 'pending'}|${parts.join(',')}`
}
export function readReport(hermesHome: string): PluginCompatReport | null {
try {
const raw = fs.readFileSync(path.join(hermesHome, REPORT_FILE), 'utf8')
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || !parsed.plugins || !Array.isArray(parsed.lines)) {
return null
}
if (Object.keys(parsed.plugins).length === 0) {
return null
}
return parsed as PluginCompatReport
} catch {
return null
}
}
export function wasDismissed(userData: string, key: string): boolean {
try {
const raw = JSON.parse(fs.readFileSync(path.join(userData, DISMISSED_FILE), 'utf8'))
return Array.isArray(raw?.keys) && raw.keys.includes(key)
} catch {
return false
}
}
export function recordDismissed(userData: string, key: string): void {
const file = path.join(userData, DISMISSED_FILE)
let keys: string[] = []
try {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
if (Array.isArray(raw?.keys)) {
keys = raw.keys.filter((k: unknown) => typeof k === 'string')
}
} catch {
/* first dismissal */
}
if (!keys.includes(key)) {
keys.push(key)
}
fs.mkdirSync(userData, { recursive: true })
fs.writeFileSync(file, JSON.stringify({ keys: keys.slice(-20) }, null, 2))
}
export interface PendingNotice {
key: string
title: string
message: string
detail: string
}
/** The modal to show this boot, or null (no report, or this exact report already dismissed). */
export function pendingNotice(hermesHome: string, userData: string): PendingNotice | null {
const report = readReport(hermesHome)
if (!report) {
return null
}
const key = reportKey(report)
if (wasDismissed(userData, key)) {
return null
}
const names = Object.keys(report.plugins).sort()
const nameList = names.join(', ')
const list = names
.map(n => {
const hits = report.plugins[n]
const first = hits[0]
return `• ${n} — ${hits.length} import${hits.length === 1 ? '' : 's'} (e.g. ${first.old} → ${first.new})`
})
.join('\n')
// User copy: what happened, what it means for them, what to do. Config keys
// and CLI commands stay out; the per-plugin list is for the plugin author.
const copy = report.in_effect
? {
title: 'Some plugins were turned off',
message: `These plugins were built for an older Hermes and were turned off: ${nameList}. Hermes works normally without them.`,
detail: `Look for an updated version of each plugin or ask its author.\n\n${list}`
}
: {
title: 'Some plugins need an update',
message: `These plugins were built for an older Hermes and will stop working on ${report.removal_date}: ${nameList}.`,
detail: `Look for an updated version or ask the plugin's author before then.\n\n${list}`
}
return { key, ...copy }
}
-80
View File
@@ -1723,10 +1723,6 @@ def main(
configure_windows_stdio()
os.environ["HERMES_INTERACTIVE"] = "1" # terminal_tool: interactive sudo prompts with timeout
# The banner names affected plugins; the raw per-name compat warnings would only duplicate it on stderr.
with suppress(Exception):
from hermes_cli.plugin_compat import quiet_for_interactive
quiet_for_interactive()
if gateway:
_run_legacy_gateway()
@@ -1786,79 +1782,3 @@ if __name__ == "__main__":
import fire
fire.Fire(main)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from prompt_toolkit.layout.menus import CompletionsMenu # noqa: F401,E402
from prompt_toolkit.filters import Condition # noqa: F401,E402
from prompt_toolkit.layout import ConditionalContainer # noqa: F401,E402
from prompt_toolkit.layout.processors import ConditionalProcessor # noqa: F401,E402
from prompt_toolkit.layout.dimension import Dimension # noqa: F401,E402
from prompt_toolkit.history import FileHistory # noqa: F401,E402
from prompt_toolkit.layout import FormattedTextControl # noqa: F401,E402
from prompt_toolkit.layout import HSplit # noqa: F401,E402
from prompt_toolkit.key_binding import KeyBindings # noqa: F401,E402
from prompt_toolkit.layout import Layout # noqa: F401,E402
from prompt_toolkit.styles import Style as PTStyle # noqa: F401,E402
from rich.panel import Panel # noqa: F401,E402
from prompt_toolkit.layout.processors import PasswordProcessor # noqa: F401,E402
from prompt_toolkit.layout.processors import Processor # noqa: F401,E402
from prompt_toolkit.widgets import TextArea # noqa: F401,E402
from prompt_toolkit.layout.processors import Transformation # noqa: F401,E402
from prompt_toolkit.layout import Window # noqa: F401,E402
from prompt_toolkit.layout import WindowAlign # noqa: F401,E402
import base64 # noqa: F401,E402
import concurrent.futures # noqa: F401,E402
import copy # noqa: F401,E402
from rich import box as rich_box # noqa: F401,E402
import tempfile # noqa: F401,E402
def AIAgent(*args, **kwargs):
from run_agent import AIAgent as _AIAgent
return _AIAgent(*args, **kwargs)
def CanonicalUsage(*args, **kwargs):
from agent.usage_pricing import CanonicalUsage as _CanonicalUsage
return _CanonicalUsage(*args, **kwargs)
_PLUGIN_COMPAT_LAZY = {
'DEFAULT_BROWSER_CDP_URL': ('hermes_cli.browser_connect', 'DEFAULT_BROWSER_CDP_URL'),
'HERMES_AGENT_LOGO': ('hermes_cli.banner', 'HERMES_AGENT_LOGO'),
'HERMES_CADUCEUS': ('hermes_cli.banner', 'HERMES_CADUCEUS'),
'SlashCommandAutoSuggest': ('hermes_cli.commands_completion', 'SlashCommandAutoSuggest'),
'SlashCommandCompleter': ('hermes_cli.commands_completion', 'SlashCommandCompleter'),
'build_welcome_banner': ('hermes_cli.banner', 'build_welcome_banner'),
'display_hermes_home': ('hermes_constants', 'display_hermes_home'),
'estimate_usage_cost': ('agent.usage_pricing', 'estimate_usage_cost'),
'get_all_toolsets': ('toolsets', 'get_all_toolsets'),
'get_job': ('cron.jobs', 'get_job'),
'get_toolset_for_tool': ('model_tools', 'get_toolset_for_tool'),
'get_toolset_info': ('toolsets', 'get_toolset_info'),
'init_skin_from_config': ('hermes_cli.skin_engine', 'init_skin_from_config'),
'is_browser_debug_ready': ('hermes_cli.browser_connect', 'is_browser_debug_ready'),
'is_table_divider': ('agent.markdown_tables', 'is_table_divider'),
'looks_like_table_row': ('agent.markdown_tables', 'looks_like_table_row'),
'manual_chrome_debug_command': ('hermes_cli.browser_connect', 'manual_chrome_debug_command'),
'print_config_warnings': ('hermes_cli.config', 'print_config_warnings'),
'prompt_for_secret': ('hermes_cli.callbacks', 'prompt_for_secret'),
'set_friendly_tool_labels': ('agent.display', 'set_friendly_tool_labels'),
'set_tool_preview_max_len': ('agent.display', 'set_tool_preview_max_len'),
'setup_logging': ('hermes_logging', 'setup_logging'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-12510
View File
File diff suppressed because it is too large. Load diff
-11
View File
@@ -3459,14 +3459,3 @@ def rewrite_skill_refs(
save_jobs(jobs)
logger.info("Curator rewrote skill references in %d cron job(s)", len(rewrites))
return {"rewrites": rewrites, "jobs_updated": len(rewrites), "jobs_scanned": len(jobs)}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def clear_drift_alerted(job_id: str) -> None:
"""Clear the drift alert-dedup marker (resolution matches again)."""
_set_alert_flag(job_id, "drift_alerted", False)
# ---- END PLUGIN-COMPAT ----
-28
View File
@@ -4223,31 +4223,3 @@ if __name__ == "__main__":
0 if _run_external_worker_payload(args.external_worker_file, args.ack_file) else 1
)
tick(verbose=True)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import asyncio # noqa: F401,E402
import shutil # noqa: F401,E402
import signal # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'BOT_CHAT_PLATFORM': ('cron.scheduler_delivery', 'BOT_CHAT_PLATFORM'),
'SharedRouteAdapters': ('cron.scheduler_preflight', 'SharedRouteAdapters'),
'cron_delivery_targets': ('cron.scheduler_delivery', 'cron_delivery_targets'),
'parse_bot_chat_deliver_token': ('cron.scheduler_delivery', 'parse_bot_chat_deliver_token'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -649,26 +649,3 @@ class InProcessCronScheduler(CronScheduler):
# burst-firing zero-length sleep cycles (#114467).
next_tick = now + wait_for
stop_event.wait(max(0.0, next_tick - now))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def provider_supports_fire_cancel(provider: Any) -> bool:
"""Return whether ``fire_claimed`` accepts a ``cancel_event`` kwarg."""
try:
parameters = inspect.signature(provider.fire_claimed).parameters.values()
except (TypeError, ValueError):
return False
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
or (
parameter.name == "cancel_event"
and parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
)
for parameter in parameters
)
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -321,13 +321,3 @@ class ArtifactRateLimiter:
"""Drop the recorded hits for ``key`` (tests/diagnostics)."""
with self._lock:
self._hits.pop(key, None)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
class ArtifactOverwrite(ArtifactError):
"""An artifact id already exists and the store refuses to overwrite it."""
# ---- END PLUGIN-COMPAT ----
-13
View File
@@ -510,16 +510,3 @@ _GLOBAL_BROKER = BrowserControlBroker()
def get_browser_control_broker() -> BrowserControlBroker:
"""Process-local broker shared by API and dashboard Gateway transports."""
return _GLOBAL_BROKER
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
BROWSER_CONTROL_ALL_CAPABILITIES = frozenset(
BROWSER_CONTROL_CAPABILITIES
| BROWSER_CONTROL_ARTIFACT_CAPABILITIES
| BROWSER_CONTROL_DEVELOPER_CAPABILITIES
)
# ---- END PLUGIN-COMPAT ----
-40
View File
@@ -350,38 +350,6 @@ def persist_home_channel(home: HomeChannel, *, enabled_if_new: bool = False) ->
save_config(config)
@dataclass
class SessionResetPolicy:
"""Inert legacy value type retained solely for the scheduled plugin-compat window.
Gateway configuration and session lifecycle do not consume this datatype.
"""
mode: str = "none"
at_hour: int = 4 # 0-23, local time
idle_minutes: int = 1440
notify: bool = True # Notify the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook")
bg_process_max_age_hours: int = 24
def to_dict(self) -> Dict[str, Any]:
return {**asdict(self), "notify_exclude_platforms": list(self.notify_exclude_platforms)}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
data = _coerce_dict(data)
exclude = data.get("notify_exclude_platforms")
# Missing keys and explicit YAML nulls both take the field default.
plain = {
f.name: f.default if data.get(f.name) is None else data[f.name]
for f in fields(cls) if f.name not in ("notify", "notify_exclude_platforms")
}
return cls(
notify=_coerce_bool(data.get("notify"), True),
notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
**plain,
)
@dataclass
class ChannelOverride:
"""Per-channel model/provider/system_prompt override (``platforms.<name>.channel_overrides[channel_id]``)."""
@@ -890,11 +858,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config (see ``gateway.config_env``)."""
from gateway.config_env import _apply_env_overrides as _impl
_impl(config)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-30
View File
@@ -562,33 +562,3 @@ def ledger_enabled(config: Optional[Dict[str, Any]] = None) -> bool:
return value.strip().lower() not in {"false", "0", "no", "off"} if isinstance(value, str) else bool(value)
except Exception:
return True
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
import json # noqa: F401,E402
def debug_rows(limit: int = 20) -> str:
"""Human-readable dump for ad-hoc inspection (sqlite3-free path)."""
with _DB_LOCK, _transaction() as conn:
rows = conn.execute(
"""SELECT obligation_id, session_key, state, attempts,
created_at, updated_at, last_error
FROM delivery_obligations
ORDER BY updated_at DESC LIMIT ?""",
(limit,),
).fetchall()
return json.dumps(
[
{
"id": r[0], "session": r[1], "state": r[2], "attempts": r[3],
"created_at": r[4], "updated_at": r[5], "last_error": r[6],
}
for r in rows
],
indent=2,
)
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -58,26 +58,3 @@ def collect_disk_status(home: Optional[Path] = None) -> Dict[str, Any]:
status.update(total_mb=total_mb, free_mb=free_mb, used_percent=round((usage.used / usage.total) * 100, 1),
pressure=classify_disk_pressure(free_mb, total_mb))
return status
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import logging # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'logger': ('gateway.run', 'logger'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-8
View File
@@ -794,11 +794,3 @@ def plan_publication(
payload={**_turn_coordinates(task), "seen_through_seq": task.seen_through_seq, **extra},
authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch))
return PublicationPlan(task_id=task.identity.task_id, terminal_kind=terminal_kind, events=tuple(effects))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -857,14 +857,3 @@ def prune_published_terminal_tasks(
f"DELETE FROM hosted_room_driver_tasks WHERE room_id=? AND task_id IN ({','.join('?' * len(candidates))})",
(room_id, *candidates))
return max(0, int(deleted.rowcount))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Iterator # noqa: F401,E402
from pathlib import Path # noqa: F401,E402
from contextlib import contextmanager # noqa: F401,E402
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-9
View File
@@ -129,12 +129,3 @@ __all__ = [
"MAX_POLICY_ITERATIONS", "POLICY_VERSION", "RoomExecutionPolicy", "RoomExecutionPolicyError",
"bind_room_execution_policy", "current_room_execution_policy", "execution_policy_mapping",
"reset_room_execution_policy"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-54
View File
@@ -493,57 +493,3 @@ def room_grant_needs_dispatch_refresh(token: str, *, now: float | None = None, l
return clock(now) + max(0.0, float(leeway_seconds)) >= expires_at
except Exception:
return True
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import time # noqa: F401,E402
@dataclass(frozen=True)
class RoomLinkProbe:
"""One gateway-verified route candidate."""
mode: LinkMode
verified: bool
encrypted: bool
latency_ms: float
_LINK_PRIORITY = {
"direct": 0,
"overlay": 1,
"relay": 2,
"pull": 3,
"desktop": 4,
}
def select_room_link(
probes: Iterable[RoomLinkProbe],
*,
desktop_available: bool,
) -> RoomLinkProbe | None:
"""Choose the fastest safe route without weakening encryption."""
candidates = [
probe
for probe in probes
if probe.verified
and probe.encrypted
and probe.mode != "desktop"
and math.isfinite(probe.latency_ms)
and probe.latency_ms >= 0
]
if candidates:
return min(
candidates,
key=lambda item: (_LINK_PRIORITY[item.mode], item.latency_ms),
)
if desktop_available:
return RoomLinkProbe(
mode="desktop",
verified=True,
encrypted=True,
latency_ms=0,
)
return None
# ---- END PLUGIN-COMPAT ----
-25
View File
@@ -284,28 +284,3 @@ def demote_room(
return {
"room_id": room_id, "authority_gateway_id": observed_gateway_id, "authority_epoch": observed_epoch,
"idempotent": False}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
import time # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'MAX_EVENT_JSON_BYTES': ('gateway.hosted_rooms', 'MAX_EVENT_JSON_BYTES'),
'MAX_ROOM_ID_CHARS': ('gateway.hosted_rooms', 'MAX_ROOM_ID_CHARS'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -1187,14 +1187,3 @@ def read_events(
if not fits(events):
raise HostedRoomError("hosted room event exceeds replay page limit")
return build_page(events)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Iterator # noqa: F401,E402
from typing import NoReturn # noqa: F401,E402
from contextlib import contextmanager # noqa: F401,E402
import time # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-27
View File
@@ -324,30 +324,3 @@ class GatewayKanbanWatchersMixin:
await self._sleep_between_ticks(interval)
self._release_kanban_dispatcher_lock()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Callable # noqa: F401,E402
from contextvars import Context # noqa: F401,E402
import logging # noqa: F401,E402
import re # noqa: F401,E402
import sqlite3 # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
't': ('agent.i18n', 't'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-23
View File
@@ -110,26 +110,3 @@ def collect_memory_status(
status["boot_id"] = started_at if isinstance(started_at, str) and started_at else None
return status
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import logging # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'logger': ('gateway.run', 'logger'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-95
View File
@@ -658,98 +658,3 @@ def is_discord_channel_obfuscated(channel) -> bool:
if isinstance(flag_value, int) and flag_value & DISCORD_CHANNEL_OBFUSCATED_FLAG:
return True
return getattr(channel, "name", None) == DISCORD_OBFUSCATED_CHANNEL_NAME
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import TYPE_CHECKING # noqa: F401,E402
import asyncio # noqa: F401,E402
import asyncio # noqa: F401,E402
class TextBatchAggregator:
"""Aggregates rapid-fire text events into single messages.
Replaces the ``_enqueue_text_event`` / ``_flush_text_batch`` pattern
previously duplicated in telegram, discord, matrix, wecom, and feishu.
Usage::
self._text_batcher = TextBatchAggregator(
handler=self._message_handler,
batch_delay=0.6,
split_threshold=1900,
)
# In message dispatch:
if msg_type == MessageType.TEXT and self._text_batcher.is_enabled():
self._text_batcher.enqueue(event, session_key)
return
"""
def __init__(
self,
handler,
*,
batch_delay: float = 0.6,
split_delay: float = 2.0,
split_threshold: int = 4000,
):
self._handler = handler
self._batch_delay = batch_delay
self._split_delay = split_delay
self._split_threshold = split_threshold
self._pending: Dict[str, MessageEvent] = {}
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:
"""Add *event* to the pending batch for *key*."""
chunk_len = len(event.text or "")
existing = self._pending.get(key)
if not existing:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending[key] = event
else:
existing.text = f"{existing.text}\n{event.text}"
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
# Cancel prior flush timer, start a new one
prior = self._pending_tasks.get(key)
if prior and not prior.done():
prior.cancel()
self._pending_tasks[key] = asyncio.create_task(self._flush(key))
async def _flush(self, key: str) -> None:
"""Wait then dispatch the batched event for *key*."""
current_task = self._pending_tasks.get(key)
pending = self._pending.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
# Use longer delay when the last chunk looks like a split message
delay = self._split_delay if last_len >= self._split_threshold else self._batch_delay
await asyncio.sleep(delay)
event = self._pending.pop(key, None)
if event:
try:
await self._handler(event)
except Exception:
logger.exception("[TextBatchAggregator] Error dispatching batched event for %s", key)
if self._pending_tasks.get(key) is current_task:
self._pending_tasks.pop(key, None)
def cancel_all(self) -> None:
"""Cancel all pending flush tasks."""
for task in self._pending_tasks.values():
if not task.done():
task.cancel()
self._pending_tasks.clear()
self._pending.clear()
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -1672,13 +1672,3 @@ class QQAdapter(OwnAccessPolicyMixin, BasePlatformAdapter):
with contextlib.suppress(ValueError, TypeError):
return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc)
return datetime.now(tz=timezone.utc)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import base64 # noqa: F401,E402
import mimetypes # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
-11
View File
@@ -290,14 +290,3 @@ def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]:
bytes_read += len(chunk)
full_md5 = md5.hexdigest()
return {"md5": full_md5, "sha1": sha1.hexdigest(), "md5_10m": md5_10m.hexdigest() if need_10m else full_md5}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Optional # noqa: F401,E402
import functools # noqa: F401,E402
ApiRequestFn = Callable[..., Awaitable[Dict[str, Any]]]
# ---- END PLUGIN-COMPAT ----
-96
View File
@@ -189,99 +189,3 @@ def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
channel_id=str(raw.get("channel_id", "")), guild_id=str(raw.get("guild_id", "")),
button_data=str(resolved.get("button_data", "")), button_id=str(resolved.get("button_id", "")),
resolver_user_id=str(resolved.get("user_id", "")))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import logging # noqa: F401,E402
from typing import Awaitable # noqa: F401,E402
from typing import Callable # noqa: F401,E402
from typing import Awaitable # noqa: F401,E402
from typing import Callable # noqa: F401,E402
import logging # noqa: F401,E402
logger = logging.getLogger(__name__)
PostMessageFn = Callable[..., Awaitable[Dict[str, Any]]]
class ApprovalSender:
"""Send an approval-request message with an inline keyboard.
Decoupled from the adapter via callables so it can be unit-tested in
isolation. Pass the adapter's ``_send_message_with_keyboard`` helper
(or any equivalent) as ``post_message``.
"""
def __init__(
self,
post_c2c: PostMessageFn,
post_group: PostMessageFn,
log_tag: str = "QQBot",
) -> None:
self._post_c2c = post_c2c
self._post_group = post_group
self._log_tag = log_tag
async def send(
self,
chat_type: str,
chat_id: str,
req: ApprovalRequest,
msg_id: Optional[str] = None,
) -> bool:
"""Send an approval message to *chat_id*.
:param chat_type: ``'c2c'`` or ``'group'``.
:param chat_id: User openid or group openid.
:param req: :class:`ApprovalRequest`.
:param msg_id: Reply-to message id (required for passive messages).
:returns: ``True`` on success, ``False`` on failure.
"""
text = build_approval_text(req)
keyboard = build_approval_keyboard(req.session_key)
logger.info(
"[%s] Sending approval request to %s:%s (session=%.20s…)",
self._log_tag, chat_type, chat_id, req.session_key,
)
try:
if chat_type == "c2c":
await self._post_c2c(chat_id, text, msg_id, keyboard)
elif chat_type == "group":
await self._post_group(chat_id, text, msg_id, keyboard)
else:
logger.warning(
"[%s] Approval: unsupported chat_type %r",
self._log_tag, chat_type,
)
return False
logger.info(
"[%s] Approval message sent to %s:%s",
self._log_tag, chat_type, chat_id,
)
return True
except Exception as exc:
logger.error(
"[%s] Failed to send approval message to %s:%s: %s",
self._log_tag, chat_type, chat_id, exc,
)
return False
_PLUGIN_COMPAT_LAZY = {
'logger': ('gateway.platforms.qqbot.adapter', 'logger'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-24
View File
@@ -978,27 +978,3 @@ class SignalAdapter(BasePlatformAdapter):
result = await self._rpc("getContact", {"account": self.account, "contactAddress": chat_id})
name = (result.get("name") or result.get("profileName")) if isinstance(result, dict) else None
return {"name": name or chat_id, "type": "dm", "chat_id": chat_id}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
TYPING_INTERVAL = 8.0 # seconds between typing indicator refreshes
_PLUGIN_COMPAT_LAZY = {
'DEFAULT_EXT_TO_MIME': ('gateway.platforms.media_cache', 'DEFAULT_EXT_TO_MIME'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
-10
View File
@@ -1240,13 +1240,3 @@ async def send_weixin_direct(
adapter._send_session = adapter._session = session
adapter._token_store = token_store
return await _deliver_direct(adapter, chat_id, message, media_files, context_token)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import struct # noqa: F401,E402
MSG_TYPE_USER = 1
# ---- END PLUGIN-COMPAT ----
-202
View File
@@ -2805,205 +2805,3 @@ class YuanbaoAdapter(BasePlatformAdapter):
async def _get_cached_token(self) -> dict:
"""Current valid sign token (module-level cache)."""
return await SignManager.get_token(self._app_key, self._app_secret, self._api_domain, route_env=self._route_env)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
AUTH_FAILED_CODES = {4001, 4002, 4003} # permanent auth failure, re-sign token
AUTH_RETRYABLE_CODES = {4010, 4011, 4099} # transient, can retry with same token
class FileUrlHandler(MediaSendHandler):
"""Strategy: send file from a URL (download → COS → TIMFileElem)."""
async def acquire_file(self, adapter, **kwargs):
file_url: str = kwargs["file_url"]
logger.info("[%s] FileUrlHandler: downloading %s", adapter.name, file_url)
file_bytes, content_type = await media_download_url(
file_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
)
filename = kwargs.get("filename")
if not filename:
path_part = file_url.split("?")[0]
filename = os.path.basename(path_part) or "file"
if not content_type or content_type == "application/octet-stream":
content_type = guess_mime_type(filename) or "application/octet-stream"
return file_bytes, filename, content_type
def build_msg_body(self, upload_result, **kwargs):
return build_file_msg_body(
url=upload_result["url"],
filename=kwargs["filename"],
uuid=kwargs["file_uuid"],
size=upload_result["size"],
)
class GroupQueryService:
"""Encapsulates all group query operations (both low-level WS calls and
higher-level AI-tool-facing wrappers).
Responsibilities:
- Low-level WS encode/decode for group info and member list queries
- Chat-id parsing, error wrapping and result filtering for AI tools
- Member cache population on the adapter
"""
def __init__(self, adapter: "YuanbaoAdapter") -> None:
self._adapter = adapter
# ------------------------------------------------------------------
# Low-level WS query methods
# ------------------------------------------------------------------
async def query_group_info_raw(self, group_code: str) -> Optional[dict]:
"""Query group info via WS (group name, owner, member count, etc.).
Returns:
Decoded dict or None on failure.
"""
adapter = self._adapter
if adapter._connection.ws is None:
return None
encoded = encode_query_group_info(group_code)
from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
decoded = _decode(encoded)
req_id = decoded["head"]["msg_id"]
try:
response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
head = response.get("head", {})
status = head.get("status", 0)
if status != 0:
logger.warning("[%s] query_group_info failed: status=%d", adapter.name, status)
return None
biz_data = response.get("data", b"") or response.get("body", b"")
if biz_data and isinstance(biz_data, bytes):
return decode_query_group_info_rsp(biz_data)
return {"group_code": group_code}
except asyncio.TimeoutError:
logger.warning("[%s] query_group_info timeout: group=%s", adapter.name, group_code)
return None
except Exception as exc:
logger.warning("[%s] query_group_info failed: %s", adapter.name, exc)
return None
async def get_group_member_list_raw(
self, group_code: str, offset: int = 0, limit: int = 200
) -> Optional[dict]:
"""Query group member list via WS.
Returns:
Decoded dict or None on failure. Also populates adapter._member_cache.
"""
adapter = self._adapter
if adapter._connection.ws is None:
return None
encoded = encode_get_group_member_list(group_code, offset=offset, limit=limit)
from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
decoded = _decode(encoded)
req_id = decoded["head"]["msg_id"]
try:
response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
head = response.get("head", {})
status = head.get("status", 0)
if status != 0:
logger.warning("[%s] get_group_member_list failed: status=%d", adapter.name, status)
return None
biz_data = response.get("data", b"") or response.get("body", b"")
if biz_data and isinstance(biz_data, bytes):
result = decode_get_group_member_list_rsp(biz_data)
else:
result = {"members": [], "next_offset": 0, "is_complete": True}
if result and result.get("members"):
adapter._member_cache[group_code] = (time.time(), result["members"])
return result
except asyncio.TimeoutError:
logger.warning("[%s] get_group_member_list timeout: group=%s", adapter.name, group_code)
return None
except Exception as exc:
logger.warning("[%s] get_group_member_list failed: %s", adapter.name, exc)
return None
# ------------------------------------------------------------------
# AI-tool-facing wrappers (chat_id parsing + filtering)
# ------------------------------------------------------------------
async def query_group_info(self, chat_id: str) -> dict:
"""AI tool: Query current group info.
No parameters needed (group_code extracted from session context).
Returns group name, owner, member count, etc.
"""
if not chat_id.startswith("group:"):
return {"error": "This command is only available in group chats"}
group_code = chat_id[len("group:"):]
result = await self.query_group_info_raw(group_code)
if result is None:
return {"error": "Failed to query group info"}
return result
async def query_session_members(
self,
chat_id: str,
action: str = "list_all",
name: Optional[str] = None,
) -> dict:
"""AI tool: Query group member list.
Args:
chat_id: Chat ID (extracted from session context)
action: 'find' (search by name) | 'list_bots' (list bots) | 'list_all' (list all)
name: Search keyword when action='find'
Returns:
{"members": [...], "total": int, "mentionHint": str}
"""
if not chat_id.startswith("group:"):
return {"error": "This command is only available in group chats"}
group_code = chat_id[len("group:"):]
result = await self.get_group_member_list_raw(group_code)
if result is None:
return {"error": "Failed to query group members"}
members = result.get("members", [])
if action == "find" and name:
query = name.lower()
members = [
m for m in members
if query in (m.get("nickname", "") or "").lower()
or query in (m.get("name_card", "") or "").lower()
or query in (m.get("user_id", "") or "").lower()
]
elif action == "list_bots":
members = [m for m in members if "bot" in (m.get("nickname", "") or "").lower()]
# Construct mentionHint
mention_hint = ""
if members and len(members) <= 10:
names = [m.get("name_card") or m.get("nickname") or m.get("user_id", "") for m in members]
mention_hint = "Mention with @name: " + ", ".join(names)
return {
"members": members[:50], # Limit return count
"total": len(members),
"mentionHint": mention_hint,
}
REPLY_REF_TTL_S = 300.0 # Reference dedup TTL (5 minutes)
def get_active_adapter() -> Optional["YuanbaoAdapter"]:
"""Delegate to ``YuanbaoAdapter.get_active()``."""
return YuanbaoAdapter.get_active()
async def send_yuanbao_direct(
adapter: "YuanbaoAdapter",
chat_id: str,
message: str,
media_files: Optional[List[Tuple[str, bool]]] = None,
) -> Dict[str, Any]:
"""Delegate to ``OutboundManager.send_direct``."""
return await adapter._outbound.sender.send_direct(chat_id, message, media_files)
# ---- END PLUGIN-COMPAT ----
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more