Skip to content

fix(plugins): wire-plugin-lifecycle-hooks - #2820

Closed
dlkakbs wants to merge 3 commits into
NousResearch:mainfrom
dlkakbs:fix/wire-plugin-lifecycle-hooks-2817
Closed

fix(plugins): wire-plugin-lifecycle-hooks#2820
dlkakbs wants to merge 3 commits into
NousResearch:mainfrom
dlkakbs:fix/wire-plugin-lifecycle-hooks-2817

Conversation

@dlkakbs

@dlkakbs dlkakbs commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #2817

Summary

Four lifecycle hooks (pre_llm_call, post_llm_call, on_session_start, on_session_end) were listed in VALID_HOOKS and accepted byregister_hook() but invoke_hook() was never called for them - plugin
callbacks silently never fired.

  • on_session_start: fired at end of AIAgent.__init__ after session_id is set
  • pre_llm_call / post_llm_call: fired in both _interruptible_api_call and _interruptible_streaming_api_call around the API request thread
  • on_session_end: fired in run_conversation after _persist_session
  • discover_plugins() is now called before on_session_start to ensure plugins are loaded at session init - previously hooks fired before any listeners were registered.

Test plan

  • pre_llm_call receives correct messages and model kwargs
  • post_llm_call receives correct messages, response, and modelkwargs
  • on_session_start / on_session_end receive correct session_id andplatform kwargs
  • Exceptions in callbacks do not propagate to the agent loop
  • All six hooks present in VALID_HOOKS
  • Manual: create a plugin registering all four hooks, verify callbacks fire on LLM requests and session start/end
  • Manual: verify on_session_start fires with registered listeners (not before plugin discovery)

dlkakbs added 2 commits March 24, 2026 19:05
…session_end hooks

Four lifecycle hooks were listed in VALID_HOOKS and accepted by
register_hook() but invoke_hook() was never called for them, so plugin
callbacks registered against these hooks never fired.

- on_session_start: fired at end of AIAgent.__init__ after session_id is set
- pre_llm_call / post_llm_call: fired in both _interruptible_api_call and
  _interruptible_streaming_api_call around the API request thread
- on_session_end: fired in run_conversation after _persist_session

Fixes NousResearch#2817
…start, on_session_end hooks

Covers:
- correct kwargs delivered to each callback
- exceptions in callbacks do not propagate
- all six hooks present in VALID_HOOKS
@dlkakbs

dlkakbs commented Mar 24, 2026

Copy link
Copy Markdown
Contributor Author

Minor edge case: on streaming fallback, pre/post_llm_call may fire twice.

Plugin discovery only happened inside handle_function_call (first tool
use), so on_session_start and early pre_llm_call invocations fired
before any plugins were loaded. Explicitly calling discover_plugins()
(idempotent) before on_session_start ensures hooks have registered
listeners at session init time.
@MillionthOdin16

MillionthOdin16 commented Mar 24, 2026

Copy link
Copy Markdown

Tested this locally - all six hooks are firing correctly now. A test tool_logger plugin is receiving callbacks and logging llm_call events with model, tokens, duration, along with session lifecycle events.

Verified:

  • pre_llm_call / post_llm_call: logging LLM calls with input_tokens, output_tokens, duration_ms, model
  • on_session_start / on_session_end: session metadata tracking and session_end events in logs
  • pre_tool_call / post_tool_call: already working, now have uid and hostname fields

Thanks for the fix.

@dlkakbs

dlkakbs commented Mar 24, 2026

Copy link
Copy Markdown
Contributor Author

@MillionthOdin16 Glad it helped! Thanks for the verification.

@cbonilla20

Copy link
Copy Markdown

Just for information, I was trying to add a post llm call hook to fix issues with local inference services, and the hook wasn't able to run because the run_agent.py doesn't trigger hooks on failure cases. This is my walking through the issue I've found and is closely related with this PR.

Debugging a Hermes Plugin Hook That Never Fires

The Symptom

A plugin registered for post_llm_call loaded successfully (confirmed by its own log: "Plugin registered: post_llm_call hook active"), but the callback never executed when the target event occurred (retry exhaustion after HTTP 500 errors).

Phase 1: Verify the Plugin Loads

The first step was confirming the plugin wasn't the problem. Checking the plugin's own log file showed the register() function ran. Running hermes plugins list (or checking ~/.hermes/plugins/) confirmed Hermes discovered the directory and its plugin.yaml.

Lesson: Always verify the simplest layer first. If register() runs, the plugin system works — the problem is upstream.

Phase 2: Trace the Hook Invocation in the Agent Core

