공부

[공부] Opencode Tool-call Engine 리버스 엔지니어링

imnewon 2026. 5. 22. 13:21

AI Agent의 핵심 중 하나는 tool-call입니다. 저는 현재 SLM을 end-point로 바꿔도 잘 돌아가는 local agent를 만드는 것을 목표로 연구하고 있으며, 오늘은 tool-call에 대해 정리해보려 합니다. 

 

저는 조립은 분해의 역순이라는 말을 정말 좋아합니다. tool call engine을 공부하기 위해, 실제 agent인 opencode의 tool call과 관련된 부분을 분석한 후 이것을 재조립해볼 생각입니다. 그리고 이 글은 '분해'에 해당합니다. 여러분들도 함께 opencode를 뜯어보시기를 바라겠습니다. 중간에 뭔가 잘못됐음을 깨달아 opencode랑 llm 디렉토리의 구분이 없습니다.

 

https://github.com/anomalyco/opencode

 

GitHub - anomalyco/opencode: The open source coding agent.

The open source coding agent. Contribute to anomalyco/opencode development by creating an account on GitHub.

github.com

 

opencode의 대략적인 흐름은 다음과 같습니다.

[진입점]
packages/opencode/src/index.ts  (yargs CLI)
         ↓
packages/opencode/src/cli/cmd/run.ts  (RunCommand)
         ↓
cli/cmd/run/runtime.ts  →  runPromptQueue()  (직렬 큐)
         ↓
SDK client: session.prompt()
         ↓
[서버]
server/server.ts  →  HTTP API 라우터
         ↓
[세션]
session/prompt.ts  →  prompt()
         ↓
         loop()
         ↓
         runLoop()  ← 여기가 메인 while(true) 루프

 

그리고 아래는 tool-call의 흐름입니다. AI SDK가 streamText()로 스트림 받으면서 tool call을 감지하고, resolveTools()에서 wrapping한 execute()가 호출되는 구조입니다. 루프는 툴 결과를 히스토리에 누적해서 계속 LLM을 재호출하는 방식으로 돌아갑니다.

사용자 입력 (텍스트)
    ↓
[1] Session.prompt()           ← 세션에 메시지 등록
    ↓
[2] resolveTools()             ← 사용 가능한 도구 목록 수집
    ↓
[3] LLM.stream()               ← AI에 요청 전송 (HTTP)
    ↓
[4] ToolRuntime.stream()       ← 응답 스트리밍 + 도구 호출 감지
    ↓
[5] dispatch(tool, input)      ← 도구 실행
    ↓
[6] 결과 주입 → [3]으로 루프   ← 결과를 다음 AI 요청에 포함

 

이제 저희는 여기서 엔드포인트를 떼서 SLM에 갖다붙였을 때 동작하게 만들면 됩니다. 진입점과 서버, 기타 wrapping에 대해 전부 분석할 경우 머리가 터질테니 생략하고 tool call만 보겠습니다. 핵심은 tool call이 어떻게 작동하는가 입니다.

 

그 전에 구조부터 정리하고 가겠습니다. 현재 src를 하위에 두는 주요 패키지는 크게 두 개입니다. packages/llm packages/opencode

 

packages/llm은 AI SDK를 대체하기 위해 직접 구현 중인 자체 LLM 클라이언트 라이브러리입니다. Provider별 HTTP 프로토콜 처리, 스트리밍, tool execution 등을 직접 구현하고 있으며, 전체 구조는 Effect 기반으로 타입 안전하게 설계되어 있습니다. 또한 이 패키지는 앱 내부 전용 코드가 아니라, 외부에서도 @opencode-ai/llm 형태로 import해서 독립적으로 사용할 수 있도록 구성되어 있습니다.


반면 packages/opencode는 실제 애플리케이션 본체입니다. 현재 opencode는 아직 AI SDK 의 streamText()를 통해 LLM을 호출하고 있습니다. 여기서 중요한 포인트가 하나 있습니다. streamText()가 호출되는 순간부터 제어권이 AI SDK 내부로 넘어가기 때문에, 이후 데이터가 어떤 경로로 흐르고 어떤 형태로 변환되는지를 외부에서 정확히 추적하기가 어렵습니다.


따라서 이번 분석에서는 step 1~3까지는 packages/opencode, step 4~5부터는 packages/llm 기준으로 흐름을 따라갈 예정입니다.

 

