90 lines
3.6 KiB
Python
90 lines
3.6 KiB
Python
"""Codex CLI headless backend. `codex exec --json --output-schema` subprocess."""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pydantic import BaseModel, ValidationError
|
|
from deep_research.backends.base import AgentBackend
|
|
|
|
|
|
def extract_final_json(jsonl_stdout: str) -> dict | None:
|
|
"""JSONL 이벤트 스트림에서 마지막 agent_message 의 text(JSON) 파싱."""
|
|
final = None
|
|
for line in jsonl_stdout.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
evt = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if evt.get("type") in ("agent_message", "message") and "text" in evt:
|
|
final = evt["text"]
|
|
# 실 CLI 포맷 (2026-06-10 스모크 확인): item.completed 에 중첩된 agent_message
|
|
item = evt.get("item")
|
|
if (evt.get("type") == "item.completed" and isinstance(item, dict)
|
|
and item.get("type") == "agent_message" and "text" in item):
|
|
final = item["text"]
|
|
if final is None:
|
|
return None
|
|
try:
|
|
return json.loads(final)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
class CodexBackend(AgentBackend):
|
|
def __init__(self, *, neutral_cwd: str | None = None, model: str | None = None):
|
|
self.neutral_cwd = neutral_cwd or tempfile.gettempdir()
|
|
self.model = model
|
|
|
|
@staticmethod
|
|
def _strict_schema(schema: dict) -> dict:
|
|
"""OpenAI structured-output strict 요구사항 적용 (실 스모크 2026-06-10 발견):
|
|
모든 object 에 additionalProperties:false + 전 property required."""
|
|
def walk(node):
|
|
if isinstance(node, dict):
|
|
if node.get("type") == "object" or "properties" in node:
|
|
node["additionalProperties"] = False
|
|
if "properties" in node:
|
|
node["required"] = list(node["properties"].keys())
|
|
for v in node.values():
|
|
walk(v)
|
|
elif isinstance(node, list):
|
|
for v in node:
|
|
walk(v)
|
|
walk(schema)
|
|
return schema
|
|
|
|
async def run_agent(self, prompt: str, schema: type[BaseModel], *, label: str):
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
|
json.dump(self._strict_schema(schema.model_json_schema()), fh)
|
|
schema_path = fh.name
|
|
# --skip-git-repo-check: neutral_cwd(/tmp)는 비신뢰 디렉터리 — 플래그 없으면 즉시 종료.
|
|
# stdin=DEVNULL: 비-tty 환경에서 codex 가 "Reading additional input from stdin" 으로
|
|
# 블록되는 것 방지 (실 CLI 스모크 2026-06-10 에서 발견).
|
|
cmd = ["codex", "exec", "--json", "--output-schema", schema_path,
|
|
"--cd", self.neutral_cwd, "-s", "read-only", "--skip-git-repo-check"]
|
|
if self.model:
|
|
cmd += ["-m", self.model]
|
|
cmd.append(prompt)
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd, stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
out, _ = await proc.communicate()
|
|
finally:
|
|
try:
|
|
os.unlink(schema_path)
|
|
except OSError:
|
|
pass
|
|
if proc.returncode != 0:
|
|
return None
|
|
obj = extract_final_json(out.decode("utf-8", "replace"))
|
|
if obj is None:
|
|
return None
|
|
try:
|
|
return schema.model_validate(obj)
|
|
except ValidationError:
|
|
return None
|