Files
hermes-agent/agent/transports/codex_event_projector.py
T

194 lines
8.4 KiB
Python

"""Projects codex app-server events into Hermes' messages list.
Converts Codex ``item/*`` notifications into OpenAI-shaped
``{role, content, tool_calls, tool_call_id}`` entries that memory/skill review
(agent/curator.py) already reads:
userMessage → user; agentMessage → assistant; reasoning → stashed onto the next
assistant entry; commandExecution / fileChange / mcpToolCall / dynamicToolCall →
assistant tool_call + tool result; anything else → opaque assistant note.
Each item yields AT MOST one assistant + one tool entry, preserving Hermes'
message-alternation invariants. ``is_tool_iteration`` ticks once per completed
tool-shaped item (AIAgent._iters_since_skill skill-nudge gate).
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
def _deterministic_call_id(item_type: str, item_id: str) -> str:
"""Stable tool_call id: the codex item id when present, else a content hash so
replay yields the same id across sessions and prefix caches stay valid."""
return f"codex_{item_type}_{item_id or hashlib.sha256(f'{item_type}'.encode()).hexdigest()[:16]}"
def _format_tool_args(d: dict) -> str:
"""Format a dict as JSON the way Hermes' existing tool_calls path does."""
return json.dumps(d, ensure_ascii=False, sort_keys=True)
def _dict_args(raw: Any) -> dict:
args = raw or {}
return args if isinstance(args, dict) else {"arguments": args}
@dataclass
class ProjectionResult:
"""Output of projecting one Codex item.
``messages`` may hold two entries (assistant tool_call + tool result); empty
means the item was ignored (e.g. a streaming delta before ``item/completed``).
"""
messages: list[dict] = field(default_factory=list)
is_tool_iteration: bool = False
final_text: Optional[str] = None # Set when an agentMessage completes
class CodexEventProjector:
"""Stateful projector consuming Codex notifications in arrival order.
Owns in-progress reasoning: codex emits it as separate items, Hermes stashes
it on the next assistant message.
"""
def __init__(self) -> None:
self._pending_reasoning: list[str] = []
def project(self, notification: dict) -> ProjectionResult:
"""Project one notification; only ``item/completed`` materializes messages.
Streaming deltas are display-only, mirroring how Hermes writes the
assistant message only after the streaming completion event.
"""
method = notification.get("method", "")
params = notification.get("params", {}) or {}
if method != "item/completed":
return ProjectionResult()
item = params.get("item") or {}
item_type = item.get("type") or ""
item_id = item.get("id") or ""
if item_type == "agentMessage":
return self._project_agent_message(item)
if item_type == "reasoning":
self._pending_reasoning.extend(item.get("summary") or [])
self._pending_reasoning.extend(item.get("content") or [])
return ProjectionResult()
if item_type == "userMessage":
return self._project_user_message(item)
tool_projection = self._TOOL_PROJECTIONS.get(item_type)
if tool_projection is not None:
return self._project_tool_item(item, item_id, tool_projection)
# Unknown / rare items (plan, hookPrompt, collabAgentToolCall, ...): keep an
# opaque note so memory review sees *something* happened, without
# fabricating tool_call structure.
return self._project_opaque(item, item_type)
def _assistant_message(self, content: Optional[str], **extra: Any) -> dict[str, Any]:
msg: dict[str, Any] = {"role": "assistant", "content": content, **extra}
if self._pending_reasoning:
msg["reasoning"] = "\n".join(self._pending_reasoning)
self._pending_reasoning = []
return msg
def _project_agent_message(self, item: dict) -> ProjectionResult:
text = item.get("text") or ""
return ProjectionResult(messages=[self._assistant_message(text)], final_text=text)
def _project_user_message(self, item: dict) -> ProjectionResult:
# userMessage content is a list of UserInput variants; flatten text
# fragments and drop non-text parts (Hermes' messages store text only).
text_parts: list[str] = []
for fragment in item.get("content") or []:
if isinstance(fragment, dict):
if fragment.get("type") == "text":
text_parts.append(fragment.get("text") or "")
elif "text" in fragment:
text_parts.append(str(fragment["text"]))
return ProjectionResult(messages=[{"role": "user", "content": "\n".join(text_parts)}])
def _project_tool_item(
self,
item: dict,
item_id: str,
spec: Callable[[dict], tuple[str, str, dict, str]],
) -> ProjectionResult:
"""Emit the (assistant tool_call, tool result) pair for a tool-shaped item.
``spec(item)`` returns ``(call_id_type, tool_name, args, tool_content)``.
"""
id_type, name, args, content = spec(item)
call_id = _deterministic_call_id(id_type, item_id)
assistant_msg = self._assistant_message(
None,
tool_calls=[{"id": call_id, "type": "function", "function": {"name": name, "arguments": _format_tool_args(args)}}],
)
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
return ProjectionResult(messages=[assistant_msg, tool_msg], is_tool_iteration=True)
@staticmethod
def _command_spec(item: dict) -> tuple[str, str, dict, str]:
args = {"command": item.get("command") or "", "cwd": item.get("cwd") or ""}
output = item.get("aggregatedOutput") or ""
exit_code = item.get("exitCode")
if exit_code is not None and exit_code != 0:
output = f"[exit {exit_code}]\n{output}"
return "exec", "exec_command", args, output
@staticmethod
def _file_change_spec(item: dict) -> tuple[str, str, dict, str]:
# Per-file change kinds only — full file contents can be huge.
changes_summary = [
{
"kind": (change.get("kind") or {}).get("type") or "update",
"path": change.get("path") or "",
}
for change in item.get("changes") or []
]
status = item.get("status") or "unknown"
content = f"apply_patch status={status}, {len(changes_summary)} change(s)"
return "apply_patch", "apply_patch", {"changes": changes_summary}, content
@staticmethod
def _mcp_tool_call_spec(item: dict) -> tuple[str, str, dict, str]:
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
result = item.get("result")
error = item.get("error")
if error:
content = f"[error] {json.dumps(error, ensure_ascii=False)[:1000]}"
elif result is not None:
content = json.dumps(result, ensure_ascii=False)[:4000]
else:
content = ""
# Mirror the native MCP name convention (mcp__server__tool) in the call id
# so it stays consistent with registration names.
return f"mcp__{server}__{tool}", f"mcp.{server}.{tool}", _dict_args(item.get("arguments")), content
@staticmethod
def _dynamic_tool_call_spec(item: dict) -> tuple[str, str, dict, str]:
tool = item.get("tool") or "unknown"
content_items = item.get("contentItems") or []
if isinstance(content_items, list) and content_items:
content = json.dumps(content_items, ensure_ascii=False)[:4000]
else:
content = f"success={item.get('success')}"
return f"dyn_{tool}", tool, _dict_args(item.get("arguments")), content
_TOOL_PROJECTIONS: dict[str, Callable[[dict], tuple[str, str, dict, str]]] = {
"commandExecution": _command_spec,
"fileChange": _file_change_spec,
"mcpToolCall": _mcp_tool_call_spec,
"dynamicToolCall": _dynamic_tool_call_spec,
}
def _project_opaque(self, item: dict, item_type: str) -> ProjectionResult:
try:
payload = json.dumps(item, ensure_ascii=False)[:1500]
except (TypeError, ValueError):
payload = repr(item)[:1500]
return ProjectionResult(messages=[{"role": "assistant", "content": f"[codex {item_type}] {payload}"}])