mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix(codex): bridge app-server item/started events to Telegram tool-progress (#38835)
When the main provider is the Codex app-server runtime (api_mode
codex_app_server), the gateway showed no verbose 'running X' tool-progress
breadcrumbs on Telegram while every other provider did. The app-server
session processes item/started notifications (command execution, file
changes, MCP/dynamic tool calls) but never surfaced them as Hermes
tool-progress events — the session was constructed without an on_event
hook, so the agent's tool_progress_callback was never invoked on this
route.
Add _codex_note_to_tool_progress() mapping item/started → (tool_name,
preview, args) for commandExecution / fileChange / mcpToolCall /
dynamicToolCall, and wire an on_event hook into CodexAppServerSession that
forwards mapped events to agent.tool_progress_callback('tool.started',
...) — the same signature the chat_completions path uses (tool_executor.py).
Non-tool items (agentMessage/reasoning) and non-item/started methods map
to None and are ignored.
Co-authored-by: jplew <462836+jplew@users.noreply.github.com>
This commit is contained in:
parent
8a506ed3ac
commit
2f4f23fbfb
2 changed files with 152 additions and 0 deletions
|
|
@ -25,6 +25,61 @@ from typing import Any, Dict, List
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None:
|
||||
"""Map a Codex app-server ``item/started`` notification to a Hermes
|
||||
tool-progress event ``(tool_name, preview, args)``.
|
||||
|
||||
The Codex app-server runtime processes ``item/started`` notifications for
|
||||
command execution, file changes, and MCP/dynamic tool calls, but never
|
||||
surfaced them as Hermes tool-progress events — so gateways (Telegram, etc.)
|
||||
showed no verbose "running X" breadcrumbs on this route while every other
|
||||
provider did (#38835). Returns None for items that aren't tool-shaped.
|
||||
"""
|
||||
if not isinstance(note, dict) or note.get("method") != "item/started":
|
||||
return None
|
||||
params = note.get("params") or {}
|
||||
item = params.get("item") or {}
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
command = item.get("command") or ""
|
||||
return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""}
|
||||
|
||||
if item_type == "fileChange":
|
||||
changes = item.get("changes") or []
|
||||
preview = "file changes"
|
||||
if isinstance(changes, list) and changes:
|
||||
paths = [
|
||||
str(change.get("path"))
|
||||
for change in changes
|
||||
if isinstance(change, dict) and change.get("path")
|
||||
]
|
||||
if paths:
|
||||
preview = ", ".join(paths[:3])
|
||||
if len(paths) > 3:
|
||||
preview += f", +{len(paths) - 3} more"
|
||||
return "apply_patch", preview, {"changes": changes}
|
||||
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return f"mcp.{server}.{tool}", tool, args
|
||||
|
||||
if item_type == "dynamicToolCall":
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return tool, tool, args
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_usage_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
|
|
@ -204,9 +259,27 @@ def run_codex_app_server_turn(
|
|||
approval_callback = _get_approval_callback()
|
||||
except Exception:
|
||||
approval_callback = None
|
||||
|
||||
def _on_codex_event(note: dict) -> None:
|
||||
# Bridge Codex app-server item/started notifications to Hermes
|
||||
# tool-progress so gateways show verbose "running X" breadcrumbs
|
||||
# on this route too (#38835).
|
||||
progress_callback = getattr(agent, "tool_progress_callback", None)
|
||||
if progress_callback is None:
|
||||
return
|
||||
mapped = _codex_note_to_tool_progress(note)
|
||||
if mapped is None:
|
||||
return
|
||||
tool_name, preview, args = mapped
|
||||
try:
|
||||
progress_callback("tool.started", tool_name, preview, args)
|
||||
except Exception:
|
||||
logger.debug("codex tool-progress callback raised", exc_info=True)
|
||||
|
||||
agent._codex_session = CodexAppServerSession(
|
||||
cwd=cwd,
|
||||
approval_callback=approval_callback,
|
||||
on_event=_on_codex_event,
|
||||
)
|
||||
|
||||
# NOTE: the user message is ALREADY appended to messages by the
|
||||
|
|
|
|||
|
|
@ -477,3 +477,82 @@ class TestSessionRetirementOnRunAgent:
|
|||
assert agent._codex_session is None
|
||||
assert result["completed"] is False
|
||||
assert "codex segfaulted" in result["error"]
|
||||
|
||||
|
||||
class TestCodexToolProgressBridge:
|
||||
"""#38835: Codex app-server item/started notifications must surface as
|
||||
Hermes tool-progress so gateways show verbose breadcrumbs on this route."""
|
||||
|
||||
def test_mapper_command_execution(self):
|
||||
from agent.codex_runtime import _codex_note_to_tool_progress
|
||||
note = {"method": "item/started", "params": {"item": {
|
||||
"type": "commandExecution", "command": "ls -la", "cwd": "/tmp"}}}
|
||||
name, preview, args = _codex_note_to_tool_progress(note)
|
||||
assert name == "exec_command"
|
||||
assert preview == "ls -la"
|
||||
assert args == {"command": "ls -la", "cwd": "/tmp"}
|
||||
|
||||
def test_mapper_file_change(self):
|
||||
from agent.codex_runtime import _codex_note_to_tool_progress
|
||||
note = {"method": "item/started", "params": {"item": {
|
||||
"type": "fileChange",
|
||||
"changes": [{"path": "a.py"}, {"path": "b.py"}]}}}
|
||||
name, preview, args = _codex_note_to_tool_progress(note)
|
||||
assert name == "apply_patch"
|
||||
assert preview == "a.py, b.py"
|
||||
|
||||
def test_mapper_mcp_and_dynamic_tool_calls(self):
|
||||
from agent.codex_runtime import _codex_note_to_tool_progress
|
||||
mcp = {"method": "item/started", "params": {"item": {
|
||||
"type": "mcpToolCall", "server": "fs", "tool": "read", "arguments": {"p": 1}}}}
|
||||
name, preview, args = _codex_note_to_tool_progress(mcp)
|
||||
assert name == "mcp.fs.read"
|
||||
assert preview == "read"
|
||||
assert args == {"p": 1}
|
||||
|
||||
dyn = {"method": "item/started", "params": {"item": {
|
||||
"type": "dynamicToolCall", "tool": "web_search", "arguments": {"q": "x"}}}}
|
||||
assert _codex_note_to_tool_progress(dyn)[0] == "web_search"
|
||||
|
||||
def test_mapper_ignores_non_tool_items_and_other_methods(self):
|
||||
from agent.codex_runtime import _codex_note_to_tool_progress
|
||||
# agentMessage / reasoning items are not tool-shaped
|
||||
assert _codex_note_to_tool_progress({"method": "item/started", "params": {
|
||||
"item": {"type": "agentMessage", "text": "hi"}}}) is None
|
||||
# non-item/started methods
|
||||
assert _codex_note_to_tool_progress({"method": "item/completed", "params": {}}) is None
|
||||
assert _codex_note_to_tool_progress({}) is None
|
||||
|
||||
def test_session_wired_with_on_event_that_fires_tool_progress(self, monkeypatch):
|
||||
"""The session is constructed with an on_event hook that, when fed an
|
||||
item/started note, calls the agent's tool_progress_callback."""
|
||||
captured_init = {}
|
||||
events = []
|
||||
|
||||
def fake_init(self, **kwargs):
|
||||
captured_init.update(kwargs)
|
||||
# minimal attrs so the rest of run_turn stubs work
|
||||
self._client = None
|
||||
|
||||
def fake_run_turn(self, user_input, **kwargs):
|
||||
# Exercise the wired on_event hook with a real item/started note.
|
||||
on_event = captured_init.get("on_event")
|
||||
if on_event:
|
||||
on_event({"method": "item/started", "params": {"item": {
|
||||
"type": "commandExecution", "command": "pytest", "cwd": "/repo"}}})
|
||||
return TurnResult(final_text="done", projected_messages=[
|
||||
{"role": "assistant", "content": "done"}], turn_id="t1", thread_id="th1")
|
||||
|
||||
monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init)
|
||||
monkeypatch.setattr(CodexAppServerSession, "ensure_started", lambda self: "th1")
|
||||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||||
|
||||
agent = _make_codex_agent()
|
||||
agent.tool_progress_callback = lambda kind, name, preview, args: events.append(
|
||||
(kind, name, preview))
|
||||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||||
agent.run_conversation("run the tests")
|
||||
|
||||
assert "on_event" in captured_init and captured_init["on_event"] is not None
|
||||
assert ("tool.started", "exec_command", "pytest") in events
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue