지식위키

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 원시출력 → 내부 이벤트 dictgemini_event_normalizer.py 등

[!note]- 펼쳐보기: 전체 데이터 모델(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)

[!note]- 펼쳐보기: 백엔드별 특이점 체크

  • claude_code(별칭 claude) — SDK 기반, 도구 하드 강제(allowed_tools)
  • codexcodex exec 서브프로세스, --sandbox 하드 강제
  • gemini / opencode / goose — 도구 소프트 강제(프롬프트 부탁)
  • hermes / pi / gjc — 도구 envelope 자체 미지원
  • kiro — envelope를 --trust-tools 네이티브로 매핑
  • litellm(fallback) — 완성 전용, 도구 실행 안 함

[!note] 능력 선언 3단계 ParamSupport RuntimeCapabilities는 백엔드가 무엇을 네이티브로 지원하나를 명시한다. 각 항목은 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_CAPABILITIESBackendCapability(name="foo", cli_name=..., supports_llm=True, ...) 추가
  • 하드 강제 불가면 soft_tool_enforcement=True, envelope 불가면 supports_tool_envelope=False
  • factory.pycreate_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줄 요약

  1. 위층은 “메시지 보내고 답 받기” 계약 하나만 알고, 백엔드별 차이는 어댑터(통역사)가 흡수한다.
  2. 계약은 두 종류 — 한 번에 답하는 완성(LLMAdapter)과 도구 쓰며 스트리밍하는 에이전트 런타임(AgentRuntime).
  3. 백엔드마다 능력이 다르므로(하드/소프트 강제, 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 메시지를 분리해 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.tomlseeds/가 있고 schemas 디렉터리는 이 리포 루트에는 별도로 존재하지 않는다(확인됨).

근거 파일

  • providers/base.pyLLMAdapter Protocol, Message/MessageRole/CompletionConfig/CompletionResponse/UsageInfo
  • providers/factory.pycreate_llm_adapter(), resolve_llm_backend(), 소프트 강제 백엔드 분기
  • providers/claude_code_adapter.py — SDK 어댑터, system 분리·JSON 강제·하드 도구 강제·strict_mcp_config 격리
  • providers/codex_cli_adapter.pycodex exec --json 서브프로세스, 스키마 정규화/복원, JSONL 파싱
  • providers/gemini_event_normalizer.py — 평문/NDJSON → 5키 내부 이벤트 정규화
  • backends/model_catalog.py — 백엔드별 모델 카탈로그, 센티널 모델, CLI 탐지
  • backends/capabilities.pyBackendCapability 단일 진실원, 별칭/능력 플래그
  • orchestrator/adapter.pyAgentRuntime Protocol, AgentMessage/RuntimeHandle/RuntimeCapabilities/ParamSupport/FULL_CAPABILITIES
  • orchestrator/codex_cli_runtime.py — Codex 프로세스 런타임, 이벤트→AgentMessage 변환(_convert_event)
  • orchestrator/runtime_factory.pycreate_agent_runtime() 런타임 백엔드 분기