왜 분리되어 있냐?  packages/llm이 완성되면 packages/opencode의 AI SDK 의존성을 @opencode-ai/llm으로 교체할 계획인 것 같습니다. 지금은 마이그레이션 진행 중이라 둘이 공존하는 상태입니다.

 

STEP 1. 입력 처리(src/session/prompt.ts)

prompt.ts 코드는 opencode 프로젝트에서 AI 세션의 프롬프트 제어, Tool Call, Subtask 분기 등 핵심 오케스트레이션(Orchestration)을 담당하는 중추적인 백엔드 로직입니다. 사용자가 명령하면(코드가 PromptInput을 받으면) 아래 흐름으로 함수들이 호출됩니다. 

export async function prompt(input: PromptInput) { 
  const userMessage = createUserMessage(input.text, input.attachments) 
  const tools = await resolveTools(input)
  await SessionProcessor.run(userMessage, tools)
}

 

 

createUserMessage는 유저가 보낸 parts(텍스트, 파일, 에이전트 멘션 등)를 실제 메시지로 변환합니다.  MessageV2.User타입의 구조체를 만듭니다. 그냥 User의 Input을 받는 구조가 있군, 생각해두고 넘어갑니다. 이걸 어떻게 하지? 싶은 부분들은 대부분 TypeScript의 Effect 라이브러리가 해결해줍니다.

 

* Effect 라이브러리? 타입 안전성과 에러 핸들링을 함수형 프로그래밍 스타일로 엮어주는 라이브러리

 

STEP 2. ToolRegistry(src/tool/registry.ts)

registry.ts에서는 AI가 사용할 수 있는 도구(tool)들을 모읍니다. 시스템 내부 registry에 등록된 툴들과, 외부 MCP 툴들을 파싱하고 현재 AI 모델이 이해할 수 있는 jsonSchema로 변환합니다. 이후 모델 종류와 설정(flag)에 따라 어떤 툴을 실제로 사용할 수 있을지 결정해 최종 Tool Registry를 구성합니다. (아래는 마찬가지로 요약 코드로, 실제와는 다릅니다.)

 

* MCP(Model Context Protocol): 대규모 언어 모델(LLM)이 데이터베이스, 파일 시스템, 외부 API 등 다양한 외부 도구와 안전하게 연결될 수 있도록 돕는 AI-외부 시스템 연결 표준 프로토콜. 슬랙이나 노션 같은 거
* Schema: AI 모델이 외부 도구(Tools), 데이터베이스, API와 상호작용하기 위한 '사용 설명서'이자 '데이터 규격'. ChatGPT API 생각해보면, 특정 틀에 맞춰 넣지 않으면 말귀를 못 알아듣습니다. 그 '특정 틀'이 schema에 해당합니다.

async function resolveTools(input) {
  const tools = {}

  // 1. 내장 도구 등록
  tools["read"]   = ReadTool      // 파일 읽기
  tools["write"]  = WriteTool     // 파일 쓰기
  tools["shell"]  = ShellTool     // 쉘 명령 실행
  tools["glob"]   = GlobTool      // 파일 패턴 검색
  tools["grep"]   = GrepTool      // 텍스트 검색

  // 2. 플러그인 도구 추가
  for (const plugin of loadedPlugins) {
    Object.assign(tools, plugin.tools)
  }

  // 3. MCP 도구 추가 (외부 서버 도구)
  const mcpTools = await loadMCPTools()
  Object.assign(tools, mcpTools)

  return tools
}

 

tool이 생긴 모습은 src/tool.ts에서 볼 수 있습니다. 대충 아래처럼 생겼습니다. description이 매우 중요합니다. AI가 이 description을 읽고 "어떤 도구를 언제 쓸지"를 스스로 결정합니다.

interface Tool<T, S> {
  description: string          // AI에게 보여주는 설명
  parameters: Schema<T>        // 입력 타입 정의 (JSON Schema)
  success: Schema<S>           // 출력 타입 정의
  execute?: (params: T) => Promise<S>  // 실제 실행 함수
}

 

STEP 3. LLM 요청 전송(src/session/llm.ts)

위는 선언과 정리를 하는 부분이고, 이제 슬슬 세션이 시작됩니다. 마찬가지로, 아래는 요약 코드입니다.

async function run(input: StreamInput) {
  const result = await streamText({
    model: languageModel,          // Anthropic / OpenAI / Gemini 등
    system: systemPrompt,          // AI의 역할 설명
    messages: conversationHistory, // 이전 대화 전체
    tools: toolDefinitions,        // 사용 가능한 도구 목록
    toolChoice: "auto",            // AI가 알아서 도구 선택
    maxTokens: 8192,
  })
  
  return result.stream  // 스트림 반환 (토큰 하나씩 오는 것)
}

우리는 AI의 응답을 한 번에 받는 것이 아니라, 토큰(단어 조각) 단위로 실시간으로 받습니다. 이게 스트리밍입니다. 터미널에서 AI가 타이핑하듯 출력되는 이유입니다.

 

opencode는 Anthropic, OpenAI, Google 등 다양한 provider를 지원합니다. 각 provider마다 API 형식이 다르기 때문에, 프로토콜 계층으로 추상화합니다 src/route/client.ts에 있습니다.

LLMClient.stream()
    ↓
Route 선택 (모델 이름으로 매핑)
    ↓
Protocol.body.from() → 제공자별 JSON 포맷 변환
    ↓
HTTP 요청 전송
    ↓
Protocol.stream.event() → 제공자별 응답을 통일된 LLMEvent로 변환

STEP 4. Tool-Call 감지 (src/tool-runtime.ts)

여기가 핵심입니다. AI의 응답 스트림을 읽으면서 도구 호출 명령을 감지합니다. AI는 일반 텍스트 응답 대신 아래와 같은 구조화된 메시지를 반환합니다. 이게 tool call입니다.

{
  "type": "tool_use",
  "id": "call_abc123",
  "name": "read",
  "input": {
    "path": "/Users/kywn/opencode/README.md"
  }
}

 

tool-runtime.ts는 아래처럼 굴러갑니다.

async function* stream(llmStream, tools, stopWhen) {
  while (true) {
    const state = await accumulate(llmStream)  // 이벤트 수집
    
    yield state.events  // 텍스트, 추론 등 중간 이벤트 출력
    
    if (state.finishReason === "tool_calls") {
      // 도구 호출이 있으면 병렬 실행
      const results = await Promise.all(
        state.toolCalls.map(call => dispatch(tools, call))
      )
      
      // 결과를 다음 AI 요청에 포함
      llmStream = followUpRequest(state, results)
      
    } else {
      break  // 텍스트 응답이면 루프 종료
    }
    
    if (stopWhen(state)) break
  }
}

 

accumulate()는 아래와 같은 이벤트들을 실행합니다.

"text-delta"        → 텍스트 조각 (화면에 실시간 출력)
"reasoning-delta"   → 추론 과정 (Claude extended thinking)
"tool-call"         → 도구 호출 요청 ← 오늘의 핵심
"finish-step"       → 이 단계 완료
"finish"            → 전체 완료

STEP 5. Tool 실행 (src/tool-runtime.ts)

아무래도 누가 불렀으면 대답을 해야겠죠... accmulate에서 tool-call이 감지되면 dispatch() 함수가 실행합니다. 이건 짧아서 진짜 코드 넣었습니다.

const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
  const tool = tools[call.name]
  if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
  // tool이 아닌데 실행되면 곤란합니다.
  if (!tool.execute)
    return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
    // tool이 빈손으로 나오면 아무래도 곤란하죠
  return decodeAndExecute(tool, call.input).pipe(
  // 실행합니다.
    Effect.catchTag("LLM.ToolFailure", (failure) =>
      Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
    ),
  )
}
const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResultValue, ToolFailure> =>
  tool._decode(input).pipe(
    Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
    // schema와 input이 다르면 곤란합니다.
    Effect.flatMap((decoded) => tool.execute!(decoded)),
    // 아니면 실행이 됩니다.
    Effect.flatMap((value) =>
      tool._encode(value).pipe(
      // 출력 validation 검증하는 부분입니다.
        Effect.mapError(
          (error) =>
            new ToolFailure({
              message: `Tool returned an invalid value for its success schema: ${error.message}`,
            }),
        ),
      ),
    ),
    Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
  )

오류 잡는 부분에 특별한 건 없고, 출력 validation도 검증하는 게 인상적입니다. 프롬프트 엔지니어링과 harness의 본질적인 차이는 '본인의 오류를 본인이 잡아낼 수 있는가'의 여부라는 말을 들었는데 이런 부분을 말씀하신 것 같습니다. (이걸 차치하고서라도 schema 맞추고 validation하는 게 tool call의 핵심입니다.) 여러모로 편리하지만 짜야할 사람 입장에서는 고려할 게 많다고 볼 수 있습니다. 특히 더 문제가 많은 SLM은 여러 번 확인하는 게 필수적입니다. Effect가 다 해주는 것 같긴 합니다.

