ouroboros는 위층이 보는 인터페이스를 딱 1개로 고정하고, 그 아래에 Claude·Codex·Gemini 같은 백엔드마다 “번역기”를 둬서, 설정 한 줄만 바꾸면 어떤 모델 위에서도 같은 워크플로가 돌게 한다.
→ 왜 배우나: 특정 모델/CLI에 묶이지 않는 시스템의 핵심 패턴(계약 1개 + 번역기 N개)을 익히기 위해서다.
그림
flowchart TD
A["위층 워크플로<br/>(인터뷰 · Seed추출 · 평가)"] -->|"통일 메시지 + 요청설정"| B["어댑터 생성<br/>create_llm_adapter()"]
B -->|"백엔드 이름 정규화<br/>resolve_llm_backend()"| C{"백엔드 분기<br/>(factory)"}
C -->|claude_code| D1["ClaudeCodeAdapter<br/>SDK 호출"]
C -->|codex| D2["CodexCliLLMAdapter<br/>codex exec 서브프로세스"]
C -->|gemini| D3["GeminiCLIAdapter<br/>+ 이벤트 번역기"]
C -->|litellm| D4["LiteLLMAdapter<br/>(완성 전용 API)"]
D1 --> E["통일 응답<br/>또는 안전한 에러(Result.err)"]
D2 --> E
D3 --> E
D4 --> E
E --> A
쉽게 풀기
비유: 다국어 콜센터
상담원(위층 코드)은 한국어만 한다. 그런데 전화는 영어·일본어·중국어로도 온다. 상담원이 모든 언어를 배우는 대신 언어별 통역사를 두면, 상담원은 늘 한국어로 말하고 통역사가 알아서 번역한다. 상대가 어느 나라 사람인지조차 신경 쓸 필요가 없다. ouroboros가 정확히 이 구조다.
상담원 = 위층 워크플로 → “메시지 보내고 답 받기”만 안다
통역사 = 어댑터 → 백엔드마다 1개, 실제 번역 담당
각 나라 고객 = 백엔드 → Claude Code, Codex, Gemini, Copilot, Goose, OpenCode, Hermes, Kiro, pi, gjc, litellm…
flowchart LR
U["위층(상담원)<br/>한국어만"] -->|통일 메시지| AD["어댑터(통역사)"]
AD -.번역.-> B1["Claude"]
AD -.번역.-> B2["Codex"]
AD -.번역.-> B3["Gemini"]
B1 & B2 & B3 -.응답 번역.-> AD --> U
1단계 — 위층은 “계약” 하나만 안다
위층이 아는 건 하나다. “메시지 묶음과 설정을 주면 답을 돌려준다.” 이 약속이 **Protocol(계약)**이다. 답이 Claude SDK에서 왔는지, codex exec에서 왔는지, Gemini JSON 스트림에서 왔는지 위층은 모른다.
2단계 — 백엔드를 바꾸려면 설정 한 줄
llm.backend 값 한 줄만 고치면 갈아탄다. 새 CLI가 등장해도 통역기 파일 하나만 추가하면 끝, 위층 코드는 손대지 않는다.
3단계 — 계약은 사실 두 종류
일의 성격이 달라 약속도 둘이다.
완성(completion) 계약 = “한 번 묻고 한 번 답하기”(단답형). → LLMAdapter
에이전트 런타임 계약 = “도구를 써가며 여러 턴 일하고 진행 상황을 실시간 스트리밍”. → AgentRuntime
4단계 — 정직하게 능력을 선언한다
모든 백엔드가 같은 기능을 지원하진 않는다. 어떤 CLI는 “도구 제한”을 진짜 강제(하드)하지만, 어떤 CLI는 프롬프트로 “제발 이 도구만”이라 부탁만 한다(소프트). ouroboros는 이 차이를 숨기지 않고 능력 카탈로그에 적어 둬, 위층이 “이 백엔드선 이게 보장 안 됨”을 알게 한다.
핵심 정리
구성요소
역할
파일
LLMAdapter 계약
한 번 묻고 한 번 답하기(완성)
providers/base.py
AgentRuntime 계약
도구 쓰며 스트리밍
orchestrator/adapter.py
factory
백엔드 이름 → 구체 어댑터 선택
providers/factory.py
능력 카탈로그
백엔드별 별칭·능력 단일 진실원
backends/capabilities.py
이벤트 번역기
CLI 원시출력 → 내부 이벤트 dict
gemini_event_normalizer.py 등
펼쳐보기: 전체 데이터 모델(Message · CompletionConfig · CompletionResponse)
통일 메시지 Message / MessageRole — 모든 백엔드는 3종 역할로 고정된 같은 형식을 입력으로 받는다(@dataclass(frozen=True, slots=True) 불변 객체).
role : system / user / assistant
content : 텍스트 본문
to_dict() : {"role","content"} OpenAI 스타일 dict 변환
요청 설정 CompletionConfig(발췌)
model : 모델 id(예: claude-sonnet-4-6, gpt-5-codex, "default" 센티널)
temperature(기본 0.7) / max_tokens(기본 4096)
response_format : {"type":"json_object"} 등 JSON 강제
role / profile : 논리적 작업 역할 → 프로파일 해석
max_turns : CLI 백엔드 에이전트 턴 예산
model_is_explicit : True면 role 프로파일이 model을 못 덮어씀
응답 CompletionResponse
content : 생성 텍스트 / model : 실제 응답 모델
usage : 토큰 사용량(CLI 백엔드는 SDK 미노출 → 0)
finish_reason : 종료 사유(기본 "stop")
raw_response : 디버깅용 원본(예: session_id, returncode)
펼쳐보기: 백엔드별 특이점 체크
claude_code(별칭 claude) — SDK 기반, 도구 하드 강제(allowed_tools)
codex — codex exec 서브프로세스, --sandbox 하드 강제
gemini / opencode / goose — 도구 소프트 강제(프롬프트 부탁)
hermes / pi / gjc — 도구 envelope 자체 미지원
kiro — envelope를 --trust-tools 네이티브로 매핑
litellm(fallback) — 완성 전용, 도구 실행 안 함
능력 선언 3단계 ParamSupport
RuntimeCapabilities는 백엔드가 무엇을 네이티브로 지원하나를 명시한다. 각 항목은 NATIVE(진짜 지원) / TRANSLATED(우회 번역) / IGNORED(무시)로 정직하게 표기. 예: Codex는 시스템 프롬프트를 user 메시지에 합쳐 넣으므로 TRANSLATED.
→ 핵심: 재시도는 내부 처리, 예상 가능한 실패는 예외 대신Result.err(ProviderError)로 돌려준다. 위층이 에러를 받아도 프로그램은 멈추지 않는다.
요청 한 건이 흐르는 길
위층은 Message 리스트 + CompletionConfig만 만들어 await adapter.complete(...)를 부른다. 그 뒤 어댑터 내부에서 일어나는 일은 백엔드마다 다르다.
sequenceDiagram
participant U as 위층 워크플로
participant A as 어댑터
participant B as 백엔드 CLI/SDK
U->>A: complete(messages, config)
A->>A: system 분리 + 백엔드 형태로 번역
A->>B: 실행(SDK query / codex exec …)
alt 성공
B-->>A: 원시출력(텍스트/JSONL)
A->>A: 통일 응답으로 포장(토큰 못 얻으면 0)
A-->>U: Result.ok(CompletionResponse)
else 실패
B-->>A: 예외/빈응답/타임아웃
A-->>U: Result.err(ProviderError)
end
백엔드 이름 정규화 (factory)
# 파일경로: src/ouroboros/providers/factory.pydef resolve_llm_backend(backend: str | None = None) -> str: candidate = (backend or get_llm_backend()).strip().lower() try: resolved = resolve_llm_backend_name(candidate) except ValueError as exc: raise ValueError(f"Unsupported LLM backend: {candidate}") from exc return "claude_code" if resolved == "claude" else resolved
백엔드의 진짜 능력을 정직하게 선언
# 파일경로: src/ouroboros/orchestrator/codex_cli_runtime.py@propertydef capabilities(self) -> RuntimeCapabilities: # Codex는 system 프롬프트·도구 가이드를 user 메시지에 합쳐 넣으므로 # 네이티브 파라미터가 아닌 TRANSLATED로 표기 return replace( FULL_CAPABILITIES, system_prompt_support=ParamSupport.TRANSLATED, tool_restriction_support=ParamSupport.TRANSLATED, )
CLI 원시출력 → 내부 이벤트로 번역
각 CLI 출력(평문/NDJSON)을 내부 이벤트 dict로 바꾼다. 보장 키 5개: type, content, raw, is_error, metadata.
언제 들어가나: ouroboros의 모든 LLM 소비 지점(인터뷰 질문 생성, Seed 추출, 평가/QA, 에이전트 실행)에서 직접 모델 SDK를 부르는 대신 어댑터를 통과한다. 어댑터는 워크플로 시작 시 create_llm_adapter()(완성) 또는 create_agent_runtime()(에이전트)로 1회 생성되어 주입된다.
어떻게: 위층은 Message 리스트 + CompletionConfig만 만들어 await adapter.complete(...)를 호출. 어댑터 내부에서 백엔드별 번역이 일어난다 — ClaudeCodeAdapter는 system 메시지를 분리해 SDK system_prompt로, 나머지는 단일 프롬프트 문자열로 합치고 claude_agent_sdk.query()를 돌린 뒤 AssistantMessage/ResultMessage에서 텍스트를 추출. CodexCliLLMAdapter는 같은 입력을 ## System Instructions / ## Tool Constraints / ## Execution Budget 섹션이 붙은 평문 프롬프트로 만들어 codex exec --json 서브프로세스에 stdin으로 먹이고, JSONL stdout을 파싱해 --output-last-message 파일에서 최종 답을 읽는다.
왜 모델이 소비하나: 모델은 ouroboros를 전혀 모른다. 모델 입장에서는 그냥 평소의 프롬프트(시스템+대화)를 받는 것이고, ouroboros가 그 백엔드별 차이(JSON 강제 방식, 도구 envelope, 권한 플래그, 토큰 노출 여부)를 어댑터에서 흡수한다.
대상지침 구조와의 연결: 이 어댑터 계층은 스펙주도 파이프라인(Interview→Seed→Execute→Evaluate, Double-Diamond)의 실행 엔진 바닥이다. 인터뷰 단계에서 use_case="interview"는 CLI 샌드박스가 LLM 출력을 막지 못하도록 bypassPermissions로 강제되며(factory.py), 에이전트 실행 단계의 이벤트소싱은 AgentMessage/RuntimeHandle를 통해 백엔드 무관하게 기록된다. .ouroboros/에는 mechanical.toml과 seeds/가 있고 schemas 디렉터리는 이 리포 루트에는 별도로 존재하지 않는다(확인됨).