The next step was reading hermes_cli/plugins.py to understand the hook system. Key findings:

  • PluginManager.invoke_hook(hook_name, **kwargs) iterates over registered callbacks
  • VALID_HOOKS includes post_llm_call — so the hook name is correct
  • Each callback is wrapped in try/except — so even a crash wouldn't be silent in the agent logs

Then the critical search: where does the agent actually call invoke_hook("post_llm_call", ...)?

grep -n 'invoke_hook.*post_llm_call' run_agent.py

This found exactly one call site, deep in the main agent loop, inside an if guard:

if final_response and not interrupted:
    invoke_hook("post_llm_call", ...)

Phase 3: Trace the Failure Code Path

The next question: does the retry-exhaustion path reach that if statement?

Searching for the error message text ("Max retries", "Giving up") revealed the retry-exhaustion handler — and it ends with an early return that exits the method entirely, hundreds of lines before the hook invocation.

# Line ~7825 — retry exhaustion
return {
    "final_response": _final_response,
    "messages": messages,
    "completed": False,
    "failed": True,
    "error": _final_summary,
}

# ... 750+ lines later ...

# Line ~8578 — hook invocation (never reached on failure)
if final_response and not interrupted:
    invoke_hook("post_llm_call", ...)

Root cause: The hook was placed at the end of the "happy path" only. Every early-return failure path bypassed it.

Phase 4: The Fix Pattern

The fix was structural, not plugin-specific:

In the agent core (run_agent.py): Add hook invocations to each early-return failure path, before the return statement. Pass additional structured kwargs (failed=True, error=<message>) so plugins can distinguish failures from successes without text parsing:

# Before the early return
try:
    invoke_hook(
        "post_llm_call",
        session_id=self.session_id,
        user_message=original_user_message,
        assistant_response=_final_response,
        conversation_history=list(messages),
        model=self.model,
        platform=getattr(self, "platform", None) or "",
        failed=True,          # new field
        error=_final_summary,  # new field
    )
except Exception as exc:
    logger.warning("post_llm_call hook failed: %s", exc)

return { ... }

In the plugin: Use the structured kwargs as a fast path, with the original text-scan as a fallback for forward compatibility:

def _is_retry_exhaustion(failed: bool, error: str, text: str) -> bool:
    # Fast path: structured kwargs
    if failed and error:
        return True
    # Fallback: text scan
    has_failure = any(pat in text for pat in FAILURE_PATTERNS)
    has_error = any(pat in text for pat in ERROR_PATTERNS)
    return has_failure and has_error

Phase 5: Debug Logging Gotcha

The initial debug line logged raw kwargs:

_log(f"DEBUG kwargs: {kwargs}")

Since post_llm_call receives the full conversation_history (every message in the session), this single log line produced a 168KB file. The fix was to exclude conversation_history and truncate long values:

summary = {
    k: (str(v)[:120] + "..." if len(str(v)) > 120 else v)
    for k, v in kwargs.items()
    if k != "conversation_history"
}

Takeaways for Hermes Plugin Authors

  1. post_llm_call only fires on code paths that reach it. If the agent returns early (retry exhaustion, invalid response, budget exhaustion), the hook may be skipped. Verify by searching for all return statements between the start of the agent loop and the hook invocation.
  2. Log on register() to confirm loading. A one-line log in your register() function instantly tells you whether Hermes found and loaded your plugin.
  3. Don't log raw kwargs. The conversation_history kwarg contains the entire session. Log keys and summaries only.
  4. Add print() for user-visible status. Plugin hooks run in-process — print() goes straight to the user's terminal. Without visible output, the user has no idea the plugin is doing anything.
  5. Design for structured + fallback detection. If you need to patch the agent core to add fields, also keep a text-scan fallback so the plugin degrades gracefully if the patch is lost during a Hermes update.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for this PR — the hooks you wired here are now live on main.

This is an automated hermes-sweeper review.

Commit 455bf2e (merged as #3542, 2026-03-28, shipped in v2026.3.28) implemented all four lifecycle hooks with the same semantics described here:

  • on_session_startrun_agent.py line 9399
  • pre_llm_callrun_agent.py line 9501 (with context-injection support)
  • post_llm_callrun_agent.py line 12501
  • on_session_endrun_agent.py line 12603

The commit message notes it was "Salvaged from PR #2823", a related PR. Your work directly informed that merge.

One related gap worth a follow-up issue: as @cbonilla20 notes, post_llm_call still only fires on the happy path (if final_response and not interrupted). Retry-exhaustion and other early-return failure paths skip it. That would be a good targeted fix on top of the current implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Plugin hooks pre_llm_call, post_llm_call, on_session_start, on_session_end are documented but never invoked

4 participants