STEP 5. 루프 실행 (src/tool-runtime.ts)

tool-runtime.ts을 잘 살펴보시면 재귀인 걸 알 수 있습니다. (아무래도 루프니까요) 루프에 갇혀서 조건이 충족되기 전까지 계속 tool call을 한다고 보시면 되겠습니다.

const loop = (request: LLMRequest, step: number): Stream.Stream<LLMEvent, LLMError> =>
    Stream.unwrap(
      Effect.gen(function* () {
        const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined }

        const modelStream = options
          .stream(request)
          .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event))))

        const continuation = Stream.unwrap(
          Effect.gen(function* () {
            if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty
            .
            .
            .
            return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1)))
          	// loop 안에 loop
          }),
          
        )

        return modelStream.pipe(Stream.concat(continuation))
      }),
    )
.
.
.
const followUpRequest = (
  request: LLMRequest,
  state: StepState,
  dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValue]>,
) =>
  LLMRequest.update(request, {
    messages: [
      ...request.messages,
      Message.assistant(state.assistantContent),
      ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })),
    ],
  })

 

architecture적으로 보면 아래 같은 감성입니다.

LLM stream
   ↓
tool-call 감지
   ↓
dispatch
   ↓
tool-result 생성
   ↓
followUpRequest
   ↓
다시 LLM 호출 (재귀)

 

권한이나 보안 시스템은 스킵하겠습니다.

 

STEP 6.  SLM

tool call의 주된 루프는 이 정도고, 코드 내에 흥미로운 부분이 있습니다. opencode 내부에 SLM 맞춤 코드가 존재합니다. src/session/llm.ts(line 134) small 플래그가 있으면 경량 설정을 적용한다든가,

const base = input.small
        ? ProviderTransform.smallOptions(input.model)
        : ProviderTransform.options({
            model: input.model,
            sessionID: input.sessionID,
            providerOptions: item.options,
          })

 

src/provider/transform.ts(line 1186) SLM에서는 store: false로 response 저장을 안 하고, resoningEffort를 낮추거나 없애죠. gemini는 thinkingConfig라고 돼있는데 그냥 resoning과 같다 봐도 무방합니다. provider마다 이름이 다 다르지만 골자는 같습니다.

export function smallOptions(model: Provider.Model) {
  if (
    model.providerID === "openai" ||
    model.api.npm === "@ai-sdk/openai" ||
    model.api.npm === "@ai-sdk/github-copilot"
  ) {
    if (model.api.id.includes("gpt-5")) {
      if (model.api.id.includes("-chat")) {
        if (gpt5Version(model.api.id) === undefined) return { store: false }
        return { store: false, reasoningEffort: "medium" }
      }
      if (model.api.id.includes("search-api")) return { store: false }
      if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
        return { store: false, reasoningEffort: "low" }
      }
      return { store: false, reasoningEffort: "minimal" }
    }
    return { store: false }
  }
  if (model.providerID === "google") {
    // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
    return { thinkingConfig: googleSmallThinkingConfig(model.api.id) }
  }
  if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
    if (model.api.id.includes("google")) {
      return { reasoning: { enabled: false } }
    }
    return { reasoningEffort: "minimal" }
  }

  if (model.providerID === "venice") {
    return { veniceParameters: { disableThinking: true } }
  }

  return {}
}

 

지금까지 Opencode의 코드를 통해 데이터가 흐르는 방향과 Tool-call이 움직이는 루프를 분해해 봤습니다. 굉장히 복잡하다고 생각했는데, 굉장히 복잡하다는 생각이 듭니다. 모든 코드에 대해 완전히 이해한 것은 아니지만 대략적인 흐름과 매커니즘을 이해한 데에 의의를 두기로 합니다. 틀린 내용이 있다면 지적해주세요.

 

대형 모델 대신 로컬 SLM을 엔드포인트로 쓸 때는 모델 체급이 작아지는 만큼 코드가 더 똑똑해져야 합니다. 툴 스키마 매칭 상태를 칼같이 감시하고, 컨텍스트 용량을 아끼기 위해 불필요한 Reasoning 설정을 깎아내는 처절한 최적화가 필요합니다. 뼈대를 완벽히 분해했으니, 다음 [조립편]에는(글을 쓸 수 있을지 모르겠으나,) 로컬 환경에서도 가볍고 기민하게 돌아가는 우리만의 'Local SLM Tool-Call Engine'을 직접 구현해 보겠습니다.