mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
1047 lines
40 KiB
Python
1047 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""Codex app-server bridge tool.
|
|
|
|
This module talks to Codex's native app-server protocol over stdio JSON-RPC.
|
|
State is persisted for status/recovery, but communication never uses mailbox,
|
|
inbox, or outbox files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from tools.registry import registry, tool_error
|
|
|
|
|
|
CODEX_BRIDGE_DB = "codex_bridge.db"
|
|
DEFAULT_APPROVAL_POLICY = "untrusted"
|
|
DEFAULT_SANDBOX = "read-only"
|
|
EVENT_TAIL_LIMIT = 20
|
|
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
|
|
|
|
|
|
def check_codex_bridge_requirements() -> bool:
|
|
return shutil.which("codex") is not None
|
|
|
|
|
|
def _now() -> float:
|
|
return time.time()
|
|
|
|
|
|
def _json_dumps(data: Any) -> str:
|
|
return json.dumps(data, ensure_ascii=False, sort_keys=True)
|
|
|
|
|
|
def _text_input(text: str) -> List[Dict[str, Any]]:
|
|
return [{"type": "text", "text": text, "text_elements": []}]
|
|
|
|
|
|
def _summarize_payload(payload: Any, max_chars: int = 1200) -> str:
|
|
if payload is None:
|
|
return ""
|
|
if isinstance(payload, str):
|
|
text = payload
|
|
else:
|
|
text = _json_dumps(payload)
|
|
text = " ".join(text.split())
|
|
if len(text) <= max_chars:
|
|
return text
|
|
return text[: max_chars - 1] + "..."
|
|
|
|
|
|
def _normalize_status(method: str, params: Dict[str, Any]) -> Optional[str]:
|
|
if method == "turn/started":
|
|
return "working"
|
|
if method == "turn/completed":
|
|
turn = params.get("turn") or {}
|
|
status = turn.get("status")
|
|
if status == "failed":
|
|
return "failed"
|
|
if status == "cancelled":
|
|
return "cancelled"
|
|
return "completed"
|
|
if method == "thread/status/changed":
|
|
status = params.get("status") or {}
|
|
if status.get("type") == "systemError":
|
|
return "failed"
|
|
if status.get("type") == "active":
|
|
return "working"
|
|
if method == "error":
|
|
return "failed" if not params.get("willRetry") else "working"
|
|
return None
|
|
|
|
|
|
class CodexBridgeStore:
|
|
def __init__(self, db_path: Optional[Path] = None):
|
|
home = get_hermes_home()
|
|
self.db_path = db_path or (home / CODEX_BRIDGE_DB)
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._lock = threading.Lock()
|
|
self._init_db()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(self.db_path), timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _init_db(self) -> None:
|
|
with self._connect() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS codex_bridge_tasks (
|
|
hermes_task_id TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
prompt_summary TEXT,
|
|
codex_thread_id TEXT,
|
|
codex_turn_id TEXT,
|
|
cwd TEXT,
|
|
model TEXT,
|
|
sandbox TEXT,
|
|
approval_policy TEXT,
|
|
degraded_mode TEXT,
|
|
last_progress_summary TEXT,
|
|
final_summary TEXT,
|
|
error_summary TEXT,
|
|
notify_target TEXT,
|
|
notification_status TEXT,
|
|
notified_at REAL,
|
|
notification_error TEXT,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
completed_at REAL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS codex_bridge_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
hermes_task_id TEXT NOT NULL,
|
|
codex_thread_id TEXT,
|
|
codex_turn_id TEXT,
|
|
source_event_type TEXT NOT NULL,
|
|
normalized_status TEXT,
|
|
payload_summary TEXT,
|
|
payload_json TEXT,
|
|
created_at REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS codex_bridge_pending_requests (
|
|
request_id TEXT NOT NULL,
|
|
hermes_task_id TEXT NOT NULL,
|
|
method TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
resolved_at REAL,
|
|
response_json TEXT,
|
|
PRIMARY KEY (request_id, hermes_task_id)
|
|
);
|
|
"""
|
|
)
|
|
self._ensure_task_columns(conn)
|
|
|
|
def _ensure_task_columns(self, conn: sqlite3.Connection) -> None:
|
|
existing = {
|
|
row["name"]
|
|
for row in conn.execute("PRAGMA table_info(codex_bridge_tasks)").fetchall()
|
|
}
|
|
migrations = {
|
|
"notify_target": "ALTER TABLE codex_bridge_tasks ADD COLUMN notify_target TEXT",
|
|
"notification_status": "ALTER TABLE codex_bridge_tasks ADD COLUMN notification_status TEXT",
|
|
"notified_at": "ALTER TABLE codex_bridge_tasks ADD COLUMN notified_at REAL",
|
|
"notification_error": "ALTER TABLE codex_bridge_tasks ADD COLUMN notification_error TEXT",
|
|
}
|
|
for column, statement in migrations.items():
|
|
if column not in existing:
|
|
conn.execute(statement)
|
|
|
|
def upsert_task(self, task: "CodexBridgeTask") -> None:
|
|
with self._lock, self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO codex_bridge_tasks (
|
|
hermes_task_id, status, prompt_summary, codex_thread_id,
|
|
codex_turn_id, cwd, model, sandbox, approval_policy,
|
|
degraded_mode, last_progress_summary, final_summary,
|
|
error_summary, notify_target, notification_status, notified_at,
|
|
notification_error, created_at, updated_at, completed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(hermes_task_id) DO UPDATE SET
|
|
status=excluded.status,
|
|
codex_thread_id=excluded.codex_thread_id,
|
|
codex_turn_id=excluded.codex_turn_id,
|
|
last_progress_summary=excluded.last_progress_summary,
|
|
final_summary=excluded.final_summary,
|
|
error_summary=excluded.error_summary,
|
|
notify_target=excluded.notify_target,
|
|
notification_status=excluded.notification_status,
|
|
notified_at=excluded.notified_at,
|
|
notification_error=excluded.notification_error,
|
|
updated_at=excluded.updated_at,
|
|
completed_at=excluded.completed_at
|
|
""",
|
|
(
|
|
task.hermes_task_id,
|
|
task.status,
|
|
task.prompt_summary,
|
|
task.codex_thread_id,
|
|
task.codex_turn_id,
|
|
task.cwd,
|
|
task.model,
|
|
task.sandbox,
|
|
task.approval_policy,
|
|
task.degraded_mode,
|
|
task.last_progress_summary,
|
|
task.final_summary,
|
|
task.error_summary,
|
|
task.notify_target,
|
|
task.notification_status,
|
|
task.notified_at,
|
|
task.notification_error,
|
|
task.created_at,
|
|
task.updated_at,
|
|
task.completed_at,
|
|
),
|
|
)
|
|
|
|
def insert_event(
|
|
self,
|
|
task_id: str,
|
|
thread_id: Optional[str],
|
|
turn_id: Optional[str],
|
|
method: str,
|
|
normalized_status: Optional[str],
|
|
payload: Any,
|
|
) -> None:
|
|
with self._lock, self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO codex_bridge_events (
|
|
hermes_task_id, codex_thread_id, codex_turn_id,
|
|
source_event_type, normalized_status, payload_summary,
|
|
payload_json, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
task_id,
|
|
thread_id,
|
|
turn_id,
|
|
method,
|
|
normalized_status,
|
|
_summarize_payload(payload),
|
|
_json_dumps(payload),
|
|
_now(),
|
|
),
|
|
)
|
|
|
|
def upsert_pending_request(
|
|
self,
|
|
task_id: str,
|
|
request_id: str,
|
|
method: str,
|
|
payload: Any,
|
|
status: str = "pending",
|
|
) -> None:
|
|
with self._lock, self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO codex_bridge_pending_requests (
|
|
request_id, hermes_task_id, method, payload_json,
|
|
status, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(request_id, hermes_task_id) DO UPDATE SET
|
|
payload_json=excluded.payload_json,
|
|
status=excluded.status
|
|
""",
|
|
(request_id, task_id, method, _json_dumps(payload), status, _now()),
|
|
)
|
|
|
|
def resolve_pending_request(
|
|
self,
|
|
task_id: str,
|
|
request_id: str,
|
|
response: Any,
|
|
status: str = "resolved",
|
|
) -> None:
|
|
with self._lock, self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
UPDATE codex_bridge_pending_requests
|
|
SET status=?, resolved_at=?, response_json=?
|
|
WHERE hermes_task_id=? AND request_id=?
|
|
""",
|
|
(status, _now(), _json_dumps(response), task_id, request_id),
|
|
)
|
|
|
|
def get_task_snapshot(self, task_id: str) -> Optional[Dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM codex_bridge_tasks WHERE hermes_task_id=?",
|
|
(task_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
events = conn.execute(
|
|
"""
|
|
SELECT source_event_type, normalized_status, payload_summary, created_at
|
|
FROM codex_bridge_events
|
|
WHERE hermes_task_id=?
|
|
ORDER BY id DESC LIMIT ?
|
|
""",
|
|
(task_id, EVENT_TAIL_LIMIT),
|
|
).fetchall()
|
|
pending = conn.execute(
|
|
"""
|
|
SELECT request_id, method, payload_json, status, created_at
|
|
FROM codex_bridge_pending_requests
|
|
WHERE hermes_task_id=? AND status='pending'
|
|
ORDER BY created_at ASC
|
|
""",
|
|
(task_id,),
|
|
).fetchall()
|
|
snap = dict(row)
|
|
snap["recent_events"] = [dict(r) for r in reversed(events)]
|
|
snap["pending_requests"] = [
|
|
{
|
|
"request_id": r["request_id"],
|
|
"method": r["method"],
|
|
"payload": json.loads(r["payload_json"]),
|
|
"status": r["status"],
|
|
"created_at": r["created_at"],
|
|
}
|
|
for r in pending
|
|
]
|
|
return snap
|
|
|
|
def list_tasks(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT hermes_task_id, status, prompt_summary, codex_thread_id,
|
|
codex_turn_id, last_progress_summary, final_summary,
|
|
error_summary, notify_target, notification_status,
|
|
notified_at, notification_error, created_at, updated_at,
|
|
completed_at
|
|
FROM codex_bridge_tasks
|
|
ORDER BY updated_at DESC LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def list_completed_for_notification(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
placeholders = ",".join("?" for _ in TERMINAL_STATUSES)
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM codex_bridge_tasks
|
|
WHERE status IN ({placeholders})
|
|
AND (
|
|
notification_status IS NULL
|
|
OR notification_status='pending'
|
|
OR notification_status='failed'
|
|
)
|
|
ORDER BY completed_at ASC, updated_at ASC
|
|
LIMIT ?
|
|
""",
|
|
(*sorted(TERMINAL_STATUSES), limit),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def update_notification_status(
|
|
self,
|
|
task_id: str,
|
|
status: str,
|
|
*,
|
|
notified_at: Optional[float] = None,
|
|
error: Optional[str] = None,
|
|
) -> None:
|
|
with self._lock, self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
UPDATE codex_bridge_tasks
|
|
SET notification_status=?, notified_at=?, notification_error=?, updated_at=?
|
|
WHERE hermes_task_id=?
|
|
""",
|
|
(status, notified_at, error, _now(), task_id),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CodexBridgeTask:
|
|
hermes_task_id: str
|
|
prompt_summary: str
|
|
cwd: str
|
|
model: Optional[str]
|
|
sandbox: str
|
|
approval_policy: str
|
|
status: str = "starting"
|
|
codex_thread_id: Optional[str] = None
|
|
codex_turn_id: Optional[str] = None
|
|
degraded_mode: str = "none"
|
|
last_progress_summary: Optional[str] = None
|
|
final_summary: Optional[str] = None
|
|
error_summary: Optional[str] = None
|
|
notify_target: Optional[str] = None
|
|
notification_status: Optional[str] = None
|
|
notified_at: Optional[float] = None
|
|
notification_error: Optional[str] = None
|
|
created_at: float = field(default_factory=_now)
|
|
updated_at: float = field(default_factory=_now)
|
|
completed_at: Optional[float] = None
|
|
pending_requests: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
|
|
def snapshot(self) -> Dict[str, Any]:
|
|
return {
|
|
"hermes_task_id": self.hermes_task_id,
|
|
"status": self.status,
|
|
"codex_thread_id": self.codex_thread_id,
|
|
"codex_turn_id": self.codex_turn_id,
|
|
"prompt_summary": self.prompt_summary,
|
|
"cwd": self.cwd,
|
|
"model": self.model,
|
|
"sandbox": self.sandbox,
|
|
"approval_policy": self.approval_policy,
|
|
"degraded_mode": self.degraded_mode,
|
|
"last_progress_summary": self.last_progress_summary,
|
|
"final_summary": self.final_summary,
|
|
"error_summary": self.error_summary,
|
|
"notify_target": self.notify_target,
|
|
"notification_status": self.notification_status,
|
|
"notified_at": self.notified_at,
|
|
"notification_error": self.notification_error,
|
|
"created_at": self.created_at,
|
|
"updated_at": self.updated_at,
|
|
"completed_at": self.completed_at,
|
|
"pending_requests": list(self.pending_requests.values()),
|
|
}
|
|
|
|
|
|
class CodexJsonRpcClient:
|
|
def __init__(self, task_id: str, task: CodexBridgeTask, manager: "CodexBridgeManager"):
|
|
self.task_id = task_id
|
|
self.task = task
|
|
self.manager = manager
|
|
self._process: Optional[subprocess.Popen[str]] = None
|
|
self._next_id = 1
|
|
self._pending: Dict[int, queue.Queue] = {}
|
|
self._pending_lock = threading.Lock()
|
|
self._write_lock = threading.Lock()
|
|
self._reader_thread: Optional[threading.Thread] = None
|
|
self._stderr_thread: Optional[threading.Thread] = None
|
|
self._closed = False
|
|
|
|
def start(self, *, codex_home: Optional[str] = None) -> None:
|
|
env = os.environ.copy()
|
|
if codex_home:
|
|
Path(codex_home).mkdir(parents=True, exist_ok=True)
|
|
env["CODEX_HOME"] = codex_home
|
|
self._process = subprocess.Popen(
|
|
["codex", "app-server", "--listen", "stdio://"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
cwd=self.task.cwd,
|
|
env=env,
|
|
)
|
|
self._reader_thread = threading.Thread(target=self._read_stdout, daemon=True)
|
|
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
self._reader_thread.start()
|
|
self._stderr_thread.start()
|
|
|
|
def initialize(self) -> Dict[str, Any]:
|
|
result = self.request(
|
|
"initialize",
|
|
{
|
|
"clientInfo": {"name": "hermes-codex-bridge", "version": "0.1.0"},
|
|
"capabilities": {"experimentalApi": True},
|
|
},
|
|
timeout=10,
|
|
)
|
|
self.notify("initialized")
|
|
return result
|
|
|
|
def request(self, method: str, params: Optional[Dict[str, Any]] = None, timeout: float = 30) -> Dict[str, Any]:
|
|
with self._pending_lock:
|
|
request_id = self._next_id
|
|
self._next_id += 1
|
|
response_q: queue.Queue = queue.Queue(maxsize=1)
|
|
self._pending[request_id] = response_q
|
|
self._send({"id": request_id, "method": method, "params": params})
|
|
try:
|
|
msg = response_q.get(timeout=timeout)
|
|
except queue.Empty:
|
|
with self._pending_lock:
|
|
self._pending.pop(request_id, None)
|
|
raise TimeoutError(f"Codex app-server request timed out: {method}")
|
|
if "error" in msg:
|
|
raise RuntimeError(msg["error"].get("message") or _json_dumps(msg["error"]))
|
|
return msg.get("result") or {}
|
|
|
|
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
msg: Dict[str, Any] = {"method": method}
|
|
if params is not None:
|
|
msg["params"] = params
|
|
self._send(msg)
|
|
|
|
def respond(self, request_id: str, result: Dict[str, Any]) -> None:
|
|
self._send({"id": request_id, "result": result})
|
|
|
|
def close(self) -> None:
|
|
self._closed = True
|
|
process = self._process
|
|
if process and process.poll() is None:
|
|
try:
|
|
process.terminate()
|
|
process.wait(timeout=3)
|
|
except Exception:
|
|
try:
|
|
process.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
def _send(self, msg: Dict[str, Any]) -> None:
|
|
if not self._process or not self._process.stdin:
|
|
raise RuntimeError("Codex app-server process is not running.")
|
|
with self._write_lock:
|
|
self._process.stdin.write(json.dumps(msg) + "\n")
|
|
self._process.stdin.flush()
|
|
|
|
def _read_stdout(self) -> None:
|
|
assert self._process and self._process.stdout
|
|
for line in self._process.stdout:
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
self.manager.record_event(self.task_id, "protocol/raw", {"line": line.rstrip()})
|
|
continue
|
|
if "id" in msg and "method" not in msg:
|
|
with self._pending_lock:
|
|
response_q = self._pending.pop(msg["id"], None)
|
|
if response_q:
|
|
response_q.put(msg)
|
|
else:
|
|
self.manager.record_event(self.task_id, "protocol/response", msg)
|
|
continue
|
|
if "id" in msg and "method" in msg:
|
|
self.manager.handle_server_request(self.task_id, self, msg)
|
|
else:
|
|
self.manager.record_event(self.task_id, msg.get("method", "unknown"), msg.get("params", {}))
|
|
|
|
def _read_stderr(self) -> None:
|
|
assert self._process and self._process.stderr
|
|
for line in self._process.stderr:
|
|
text = line.strip()
|
|
if text:
|
|
self.manager.record_event(self.task_id, "codex/stderr", {"message": text})
|
|
|
|
|
|
class CodexBridgeManager:
|
|
def __init__(self, store: Optional[CodexBridgeStore] = None):
|
|
self.store = store or CodexBridgeStore()
|
|
self._tasks: Dict[str, CodexBridgeTask] = {}
|
|
self._clients: Dict[str, CodexJsonRpcClient] = {}
|
|
self._lock = threading.RLock()
|
|
|
|
def start_task(
|
|
self,
|
|
prompt: str,
|
|
*,
|
|
cwd: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
sandbox: str = DEFAULT_SANDBOX,
|
|
approval_policy: str = DEFAULT_APPROVAL_POLICY,
|
|
codex_home: Optional[str] = None,
|
|
notify_target: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
if not prompt or not prompt.strip():
|
|
raise ValueError("codex_bridge start requires a non-empty prompt.")
|
|
if sandbox == "danger-full-access":
|
|
raise ValueError("codex_bridge refuses danger-full-access as a default bridge sandbox.")
|
|
if approval_policy == "never":
|
|
raise ValueError("codex_bridge refuses approval_policy=never.")
|
|
task_id = f"codex-{uuid.uuid4().hex[:12]}"
|
|
cwd = str(Path(cwd or os.getcwd()).resolve())
|
|
task = CodexBridgeTask(
|
|
hermes_task_id=task_id,
|
|
prompt_summary=_summarize_payload(prompt, max_chars=300),
|
|
cwd=cwd,
|
|
model=model,
|
|
sandbox=sandbox,
|
|
approval_policy=approval_policy,
|
|
notify_target=notify_target.strip() if notify_target and notify_target.strip() else None,
|
|
notification_status="pending" if notify_target and notify_target.strip() else None,
|
|
)
|
|
client = CodexJsonRpcClient(task_id, task, self)
|
|
with self._lock:
|
|
self._tasks[task_id] = task
|
|
self._clients[task_id] = client
|
|
self.store.upsert_task(task)
|
|
|
|
try:
|
|
client.start(codex_home=codex_home)
|
|
init = client.initialize()
|
|
self.record_event(task_id, "bridge/initialized", init)
|
|
thread_params: Dict[str, Any] = {
|
|
"cwd": cwd,
|
|
"sandbox": sandbox,
|
|
"approvalPolicy": approval_policy,
|
|
"approvalsReviewer": "user",
|
|
"ephemeral": True,
|
|
"sessionStartSource": "startup",
|
|
"developerInstructions": (
|
|
"You are running under Hermes Codex Bridge. Do not treat your "
|
|
"own output as approval. Ask for approval through Codex app-server "
|
|
"requests when required."
|
|
),
|
|
}
|
|
if model:
|
|
thread_params["model"] = model
|
|
thread_result = client.request("thread/start", thread_params, timeout=15)
|
|
thread = thread_result.get("thread") or {}
|
|
task.codex_thread_id = thread.get("id")
|
|
task.status = "starting"
|
|
task.updated_at = _now()
|
|
self.store.upsert_task(task)
|
|
if not task.codex_thread_id:
|
|
raise RuntimeError("Codex app-server did not return a thread id.")
|
|
|
|
turn_params: Dict[str, Any] = {
|
|
"threadId": task.codex_thread_id,
|
|
"input": _text_input(prompt),
|
|
"approvalPolicy": approval_policy,
|
|
"approvalsReviewer": "user",
|
|
"cwd": cwd,
|
|
}
|
|
if model:
|
|
turn_params["model"] = model
|
|
turn_result = client.request("turn/start", turn_params, timeout=15)
|
|
turn = turn_result.get("turn") or {}
|
|
task.codex_turn_id = turn.get("id")
|
|
task.status = "working"
|
|
task.updated_at = _now()
|
|
self.store.upsert_task(task)
|
|
except Exception:
|
|
client.close()
|
|
with self._lock:
|
|
self._clients.pop(task_id, None)
|
|
raise
|
|
return {
|
|
"success": True,
|
|
"message": "Codex task started through app-server stdio JSON-RPC.",
|
|
"task": task.snapshot(),
|
|
"protocol": {
|
|
"transport": "app-server stdio",
|
|
"mailbox": False,
|
|
},
|
|
}
|
|
|
|
def status(self, task_id: str) -> Dict[str, Any]:
|
|
with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
if task:
|
|
snap = task.snapshot()
|
|
stored = self.store.get_task_snapshot(task_id)
|
|
if stored:
|
|
snap["recent_events"] = stored.get("recent_events", [])
|
|
snap["pending_requests"] = stored.get("pending_requests", snap["pending_requests"])
|
|
for key in ("notification_status", "notified_at", "notification_error"):
|
|
snap[key] = stored.get(key)
|
|
return {"success": True, "task": snap}
|
|
stored = self.store.get_task_snapshot(task_id)
|
|
if stored:
|
|
stored["active_connection"] = False
|
|
return {"success": True, "task": stored}
|
|
return {"success": False, "error": f"Unknown Codex bridge task: {task_id}"}
|
|
|
|
def list_tasks(self, limit: int = 10) -> Dict[str, Any]:
|
|
return {"success": True, "tasks": self.store.list_tasks(limit=limit)}
|
|
|
|
def notify_completed(
|
|
self,
|
|
*,
|
|
limit: int = 10,
|
|
dry_run: bool = False,
|
|
notifier: Optional[Callable[[str, str], Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
notifier = notifier or _default_completion_notifier
|
|
candidates = self.store.list_completed_for_notification(limit=limit)
|
|
results: List[Dict[str, Any]] = []
|
|
for task in candidates:
|
|
task_id = str(task["hermes_task_id"])
|
|
target = str(task.get("notify_target") or "").strip()
|
|
message = _completion_notification_message(task)
|
|
if not target:
|
|
result = {
|
|
"task_id": task_id,
|
|
"status": task.get("status"),
|
|
"notification_status": "no_target",
|
|
"sent": False,
|
|
"message": message,
|
|
}
|
|
if not dry_run:
|
|
self.store.update_notification_status(task_id, "no_target")
|
|
results.append(result)
|
|
continue
|
|
|
|
result = {
|
|
"task_id": task_id,
|
|
"status": task.get("status"),
|
|
"target": target,
|
|
"notification_status": "dry_run" if dry_run else "pending",
|
|
"sent": False,
|
|
"message": message,
|
|
}
|
|
if dry_run:
|
|
results.append(result)
|
|
continue
|
|
|
|
try:
|
|
delivery = notifier(target, message)
|
|
except Exception as exc:
|
|
error = str(exc)
|
|
self.store.update_notification_status(task_id, "failed", error=error)
|
|
result["notification_status"] = "failed"
|
|
result["error"] = error
|
|
results.append(result)
|
|
continue
|
|
|
|
notified_at = _now()
|
|
self.store.update_notification_status(task_id, "sent", notified_at=notified_at)
|
|
result["notification_status"] = "sent"
|
|
result["sent"] = True
|
|
result["notified_at"] = notified_at
|
|
result["delivery"] = delivery
|
|
results.append(result)
|
|
|
|
return {
|
|
"success": True,
|
|
"dry_run": dry_run,
|
|
"processed": len(results),
|
|
"notifications": results,
|
|
}
|
|
|
|
def steer(self, task_id: str, instruction: str) -> Dict[str, Any]:
|
|
task, client = self._active(task_id)
|
|
if not task.codex_thread_id or not task.codex_turn_id:
|
|
raise RuntimeError("Task has no active Codex turn to steer.")
|
|
result = client.request(
|
|
"turn/steer",
|
|
{
|
|
"threadId": task.codex_thread_id,
|
|
"expectedTurnId": task.codex_turn_id,
|
|
"input": _text_input(instruction),
|
|
},
|
|
timeout=15,
|
|
)
|
|
self.record_event(task_id, "bridge/steer", {"instruction": instruction, "result": result})
|
|
return {"success": True, "task": task.snapshot(), "result": result}
|
|
|
|
def interrupt(self, task_id: str) -> Dict[str, Any]:
|
|
task, client = self._active(task_id)
|
|
if not task.codex_thread_id or not task.codex_turn_id:
|
|
raise RuntimeError("Task has no active Codex turn to interrupt.")
|
|
result = client.request(
|
|
"turn/interrupt",
|
|
{"threadId": task.codex_thread_id, "turnId": task.codex_turn_id},
|
|
timeout=15,
|
|
)
|
|
task.status = "cancelled"
|
|
task.completed_at = _now()
|
|
task.updated_at = _now()
|
|
self.store.upsert_task(task)
|
|
self.record_event(task_id, "bridge/interrupt", result)
|
|
return {"success": True, "task": task.snapshot(), "result": result}
|
|
|
|
def respond(self, task_id: str, request_id: str, decision: str, answers: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
task, client = self._active(task_id)
|
|
pending = task.pending_requests.get(str(request_id))
|
|
if not pending:
|
|
raise RuntimeError(f"No pending Codex request {request_id!r} for task {task_id}.")
|
|
method = pending["method"]
|
|
if method == "item/tool/requestUserInput":
|
|
result = {"answers": answers or {}}
|
|
elif method == "item/commandExecution/requestApproval":
|
|
result = {"decision": decision}
|
|
elif method == "item/fileChange/requestApproval":
|
|
result = {"decision": decision}
|
|
elif method == "item/permissions/requestApproval":
|
|
result = {"decision": decision}
|
|
else:
|
|
result = {"decision": decision}
|
|
client.respond(str(request_id), result)
|
|
pending["status"] = "resolved"
|
|
pending["response"] = result
|
|
task.pending_requests.pop(str(request_id), None)
|
|
task.status = "working"
|
|
task.updated_at = _now()
|
|
self.store.resolve_pending_request(task_id, str(request_id), result)
|
|
self.store.upsert_task(task)
|
|
return {"success": True, "task": task.snapshot(), "response": result}
|
|
|
|
def handle_server_request(self, task_id: str, client: CodexJsonRpcClient, msg: Dict[str, Any]) -> None:
|
|
request_id = str(msg.get("id"))
|
|
method = msg.get("method", "unknown")
|
|
params = msg.get("params") or {}
|
|
with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return
|
|
if method == "item/tool/requestUserInput":
|
|
task.status = "waiting_for_user_input"
|
|
else:
|
|
task.status = "waiting_for_approval"
|
|
task.updated_at = _now()
|
|
task.pending_requests[request_id] = {
|
|
"request_id": request_id,
|
|
"method": method,
|
|
"payload": params,
|
|
"status": "pending",
|
|
"created_at": task.updated_at,
|
|
}
|
|
self.store.upsert_task(task)
|
|
self.store.upsert_pending_request(task_id, request_id, method, params)
|
|
self.record_event(task_id, method, params)
|
|
|
|
def record_event(self, task_id: str, method: str, params: Any) -> None:
|
|
terminal = False
|
|
with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return
|
|
normalized = _normalize_status(method, params if isinstance(params, dict) else {})
|
|
if method == "turn/completed" and task.status == "cancelled" and normalized == "completed":
|
|
normalized = "cancelled"
|
|
if normalized:
|
|
task.status = normalized
|
|
if method == "turn/completed":
|
|
terminal = True
|
|
task.completed_at = _now()
|
|
turn = params.get("turn") if isinstance(params, dict) else {}
|
|
if isinstance(turn, dict) and turn.get("error"):
|
|
task.error_summary = _summarize_payload(turn.get("error"), max_chars=500)
|
|
else:
|
|
task.final_summary = _summarize_payload(params, max_chars=500)
|
|
elif method == "error":
|
|
task.error_summary = _summarize_payload(params, max_chars=500)
|
|
elif method not in {"codex/stderr"}:
|
|
task.last_progress_summary = f"{method}: {_summarize_payload(params, max_chars=300)}"
|
|
task.updated_at = _now()
|
|
thread_id = task.codex_thread_id
|
|
turn_id = task.codex_turn_id
|
|
self.store.upsert_task(task)
|
|
self.store.insert_event(task_id, thread_id, turn_id, method, normalized, params)
|
|
if terminal:
|
|
with self._lock:
|
|
client = self._clients.pop(task_id, None)
|
|
if client:
|
|
threading.Thread(target=client.close, daemon=True).start()
|
|
|
|
def _active(self, task_id: str) -> tuple[CodexBridgeTask, CodexJsonRpcClient]:
|
|
with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
client = self._clients.get(task_id)
|
|
if not task or not client:
|
|
raise RuntimeError(f"Codex bridge task is not active in this process: {task_id}")
|
|
return task, client
|
|
|
|
|
|
_MANAGER: Optional[CodexBridgeManager] = None
|
|
_MANAGER_LOCK = threading.Lock()
|
|
|
|
|
|
def _get_manager() -> CodexBridgeManager:
|
|
global _MANAGER
|
|
with _MANAGER_LOCK:
|
|
if _MANAGER is None:
|
|
_MANAGER = CodexBridgeManager()
|
|
return _MANAGER
|
|
|
|
|
|
def _completion_notification_message(task: Dict[str, Any]) -> str:
|
|
task_id = task.get("hermes_task_id") or "unknown"
|
|
status = task.get("status") or "unknown"
|
|
prompt = task.get("prompt_summary") or "(no prompt summary)"
|
|
summary = task.get("final_summary") or task.get("error_summary") or task.get("last_progress_summary") or ""
|
|
lines = [
|
|
f"Codex Bridge task {task_id} finished with status: {status}.",
|
|
f"Prompt: {prompt}",
|
|
]
|
|
if summary:
|
|
lines.append(f"Summary: {_summarize_payload(summary, max_chars=700)}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _default_completion_notifier(target: str, message: str) -> Dict[str, Any]:
|
|
if target == "local":
|
|
return {"success": True, "target": target, "local": True}
|
|
from tools.send_message_tool import send_message_tool
|
|
|
|
raw = send_message_tool({"action": "send", "target": target, "message": message})
|
|
try:
|
|
result = json.loads(raw)
|
|
except Exception:
|
|
result = {"raw": raw}
|
|
if isinstance(result, dict) and result.get("error"):
|
|
raise RuntimeError(str(result["error"]))
|
|
return result if isinstance(result, dict) else {"result": result}
|
|
|
|
|
|
def codex_bridge(
|
|
action: str,
|
|
prompt: Optional[str] = None,
|
|
task_id: Optional[str] = None,
|
|
instruction: Optional[str] = None,
|
|
decision: str = "decline",
|
|
answers: Optional[Dict[str, Any]] = None,
|
|
cwd: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
sandbox: str = DEFAULT_SANDBOX,
|
|
approval_policy: str = DEFAULT_APPROVAL_POLICY,
|
|
codex_home: Optional[str] = None,
|
|
notify_target: Optional[str] = None,
|
|
limit: int = 10,
|
|
dry_run: bool = False,
|
|
) -> str:
|
|
try:
|
|
action = (action or "").strip().lower()
|
|
if action == "start":
|
|
result = _get_manager().start_task(
|
|
prompt or "",
|
|
cwd=cwd,
|
|
model=model,
|
|
sandbox=sandbox or DEFAULT_SANDBOX,
|
|
approval_policy=approval_policy or DEFAULT_APPROVAL_POLICY,
|
|
codex_home=codex_home,
|
|
notify_target=notify_target,
|
|
)
|
|
elif action == "status":
|
|
if not task_id:
|
|
raise ValueError("codex_bridge status requires task_id.")
|
|
result = _get_manager().status(task_id)
|
|
elif action == "list":
|
|
result = _get_manager().list_tasks(limit=limit)
|
|
elif action == "notify_completed":
|
|
result = _get_manager().notify_completed(limit=limit, dry_run=dry_run)
|
|
elif action == "steer":
|
|
if not task_id or not instruction:
|
|
raise ValueError("codex_bridge steer requires task_id and instruction.")
|
|
result = _get_manager().steer(task_id, instruction)
|
|
elif action in {"interrupt", "cancel"}:
|
|
if not task_id:
|
|
raise ValueError("codex_bridge interrupt requires task_id.")
|
|
result = _get_manager().interrupt(task_id)
|
|
elif action == "respond":
|
|
if not task_id or not instruction:
|
|
raise ValueError("codex_bridge respond requires task_id and instruction=request_id.")
|
|
result = _get_manager().respond(task_id, instruction, decision=decision, answers=answers)
|
|
else:
|
|
raise ValueError("action must be one of start, status, list, notify_completed, steer, interrupt, respond.")
|
|
return _json_dumps(result)
|
|
except Exception as exc:
|
|
return tool_error(str(exc))
|
|
|
|
|
|
CODEX_BRIDGE_SCHEMA = {
|
|
"name": "codex_bridge",
|
|
"description": (
|
|
"Start and control local Codex tasks through Codex app-server JSON-RPC. "
|
|
"Uses stdio/WebSocket-capable app-server protocol semantics and never "
|
|
"uses mailbox, inbox, or outbox files as the communication path. "
|
|
"Actions: start, status, list, notify_completed, steer, interrupt, respond."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["start", "status", "list", "notify_completed", "steer", "interrupt", "cancel", "respond"],
|
|
"description": "Bridge operation to perform.",
|
|
},
|
|
"prompt": {"type": "string", "description": "Task prompt for action=start."},
|
|
"task_id": {"type": "string", "description": "Hermes Codex task id for status/control actions."},
|
|
"instruction": {
|
|
"type": "string",
|
|
"description": "Steering text for steer, or pending request id for respond.",
|
|
},
|
|
"decision": {
|
|
"type": "string",
|
|
"enum": ["accept", "acceptForSession", "decline", "cancel"],
|
|
"description": "Approval decision for action=respond.",
|
|
},
|
|
"answers": {
|
|
"type": "object",
|
|
"description": "requestUserInput answers keyed by Codex question id, e.g. {'q1': {'answers': ['value']}}.",
|
|
},
|
|
"cwd": {"type": "string", "description": "Working directory for the Codex thread."},
|
|
"model": {"type": "string", "description": "Optional Codex model override."},
|
|
"sandbox": {
|
|
"type": "string",
|
|
"enum": ["read-only", "workspace-write"],
|
|
"description": "Codex sandbox mode. danger-full-access is intentionally not exposed.",
|
|
},
|
|
"approval_policy": {
|
|
"type": "string",
|
|
"enum": ["untrusted", "on-request"],
|
|
"description": "Codex approval policy. Default untrusted.",
|
|
},
|
|
"codex_home": {
|
|
"type": "string",
|
|
"description": "Optional CODEX_HOME override for testing or isolated runs.",
|
|
},
|
|
"notify_target": {
|
|
"type": "string",
|
|
"description": (
|
|
"Optional completion notification target for action=start, such as "
|
|
"'local', 'feishu:<chat_id>', or any send_message target."
|
|
),
|
|
},
|
|
"limit": {"type": "integer", "description": "List limit for action=list."},
|
|
"dry_run": {
|
|
"type": "boolean",
|
|
"description": "For action=notify_completed, preview notifications without sending or marking tasks.",
|
|
},
|
|
},
|
|
"required": ["action"],
|
|
},
|
|
}
|
|
|
|
|
|
registry.register(
|
|
name="codex_bridge",
|
|
toolset="codex_bridge",
|
|
schema=CODEX_BRIDGE_SCHEMA,
|
|
handler=lambda args, **kw: codex_bridge(
|
|
action=args.get("action", ""),
|
|
prompt=args.get("prompt"),
|
|
task_id=args.get("task_id"),
|
|
instruction=args.get("instruction"),
|
|
decision=args.get("decision", "decline"),
|
|
answers=args.get("answers"),
|
|
cwd=args.get("cwd"),
|
|
model=args.get("model"),
|
|
sandbox=args.get("sandbox", DEFAULT_SANDBOX),
|
|
approval_policy=args.get("approval_policy", DEFAULT_APPROVAL_POLICY),
|
|
codex_home=args.get("codex_home"),
|
|
notify_target=args.get("notify_target"),
|
|
limit=args.get("limit", 10),
|
|
dry_run=bool(args.get("dry_run", False)),
|
|
),
|
|
check_fn=check_codex_bridge_requirements,
|
|
emoji="C",
|
|
)
|