Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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))
Expand Down
25 changes: 22 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
133 changes: 130 additions & 3 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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)

Expand All @@ -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``).

Expand All @@ -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

Expand All @@ -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).

Expand All @@ -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
Expand Down Expand Up @@ -2348,9 +2383,99 @@ 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


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_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.

Expand All @@ -2363,19 +2488,21 @@ 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()
except RuntimeError:
return asyncio.run(result)
return asyncio.run(_await_plugin_command_result(result))

outcome: Dict[str, Any] = {}
failure: Dict[str, BaseException] = {}
done = threading.Event()

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:
Expand Down
3 changes: 3 additions & 0 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions tests/agent/test_turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading