Commit Graph

84 Commits

Author SHA1 Message Date
Siddharth Balyan
0761031767 Portable plugin MCP tools keep their names instead of a hash (64-char provider cap) (#119263)
* fix(plugins): portable MCP servers get a readable name so tool names fit the 64-char cap

A portable plugin's MCP server was named `<skill_namespace>__<server>`, i.e.
`agent-plugin-<slug>-<sha8>__<server>`. That prefix is right for plugin-data and skill names
(collision-free without coordination, and persisted on disk) but it costs ~40 chars of every
`mcp__<server>__<tool>` name. Providers cap function names at 64, so the registry clamped every
tool of the NVIDIA plugin to a hash-suffixed stub with the verb cut off:
`mcp__agent_plugin_hermes_nvidia_72eb26b1__nvidia_app__n_3fa9c2d1`.

Server names only need to be unique among loaded portable servers, and the loader already refuses
a clash. `portable_mcp_server_name(key, server)` = `<plugin-slug>__<server>`, collapsed to
`<plugin-slug>` when the two match (the one-server package). The loader and the desktop card's
server rows both call it (the card recomputed the f-string on its own before). Skill and plugin-data
namespaces are unchanged; nothing persisted refers to the server name, so no migration.

Tests: the existing portable-load test now pins the relationship (server name = plugin slug +
server; a long vendor tool name reaches the wire unclamped), and a new test covers the clash the
digest used to hide: two enabled packages folding to one slug, second server skipped, first served.
Both red on main. Docs: developer-guide/plugins/index.md.

* fix(plugins): a portable MCP server is named what its mcp.json calls it, nothing prepended

Drop the plugin segment too. A user's own config.yaml server named `nvidia-app` yields
`mcp__nvidia_app__<tool>`; a portable plugin's server of the same name now yields the same. Duplicates
are refused at load (config.yaml first, then first-loaded plugin) with a warning naming both owners.

Spike, isolated HERMES_HOME, real session: a portable plugin with server `acme-tools` and tool
`acme_client_get_driver_status_report` registers as
`mcp__acme_tools__acme_client_get_driver_status_report`; the model found it by tool_search, called it,
and reported that exact name. Same plugin on main: `mcp__agent_plugin_acme_tools_88e7456f__acme_tools__acme_153862de`.
2026-09-22 22:05:28 +05:30
Siddharth Balyan
adefd99671 Portable plugins declare the application each MCP server needs; install refuses on an unsupported host (NS-942) (#119049)
* feat: enforce portable server declarations

* test: cover portable plugin host gates
2026-09-22 17:54:41 +05:30
teknium1
75e9567ca7 fix(plugins): ainvoke_hook shares the sync path's failure contract
Follow-up trim of the #110265 salvage. `ainvoke_hook` logged raising callbacks
with a bare warning; route them through `_report_hook_failure` (warn-once per
distinct failure, #111922) and, for `_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS`, append
the same named block directive the sync path emits (#109624), so the async twin
cannot drift into a fail-open policy path. Tests trimmed to the salvage bar: the
in-loop await is proven once through the real `_handle_message` path
(`test_async_hook_callback_is_awaited_on_the_gateway_loop`); the manager-level
duplicate is dropped and the narrowing test also pins failure isolation. Docs:
`pre_gateway_dispatch` callbacks may be `async def` and stay unbounded.

Credit order for the three PRs fixing this gap: #102485 (dmspark, earliest,
pre-decomposition `gateway/run.py`), #110253 (KoNit-K, bounded the hook —
rejected by design: neither fail mode is acceptable for a policy gate), #110265
(twidtwid, reporter; cherry-picked because it matches the ainvoke_hook shape,
keeps the hook unbounded, and adapts the existing sync test seams honestly).

Part of #110241
Supersedes #102485
Supersedes #110253
Co-authored-by: David Marcus <dmspark@users.noreply.github.com>
Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-21 23:47:51 -07:00
Todd Dailey
727e7342cf fix(gateway): await async pre_gateway_dispatch callbacks on the event loop
`GatewayInboundMixin._hm_pre_gateway_dispatch_hook` was a plain `def` calling
the sync `hermes_cli.lifecycle.invoke_hook` from the async `_hm_admit_event`,
so an `async def pre_gateway_dispatch` callback was resolved through
`resolve_plugin_command_result` on a helper thread with its own loop: the
gateway loop blocked for the callback's whole duration and any loop-bound await
(an `asyncio.Event` set by a loop task, a loop-bound aiohttp session,
`asyncio.to_thread`) could never complete, failing at 30s.

Add `PluginManager.ainvoke_hook` (+ `hermes_cli.plugins.ainvoke_hook` /
`hermes_cli.lifecycle.ainvoke_hook`): same payload narrowing (shared
`_hook_callback_kwargs`), observer + isolation semantics and result contract as
`invoke_hook`, but awaitable results are awaited on the caller's loop.
`pre_gateway_dispatch` stays intentionally unbounded. The inbound hook becomes
`async def` and `_hm_admit_event` awaits it; the sync `invoke_hook` is untouched
for every other caller. Existing tests that stubbed the hook synchronously are
adapted to the async seam.

Fixes #110241
Salvages #110265

(cherry picked from commit 22bb10d305c7992f43bbfdf1481ad0ef0015b2b7)
2026-09-21 23:47:51 -07:00
teknium1
02ad41df3a fix(plugins): a pre_tool_call block outranks an earlier plugin's approve
`_get_pre_tool_call_directive_details` returned the first valid block-or-approve
in registration order, so a plugin registered earlier that returned `approve`
hid a later security plugin's `block`; under `approvals.mode: off` an approve
means no prompt at all, so the veto was dropped silently. Precedence is now
`block` > `approve` > none: a valid block still returns immediately (modify
directives seen before it stay attached, as before), a valid approve is held
back until the whole result list has been scanned for a veto, and among approves
the first valid one (with its rule_key) still wins. Modify accumulation is
unchanged and now also keeps modify directives that follow the winning approve,
since the scan no longer stops there. Docstring and hooks.md no longer describe
"first valid directive wins".

Slim redo of #68644 (earliest) and #87449 against the modify-aware shape of the
function on main; both PRs predate it and could not be cherry-picked.

Fixes #87420
Supersedes #68644
Supersedes #87449
Co-authored-by: synscott <1563043+synscott@users.noreply.github.com>
Co-authored-by: Jack Lau <72348727+jackulau@users.noreply.github.com>
2026-09-21 23:47:51 -07:00
teknium1
3755520916 fix(plugins): a hung pre_tool_call guard no longer blocks every tool call until restart
`_run_hook_callback_bounded` treated any live abandoned worker for a callback
(`bool(self._hook_abandoned.get(suppression_key))`) as "still running", so one
never-returning `pre_tool_call` callback made every later tool call fail closed
with the timeout message until the process restarted. The timeout path
self-heals through the 60s suppression window; the abandoned path never did.

Policy now: while the suppression window is open the callback is skipped as
before. After it expires a fresh call id may start a new worker even though the
abandoned one is still alive — capped at `_HOOK_MAX_ABANDONED_WORKERS` (3) live
abandoned workers per callback so a hung plugin cannot leak a thread per call
(the #98382 constraint). At the cap the callback keeps being skipped (fail-closed
for pre_tool_call) with a WARNING naming the callback and its module, until one
of its workers finishes and frees a slot.

Test changes: `test_hung_worker_blocks_new_call_identity_after_suppression`
encoded the removed behaviour (exactly one worker, forever); it becomes
`test_hung_worker_caps_new_call_identities_after_suppression`, which pins the
same invariant it was protecting — bounded leak, never one per call — at the new
bound and checks the warning. `test_hung_worker_does_not_fail_closed_forever`
is the #105223 regression (red on base: call-c returned the block directive).

Redone slim against the per-call-id gate that landed in #111177; #105241
targeted the pre-#111177 shape and needed plugins_ledger/__init__ changes for a
one-retry-then-quarantine policy. Its analysis and shape informed this fix.

Fixes #105223
Supersedes #105241
Co-authored-by: fangliquanflq <fangliquan@qq.com>
2026-09-21 23:47:51 -07:00
teknium1
32518f024f fix(plugins): name the callback and error in the raise-fail-closed block
Follow-up trim of the #109632 salvage: the block directive a raising
`pre_tool_call` guard produces reused the timeout wording, so an operator could
not tell a crashing guard from a slow one from the tool result. Build it from
the callback name and `TypeError: ...` (error text truncated like
`_report_hook_failure`). The two salvaged tests collapse into one parametrized
invariant (caller-thread and bounded-worker path) that also pins the message
shape and that a sibling callback's result still flows.

Part of #109624
Salvages #109632
2026-09-21 23:47:51 -07:00
KoNit-K
89de608317 fix(plugins): fail closed on policy hook exceptions
A `pre_tool_call` guard that raised failed OPEN (only `_report_hook_failure`,
results stayed empty, the tool ran) while one that timed out failed CLOSED with
a block directive. For a veto hook a crashing guard is the control quietly
disappearing; make both failure modes of `_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS`
consistent: an exception appends a block directive in addition to the warning.

Fixes #109624
Salvages #109632

(cherry picked from commit 6ccf5d8a4082fc6b9af80270aae9f13b0320cfb8)
2026-09-21 23:47:51 -07:00
liuhao1024
592163d299 fix(plugins): route execution middleware through lazy discovery (#105827)
`_run_execution_chain` read `get_plugin_manager()._middleware` directly,
bypassing the lazy discovery every other delivery entry point gained in
the #64178 parity work, so `tool_execution`/`llm_execution` middleware
registered by user plugins silently failed to fire (fail-open) on
surfaces that never run discovery at startup: query mode `chat -q`/`-z`,
cron delivery, dashboard, TUI slash workers. Route it through
`_delivery_manager()` like invoke_hook/invoke_middleware/has_middleware.

(cherry picked from commit 06b9d44095733f8e46f40f0331b3f6dff352e98e)

Fixes #105827
Salvages #105832
2026-09-21 23:47:51 -07:00
teknium1
07347414ed fix: keep cron-provider plugins out of the general PluginManager
With `chronos` in plugins.enabled every start warned
"Failed to load plugin 'chronos': 'PluginContext' object has no attribute
'register_cron_scheduler'": collect_directory_manifests skipped the bundled
memory/context_engine/model-providers categories but not cron_providers, and a
user-installed cron provider under ~/.hermes/plugins/<name> was never
auto-coerced to kind=exclusive. Cron providers activate through
plugins.cron_providers (cron.provider config), so the general manager must only
record them, exactly like memory providers.

Fixes #62951
credit: @wdmason #100362 (slim redo; #100396 is the consolidated duplicate)
2026-09-21 23:38:16 -07:00
mannnrachman
9778f00970 fix(plugins): register entry-point plugins declared as module:function
EntryPoint.load() resolves the module:function form to the referenced
attribute (typically the register callable itself), not its module. The
loader then looked for a .register attribute on that function object,
found none, and warned "Plugin '<name>' has no register() function" on
every discovery pass — so pip plugins with module:function entry points
never registered at all.

Detect a non-module callable from ep.load() and use it directly as the
register function, resolving LoadedPlugin.module from sys.modules via
the callable's __module__ for attribution.

The existing entry-point test masked this because its mocked ep.load()
returned the module even though its declared value was module:function;
the new regression test mirrors real importlib behavior.

Fixes #72052

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-21 23:26:19 -07:00
teknium1
656c1c9bda fix(plugins): isolate SystemExit in event, middleware and prompt-section callbacks too
The hook loop now treats SystemExit like any other callback failure; the
three sibling dispatch loops in this module (event subscribers,
invoke_middleware, system prompt section content) had the same
`except Exception` gap and let a plugin dependency's sys.exit() abandon
the loop and kill the turn. KeyboardInterrupt still propagates on all
of them.
2026-09-20 15:21:14 -07:00
Jony
d1221d43b7 fix(plugins): isolate SystemExit from hook callbacks
(cherry picked from commit 99facb98dc9e9c88af6c4fcc1049c6018871e520)
2026-09-20 15:21:14 -07:00
kshitijk4poor
73f808e47f fix(plugins): only mark a timed-out hook worker abandoned while it still holds its token
The timeout branch of _run_hook_callback_bounded unconditionally added
gate_key to _hook_abandoned. A worker that finishes between done.wait()
returning False and the caller taking the lock has already popped its
token via _release_token, so nothing would ever clear that entry: the
callback stayed blocked for every later call id until reload with no
thread behind it. Guard the insert on the worker still being registered.

The new test makes the race deterministic by swapping the module's
threading.Event for one whose wait() lets the worker finish and then
reports a timeout, and asserts a fresh call id still runs.

Also pass tool_call_id inline from terminal_tool_result instead of the
conditional dict plumbing: an empty id is already treated as "no
identity" by _hook_call_identity and unknown fields are withheld from
narrow-signature callbacks (same shape as _fire_approval_hook). Update
the stale "(hook_name, id(cb))" comment above _hook_running_callbacks.
2026-09-15 10:48:49 +05:30
kshitijk4poor
f49c1e3bd7 test(plugins): bind the identity gate, not timeout suppression, in the dedupe tests
The same-call negative control fired two sequential calls with a 0.1 s
timeout, so the first call timed out and the second was dropped by the
60 s suppression window; the identity gate itself was never exercised.
Mirror the positive test instead: a 5 s timeout, two threads with the
same tool_call_id while the callback is held on an Event.

Add one test for the abandoned-worker gate: a hung callback followed by
a call with a fresh tool_call_id, with suppression zeroed, must start
exactly one worker. Reverting the gate change makes it fail.
2026-09-15 10:48:49 +05:30
deadczarvc
4121aa295a fix(plugins): gate hook callbacks by call identity, not by tool name alone
Concurrent invocations of the same tool in one session collapsed into a single
busy key (hook_name, id(cb)): the second invocation was reported as 'still
running' and dropped. For pre_tool_call a drop is a fail-closed block, so the
gate silenced itself on an ordinary, healthy callback.

Measured on a busy profile: 3574 skip lines and 0 timeout lines in one hour —
every skip was the 'while still running' branch, i.e. pure key collision, not
slowness.

The gate now keys on the call identity that is already in the payload
(tool_call_id, else turn_id, else none — the last case behaves exactly as
before). Suppression stays keyed coarsely on (hook_name, id(cb)): a hung
callback is a fact about the callback, so its back-off must not be diluted
per call.

Refs #98382. Independent of #107894 (that one releases the slot on timeout;
this one stops healthy concurrency from colliding).

(cherry picked from commit 53b3dacd008418fcdf5fa6dfcadde575a35a776e)
2026-09-15 10:48:49 +05:30
teknium1
12fe7684e1 fix(plugins): async-await helper thread runs under the caller's ContextVars
Under a running loop `resolve_plugin_command_result` awaited the coroutine on
a raw thread, so an async hook saw the process-default HERMES_HOME and no
secret scope (get_secret -> UnscopedSecretError on a secondary profile).
Run the thread body through `contextvars.copy_context().run`, matching the
bounded hook worker. Also fixes async plugin slash commands the same way.
2026-09-12 08:26:48 -07:00
teknium1
1664e12fc7 chore(tests): explicit utf-8 encoding in test_plugins (windows-footgun ratchet) 2026-09-12 08:26:48 -07:00
Bruce Xu
5f561efe67 fix(plugins): recover when hook worker cannot start 2026-09-07 13:11:36 +05:30
Teknium
7a33369e81 simplify(compat): interrupt — drop _ThreadAwareEventProxy/_interrupt_event legacy alias, repoint 2 test files
No runtime consumer read the proxy (terminal_tool/environments call is_interrupted()/set_interrupt()
directly); its only users were tests patching tools.interrupt._interrupt_event, which had no effect on
the code under test. tools/terminal_tool.py's own re-export of the name is owned by another worker.
2026-09-03 14:00:59 -07:00
Teknium
c93ace77c2 simplify(compat): config/runtime_provider/plugins/commands/secrets_cli/kanban — drop 96 re-exports (incl. PEP 562 facades) + 3 aliases (get_pre_tool_call_directive/_block_message, get_telegram_handler_factories), repoint 56 callers + 50 test files 2026-09-03 14:00:17 -07:00
Teknium
14791b4d4e simplify(compat): approval — drop 43 facade re-exports + _command_detection_variants late-bind seam, repoint 30 callers + 46 test files
tools/approval.py no longer re-exports sibling names (approval_context/prompt/floors/detection/
human_wait/smart/gateway_wait); it imports only what it uses. Siblings reference sibling-defined
names directly (module-attribute reads on tools.approval_context so patching the defining module
still works); only facade-owned state (_lock, _gateway_queues, _permanent_approved, _denied,
_denial_breaker_addendum, _gateway_notify_cb) is still read back through tools.approval.
approval_detection calls its own _command_detection_variants instead of late-binding through the facade.
2026-09-03 13:49:57 -07:00
chelsealong
600b9c7e7e fix(gateway): scope force-reload hook re-registration to its own home
re_register_config_hooks() cleared the entire process-global idempotence
set on every force-reload, so a profile-local plugin force-reload dropped
another live profile's ledger key without touching its still-registered
callback — the next registration call for that profile then appended a
duplicate. Scope the clear to the reloading profile's own home, and give
outbound webhooks the same force-reload restoration shell hooks already
had, since unload() wipes both from the shared _hooks dict.
2026-09-02 07:00:13 -07:00
Teknium
28834a2098 test: raise tight wall-clock bounds that flaked on loaded CI runners
Seven test files asserted sub-2s wall-clock bounds (elapsed < 0.5/1.0s,
stop(timeout=1.0), event waits of 0.5-2s). Under CI load these fired on
healthy code: main run 33455779041 alone flaked 6 of them in one pass
(observed 1.01s vs 0.5, 1.20s vs 1.0, 3.61s vs 3.0, 1.55s vs 1.0,
stop(1.0) returning False, lease TTL 0.1s expiring before the authority
change was observed).

Per the AGENTS.md flake policy (waits >= 2s), bounds are raised to 5s+
while keeping their teeth: every hang path they guard blocks for 10s+
(release.wait holds), so the loosened bounds still distinguish bounded
from unbounded behavior. The authority-loss test gets a 30s lease TTL so
lease expiry can no longer preempt the authority-change assertion.
2026-09-01 10:52:42 -07:00
Brooklyn Nicholson
b61408e95e feat(commands): attach desktop slash metadata to the registry
New commands and plugins declare argument_mode and desktop availability
on CommandDef / register_command. commands.catalog ships that map so
desktop does not need a second command list.
2026-08-27 22:05:40 -05:00
fangliquanflq
091cc0e8be fix(hermes_cli): scope hook timeouts and fail closed on pre_tool_call
Allowlist hot-path hooks for abandon-on-timeout, keep subagent_stop on the caller thread, suppress re-fires of hung callbacks, and block tools when pre_tool_call times out.
2026-08-27 16:13:45 +05:30
Bryan Bednarski
3fad83df31 fix(relay): guard native plugin ownership cutover
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
2026-08-19 08:52:03 -07:00
Nikola Hristov
d083b85591 feat(hooks): pre_tool_call content transformation via modify directive
Adds a `modify` response type to pre_tool_call hooks so a hook can
transform tool arguments before the tool executes, instead of repairing
results afterwards via post_tool_call.

- hermes_cli/plugins.py: _dispatch_pre_tool_call_hooks() fires hooks once
  and returns (block_message, modified_args); modify directives
  shallow-merge into an accumulated dict built from the original args.
- agent/shell_hooks.py: _parse_response() accepts both the canonical
  {"action": "modify", "args": {...}} and Claude Code-compatible
  {"decision": "modify", "tool_input": {...}} wire formats.
- model_tools.py, agent/tool_executor.py, agent/agent_runtime_helpers.py:
  dispatch sites migrated; modified args applied before execution.
- Docs + 10 new tests (merge semantics, precedence, block interplay).

Salvaged from PR #28953. Best fix for #18988.
2026-08-15 23:03:52 -07:00
Mike Smith
a883977b12 test(plugins): activation-contract coverage for entry-point classification
Documents and tests the routing contract the sweeper review asked about:
classification records the manifest but does not activate anything.

- model-provider test now exercises providers.get_provider_profile() against
  the pip-only name (None today — providers discovery is directory-based)
  and asserts the module never leaks into sys.modules via that path.
- new test for the mnemosyne shape: a pip entry point duplicating a
  same-name directory provider. The pip copy is classified exclusive and
  never imported; the directory copy still activates through
  plugins.memory discovery, exactly once.
- _classify_entrypoint_kind docstring now states the activation contract
  explicitly: pip-only providers were equally unactivatable pre-change
  (both destination systems are directory-only; the
  hermes_agent.memory_providers entry-point group has no consumers), so
  classification only removes the wasted import. Entry-point activation
  is tracked upstream (#40644 for memory); this change is its
  prerequisite, preventing double import once it lands.
2026-08-13 11:49:14 -07:00
Mike Smith
450bd0930a fix(plugins): never import parent packages of dotted entry points
find_spec() on a dotted module name imports the parent package first,
executing its __init__.py — which is exactly where a provider's heavy
imports typically live (fastembed -> onnxruntime and friends). The
previous classifier only preserved the no-import property for
top-level entry points.

_resolve_module_source() now resolves only the top-level name with
find_spec() (import-free for top-level names) and walks the remaining
dotted segments through submodule_search_locations by hand, mirroring
PathFinder's file conventions (part.py module / part/__init__.py
package). Namespace packages, zipped modules, extension modules, and
anything else unexpected fall back to standalone (the safe default).
.pyc origins map back to source via source_from_cache.

Regression: a dotted entry point whose parent __init__.py writes an
execution marker and imports the child — asserts the parent never
executed and neither module enters sys.modules during classification.
Fails against the previous implementation (marker written), passes now.
2026-08-13 11:49:14 -07:00
Mike Smith
826e9d18af fix(plugins): classify pip entry-point provider plugins without importing
Entry-point (pip-installed) plugins exposing register_memory_provider()
or register_provider() + ProviderProfile were treated as plain
standalone plugins and eagerly imported by the general PluginManager,
even though memory and model providers have their own discovery
systems and the module has no register() for the general manager to
call. The import registered nothing and paid the module's full import
cost in every Hermes process (a pip memory provider pulls fastembed ->
onnxruntime, ~60 MB RSS).

Entry-point manifests now get the same source-scan classification as
directory plugins via a shared _detect_kind_from_source() helper: the
module is resolved with importlib.util.find_spec (no import) and its
first 8192 chars are scanned for provider markers. Memory providers ->
kind=exclusive, model providers -> kind=model-provider; both are
recorded for introspection and skipped by the general loader.
Unresolvable or non-Python modules stay standalone (default behavior
unchanged).

Tests: an enabled pip entry-point memory provider is never imported;
a pip entry-point model provider routes to providers/ discovery.
2026-08-13 11:49:14 -07:00
Bartok9
8be9c76f8c fix(plugins): hook delivery parity + symmetric force-reload (#64178)
Salvaged from PR #64188 (@Bartok9), re-reviewed against the #64229
ownership ledger (landed in #84923).

Delivery parity (survived):
- Module-level invoke_hook/invoke_middleware/has_hook/has_middleware
  lazily run plugin discovery via _delivery_manager(), so surfaces that
  never import model_tools (dashboards, TUI slash workers, query mode,
  cron, gateway platform events) deliver plugin callbacks instead of
  silently dropping them (#50776, #67597, #67890, #50937).
- _delivery_manager() joins any in-flight background discovery first and
  tolerates test doubles that monkeypatch get_plugin_manager().

Symmetric force-reload (survived):
- agent/shell_hooks.py gains re_register_config_hooks(); the force branch
  of discover_and_load() calls it after a successful sweep, restoring
  config.yaml shell hooks that the ledger-driven unload wiped but cannot
  restore (they are config-owned, not plugin-owned) (#60036).
- unload(plugin=None) now sweeps pre-ledger _plugin_tool_names entries
  out of the process-global tools.registry, mirroring the platform-name
  sweep that already existed, so zombie tools cannot survive a force
  reload in long-lived pre-ledger processes (#60050).

Superseded by the ownership ledger (dropped from #64188):
- _unload_global_plugin_registrations() bulk tool/platform teardown —
  the ledger's reverse-order handle disposal with previous-entry
  restoration covers it more precisely.
- tools.registry/platform_registry displaced-entry LIFO restore stacks —
  the ledger's restore_registration() identity-checked previous-entry
  restoration made them redundant.
- Discovery serialization lock + double-checked singleton — main already
  has _discovery_lock on every discover/unload path and a keyed,
  lock-guarded per-home manager cache (#24714 concern is covered).

Fixes tracked under #64178 (#50776, #60036, #60050, #24714, #67798,
#50937, #67597, #67890, #31480 — #31480 already handled on main by
_parse_hooks_block warn+suggest).
2026-08-12 19:40:59 -07:00
doncazper
85020f2238 fix(plugins): isolate ownership by profile 2026-08-12 19:13:32 -07:00
terry197913
4e1b2e436c fix: scope plugin manager by resolved hermes home (keyed cache)
fix: remove .codegraph artifacts from commit
2026-08-12 19:13:32 -07:00
GodsBoy
8cb066404e fix(plugins): address portable MCP review feedback 2026-08-07 09:44:21 -07:00
GodsBoy
e288d93fc1 fix(review): harden portable plugin boundaries 2026-08-07 09:44:21 -07:00
GodsBoy
ca78c6d7a6 feat(plugins): load portable agent components 2026-08-07 09:44:21 -07:00
Alex Fournier
aef76ba398 Merge updated model metrics into tool metrics
# Conflicts:
#	hermes_cli/observability/shared_metrics_contract.py
#	hermes_cli/observability/shared_metrics_subscriber.py
#	scripts/smoke_nemo_relay_shared_metrics.py
#	tests/hermes_cli/test_plugins.py
#	tests/hermes_cli/test_relay_shared_metrics.py
#	tests/hermes_cli/test_relay_shared_metrics_runtime.py
#	tests/run_agent/test_run_agent.py
#	tests/test_model_tools.py
#	tests/tools/test_approval.py
2026-07-31 07:18:02 -07:00
Teknium
39975613b1 test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00
Teknium
6b81590c55 test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00
Alex Fournier
8b0c3da8c0 feat(observability): aggregate bounded tool metrics 2026-07-29 11:25:55 -07:00
Alex Fournier
14bed44c8c Reapply "feat(observability): integrate NeMo Relay runtime and shared metrics"
Signed-off-by: Alex Fournier <afournier@nvidia.com>
2026-07-27 21:10:51 -07:00
Jeffrey Quesnelle
841a5a744a Revert "feat(observability): integrate NeMo Relay runtime and shared metrics" 2026-07-27 22:28:08 -04:00
Alex Fournier
45580cc93a Merge origin/main into feat/hermes-relay-shared-metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
2026-07-26 09:21:19 -07:00
Teknium
e289e561c7 fix(tools): /tools shows the full pre-assembly catalog; adapt tests to tiered disclosure
CI slices 2 and 7 caught three tests broken by always-defer:

- /tools (CLI show_tools + TUI gateway tools.show) now passes
  skip_tool_search_assembly=True — it's a discovery/inspection surface,
  so users verifying an MCP installed must see deferred tools, not a
  collapsed bridge row. This also fixes
  test_slash_worker_mcp_discovery (profile MCP tool visible in /tools).
- test_plugins.py::test_plugin_tools_in_definitions: 'visible' becomes
  'reachable' — direct schema OR listed in the bridge description;
  scope-exclusion assertions unchanged (not direct AND not listed).
- test_discord_tool.py dynamic-schema-rebuild test reads the
  pre-assembly list (the rebuilt schema is what tool_describe serves).

Banner/status tool counts intentionally keep the post-assembly view —
they reflect what the model actually sees.
2026-07-26 08:26:09 -07:00
Alex Fournier
0be07b0553 fix(middleware): hide Relay control flag
Signed-off-by: Alex Fournier <afournier@nvidia.com>
2026-07-23 13:41:47 -07:00
doncazper
36308f0667 feat(plugins): pass approve rule keys to approval gate 2026-07-07 15:14:30 -07:00
kshitijk4poor
74cc9ee3f0 Revert "Merge pull request #58698 from kshitijk4poor/feat/pre-tool-call-approve-escalation"
This reverts commit 368e5f197e, reversing
changes made to abf9638f4e.
2026-07-06 02:36:52 +05:30
kshitijk4poor
f512d6f020 feat(plugins): pre_tool_call approve action escalates to human gate
Extend the pre_tool_call plugin hook return contract with a new directive:

    {"action": "approve", "message": "why this needs human confirmation"}

Previously a pre_tool_call hook could only veto a tool call (action: block)
or allow it silently. It could not escalate to the existing human-approval
flow. This unlocks user-defined runtime approval rules on ANY tool (HTTP
writes, file writes to sensitive paths, email sends), enforced at runtime —
resolving #51221 as a pure plugin, with no core approval.py rule schema.

Mechanism:
- get_pre_tool_call_directive() returns (action, message) for block|approve;
  get_pre_tool_call_block_message() kept as a block-only back-compat shim.
- resolve_pre_tool_block() is the single dispatch-site chokepoint: fetches
  the directive and, for approve, invokes the human gate; fail-closed to a
  block on denial, timeout, or gate exception. ALL FOUR tool-dispatch sites
  now call it: tool_executor (concurrent + sequential), agent_runtime_helpers,
  and model_tools.handle_function_call.
- request_tool_approval() escalates via the SAME machinery as Tier-2
  dangerous commands: session/permanent allowlist, prompt_dangerous_approval
  (CLI) / submit_pending (gateway), [o]nce/[s]ession/[a]lways/[d]eny,
  timeout fail-closed, approvals.cron_mode for cron contexts.

Architecture: extracted the shared decision core into _run_approval_gate(),
called by BOTH check_dangerous_command() and request_tool_approval() so the
fail-closed / cron / gateway / yolo / persist policy lives in ONE place and
cannot drift. Fixed a latent divergence — the plugin path now honors --yolo.

Approval grain: [a]lways is keyed on tool_name + a hash of the reason (an
explicit plugin rule_key overrides), so distinct reasons on the same tool
persist independently instead of one 'always' blanketing the whole tool.

Non-interactive: cron honors approvals.cron_mode (parity with commands); any
other non-interactive non-gateway context fails CLOSED for the plugin path
(the command path keeps its historical fail-open default, unchanged).

No new config schema, no new env vars, no new hook events.
2026-07-05 12:48:11 +05:30
memosr
12f5624a76 fix(security): bind tool_override authorization to handler's defining plugin module
egilewski found the prior sink gate was transient: it only applied while
PluginManager executed register(ctx). A plugin could defer a direct
registry.register(..., override=True) to a post-load callback/thread, after
the scope was cleared, and still replace a built-in.

Make authorization durable by binding it to where the handler is DEFINED
(handler.__globals__['__name__']) rather than to call timing. At load, each
plugin's module namespace is mapped to its allow_tool_override opt-in in a
table that is never cleared. The sink resolves the handler's owning plugin
module and rejects an override from any plugin namespace without opt-in,
regardless of when or on which thread the call happens. Plugin namespaces
with no recorded policy are treated as not-opted-in (fail-closed). Built-in
and MCP handlers live outside the plugin namespace and are unaffected.

Adds a regression test for the delayed/post-load direct-registry override.
2026-06-30 04:00:42 -07:00