From 4d8b9bf965276ba16be76356da2d3451ceb28e92 Mon Sep 17 00:00:00 2001 From: mol <1477787+Gerkinfeltser@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:03:44 +0000 Subject: [PATCH 1/4] fix(plugins): propagate gateway session context --- agent/agent_runtime_helpers.py | 2 + agent/tool_executor.py | 4 + agent/turn_context.py | 1 + cli.py | 7 +- gateway/run.py | 25 ++++- hermes_cli/plugins.py | 87 +++++++++++++++- model_tools.py | 3 + tests/agent/test_turn_context.py | 13 +++ tests/gateway/test_unknown_command.py | 55 +++++++++- tests/hermes_cli/test_plugins.py | 142 +++++++++++++++++++++++++- tests/run_agent/test_run_agent.py | 30 ++++++ tests/test_model_tools.py | 2 + tests/tui_gateway/test_protocol.py | 68 ++++++++++-- tui_gateway/server.py | 33 +++++- 14 files changed, 453 insertions(+), 19 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index c6ed459e93d97..39a428c5fc5e5 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2225,6 +2225,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", middleware_trace=list(_tool_middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) except Exception: block_message = None @@ -2372,6 +2373,7 @@ def _execute(next_args: dict) -> Any: enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), tool_request_middleware_trace=list(_tool_middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) from hermes_cli.middleware import run_tool_execution_middleware diff --git a/agent/tool_executor.py b/agent/tool_executor.py index a5871442b4491..7bf9108d49e41 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -460,6 +460,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", middleware_trace=list(middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) except Exception: block_message = None @@ -1123,6 +1124,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", middleware_trace=list(middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) except Exception: pass @@ -1494,6 +1496,7 @@ def _execute(next_args: dict) -> Any: enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), tool_request_middleware_trace=list(middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) _spinner_result = function_result except KeyboardInterrupt: @@ -1536,6 +1539,7 @@ def _execute(next_args: dict) -> Any: enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), tool_request_middleware_trace=list(middleware_trace), + gateway_session_key=getattr(agent, "_gateway_session_key", "") or "", ) except KeyboardInterrupt: _emit_cancelled_terminal_post_tool_call( diff --git a/agent/turn_context.py b/agent/turn_context.py index ea150ff30a7c4..fbd59110ef289 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -521,6 +521,7 @@ def _ensure_and_persist() -> None: _pre_results = _invoke_hook( "pre_llm_call", session_id=agent.session_id, + gateway_session_key=getattr(agent, "_gateway_session_key", None) or "", task_id=effective_task_id, turn_id=turn_id, user_message=original_user_message, diff --git a/cli.py b/cli.py index 8a8fdf6f5ff57..3fb0e03d5d931 100644 --- a/cli.py +++ b/cli.py @@ -8950,6 +8950,7 @@ def process_command(self, command: str) -> bool: # Check for plugin-registered slash commands elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): from hermes_cli.plugins import ( + call_plugin_command_handler, get_plugin_command_handler, resolve_plugin_command_result, ) @@ -8958,7 +8959,11 @@ def process_command(self, command: str) -> bool: user_args = cmd_original[len(base_cmd):].strip() try: result = resolve_plugin_command_result( - plugin_handler(user_args) + call_plugin_command_handler( + plugin_handler, + user_args, + session_id=self.session_id, + ) ) if result: _cprint(str(result)) diff --git a/gateway/run.py b/gateway/run.py index f05d89540b5e8..8b2acde625f7b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10223,15 +10223,34 @@ async def _do_undo(): # Plugin-registered slash commands if command: try: - from hermes_cli.plugins import get_plugin_command_handler + from hermes_cli.plugins import ( + call_plugin_command_handler, + get_plugin_command_handler, + ) # Normalize underscores to hyphens so Telegram's underscored # autocomplete form matches plugin commands registered with # hyphens. See hermes_cli/commands.py:_build_telegram_menu. plugin_handler = get_plugin_command_handler(command.replace("_", "-")) if plugin_handler: user_args = event.get_command_args().strip() - result = plugin_handler(user_args) - if asyncio.iscoroutine(result): + command_agent = self._running_agents.get(_quick_key) + command_session_id = getattr(command_agent, "session_id", None) + if not command_session_id: + try: + command_session = await self.async_session_store.get_or_create_session(source) + command_session_id = getattr(command_session, "session_id", None) + except Exception: + logger.debug( + "Could not resolve live session ID for plugin command", + exc_info=True, + ) + result = call_plugin_command_handler( + plugin_handler, + user_args, + session_id=command_session_id or "", + gateway_session_key=_quick_key, + ) + if inspect.isawaitable(result): result = await result return str(result) if result else None except Exception as e: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 6ca393fca53c1..ea5c5b9198642 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1914,7 +1914,34 @@ def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]: results: List[Any] = [] for cb in callbacks: try: - ret = cb(**kwargs) + # Hook context grows over time. Preserve strict legacy callbacks + # by forwarding additive fields only when they are explicitly + # declared; callbacks using **kwargs retain the full schema. + try: + signature = inspect.signature(cb) + accepts_kwargs = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + callback_kwargs = ( + kwargs + if accepts_kwargs + else { + name: value + for name, value in kwargs.items() + if (parameter := signature.parameters.get(name)) is not None + and parameter.kind + in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + } + ) + except (TypeError, ValueError): + # Retain the established full-kwargs behavior for opaque + # callables that do not expose a Python signature. + callback_kwargs = kwargs + ret = cb(**callback_kwargs) if ret is not None: results.append(ret) except Exception as exc: @@ -2107,6 +2134,7 @@ def _get_pre_tool_call_directive_details( turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, + gateway_session_key: str = "", ) -> _PreToolCallDirective: """Check ``pre_tool_call`` hooks for a blocking or approval directive. @@ -2148,6 +2176,7 @@ def _get_pre_tool_call_directive_details( args=args if isinstance(args, dict) else {}, task_id=task_id, session_id=session_id, + gateway_session_key=gateway_session_key, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, @@ -2184,6 +2213,7 @@ def get_pre_tool_call_directive( turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, + gateway_session_key: str = "", ) -> tuple[Optional[str], Optional[str]]: """Check ``pre_tool_call`` hooks for a blocking or approval directive. @@ -2196,6 +2226,7 @@ def get_pre_tool_call_directive( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, + gateway_session_key=gateway_session_key, ) return (details.action, details.message) @@ -2209,6 +2240,7 @@ def get_pre_tool_call_block_message( turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, + gateway_session_key: str = "", ) -> Optional[str]: """Back-compat shim: return only a ``block`` message (or ``None``). @@ -2221,6 +2253,7 @@ def get_pre_tool_call_block_message( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, + gateway_session_key=gateway_session_key, ) return message if directive == "block" else None @@ -2234,6 +2267,7 @@ def resolve_pre_tool_block( turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, + gateway_session_key: str = "", ) -> Optional[str]: """Resolve the pre_tool_call directive to a final block message (or None). @@ -2253,6 +2287,7 @@ def resolve_pre_tool_block( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, + gateway_session_key=gateway_session_key, ) if details.action == "block": return details.message @@ -2348,6 +2383,56 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]: return entry["handler"] if entry else None +def call_plugin_command_handler( + handler: Callable, + raw_args: str, + *, + session_id: str = "", + gateway_session_key: str = "", + **kwargs: Any, +) -> Any: + """Invoke a plugin command handler with its supported runtime context. + + ``raw_args`` remains the first positional argument, preserving the public + legacy command contract of ``handler(raw_args)``. Handlers may opt into + ``session_id``, ``gateway_session_key``, or additional named context + keyword arguments. ``gateway_session_key`` identifies a gateway-backed + conversation when available and may be empty outside a gateway; it does + not replace the live agent ``session_id``. + """ + context = { + "session_id": session_id, + "gateway_session_key": gateway_session_key, + **kwargs, + } + try: + signature = inspect.signature(handler) + bound = signature.bind_partial(raw_args) + except (TypeError, ValueError): + # Some callable objects do not expose an inspectable Python signature. + # Their historic contract is the one positional raw-arguments value. + return handler(raw_args) + + accepts_kwargs = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + supported_context = { + name: value + for name, value in context.items() + if name not in bound.arguments + and ( + accepts_kwargs + or ( + (parameter := signature.parameters.get(name)) is not None + and parameter.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + ) + ) + } + return handler(raw_args, **supported_context) + + _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0 diff --git a/model_tools.py b/model_tools.py index c59c189e36d9d..3926930b98da6 100644 --- a/model_tools.py +++ b/model_tools.py @@ -1037,6 +1037,7 @@ def handle_function_call( tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None, enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None, + gateway_session_key: Optional[str] = None, ) -> str: """ Main function call dispatcher that routes calls to the tool registry. @@ -1141,6 +1142,7 @@ def handle_function_call( tool_request_middleware_trace=list(_tool_middleware_trace), enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, + gateway_session_key=gateway_session_key, ) _tool_original_args = dict(function_args) @@ -1191,6 +1193,7 @@ def handle_function_call( turn_id=turn_id or "", api_request_id=api_request_id or "", middleware_trace=list(_tool_middleware_trace), + gateway_session_key=gateway_session_key or "", ) except Exception as _hook_err: logger.debug("pre_tool_call hook error: %s", _hook_err) diff --git a/tests/agent/test_turn_context.py b/tests/agent/test_turn_context.py index e0642c5f11bfb..416375aa1a307 100644 --- a/tests/agent/test_turn_context.py +++ b/tests/agent/test_turn_context.py @@ -201,6 +201,19 @@ def test_task_id_passthrough(): assert agent._current_task_id == "fixed-task" +def test_pre_llm_hook_receives_gateway_session_key_once(): + agent = _FakeAgent() + agent._gateway_session_key = "agent:main:telegram:dm:42" + + with patch("hermes_cli.plugins.invoke_hook", return_value=[]) as invoke_hook: + _build(agent) + + invoke_hook.assert_called_once() + assert invoke_hook.call_args.args == ("pre_llm_call",) + assert invoke_hook.call_args.kwargs["session_id"] == "sess-1" + assert invoke_hook.call_args.kwargs["gateway_session_key"] == "agent:main:telegram:dm:42" + + def test_persist_user_message_becomes_original(): agent = _FakeAgent() ctx = _build(agent, user_message="api-prefixed", persist_user_message="clean") diff --git a/tests/gateway/test_unknown_command.py b/tests/gateway/test_unknown_command.py index 1141344963831..2e70b252a2b82 100644 --- a/tests/gateway/test_unknown_command.py +++ b/tests/gateway/test_unknown_command.py @@ -5,6 +5,7 @@ delegate_task call instead of telling the user the command doesn't exist). """ +import asyncio from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -359,10 +360,17 @@ async def _emit_collect(event_type, ctx): "get_plugin_commands", lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}}, ) + expected_session_key = build_session_key(_make_source()) + + def _handler(args, *, session_id, gateway_session_key): + assert session_id == "sess-1" + assert gateway_session_key == expected_session_key + return f"metrics {args}" + monkeypatch.setattr( _plugins_mod, "get_plugin_command_handler", - lambda name: (lambda args: f"metrics {args}") if name == "metricas" else None, + lambda name: _handler if name == "metricas" else None, ) result = await runner._handle_message(_make_event("/status")) @@ -371,3 +379,48 @@ async def _emit_collect(event_type, ctx): # First emit_collect fires on the original command; after rewrite the # dispatcher does NOT re-fire for the new command (one decision per turn). assert call_log == ["command:status"] + + +@pytest.mark.asyncio +async def test_command_hook_rewrite_awaits_future_plugin_result(monkeypatch): + """Gateway plugin commands await all awaitables, not only coroutines.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._run_agent = AsyncMock( + side_effect=AssertionError("rewritten command leaked to the agent") + ) + + async def _emit_collect(event_type, _ctx): + if event_type == "command:status": + return [ + { + "decision": "rewrite", + "command_name": "metricas", + "raw_args": "dias:7", + } + ] + return [] + + runner.hooks.emit_collect = AsyncMock(side_effect=_emit_collect) + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + from hermes_cli import plugins as _plugins_mod + + monkeypatch.setattr( + _plugins_mod, + "get_plugin_commands", + lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}}, + ) + future = asyncio.get_running_loop().create_future() + future.set_result("future metrics dias:7") + monkeypatch.setattr( + _plugins_mod, + "get_plugin_command_handler", + lambda name: (lambda _args: future) if name == "metricas" else None, + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "future metrics dias:7" diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 3fdb6d1812d20..1012d21837cee 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -15,6 +15,7 @@ PluginContext, PluginManager, PluginManifest, + call_plugin_command_handler, get_plugin_command_handler, get_plugin_commands, get_pre_tool_call_block_message, @@ -694,6 +695,71 @@ def test_invoke_hook_adds_observer_schema_version(self, tmp_path, monkeypatch): "hermes.observer.v1" ] + def test_strict_legacy_pre_tool_hook_still_returns_block_directive(self): + """Additive hook context must not silently bypass legacy policy hooks.""" + def hook(tool_name, args, task_id, session_id): + assert tool_name == "terminal" + assert args == {"command": "whoami"} + assert task_id == "task-1" + assert session_id == "live-session" + return {"action": "block", "message": "legacy policy"} + + mgr = PluginManager() + mgr._hooks["pre_tool_call"] = [hook] + + assert mgr.invoke_hook( + "pre_tool_call", + tool_name="terminal", + args={"command": "whoami"}, + task_id="task-1", + session_id="live-session", + gateway_session_key="agent:main:discord:channel:42", + telemetry_schema_version="future-schema", + ) == [{"action": "block", "message": "legacy policy"}] + + def test_strict_legacy_pre_llm_hook_still_returns_context(self): + """Strict pre_llm_call callbacks remain compatible with new context.""" + def hook(session_id, user_message, conversation_history, is_first_turn, model): + assert session_id == "live-session" + assert user_message == "hello" + assert conversation_history == [] + assert is_first_turn is True + assert model == "test-model" + return {"context": "legacy context"} + + mgr = PluginManager() + mgr._hooks["pre_llm_call"] = [hook] + + assert mgr.invoke_hook( + "pre_llm_call", + session_id="live-session", + gateway_session_key="agent:main:discord:channel:42", + task_id="task-1", + turn_id="turn-1", + user_message="hello", + conversation_history=[], + is_first_turn=True, + model="test-model", + platform="discord", + sender_id="42", + ) == [{"context": "legacy context"}] + + def test_strict_hook_receives_declared_gateway_session_key(self): + """Strict callbacks can opt in to the new gateway identity field.""" + def hook(tool_name, gateway_session_key): + assert tool_name == "terminal" + return gateway_session_key + + mgr = PluginManager() + mgr._hooks["pre_tool_call"] = [hook] + + assert mgr.invoke_hook( + "pre_tool_call", + tool_name="terminal", + args={}, + gateway_session_key="agent:main:discord:channel:42", + ) == ["agent:main:discord:channel:42"] + def test_hook_exception_does_not_propagate(self, tmp_path, monkeypatch): """A hook callback that raises does NOT crash the caller.""" plugins_dir = tmp_path / "hermes_test" / "plugins" @@ -927,10 +993,31 @@ def test_block_returns_message(self, monkeypatch): def test_no_directive_returns_none(self, monkeypatch): from hermes_cli.plugins import resolve_pre_tool_block - monkeypatch.setattr( - "hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: []) + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: []) assert resolve_pre_tool_block("terminal", {}) is None + def test_forwards_distinct_gateway_session_key_to_pre_tool_hook(self, monkeypatch): + from hermes_cli.plugins import resolve_pre_tool_block + + seen = {} + + def invoke_hook(hook_name, **kwargs): + seen["hook_name"] = hook_name + seen.update(kwargs) + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook) + + assert resolve_pre_tool_block( + "terminal", + {"command": "pwd"}, + session_id="agent-session", + gateway_session_key="agent:main:discord:channel:42", + ) is None + assert seen["hook_name"] == "pre_tool_call" + assert seen["session_id"] == "agent-session" + assert seen["gateway_session_key"] == "agent:main:discord:channel:42" + def test_approve_denied_blocks(self, monkeypatch): from hermes_cli.plugins import resolve_pre_tool_block monkeypatch.setattr( @@ -1797,6 +1884,57 @@ def test_routing_logic_all_to_user_message(self, tmp_path, monkeypatch): class TestPluginCommands: """Tests for plugin slash command registration via register_command().""" + def test_call_handler_preserves_legacy_raw_args_signature(self): + """Handlers written for the original one-argument API keep working.""" + def handler(raw_args): + return f"legacy:{raw_args}" + + assert call_plugin_command_handler( + handler, + "hello world", + session_id="agent-session", + gateway_session_key="agent:main:discord:channel:42", + ) == "legacy:hello world" + + def test_call_handler_forwards_supported_named_context(self): + """Opt-in handlers receive only the context parameters they declare.""" + def handler(raw_args, *, session_id, gateway_session_key, platform): + return raw_args, session_id, gateway_session_key, platform + + assert call_plugin_command_handler( + handler, + "hello world", + session_id="agent-session", + gateway_session_key="agent:main:discord:channel:42", + platform="discord", + ignored="not forwarded", + ) == ( + "hello world", + "agent-session", + "agent:main:discord:channel:42", + "discord", + ) + + def test_call_handler_forwards_all_context_to_kwargs_handler(self): + """Handlers accepting ``**kwargs`` receive the complete runtime context.""" + def handler(raw_args, **context): + return raw_args, context + + assert call_plugin_command_handler( + handler, + "hello world", + session_id="agent-session", + gateway_session_key="agent:main:discord:channel:42", + platform="discord", + ) == ( + "hello world", + { + "session_id": "agent-session", + "gateway_session_key": "agent:main:discord:channel:42", + "platform": "discord", + }, + ) + def test_register_command_basic(self): """register_command() stores handler, description, and plugin name.""" mgr = PluginManager() diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 2dc46c1d785b2..59402bc48b94c 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3034,6 +3034,7 @@ def test_invoke_tool_dispatches_to_handle_function_call(self, agent): enabled_toolsets=agent.enabled_toolsets, disabled_toolsets=agent.disabled_toolsets, tool_request_middleware_trace=[], + gateway_session_key="", ) assert result == "result" @@ -3176,6 +3177,35 @@ def test_invoke_tool_blocked_skips_handle_function_call(self, agent, monkeypatch assert json.loads(result) == {"error": "Blocked"} + def test_agent_tool_routes_forward_gateway_session_key_to_pre_tool_hook(self, agent, monkeypatch): + gateway_session_key = "agent:main:discord:channel:42" + agent._gateway_session_key = gateway_session_key + seen = [] + + def resolve_pre_tool_block(*args, **kwargs): + seen.append(kwargs) + return None + + monkeypatch.setattr( + "hermes_cli.plugins.resolve_pre_tool_block", + resolve_pre_tool_block, + ) + + tool_call = _mock_tool_call( + name="todo", + arguments='{"todos": []}', + call_id="todo-1", + ) + mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call]) + + with patch("tools.todo_tool.todo_tool", return_value='{"ok": true}'): + agent._invoke_tool("todo", {"todos": []}, "task-1") + agent._execute_tool_calls_sequential(mock_msg, [], "task-1") + agent._execute_tool_calls_concurrent(mock_msg, [], "task-1") + + assert len(seen) == 3 + assert all(call["gateway_session_key"] == gateway_session_key for call in seen) + def test_sequential_blocked_tool_skips_checkpoints_and_callbacks(self, agent, monkeypatch): """Sequential path: blocked tool should not trigger checkpoints or start callbacks.""" tool_call = _mock_tool_call(name="write_file", diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 469b8a6921e95..f1e3079419283 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -51,6 +51,7 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self): task_id="task-1", tool_call_id="call-1", session_id="session-1", + gateway_session_key="agent:main:discord:channel:42", ) assert result == '{"ok":true}' @@ -61,6 +62,7 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self): args={"q": "test"}, task_id="task-1", session_id="session-1", + gateway_session_key="agent:main:discord:channel:42", tool_call_id="call-1", turn_id="", api_request_id="", diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 274ad8906be44..f1dac0dbf3134 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1217,6 +1217,42 @@ def set_session_title(self, _key, _title): assert kwargs["model_config"] == {"_branched_from": parent_key} +def test_make_agent_forwards_distinct_gateway_session_key(server, monkeypatch): + """TUI agents retain their stable gateway key beside the live session ID.""" + captured = {} + + class _Agent: + def __init__(self, **kwargs): + captured.update(kwargs) + self.model = kwargs.get("model", "") + + monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=_Agent)) + monkeypatch.setitem( + sys.modules, + "hermes_cli.runtime_provider", + types.SimpleNamespace( + resolve_runtime_provider=lambda **_kwargs: { + "provider": "test", + "base_url": None, + "api_key": None, + "api_mode": None, + } + ), + ) + monkeypatch.setattr(server, "_load_cfg", lambda: {}) + monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test/model", "test")) + monkeypatch.setattr(server, "_get_db", lambda: None) + + server._make_agent( + "tui-session", + "agent:main:discord:channel:42", + session_id="live-agent-session", + ) + + assert captured["session_id"] == "live-agent-session" + assert captured["gateway_session_key"] == "agent:main:discord:channel:42" + + def test_make_agent_accepts_list_system_prompt(server, monkeypatch): captured = {} @@ -1361,7 +1397,7 @@ def run(self, cmd): def test_slash_exec_handles_plugin_commands_in_live_gateway(server): - """Plugin slash commands return normal slash.exec output without using the worker.""" + """Plugin slash commands receive both agent and stable gateway session context.""" sid = "test-session" class Worker: @@ -1373,11 +1409,18 @@ def run(self, cmd): return f"worker:{cmd}" worker = Worker() - server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker} + server._sessions[sid] = { + "session_key": "gateway-key", + "agent": types.SimpleNamespace(session_id="agent-session"), + "slash_worker": worker, + } + + def handler(arg, *, session_id, gateway_session_key): + return f"plugin:{arg}:{session_id}:{gateway_session_key}" with patch( "hermes_cli.plugins.get_plugin_command_handler", - lambda name: (lambda arg: f"plugin:{arg}") if name == "plugin-cmd" else None, + lambda name: handler if name == "plugin-cmd" else None, ): resp = server.handle_request({ "id": "r-plugin-slash", @@ -1386,7 +1429,7 @@ def run(self, cmd): }) assert "error" not in resp - assert resp["result"] == {"output": "plugin:hello"} + assert resp["result"] == {"output": "plugin:hello:agent-session:gateway-key"} assert worker.calls == [] @@ -1793,8 +1836,14 @@ def test_command_dispatch_returns_custom_bundle_payload(server): def test_command_dispatch_awaits_async_plugin_handler(server): - async def _handler(arg): - return f"async:{arg}" + sid = "test-session" + server._sessions[sid] = { + "session_key": "gateway-key", + "agent": types.SimpleNamespace(session_id="agent-session"), + } + + async def _handler(arg, *, session_id, gateway_session_key): + return f"async:{arg}:{session_id}:{gateway_session_key}" with patch( "hermes_cli.plugins.get_plugin_command_handler", @@ -1803,11 +1852,14 @@ async def _handler(arg): resp = server.handle_request({ "id": "r-plugin", "method": "command.dispatch", - "params": {"name": "async-cmd", "arg": "hello"}, + "params": {"name": "async-cmd", "arg": "hello", "session_id": sid}, }) assert "error" not in resp - assert resp["result"] == {"type": "plugin", "output": "async:hello"} + assert resp["result"] == { + "type": "plugin", + "output": "async:hello:agent-session:gateway-key", + } # ── dispatch(): pool routing for long handlers (#12546) ────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2f6e833934f63..86ea39738334e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -542,6 +542,14 @@ def _is_gateway_owned_source(source: str) -> bool: return False +def _session_context_id(session: dict | None) -> str: + """Return the live agent session id, falling back to the TUI session key.""" + if not session: + return "" + agent = session.get("agent") + return getattr(agent, "session_id", None) or session.get("session_key", "") or "" + + def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None: """Best-effort finalize hook + memory commit for a session. @@ -4669,6 +4677,7 @@ def _make_agent( provider_data_collection=_pr.get("data_collection"), platform=_resolve_agent_platform(platform_override), session_id=session_id or key, + gateway_session_key=key, session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None, checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")), @@ -11970,13 +11979,21 @@ def _(rid, params: dict) -> dict: try: from hermes_cli.plugins import ( + call_plugin_command_handler, get_plugin_command_handler, resolve_plugin_command_result, ) handler = get_plugin_command_handler(name) if handler: - result = resolve_plugin_command_result(handler(arg)) + result = resolve_plugin_command_result( + call_plugin_command_handler( + handler, + arg, + session_id=_session_context_id(session), + gateway_session_key=(session or {}).get("session_key", ""), + ) + ) return _ok(rid, {"type": "plugin", "output": str(result or "")}) except Exception: pass @@ -13295,10 +13312,12 @@ def _(rid, params: dict) -> dict: pass plugin_handler = None + call_plugin_command_handler = None resolve_plugin_command_result = None if _cmd_base: try: from hermes_cli.plugins import ( + call_plugin_command_handler, get_plugin_command_handler, resolve_plugin_command_result, ) @@ -13306,11 +13325,19 @@ def _(rid, params: dict) -> dict: plugin_handler = get_plugin_command_handler(_cmd_base) except Exception: plugin_handler = None + call_plugin_command_handler = None resolve_plugin_command_result = None - if plugin_handler and resolve_plugin_command_result: + if plugin_handler and call_plugin_command_handler and resolve_plugin_command_result: try: - result = resolve_plugin_command_result(plugin_handler(_cmd_arg)) + result = resolve_plugin_command_result( + call_plugin_command_handler( + plugin_handler, + _cmd_arg, + session_id=_session_context_id(session), + gateway_session_key=(session or {}).get("session_key", ""), + ) + ) return _ok(rid, {"output": str(result or "(no output)")}) except Exception as e: return _ok(rid, {"output": f"Plugin command error: {e}"}) From 10d0bc936dec37db30402567168305284aa10f7a Mon Sep 17 00:00:00 2001 From: mol <1477787+Gerkinfeltser@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:36:58 +0000 Subject: [PATCH 2/4] fix(plugins): resolve Future command results --- hermes_cli/plugins.py | 9 ++++++-- tests/hermes_cli/test_plugins.py | 22 ++++++++++++++++++ tests/tui_gateway/test_protocol.py | 36 +++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index ea5c5b9198642..19642ca80c595 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2436,6 +2436,11 @@ def call_plugin_command_handler( _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0 +async def _await_plugin_command_result(result: Any) -> Any: + """Adapt a general awaitable to the coroutine contract of ``asyncio.run``.""" + return await result + + def resolve_plugin_command_result(result: Any) -> Any: """Resolve a plugin command return value, awaiting async handlers when needed. @@ -2452,7 +2457,7 @@ def resolve_plugin_command_result(result: Any) -> Any: try: asyncio.get_running_loop() except RuntimeError: - return asyncio.run(result) + return asyncio.run(_await_plugin_command_result(result)) outcome: Dict[str, Any] = {} failure: Dict[str, BaseException] = {} @@ -2460,7 +2465,7 @@ def resolve_plugin_command_result(result: Any) -> Any: def _runner() -> None: try: - outcome["value"] = asyncio.run(result) + outcome["value"] = asyncio.run(_await_plugin_command_result(result)) except BaseException as exc: # pragma: no cover - re-raised below failure["exc"] = exc finally: diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 1012d21837cee..6ef1d01a1e4e0 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -2214,6 +2214,28 @@ async def _handler(): monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop()) assert resolve_plugin_command_result(_handler()) == "threaded-ok" + def test_awaits_completed_future_without_running_loop(self): + import asyncio + + loop = asyncio.new_event_loop() + future = loop.create_future() + future.set_result("future-ok") + + assert resolve_plugin_command_result(future) == "future-ok" + + def test_awaits_completed_future_with_running_loop(self, monkeypatch): + import asyncio + + class _Loop: + pass + + loop = asyncio.new_event_loop() + future = loop.create_future() + future.set_result("threaded-future-ok") + + monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop()) + assert resolve_plugin_command_result(future) == "threaded-future-ok" + def test_running_loop_timeout_does_not_hang_forever(self, monkeypatch): """Threaded path must abort a hung async handler instead of blocking the caller.""" import asyncio as _asyncio diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index f1dac0dbf3134..4601e0a072c1d 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1,5 +1,6 @@ """Tests for tui_gateway JSON-RPC protocol plumbing.""" +import asyncio import io import json import sys @@ -1416,7 +1417,10 @@ def run(self, cmd): } def handler(arg, *, session_id, gateway_session_key): - return f"plugin:{arg}:{session_id}:{gateway_session_key}" + loop = asyncio.new_event_loop() + future = loop.create_future() + future.set_result(f"plugin:{arg}:{session_id}:{gateway_session_key}") + return future with patch( "hermes_cli.plugins.get_plugin_command_handler", @@ -1862,6 +1866,36 @@ async def _handler(arg, *, session_id, gateway_session_key): } +def test_command_dispatch_awaits_future_plugin_handler(server): + sid = "test-session" + server._sessions[sid] = { + "session_key": "gateway-key", + "agent": types.SimpleNamespace(session_id="agent-session"), + } + + def _handler(arg, *, session_id, gateway_session_key): + loop = asyncio.new_event_loop() + future = loop.create_future() + future.set_result(f"future:{arg}:{session_id}:{gateway_session_key}") + return future + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: _handler if name == "future-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin-future", + "method": "command.dispatch", + "params": {"name": "future-cmd", "arg": "hello", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "plugin", + "output": "future:hello:agent-session:gateway-key", + } + + # ── dispatch(): pool routing for long handlers (#12546) ────────────── From bf4877a1cfe79c0d1bea22e3cb7b3f68c9341227 Mon Sep 17 00:00:00 2001 From: mol <1477787+Gerkinfeltser@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:15:32 +0000 Subject: [PATCH 3/4] fix(plugins): resolve pending command Futures --- hermes_cli/plugins.py | 37 ++++++++++++++ tests/hermes_cli/test_plugins.py | 53 ++++++++++++++++++++ tests/tui_gateway/test_protocol.py | 78 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 19642ca80c595..122438b1dd41d 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2441,6 +2441,41 @@ async def _await_plugin_command_result(result: Any) -> Any: return await result +def _resolve_plugin_command_future(result: asyncio.Future) -> Any: + """Synchronously resolve a Future on the event loop that owns it. + + A Future pending on the caller's active loop cannot be synchronously + awaited without deadlocking that loop, so that case fails explicitly. + """ + if result.done(): + return result.result() + + owner_loop = result.get_loop() + try: + caller_loop = asyncio.get_running_loop() + except RuntimeError: + caller_loop = None + + if owner_loop is caller_loop: + raise RuntimeError( + "Cannot synchronously resolve a pending plugin command Future on " + "the caller's active event loop" + ) + if owner_loop.is_running(): + scheduled = asyncio.run_coroutine_threadsafe( + _await_plugin_command_result(result), owner_loop + ) + try: + return scheduled.result(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS) + except TimeoutError: + scheduled.cancel() + raise TimeoutError( + "Plugin command async handler did not complete within " + f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s" + ) from None + return owner_loop.run_until_complete(result) + + def resolve_plugin_command_result(result: Any) -> Any: """Resolve a plugin command return value, awaiting async handlers when needed. @@ -2453,6 +2488,8 @@ def resolve_plugin_command_result(result: Any) -> Any: """ if not inspect.isawaitable(result): return result + if isinstance(result, asyncio.Future): + return _resolve_plugin_command_future(result) try: asyncio.get_running_loop() diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 6ef1d01a1e4e0..896ee286924fe 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -2236,6 +2236,59 @@ class _Loop: monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop()) assert resolve_plugin_command_result(future) == "threaded-future-ok" + def test_awaits_pending_future_on_its_own_running_loop(self): + import asyncio + import threading + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + async def _make_future(): + future = loop.create_future() + loop.call_later(0.01, future.set_result, "pending-future-ok") + return future + + future = asyncio.run_coroutine_threadsafe(_make_future(), loop).result(timeout=1) + assert resolve_plugin_command_result(future) == "pending-future-ok" + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=1) + loop.close() + + def test_awaits_pending_task_on_its_own_running_loop(self): + import asyncio + import threading + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + async def _answer(): + await asyncio.sleep(0.01) + return "pending-task-ok" + + async def _make_task(): + return asyncio.create_task(_answer()) + + task = asyncio.run_coroutine_threadsafe(_make_task(), loop).result(timeout=1) + assert resolve_plugin_command_result(task) == "pending-task-ok" + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=1) + loop.close() + + def test_rejects_pending_future_on_callers_active_loop(self): + import asyncio + + async def _resolve_from_own_loop(): + future = asyncio.get_running_loop().create_future() + with pytest.raises(RuntimeError, match="caller's active event loop"): + resolve_plugin_command_result(future) + future.cancel() + + asyncio.run(_resolve_from_own_loop()) + def test_running_loop_timeout_does_not_hang_forever(self, monkeypatch): """Threaded path must abort a hung async handler instead of blocking the caller.""" import asyncio as _asyncio diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 4601e0a072c1d..89494665cb774 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1437,6 +1437,45 @@ def handler(arg, *, session_id, gateway_session_key): assert worker.calls == [] +def test_slash_exec_awaits_pending_future_plugin_handler(server): + sid = "test-session" + server._sessions[sid] = { + "session_key": "gateway-key", + "agent": types.SimpleNamespace(session_id="agent-session"), + } + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + async def _make_future(): + future = loop.create_future() + loop.call_later(0.01, future.set_result, "pending-future:hello") + return future + + future = asyncio.run_coroutine_threadsafe(_make_future(), loop).result(timeout=1) + + def handler(arg, *, session_id, gateway_session_key): + assert (arg, session_id, gateway_session_key) == ("hello", "agent-session", "gateway-key") + return future + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: handler if name == "pending-future-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin-slash-pending-future", + "method": "slash.exec", + "params": {"command": "pending-future-cmd hello", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == {"output": "pending-future:hello"} + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=1) + loop.close() + + def test_slash_exec_plugin_lookup_failure_falls_back_to_worker(server): """Plugin discovery failures must not break ordinary slash-worker commands.""" sid = "test-session" @@ -1896,6 +1935,45 @@ def _handler(arg, *, session_id, gateway_session_key): } +def test_command_dispatch_awaits_pending_future_plugin_handler(server): + sid = "test-session" + server._sessions[sid] = { + "session_key": "gateway-key", + "agent": types.SimpleNamespace(session_id="agent-session"), + } + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + async def _make_future(): + future = loop.create_future() + loop.call_later(0.01, future.set_result, "pending-future:hello") + return future + + future = asyncio.run_coroutine_threadsafe(_make_future(), loop).result(timeout=1) + + def _handler(arg, *, session_id, gateway_session_key): + assert (arg, session_id, gateway_session_key) == ("hello", "agent-session", "gateway-key") + return future + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: _handler if name == "pending-future-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin-pending-future", + "method": "command.dispatch", + "params": {"name": "pending-future-cmd", "arg": "hello", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == {"type": "plugin", "output": "pending-future:hello"} + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=1) + loop.close() + + # ── dispatch(): pool routing for long handlers (#12546) ────────────── From fcbb1653925c24c0992d4128781877bd3d54fc60 Mon Sep 17 00:00:00 2001 From: mol <1477787+Gerkinfeltser@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:16:31 +0000 Subject: [PATCH 4/4] docs(plugins): document session context --- website/docs/developer-guide/plugins/index.md | 28 +++++++++++++++++-- website/docs/user-guide/features/hooks.md | 5 +++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 99fe800b41003..8f1df7517b35e 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -286,7 +286,8 @@ def handle_scan(ctx, raw_args: str): return result # returned to the caller's chat UI def register(ctx): - # Handlers receive a single raw_args string; close over ctx via a lambda. + # Handlers receive positional raw_args; close over ctx via a lambda. + # They may also opt into keyword-only session context. ctx.register_command( "scan", lambda raw: handle_scan(ctx, raw), @@ -769,7 +770,12 @@ After registration, users can run `hermes my-plugin status`, `hermes my-plugin c Plugins can register in-session slash commands — commands users type during a conversation (like `/lcm status` or `/ping`). These work in both CLI and gateway (Telegram, Discord, etc.). ```python -def _handle_status(raw_args: str) -> str: +def _handle_status( + raw_args: str, + *, + session_id: str = "", + gateway_session_key: str = "", +) -> str: """Handler for /mystatus — called with everything after the command name.""" if raw_args.strip() == "help": return "Usage: /mystatus [help|check]" @@ -790,7 +796,7 @@ After registration, users can type `/mystatus` in any session. The command appea | Parameter | Type | Description | |-----------|------|-------------| | `name` | `str` | Command name without the leading slash (e.g. `"lcm"`, `"mystatus"`) | -| `handler` | `Callable[[str], str \| None]` | Called with the raw argument string. May also be `async`. | +| `handler` | `Callable[[str], str \| None]` | Called with `raw_args` as its first positional argument. Handlers may optionally declare keyword-only `session_id` and `gateway_session_key`; may also be `async`. | | `description` | `str` | Shown in `/help`, autocomplete, and Telegram bot menu | **Key differences from `register_cli_command()`:** @@ -802,6 +808,22 @@ After registration, users can type `/mystatus` in any session. The command appea | Handler receives | Raw args string | argparse `Namespace` | | Use case | Diagnostics, status, quick actions | Complex subcommand trees, setup wizards | +**Optional session context:** `raw_args` remains the first positional argument, so existing `handler(raw_args)` implementations remain compatible. A handler may opt in to keyword-only session context: + +```python +def _handle_status( + raw_args: str, + *, + session_id: str = "", + gateway_session_key: str = "", +) -> str: + # session_id is the current live agent session. + # gateway_session_key is a stable gateway conversation key, or "" outside gateway sessions. + return f"session={session_id} gateway={gateway_session_key} args={raw_args}" +``` + +`session_id` identifies the current live session and can change after a reset or rotation. `gateway_session_key` identifies the stable gateway-backed conversation or routing scope and does not replace `session_id`. + **Conflict protection:** If a plugin tries to register a name that conflicts with a built-in command (`help`, `model`, `new`, etc.), the registration is silently rejected with a log warning. Built-in commands always take precedence. **Async handlers:** The gateway dispatch automatically detects and awaits async handlers, so you can use either sync or async functions: diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index f38ed9343b96d..cfa48c7add1dd 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -372,7 +372,7 @@ def register(ctx): - Callbacks receive **keyword arguments**. Always accept `**kwargs` for forward compatibility — new parameters may be added in future versions without breaking your plugin. - If a callback **crashes**, it's logged and skipped. Other hooks and the agent continue normally. A misbehaving plugin can never break the agent. - Two hooks' return values affect behavior: [`pre_tool_call`](#pre_tool_call) can **block** the tool, and [`pre_llm_call`](#pre_llm_call) can **inject context** into the LLM call. All other hooks are fire-and-forget observers. -- Observer callbacks receive `telemetry_schema_version` automatically. When present, `turn_id`, `api_request_id`, `task_id`, `session_id`, and `api_call_count` are separate correlation fields. Treat `api_request_id` as an opaque identifier; do not parse its string format. +- Observer callbacks receive `telemetry_schema_version` automatically. When present, `turn_id`, `api_request_id`, `task_id`, `session_id`, `gateway_session_key`, and `api_call_count` are separate correlation fields. `gateway_session_key` is the stable gateway conversation key and may be empty outside gateway-backed sessions; it does not replace the live `session_id`. Treat `api_request_id` as an opaque identifier; do not parse its string format. ### Quick reference @@ -413,6 +413,8 @@ def my_callback(tool_name: str, args: dict, task_id: str, **kwargs): | `tool_name` | `str` | Name of the tool about to execute (e.g. `"terminal"`, `"web_search"`, `"read_file"`) | | `args` | `dict` | The arguments the model passed to the tool | | `task_id` | `str` | Session/task identifier. Empty string if not set. | +| `session_id` | `str` | Current live agent session identifier. May change after reset or rotation. | +| `gateway_session_key` | `str` | Stable gateway conversation or routing key. Empty outside gateway-backed sessions; does not replace `session_id`. | **Fires:** In `model_tools.py`, inside `handle_function_call()`, before the tool's handler runs. Fires once per tool call — if the model calls 3 tools in parallel, this fires 3 times. @@ -522,6 +524,7 @@ def my_callback(session_id: str, user_message: str, conversation_history: list, | Parameter | Type | Description | |-----------|------|-------------| | `session_id` | `str` | Unique identifier for the current session | +| `gateway_session_key` | `str` | Stable gateway conversation or routing key. Empty outside gateway-backed sessions; does not replace `session_id`. | | `user_message` | `str` | The user's original message for this turn (before any skill injection) | | `conversation_history` | `list` | Copy of the full message list (OpenAI format: `[{"role": "user", "content": "..."}]`) | | `is_first_turn` | `bool` | `True` if this is the first turn of a new session, `False` on subsequent turns |