Merge branch 'simp/r3-22-A' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:58:11 -07:00
6 changed files with 681 additions and 1668 deletions
+33 -74
View File
@@ -1,8 +1,7 @@
"""Plugin capability declarations + consent state (#64228).
"""Plugin capability declarations + consent state.
Canonical registry ------------------ Every capability id maps 1:1 to a trust gate that **already
exists** on the enforcing surface. We deliberately do not mint capability ids without an enforcing
gate:
Every capability id maps 1:1 to a trust gate that already exists on the enforcing surface; ids
without an enforcing gate are deliberately never minted.
"""
from __future__ import annotations
@@ -21,15 +20,11 @@ class CapabilitySpec:
"""One declarable capability and the legacy gate it maps to."""
id: str
# Path of the deprecated boolean under ``plugins.entries.<plugin_id>``,
# e.g. ("allow_tool_override",) or ("llm", "allow_model_override").
legacy_path: Tuple[str, ...]
# One-line risk description shown on the consent screen.
description: str
legacy_path: Tuple[str, ...] # deprecated boolean under plugins.entries.<id>, e.g. ("llm", "allow_model_override")
description: str # one-line risk description shown on the consent screen
# Canonical registry — ONLY capabilities with an existing enforcing surface.
# (id, legacy_path, description)
# (id, legacy_path, description) — ONLY capabilities with an existing enforcing surface.
_CAPABILITY_ROWS = (
("tools.override", ("allow_tool_override",),
"Replace built-in tools (e.g. shell_exec, write_file) — an "
@@ -49,12 +44,10 @@ _CAPABILITY_ROWS = (
"task lanes"),
("gateway.platform_actions", ("allow_platform_actions",),
"Act on connected chat platforms as the gateway bot "
"(add reactions, rename threads) via ctx.platform_actions"),
)
"(add reactions, rename threads) via ctx.platform_actions"))
CAPABILITY_REGISTRY: Dict[str, CapabilitySpec] = {
cid: CapabilitySpec(id=cid, legacy_path=path, description=desc) for cid, path, desc in _CAPABILITY_ROWS
cid: CapabilitySpec(cid, path, desc) for cid, path, desc in _CAPABILITY_ROWS
}
VALID_CAPABILITY_IDS = frozenset(CAPABILITY_REGISTRY)
# Config keys under ``plugins.entries.<plugin_id>``.
@@ -62,24 +55,18 @@ GRANTED_KEY = "granted_capabilities"
CONSENT_KEY = "capabilities_consent"
# ---------------------------------------------------------------------------
# Declaration parsing
# ---------------------------------------------------------------------------
def parse_declared_capabilities(raw: Any, plugin_name: str = "?") -> List[str]:
"""Normalize a manifest ``capabilities:`` value into known capability ids.
Unknown ids are dropped with a warning (forward compat: a plugin built for a newer Hermes may
declare ids this build doesn't know; they can never be granted here, so hiding them from the
consent screen is the fail-closed choice — the plugin must degrade gracefully).
Unknown ids are dropped with a warning: they can never be granted by this build, so hiding
them from the consent screen is the fail-closed choice (the plugin must degrade gracefully).
"""
if not raw:
return []
if not isinstance(raw, (list, tuple)):
logger.warning(
"Plugin %s: manifest 'capabilities' must be a list, got %s — ignoring",
plugin_name, type(raw).__name__,
)
plugin_name, type(raw).__name__)
return []
out: List[str] = []
for item in raw:
@@ -90,8 +77,7 @@ def parse_declared_capabilities(raw: Any, plugin_name: str = "?") -> List[str]:
if cap not in VALID_CAPABILITY_IDS:
logger.warning(
"Plugin %s: unknown capability %r (known: %s) — ignoring",
plugin_name, cap, ", ".join(sorted(VALID_CAPABILITY_IDS)),
)
plugin_name, cap, ", ".join(sorted(VALID_CAPABILITY_IDS)))
elif cap not in out:
out.append(cap)
return out
@@ -108,29 +94,27 @@ def capability_set_hash(capabilities: Iterable[str]) -> str:
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Consent state (read side — fail closed on ANY error)
# ---------------------------------------------------------------------------
# ── Consent state (read side — fail closed on ANY error) ────────────────────────────────────
def _plugin_entry(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> dict:
"""Return ``plugins.entries.<plugin_id>`` or ``{}`` — never raises."""
"""``plugins.entries.<plugin_id>`` or ``{}`` — never raises (unreadable state = not granted)."""
try:
cfg: Any = config
if cfg is None:
from hermes_cli.config import load_config
cfg = load_config() or {}
entries = (cfg.get("plugins") or {}).get("entries") or {}
entry = entries.get(plugin_id) or {}
entry = ((cfg.get("plugins") or {}).get("entries") or {}).get(plugin_id) or {}
return entry if isinstance(entry, dict) else {}
except Exception:
# Ground rule: failure to read consent state = not granted.
return {}
def granted_capabilities(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> frozenset:
"""Return the set of capabilities the user has granted this plugin."""
"""The set of capabilities the user has granted this plugin."""
raw = _plugin_entry(plugin_id, config).get(GRANTED_KEY)
return frozenset(_known(c.strip() for c in raw if isinstance(c, str))) if isinstance(raw, list) else frozenset()
if not isinstance(raw, list):
return frozenset()
return frozenset(_known(c.strip() for c in raw if isinstance(c, str)))
def _legacy_gate_set(entry: Mapping[str, Any], spec: CapabilitySpec) -> bool:
@@ -144,12 +128,8 @@ def _legacy_gate_set(entry: Mapping[str, Any], spec: CapabilitySpec) -> bool:
def plugin_capability_granted(plugin_id: str, capability: str, config: Optional[Mapping[str, Any]] = None) -> bool:
"""Canonical check: is *capability* live for *plugin_id*?
True when the capability is in ``granted_capabilities`` (consent flow) OR the legacy
``allow_*`` key is set (deprecated, still honored so existing configs keep working). Unknown
ids and any failure to read state return ``False`` (fail closed).
"""
"""Canonical check: is *capability* live for *plugin_id*? True via ``granted_capabilities`` OR
the deprecated-but-honored legacy ``allow_*`` key. Unknown ids / unreadable state -> False."""
spec = CAPABILITY_REGISTRY.get(capability)
if spec is None:
logger.debug("capability check for unknown id %r (plugin %s) — denied", capability, plugin_id)
@@ -161,17 +141,13 @@ def plugin_capability_granted(plugin_id: str, capability: str, config: Optional[
allowed, evidence = True, f"legacy key plugins.entries.{plugin_id}.{'.'.join(spec.legacy_path)} (deprecated)"
else:
allowed, evidence = False, "not granted"
# Audit line for capability gate decisions (the ``checked_by`` trail).
logger.info(
logger.info( # audit trail for capability gate decisions
"capability_check plugin=%s capability=%s decision=%s checked_by=plugin_capability_granted evidence=%s",
plugin_id, capability, "allow" if allowed else "deny", evidence,
)
plugin_id, capability, "allow" if allowed else "deny", evidence)
return allowed
# ---------------------------------------------------------------------------
# Consent state (write side)
# ---------------------------------------------------------------------------
# ── Consent state (write side) ──────────────────────────────────────────────────────────────
def _child_dict(parent: dict, key: str) -> dict:
"""``parent[key]`` as a dict, replacing any non-dict value in place."""
@@ -183,17 +159,12 @@ def _child_dict(parent: dict, key: str) -> dict:
def record_consent(plugin_id: str, granted: Iterable[str], declared: Iterable[str]) -> None:
"""Persist a consent decision for *plugin_id*.
Writes ``granted_capabilities`` (union with prior grants), the consent record (hash of the
declared set the user saw + UTC timestamp), and the legacy ``allow_*`` keys for each new grant
so every existing enforcement site keeps working unchanged.
"""
"""Persist a consent decision: ``granted_capabilities`` (union with prior grants), the consent
record (hash of the declared set the user saw + UTC timestamp), and the legacy ``allow_*`` key
for each grant so existing enforcement sites keep working unchanged."""
from hermes_cli.config import load_config, save_config
config = load_config()
entry = _child_dict(_child_dict(_child_dict(config, "plugins"), "entries"), plugin_id)
previous = entry.get(GRANTED_KEY)
merged = (list(previous) if isinstance(previous, list) else []) + _known(granted)
entry[GRANTED_KEY] = sorted(_known(c for c in merged if isinstance(c, str)))
@@ -201,9 +172,7 @@ def record_consent(plugin_id: str, granted: Iterable[str], declared: Iterable[st
"hash": capability_set_hash(_known(declared)),
"granted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
# Bridge: mirror each granted capability into its legacy gate so the
# existing enforcement sites (which still read allow_*) honor the grant.
# Bridge: mirror each grant into its legacy gate (enforcement sites still read allow_*).
for cap in entry[GRANTED_KEY]:
*parents, leaf = CAPABILITY_REGISTRY[cap].legacy_path
node = entry
@@ -213,10 +182,8 @@ def record_consent(plugin_id: str, granted: Iterable[str], declared: Iterable[st
save_config(config)
logger.info(
"capability_consent plugin=%s granted=%s declared_hash=%s",
plugin_id, ",".join(entry[GRANTED_KEY]) or "(none)",
entry[CONSENT_KEY]["hash"][:12],
)
"capability_consent plugin=%s granted=%s declared_hash=%s", plugin_id,
",".join(entry[GRANTED_KEY]) or "(none)", entry[CONSENT_KEY]["hash"][:12])
def consent_hash(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> Optional[str]:
@@ -229,13 +196,8 @@ def consent_hash(plugin_id: str, config: Optional[Mapping[str, Any]] = None) ->
def pending_capabilities(
plugin_id: str, declared: Iterable[str], config: Optional[Mapping[str, Any]] = None
) -> List[str]:
"""Capabilities declared by the plugin but not yet granted.
Used both at first consent (everything is pending) and on update re-consent: when a new version
declares capabilities the granted set lacks, those additions are returned and must be re-
consented before they go live. The stored consent hash tells whether the *declared* set changed
since the user last saw it.
"""
"""Declared-but-ungranted capabilities: everything at first consent, only the additions on an
update re-consent (they must be re-consented before going live)."""
granted = granted_capabilities(plugin_id, config)
return [c for c in _known(declared) if c not in granted]
@@ -243,9 +205,6 @@ def pending_capabilities(
def declared_set_changed(
plugin_id: str, declared: Iterable[str], config: Optional[Mapping[str, Any]] = None
) -> bool:
"""True when the declared set differs from what the user consented to.
No stored consent at all counts as changed (never consented).
"""
"""True when the declared set differs from what the user consented to (or never consented)."""
stored = consent_hash(plugin_id, config)
return stored is None or stored != capability_set_hash(_known(declared))
+61 -138
View File
@@ -1,8 +1,5 @@
"""Runtime-backed validation behind ``hermes plugins doctor``.
This core command keeps that contribution's manifest/import/ registration validation intent while
routing every check through the current runtime contracts instead of maintaining a parallel scanner.
"""
"""Runtime-backed validation behind ``hermes plugins doctor``: every manifest/import/registration
check routes through the real runtime contracts instead of a parallel scanner."""
from __future__ import annotations
@@ -32,43 +29,29 @@ def _deny_network(*_args: Any, **_kwargs: Any) -> None:
@contextmanager
def _doctor_runtime(plugin_path: Path):
"""Load one plugin through the real runtime and restore global state.
"""Load one plugin through the real runtime and restore global state afterwards.
Deliberately private Doctor machinery, not a plugin test framework. Registration code runs
under a temporary HERMES_HOME with outbound socket connects blocked.
Private Doctor machinery, not a plugin test framework. Registration code runs under a temporary
HERMES_HOME with outbound socket connects blocked.
"""
stack = ExitStack()
try:
# The temp dir enters the stack FIRST so any failure below — including
# ENOSPC or KeyboardInterrupt inside copytree — deterministically
# removes it instead of stranding a hermes-plugin-doctor-* directory
# until interpreter GC (or never, on a hard exit).
temporary_home = stack.enter_context(
tempfile.TemporaryDirectory(prefix="hermes-plugin-doctor-")
)
home = Path(temporary_home)
# The temp dir enters the stack FIRST so any failure below (ENOSPC / KeyboardInterrupt in
# copytree) removes it instead of stranding a hermes-plugin-doctor-* directory.
home = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="hermes-plugin-doctor-")))
bundled = home / "bundled-plugins"
plugins_root = home / "plugins"
bundled.mkdir(parents=True)
plugins_root.mkdir(parents=True)
copied = plugins_root / plugin_path.name
shutil.copytree(
plugin_path,
copied,
ignore=shutil.ignore_patterns(".git", "__pycache__", ".pytest_cache", "*.pyc"),
)
stack.enter_context(
patch.dict(
os.environ,
{
"HERMES_HOME": str(home),
"HERMES_BUNDLED_PLUGINS": str(bundled),
"HERMES_ENABLE_PROJECT_PLUGINS": "0",
},
clear=False,
)
)
plugin_path, copied,
ignore=shutil.ignore_patterns(".git", "__pycache__", ".pytest_cache", "*.pyc"))
stack.enter_context(patch.dict(os.environ, {
"HERMES_HOME": str(home),
"HERMES_BUNDLED_PLUGINS": str(bundled),
"HERMES_ENABLE_PROJECT_PLUGINS": "0",
}, clear=False))
stack.enter_context(patch.object(socket, "create_connection", _deny_network))
stack.enter_context(patch.object(socket.socket, "connect", _deny_network))
stack.enter_context(patch.object(socket.socket, "connect_ex", _deny_network))
@@ -78,25 +61,17 @@ def _doctor_runtime(plugin_path: Path):
from hermes_cli.plugins import PluginManager
from tools.registry import registry
entries_before = {entry.name: entry for entry in registry._snapshot_entries()}
policy_before = dict(registry._plugin_override_policy)
modules_before = {
name
for name in sys.modules
if name == "hermes_plugins" or name.startswith("hermes_plugins.")
}
modules_before = {name for name in sys.modules if _is_plugin_module(name)}
manager = PluginManager()
try:
manifests = manager._scan_directory(plugins_root, source="user")
if not manifests:
raise _DoctorLoadError(
f"Hermes discovery found no valid plugin manifest under {copied}"
)
raise _DoctorLoadError(f"Hermes discovery found no valid plugin manifest under {copied}")
if len(manifests) != 1:
raise _DoctorLoadError(
f"Expected one plugin manifest, discovered {len(manifests)} under {copied}"
)
f"Expected one plugin manifest, discovered {len(manifests)} under {copied}")
manifest = manifests[0]
manager._load_plugin(manifest)
loaded = manager._plugins.get(manifest.key or manifest.name)
@@ -107,11 +82,8 @@ def _doctor_runtime(plugin_path: Path):
if not loaded.enabled:
raise _DoctorLoadError("Plugin registration did not enable the runtime record")
yield SimpleNamespace(
manifest=manifest,
manager=manager,
registered_tools=tuple(sorted(loaded.tools_registered)),
registered_hooks=tuple(loaded.hooks_registered),
)
manifest=manifest, manager=manager, registered_tools=tuple(sorted(loaded.tools_registered)),
registered_hooks=tuple(loaded.hooks_registered))
finally:
entries_after = {entry.name: entry for entry in registry._snapshot_entries()}
changed_names = {
@@ -131,14 +103,15 @@ def _doctor_runtime(plugin_path: Path):
if changed_names:
registry._generation += 1
for name in list(sys.modules):
if (
name not in modules_before
and (name == "hermes_plugins" or name.startswith("hermes_plugins."))
):
if name not in modules_before and _is_plugin_module(name):
sys.modules.pop(name, None)
stack.close()
def _is_plugin_module(name: str) -> bool:
return name == "hermes_plugins" or name.startswith("hermes_plugins.")
@dataclass(frozen=True)
class DoctorFinding:
level: Literal["error", "warning"]
@@ -155,7 +128,7 @@ class DoctorReport:
@property
def ok(self) -> bool:
return not any(finding.level == "error" for finding in self.findings)
return all(finding.level != "error" for finding in self.findings)
def error(self, message: str) -> None:
self.findings.append(DoctorFinding("error", message))
@@ -168,19 +141,13 @@ class DoctorReport:
if self.manifest is not None:
lines.append(
f" manifest: {self.manifest.name} "
f"{self.manifest.version or '(no version)'} ({self.manifest.kind})"
)
f"{self.manifest.version or '(no version)'} ({self.manifest.kind})")
for finding in self.findings:
marker = "ERROR" if finding.level == "error" else "WARN"
lines.append(f" {marker}: {finding.message}")
lines.append(f" {'ERROR' if finding.level == 'error' else 'WARN'}: {finding.message}")
if self.ok:
lines.append(
" OK: runtime discovery, manifest parsing, import, and registration passed"
)
lines.append(" OK: runtime discovery, manifest parsing, import, and registration passed")
lines.append(
f" registrations: {len(self.registered_tools)} tool(s), "
f"{len(self.registered_hooks)} hook(s)"
)
f" registrations: {len(self.registered_tools)} tool(s), {len(self.registered_hooks)} hook(s)")
return "\n".join(lines)
@@ -188,20 +155,13 @@ _MANIFEST_NAMES = ("plugin.yaml", "plugin.yml", "plugin.json")
def _has_manifest(path: Path) -> bool:
return any(
(path / name).exists() or (path / name).is_symlink()
for name in _MANIFEST_NAMES
)
return any((path / name).exists() or (path / name).is_symlink() for name in _MANIFEST_NAMES)
def _holds_plugin(path: Path) -> bool:
"""True when plugin discovery would find a manifest under *path*.
Mirrors ``PluginManager._scan_directory``: a manifest in *path* itself or in one immediate
subdirectory (category layout). Doctor copies the resolved directory wholesale before the
runtime can reject it, so an unvalidated resolve is a disk-usage bug: with no argument
Doctor defaults to ``.``, and any directory used to satisfy that.
"""
"""True when discovery would find a manifest in *path* or one immediate subdirectory (category
layout), mirroring ``PluginManager._scan_directory``. Doctor copies the resolved directory
wholesale, so resolving an arbitrary directory (e.g. the default ``.``) would be a disk bug."""
if not path.is_dir():
return False
if _has_manifest(path):
@@ -214,12 +174,8 @@ def _holds_plugin(path: Path) -> bool:
def _is_plugin_id(raw: str) -> bool:
"""True when *raw* can name an installed plugin rather than a path.
Ids are relative and may carry one category segment (``image_gen/openai``). Dot components
are excluded: joining ``.`` onto a plugins root yields the root itself, handing Doctor every
installed plugin at once instead of one.
"""
"""True when *raw* can name an installed plugin (relative, maybe ``category/name``). Dot
components are excluded: ``.`` joined onto a plugins root would hand Doctor every plugin."""
if not raw or PurePath(raw).is_absolute():
return False
parts = PurePath(raw).parts
@@ -236,19 +192,11 @@ def resolve_plugin_path(target: str | os.PathLike[str] | None = None) -> Path:
candidates: list[Path] = []
if _is_plugin_id(raw):
user_root = get_hermes_home() / "plugins"
candidates.append(user_root / raw)
candidates.append(get_hermes_home() / "plugins" / raw)
try:
from hermes_cli.plugins import get_bundled_plugins_dir
bundled = get_bundled_plugins_dir()
candidates.extend(
[
bundled / raw,
bundled / "platforms" / raw,
bundled / "model-providers" / raw,
]
)
candidates += [bundled / raw, bundled / "platforms" / raw, bundled / "model-providers" / raw]
except Exception:
pass
candidates.append(Path.cwd() / ".hermes" / "plugins" / raw)
@@ -259,11 +207,8 @@ def resolve_plugin_path(target: str | os.PathLike[str] | None = None) -> Path:
raise FileNotFoundError(
f"{direct.resolve()} holds no plugin manifest "
f"({', '.join(_MANIFEST_NAMES)}), and {raw!r} is not an installed "
"plugin id. Point Doctor at a plugin directory."
)
raise FileNotFoundError(
f"Plugin {raw!r} was not found as a path or installed plugin id"
)
"plugin id. Point Doctor at a plugin directory.")
raise FileNotFoundError(f"Plugin {raw!r} was not found as a path or installed plugin id")
def _accepts_var_kwargs(callback: Any) -> bool:
@@ -275,18 +220,15 @@ def _accepts_var_kwargs(callback: Any) -> bool:
def _check_manifest_v2(report: "DoctorReport", manifest: Any) -> None:
"""Manifest v2 (#64165) checks: versions, deps, pip declarations, schema."""
"""Manifest v2 checks: versions, deps, pip declarations, config schema."""
import importlib.metadata
import re as _re
from hermes_cli.plugins import SUPPORTED_MANIFEST_VERSION
mv = getattr(manifest, "manifest_version", 1)
if mv > SUPPORTED_MANIFEST_VERSION:
report.warning(
f"manifest_version {mv} is newer than this Hermes supports "
f"({SUPPORTED_MANIFEST_VERSION}); unknown fields are ignored"
)
f"({SUPPORTED_MANIFEST_VERSION}); unknown fields are ignored")
api_version = getattr(manifest, "api_version", None)
if api_version is not None and api_version < 1:
@@ -301,8 +243,7 @@ def _check_manifest_v2(report: "DoctorReport", manifest: Any) -> None:
if vr:
report.warning(
f"requires plugin {dep_id!r} ({vr}) — version ranges are "
"advisory; a missing dependency logs a warning at load"
)
"advisory; a missing dependency logs a warning at load")
pydeps = getattr(manifest, "python_dependencies", []) or []
missing: list[str] = []
@@ -322,29 +263,20 @@ def _check_manifest_v2(report: "DoctorReport", manifest: Any) -> None:
for req in unpinned:
report.warning(
f"python_dependencies entry {req!r} has no upper bound — "
"pin an upper bound (e.g. 'pkg>=1.0,<2') per the dependency policy"
)
"pin an upper bound (e.g. 'pkg>=1.0,<2') per the dependency policy")
if missing:
report.warning(
"declared python_dependencies not installed: "
+ ", ".join(missing)
+ " — Hermes never auto-installs plugin dependencies; "
+ "install manually: pip install "
+ " ".join(f"'{m}'" for m in missing)
)
"declared python_dependencies not installed: " + ", ".join(missing)
+ " — Hermes never auto-installs plugin dependencies; install manually: pip install "
+ " ".join(f"'{m}'" for m in missing))
schema = getattr(manifest, "config_schema", {}) or {}
if schema:
from hermes_cli.plugins import _CONFIG_SCHEMA_TYPES
for skey, spec in schema.items():
if not isinstance(spec, dict):
continue
stype = spec.get("type")
stype = spec.get("type") if isinstance(spec, dict) else None
if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES:
report.warning(
f"config_schema key {skey!r} declares unknown type {stype!r}"
)
report.warning(f"config_schema key {skey!r} declares unknown type {stype!r}")
def doctor_plugin(target: str | os.PathLike[str] | None = None) -> DoctorReport:
@@ -388,22 +320,18 @@ def doctor_plugin(target: str | os.PathLike[str] | None = None) -> DoctorReport:
callback_name = getattr(callback, "__name__", repr(callback))
report.error(
f"hook callback {callback_name!r} for {hook_name!r} "
"must accept **kwargs for forward compatibility"
)
"must accept **kwargs for forward compatibility")
declared_hook_names = {name for name in declared_hooks if isinstance(name, str)}
registered_hook_names = set(host.registered_hooks)
for name in sorted(declared_hook_names - registered_hook_names):
report.warning(f"manifest declares hook {name!r} but registration did not add it")
for name in sorted(registered_hook_names - declared_hook_names):
report.warning(f"registration adds hook {name!r} not listed in provides_hooks")
declared_tool_names = {name for name in declared_tools if isinstance(name, str)}
registered_tool_names = set(host.registered_tools)
for name in sorted(declared_tool_names - registered_tool_names):
report.warning(f"manifest declares tool {name!r} but registration did not add it")
for name in sorted(registered_tool_names - declared_tool_names):
report.warning(f"registration adds tool {name!r} not listed in provides_tools")
for kind, declared, registered in (
("hook", declared_hooks, host.registered_hooks),
("tool", declared_tools, host.registered_tools),
):
declared_names = {name for name in declared if isinstance(name, str)}
registered_names = set(registered)
for name in sorted(declared_names - registered_names):
report.warning(f"manifest declares {kind} {name!r} but registration did not add it")
for name in sorted(registered_names - declared_names):
report.warning(f"registration adds {kind} {name!r} not listed in provides_{kind}s")
_check_manifest_v2(report, host.manifest)
except _DoctorLoadError as exc:
@@ -413,9 +341,4 @@ def doctor_plugin(target: str | os.PathLike[str] | None = None) -> DoctorReport:
return report
__all__ = [
"DoctorFinding",
"DoctorReport",
"doctor_plugin",
"resolve_plugin_path",
]
__all__ = ["DoctorFinding", "DoctorReport", "doctor_plugin", "resolve_plugin_path"]
+29 -68
View File
@@ -1,10 +1,8 @@
"""Community plugin index — fetch, cache, search, and name resolution.
Mirrors the Skills Hub catalog pattern (``tools/skills_hub.py``): a static machine-readable JSON
index hosted at a canonical URL, cached locally under ``HERMES_HOME/cache/`` with a TTL, with a
bundled seed file as the offline fallback and format reference.
Fallback chain: remote index → cached copy (fresh or stale) → bundled seed.
Mirrors the Skills Hub catalog (``tools/skills_hub.py``): a static JSON index at a canonical URL,
cached under ``HERMES_HOME/cache/`` with a TTL, bundled seed as offline fallback / format reference.
Fallback chain: fresh cache → remote → stale cache → bundled seed.
"""
from __future__ import annotations
@@ -21,24 +19,15 @@ from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Canonical index location. Override via config key ``plugins.index_url``.
DEFAULT_INDEX_URL = (
"https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json"
)
# Cache the fetched index for 24 hours; a stale cache is still preferred over
# the bundled seed when the remote is unreachable.
INDEX_CACHE_TTL = 24 * 3600
# Bundled seed — offline fallback and the machine-readable format reference.
DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json"
INDEX_CACHE_TTL = 24 * 3600 # a stale cache still beats the bundled seed when remote is unreachable
SEED_INDEX_PATH = Path(__file__).parent / "data" / "plugin_index.json"
_FETCH_TIMEOUT = 10.0
_MAX_INDEX_BYTES = 5 * 1024 * 1024 # refuse absurdly large index payloads
SECURITY_FOOTER = (
"Indexed \u2260 audited: inclusion in the index is a metadata review only, "
"not a code audit. Review a plugin before enabling it."
)
"not a code audit. Review a plugin before enabling it.")
@dataclass
@@ -96,17 +85,16 @@ def get_index_url() -> str:
def _parse_entries(raw: Any) -> List[PluginIndexEntry]:
"""Parse a decoded index document into entries, skipping malformed items."""
"""Parse a decoded index document (object with ``plugins`` or bare list), skipping malformed items."""
if isinstance(raw, dict):
raw = raw.get("plugins", [])
if not isinstance(raw, list):
raise ValueError("Plugin index 'plugins' field must be a list.")
elif not isinstance(raw, list): # bare-list form also accepted
elif not isinstance(raw, list):
raise ValueError("Plugin index must be a JSON object or list.")
items = raw
entries: List[PluginIndexEntry] = []
for item in items:
for item in raw:
if not isinstance(item, dict):
continue
name = item.get("name")
@@ -118,21 +106,18 @@ def _parse_entries(raw: Any) -> List[PluginIndexEntry]:
continue
subdir = item.get("subdir")
api_version = item.get("api_version")
entries.append(
PluginIndexEntry(
name=name.strip(),
description=str(item.get("description") or ""),
author=str(item.get("author") or ""),
tags=[str(t) for t in item.get("tags") or [] if isinstance(t, (str, int))],
repo=repo.strip(),
ref=str(item.get("ref") or ""),
subdir=str(subdir).strip("/") if isinstance(subdir, str) and subdir.strip("/") else None,
homepage=str(item["homepage"]) if item.get("homepage") else None,
capabilities=[str(c) for c in item.get("capabilities") or []],
api_version=int(api_version) if isinstance(api_version, (int, str)) and str(api_version).isdigit() else None,
added_at=str(item["added_at"]) if item.get("added_at") else None,
)
)
entries.append(PluginIndexEntry(
name=name.strip(),
description=str(item.get("description") or ""),
author=str(item.get("author") or ""),
tags=[str(t) for t in item.get("tags") or [] if isinstance(t, (str, int))],
repo=repo.strip(),
ref=str(item.get("ref") or ""),
subdir=subdir.strip("/") if isinstance(subdir, str) and subdir.strip("/") else None,
homepage=str(item["homepage"]) if item.get("homepage") else None,
capabilities=[str(c) for c in item.get("capabilities") or []],
api_version=int(api_version) if isinstance(api_version, (int, str)) and str(api_version).isdigit() else None,
added_at=str(item["added_at"]) if item.get("added_at") else None))
return entries
@@ -163,7 +148,6 @@ def _write_cache(text: str) -> None:
cache = _cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
from utils import atomic_write_text
atomic_write_text(cache, text)
except OSError as exc: # pragma: no cover - best effort
logger.debug("plugin index: cache write failed: %s", exc)
@@ -189,51 +173,34 @@ def _fetch_remote() -> Optional[List[PluginIndexEntry]]:
def load_index(*, refresh: bool = False, offline: bool = False) -> tuple[List[PluginIndexEntry], str]:
"""Load the plugin index.
Returns ``(entries, source)`` where *source* is one of ``"remote"``, ``"cache"``, or ``"seed"``.
Order: fresh cache (unless *refresh*) → remote → stale cache → bundled seed. ``offline=True``
skips the network entirely.
"""
"""Load the plugin index as ``(entries, source)``; source is ``"remote"``/``"cache"``/``"seed"``.
Order: fresh cache (unless *refresh*) → remote (unless *offline*) → stale cache → seed."""
if not refresh:
cached = _read_cache(max_age=INDEX_CACHE_TTL)
if cached is not None:
return cached, "cache"
if not offline:
remote = _fetch_remote()
if remote is not None:
return remote, "remote"
stale = _read_cache(max_age=None)
if stale is not None:
return stale, "cache"
return _load_seed_entries(), "seed"
# Search
def _score_entry(entry: PluginIndexEntry, term: str) -> float:
"""Fuzzy relevance score for *entry* against lowercase *term* (0 = no match)."""
import difflib
name = entry.name.lower()
tags = [t.lower() for t in entry.tags]
if term == name:
return 100.0
# Fuzzy close-match on the name for typo tolerance.
ratio = difflib.SequenceMatcher(None, term, name).ratio()
ratio = difflib.SequenceMatcher(None, term, name).ratio() # typo tolerance on the name
signals = (
(term in name, 80.0),
(term in tags, 70.0),
(any(term in t for t in tags), 55.0),
(term in entry.description.lower(), 50.0),
(term in entry.author.lower(), 40.0),
(ratio >= 0.6, ratio * 60.0),
)
(term in name, 80.0), (term in tags, 70.0), (any(term in t for t in tags), 55.0),
(term in entry.description.lower(), 50.0), (term in entry.author.lower(), 40.0),
(ratio >= 0.6, ratio * 60.0))
return max((points for hit, points in signals if hit), default=0.0)
@@ -245,11 +212,9 @@ def search_index(
if capability:
cap = capability.lower()
pool = [e for e in entries if any(cap == c.lower() for c in e.capabilities)]
term = (term or "").strip().lower()
if not term:
return sorted(pool, key=lambda e: e.name)
matched = [(e, s) for e in pool if (s := _score_entry(e, term)) > 0]
matched.sort(key=lambda pair: (-pair[1], pair[0].name))
return [e for e, _s in matched]
@@ -258,12 +223,8 @@ def search_index(
def resolve_name(
entries: List[PluginIndexEntry], name: str
) -> tuple[Optional[PluginIndexEntry], List[PluginIndexEntry]]:
"""Resolve a bare plugin *name* against the index.
Returns ``(entry, candidates)``: an exact (case-insensitive) unique match in ``entry``,
otherwise ``entry is None`` and ``candidates`` holds any partial matches (empty = nothing
similar, >1 on exact = ambiguous).
"""
"""Resolve a bare *name*: ``(entry, candidates)`` — a unique case-insensitive match in
``entry``, else ``None`` with the partial matches (empty = nothing similar, >1 = ambiguous)."""
lowered = name.strip().lower()
exact = [e for e in entries if e.name.lower() == lowered]
matches = exact or [e for e in entries if lowered in e.name.lower()]
+144 -314
View File
@@ -1,32 +1,26 @@
"""Plugin packs — declarative, shareable plugin sets (#64166).
"""Plugin packs — declarative, shareable plugin sets.
* Every plugin entry MUST pin an exact 40-character commit SHA in ``ref``. Tags and branch names are
Every plugin entry MUST pin an exact 40-character commit SHA in ``ref``; tags and branch names are
rejected with an error naming the entry.
"""
from __future__ import annotations
import logging
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, List, Optional
logger = __import__("logging").getLogger(__name__)
logger = logging.getLogger(__name__)
_EXACT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
# Key names that look like secrets are refused in pack config seeds and
# stripped from exports. Packs declare needed secrets via each plugin's own
# ``requires_env`` manifest field, which prompts at install time.
_SECRET_KEY_RE = re.compile(
r"(?i)(token|secret|passw(or)?d|api[_-]?key|private[_-]?key|credential|auth)"
)
# plugins.entries.<id> keys a pack may never set: consent/capability state
# and the deprecated allow_* trust gates. A pack cannot pre-grant anything.
# Secret-shaped keys are refused in config seeds and stripped from exports; packs declare secrets
# via each plugin's ``requires_env``, which prompts at install time.
_SECRET_KEY_RE = re.compile(r"(?i)(token|secret|passw(or)?d|api[_-]?key|private[_-]?key|credential|auth)")
# plugins.entries.<id> keys a pack may never set (consent state; allow_* gates are also refused).
_RESERVED_ENTRY_KEYS = frozenset({"granted_capabilities", "capabilities_consent"})
_MAX_PACK_BYTES = 1 * 1024 * 1024 # a pack is a small manifest, not a payload
_FETCH_TIMEOUT = 15.0
@@ -51,23 +45,11 @@ class PackPluginEntry:
@property
def install_identifier(self) -> Optional[str]:
"""Identifier for the existing install path (None for bare names,
which must first resolve through the community index)."""
"""Identifier for the install path; None for bare names (resolved via the community index)."""
if self.repo:
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
return None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {}
if self.name:
d["name"] = self.name
if self.repo:
d["repo"] = self.repo
d["ref"] = self.ref
if self.subdir:
d["subdir"] = self.subdir
return d
@dataclass
class PluginPack:
@@ -83,25 +65,8 @@ class PluginPack:
# Skill-hub ids. Parsed + displayed, NOT installed (documented seam).
skills: List[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"name": self.name}
if self.description:
d["description"] = self.description
if self.author:
d["author"] = self.author
if self.version:
d["version"] = self.version
d["plugins"] = [p.to_dict() for p in self.plugins]
if self.config:
d["config"] = self.config
if self.skills:
d["skills"] = list(self.skills)
return d
# ---------------------------------------------------------------------------
# Parse + validate
# ---------------------------------------------------------------------------
# ── Parse + validate ────────────────────────────────────────────────────────────────────────
def _entry_label(item: Any, index: int) -> str:
if isinstance(item, dict):
@@ -112,40 +77,30 @@ def _entry_label(item: Any, index: int) -> str:
def validate_config_seed(plugin_id: str, seed: Any) -> dict[str, Any]:
"""Validate one plugin's config seed mapping and return it.
Rejects non-dict seeds, reserved consent/capability keys, deprecated ``allow_*`` trust gates,
and secret-shaped key names -- a pack must not be able to grant itself trust or ship secrets.
"""
"""Validate one plugin's config seed mapping and return a copy. Rejects non-dict seeds,
reserved consent keys, ``allow_*`` trust gates, and secret-shaped keys."""
if not isinstance(seed, dict):
raise PackError(
f"Pack config for plugin '{plugin_id}' must be a mapping of "
f"plugins.entries.{plugin_id} keys."
)
f"Pack config for plugin '{plugin_id}' must be a mapping of plugins.entries.{plugin_id} keys.")
for key in seed:
if not isinstance(key, str) or not key.strip():
raise PackError(
f"Pack config for plugin '{plugin_id}' has an invalid key: {key!r}."
)
raise PackError(f"Pack config for plugin '{plugin_id}' has an invalid key: {key!r}.")
if key in _RESERVED_ENTRY_KEYS or key.startswith("allow_"):
raise PackError(
f"Pack config for plugin '{plugin_id}' sets reserved key "
f"'{key}': packs cannot pre-grant capabilities or trust gates. "
"Capability consent happens interactively at install time."
)
"Capability consent happens interactively at install time.")
if _SECRET_KEY_RE.search(key):
raise PackError(
f"Pack config for plugin '{plugin_id}' sets secret-shaped key "
f"'{key}': secrets never travel in packs. Declare the secret in "
"the plugin's requires_env instead — it is prompted at install."
)
"the plugin's requires_env instead — it is prompted at install.")
return dict(seed)
def parse_pack(text: str, *, source: str = "<pack>") -> PluginPack:
"""Parse and validate a pack YAML document."""
import yaml
try:
raw = yaml.safe_load(text)
except yaml.YAMLError as exc:
@@ -153,76 +108,58 @@ def parse_pack(text: str, *, source: str = "<pack>") -> PluginPack:
if not isinstance(raw, dict):
raise PackError(f"Pack {source} must be a YAML mapping.")
# Accept the issue-sketch nested form (pack: {name: ...}) as sugar.
# Accept the nested form (pack: {name: ...}) as sugar.
meta = raw.get("pack") if isinstance(raw.get("pack"), dict) else raw
name = meta.get("name")
if not isinstance(name, str) or not name.strip():
raise PackError(f"Pack {source} is missing a 'name'.")
plugins_raw = raw.get("plugins")
if not isinstance(plugins_raw, list) or not plugins_raw:
raise PackError(f"Pack {source} must declare a non-empty 'plugins' list.")
entries: List[PackPluginEntry] = []
for i, item in enumerate(plugins_raw):
label = _entry_label(item, i)
if not isinstance(item, dict):
raise PackError(f"Pack plugin entry {label} must be a mapping.")
entry_name = item.get("name")
entry_repo = item.get("repo") or item.get("source")
if isinstance(entry_repo, str):
entry_repo = entry_repo.removeprefix("github:").strip() or None
if entry_name is not None and (
not isinstance(entry_name, str) or not entry_name.strip()
):
raise PackError(f"Pack plugin entry {label} has an invalid 'name'.")
entry_name = entry_name.strip() if isinstance(entry_name, str) else None
if not entry_name and not entry_repo:
raise PackError(
f"Pack plugin entry {label} needs either 'name' (community "
"index) or 'repo' (owner/repo or git URL)."
)
ref = item.get("ref") or item.get("version")
if not isinstance(ref, str) or not _EXACT_SHA_RE.fullmatch(ref.strip()):
raise PackError(
f"Pack plugin entry {label} has ref {ref!r}: refs must be "
"exact 40-character commit SHAs (tags and branch names are "
"rejected — pin the commit for reproducible installs)."
)
subdir = item.get("subdir")
if subdir is not None and (not isinstance(subdir, str) or not subdir.strip("/")):
raise PackError(f"Pack plugin entry {label} has an invalid 'subdir'.")
entries.append(
PackPluginEntry(
ref=ref.strip().lower(),
name=entry_name,
repo=entry_repo,
subdir=subdir.strip("/") if isinstance(subdir, str) else None,
)
)
entries = [_parse_pack_entry(item, _entry_label(item, i)) for i, item in enumerate(plugins_raw)]
config_raw = raw.get("config") or {}
if not isinstance(config_raw, dict):
raise PackError(f"Pack {source} 'config' must be a mapping of plugin id → settings.")
config: dict[str, dict[str, Any]] = {}
for plugin_id, seed in config_raw.items():
config[str(plugin_id)] = validate_config_seed(str(plugin_id), seed)
config = {str(pid): validate_config_seed(str(pid), seed) for pid, seed in config_raw.items()}
skills_raw = raw.get("skills") or []
if not isinstance(skills_raw, list):
raise PackError(f"Pack {source} 'skills' must be a list of skill ids.")
skills = [str(s).strip() for s in skills_raw if str(s).strip()]
return PluginPack(
name=name.strip(),
description=str(meta.get("description") or ""),
author=str(meta.get("author") or ""),
version=str(meta.get("version") or ""),
plugins=entries,
config=config,
skills=skills,
)
name=name.strip(), description=str(meta.get("description") or ""),
author=str(meta.get("author") or ""), version=str(meta.get("version") or ""), plugins=entries,
config=config, skills=[str(s).strip() for s in skills_raw if str(s).strip()])
def _parse_pack_entry(item: Any, label: str) -> PackPluginEntry:
"""Validate one ``plugins:`` item (``name`` and/or ``repo``, exact-SHA ``ref``, ``subdir``)."""
if not isinstance(item, dict):
raise PackError(f"Pack plugin entry {label} must be a mapping.")
entry_name = item.get("name")
entry_repo = item.get("repo") or item.get("source")
if isinstance(entry_repo, str):
entry_repo = entry_repo.removeprefix("github:").strip() or None
if entry_name is not None and (not isinstance(entry_name, str) or not entry_name.strip()):
raise PackError(f"Pack plugin entry {label} has an invalid 'name'.")
entry_name = entry_name.strip() if isinstance(entry_name, str) else None
if not entry_name and not entry_repo:
raise PackError(
f"Pack plugin entry {label} needs either 'name' (community "
"index) or 'repo' (owner/repo or git URL).")
ref = item.get("ref") or item.get("version")
if not isinstance(ref, str) or not _EXACT_SHA_RE.fullmatch(ref.strip()):
raise PackError(
f"Pack plugin entry {label} has ref {ref!r}: refs must be "
"exact 40-character commit SHAs (tags and branch names are "
"rejected — pin the commit for reproducible installs).")
subdir = item.get("subdir")
if subdir is not None and (not isinstance(subdir, str) or not subdir.strip("/")):
raise PackError(f"Pack plugin entry {label} has an invalid 'subdir'.")
return PackPluginEntry(
ref=ref.strip().lower(), name=entry_name, repo=entry_repo,
subdir=subdir.strip("/") if isinstance(subdir, str) else None)
def load_pack(path_or_url: str) -> PluginPack:
@@ -240,9 +177,7 @@ def load_pack(path_or_url: str) -> PluginPack:
raise PackError("Pack payload exceeds the 1 MiB size limit.")
return parse_pack(text, source=path_or_url)
if path_or_url.startswith(("http://", "file://", "ftp://")):
raise PackError(
"Pack URLs must use https:// (or pass a local file path)."
)
raise PackError("Pack URLs must use https:// (or pass a local file path).")
path = Path(path_or_url).expanduser()
if not path.is_file():
raise PackError(f"Pack file not found: {path}")
@@ -251,9 +186,7 @@ def load_pack(path_or_url: str) -> PluginPack:
return parse_pack(path.read_text(encoding="utf-8"), source=str(path))
# ---------------------------------------------------------------------------
# Resolution (bare index names → owner/repo) + review screen
# ---------------------------------------------------------------------------
# ── Resolution (bare index names → owner/repo) + review screen ──────────────────────────────
@dataclass
class ResolvedPackPlugin:
@@ -266,62 +199,38 @@ class ResolvedPackPlugin:
def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]:
"""Resolve every entry; bare names go through the community index.
Resolution failures do not raise — they are carried per-entry so the review screen can show them
and install can report partial failure.
"""
"""Resolve every entry; bare names go through the community index. Failures do not raise —
they are carried per-entry so the review screen shows them and install reports partial failure."""
resolved: List[ResolvedPackPlugin] = []
index_entries = None
for entry in pack.plugins:
ident = entry.install_identifier
if ident is not None:
resolved.append(ResolvedPackPlugin(entry=entry, identifier=ident))
if entry.install_identifier is not None:
resolved.append(ResolvedPackPlugin(entry=entry, identifier=entry.install_identifier))
continue
# Bare community-index name.
try:
from hermes_cli.plugin_index import load_index, resolve_name
if index_entries is None:
from hermes_cli.plugin_index import load_index
index_entries, _src = load_index()
from hermes_cli.plugin_index import resolve_name
match, candidates = resolve_name(index_entries, entry.name or "")
except Exception as exc: # index load must not crash pack handling
resolved.append(
ResolvedPackPlugin(
entry=entry, identifier=None,
resolve_error=f"community index unavailable: {exc}",
)
)
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=None, resolve_error=f"community index unavailable: {exc}"))
continue
if match is None:
detail = (
"ambiguous in the community index"
if len(candidates) > 1
else "not found in the community index"
)
resolved.append(
ResolvedPackPlugin(entry=entry, identifier=None, resolve_error=detail)
)
detail = "ambiguous" if len(candidates) > 1 else "not found"
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=None, resolve_error=f"{detail} in the community index"))
continue
resolved.append(
ResolvedPackPlugin(
entry=entry,
identifier=match.install_identifier,
index_capabilities=list(match.capabilities),
)
)
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=match.install_identifier,
index_capabilities=list(match.capabilities)))
return resolved
def render_pack_review(console, pack: PluginPack, resolved: List[ResolvedPackPlugin]) -> None:
"""Print the full pack review screen (mandatory before install)."""
from rich.table import Table
header = f"[bold]{pack.name}[/bold]"
if pack.version:
header += f" v{pack.version}"
header = f"[bold]{pack.name}[/bold]" + (f" v{pack.version}" if pack.version else "")
if pack.author:
header += f" — by {pack.author}"
console.print(f"\n{header}")
@@ -346,22 +255,15 @@ def render_pack_review(console, pack: PluginPack, resolved: List[ResolvedPackPlu
console.print(f" {plugin_id}.{key} = {value!r}")
if pack.skills:
console.print(
"[yellow]Pack lists skills (NOT auto-installed yet):[/yellow] "
+ ", ".join(pack.skills)
)
console.print(
"[dim]Install them manually, e.g. `hermes skills install <id>`.[/dim]"
)
"[yellow]Pack lists skills (NOT auto-installed yet):[/yellow] " + ", ".join(pack.skills))
console.print("[dim]Install them manually, e.g. `hermes skills install <id>`.[/dim]")
console.print(
"\n[dim]Installing a pack runs third-party code × "
f"{len(resolved)} plugins. Each plugin's declared capabilities still "
"require individual consent after install — a pack never bulk-grants.[/dim]"
)
"require individual consent after install — a pack never bulk-grants.[/dim]")
# ---------------------------------------------------------------------------
# Install fan-out
# ---------------------------------------------------------------------------
# ── Install fan-out ─────────────────────────────────────────────────────────────────────────
@dataclass
class PackInstallResult:
@@ -374,30 +276,20 @@ class PackInstallResult:
def _seed_plugin_config(plugin_id: str, seed: dict[str, Any], console) -> None:
"""Seed plugins.entries.<plugin_id> keys that are not already set.
Existing user values always win; the pack only fills blanks.
"""
"""Seed plugins.entries.<plugin_id> keys that are not already set (user values always win)."""
from hermes_cli.config import load_config, save_config
seed = validate_config_seed(plugin_id, seed)
config = load_config()
plugins_cfg = config.setdefault("plugins", {})
entries = plugins_cfg.setdefault("entries", {})
entry = entries.setdefault(plugin_id, {})
entry = config.setdefault("plugins", {}).setdefault("entries", {}).setdefault(plugin_id, {})
if not isinstance(entry, dict):
console.print(
f"[yellow]Warning:[/yellow] plugins.entries.{plugin_id} is not a "
"mapping; skipping pack config seed."
)
"mapping; skipping pack config seed.")
return
wrote = False
for key, value in seed.items():
if key in entry:
console.print(
f"[dim] plugins.entries.{plugin_id}.{key} already set — keeping "
"your value.[/dim]"
)
console.print(f"[dim] plugins.entries.{plugin_id}.{key} already set — keeping your value.[/dim]")
continue
entry[key] = value
wrote = True
@@ -415,9 +307,9 @@ def install_pack_plugins(
) -> List[PackInstallResult]:
"""Fan a pack out to N ordinary pinned installs; never raises per-plugin.
Each plugin uses the existing exact-ref install path and then the SAME per-plugin capability
consent flow as a single install. Successful installs are enabled (the user consented via the
review screen) and their pack config seed is applied.
Each plugin goes through the exact-ref install path, then the SAME per-plugin capability
consent flow as a single install (a pack never bulk-grants). Successful installs are enabled
(the user consented via the review screen) and their config seed applied.
"""
from hermes_cli.plugins_cmd import (
PluginOperationError,
@@ -430,29 +322,27 @@ def install_pack_plugins(
_save_disabled_set,
_save_enabled_set,
)
results: List[PackInstallResult] = []
def _fail(display: str, error: str) -> None:
results.append(PackInstallResult(display=display, ok=False, error=error))
console.print(f"[red]✗[/red] {display}: {error}")
for rp in resolved:
display = rp.entry.display
if rp.identifier is None:
results.append(
PackInstallResult(display=display, ok=False, error=rp.resolve_error)
)
console.print(f"[red]✗[/red] {display}: {rp.resolve_error}")
_fail(display, rp.resolve_error)
continue
console.print(f"[dim]Installing {display} @ {rp.entry.ref[:12]}...[/dim]")
try:
target, manifest, installed_name = _install_plugin_core(
rp.identifier, force=force, ref=rp.entry.ref
)
rp.identifier, force=force, ref=rp.entry.ref)
except PluginOperationError as exc:
results.append(PackInstallResult(display=display, ok=False, error=str(exc)))
console.print(f"[red]✗[/red] {display}: {exc}")
_fail(display, str(exc))
continue
except Exception as exc: # keep the fan-out alive on unexpected errors
logger.exception("pack install failed for %s", display)
results.append(PackInstallResult(display=display, ok=False, error=str(exc)))
console.print(f"[red]✗[/red] {display}: {exc}")
_fail(display, str(exc))
continue
# Secrets are prompted (requires_env), never carried by the pack.
@@ -461,7 +351,6 @@ def install_pack_plugins(
except Exception:
logger.debug("requires_env prompt failed for %s", installed_name, exc_info=True)
# Enable: the user confirmed the mandatory review screen.
enabled = _get_enabled_set()
disabled = _get_disabled_set()
enabled.add(installed_name)
@@ -469,8 +358,6 @@ def install_pack_plugins(
_save_enabled_set(enabled)
_save_disabled_set(disabled)
# Per-plugin capability consent — the SAME flow as a single
# install (#64228). A pack never bulk-grants capabilities.
declared = _declared_capabilities_from_manifest(manifest, installed_name)
if declared:
_run_capability_consent(console, installed_name, declared, context="install")
@@ -485,19 +372,13 @@ def install_pack_plugins(
console.print(f"[yellow]Warning:[/yellow] {exc}")
console.print(f"[green]✓[/green] {display} installed as [bold]{installed_name}[/bold].")
results.append(
PackInstallResult(display=display, ok=True, installed_name=installed_name)
)
results.append(PackInstallResult(display=display, ok=True, installed_name=installed_name))
return results
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
# ── Export ──────────────────────────────────────────────────────────────────────────────────
_GITHUB_HTTPS_RE = re.compile(
r"^https://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s#]+?)(?:\.git)?$"
)
_GITHUB_HTTPS_RE = re.compile(r"^https://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s#]+?)(?:\.git)?$")
def _source_to_repo_subdir(source: str) -> tuple[Optional[str], Optional[str]]:
@@ -520,46 +401,27 @@ def _sanitized_entry_config(plugin_id: str) -> dict[str, Any]:
config = load_config() or {}
except Exception:
return {}
entries = (config.get("plugins") or {}).get("entries") or {}
entry = entries.get(plugin_id)
entry = ((config.get("plugins") or {}).get("entries") or {}).get(plugin_id)
if not isinstance(entry, dict):
return {}
out: dict[str, Any] = {}
for key, value in entry.items():
if not isinstance(key, str):
continue
if key in _RESERVED_ENTRY_KEYS or key.startswith("allow_"):
continue
if _SECRET_KEY_RE.search(key):
continue
if isinstance(value, (str, int, float, bool)) or value is None or isinstance(value, (list, dict)):
out[key] = value
return out
return {
key: value for key, value in entry.items()
if isinstance(key, str)
and key not in _RESERVED_ENTRY_KEYS
and not key.startswith("allow_")
and not _SECRET_KEY_RE.search(key)
and (value is None or isinstance(value, (str, int, float, bool, list, dict)))
}
def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack") -> tuple[str, List[str]]:
"""Build pack YAML from the current install.
Returns ``(yaml_text, warnings)``. Plugins whose Git provenance is unknown (local-only, no
install metadata) are listed in the warnings and included as comments in the YAML, never as
installable entries.
"""
"""Build pack YAML from the current install; returns ``(yaml_text, warnings)``. Plugins with
unknown Git provenance (no install metadata) become warnings + YAML comments, never entries."""
import yaml
from hermes_cli.plugins_cmd import (
_get_enabled_set,
_plugins_dir,
_read_install_metadata,
)
from hermes_cli.plugins_cmd import _get_enabled_set, _plugins_dir, _read_install_metadata
metadata = _read_install_metadata()
enabled = _get_enabled_set()
plugins_dir = _plugins_dir()
installed = sorted(
d.name for d in plugins_dir.iterdir()
if d.is_dir() and not d.name.startswith(".")
)
installed = sorted(d.name for d in _plugins_dir().iterdir() if d.is_dir() and not d.name.startswith("."))
if enabled_only:
installed = [n for n in installed if n in enabled]
@@ -574,8 +436,7 @@ def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack"
if not repo or not isinstance(revision, str) or not _EXACT_SHA_RE.fullmatch(revision):
warnings.append(
f"{plugin_id}: no Git provenance (local-only or pre-metadata "
"install) — listed as a comment, not installable from this pack."
)
"install) — listed as a comment, not installable from this pack.")
continue
entry: dict[str, Any] = {"repo": repo, "ref": revision.lower()}
if subdir:
@@ -596,83 +457,56 @@ def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack"
text = yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
if warnings:
comment_lines = "\n".join(
f"# WARNING (not exported): {w}" for w in warnings
)
text = f"{comment_lines}\n{text}"
text = "\n".join(f"# WARNING (not exported): {w}" for w in warnings) + f"\n{text}"
return text, warnings
# ---------------------------------------------------------------------------
# CLI commands
# ---------------------------------------------------------------------------
# ── CLI commands ────────────────────────────────────────────────────────────────────────────
def cmd_pack_show(source: str) -> None:
"""``hermes plugins pack show <path-or-url>`` — dry-run review."""
from rich.console import Console
console = Console()
def _load_and_review(console, source: str):
"""Load *source* (exit 1 on PackError), resolve it, print the review screen."""
from hermes_cli.plugins_cmd import _fail
try:
pack = load_pack(source)
except PackError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)
_fail(console, f"[red]Error:[/red] {exc}")
resolved = resolve_pack_plugins(pack)
render_pack_review(console, pack, resolved)
return pack, resolved
def cmd_pack_show(source: str) -> None:
"""``hermes plugins pack show <path-or-url>`` — dry-run review."""
from hermes_cli.plugins_cmd import _console
console = _console()
pack, resolved = _load_and_review(console, source)
unresolved = [rp for rp in resolved if rp.identifier is None]
if unresolved:
console.print(
f"\n[yellow]{len(unresolved)} entr{'y' if len(unresolved) == 1 else 'ies'} "
"could not be resolved — install would skip them and exit non-zero.[/yellow]"
)
"could not be resolved — install would skip them and exit non-zero.[/yellow]")
console.print("\n[dim]Dry run only. Install with `hermes plugins pack install ...`.[/dim]")
def cmd_pack_install(source: str, *, force: bool = False) -> None:
"""``hermes plugins pack install <path-or-url>``.
"""``hermes plugins pack install <path-or-url>``: mandatory review screen -> one pack-level
consent -> pinned fan-out installs -> per-plugin capability consent. Exit 1 if any failed."""
from hermes_cli.plugins_cmd import _ask_yes, _console, _fail, _is_tty
console = _console()
pack, resolved = _load_and_review(console, source)
Mandatory review screen -> one summary consent for the pack -> pinned fan-out installs ->
per-plugin capability consent via the standard flow. Partial failures are reported per plugin
and the exit code is non-zero when any plugin failed.
"""
from rich.console import Console
console = Console()
try:
pack = load_pack(source)
except PackError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)
resolved = resolve_pack_plugins(pack)
render_pack_review(console, pack, resolved)
# Mandatory review confirmation — no --yes in v1 (a pack is arbitrary
# third-party code × N). Non-interactive sessions cannot consent.
if not (sys.stdin.isatty() and sys.stdout.isatty()):
console.print(
# Mandatory review confirmation — no --yes in v1 (arbitrary third-party code × N).
if not _is_tty():
_fail(console, (
"[red]Error:[/red] Pack install requires an interactive terminal "
"to review and confirm the pack contents (no --yes in v1)."
)
sys.exit(1)
try:
answer = console.input(
f"\nInstall {len(resolved)} plugin(s) from pack "
f"'{pack.name}'? [y/N] "
).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in {"y", "yes"}:
console.print("[dim]Aborted — nothing installed.[/dim]")
sys.exit(1)
"to review and confirm the pack contents (no --yes in v1)."))
if not _ask_yes(f"\nInstall {len(resolved)} plugin(s) from pack '{pack.name}'? [y/N] ", console.input):
_fail(console, "[dim]Aborted — nothing installed.[/dim]")
results = install_pack_plugins(pack, resolved, console, force=force)
ok = [r for r in results if r.ok]
failed = [r for r in results if not r.ok]
console.print(
f"\n[bold]Pack '{pack.name}':[/bold] {len(ok)} installed, {len(failed)} failed."
)
console.print(f"\n[bold]Pack '{pack.name}':[/bold] {len(ok)} installed, {len(failed)} failed.")
for r in failed:
console.print(f" [red]✗[/red] {r.display}: {r.error}")
if ok:
@@ -685,7 +519,6 @@ def cmd_pack_install(source: str, *, force: bool = False) -> None:
def cmd_pack_export(*, enabled_only: bool = False, name: str = "my-hermes-pack") -> None:
"""``hermes plugins pack export [--enabled-only]`` — pack YAML on stdout."""
from rich.console import Console
console = Console(stderr=True)
try:
text, warnings = export_pack(enabled_only=enabled_only, pack_name=name)
@@ -699,20 +532,17 @@ def cmd_pack_export(*, enabled_only: bool = False, name: str = "my-hermes-pack")
def pack_command(args) -> None:
"""Dispatch ``hermes plugins pack <action>``."""
action = getattr(args, "pack_action", None)
if action == "install":
cmd_pack_install(args.source, force=getattr(args, "force", False))
elif action == "export":
cmd_pack_export(
enabled_only=getattr(args, "enabled_only", False),
name=getattr(args, "name", None) or "my-hermes-pack",
)
elif action == "show":
cmd_pack_show(args.source)
else:
from rich.console import Console
handler = _PACK_ACTIONS.get(getattr(args, "pack_action", None))
if handler is None:
from hermes_cli.plugins_cmd import _console, _fail
_fail(_console(), "[red]Error:[/red] Usage: hermes plugins pack {install|export|show}")
handler(args)
Console().print(
"[red]Error:[/red] Usage: hermes plugins pack {install|export|show}"
)
sys.exit(1)
_PACK_ACTIONS = {
"install": lambda args: cmd_pack_install(args.source, force=getattr(args, "force", False)),
"export": lambda args: cmd_pack_export(
enabled_only=getattr(args, "enabled_only", False),
name=getattr(args, "name", None) or "my-hermes-pack"),
"show": lambda args: cmd_pack_show(args.source),
}
+372 -946
View File
File diff suppressed because it is too large Load Diff
+42 -128
View File
@@ -1,7 +1,7 @@
"""Plugin hook / middleware / event-bus / system-prompt-section dispatch.
Mixed into :class:`hermes_cli.plugins.PluginManager`. ``_resolve_hook_callback_timeout`` stays on the
origin (tests patch it there) and is looked up lazily.
Mixed into :class:`hermes_cli.plugins.PluginManager`. ``_resolve_hook_callback_timeout`` stays on
the origin (tests patch it there) and is looked up lazily.
"""
from __future__ import annotations
@@ -22,7 +22,6 @@ from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION
logger = logging.getLogger("hermes_cli.plugins")
# Allowlist of agent-turn hot-path hooks bounded by plugins.hook_callback_timeout (fail-open:
# abandon without join — joining reintroduced a shutdown hang). Unlisted hooks run synchronously.
# Intentionally unbounded: on_session_finalize/reset (last-chance flush — abandon can lose state);
@@ -34,49 +33,23 @@ _HOOK_TIMEOUT_BOUNDED_HOOKS: Set[str] = {
"pre_verify", "on_session_start", "on_session_end",
}
# Policy hooks: timeout / still-running must fail closed (block the tool).
_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS: Set[str] = {"pre_tool_call"}
# Documented parent-thread serialization contract — never run on a timeout worker (hooks.md).
_HOOK_CALLER_THREAD_HOOKS: Set[str] = {"subagent_stop"}
# After a timeout, suppress the same callback this long so a hung hook cannot pile up threads.
_HOOK_TIMEOUT_SUPPRESSION_SECONDS = 60.0
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = (
"pre_tool_call plugin callback timed out or is still running"
)
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = "pre_tool_call plugin callback timed out or is still running"
# System-prompt sections are tightly bounded: they become high-trust prompt bytes charged every turn.
SYSTEM_PROMPT_SECTION_POSITIONS = frozenset({"after_memory"})
DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS = 4_000
MAX_SYSTEM_PROMPT_SECTION_CHARS = 4_000
MAX_SYSTEM_PROMPT_SECTIONS = 32
MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS = 8_000
_SYSTEM_PROMPT_SECTION_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
_SYSTEM_PROMPT_SECTION_HEADING_PREFIX = "## Plugin Context: "
PLUGIN_SECTIONS_START = "<!-- hermes-plugin-sections:start -->"
PLUGIN_SECTIONS_END = "<!-- hermes-plugin-sections:end -->"
@@ -89,8 +62,7 @@ def format_system_prompt_section(section_id: str, content: str) -> str:
"""Render an auditable, length-framed block recoverable from the full prompt."""
return (
f"{_SYSTEM_PROMPT_SECTION_HEADING_PREFIX}{section_id}\n"
f"<!-- hermes-plugin-section-chars:{len(content)} -->\n\n" f"{content}"
)
f"<!-- hermes-plugin-section-chars:{len(content)} -->\n\n{content}")
def format_system_prompt_sections(sections: list) -> str:
@@ -103,16 +75,10 @@ def format_system_prompt_sections(sections: list) -> str:
# Reserved event namespace prefix — only core may publish ``hermes:<event>``.
HERMES_EVENT_NAMESPACE = "hermes"
# Event recursion depth cap (subscribers may emit); over-deep emits are dropped with a warning.
_EVENT_EMIT_DEPTH_CAP = 8
# Max queued + running events per manager generation; emit never waits — a full budget drops.
_EVENT_PENDING_CAP = 64
_EVENT_WORKER_STOP = object()
@@ -159,21 +125,15 @@ class _QueuedPluginEvent:
# Hook callback timeout (non-blocking abandon). Default cap per Python hook callback; overridden by
# ``plugins.hook_callback_timeout``. Shell hooks enforce their own subprocess timeout.
_HOOK_CALLBACK_TIMEOUT_SECS = 30.0
_HOOK_SKIPPED = object() # returned by _run_hook_callback_bounded on skip/timeout
_MAX_HOOK_CALLBACK_TIMEOUT_SECS = 600.0
_HOOK_SKIPPED = object() # returned by _run_hook_callback_bounded on skip/timeout
def _hook_uses_callback_timeout(hook_name: str, timeout: float) -> bool:
"""Whether *hook_name* should run under the non-blocking timeout path."""
if timeout <= 0 or hook_name in _HOOK_CALLER_THREAD_HOOKS:
return False
return (
hook_name in _HOOK_TIMEOUT_BOUNDED_HOOKS or hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
)
return hook_name in _HOOK_TIMEOUT_BOUNDED_HOOKS or hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
def _pre_tool_call_timeout_block() -> Dict[str, str]:
@@ -189,43 +149,33 @@ class PluginDispatchMixin:
parameters = inspect.signature(callback).parameters
except (TypeError, ValueError):
return callback(**payload) # no introspectable signature: historical behavior
if any(
parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
):
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return callback(**payload)
accepted_payload = {
name: value for name, value in payload.items() if name in parameters
and parameters[name].kind
in {
inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY,
}
}
return callback(**accepted_payload)
keyword_kinds = {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}
return callback(**{
name: value for name, value in payload.items()
if name in parameters and parameters[name].kind in keyword_kinds
})
def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
"""Call all callbacks for *hook_name*; return their non-``None`` results.
Payloads evolve additively: ``**kwargs`` callbacks get everything, narrow signatures only
what they declare. Each callback is isolated. Hooks in ``_HOOK_TIMEOUT_BOUNDED_HOOKS`` and
``pre_tool_call`` are bounded by ``plugins.hook_callback_timeout`` (worker abandoned, never
joined); ``pre_tool_call`` fails closed with a block directive, others skip.
``_HOOK_CALLER_THREAD_HOOKS`` always run on the caller thread. ``pre_llm_call`` callbacks may
return ``{"context": "..."}`` (or a plain string) to inject into the turn.
what they declare. Each callback is isolated. Bounded hooks and ``pre_tool_call`` run under
``plugins.hook_callback_timeout`` (worker abandoned, never joined); ``pre_tool_call`` fails
closed with a block directive, others skip. ``_HOOK_CALLER_THREAD_HOOKS`` always run on the
caller thread. ``pre_llm_call`` may return ``{"context": "..."}`` (or a str) to inject.
"""
from hermes_cli.plugins import _resolve_hook_callback_timeout
# Gateway platform events define event-local envelopes; a bus-wide version here would turn
# unrelated adapter payloads into one monolithic compatibility contract.
if hook_name != "gateway_platform_event":
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
callbacks = self._hooks.get(hook_name, [])
results: List[Any] = []
timeout = _resolve_hook_callback_timeout()
use_timeout = _hook_uses_callback_timeout(hook_name, timeout)
fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
for cb in callbacks:
for cb in self._hooks.get(hook_name, []):
try:
if use_timeout:
ret = self._run_hook_callback_bounded(hook_name, cb, kwargs, timeout)
@@ -239,9 +189,7 @@ class PluginDispatchMixin:
results.append(ret)
except Exception as exc:
logger.warning(
"Hook '%s' callback %s raised: %s", hook_name,
getattr(cb, "__name__", repr(cb)), exc,
)
"Hook '%s' callback %s raised: %s", hook_name, getattr(cb, "__name__", repr(cb)), exc)
return results
def _run_hook_callback_bounded(
@@ -253,15 +201,13 @@ class PluginDispatchMixin:
callback_name = getattr(cb, "__name__", repr(cb))
callback_key = (hook_name, id(cb))
token = object()
now = time.monotonic()
with self._hook_timeout_lock:
suppressed_until = self._hook_timeout_suppressed_until.get(callback_key)
running = callback_key in self._hook_running_callbacks
if (suppressed_until is not None and suppressed_until > now) or running:
if (suppressed_until is not None and suppressed_until > time.monotonic()) or running:
logger.warning(
"Hook '%s' callback %s skipped after previous "
"timeout or while still running", hook_name, callback_name,
)
"timeout or while still running", hook_name, callback_name)
return _HOOK_SKIPPED
if suppressed_until is not None:
self._hook_timeout_suppressed_until.pop(callback_key, None)
@@ -283,19 +229,14 @@ class PluginDispatchMixin:
self._hook_running_callbacks.pop(callback_key, None)
done.set()
thread = threading.Thread(
target=_runner, name=f"hermes-hook-{callback_name}"[:40], daemon=True,
)
thread = threading.Thread(target=_runner, name=f"hermes-hook-{callback_name}"[:40], daemon=True)
thread.start()
if not done.wait(timeout=timeout): # do not join — that would reintroduce the hang
with self._hook_timeout_lock:
self._hook_timeout_suppressed_until[callback_key] = (
time.monotonic() + self._hook_timeout_suppression_seconds
)
time.monotonic() + self._hook_timeout_suppression_seconds)
logger.warning(
"Hook '%s' callback %s timed out after %gs — skipping", hook_name, callback_name,
timeout,
)
"Hook '%s' callback %s timed out after %gs — skipping", hook_name, callback_name, timeout)
return _HOOK_SKIPPED
if "exc" in failure:
raise failure["exc"]
@@ -305,9 +246,8 @@ class PluginDispatchMixin:
"""Add an owner-tagged event subscription in registration order."""
if not callable(callback):
raise TypeError("Event subscriber callback must be callable")
entry = _EventSubscription(owner=owner, callback=callback)
with self._event_lock:
self._subscriptions.setdefault(event, []).append(entry)
self._subscriptions.setdefault(event, []).append(_EventSubscription(owner, callback))
def _remove_plugin_subscriptions(self, owner: str) -> int:
"""Remove every subscription owned by *owner*; return the count. Queued envelopes re-check
@@ -328,9 +268,8 @@ class PluginDispatchMixin:
worker = self._event_worker
if worker is not None and worker.is_alive():
return
dispatch_queue = self._event_queue
worker = threading.Thread(
target=self._event_worker_loop, args=(dispatch_queue,), name="hermes-plugin-events",
target=self._event_worker_loop, args=(self._event_queue,), name="hermes-plugin-events",
daemon=True,
)
self._event_worker = worker
@@ -369,22 +308,16 @@ class PluginDispatchMixin:
if item.generation != self._event_generation:
break
# Owner unload may have removed this entry after the event was queued.
if not any(
current is subscription
for current in self._subscriptions.get(item.event, [])
):
if not any(cur is subscription for cur in self._subscriptions.get(item.event, [])):
continue
callback = subscription.callback
try:
# Fresh deep copy per subscriber: no callback can mutate what the next sees.
owned_payload = copy.deepcopy(item.payload)
result = callback(**owned_payload)
resolve_plugin_command_result(result)
resolve_plugin_command_result(callback(**copy.deepcopy(item.payload)))
except Exception as exc:
logger.warning(
"Event '%s' subscriber %s raised: %s", item.event,
getattr(callback, "__name__", repr(callback)), exc,
)
getattr(callback, "__name__", repr(callback)), exc)
finally:
self._emit_depth.value = previous_depth
@@ -393,8 +326,7 @@ class PluginDispatchMixin:
with self._event_idle:
generation = self._event_generation
return self._event_idle.wait_for(
lambda: self._event_pending_by_generation.get(generation, 0) == 0, timeout=timeout,
)
lambda: self._event_pending_by_generation.get(generation, 0) == 0, timeout=timeout)
def _dispatch_event(self, event: str, payload: Dict[str, Any]) -> int:
"""Queue *event* without blocking; return the subscriber count scheduled. Pending work is
@@ -403,10 +335,9 @@ class PluginDispatchMixin:
if depth >= _EVENT_EMIT_DEPTH_CAP:
logger.warning(
"Event bus recursion cap (%d) exceeded while dispatching '%s' "
"— dropping this emit to prevent an infinite loop", _EVENT_EMIT_DEPTH_CAP, event,
)
"— dropping this emit to prevent an infinite loop", _EVENT_EMIT_DEPTH_CAP, event)
return 0
budget_msg = "Event bus pending budget (%d) exhausted while dispatching '%s' — dropping this emit"
with self._event_lock:
subscriptions = tuple(self._subscriptions.get(event, []))
if not subscriptions:
@@ -414,22 +345,15 @@ class PluginDispatchMixin:
generation = self._event_generation
pending = self._event_pending_by_generation.get(generation, 0)
if pending >= _EVENT_PENDING_CAP:
logger.warning(
"Event bus pending budget (%d) exhausted while dispatching "
"'%s' — dropping this emit", _EVENT_PENDING_CAP, event,
)
logger.warning(budget_msg, _EVENT_PENDING_CAP, event)
return 0
item = _QueuedPluginEvent(
event=event, payload=dict(payload), subscriptions=subscriptions, depth=depth + 1,
generation=generation,
)
generation=generation)
try:
self._event_queue.put_nowait(item)
except queue.Full:
logger.warning(
"Event bus pending budget (%d) exhausted while dispatching "
"'%s' — dropping this emit", _EVENT_PENDING_CAP, event,
)
logger.warning(budget_msg, _EVENT_PENDING_CAP, event)
return 0
self._event_pending_by_generation[generation] = pending + 1
self._ensure_event_worker_locked()
@@ -450,13 +374,11 @@ class PluginDispatchMixin:
frozen_info = types.MappingProxyType(dict(session_info))
rendered: List[RenderedPluginSystemPromptSection] = []
total_chars = len(PLUGIN_SECTIONS_START) + len(PLUGIN_SECTIONS_END) + 2
for section_id in sorted(self._system_prompt_sections):
section = self._system_prompt_sections[section_id]
for _section_id, section in sorted(self._system_prompt_sections.items()):
if len(rendered) >= MAX_SYSTEM_PROMPT_SECTIONS:
logger.warning(
"Plugin system prompt section %s exceeded the section-count "
"budget (%d) and was skipped", section.id, MAX_SYSTEM_PROMPT_SECTIONS,
)
"budget (%d) and was skipped", section.id, MAX_SYSTEM_PROMPT_SECTIONS)
continue
text = self._render_prompt_section_text(section, frozen_info)
if text is None:
@@ -468,19 +390,15 @@ class PluginDispatchMixin:
logger.warning(
"Plugin system prompt section %s (%s) exceeded the aggregate "
"session budget (%d chars) and was skipped", section.id, section.plugin,
MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS,
)
MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS)
continue
rendered.append(
RenderedPluginSystemPromptSection(
id=section.id, content=text, position=section.position, plugin=section.plugin,
)
)
id=section.id, content=text, position=section.position, plugin=section.plugin))
total_chars += rendered_chars
logger.info(
"Session plugin prompt section: id=%s plugin=%s position=%s chars=%d", section.id,
section.plugin, section.position, len(text),
)
section.plugin, section.position, len(text))
return rendered
@staticmethod
@@ -490,8 +408,7 @@ class PluginDispatchMixin:
"""Evaluate one section; return its stripped text or None (with a warning) when skipped."""
def _skip(detail: str, *args: Any) -> None:
logger.warning(
"Plugin system prompt section %s (%s) " + detail, section.id, section.plugin, *args,
)
"Plugin system prompt section %s (%s) " + detail, section.id, section.plugin, *args)
try:
value = section.content(frozen_info) if callable(section.content) else section.content
@@ -518,16 +435,13 @@ class PluginDispatchMixin:
def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]:
"""Call middleware callbacks for *kind* (each isolated); return non-``None`` results."""
callbacks = self._middleware.get(kind, [])
results: List[Any] = []
for cb in callbacks:
for cb in self._middleware.get(kind, []):
try:
ret = cb(**kwargs)
if ret is not None:
results.append(ret)
except Exception as exc:
logger.warning(
"Middleware '%s' callback %s raised: %s", kind,
getattr(cb, "__name__", repr(cb)), exc,
)
"Middleware '%s' callback %s raised: %s", kind, getattr(cb, "__name__", repr(cb)), exc)
return results