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

836 lines
37 KiB
Python

"""Session adapter for codex app-server runtime.
Owns one Codex thread per Hermes session: drives ``turn/start``, consumes
streaming notifications via CodexEventProjector, bridges server-initiated
approval requests, translates cancellation, and returns a TurnResult that
AIAgent.run_conversation() splices into ``messages``. Synchronous from the
caller's view: the client's reader threads feed blocking-with-timeout queues
that this adapter polls, matching AIAgent's chat_completions loop.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from agent.codex_responses_adapter import _format_responses_error
from agent.redact import redact_sensitive_text
from agent.transports.codex_app_server import (
CodexAppServerClient,
CodexAppServerError,
)
from agent.transports.codex_event_projector import CodexEventProjector, ProjectionResult
logger = logging.getLogger(__name__)
# Tail of codex stderr attached to generic user-facing errors: small enough to
# stay legible, large enough to surface a config/provider/auth diagnostic.
_STDERR_TAIL_LINES = 12
# Hermes' tools.terminal.security_mode -> Codex permissions profile id.
# Missing config -> workspace-write (Codex's own default).
_HERMES_TO_CODEX_PERMISSION_PROFILE = {
"auto": "workspace-write", "approval-required": "read-only-with-approval",
"unrestricted": "full-access", "yolo": "full-access", # yolo: backstop alias used by some skills/tests
}
@dataclass
class TurnResult:
"""Result of one user→assistant→tool turn through the codex app-server."""
final_text: str = ""
projected_messages: list[dict] = field(default_factory=list)
tool_iterations: int = 0
interrupted: bool = False
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
compacted: bool = False
# Codex subprocess is likely wedged (turn timeout, post-tool watchdog, token
# refresh failure): caller should retire the session so the next turn respawns.
should_retire: bool = False
# Some codex versions stream ``<turn_aborted>`` as raw agentMessage text when an
# interrupt/upstream error tears the turn down without emitting turn/completed.
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
def _notification_scope_ids(note: dict) -> tuple[Optional[str], Optional[str]]:
"""Extract the thread/turn identity carried by a notification (top-level, then turn/item)."""
params = (note.get("params") or {}) if isinstance(note, dict) else None
if not isinstance(params, dict):
return None, None
turn, item = params.get("turn") or {}, params.get("item") or {}
def first(*lookups: tuple[Any, str, str]) -> Any:
"""``src.get(a) or src.get(b)`` over successive dict sources until one is not None."""
for src, primary, fallback in lookups:
if isinstance(src, dict):
observed = src.get(primary) or src.get(fallback)
if observed is not None:
return observed
return None
return (
first((params, "threadId", "thread_id"), (turn, "threadId", "thread_id"), (item, "threadId", "thread_id")),
first((params, "turnId", "turn_id"), (turn, "id", "turnId"), (item, "turnId", "turn_id")),
)
def _notification_belongs_to_turn(note: dict, *, thread_id: Optional[str], turn_id: Optional[str]) -> bool:
"""Whether a multiplexed notification belongs to this turn.
One JSON-RPC connection can carry parent and hosted subagent threads; an
explicitly foreign thread/turn event must not mutate this transcript.
Unscoped notifications remain accepted for protocol compatibility.
"""
if not isinstance(note, dict):
return False
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if thread_id is not None and observed_thread_id is not None and str(observed_thread_id) != str(thread_id):
return False
return not (turn_id is not None and observed_turn_id is not None and str(observed_turn_id) != str(turn_id))
def _coerce_turn_input_text(user_input: Any) -> str:
"""Collapse Hermes/OpenAI rich content parts into app-server text input.
``turn/start`` sends text items only; TUI image attachments arrive as content
parts, so keep text and replace image payloads with a marker.
"""
if isinstance(user_input, str):
return user_input
if not isinstance(user_input, list):
return "" if user_input is None else str(user_input)
parts: list[str] = []
for item in user_input:
if not isinstance(item, dict):
keep = item.strip() if isinstance(item, str) else item is not None
if keep:
parts.append(str(item))
continue
item_type = item.get("type")
if item_type in {"text", "input_text"}:
text = item.get("text") or item.get("content") or ""
if text:
parts.append(str(text))
elif item_type in {"image", "image_url", "input_image"}:
parts.append("[image attached]")
text = "\n\n".join(p for p in parts if p).strip()
return text or "What do you see in this image?"
# Substrings in codex stderr / JSON-RPC errors signalling expired OAuth creds.
# Conservative on purpose: only redirect to `codex login` on a strong signal,
# otherwise the original error surfaces verbatim.
_OAUTH_REFRESH_FAILURE_HINTS = (
"invalid_grant", "invalid grant",
"refresh token", "refresh_token", "token refresh", "token_refresh",
"token has expired", "expired_token", "expired token",
"not authenticated", "unauthenticated", "unauthorized", "401 unauthorized",
"re-authenticate", "reauthenticate", "please log in", "please login",
"auth profile", "no auth profile", "oauth",
)
_OAUTH_REAUTH_HINT = (
"Codex authentication failed — your ChatGPT/Codex login looks expired or invalid. Run `codex login` to refresh, "
"then retry. (Fall back to default runtime with `/codex-runtime auto` if the issue persists.)"
)
def _classify_oauth_failure(*parts: str) -> Optional[str]:
"""Re-auth hint if any part looks like a codex OAuth/token-refresh failure, else None."""
haystack = " ".join(p for p in parts if p).lower()
if haystack and any(needle in haystack for needle in _OAUTH_REFRESH_FAILURE_HINTS):
return _OAUTH_REAUTH_HINT
return None
@dataclass
class _ServerRequestRouting:
"""Default approval policies when no interactive callback is wired in.
Used by tests + cron / non-interactive contexts; the live CLI passes an
approval_callback that defers to tools.approval.prompt_dangerous_approval().
"""
auto_approve_exec: bool = False
auto_approve_apply_patch: bool = False
class CodexAppServerSession:
"""One Codex thread per Hermes session, lifetime owned by AIAgent.
Not thread-safe: one caller drives it at a time (projector, thread_id and
turn state are owned by the caller thread), like AIAgent.run_conversation().
"""
def __init__(
self,
*,
cwd: Optional[str] = None,
codex_bin: str = "codex",
codex_home: Optional[str] = None,
permission_profile: Optional[str] = None,
approval_callback: Optional[Callable[..., str]] = None,
on_event: Optional[Callable[[dict], None]] = None,
request_routing: Optional[_ServerRequestRouting] = None,
client_factory: Optional[Callable[..., CodexAppServerClient]] = None,
) -> None:
self._cwd = cwd or os.getcwd()
self._codex_bin = codex_bin
self._codex_home = codex_home
self._permission_profile = permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get(
os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"), "workspace-write"
)
self._approval_callback = approval_callback
self._on_event = on_event # Display hook (kawaii spinner ticks etc.)
self._routing = request_routing or _ServerRequestRouting()
self._client_factory = client_factory or CodexAppServerClient
self._client: Optional[CodexAppServerClient] = None
self._thread_id: Optional[str] = None
self._interrupt_event = threading.Event()
self._active_turn_id: Optional[str] = None
self._active_turn_lock = threading.Lock()
# In-progress fileChange items by id (item/started -> item/completed):
# approval params don't carry the changeset, so this feeds the prompt summary.
self._pending_file_changes: dict[str, str] = {}
self._closed = False
def ensure_started(self) -> str:
"""Spawn, handshake, and ``thread/start``; idempotent, returns the codex thread id."""
if self._thread_id is not None:
return self._thread_id
if self._client is None:
self._client = self._client_factory(codex_bin=self._codex_bin, codex_home=self._codex_home)
self._client.initialize(client_name="hermes", client_title="Hermes Agent", client_version=_get_hermes_version())
# Permissions are intentionally NOT sent on thread/start: on codex 0.130
# ``thread/start.permissions`` is gated behind experimentalApi and also
# requires a matching ``[permissions]`` table in ~/.codex/config.toml.
# Users configure a write-capable profile there, as for any codex use.
result = self._client.request("thread/start", {"cwd": self._cwd}, timeout=15)
# Different codex versions serialize the id under thread.id / sessionId / threadId.
thread_obj = result.get("thread") or {}
thread_id = (
thread_obj.get("id") or thread_obj.get("sessionId") or result.get("sessionId") or result.get("threadId")
)
if not thread_id:
raise CodexAppServerError(
code=-32603,
message=f"codex thread/start returned no thread id (payload keys: {sorted(result.keys())})",
)
self._thread_id = thread_id
logger.info(
"codex app-server thread started: id=%s profile=%s cwd=%s", thread_id[:8], self._permission_profile, self._cwd
)
return thread_id
def close(self) -> None:
if self._closed:
return
self._closed = True
with self._active_turn_lock:
self._active_turn_id = None
if self._client is not None:
try:
self._client.close()
except Exception: # pragma: no cover - best-effort cleanup
pass
self._client = None
self._thread_id = None
def __enter__(self) -> "CodexAppServerSession":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def request_interrupt(self) -> None:
"""Idempotent: signal the active turn loop to issue turn/interrupt and unwind."""
self._interrupt_event.set()
def request_steer(self, text: str) -> bool:
"""Append user guidance to the active Codex turn via ``turn/steer``."""
cleaned = str(text or "").strip()
if not cleaned:
return False
with self._active_turn_lock:
turn_id = self._active_turn_id
thread_id = self._thread_id
client = self._client
if not turn_id or not thread_id or client is None:
return False
try:
response = client.request(
"turn/steer",
{"threadId": thread_id, "input": [{"type": "text", "text": cleaned}], "expectedTurnId": turn_id},
timeout=10,
)
except (CodexAppServerError, TimeoutError):
logger.debug("turn/steer rejected for active Codex turn", exc_info=True)
return False
accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None
return accepted_turn_id in {None, turn_id}
def _format_error_with_stderr(self, prefix: str, exc: Any = "", *, tail_lines: int = _STDERR_TAIL_LINES) -> str:
"""User-facing error string for generic codex failures.
Appends the redacted (force=True) stderr tail so provider/auth secrets
never leak into chat output, while codex's opaque 'Internal error' text
becomes diagnosable. Specific classifications (OAuth, wedge watchdog)
produce their own clean hint instead.
"""
exc_str = str(exc) if exc != "" and exc is not None else ""
base = f"{prefix}: {exc_str}" if exc_str else prefix
try:
tail = self._client.stderr_tail(tail_lines) if self._client is not None else []
except Exception: # pragma: no cover - diagnostic best-effort
return base
joined = "\n".join(line.rstrip() for line in tail if line)
if not joined.strip():
return base
redacted = redact_sensitive_text(joined, force=True)
return f"{base}\ncodex stderr (last {len(tail)} lines):\n{redacted}"
def _stderr_blob(self, n: int) -> str:
return "\n".join(self._client.stderr_tail(n))
@staticmethod
def _retire(result: TurnResult, error: str) -> None:
"""Record a terminal error and flag the session for respawn on the next turn."""
result.error = error
result.should_retire = True
def _set_classified_error(self, result: TurnResult, prefix: str, classify_text: str, detail: Any) -> None:
"""OAuth failures become the re-auth hint AND retire the session (token store
is broken even though JSON-RPC is fine); everything else gets the stderr tail."""
hint = _classify_oauth_failure(classify_text, self._stderr_blob(40))
if hint is not None:
self._retire(result, hint)
else:
result.error = self._format_error_with_stderr(prefix, detail)
def _start_for(self, result: TurnResult) -> bool:
"""ensure_started() into ``result``; startup failures surface as TurnResult.error
(retiring the session) instead of raw codex exceptions reaching AIAgent."""
try:
self.ensure_started()
except (CodexAppServerError, TimeoutError) as exc:
self._retire(result, self._format_error_with_stderr("codex app-server startup failed", exc))
return False
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
return True
def _request_for(self, result: TurnResult, method: str, params: dict, label: str) -> Optional[dict]:
"""Issue ``method``; on failure fill ``result.error`` and return None.
A timeout is a strong wedge signal and always retires the session."""
try:
return self._client.request(method, params, timeout=10)
except CodexAppServerError as exc:
self._set_classified_error(result, f"{label} failed", exc.message, exc)
except TimeoutError as exc:
hint = _classify_oauth_failure(self._stderr_blob(40))
self._retire(result, hint or self._format_error_with_stderr(f"{label} timed out", exc))
return None
def _subprocess_died(self, result: TurnResult) -> bool:
"""Bail out early (rather than waiting on the deadline) when codex exited."""
if self._client.is_alive():
return False
hint = _classify_oauth_failure(self._stderr_blob(60))
self._retire(
result,
hint or self._format_error_with_stderr("codex app-server subprocess exited unexpectedly", tail_lines=20),
)
return True
def _absorb_notification(
self, result: TurnResult, projector: CodexEventProjector, note: dict
) -> tuple[ProjectionResult, bool]:
"""Fan one in-scope notification out to display, accounting, file-change
tracking and the projector. Returns (projection, aborted) where aborted
means the agent text carried a ``<turn_aborted>`` marker (terminal)."""
if self._on_event is not None:
try:
self._on_event(note)
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
_apply_compaction_notification(result, note)
self._track_pending_file_change(note)
projection = projector.project(note)
if projection.messages:
result.projected_messages.extend(projection.messages)
if projection.is_tool_iteration:
result.tool_iterations += 1
aborted = False
if projection.final_text is not None:
# Multiple agentMessage items per turn: the last one is canonical.
result.final_text = projection.final_text
if _has_turn_aborted_marker(projection.final_text):
aborted = True
result.interrupted = True
result.error = result.error or "codex reported turn_aborted"
return projection, aborted
def run_turn(
self,
user_input: Any,
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
post_tool_quiet_timeout: float = 90.0,
) -> TurnResult:
"""Send a user message and block until turn/completed, bridging approvals
and projecting items into Hermes' messages shape.
post_tool_quiet_timeout: if codex completes a tool and then stays silent
this long, fast-fail and retire instead of burning the full turn deadline.
"""
result = TurnResult()
if not self._start_for(result):
self._interrupt_event.clear()
return result
# Do not clear first: a hard stop arriving during ensure_started() must
# be honored before launching a Codex turn.
if self._interrupt_event.is_set():
result.interrupted = True
self._interrupt_event.clear()
return result
projector = CodexEventProjector()
ts = self._request_for(
result,
"turn/start",
{"threadId": self._thread_id, "input": [{"type": "text", "text": _coerce_turn_input_text(user_input)}]},
"turn/start",
)
if ts is None:
self._interrupt_event.clear()
return result
result.turn_id = (ts.get("turn") or {}).get("id")
with self._active_turn_lock:
self._active_turn_id = result.turn_id
# Post-tool watchdog: armed on each tool completion, cleared by any other activity.
last_tool_completion_at: Optional[float] = None
def watchdog_tripped() -> bool:
if (
last_tool_completion_at is not None
and (time.monotonic() - last_tool_completion_at) > post_tool_quiet_timeout
):
self._issue_interrupt(result.turn_id)
result.interrupted = True
self._retire(
result,
f"codex went silent for {post_tool_quiet_timeout:.0f}s after a tool result; "
f"retiring app-server session.",
)
return True
return False
def on_server_request(sreq: dict) -> bool:
nonlocal last_tool_completion_at
# Drain pending notifications first (bounded) so per-turn state such
# as _pending_file_changes is current for the approval decision, and
# so display events around approvals still reach on_event.
turn_complete = False
for _ in range(8):
pending = self._client.take_notification(timeout=0)
if pending is None:
break
if not _notification_belongs_to_turn(
pending, thread_id=self._thread_id, turn_id=result.turn_id
):
logger.debug(
"ignoring foreign codex notification while draining "
"server request: method=%s",
pending.get("method"),
)
continue
proj, aborted = self._absorb_notification(result, projector, pending)
if proj.is_tool_iteration:
last_tool_completion_at = time.monotonic()
if aborted:
turn_complete = True
self._handle_server_request(sreq)
# An approval round-trip is live signal — don't let it trip the watchdog.
last_tool_completion_at = None
return turn_complete
def on_note(note: dict, method: str) -> bool:
nonlocal last_tool_completion_at
projection, aborted = self._absorb_notification(result, projector, note)
if projection.is_tool_iteration:
last_tool_completion_at = time.monotonic()
elif projection.messages or projection.final_text is not None:
last_tool_completion_at = None
if method != "turn/completed":
return aborted
turn_obj = (note.get("params") or {}).get("turn") or {}
turn_status = turn_obj.get("status")
if turn_status and turn_status not in {"completed", "interrupted"}:
err_obj = turn_obj.get("error")
if err_obj:
err_msg = _format_responses_error(err_obj, str(turn_status))
self._set_classified_error(
result, f"turn ended status={turn_status}", err_msg, err_msg
)
return True
self._drive_turn(
result,
turn_timeout=turn_timeout,
notification_poll_timeout=notification_poll_timeout,
timeout_label="turn",
before_poll=watchdog_tripped,
on_server_request=on_server_request,
on_note=on_note,
accept_final_text_at_deadline=True,
)
with self._active_turn_lock:
self._active_turn_id = None
self._interrupt_event.clear()
return result
def _drive_turn(
self,
result: TurnResult,
*,
turn_timeout: float,
notification_poll_timeout: float,
timeout_label: str,
on_server_request: Callable[[dict], bool],
on_note: Callable[[dict, str], bool],
before_poll: Optional[Callable[[], bool]] = None,
pre_scope_filter: Optional[Callable[[dict, str], bool]] = None,
accept_final_text_at_deadline: bool = False,
) -> None:
"""Shared poll loop for run_turn / compact_thread until turn/completed or deadline.
Per iteration: interrupt -> subprocess death -> ``before_poll`` (watchdog) ->
server requests (answered before notifications so codex isn't blocked) ->
one notification, filtered by ``pre_scope_filter`` then by turn scope, handed
to ``on_note``. Hooks return True to mark the turn complete / stop the loop.
A deadline without completion interrupts and retires the session — a turn
that never finished is a strong sign the next turn shouldn't inherit this codex.
"""
deadline = time.monotonic() + turn_timeout
turn_complete = False
while time.monotonic() < deadline and not turn_complete:
if self._interrupt_event.is_set():
self._issue_interrupt(result.turn_id)
result.interrupted = True
break
if self._subprocess_died(result):
break
if before_poll is not None and before_poll():
break
sreq = self._client.take_server_request(timeout=0)
if sreq is not None:
if on_server_request(sreq):
turn_complete = True
continue
note = self._client.take_notification(timeout=notification_poll_timeout)
if note is None:
continue
method = note.get("method", "")
if pre_scope_filter is not None and not pre_scope_filter(note, method):
continue
if not _notification_belongs_to_turn(
note, thread_id=self._thread_id, turn_id=result.turn_id
):
logger.debug("ignoring foreign codex notification: method=%s", method)
continue
if on_note(note, method):
turn_complete = True
if (
accept_final_text_at_deadline
and not turn_complete
and not result.interrupted
and result.final_text
and result.error is None
):
logger.warning(
"codex app-server turn reached deadline after a completed "
"assistant message but before turn/completed; accepting "
"the assistant text as the terminal response"
)
turn_complete = True
if not turn_complete and not result.interrupted:
self._issue_interrupt(result.turn_id)
result.interrupted = True
if not result.error:
result.error = self._format_error_with_stderr(f"{timeout_label} timed out after {turn_timeout}s")
result.should_retire = True
def compact_thread(
self,
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
) -> TurnResult:
"""Trigger Codex-native history compaction for the current thread.
``thread/compact/start`` returns immediately (with no turn id); progress
streams through normal turn/item notifications, so we wait for the
matching ``turn/completed`` to treat the return as a compaction boundary.
"""
result = TurnResult()
if not self._start_for(result):
return result
self._interrupt_event.clear()
projector = CodexEventProjector()
if self._request_for(
result, "thread/compact/start", {"threadId": self._thread_id}, "thread/compact/start"
) is None:
return result
def pre_scope_filter(note: dict, method: str) -> bool:
if result.turn_id is not None:
return True
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if method == "turn/started":
if observed_thread_id is not None and str(observed_thread_id) != str(self._thread_id):
logger.debug("ignoring foreign compact turn/started: thread=%s", observed_thread_id)
return False
if observed_turn_id is None:
logger.debug("ignoring compact turn/started without a turn id")
return False
result.turn_id = str(observed_turn_id)
elif observed_turn_id is not None or method in {"item/completed", "turn/completed"}:
# Until the new turn/started arrives, terminal/projectable
# events are stale or can't be attributed to this compaction.
logger.debug("ignoring codex notification before compact turn start: method=%s", method)
return False
return True
def on_note(note: dict, method: str) -> bool:
_, aborted = self._absorb_notification(result, projector, note)
if method not in {"turn/started", "turn/completed"}:
return aborted
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
if method == "turn/started":
return aborted
turn_status = turn_obj.get("status")
if turn_status == "interrupted":
result.interrupted = True
result.error = result.error or "compact turn interrupted"
elif turn_status and turn_status != "completed":
err_msg = _format_responses_error(turn_obj.get("error"), str(turn_status))
self._set_classified_error(
result, f"compact turn ended status={turn_status}", err_msg, err_msg
)
return True
def on_server_request(sreq: dict) -> bool:
self._handle_server_request(sreq)
return False
self._drive_turn(
result,
turn_timeout=turn_timeout,
notification_poll_timeout=notification_poll_timeout,
timeout_label="compact turn",
on_server_request=on_server_request,
on_note=on_note,
pre_scope_filter=pre_scope_filter,
)
return result
def _issue_interrupt(self, turn_id: Optional[str]) -> None:
if self._client is None or self._thread_id is None or turn_id is None:
return
try:
self._client.request("turn/interrupt", {"threadId": self._thread_id, "turnId": turn_id}, timeout=5)
except CodexAppServerError as exc:
# "no active turn to interrupt" is fine — already done.
logger.debug("turn/interrupt non-fatal: %s", exc)
except TimeoutError:
logger.warning("turn/interrupt timed out")
def _handle_server_request(self, req: dict) -> None:
"""Answer a codex server request (approval / elicitation) via Hermes' approval flow.
Method names verified live against codex 0.130.0. Permission escalations
are always declined: the user chose their profile in ~/.codex/config.toml.
Unknown methods get a clean JSON-RPC error so codex doesn't hang.
"""
if self._client is None:
return
method = req.get("method", "")
rid = req.get("id")
params = req.get("params") or {}
handler = self._SERVER_REQUEST_HANDLERS.get(method)
if handler is None:
logger.warning("Unknown codex server request: %s", method)
self._client.respond_error(rid, code=-32601, message=f"Unsupported method: {method}")
return
self._client.respond(rid, handler(self, params))
def _respond_elicitation(self, params: dict) -> dict:
"""MCP elicitation (tool confirmation, OAuth, form data): auto-accept for our
own hermes-tools server (the user opted in by enabling the runtime; it exposes
nothing codex's shell can't already do); decline others so the user opts in
via codex's own auth flow."""
action = "accept" if (params.get("serverName") or "") == "hermes-tools" else "decline"
return {"action": action, "content": None, "_meta": None}
_SERVER_REQUEST_HANDLERS: dict[str, Callable[..., dict]] = {
"item/commandExecution/requestApproval": lambda self, p: {"decision": self._decide_exec_approval(p)},
"item/fileChange/requestApproval": lambda self, p: {"decision": self._decide_apply_patch_approval(p)},
"item/permissions/requestApproval": lambda self, p: {"decision": "decline"},
"mcpServer/elicitation/request": _respond_elicitation,
}
def _run_approval_callback(self, command: str, description: str, log_label: str) -> str:
try:
choice = self._approval_callback(command, description, allow_permanent=False)
return _approval_choice_to_codex_decision(choice)
except Exception:
logger.exception("approval_callback raised on %s", log_label)
return "decline"
def _decide_exec_approval(self, params: dict) -> str:
"""Decide a Codex exec approval request — protocol-level routing only.
Hermes approval mode/timeout resolution lives upstream: codex_runtime.py
derives ``auto_approve_exec`` from tools.approval, and the callback runs
the shared approval gate. Do not re-read approval config here.
"""
if self._routing.auto_approve_exec:
return "accept"
if self._approval_callback is None:
return "decline" # fail-closed when no callback wired
# ``cwd`` is Optional on codex's side; fall back so the prompt is never empty.
description = f"Codex requests exec in {params.get('cwd') or self._cwd or '<unknown>'}"
if params.get("reason"):
description += f" — {params['reason']}"
return self._run_approval_callback(params.get("command") or "", description, "exec request")
def _decide_apply_patch_approval(self, params: dict) -> str:
"""Decide a Codex apply_patch approval request (routing only; see _decide_exec_approval)."""
if self._routing.auto_approve_apply_patch:
return "accept"
if self._approval_callback is None:
return "decline"
# Params carry reason + grantRoot only; the changeset comes from the
# fileChange item cached by _track_pending_file_change.
reason = params.get("reason")
grant_root = params.get("grantRoot")
change_summary = self._pending_file_changes.get(params.get("itemId") or "") or None
description_parts = [p for p in (reason, change_summary) if p]
if grant_root:
description_parts.append(f"grants write to {grant_root}")
description = "; ".join(description_parts) if description_parts else "Codex requests to apply a patch"
command_label = (
f"apply_patch: {change_summary}" if change_summary
else f"apply_patch: {reason}" if reason
else "apply_patch"
)
return self._run_approval_callback(command_label, description, "apply_patch")
def _track_pending_file_change(self, note: dict) -> None:
"""Maintain _pending_file_changes from item/started + item/completed so the
apply_patch approval prompt can show what's actually changing."""
method = note.get("method", "")
item = (note.get("params") or {}).get("item") or {}
item_id = item.get("id") or ""
if item.get("type") != "fileChange" or not item_id:
return
if method == "item/completed":
self._pending_file_changes.pop(item_id, None)
elif method == "item/started":
raw_changes = item.get("changes") or []
if not raw_changes:
self._pending_file_changes[item_id] = "1 change pending"
return
changes = [ch for ch in raw_changes if isinstance(ch, dict)]
kinds: dict[str, int] = {}
for ch in changes:
kind = (ch.get("kind") or {}).get("type") or "update"
kinds[kind] = kinds.get(kind, 0) + 1
paths: list[str] = [ch["path"] for ch in changes if ch.get("path")]
counts = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items()))
preview = ", ".join(paths[:3])
if len(paths) > 3:
preview += f", +{len(paths) - 3} more"
self._pending_file_changes[item_id] = f"{counts}: {preview}" if preview else counts
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture token usage: codex emits it as a separate thread/tokenUsage/updated
notification (cumulative totals + last-turn breakdown), not on turn/completed."""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
token_usage = (note.get("params") or {}).get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
if isinstance(last, dict):
result.token_usage_last = dict(last)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex-native compaction boundaries: a contextCompaction item
(recent builds) or the deprecated thread/compacted notification (older builds)."""
if not isinstance(note, dict):
return
method = note.get("method") or ""
params = note.get("params") or {}
if not isinstance(params, dict):
return
if method != "thread/compacted":
item = params.get("item") if method in {"item/started", "item/completed"} else None
if not isinstance(item, dict) or item.get("type") != "contextCompaction":
return
result.compacted = True
result.thread_id = params.get("threadId") or result.thread_id
result.turn_id = params.get("turnId") or result.turn_id
# Hermes 'once'/'session'/'always'/'deny'(/'timeout') -> codex approval decisions
# (codex-rs app-server-protocol v2). Only the wire translation lives here; the
# approval mode/timeout resolution stays in tools/approval.py. "deny" and
# "timeout" both decline — codex has no "prompt expired" wire value.
_APPROVAL_CHOICE_TO_DECISION = {"once": "accept", "session": "acceptForSession", "always": "acceptForSession"}
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map a Hermes approval choice onto codex's approval decision wire value."""
return _APPROVAL_CHOICE_TO_DECISION.get(choice, "decline")
def _has_turn_aborted_marker(text: str) -> bool:
"""True if ``text`` carries a raw ``<turn_aborted>`` marker (terminal without turn/completed)."""
return bool(text) and any(marker in text for marker in _TURN_ABORTED_MARKERS)
def _get_hermes_version() -> str:
"""Best-effort Hermes version string for codex's userAgent line."""
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception: # pragma: no cover
return "0.0.0"