ouroboros · 멀티 백엔드 프로바이더 어댑터 / 런타임 추상화
ouroboros · 멀티 백엔드 프로바이더 어댑터 / 런타임 추상화
한 줄 요약
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 등 |
[!note]- 펼쳐보기: 전체 데이터 모델(Message · CompletionConfig · CompletionResponse) 통일 메시지
Message/MessageRole— 모든 백엔드는 3종 역할로 고정된 같은 형식을 입력으로 받는다(@dataclass(frozen=True, slots=True)불변 객체).
role:system/user/assistantcontent: 텍스트 본문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)
[!note]- 펼쳐보기: 백엔드별 특이점 체크
claude_code(별칭claude) — SDK 기반, 도구 하드 강제(allowed_tools)codex—codex exec서브프로세스,--sandbox하드 강제gemini/opencode/goose— 도구 소프트 강제(프롬프트 부탁)hermes/pi/gjc— 도구 envelope 자체 미지원kiro— envelope를--trust-tools네이티브로 매핑litellm(fallback) — 완성 전용, 도구 실행 안 함
[!note] 능력 선언 3단계
ParamSupportRuntimeCapabilities는 백엔드가 무엇을 네이티브로 지원하나를 명시한다. 각 항목은NATIVE(진짜 지원) /TRANSLATED(우회 번역) /IGNORED(무시)로 정직하게 표기. 예: Codex는 시스템 프롬프트를 user 메시지에 합쳐 넣으므로TRANSLATED.
실제 예시
모든 어댑터가 따르는 단 하나의 계약
# 파일경로: src/ouroboros/providers/base.py
class LLMAdapter(Protocol):
async def complete(
self,
messages: list[Message],
config: CompletionConfig,
) -> Result[CompletionResponse, ProviderError]:
...
→ 핵심: 재시도는 내부 처리, 예상 가능한 실패는 예외 대신 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.py
def 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
@property
def 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.
# 파일경로: src/ouroboros/providers/gemini_event_normalizer.py
def normalize_line(self, raw_line: str) -> dict[str, Any]:
stripped = raw_line.strip()
if stripped.startswith("{") or stripped.startswith("["):
return self._normalize_json_line(stripped, original=raw_line)
return self._normalize_text_line(stripped, original=raw_line)
[!note]- 펼쳐보기: 능력 카탈로그(단일 진실원) 코드
# 파일경로: src/ouroboros/backends/model_catalog.py # 센티널 백엔드: 실행 모델이 Claude id가 아니라 CLI 자체 설정값 _SENTINEL_MODEL_BACKENDS = frozenset( {"codex", "opencode", "kiro", "copilot", "hermes", "pi", "gjc"} ) DEFAULT_MODEL_SENTINEL = "default" # 손수 검증한 추가 모델 (live `opencode models` 카탈로그로 확인) _EXTRA_KNOWN_MODELS: dict[str, tuple[str, ...]] = { "claude": ("claude-haiku-4-5-20251001",), "codex": ("gpt-5-codex", "gpt-5", "gpt-5-mini"), }
[!note]- 펼쳐보기: 새 백엔드
foo추가 — 최소 어댑터 템플릿# 파일경로: src/ouroboros/providers/foo_adapter.py from ouroboros.core.errors import ProviderError from ouroboros.core.types import Result from ouroboros.providers.base import ( CompletionConfig, CompletionResponse, Message, MessageRole, UsageInfo, ) class FooLLMAdapter: # LLMAdapter Protocol을 구조적으로 만족(명시 상속 불필요) def __init__(self, *, cli_path=None, cwd=None, max_turns=1, on_message=None, timeout=None, max_retries=3, allowed_tools=None, permission_mode=None): self._cli_path = cli_path # ... 나머지 저장 async def complete( self, messages: list[Message], config: CompletionConfig, ) -> Result[CompletionResponse, ProviderError]: # 1) system 분리 + 나머지를 백엔드가 원하는 형태로 번역 system = next((m.content for m in messages if m.role == MessageRole.SYSTEM), None) prompt = "\n".join(f"{m.role.value}: {m.content}" for m in messages if m.role != MessageRole.SYSTEM) # 2) foo CLI/SDK 실행 (서브프로세스든 HTTP든) try: text = await self._run_foo(prompt, system, config) # 구현 except Exception as e: return Result.err(ProviderError(message=f"foo failed: {e}", details={"error_type": type(e).__name__})) if not text: return Result.err(ProviderError(message="Empty response from foo")) # 3) 통일 응답으로 포장 (토큰 못 얻으면 0) return Result.ok(CompletionResponse( content=text, model=config.model or "default", usage=UsageInfo(0, 0, 0), finish_reason="stop", raw_response={}, ))
[!note]- 펼쳐보기: 새 백엔드 추가 체크리스트(전체)
complete()가 예외 대신Result.err(ProviderError)로 예상 실패 반환- system 메시지를 분리해 네이티브 system 자리에(없으면 user 프롬프트에 합침)
capabilities.py의_CAPABILITIES에BackendCapability(name="foo", cli_name=..., supports_llm=True, ...)추가- 하드 강제 불가면
soft_tool_enforcement=True, envelope 불가면supports_tool_envelope=Falsefactory.py의create_llm_adapter()if-체인에if resolved_backend == "foo": return FooLLMAdapter(...)model_catalog.py: 모델 id가 CLI 설정이면_SENTINEL_MODEL_BACKENDS, 동적 목록 가능하면_LIST_ARGS에 등록- 빈 응답·타임아웃·재시도 처리(claude/codex의
_RETRYABLE_ERROR_PATTERNS참고)- 에이전트 런타임 필요 시
orchestrator/foo_runtime.py+runtime_factory.py분기 +RuntimeCapabilities정직하게(TRANSLATED/IGNORED) 선언
요약 & 셀프체크
3줄 요약
- 위층은 “메시지 보내고 답 받기” 계약 하나만 알고, 백엔드별 차이는 어댑터(통역사)가 흡수한다.
- 계약은 두 종류 — 한 번에 답하는 완성(
LLMAdapter)과 도구 쓰며 스트리밍하는 에이전트 런타임(AgentRuntime). - 백엔드마다 능력이 다르므로(하드/소프트 강제, envelope 등) 능력 카탈로그에 정직하게 선언하고, 새 백엔드는 “계약 구현 + 카탈로그 등록 + factory 분기”로 추가한다.
스스로 답해보기
- 백엔드를 갈아탈 때 무엇만 바꾸면 되고, 위층 코드는 왜 손댈 필요가 없을까?
- 완성 계약과 에이전트 런타임 계약을 굳이 나눈 이유는?
complete()가 실패 시 예외 대신Result.err을 돌려주도록 한 이유는?
연결
OB_개요 · _분석축_루브릭 · OB_40_orchestrator-execution-loop · OB_20_spec-engine-seed-and-double-diamond
[!tip]- Codex 교차검증 메모 (원문 보존)
- 언제 들어가나: ouroboros의 모든 LLM 소비 지점(인터뷰 질문 생성, Seed 추출, 평가/QA, 에이전트 실행)에서 직접 모델 SDK를 부르는 대신 어댑터를 통과한다. 어댑터는 워크플로 시작 시
create_llm_adapter()(완성) 또는create_agent_runtime()(에이전트)로 1회 생성되어 주입된다.- 어떻게: 위층은
Message리스트 +CompletionConfig만 만들어await adapter.complete(...)를 호출. 어댑터 내부에서 백엔드별 번역이 일어난다 —ClaudeCodeAdapter는 system 메시지를 분리해 SDKsystem_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 디렉터리는 이 리포 루트에는 별도로 존재하지 않는다(확인됨).근거 파일
providers/base.py—LLMAdapterProtocol,Message/MessageRole/CompletionConfig/CompletionResponse/UsageInfoproviders/factory.py—create_llm_adapter(),resolve_llm_backend(), 소프트 강제 백엔드 분기providers/claude_code_adapter.py— SDK 어댑터, system 분리·JSON 강제·하드 도구 강제·strict_mcp_config 격리providers/codex_cli_adapter.py—codex exec --json서브프로세스, 스키마 정규화/복원, JSONL 파싱providers/gemini_event_normalizer.py— 평문/NDJSON → 5키 내부 이벤트 정규화backends/model_catalog.py— 백엔드별 모델 카탈로그, 센티널 모델, CLI 탐지backends/capabilities.py—BackendCapability단일 진실원, 별칭/능력 플래그orchestrator/adapter.py—AgentRuntimeProtocol,AgentMessage/RuntimeHandle/RuntimeCapabilities/ParamSupport/FULL_CAPABILITIESorchestrator/codex_cli_runtime.py— Codex 프로세스 런타임, 이벤트→AgentMessage변환(_convert_event)orchestrator/runtime_factory.py—create_agent_runtime()런타임 백엔드 분기