194 lines
8.4 KiB
Python
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}"}])
|