mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
fe18e71863
commit
a5bd246865
360 files changed
+36
-27747
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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 ----
|
||||
@@ -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
Reference in new issue
Block a user