Agent 소스 코드 분석
모듈 개요
TypeScript 버전에서 Agent는 실행 엔진이 아닌 순수 구성 정의입니다. “역할”의 동작 매개변수 — 이름, 권한 규칙, 시스템 프롬프트, 모델 설정, 온도 설정 — 를 설명하지만 실행 로직은 포함하지 않습니다.
실제 실행 루프는 Session 모듈이 구동합니다. Session은 SessionPrompt.loop()를 통해 메인 루프를 시작하고, 각 반복에서 SessionProcessor.process() -> LLM.stream() -> Vercel AI SDK streamText()를 호출하여 LLM 호출을 완료한 다음, SessionProcessor가 스트리밍 이벤트, 도구 호출, 컨텍스트 압축 등을 처리합니다.
이 “구성과 실행의 분리” 설계는 다음을 의미합니다:
- Agent 추가 또는 수정은 구성 선언만으로 가능하며, 실행 로직을 작성할 필요가 없습니다
- 모든 Agent가 동일한 Session 실행 루프를 공유하며, 도구 호출, 에러 처리, 컨텍스트 관리는 한 번만 구현됩니다
- Agent 권한, 도구 세트, 시스템 프롬프트는 모두 선언적 구성으로 제어됩니다
주요 파일
| 파일 경로 | 라인 수 | 책임 범위 |
|---|---|---|
src/agent/agent.ts | ~230 | Agent 구성 정의: Agent.Info 스키마, 내장 Agent 목록, state() 팩토리, generate() 동적 생성 |
src/session/index.ts | ~400 | Session 관리: CRUD, 메시지 영속화, 비용 계산, 페이지네이션 쿼리 |
src/session/prompt.ts | ~580 | 핵심 진입점: loop() 메인 루프, 사용자 메시지 구성, 도구 해결, 서브태스크 디스패치 |
src/session/llm.ts | ~200 | LLM 호출: LLM.stream()은 Vercel AI SDK streamText()를 래핑하며, 시스템 프롬프트 조립 및 매개변수 병합 처리 |
src/session/processor.ts | ~220 | 메시지 프로세서: 스트리밍 이벤트 디스패치, 도구 라이프사이클, Doom Loop 감지, 스냅샷 추적 |
src/session/compaction.ts | ~220 | 컨텍스트 압축: 오버플로우 감지, 메시지 프루닝, 요약 생성 |
src/session/overflow.ts | ~20 | 오버플로우 확인: 토큰 사용량이 모델의 컨텍스트 윈도우를 초과하는지 확인하는 순수 함수 |
src/session/retry.ts | ~100 | 재시도 전략: 에러 분류, 지수 백오프, retry-after 헤더 파싱 |
src/session/revert.ts | ~160 | 메시지 되돌리기: 스냅샷 복원, 메시지 삭제, diff 계산 |
src/session/status.ts | ~80 | 상태 관리: idle/busy/retry 3상태 전환, Bus를 통한 브로드캐스트 |
src/session/message-v2.ts | ~600 | 메시지 타입: 모든 메시지 및 Part 타입을 정의하는 Zod 스키마, 영속화, 스트리밍 쿼리 |
타입 시스템
Agent.Info — Agent 구성 스키마
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: PermissionNext.Ruleset,
model: z
.object({
modelID: z.string(),
providerID: z.string(),
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
주요 필드 설명:
mode:"primary"는 기본 Agent(사용자와 직접 대화),"subagent"는 서브 Agent(Task 도구로 호출),"all"은 커스텀 Agent를 나타냅니다permission:PermissionNext.Ruleset, 이 Agent의 도구 권한 규칙 체인을 정의합니다prompt: Provider의 기본 프롬프트를 오버라이드하는 커스텀 시스템 프롬프트model: 선택적 고정 모델 바인딩. 지정하지 않으면 사용자가 현재 선택한 모델이 사용됩니다
내장 Agent 목록
| Agent 이름 | mode | hidden | 목적 |
|---|---|---|---|
build | primary | 아니오 | 전체 도구 권한을 가진 기본 Agent, 질문 및 plan_enter 지원 |
plan | primary | 아니오 | 계획 모드, 모든 편집 도구를 비활성화하고 plans 디렉토리에만 쓰기 허용 |
explore | subagent | 아니오 | 읽기 전용 도구만 사용하는 빠른 코드 탐색(grep, glob, read, bash, webfetch 등) |
general | subagent | 아니오 | 병렬 다단계 작업 실행을 위한 범용 서브 Agent, todo 도구 비활성화 |
compaction | primary | 예 | 컨텍스트 압축 전용, 모든 도구 비활성화, 대화 요약 생성 |
title | primary | 예 | Session 제목 생성, 모든 도구 비활성화, 온도 0.5로 고정 |
summary | primary | 예 | Session 요약 생성, 모든 도구 비활성화 |
Session.Info — Session 스키마
export const Info = z
.object({
id: Identifier.schema("session"),
slug: z.string(),
projectID: z.string(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
summary: z
.object({
additions: z.number(),
deletions: z.number(),
files: z.number(),
diffs: Snapshot.FileDiff.array().optional(),
})
.optional(),
share: z.object({ url: z.string() }).optional(),
title: z.string(),
version: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
}),
permission: PermissionNext.Ruleset.optional(),
revert: z
.object({
messageID: z.string(),
partID: z.string().optional(),
snapshot: z.string().optional(),
diff: z.string().optional(),
})
.optional(),
})
동시성 제어 — Runner 메커니즘
각 SessionID는 Effect의 Runner.make()로 생성된 Runner 인스턴스에 바인딩됩니다. Runner는 내부적으로 작업 큐를 유지하여 동일한 Session에 대한 작업이 직렬로 실행되도록 보장합니다:
const runners = new Map<string, Runner<MessageV2.WithParts>>()
const getRunner = (runners, sessionID) => {
const existing = runners.get(sessionID)
if (existing) return existing
const runner = Runner.make<MessageV2.WithParts>(scope, {
onIdle: Effect.gen(function* () {
runners.delete(sessionID) // 유휴 시 자동 정리, 리소스 해제
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt: lastAssistant(sessionID),
busy: () => { throw new Session.BusyError(sessionID) },
})
runners.set(sessionID, runner)
return runner
}
주요 설계 포인트:
- 직렬화 보장: 동일한 Session에 대한 모든 작업은 Runner를 통해 큐잉되며, 동시성 경쟁 상태가 없습니다
- 자동 정리: Runner는 유휴 상태가 되면 Map에서 제거되어 메모리 누수를 방지합니다
- 인터럽트 콜백:
onInterrupt는 취소 시lastAssistant()를 트리거하여 인터럽트 후 상태 일관성을 보장합니다 - 바쁨 거부: Session이 바쁘고 큐잉을 지원하지 않으면
BusyError를 직접 throw합니다
경쟁 상태 — SyncEvent 이벤트 소스
메시지 쓰기는 데이터베이스에 직접 조작하지 않고 SyncEvent 이벤트 소스를 통해 동기적으로 실행됩니다:
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
return msg
})
이 설계는 경쟁 상태의 가능성을 제거합니다:
SyncEvent.run()은 동기적으로 실행됩니다: 먼저 SQLite WAL에 쓴 다음 BusEvent를 브로드캐스트합니다- Effect의 단일 스레드 코루틴 모델이 작업의 원자성을 보장합니다
- 동일한 Session에 대한 작업은 이미 Runner에 의해 직렬화되어 있으므로 멀티스레드 경쟁이 없습니다
- Part 업데이트에는 델타 최적화가 있습니다: 스트리밍 텍스트는 DB에 쓰지 않고 BusEvent만 보내 IO 오버헤드를 줄입니다
코어 플로우 — Session 실행 루프
전체 호출 체인
User input -> SessionPrompt.prompt()
-> SessionPrompt.loop()
|-- First message? -> ensureTitle() generates title asynchronously
|-- Pending subtask? -> TaskTool executes directly
|-- Pending compaction? -> SessionCompaction.process()
|-- Context overflow? -> SessionCompaction.create() -> continue
`-- Normal processing:
|-- SessionProcessor.create()
|-- resolveSystemPrompt() assembles system prompt
|-- resolveTools() registers built-in + MCP tools
`-- processor.process()
`-- LLM.stream()
`-- ai.streamText() (Vercel AI SDK)
`-- for await (stream.fullStream) process streaming events
|-- text-start / text-delta / text-end -> Text Part
|-- reasoning-start / reasoning-delta / reasoning-end -> Reasoning Part
|-- tool-input-start / tool-call / tool-result / tool-error -> Tool Part
|-- start-step / finish-step -> Snapshot tracking + token billing
`-- Error? -> SessionRetry.retryable() determines whether to retry
SessionPrompt.loop() — 메인 루프 상세
loop()는 시스템 전체의 핵심입니다. SessionPrompt.prompt() 내에서 호출되며, 취소 제어를 위해 start()를 통해 AbortController를 등록합니다. 주요 루프 로직:
- 메시지 스트림 읽기:
MessageV2.filterCompacted(MessageV2.stream(sessionID))가 메시지를 역순으로 순회하며 압축된 이력을 건너뜁니다 - 루프 종료 조건 확인: 마지막 Assistant 메시지의
finish가"tool-calls"또는"unknown"이 아니고, 그 ID가 마지막 User 메시지 ID보다 크면 루프를 종료합니다 - 대기 중인 작업 우선 처리: 마지막 메시지의 Parts에
compaction또는subtask타입의 Part가 포함되어 있는지 확인합니다 - 컨텍스트 오버플로우 감지:
SessionCompaction.isOverflow()를 통해 압축 필요 여부를 확인합니다 - 일반 처리: Assistant 메시지를 생성하고, 도구를 해결하고,
processor.process()를 호출합니다
SessionProcessor.process() — 스트리밍 이벤트 처리
SessionProcessor는 현재 Assistant 메시지, 도구 호출 매핑 테이블, 스냅샷 참조를 보유하는 상태 저장 객체입니다. process() 메서드는 내부적으로 while(true) 루프를 포함합니다:
async process(streamInput: LLM.StreamInput) {
while (true) {
const stream = await LLM.stream(streamInput)
for await (const value of stream.fullStream) {
abort.throwIfAborted()
switch (value.type) {
// Text stream -> create/append TextPart
// Reasoning stream -> create/append ReasoningPart
// Tool call -> create ToolPart (pending -> running -> completed/error)
// Step tracking -> Snapshot.track() + StepStartPart/StepFinishPart
}
if (needsCompaction) break // Break on context overflow
}
// Error handling -> retryable? continue loop : mark error and exit
// Normal end -> return "continue" | "stop" | "compact"
}
}
LLM.stream() — Vercel AI SDK 래퍼
LLM.stream()은 Vercel AI SDK streamText()의 래퍼로 다음을 담당합니다:
- 언어 모델 획득:
Provider.getLanguage(model)이 AI SDK 호환LanguageModel을 반환합니다 - 시스템 프롬프트 조립: Agent 프롬프트, Provider 기본 프롬프트, 사용자 커스텀 시스템 프롬프트를 우선순위에 따라 병합합니다
- 매개변수 병합 파이프라인:
base->model.options->agent.options->variant, 각 레이어가 이전 것을 오버라이드합니다 - 도구 해결:
resolveTools()가 사용자의 비활성화 목록과 권한 규칙에 따라 도구를 필터링합니다 streamText()호출: 메시지 형식 변환을 위한wrapLanguageModel()미들웨어를 전달합니다
export async function stream(input: StreamInput) {
const [language, cfg, provider, auth] = await Promise.all([
Provider.getLanguage(input.model),
Config.get(),
Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID),
])
// ... system prompt assembly, parameter merging ...
return streamText({
model: wrapLanguageModel({ model: language, middleware: [...] }),
messages: [...system.map(x => ({ role: "system", content: x })), ...input.messages],
tools,
abortSignal: input.abort,
maxRetries: input.retries ?? 0,
// ... other parameters
})
}
Doom Loop 감지 메커니즘
SessionProcessor는 LLM이 동일한 도구를 반복 호출하는 무한 루프에 빠지는 것을 방지하기 위해 내부적으로 Doom Loop 감지를 구현합니다:
const DOOM_LOOP_THRESHOLD = 3
// In the tool-call event handler
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
lastThree.length === DOOM_LOOP_THRESHOLD &&
lastThree.every(
(p) =>
p.type === "tool" &&
p.tool === value.toolName &&
p.state.status !== "pending" &&
JSON.stringify(p.state.input) === JSON.stringify(value.input),
)
) {
await PermissionNext.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID,
// ...
})
}
감지 조건: 마지막 3번의 도구 호출이 모두 동일한 도구 이름과 동일한 입력 매개변수인 경우. 트리거되면 PermissionNext.ask()가 사용자 확인을 요청합니다. 사용자는 해당 도구의 Doom Loop를 “항상 허용”하도록 선택할 수 있습니다.
서브태스크 실행 모델
서브태스크는 새로운 Session을 생성하지 않고, 현재 Session 내에 새로운 Assistant 메시지 + Tool Parts를 생성합니다:
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input) {
// 1. 독립적인 assistant 메시지 생성 (mode = task.agent)
// 2. 도구 Part 생성 (status: "running")
// 3. 동일한 세션에서 taskTool.execute() 실행
// 서브 Agent의 권한 규칙 사용
// 현재 세션의 메시지 이력 전달
})
주요 설계 포인트:
- 서브태스크는 현재 Session의 메시지 이력을 재사용하여 중복 로딩을 방지합니다
- 기본 Agent의 권한이 아닌 서브 Agent의 권한 규칙을 사용합니다 (Session 레벨과 병합)
/command로 트리거된 경우, 완료 시 요약 User 메시지가 자동으로 삽입되어 기본 Agent에게 서브태스크 실행 결과를 알립니다- Tool Part 라이프사이클:
pending->running->completed/error, 다른 도구 호출과 일관됨
플러그인 훅 시스템
Session 실행 루프는 여러 지점에서 플러그인 훅을 주입하여 외부 확장을 허용합니다:
| 훅 포인트 | 트리거 시점 | 목적 |
|---|---|---|
chat.system.transform | 시스템 프롬프트 구성 중 | 시스템 프롬프트 콘텐츠 변환/주입 |
chat.params | LLM 매개변수 전달 전 | temperature, maxTokens 등 매개변수 수정 |
tool.execute.before | 도구 실행 전 | 도구 입력 가로채기, 로깅, 수정 |
tool.execute.after | 도구 실행 후 | 도구 출력 후처리, 감사 로깅 |
command.execute.before | 명령 실행 전 | 명령 매개변수 가로채기/수정 |
chat.message | 메시지 구성 중 | 메시지 콘텐츠 변환/확장 |
shell.env | 셸 명령 실행 중 | 환경 변수 주입 |
이러한 훅을 통해 플러그인은 코어 코드를 수정하지 않고 실행 흐름에 개입할 수 있습니다. 예를 들어, 감사 플러그인은 tool.execute.before를 통해 모든 도구 호출 입력을 로깅하거나, shell.env를 통해 셸 명령에 프로젝트별 환경 변수를 주입할 수 있습니다.
컨텍스트 압축 — 2계층 최적화
압축은 두 단계로 나뉘며, 각 단계에는 세분화된 보호 메커니즘이 있습니다:
1. 오버플로우 감지 (SessionCompaction.isOverflow()):
// overflow.ts
export function isOverflow(input) {
const reserved = input.cfg.compaction?.reserved ?? Math.min(20_000, maxOutputTokens(model))
const usable = input.model.limit.input
? input - reserved
: context - maxOutputTokens(model)
return count >= usable
}
input + output + cache.read + cache.write의 총 토큰 수를 계산합니다- 사용 가능 공간 =
model.limit.input - reserved(reserved 기본값은min(20000, maxOutputTokens)) - 토큰 사용량 >= 사용 가능 공간일 때 압축을 트리거합니다
2. 메시지 프루닝 (SessionCompaction.prune()) — 2계층 보호:
첫 번째 계층 — 최소 임계값:
PRUNE_MINIMUM = 20,000토큰. 이 값 미만에서는 프루닝이 트리거되지 않습니다- 짧은 대화에서 의미 없는 프루닝 작업을 방지합니다
두 번째 계층 — 보호 밴드:
PRUNE_PROTECT = 40,000토큰, 최근 도구 출력을 프루닝으로부터 보호합니다PRUNE_PROTECTED_TOOLS = ["skill"], 보호 대상으로 표시된 도구 출력은 절대 프루닝되지 않습니다- 최신 메시지에서 역방향으로 순회하며, 가장 최근 2라운드의 대화를 건너뜁니다
프루닝 실행:
- 임계값을 초과하는 도구 출력은
compacted로 표시됩니다 (state.time.compacted = Date.now()설정) - 후속
toModelMessages()호출에서 프루닝된 출력을[Old tool result content cleared]로 교체합니다 - 각 프롬프트 루프 종료 시 비동기적으로 실행됩니다 (
Effect.forkIn(scope)), 메인 루프를 차단하지 않습니다
3. 요약 생성 (SessionCompaction.process()):
compactionAgent를 사용합니다 (모든 도구 비활성화)- 전체 메시지 이력 + 요약 지침을 LLM에 전송합니다
- 생성된 요약 메시지는
summary: true로 표시되며, 후속filterCompacted()호출이 이를 프루닝 포인트로 사용합니다 - 자동 압축이 성공하면 대화를 재개하기 위해 “Continue” 메시지가 자동으로 추가됩니다
에러 처리 및 엣지 케이스
LLM 호출 재시도 전략
SessionRetry 모듈은 완전한 재시도 전략을 구현합니다:
재시도 가능한 에러 분류 (retryable()):
isRetryable === true인APIError: 429 속도 제한 및 500 서버 에러 포함ContextOverflowError: 재시도하지 않음, 압축을 직접 트리거합니다FreeUsageLimitError: 프롬프트 메시지를 반환하며, 재시도하지 않습니다- 응답 본문에
"exhausted"/"unavailable"/"rate_limit"가 포함된 에러
백오프 알고리즘 (delay()):
- 먼저 응답 헤더
retry-after-ms(밀리초) 또는retry-after(초/HTTP 날짜)를 읽습니다 - 헤더가 없으면 지수 백오프를 사용합니다:
2000ms * 2^(attempt-1), 상한 30초 - 헤더가 있으면 상한은
2^31 - 1(32비트 부호 있는 정수의 최대값)입니다
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000
SessionProcessor.process()의 catch 블록:
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
const delay = SessionRetry.delay(attempt, ...)
SessionStatus.set(sessionID, { type: "retry", attempt, message: retry, next: ... })
await SessionRetry.sleep(delay, input.abort)
continue // Return to top of while(true) to retry
}
도구 실행 에러 처리
도구 실행이 실패하면 (tool-error 이벤트), 핸들러는 에러 타입에 따라 다른 조치를 취합니다:
PermissionNext.RejectedError: 사용자가 권한 요청을 거부했습니다. 구성에 따라 루프를 중단할지 결정합니다Question.RejectedError: 사용자가 질문 확인을 거부했습니다. 마찬가지로 구성에 따라 결정합니다- 기타 에러: 에러 정보를 로깅하고, 도구 상태를
error로 설정한 후 후속 이벤트를 계속 처리합니다
루프 종료 후, pending 또는 running 상태의 모든 도구는 "Tool execution aborted" 에러로 표시됩니다.
메시지 되돌리기 — 완전한 메커니즘
SessionRevert 모듈은 Effect Service 패턴을 사용하여 세 가지 작업을 제공합니다. 되돌리기의 핵심은 파일 시스템 상태를 정확하게 복원하는 것입니다:
revert() 전체 흐름:
- 메시지와 Parts를 순회하여 사용자가 지정한 되돌리기 포인트를 찾습니다
- 되돌리기 포인트부터 시작하여 이후의 모든 Patch Parts(파일 편집 작업)를 수집합니다
- 현재 파일 시스템 스냅샷을 되돌리기 베이스라인으로 캡처합니다
- 역방향 Patch 작업을 적용합니다:
snap.revert(patches)가 모든 파일 수정을 역순으로 되돌립니다 - 되돌리기 메타데이터(messageID, partID, snapshot, diff)를 Session.revert 필드에 씁니다
// Simplified core logic
const revert = Effect.fn("SessionRevert.revert")(function* (input) {
// 1. 되돌리기 포인트 찾기
// 2. 이후 패치 수집
// 3. 현재 스냅샷 캡처
// 4. snap.revert(patches) — 모든 편집 역적용
// 5. session.revert 메타데이터 업데이트
})
revert(): 지정된 메시지/Part로 되돌리고, 스냅샷을 통해 파일 시스템 상태를 복원하며, diff를 계산합니다unrevert(): 되돌리기를 취소하고, 되돌리기 전 스냅샷으로 복원합니다cleanup(): 다음 대화 시작 시(prompt()호출 내) 되돌리기 상태를 정리하고, 되돌리기 포인트 이후의 모든 메시지를 삭제합니다
이 설계는 되돌리기가 가역적임을 보장합니다 — 되돌리기 전 상태로 정확하게 복원할 수 있습니다. 스냅샷과 diff가 매 단계에서 저장되기 때문입니다.
Provider 사용 불가 시 성능 저하
Provider 인증이 실패하면 MessageV2.fromError()가 AuthError를 생성합니다. SessionRetry.retryable()은 인증 에러에 대해 undefined를 반환하며(재시도 불가), 루프가 즉시 종료됩니다. 컨텍스트 오버플로우 에러(ContextOverflowError)도 재시도하지 않으며, loop()의 isOverflow()를 통해 감지되어 자동 압축을 트리거합니다.
정확한 비용 계산
Session의 비용 계산에는 정밀한 처리가 필요한 여러 세부 사항이 있습니다:
- 캐시 쓰기 토큰: anthropic, vertex, bedrock, venice 등 여러 소스에서 Provider별 필드를 추출합니다
- 캐시 토큰 중복 제거: AI SDK v6의
inputTokens에는 이미 캐시 토큰이 포함되어 있습니다. 이중 청구를 피하기 위해 차감해야 합니다 - 200K 이상 가격 책정:
experimentalOver200K필드를 사용하여 차등 가격 책정을 적용합니다 - 추론 토큰: 별도 가격 책정이 아닌 출력 가격으로 청구됩니다
- 정밀도 보장: JavaScript의 네이티브 부동소수점 에러를 피하기 위해
Decimal.js를 사용하여 정확한 부동소수점 계산을 수행합니다
도구 호출 복구 메커니즘
LLM이 반환하는 도구 호출 이름에 대소문자 에러가 있을 수 있습니다. experimental_repairToolCall이 자동 복구를 제공합니다:
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
// 대소문자 복구: 도구 이름을 소문자로 변환하여 매칭
// 여전히 매칭되지 않으면 -> "invalid" 도구로 라우팅
// "invalid" 도구는 친절한 에러 메시지를 반환합니다
}
이는 모델이 read_file 대신 Read_File을 출력하여 도구 호출이 실패하는 것을 방지합니다 — 시스템이 자동으로 대소문자 구분 없는 매칭을 시도하며, 실패하면 “invalid” 도구가 명확한 에러 메시지를 제공합니다.
상태 관리
InstanceState 캐시
Agent 목록은 Instance.state()를 통해 지연 로드 캐싱을 구현합니다:
const state = Instance.state(async () => {
const cfg = await Config.get()
// ... Agent 목록 구성 ...
return result as Record<string, Agent.Info>
})
export async function get(agent: string) {
return state().then((x) => x[agent])
}
Instance.state()는 첫 호출 시 팩토리 함수를 실행하고 결과를 캐시하는 비동기 함수를 반환합니다. 후속 호출은 캐시된 값을 직접 반환합니다. 캐시는 Instance가 파기될 때 자동으로 정리됩니다.
Session 상태 3상태
SessionStatus는 Effect Service를 통해 각 Session의 상태를 관리합니다:
export const Info = z.union([
z.object({ type: z.literal("idle") }),
z.object({ type: z.literal("busy") }),
z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number() }),
])
상태 변경은 Bus.publish(Event.Status, ...)를 통해 브로드캐스트되며, UI 레이어는 이러한 이벤트를 구독하여 인터페이스를 업데이트합니다.
취소 제어 및 정밀한 인터럽트
SessionPrompt.loop()는 start()를 통해 각 Session에 AbortController를 등록합니다:
function start(sessionID: string) {
const controller = new AbortController()
s[sessionID] = { abort: controller, callbacks: [] }
return controller.signal
}
cancel()은 controller.abort()를 호출하여 진행 중인 모든 LLM 호출과 도구 실행을 인터럽트합니다. 동일한 Session에 큐잉된 요청(callbacks)이 있으면 거부됩니다.
AbortController 정밀 인터럽트 포인트: LLM.Service의 stream 메서드는 Effect.acquireRelease를 통해 AbortController를 생성하며, 인터럽트 시 정밀한 리소스 정리를 수행합니다:
- 불완전한 스냅샷: 현재 파일 시스템 상태에 대한 패치를 계산하고, 완료된 편집을 저장합니다
- 불완전한 텍스트: 수신했지만 아직 쓰이지 않은 스트리밍 텍스트를 저장합니다
- 추론 Part: 불완전한 추론 콘텐츠를 정리합니다
- 도구 마커:
running/pending상태의 모든 도구를error("Tool execution aborted")로 표시합니다
이 세분화된 인터럽트 처리는 취소 작업이 불일치 상태를 남기지 않도록 보장합니다 — 불완전한 편집은 롤백되거나 저장되며, 도구 호출은 정상적으로 종료됩니다.
호출 체인 예제
체인 1: 사용자 메시지 전송 -> LLM 호출 -> 도구 실행 -> 응답 렌더링
1. SessionPrompt.prompt({ sessionID, parts: [{ type: "text", text: "read main.ts" }] })
|-- createUserMessage() -> write User message + TextPart to Storage
`-- loop(sessionID)
|-- start() -> register AbortController
|-- MessageV2.filterCompacted(stream) -> load uncompacted message history
|-- Agent.get("build") -> get build Agent configuration
|-- SessionProcessor.create() -> create empty Assistant message
|-- resolveSystemPrompt() -> [header, agentPrompt + environment + custom]
|-- resolveTools() -> register ReadTool, BashTool, EditTool and other built-in tools + MCP tools
`-- processor.process()
|-- LLM.stream() -> ai.streamText() initiates streaming request
|-- Streaming events:
| |-- text-delta -> write TextPart, Bus broadcast PartUpdated
| |-- tool-input-start -> create ToolPart (status: "pending")
| |-- tool-call -> update ToolPart (status: "running", input: { filePath: "main.ts" })
| |-- ReadTool.execute() -> read file contents
| `-- tool-result -> update ToolPart (status: "completed", output: file contents)
`-- return "continue" (finish reason is not tool-calls)
2. UI layer subscribes to MessageV2.Event.PartUpdated via Bus for real-time rendering
3. loop() next iteration detects Assistant is complete -> break -> return final message
체인 2: 컨텍스트 압축 트리거 -> 요약 생성 -> 메시지 프루닝
1. finish-step event in processor.process():
|-- Session.getUsage() -> calculate token usage: { input: 180000, output: 8000, ... }
|-- SessionCompaction.isOverflow() -> 180000 + 8000 >= 190000 -> true
`-- needsCompaction = true -> break interrupts streaming
2. process() returns "compact" -> loop() continues
3. loop() next iteration:
|-- Detects isOverflow() -> true
`-- SessionCompaction.create() -> write User message + CompactionPart
4. loop() iterates again:
|-- Detects pending compaction task
`-- SessionCompaction.process()
|-- Agent.get("compaction") -> get compaction-dedicated Agent
|-- Create Assistant message (mode: "compaction", summary: true)
|-- SessionProcessor.create()
`-- processor.process()
|-- Historical messages + summary instructions -> LLM generates structured summary
| (Goal / Instructions / Discoveries / Accomplished / Relevant files)
`-- return "continue"
5. Subsequent filterCompacted() uses the summary:true message as the pruning point
6. SessionCompaction.prune() -> clean up old tool outputs (preserving the most recent 40000 tokens)
설계 트레이드오프
왜 Agent는 클래스가 아닌 구성인가?
TypeScript 버전은 Agent를 Go 버전의 “인터페이스 + 구현” 패턴에서 순수 구성 객체로 변경했습니다. 이유:
- 코드 중복 제거: Go 버전에서 Coder Agent와 Task Agent는 실행 루프가 거의 동일했고, 도구 세트만 달랐습니다. TS 버전에서는 모든 Agent가
SessionPrompt.loop()->SessionProcessor.process()실행 경로를 공유합니다 - 선언적 구성: 권한은
PermissionNext.merge()를 통해 결합되고, 도구는ToolRegistry.enabled()를 통해 필터링되며, 모델/온도는 구성을 통해 오버라이드됩니다 — 서브클래싱이 필요 없습니다 - 런타임 확장성:
generate()메서드는 LLM을 통해 새로운 Agent를 동적으로 생성할 수 있으며, 사용자 구성에서 내장 Agent를disable하거나 커스텀 Agent를 추가할 수 있습니다
왜 Vercel AI SDK인가?
ai 패키지(Vercel AI SDK)는 Provider 간의 프로토콜 차이(Anthropic, OpenAI, Google 등)를 추상화하는 통합된 streamText() API를 제공합니다. OpenCode는 이를 기반으로 wrapLanguageModel() 미들웨어(ProviderTransform.message())를 통해 자체 메시지 변환 로직을 주입하고, ProviderTransform.providerOptions()를 통해 Provider별 매개변수 형식을 처리합니다.
왜 prompt.ts가 이렇게 큰가 (~580줄)?
prompt.ts는 Go 버전에서 agent.go, tools.go, session.go에 분산되어 있던 책임을 통합합니다:
- 사용자 메시지 구성(파일 읽기, Agent 참조 해결, 디렉토리 목록)
- 도구 해결 및 등록(내장 도구 + MCP 도구 + 권한 필터링)
- 서브태스크 직접 실행
- 명령(
/command) 템플릿 확장 - 셸 명령 실행
- 자동 제목 생성
다른 모듈과의 관계
- Provider (
src/provider/):LLM.stream()은Provider.getLanguage()를 통해 AI SDK 호환LanguageModel을 획득합니다;Provider.getModel()을 통해 모델 메타데이터(컨텍스트 윈도우, 가격 책정 등)를 획득합니다 - Config (
src/config/): Agent 목록의state()는Config.get()를 읽어 사용자 커스텀 Agent 구성과 권한 오버라이드를 가져옵니다 - Permission (
src/permission/):resolveTools()는PermissionNext.disabled()를 호출하여 비활성화된 도구를 필터링합니다; Doom Loop 감지는PermissionNext.ask()를 통해 사용자 확인을 요청합니다 - Storage (
src/storage/): 모든 메시지, Parts, Session은Storage.write/update/read를 통해 영속화됩니다 - Bus (
src/bus/): 메시지 업데이트, Part 업데이트, 상태 변경은 모두 Bus를 통해 브로드캐스트됩니다; UI 레이어는 이러한 이벤트를 구독하여 렌더링을 구동합니다 - Snapshot (
src/snapshot/):SessionProcessor는 각 단계(step-start/step-finish)에서 파일 시스템 스냅샷을 추적하여 diff 계산과 되돌리기를 지원합니다 - ToolRegistry (
src/tool/):resolveTools()는ToolRegistry.tools()에서 내장 도구 목록을 가져오고,ToolRegistry.enabled()를 통해 Agent의 도구 활성화 구성을 병합합니다 - MCP (
src/mcp/):resolveTools()는MCP.tools()에서 외부 도구를 가져와 AI SDKtool()형식으로 래핑합니다 - Plugin (
src/plugin/): 시스템 프롬프트 구성, 도구 실행 전후, 채팅 매개변수 등 여러 단계에서 훅 포인트를 제공합니다 - Instance (
src/project/):Instance.state()는 Agent 목록 캐시의 라이프사이클 관리를 제공합니다;Instance.directory/worktree는 작업 디렉토리 정보를 제공합니다