"""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