Merge branch 'simp/r3-22-A' into simp/integration3
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+42
-128
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user