From 0e64adbfdd3b15bf275105280ca45ed8958fba12 Mon Sep 17 00:00:00 2001 From: Tosko4 <1294707+Tosko4@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:43:59 +0200 Subject: [PATCH 1/3] fix(agent): pass lane metadata to post-llm hooks --- agent/turn_finalizer.py | 8 + tests/test_post_llm_call_hook_metadata.py | 180 ++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 tests/test_post_llm_call_hook_metadata.py diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index fdf5babe1aea5..102818f9bb22b 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -409,6 +409,14 @@ def finalize_turn( conversation_history=list(messages), model=agent.model, platform=getattr(agent, "platform", None) or "", + context_compressor=getattr(agent, "context_compressor", None), + conversation_id=getattr(agent, "_gateway_session_key", None) or "", + gateway_session_key=getattr(agent, "_gateway_session_key", None) or "", + sender_id=getattr(agent, "_user_id", None) or "", + chat_id=getattr(agent, "chat_id", None) or "", + chat_name=getattr(agent, "chat_name", None) or "", + chat_type=getattr(agent, "chat_type", None) or "", + thread_id=getattr(agent, "thread_id", None) or "", ) except Exception as exc: logger.warning("post_llm_call hook failed: %s", exc) diff --git a/tests/test_post_llm_call_hook_metadata.py b/tests/test_post_llm_call_hook_metadata.py new file mode 100644 index 0000000000000..fc5c58be7d889 --- /dev/null +++ b/tests/test_post_llm_call_hook_metadata.py @@ -0,0 +1,180 @@ +from agent.turn_finalizer import finalize_turn + + +class _Budget: + remaining = 5 + used = 1 + max_total = 5 + + +class _Compressor: + name = "lcm" + last_prompt_tokens = 123 + + +class _Agent: + max_iterations = 5 + iteration_budget = _Budget() + quiet_mode = True + model = "test-model" + provider = "test-provider" + base_url = "" + session_id = "session-1" + platform = "discord" + chat_id = "channel-123" + chat_name = "Hermes LCM" + chat_type = "thread" + thread_id = "thread-456" + _gateway_session_key = "agent:main:discord:thread:thread-456:thread-456" + _user_id = "user-789" + context_compressor = _Compressor() + session_input_tokens = 1 + session_output_tokens = 2 + session_cache_read_tokens = 3 + session_cache_write_tokens = 4 + session_reasoning_tokens = 5 + session_prompt_tokens = 6 + session_completion_tokens = 7 + session_total_tokens = 8 + session_estimated_cost_usd = 0.0 + session_cost_status = "ok" + session_cost_source = "test" + _tool_guardrail_halt_decision = None + _response_was_previewed = False + _interrupt_message = "" + _stream_callback = None + _skill_nudge_interval = 0 + _iters_since_skill = 0 + valid_tool_names = set() + + def _emit_status(self, _msg): + pass + + def _safe_print(self, _msg): + pass + + def _handle_max_iterations(self, _messages, _api_call_count): + return "max iteration summary" + + def _save_trajectory(self, *_args, **_kwargs): + pass + + def _cleanup_task_resources(self, *_args, **_kwargs): + pass + + def _drop_trailing_empty_response_scaffolding(self, _messages): + pass + + def _persist_session(self, *_args, **_kwargs): + pass + + def _file_mutation_verifier_enabled(self): + return False + + def _turn_completion_explainer_enabled(self): + return False + + def _format_turn_completion_explanation(self, _reason): + return "" + + def _drain_pending_steer(self): + return None + + def clear_interrupt(self): + pass + + def _sync_external_memory_for_turn(self, **_kwargs): + pass + + def _spawn_background_review(self, **_kwargs): + pass + + +def test_post_llm_call_hook_receives_active_context_engine_and_gateway_lane(monkeypatch): + calls = [] + + def fake_invoke_hook(name, **kwargs): + calls.append((name, kwargs)) + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook) + + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "reply"}, + ] + result = finalize_turn( + _Agent(), + final_response="reply", + api_call_count=1, + interrupted=False, + failed=False, + messages=messages, + conversation_history=[], + effective_task_id="task-1", + turn_id="turn-1", + user_message="hello", + original_user_message="hello", + _should_review_memory=False, + _turn_exit_reason="text_response(final)", + ) + + assert result["final_response"] == "reply" + post_call = next(kwargs for name, kwargs in calls if name == "post_llm_call") + assert post_call["context_compressor"] is _Agent.context_compressor + assert post_call["conversation_id"] == "agent:main:discord:thread:thread-456:thread-456" + assert post_call["gateway_session_key"] == post_call["conversation_id"] + assert post_call["sender_id"] == "user-789" + assert post_call["chat_id"] == "channel-123" + assert post_call["chat_type"] == "thread" + assert post_call["thread_id"] == "thread-456" + + +def test_post_llm_call_hook_metadata_is_backward_compatible_for_telegram(monkeypatch): + calls = [] + + def fake_invoke_hook(name, **kwargs): + calls.append((name, kwargs)) + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook) + + class _TelegramAgent(_Agent): + platform = "telegram" + chat_id = "1782862480" + chat_name = "Home" + chat_type = "private" + thread_id = "" + _gateway_session_key = "agent:main:telegram:private:1782862480" + _user_id = "1782862480" + + messages = [ + {"role": "user", "content": "telegram hello"}, + {"role": "assistant", "content": "telegram reply"}, + ] + result = finalize_turn( + _TelegramAgent(), + final_response="telegram reply", + api_call_count=1, + interrupted=False, + failed=False, + messages=messages, + conversation_history=[], + effective_task_id="task-2", + turn_id="turn-2", + user_message="telegram hello", + original_user_message="telegram hello", + _should_review_memory=False, + _turn_exit_reason="text_response(final)", + ) + + assert result["final_response"] == "telegram reply" + post_call = next(kwargs for name, kwargs in calls if name == "post_llm_call") + assert post_call["platform"] == "telegram" + assert post_call["context_compressor"] is _TelegramAgent.context_compressor + assert post_call["conversation_id"] == "agent:main:telegram:private:1782862480" + assert post_call["gateway_session_key"] == post_call["conversation_id"] + assert post_call["sender_id"] == "1782862480" + assert post_call["chat_id"] == "1782862480" + assert post_call["chat_type"] == "private" + assert post_call["thread_id"] == "" From d60ba8c58545fcad42a7ea4624f468770b46d019 Mon Sep 17 00:00:00 2001 From: Tosko4 <1294707+Tosko4@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:38:55 +0200 Subject: [PATCH 2/3] fix(agent): read private lane attrs in post hook --- agent/turn_finalizer.py | 24 +++++++-- tests/test_post_llm_call_hook_metadata.py | 64 ++++++++++++++++++++--- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 102818f9bb22b..e86f06b85482a 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -413,10 +413,26 @@ def finalize_turn( conversation_id=getattr(agent, "_gateway_session_key", None) or "", gateway_session_key=getattr(agent, "_gateway_session_key", None) or "", sender_id=getattr(agent, "_user_id", None) or "", - chat_id=getattr(agent, "chat_id", None) or "", - chat_name=getattr(agent, "chat_name", None) or "", - chat_type=getattr(agent, "chat_type", None) or "", - thread_id=getattr(agent, "thread_id", None) or "", + chat_id=( + getattr(agent, "_chat_id", None) + or getattr(agent, "chat_id", None) + or "" + ), + chat_name=( + getattr(agent, "_chat_name", None) + or getattr(agent, "chat_name", None) + or "" + ), + chat_type=( + getattr(agent, "_chat_type", None) + or getattr(agent, "chat_type", None) + or "" + ), + thread_id=( + getattr(agent, "_thread_id", None) + or getattr(agent, "thread_id", None) + or "" + ), ) except Exception as exc: logger.warning("post_llm_call hook failed: %s", exc) diff --git a/tests/test_post_llm_call_hook_metadata.py b/tests/test_post_llm_call_hook_metadata.py index fc5c58be7d889..7a15605ec1099 100644 --- a/tests/test_post_llm_call_hook_metadata.py +++ b/tests/test_post_llm_call_hook_metadata.py @@ -21,10 +21,10 @@ class _Agent: base_url = "" session_id = "session-1" platform = "discord" - chat_id = "channel-123" - chat_name = "Hermes LCM" - chat_type = "thread" - thread_id = "thread-456" + _chat_id = "channel-123" + _chat_name = "Hermes LCM" + _chat_type = "thread" + _thread_id = "thread-456" _gateway_session_key = "agent:main:discord:thread:thread-456:thread-456" _user_id = "user-789" context_compressor = _Compressor() @@ -141,10 +141,10 @@ def fake_invoke_hook(name, **kwargs): class _TelegramAgent(_Agent): platform = "telegram" - chat_id = "1782862480" - chat_name = "Home" - chat_type = "private" - thread_id = "" + _chat_id = "1782862480" + _chat_name = "Home" + _chat_type = "private" + _thread_id = "" _gateway_session_key = "agent:main:telegram:private:1782862480" _user_id = "1782862480" @@ -178,3 +178,51 @@ class _TelegramAgent(_Agent): assert post_call["chat_id"] == "1782862480" assert post_call["chat_type"] == "private" assert post_call["thread_id"] == "" + + +def test_post_llm_call_hook_metadata_falls_back_to_public_lane_attrs(monkeypatch): + calls = [] + + def fake_invoke_hook(name, **kwargs): + calls.append((name, kwargs)) + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook) + + class _PublicLaneAgent(_Agent): + _chat_id = "" + _chat_name = "" + _chat_type = "" + _thread_id = "" + chat_id = "public-channel" + chat_name = "Public Channel" + chat_type = "channel" + thread_id = "public-thread" + _gateway_session_key = "agent:main:discord:channel:public-channel:public-thread" + + messages = [ + {"role": "user", "content": "public hello"}, + {"role": "assistant", "content": "public reply"}, + ] + result = finalize_turn( + _PublicLaneAgent(), + final_response="public reply", + api_call_count=1, + interrupted=False, + failed=False, + messages=messages, + conversation_history=[], + effective_task_id="task-3", + turn_id="turn-3", + user_message="public hello", + original_user_message="public hello", + _should_review_memory=False, + _turn_exit_reason="text_response(final)", + ) + + assert result["final_response"] == "public reply" + post_call = next(kwargs for name, kwargs in calls if name == "post_llm_call") + assert post_call["chat_id"] == "public-channel" + assert post_call["chat_name"] == "Public Channel" + assert post_call["chat_type"] == "channel" + assert post_call["thread_id"] == "public-thread" From e5c16a5ac84a44802a4e44909b1034fcd14e5494 Mon Sep 17 00:00:00 2001 From: Tosko4 <1294707+Tosko4@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:54:00 +0200 Subject: [PATCH 3/3] fix(plugins): expose safe post-llm metadata --- agent/turn_finalizer.py | 8 +++++++- tests/test_post_llm_call_hook_metadata.py | 8 ++++++-- website/docs/developer-guide/plugins/index.md | 2 +- website/docs/user-guide/features/hooks.md | 10 ++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index e86f06b85482a..38b64a1bba59c 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -409,7 +409,13 @@ def finalize_turn( conversation_history=list(messages), model=agent.model, platform=getattr(agent, "platform", None) or "", - context_compressor=getattr(agent, "context_compressor", None), + # Hooks get a safe engine identifier, not the live compressor + # object: compressor instances can carry provider credentials + # and observer plugins may serialize hook kwargs wholesale. + context_engine=str( + getattr(getattr(agent, "context_compressor", None), "name", "") + or "" + ), conversation_id=getattr(agent, "_gateway_session_key", None) or "", gateway_session_key=getattr(agent, "_gateway_session_key", None) or "", sender_id=getattr(agent, "_user_id", None) or "", diff --git a/tests/test_post_llm_call_hook_metadata.py b/tests/test_post_llm_call_hook_metadata.py index 7a15605ec1099..a6ce094ba2c84 100644 --- a/tests/test_post_llm_call_hook_metadata.py +++ b/tests/test_post_llm_call_hook_metadata.py @@ -10,6 +10,7 @@ class _Budget: class _Compressor: name = "lcm" last_prompt_tokens = 123 + api_key = "provider-secret-must-not-reach-hooks" class _Agent: @@ -121,7 +122,9 @@ def fake_invoke_hook(name, **kwargs): assert result["final_response"] == "reply" post_call = next(kwargs for name, kwargs in calls if name == "post_llm_call") - assert post_call["context_compressor"] is _Agent.context_compressor + assert post_call["context_engine"] == "lcm" + assert "context_compressor" not in post_call + assert _Compressor.api_key not in repr(post_call) assert post_call["conversation_id"] == "agent:main:discord:thread:thread-456:thread-456" assert post_call["gateway_session_key"] == post_call["conversation_id"] assert post_call["sender_id"] == "user-789" @@ -171,7 +174,8 @@ class _TelegramAgent(_Agent): assert result["final_response"] == "telegram reply" post_call = next(kwargs for name, kwargs in calls if name == "post_llm_call") assert post_call["platform"] == "telegram" - assert post_call["context_compressor"] is _TelegramAgent.context_compressor + assert post_call["context_engine"] == "lcm" + assert "context_compressor" not in post_call assert post_call["conversation_id"] == "agent:main:telegram:private:1782862480" assert post_call["gateway_session_key"] == post_call["conversation_id"] assert post_call["sender_id"] == "1782862480" diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 99fe800b41003..0f3d46e12e5e6 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -600,7 +600,7 @@ Each hook is documented in full on the **[Event Hooks reference](/user-guide/fea | [`pre_tool_call`](/user-guide/features/hooks#pre_tool_call) | Before any tool executes | `tool_name: str, args: dict, task_id: str` | ignored | | [`post_tool_call`](/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | ignored | | [`pre_llm_call`](/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) | -| [`post_llm_call`](/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored | +| [`post_llm_call`](/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | Core turn fields plus optional `context_engine`, `conversation_id`, `gateway_session_key`, `sender_id`, `chat_id`, `chat_name`, `chat_type`, and `thread_id` via `**kwargs` | ignored | | [`on_session_start`](/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored | | [`on_session_end`](/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored | | [`on_session_finalize`](/user-guide/features/hooks#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored | diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index f38ed9343b96d..68567aed623d7 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -609,6 +609,16 @@ def my_callback(session_id: str, user_message: str, assistant_response: str, | `conversation_history` | `list` | Copy of the full message list after the turn completed | | `model` | `str` | The model identifier | | `platform` | `str` | Where the session is running | +| `context_engine` | `str` | Name of the active context engine/compressor; empty when unavailable | +| `conversation_id` | `str` | Gateway conversation key; empty outside gateway sessions | +| `gateway_session_key` | `str` | Alias of `conversation_id` for explicit gateway integrations; empty outside gateway sessions | +| `sender_id` | `str` | Gateway sender/user identifier; empty when unavailable | +| `chat_id` | `str` | Gateway chat/channel identifier; empty when unavailable | +| `chat_name` | `str` | Gateway chat/channel display name; empty when unavailable | +| `chat_type` | `str` | Gateway chat type, such as `private`, `group`, or `thread`; empty when unavailable | +| `thread_id` | `str` | Gateway thread/topic identifier; empty when unavailable or not applicable | + +The context-engine and gateway lane fields are optional extension metadata and should be accepted through `**kwargs`. Hermes exposes only the context engine's name, not the live compressor object, because engine instances may contain provider credentials or other private runtime state. CLI and other non-gateway sessions normally receive empty strings for gateway-specific values. Plugins must not assume that a chat or thread identifier is present. **Fires:** In `run_agent.py`, inside `run_conversation()`, after the tool loop exits with a final response. Guarded by `if final_response and not interrupted` — so it does **not** fire when the user interrupts mid-turn or the agent hits the iteration limit without producing a response.