ClaudeSdkEngine 통합
ClaudeSdkEngine은 @anthropic-ai/claude-agent-sdk를 감싸는 IEngine 래퍼입니다. 프로바이더 프록시 설정, API Key 해석, 다중 키 로드 밸런싱, Windows 환경에서의 Git/Bash 자동 구성을 처리합니다.
아키텍처 다이어그램
graph TB
Router["AgentRouter / MagiService"] --> Engine["ClaudeSdkEngine"]
Engine --> EnvBuild["buildProviderEnvWithProxy()"]
Engine --> AgentSvc["AgentService"]
Engine --> GitRt["GitRuntimeService<br/>(Windows)"]
EnvBuild --> Decision{"Route?"}
Decision -->|"cliBackend='claude-code'<br/>(Subscription OAuth)"| PassThrough["AgentProxyServer<br/>passThrough mode"]
Decision -->|"id='anthropic'<br/>and no streaming callback"| Direct["Direct API Key pass<br/>env: ANTHROPIC_API_KEY"]
Decision -->|"Other providers"| ProxySvc["AgentProxyServer<br/>transformer chain / direct pass"]
PassThrough --> Anthropic["api.anthropic.com<br/>passthrough SDK native Bearer<br/>collect 5h/7d quota headers"]
ProxySvc --> URLNorm["URL normalization<br/>remove duplicate /v1"]
ProxySvc --> AuthH["Auth header conversion<br/>x-api-key → Bearer"]
ProxySvc --> FmtConv["Format conversion<br/>OpenAI ↔ Anthropic"]
ProxySvc --> Retry["Retry callback<br/>429/529 → IPC notify"]
ProxySvc --> BgModel["Background model routing<br/>background task model replacement"]
Direct --> AgentSvc
ProxySvc -->|"env: ANTHROPIC_BASE_URL=localhost:PORT"| AgentSvc
PassThrough -->|"env: ANTHROPIC_BASE_URL=localhost:PORT"| AgentSvc
AgentSvc --> SDK["Claude Agent SDK<br/>subprocess"]
핵심 로직: buildProviderEnvWithProxy
이 함수는 Claude Agent SDK를 위한 환경 변수를 준비합니다. chat_sessions 행의 플랫 필드(providerId, cliBackend, useExtendedContext, 원시 model)를 기반으로 서로 다른 전략을 채택합니다.
알고리즘 단계
- providerId 없음 → 빈 객체 반환; SDK가
process.env를 사용 code-cli+cliBackend='claude-code'(구독 OAuth) →buildClaudeCodePassThroughResult:passThrough: true로AgentProxyServer실행- SDK의 네이티브 Bearer 헤더를
api.anthropic.com으로 패스스루 - 응답 헤더
anthropic-ratelimit-unified-{5h,7d}-*수집 후SubscriptionUsageService를 트리거하여 프론트엔드 5h/7d 배지 업데이트 - Elftia 관리 OAuth Token(
TokensService.getValidClaudeAccessToken())의 선택적 주입으로 시스템 자격증명 재정의 가능 isOfficialProvider: false표시
- 기타 경로 →
llmConfig.getProvider(providerId)+resolveApiFormat(provider) - 공식 Anthropic 프로바이더(
provider.id === 'anthropic'):- ApiKeyPool 또는 provider.api_key에서 Key 조회
ANTHROPIC_API_KEY설정- 커스텀 base URL이 있으면
ANTHROPIC_BASE_URL설정 (후행/v1제거) isOfficialProvider: true표시
- 기타 프로바이더:
- ApiKeyPool 또는 provider.api_key에서 Key 조회
AgentProxyServer(로컬 HTTP 프록시) 실행ANTHROPIC_BASE_URL = proxy.getBaseUrl()설정- Anthropic 형식 프로바이더에는 실제 Key 전달 (서버 사이드 도구 활성화)
- 비 Anthropic 형식에는
'proxy-mode'전달 (Key는 프록시가 처리) onSessionEnd정리 콜백 반환 (프록시 중지)
함수 시그니처에
schemaFields?: { cliBackend, useExtendedContext }파라미터가 추가되었습니다 (v89+). 호출자가chat_sessions행에서 투명하게 전달합니다.cliBackend === 'claude-code'는 passthrough 분기를 트리거하고,useExtendedContext === true는 1M-context beta 주입을 트리거합니다 (아래 참조).
1M-context beta 주입 (v89+)
chat_sessions.useExtendedContext = 1이고 model이 1M-capable 화이트리스트(claude-opus-4-7 / claude-opus-4-6 / claude-sonnet-4-6)에 있으면, injectExtendedContextBeta(headers, model, useExtendedContext)가 아웃바운드 요청의 anthropic-beta HTTP 헤더에 'context-1m-2025-08-07'을 병합합니다 (쉼표로 구분된 목록).
body 필드가 아닙니다. 초기 구현에서는 플래그를
body.anthropic_beta배열에 넣었으나,/v1/messages엔드포인트가 400"anthropic_beta: Extra inputs are not permitted"오류로 거부했습니다. 표준 경로는 HTTP 헤더입니다.
주입 지점 (세 곳에서 동일한 멱등 + 대소문자 무관 헬퍼를 공유):
| 경로 | 위치 | 주입 대상 headers 객체 |
|---|---|---|
| passthrough 모드 (claude-code OAuth) | AgentProxyServer.handlePassThroughRequest의 fetch 이전 | upstreamHeaders (req.headers에서 복사 + 정규화) |
| isOfficialProvider 직접 전달 분기 | AgentProxyServer의 fetchWithRetry 이전 | getProviderHeaders(provider, apiKey)가 반환하는 객체 |
| 트랜스포머 체인 경로 | TransformerChainExecutor.executeRequestChain 출구 | config.headers (fetch 헤더에 병합) |
헬퍼는 SDK가 이미 작성한 다른 beta(예: prompt-caching-2024-07-31)를 보존하며, 대소문자 변형과 호환됩니다 (Anthropic-Beta도 인식되어 표준 소문자 anthropic-beta로 재작성됩니다).
Claude Agent SDK 자체는 1M-context beta를 자동으로 추가하지 않습니다 (소스에서
context-1m발생 횟수 0).[1m]UI 마커가 이 헬퍼를 통해 HTTP 헤더에 주입되지 않으면 1M 컨텍스트가 적용되지 않으며 요청은 계속 200K 모드로 처리됩니다.
프록시 서버의 역할
비내장 Anthropic 프로바이더는 모두 다음 이유로 프록시를 통해 라우팅됩니다:
- URL 정규화 —
api_base_url에/v1이 포함될 수 있으며, SDK가 이를/v1/v1/messages로 중복 연결합니다 - Auth 헤더 처리 — 프로바이더마다 다른 인증 헤더 형식 사용 (x-api-key vs Bearer)
- 형식 변환 — 비 Anthropic 프로바이더는 요청/응답 형식 변환이 필요합니다
- 재시도 콜백 — 429/529 오류 시 콜백을 통해 프론트엔드에 알림
- 백그라운드 모델 라우팅 — SDK의 백그라운드 태스크 요청을 다른 모델로 라우팅 가능
API Key 해석
function resolveApiKey(apiKey: string): string {
if (apiKey.startsWith('$')) {
return process.env[apiKey.slice(1)] || '';
}
return apiKey;
}
$ 접두사를 통한 환경 변수 참조를 지원합니다. 예: $ANTHROPIC_API_KEY.
다중 키 로드 밸런싱
ApiKeyPoolService를 통해 구현됩니다:
setApiKeyPool(pool: ApiKeyPoolService): void;
설정 후 buildProviderEnvWithProxy는 Pool에서 Key를 우선적으로 조회합니다:
let apiKey = '';
if (apiKeyPool && sessionId) {
apiKey = await apiKeyPool.getKeyForSession(provider.id, sessionId);
}
if (!apiKey) {
apiKey = resolveApiKey(provider.api_key);
}
Pool은 가중 라운드 로빈 + 세션 어피니티 전략으로 Key를 분배하며, 429/529 오류 발생 시 자동 쿨다운이 트리거됩니다.
Windows Git 설정
setGitRuntime(runtime: GitRuntimeService): void;
Claude Agent SDK 서브프로세스는 Git과 Bash를 필요로 합니다. Windows에서 GitRuntimeService는 다음을 담당합니다:
- Git for Windows 설치 경로 감지
- git-bash 경로를
PATH에 추가 - SDK 서브프로세스가 정상적으로 실행될 수 있도록 보장
각 startSession 및 resumeSession 이전에 ensureGitForSdk()를 자동으로 호출합니다.
두 가지 호출 경로
셀프서비스 경로 (AgentRouter)
AgentRouter는 providerEnv 없이 ClaudeSdkEngine을 직접 호출합니다:
// ctx.providerEnv is empty
engine.startSession(ctx);
// → internally calls buildProviderEnvWithProxy() to construct itself
Magi 사전 빌드 경로
MagiService는 providerEnv를 미리 구성하여 전달합니다:
// ctx.providerEnv already built by MagiService
engine.startSession(ctx);
// → directly uses ctx.providerEnv, skips self-construction
// → uses startWithExistingSession (DB session already exists)
결정 로직
if (ctx.providerEnv && dbSessionId) {
await this.agent.startWithExistingSession(sender, dbSessionId, sessionOpts);
} else {
await this.agent.createSession(sender, sessionOpts);
}
API Key 검증
세션 시작 전 키를 검증하여 키 누락으로 인한 CLI 서브프로세스 실패를 방지합니다:
if (!env?.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_API_KEY) {
throw new Error(`API key not configured for provider "${providerId}"`);
}
IPC 이벤트
표준 agent:event 이벤트 외에도 ClaudeSdkEngine은 재시도 알림을 전송합니다:
| 이벤트 | 페이로드 | 설명 |
|---|---|---|
agent:event type=retry | { attempt, maxAttempts, delayMs, error } | API 요청 재시도 알림 |
SessionStore (SDK 0.3.x, 2026-05-19+)
SDK 0.3.x는 공식 SessionStore 인터페이스를 제공하여 세션 트랜스크립트를 임의의 외부 저장소에 미러링합니다. AgentService.runSession은 sdkOptions.sessionStore = new SqliteSessionStore(db)를 통해 이를 주입합니다:
SDK subprocess writes disk JSONL → SDK also calls sessionStore.append(key, entries)
→ SqliteSessionStore → sdkSessionStore:append IPC → DB worker
→ INSERT OR IGNORE to sdk_session_store table (idempotent by entryUuid)
On Resume: SDK calls sessionStore.load(key) → returns entries[] | null
→ SDK materializes entries to temp JSONL → subprocess --resume
→ can recover even if disk JSONL is lost (as long as sdk_session_store has data)
재개 안전 검사(AgentService.resumeSession): 디스패치 전에 hasResumableSdkJsonl(projectPath, sdkSessionId) (디스크 파일 존재 + type:'user' 레코드 포함)와 sdkSessionStore:countBySessionId > 0 모두를 확인합니다. 둘 다 비어있으면 → sdkSessionId를 초기화하고 SDK가 새 대화를 시작하도록 합니다 (DB 채팅 기록은 보존됨).
사용 중단된 경로: 구버전 sdk_records 테이블 + JsonlBuilder.reconstruct() 경로 (IPC 스키마 ≠ 디스크 스키마, SDK 거부)는 migration v97에서 완전히 종료되었습니다. docs/dev/66_sdk_records/01_schema_divergence_investigation.md를 참조하세요.
주요 파일
| 파일 | 경로 | 설명 |
|---|---|---|
| ClaudeSdkEngine | agent-core/engine/ClaudeSdkEngine.ts | IEngine 구현 + buildProviderEnvWithProxy (passThrough 분기 + extendedContext 투명 전달 포함) |
| AgentService | agent-core/agent/AgentService.ts | SDK 세션 라이프사이클; AgentSessionOptions에 cliBackend + useExtendedContext 포함, DB model 컬럼에 원시 SDK id 저장; runSession에서 sdkOptions.sessionStore 주입; resumeSession에서 폴백 검사 수행 |
| AgentProxyServer | agent-core/agent/AgentProxyServer.ts | HTTP 프록시 서버, 세 가지 모드: passThrough / isOfficialProvider 직접 전달 / 기본 트랜스포머 체인 |
| 1M-context beta 주입 헬퍼 | agent-core/agent/anthropicBetaInject.ts | injectExtendedContextBeta(body, model, useExtendedContext), 세 곳의 출구에서 공유 |
| SqliteSessionStore | agent-core/agent/SqliteSessionStore.ts | SDK 0.3.x SessionStore 인터페이스의 SQLite 구현 (append/load/delete/listSubkeys) |
| sdkPathEncoding 헬퍼 | agent-core/agent/sdkPathEncoding.ts | encodeProjectPath (= replace(/[^A-Za-z0-9]/g, '-')) + hasResumableSdkJsonl (파일 존재 + user 레코드 포함) |
| sdkSessionStore worker DAO | workers/db/sdkSessionStore.ts | sdk_session_store 테이블 CRUD, 6개 IPC (append/load/delete/listSubkeys/listSessions/countBySessionId) |
| Code CLI 타입 + 분할 프로토콜 | shared/contracts/code-cli-types.ts | splitModelReference()는 IPC 진입 지점의 유일한 문자열 프로토콜 파서 |
| ApiKeyPoolService | capabilities/llm/completion/ApiKeyPoolService.ts | 다중 키 로드 밸런싱 |
| GitRuntimeService | platform/runtime/GitRuntimeService.ts | Windows Git 설정 |
모든 경로는 packages/desktop/app/main/services/에 상대적입니다. shared/contracts/code-cli-types.ts는 packages/desktop/app/에 있으며, workers/db/sdkSessionStore.ts는 packages/desktop/app/main/에 있습니다.
사용자 상호작용 채널 (permission + ask_user_question)
Claude SDK 엔진은 백엔드에서 렌더러로 향하는 두 개의 대칭적인 대화 채널을 가지며, 둘 다 AgentService가 pendingXxx: Map<requestId, resolver> + 5분 타임아웃 + active.sender.send를 통한 IPC 푸시로 유지 관리합니다.
| 채널 | 트리거 | Main → Renderer IPC | Renderer → Main IPC |
|---|---|---|---|
| Permission | SDK canUseTool 콜백 (도구 사용 권한) | agent:permissionRequest | agent:respondPermission |
| AskUserQuestion | Agent가 능동적으로 mcp__elftia-ask__ask 도구 호출 | agent:askUserQuestion | agent:respondAskUserQuestion |
Permission: bypassPermissions 모드에서의 폴백 콜백
AgentService.runSession은 항상 onPermissionRequest를 등록하며, lib/claude-sdk.ts의 mapCliOptionsToSDK도 항상 이를 SDK의 canUseTool에 연결합니다. bypass 모드에서는 콜백 내부적으로 behavior: 'allow'를 적용하고 agentID / blockedPath / decisionReason 진단 필드를 출력합니다.
이전 코드는 두 레이어 모두에서 if (skipPermissions) skip으로 콜백을 단락시켰습니다. 이로 인해 무음 거부 버그가 발생했습니다: 주 에이전트가 bypassPermissions이지만 서브에이전트가 tools: [Read, Glob]을 사용해 도구 화이트리스트를 좁힐 때, SDK는 서브에이전트의 범위 초과 호출에 대해 여전히 canUseTool 게이트를 실행하지만 콜백이 등록되지 않아 → 무음 거부 → 사용자는 "권한이 거부되었습니다"를 보지만 Elftia는 대화상자를 표시하지 않았습니다.
주요 수정 위치:
packages/desktop/app/main/lib/claude-sdk.ts:262-339(양쪽 콜백 레이어 항상 등록)packages/desktop/app/main/services/agent-core/agent/AgentService.ts:1064-1086(isSkipPermissions단락 제거)
AskUserQuestion: SDK 인-프로세스 MCP로 내장 도구 대체
Claude Code 프리셋에 번들된 AskUserQuestion 도구는 터미널 프롬프트를 표시하는데 Elftia 메인 프로세스가 이를 노출할 수 없습니다. 수정 접근법 (옵션 A):
- 내장 비활성화:
sdkOptions.toolsSettings.disallowedTools에'AskUserQuestion'추가 - 대체 MCP 등록:
AskUserQuestionMcp.ts가createSdkMcpServer+tool()을 사용해mcp__elftia-ask__ask를 노출. 스키마는 내장과 동일 (questions: Array<{question, header(≤12 chars), multiSelect, options[2-4]}>) - 모델 안내:
appendSystemPrompt가 질문 시나리오에서 내장 대신mcp__elftia-ask__ask를 호출하도록 안내 추가 - 핸들러 흐름:
AgentService.askUserQuestion(dbSessionId, questions)호출 → Promise 생성 +pendingQuestionsMap에 resolver 저장 → IPCagent:askUserQuestion→ 렌더러AskUserQuestionDialog가 스테퍼 UI 표시 → 사용자 Submit → IPCagent:respondAskUserQuestion→ resolver가 답변을 JSON으로 직렬화하여 tool_result로 반환. 취소 시isError: true분기를 통해 사용자가 취소했음을 모델에 알립니다.
MCP 서버 인스턴스는 세션별로 빌드됩니다 (dbSessionId의 클로저), 멀티탭 시나리오에서 질문이 올바른 렌더러에 도달하도록 보장합니다.
관련 코드:
packages/desktop/app/main/services/agent-core/agent/AskUserQuestionMcp.ts(MCP 서버 생성자 +ELFTIA_ASK_MCP_NAME)packages/desktop/app/main/services/agent-core/agent/AgentService.ts(pendingQuestionsMap +askUserQuestion()+respondToAskUserQuestion(),runSession끝부분의 주입 로직)packages/desktop/app/main/services/routers/AgentRouter.ts(agent:respondAskUserQuestionIPC + zod 스키마)packages/renderer/src/features/chat/components/agent/AskUserQuestionDialog.tsx(스테퍼 UI: 현재 질문 확장 + 답변된 질문 축소 요약 행 클릭 시 되돌리기 + 항상 존재하는 "Other" 텍스트 입력 + 모든 질문 답변 후 Submit 활성화)
확장 지점
- 새 프록시 형식 변환:
AgentProxyServer에 새 API 형식 어댑터 추가 - 커스텀 재시도 전략:
RetryCallback파라미터를 통해 재시도 동작 커스터마이즈 - 백그라운드 모델 설정:
agentDefaults.background를 통해 백그라운드 태스크 모델 설정
관련 모듈
| 모듈 | 경로 | 관계 |
|---|---|---|
| EngineDispatcher | agent-core/engine/EngineDispatcher.ts | 엔진 등록 |
| LLMConfigService | capabilities/llm/config-service/ | 프로바이더 설정 |
| MagiService | agent-core/magi/MagiService.ts | 고수준 오케스트레이션 (assembleMagiMcps가 레지스트리 탐색 + Object.assign으로 customAgentOptions.additionalMcpServers에 병합; buildTinyElfDirectMcpServers는 TinyElf 진입점) |
| MagiSdkOptionsBuilder | agent-core/magi/MagiSdkOptionsBuilder.ts | 프롬프트 구성 (V1-V4) + 사용자 MCP 주입(setUserMcpServers / getMcpServers(allowedUserMcpNames)) + setChannelMcpProbe. 내장 MCP 어셈블리는 Phase 5.9에서 services/capabilities/tools/mcp-builtin/으로 마이그레이션됨; 사용자 MCP 부분을 여전히 담당하므로 원래 이름 유지 |
| McpProviderRegistry | capabilities/tools/mcp-builtin/ | 11개 정적 등록 내장 MCP Provider + 동적 ScriptPluginProviders 팩토리; 통합 assembleMcpForSession(ctx) 진입점. SDK 경로 호출: MagiService.assembleMagiMcps (Clawia), AgentService.mergeMcpAssembly (기타). 원래 media-tools MCP는 Tier C 파일럿에서 마이그레이션됨 elftia_toolkit 내 media 툴킷으로 (08_builtin_mcp_and_toolkits.md §3 참조). capabilities/tools/mcp-builtin/README.md 참조 |
| Skill Toolkit Registry | capabilities/tools/skill-toolkit/ | elftia_toolkit MCP 내 인-프로세스 함수 디스패처. 4개의 메타 도구 노출: list_toolkits / read_toolkit / read_toolkit_reference / skill_invoke. 내장 툴킷 두 개: chrome-use (28개 CDP 함수) + media (10개 미디어 생성/음성 함수). 점진적 공개: SKILL.md가 인덱스, 프로바이더별/작업별 심층 문서는 툴킷의 references 맵에 저장, 필요 시 read_toolkit_reference로 조회 |
| AgentService MCP 주입 | agent-core/agent/AgentService.ts | mergeMcpAssembly(sdkOptions, options, dbSessionId)는 비 Clawia SDK 세션의 레지스트리 진입점; main/index.ts에서 setMcpAssembler를 통해 연결. 세션별 정리 작업이 onSessionEnd에 자동으로 체인됨 |