Codex · 도구 시스템 (ToolSpec 정의 형식과 모델 노출 / ToolRouter 디스패치)
한 줄 요약
Codex는 AI에게 “쓸 수 있는 도구 목록”을 JSON으로 만들어 건네고, AI가 “이 도구를 쓰겠다”고 답하면 그 호출을 실제 코드로 연결해 실행하는 중개소(ToolRouter)를 둔다. 왜 배우나: AI가 쉘 실행·파일 수정·웹검색 같은 “실제 행동”을 할 수 있는 이유가 전부 이 도구 시스템에서 시작되기 때문이다.
그림
flowchart TD A[턴 시작] --> B["도구 목록 새로 계산<br/>build_tool_router: 기능/모델/설정 평가"] B --> C["핸들러마다 명세·노출방식 수집"] C --> D{"노출 방식?"} D -- 바로 노출 Direct --> E[모델에 보이는 도구 목록] D -- 검색용 Deferred --> F[tool_search 색인에만 등록] E --> G[도구 목록을 JSON으로 변환해 모델에 전달] G --> H["모델: 이 도구를 이 인자로 쓰겠다 응답"] H --> I[응답을 ToolCall로 해석] I --> J[이름으로 핸들러 찾아 실행] J --> K[실행 결과를 다시 모델에 돌려줌] H -. tool_search 호출 .-> F F -. 검색 매칭된 도구 .-> G
쉽게 풀기
도구 시스템을 음식점의 메뉴판과 주문 시스템에 비유하면 쉽다.
-
도구 정의 = 메뉴 항목 하나 만들기. 메뉴 항목은 결국 세 가지로 끝난다 — 음식 이름, 음식 설명, 그리고 “주문서에 뭘 적어야 하는지”(예: 매운맛 단계, 양 선택). 도구도 똑같이 이름(name) + 설명(description) + 입력 형식(parameters, JSON 스키마) 세 가지로 정의된다.
-
모델 노출 = 손님에게 메뉴판 건네기. 주방은 만들 수 있는 모든 음식 중에서 “오늘 낼 수 있는 것”만 골라 메뉴판으로 인쇄한다. Codex도 매 턴마다 지금 모델·설정·환경에서 켤 수 있는 도구만 골라 JSON 목록으로 만들어 모델에게 건넨다.
-
실행 = 주문 받아 주방에 넘기기. 손님(모델)이 “1번 메뉴를 이렇게 주문할게요”라고 하면, 주문 시스템(ToolRouter)이 그 이름으로 담당 요리사(핸들러)를 찾아 실제 조리를 시키고, 완성된 음식(결과)을 손님에게 돌려준다.
여기에 영리한 장치가 하나 더 있다. 메뉴가 수백 개라면 메뉴판이 너무 두꺼워진다(=토큰 폭증). 그래서 Codex는 자주 안 쓰는 메뉴는 메뉴판에서 빼두고, 대신 “메뉴 검색대”(tool_search) 하나만 올려둔다. 손님이 “한식 매운 거 뭐 있어요?”라고 검색하면 그때서야 해당 메뉴들이 메뉴판에 추가되는 식이다. 이걸 deferred(지연) 도구라고 부른다.
핵심 정리
도구 하나의 최상위 형식은 ToolSpec이라는 enum이고, 어떤 종류인지에 따라 JSON의 "type" 값이 달라진다.
| 종류(variant) | JSON "type" | 쓰임새 |
|---|---|---|
Function | "function" | 가장 흔한 일반 도구 (shell, apply_patch 등) |
Namespace | "namespace" | 여러 function 도구를 한 묶음으로 (MCP 서버 등) |
ToolSearch | "tool_search" | 지연 도구를 검색해 꺼내오는 메타 도구 |
ImageGeneration | "image_generation" | 이미지 생성 |
WebSearch | "web_search" | 웹검색 |
Freeform | "custom" | 문법 기반 자유형식 도구 |
"function"한 개의 실제 필드 (ResponsesApiTool)
- name (필수): 모델이 호출할 때 쓰는 이름
- description (필수): 모델이 읽는 자연어 설명
- strict (필수): Structured Outputs strict 모드 여부 — 현재 대부분
false- parameters (필수): 입력 인자 JSON 스키마(
JsonSchema)- defer_loading (선택):
true면 초기 목록에서 빼고 tool_search로만 노출 /None이면 생략- output_schema (내부용):
#[serde(skip)]이라 모델에는 가지 않음
입력 형식을 적는
JsonSchema— OpenAI Structured Outputs의 서브셋핵심 키만:
"type"(string/number/object/array…),description,"enum"(허용 값),items(배열 요소),properties(객체 속성),required(필수 속성),"additionalProperties". 합성·참조용으로any_of/one_of/all_of,"$ref","$defs"도 지원. 생성 헬퍼:JsonSchema::string/number/boolean/integer/array/object/string_enum(...). 외부(MCP)에서 들어온 스키마는parse_tool_input_schema()가 정리→가지치기→압축(약 1k 토큰/4000바이트 예산 초과 시 손실 압축)한다.
노출 방식(exposure) 4가지 — 핸들러가 자기 도구를 모델에 어떻게 보일지 정한다.
-
Direct— 처음부터 도구 목록에 보임 (기본값) -
DirectModelOnly— 모델에만 직접 노출 -
Deferred— 목록에서 빼고 tool_search로만 검색 노출 (이때search_info필요) -
Hidden— 목록에 안 보이고 디스패치(내부 호출)만 가능
모델 변환 전 중립 메타데이터 (
ToolDefinition)다운스트림(특히 MCP 도구)은
ToolSpec으로 바뀌기 전ToolDefinition(name·description·input_schema·output_schema·defer_loading)을 거친다.tool_definition_to_responses_api_tool()이 이걸ResponsesApiTool로 변환한다(strict: false)..into_deferred()를 붙이면 output_schema를 비우고 지연 로딩으로 바꾼다.
실제 예시
먼저 실제로 모델에게 나가는 JSON 모양이다(테스트가 보장).
// /home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_spec_tests.rs
// ToolSpec::Function → create_tools_json_for_responses_api 결과
{
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": { "foo": { "type": "string" } }
}
}
// ToolSpec::Namespace
{
"type": "namespace",
"name": "mcp__demo__",
"description": "Demo tools",
"tools": [ { "type": "function", "name": "lookup_order", "description": "Look up an order",
"strict": false, "parameters": { "type": "object",
"properties": { "order_id": { "type": "string" } } } } ]
}
// ToolSpec::ToolSearch
{
"type": "tool_search",
"execution": "sync",
"description": "Search app tools",
"parameters": { "type": "object", "properties": {
"query": { "type": "string", "description": "Tool search query" } },
"required": ["query"], "additionalProperties": false }
}실제 빌트인 도구 정의 예 — shell_command (복붙용 패턴).
// /home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/shell_spec.rs
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description: "Runs a shell command and returns its output.\n- Always set the `workdir` ...".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::from([
("command".to_string(), JsonSchema::string(Some("Shell script to run ...".to_string()))),
("workdir".to_string(), JsonSchema::string(Some("Working directory ...".to_string()))),
("timeout_ms".to_string(), JsonSchema::number(Some("Maximum command runtime ...".to_string()))),
]),
Some(vec!["command".to_string()]), // required
Some(false.into()), // additionalProperties: false
),
output_schema: None,
})새 빌트인 도구 echo_tool을 직접 만든다면 (스펙 + 핸들러 + 등록).
// 1) 스펙: ToolSpec::Function 으로 name + JSON 스키마 정의
use codex_tools::{JsonSchema, ResponsesApiTool, ToolSpec};
use std::collections::BTreeMap;
pub fn create_echo_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "echo_tool".to_string(),
description: "Echoes the given text back.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::from([(
"text".to_string(),
JsonSchema::string(Some("Text to echo.".to_string())),
)]),
Some(vec!["text".to_string()]), // required
Some(false.into()), // additionalProperties: false
),
output_schema: None,
})
}
// 2) 핸들러: ToolExecutor + CoreToolRuntime 구현 (plan.rs 패턴 참고)
pub struct EchoHandler;
impl ToolExecutor<ToolInvocation> for EchoHandler {
fn tool_name(&self) -> ToolName { ToolName::plain("echo_tool") }
fn spec(&self) -> ToolSpec { create_echo_tool() }
// exposure() 기본값 = Direct → 초기 tool 목록에 노출됨
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel("bad payload".into()));
};
// arguments(JSON 문자열) 파싱 후 처리 → Box<dyn ToolOutput> 반환
todo!()
})
}
}
impl CoreToolRuntime for EchoHandler {}
// 3) 등록: spec_plan.rs 의 add_core_utility_tools 등에서
// planned_tools.add(EchoHandler); // (조건부면 feature 체크 후)만들 때 체크리스트
ToolSpec::Function에name/description/parameters(JsonSchema) 세 가지를 채웠다.required와additionalProperties:false를 명시했다(strict 미사용이어도 권장).- 핸들러가
tool_name()(=spec의 name과 동일!)·spec()·handle()을 구현했고CoreToolRuntime을 단다.exposure(): 항상 노출=Direct, 검색으로만=Deferred(이때search_info필요), 디스패치만=Hidden.- 동시 실행 허용이면
supports_parallel_tool_calls()를 true로(부작용 없는 read-only 도구만).spec_plan.rs의add_*함수에서planned_tools.add(...)로 등록(feature/모델 조건 포함).- 이름 충돌 주의: registry는 중복 이름 등록 시 panic/에러.
요약 & 셀프체크
3줄 요약:
- 도구 하나는 이름 + 설명 + 입력 스키마로 정의되고,
ToolSpecenum의 종류에 따라 JSON"type"이 달라진다. - 매 턴마다
build_tool_router()가 켤 도구를 새로 골라 JSON으로 모델에 건네고, 모델이 호출하면ToolRouter가 이름으로 핸들러를 찾아 실행 후 결과를 되돌린다. - 도구가 너무 많으면
Deferred로 빼두고tool_search(BM25 검색) 한 개만 노출해 토큰 폭증 없이 온디맨드로 꺼낸다.
스스로 답해보기:
- 도구를 정의하는 세 가지 핵심 요소는 무엇인가? (이름이 핸들러와 일치해야 하는 이유는?)
Direct와Deferred노출의 차이는 무엇이며, 수백 개 MCP 도구에Deferred를 쓰는 이유는?- 모델이 응답한 function-call이 실제 코드로 연결되기까지 거치는 단계(파싱→디스패치→실행→반환)를 말로 설명할 수 있는가?
더 깊이 — 언제 무엇이 켜지고, 어떻게 흘러가나
언제: 매 턴마다.
ToolRouter::from_turn_context()→build_tool_router()가 모델 정보(ModelInfo)·기능 플래그(Features)·설정(Config)·MCP/확장/동적 도구를 보고 목록을 새로 계산한다. 어떤 빌트인이 켜지나: shell 계열은shell_type_for_model_and_features()가UnifiedExec(=exec_command+write_stdin) /shell_command/Disabled중 결정. apply_patch는 환경+model_info.apply_patch_tool_type.is_some()일 때. update_plan/view_image/get_context_remaining/request_permissions 등은 환경·feature 플래그로 on/off. web_search/image_generation은hosted_model_tool_specs()에서 provider capability·auth·feature로 결정(use_responses_lite모델은 호스티드 도구 미전송). MCP/확장/동적 도구는 런타임 목록을 그대로 등록. 노출 단계: 각 핸들러의spec()·exposure()를 모아build_model_visible_specs_and_registry()가 Direct인 것만Vec<ToolSpec>으로 만들고, 같은 네임스페이스 function을merge_into_namespaces()로 묶는다. 상위에서create_tools_json_for_responses_api()로 직렬화되어 요청의tools필드가 된다. 실행 단계: 모델 응답의ResponseItem을ToolRouter::build_tool_call()이ToolCall로 파싱(FunctionCall→Function, ToolSearchCall→ToolSearch, CustomToolCall→Custom).ToolRegistry::dispatch_any_with_terminal_outcome()가 이름으로 핸들러를 찾아 PreToolUse 훅 →handle()→ PostToolUse 훅 → 텔레메트리 처리 후FunctionCallOutput으로 반환. 미등록 이름은 “unsupported call: …” 에러 응답. 병렬 실행:ToolRouterRuntime(parallel.rs)이 각 호출을tokio::spawn하되supports_parallel_tool_calls()가 true일 때만 동시 실행. false면 직렬화. tool_search 동작:Deferred도구의search_info()(BM25 메타데이터)를 모아tool_search한 개를 노출. 모델이tool_search(query, limit)를 부르면 BM25(bm25크레이트) 검색→coalesce_loadable_tool_specs로 묶어 반환→다음 모델 호출의 tool 목록에 동적 추가.
기존 Codex 교차검증 메모(보존)
근거 파일:
/home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_spec.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_definition.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/responses_api.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/json_schema.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_config.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_executor.rs/home/seunghyeong/harness-work/codex/codex-rs/tools/src/tool_spec_tests.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/registry.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/router.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/spec_plan.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/parallel.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/shell_spec.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/plan.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/plan_spec.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/tool_search.rs/home/seunghyeong/harness-work/codex/codex-rs/core/src/tools/handlers/tool_search_spec.rs