컨텐츠로 건너뛰기

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~230Agent 구성 정의: Agent.Info 스키마, 내장 Agent 목록, state() 팩토리, generate() 동적 생성
src/session/index.ts~400Session 관리: CRUD, 메시지 영속화, 비용 계산, 페이지네이션 쿼리
src/session/prompt.ts~580핵심 진입점: loop() 메인 루프, 사용자 메시지 구성, 도구 해결, 서브태스크 디스패치
src/session/llm.ts~200LLM 호출: 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 이름modehidden목적
buildprimary아니오전체 도구 권한을 가진 기본 Agent, 질문 및 plan_enter 지원
planprimary아니오계획 모드, 모든 편집 도구를 비활성화하고 plans 디렉토리에만 쓰기 허용
exploresubagent아니오읽기 전용 도구만 사용하는 빠른 코드 탐색(grep, glob, read, bash, webfetch 등)
generalsubagent아니오병렬 다단계 작업 실행을 위한 범용 서브 Agent, todo 도구 비활성화
compactionprimary컨텍스트 압축 전용, 모든 도구 비활성화, 대화 요약 생성
titleprimarySession 제목 생성, 모든 도구 비활성화, 온도 0.5로 고정
summaryprimarySession 요약 생성, 모든 도구 비활성화

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를 등록합니다. 주요 루프 로직:

  1. 메시지 스트림 읽기: MessageV2.filterCompacted(MessageV2.stream(sessionID))가 메시지를 역순으로 순회하며 압축된 이력을 건너뜁니다
  2. 루프 종료 조건 확인: 마지막 Assistant 메시지의 finish"tool-calls" 또는 "unknown"이 아니고, 그 ID가 마지막 User 메시지 ID보다 크면 루프를 종료합니다
  3. 대기 중인 작업 우선 처리: 마지막 메시지의 Parts에 compaction 또는 subtask 타입의 Part가 포함되어 있는지 확인합니다
  4. 컨텍스트 오버플로우 감지: SessionCompaction.isOverflow()를 통해 압축 필요 여부를 확인합니다
  5. 일반 처리: 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()의 래퍼로 다음을 담당합니다:

  1. 언어 모델 획득: Provider.getLanguage(model)이 AI SDK 호환 LanguageModel을 반환합니다
  2. 시스템 프롬프트 조립: Agent 프롬프트, Provider 기본 프롬프트, 사용자 커스텀 시스템 프롬프트를 우선순위에 따라 병합합니다
  3. 매개변수 병합 파이프라인: base -> model.options -> agent.options -> variant, 각 레이어가 이전 것을 오버라이드합니다
  4. 도구 해결: resolveTools()가 사용자의 비활성화 목록과 권한 규칙에 따라 도구를 필터링합니다
  5. 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.paramsLLM 매개변수 전달 전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()):

  • compaction Agent를 사용합니다 (모든 도구 비활성화)
  • 전체 메시지 이력 + 요약 지침을 LLM에 전송합니다
  • 생성된 요약 메시지는 summary: true로 표시되며, 후속 filterCompacted() 호출이 이를 프루닝 포인트로 사용합니다
  • 자동 압축이 성공하면 대화를 재개하기 위해 “Continue” 메시지가 자동으로 추가됩니다

에러 처리 및 엣지 케이스

LLM 호출 재시도 전략

SessionRetry 모듈은 완전한 재시도 전략을 구현합니다:

재시도 가능한 에러 분류 (retryable()):

  • isRetryable === trueAPIError: 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() 전체 흐름:

  1. 메시지와 Parts를 순회하여 사용자가 지정한 되돌리기 포인트를 찾습니다
  2. 되돌리기 포인트부터 시작하여 이후의 모든 Patch Parts(파일 편집 작업)를 수집합니다
  3. 현재 파일 시스템 스냅샷을 되돌리기 베이스라인으로 캡처합니다
  4. 역방향 Patch 작업을 적용합니다: snap.revert(patches)가 모든 파일 수정을 역순으로 되돌립니다
  5. 되돌리기 메타데이터(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를 생성하며, 인터럽트 시 정밀한 리소스 정리를 수행합니다:

  1. 불완전한 스냅샷: 현재 파일 시스템 상태에 대한 패치를 계산하고, 완료된 편집을 저장합니다
  2. 불완전한 텍스트: 수신했지만 아직 쓰이지 않은 스트리밍 텍스트를 저장합니다
  3. 추론 Part: 불완전한 추론 콘텐츠를 정리합니다
  4. 도구 마커: 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 버전의 “인터페이스 + 구현” 패턴에서 순수 구성 객체로 변경했습니다. 이유:

  1. 코드 중복 제거: Go 버전에서 Coder Agent와 Task Agent는 실행 루프가 거의 동일했고, 도구 세트만 달랐습니다. TS 버전에서는 모든 Agent가 SessionPrompt.loop() -> SessionProcessor.process() 실행 경로를 공유합니다
  2. 선언적 구성: 권한은 PermissionNext.merge()를 통해 결합되고, 도구는 ToolRegistry.enabled()를 통해 필터링되며, 모델/온도는 구성을 통해 오버라이드됩니다 — 서브클래싱이 필요 없습니다
  3. 런타임 확장성: 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 SDK tool() 형식으로 래핑합니다
  • Plugin (src/plugin/): 시스템 프롬프트 구성, 도구 실행 전후, 채팅 매개변수 등 여러 단계에서 훅 포인트를 제공합니다
  • Instance (src/project/): Instance.state()는 Agent 목록 캐시의 라이프사이클 관리를 제공합니다; Instance.directory/worktree는 작업 디렉토리 정보를 제공합니다