init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Antigravity CLI headless backend. `agy -p` subprocess + 드라이버 스키마 검증·재시도."""
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from deep_research.backends.base import AgentBackend
|
||||
|
||||
_FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
||||
|
||||
|
||||
def extract_json_block(text: str) -> dict | None:
|
||||
"""출력 텍스트에서 JSON 객체 추출: 펜스 우선, 없으면 최외곽 중괄호."""
|
||||
m = _FENCE.search(text)
|
||||
candidates = [m.group(1)] if m else []
|
||||
if not candidates:
|
||||
start, depth = -1, 0
|
||||
for i, ch in enumerate(text):
|
||||
if ch == "{":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif ch == "}" and depth > 0:
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
candidates.append(text[start:i + 1])
|
||||
break
|
||||
for c in candidates:
|
||||
try:
|
||||
return json.loads(c)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class AntigravityBackend(AgentBackend):
|
||||
def __init__(self, *, retries: int = 2, neutral_cwd: str | None = None, model: str | None = None):
|
||||
self.retries = retries
|
||||
self.neutral_cwd = neutral_cwd or tempfile.gettempdir()
|
||||
self.model = model
|
||||
|
||||
async def _invoke(self, prompt: str) -> str | None:
|
||||
# 실 CLI 스모크 (2026-06-10): agy 에 `--cd`/`-m` 플래그 없음 ("flags provided but
|
||||
# not defined" 즉사) — cwd 는 subprocess 인자로, 모델은 `--model`. stdin 차단(비-tty 안전).
|
||||
cmd = ["agy", "-p", prompt]
|
||||
if self.model:
|
||||
cmd += ["--model", self.model]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, cwd=self.neutral_cwd, stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return out.decode("utf-8", "replace")
|
||||
|
||||
async def run_agent(self, prompt: str, schema: type[BaseModel], *, label: str):
|
||||
schema_json = json.dumps(schema.model_json_schema())
|
||||
full = (prompt + "\n\n## Output format\nReturn ONLY a JSON object conforming to this "
|
||||
"JSON Schema (no prose, no markdown fences):\n" + schema_json)
|
||||
for attempt in range(self.retries + 1):
|
||||
text = await self._invoke(full if attempt == 0 else
|
||||
full + "\n\nYour previous output was invalid. Return ONLY the JSON object.")
|
||||
if text is None:
|
||||
continue
|
||||
obj = extract_json_block(text)
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
return schema.model_validate(obj)
|
||||
except ValidationError:
|
||||
continue
|
||||
return None
|
||||
@@ -0,0 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AgentBackend(ABC):
|
||||
"""run_agent: 프롬프트를 1개 CLI headless 에이전트로 실행, schema 로 검증된 객체 반환.
|
||||
실패/사용자-skip -> None (원본 .filter(Boolean) 의미)."""
|
||||
|
||||
@abstractmethod
|
||||
async def run_agent(self, prompt: str, schema: type[BaseModel], *, label: str) -> BaseModel | None:
|
||||
...
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
from deep_research.backends.base import AgentBackend
|
||||
|
||||
|
||||
class MockBackend(AgentBackend):
|
||||
"""테스트 더블. label prefix -> 응답객체(또는 None) 매핑. 네트워크·subprocess 0.
|
||||
|
||||
responses 의 키는 label prefix. 가장 먼저 매칭되는 prefix 의 값을 반환.
|
||||
값이 콜러블이면 (prompt, label) 로 호출해 동적 응답 가능."""
|
||||
|
||||
def __init__(self, responses: dict, default=None):
|
||||
self.responses = responses
|
||||
self.default = default
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def run_agent(self, prompt: str, schema: type[BaseModel], *, label: str):
|
||||
self.calls.append(label)
|
||||
for prefix, resp in self.responses.items():
|
||||
if label.startswith(prefix):
|
||||
return resp(prompt, label) if callable(resp) else resp
|
||||
return self.default
|
||||
Reference in New Issue
Block a user