Code Mode는 도구를 하나씩 호출하는 대신 exec 도구 하나에 짧은 자바스크립트(JS)를 써서 여러 도구를 한 번에 엮어 실행하는 Codex 고유 기능이다. 모델↔서버 왕복 횟수를 줄여 속도와 토큰(비용)을 아끼는 차별화 설계다.
그림
flowchart TD
M["모델: exec 도구에 JS 셀 작성"] --> H[CodeModeExecuteHandler 접수]
H --> P["parse_exec_source: 첫 줄 옵션 분리"]
P --> S["CodeModeService.execute: 새 V8 격리공간 생성"]
S --> JS["V8 안에 tools/store/text 등 도구 설치"]
JS -->|await tools.X 호출| CB["tool_callback → 도구호출 이벤트 발생"]
CB --> D[CodeModeSessionDelegate.invoke_tool]
D --> BR["DispatchBroker → 실제 도구 라우터로 전달"]
BR --> R["(실제 도구 / MCP / exec_command 실행)"]
R -->|결과 JSON| RC[JS의 Promise를 결과로 채움]
JS -->|끝 또는 시간초과| RR["RuntimeResponse: Result / Yielded / Terminated"]
RR --> HR["handle_runtime_response: 헤더+절단+이미지 정리"]
HR --> M2[모델에게 최종 결과만 반환]
RR -.시간초과(Yielded).-> W[모델이 wait 도구로 이어받기]
W --> S
쉽게 풀기
보통 방식(느린 이유) — 식당에서 “물 주세요”→받고→“메뉴 주세요”→받고처럼 하나씩 부탁하고 매번 답을 기다린다. 도구 5개면 모델↔서버를 5번 오간다. 왕복이 많을수록 느리고 토큰도 많이 쓴다.
Code Mode(빠른 이유) — 주문서 한 장에 “물·메뉴·주문을 한 번에”라고 적어 건넨다. 모델에게는 도구 목록 대신 exec 단 하나만 준다. exec는 “JS 코드를 써라”는 도구다.
flowchart LR
subgraph 일반["일반 방식: 5왕복"]
A1[도구1] --> A2[도구2] --> A3[도구3] --> A4[도구4] --> A5[도구5]
end
subgraph CM["Code Mode: 1왕복"]
B[exec 셀 한 장에 도구 5개 엮기] --> B2[최종 결과만 반환]
end
용어 3개만 잡으면 된다.
셀(Cell) = 모델이 한 번에 쓰는 짧은 JS 한 덩어리. 곧 “주문서 한 장”.
V8 엔진 = Codex 안의 작은 JS 실행기(브라우저용 그 엔진). 주문서를 처리하는 주방.
코드 안에서 도구는 await tools.exec_command(...)처럼 평범한 함수로 불린다. 모델은 도구 10개를 엮는 작은 프로그램을 한 번에 짜고, Codex는 돌려서 최종 결과만 돌려준다.
오래 걸리면 — yield/wait — 오래 걸리는 요리면 exec가 “지금까지 나온 것”을 먼저 내놓고(yield) 셀 번호(cell_id)를 준다. 모델은 나중에 wait로 이어받는다. 진동벨을 받아 두고 기다리는 것과 같다.
안전 울타리 — 이 JS는 격리된 작은 상자(V8 isolate) 안에서만 돈다. Node·파일시스템·네트워크·console이 전부 없다.
핵심 정리
모델이 직접 호출하는 공개 도구는 두 개뿐이다.
도구
입력
역할
exec
raw JS 텍스트(Custom)
JS 셀을 새 V8 격리공간에서 실행
wait
JSON 인자
yield된 셀을 재개/대기/종료
펼쳐보기: exec 첫 줄 옵션과 wait 인자
exec 첫 줄 프래그마: // @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000} (허용 키는 이 둘뿐).
wait의 JSON 인자(ExecWaitArgs):
cell_id (string, 필수) — 재개할 셀 ID. exec가 “Script running with cell ID …”를 반환한 뒤에만 사용
yield_time_ms (u64, 기본 10000) — 더 기다릴 시간
max_tokens (usize?, 기본 10000) — 이번 wait가 반환할 새 출력 토큰 한도
terminate (bool, 기본 false) — true면 셀 강제 종료, false면 출력 대기
CodeModeNestedToolCall (셀 안 tools.X()의 변환 메시지): cell_id, runtime_tool_call_id(예 tool-1), tool_name, tool_kind, input(객체/문자열).
RuntimeResponse (셀 단위 결과, enum):
Yielded — 아직 실행 중, 중간 출력 후 양보 → 모델은 wait 필요
Terminated — 강제 종료됨
Result — 끝남(성공 error_text=None, 실패면 메시지)
content_items 원소는 FunctionCallOutputContentItem — InputText { text } 또는 InputImage { image_url, detail? }.
언제 켜지나 — ToolMode 세 가지 (protocol/src/openai_models.rs)
flowchart LR
D["Direct: 개별 function-call, 코드모드 OFF"]
C["CodeMode: exec + 기존 직접 도구 병행"]
O["CodeModeOnly: exec 단일 진입점, 모든 nested 도구 TS 선언을 exec 설명에 인라인"]
핵심 동시성 사실(소스 확인)
sequenceDiagram
participant JS as 셀(V8 isolate, 별도 OS 스레드)
participant DG as delegate
JS->>DG: RuntimeEvent::ToolCall (await tools.X)
DG-->>JS: RuntimeCommand::ToolResponse (Promise 채움)
셀은 별도 OS 스레드의 V8 isolate에서 돈다. nested 호출은 위 양방향 채널로 Promise를 채운다.
store/load 값은 같은 세션의 셀끼리만 공유, 세션 간 격리(테스트 stored_values_are_shared_between_cells_but_not_sessions).
yield_time_ms 안에 못 끝나면 Yielded + cell_id, 헤더 "Script running with cell ID {cell_id}".
종료 헤더: Script completed / Script failed / Script terminated, 뒤에 Wall time {n} seconds + 출력.
펼쳐보기: (보조) unified_exec — 영속 셸 세션
core/src/unified_exec는 별개 도구지만 같은 “왕복 줄이기” 철학이다. PTY 기반 대화형/영속 프로세스를 만들어 재사용: {command, cwd} → 승인/샌드박스 선택 → PTY spawn, 거부 시 SandboxType::None으로 재시도. 출력은 head/tail 버퍼로 캡(UNIFIED_EXEC_OUTPUT_MAX_BYTES = 1 MiB), yield MIN 250ms ~ MAX 30_000ms, 최대 64개 프로세스. 셀이 tools.exec_command(...)로 부르는 대상이 이 계열이다.
실제 예시
1) 모델이 작성하는 exec 셀 (복붙 예시)
// @exec: {"yield_time_ms": 8000, "max_output_tokens": 2000}const [profile, prefs] = await Promise.all([ // 도구 3개 병렬 tools.mcp__ologs__get_profile({ user_id: "u_42" }), tools.read_file({ path: "/etc/config.toml" }), // Function: 객체 인자]);const cached = load("last_run"); // 이전 셀 값 재사용store("last_run", Date.now()); // 다음 셀로 값 전달text(`profile=${JSON.stringify(profile)}`); // 텍스트 출력 누적if (!profile) exit(); // 즉시 성공 종료
tools.X() 한 줄이 Rust 이벤트로 변환되어 실제 도구 라우터까지 가는 경로:
flowchart LR
A["await tools.X(input)"] --> B["tool_callback: Promise 생성 + RuntimeEvent::ToolCall 발신"]
B --> C["delegate.invoke_tool → ToolCall{"tool_name, call_id, payload"}"]
C --> D["handle_tool_call_with_source(ToolCallSource::CodeMode)"]
D --> E["결과 JSON → code_mode_result() → 셀 Promise resolve"]
펼쳐보기: 위 경로의 실제 Rust 코드 (callback / dispatch)
// code-mode/src/runtime/callbacks.rs — JS 함수콜이 Rust 이벤트가 되는 곳pub(super) fn tool_callback(scope, args, mut retval) { // ... tool_index, input 파싱, V8 PromiseResolver 생성 ... let id = format!("tool-{}", state.next_tool_call_id); state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); state.pending_tool_calls.insert(id.clone(), resolver); let _ = event_tx.send(RuntimeEvent::ToolCall { id, name: tool_name, kind: tool_kind, input }); retval.set(promise.into()); // JS에는 즉시 Promise 반환 (await 가능)}
// core/src/tools/code_mode/mod.rs — nested 호출을 실제 도구 라우터로 디스패치let call = ToolCall { tool_name, call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()), payload, // Function{arguments} 또는 Custom{input}};let result = tool_runtime .handle_tool_call_with_source(call, ToolCallSource::CodeMode { cell_id: cell_id.to_string(), runtime_tool_call_id }, cancellation_token).await?;Ok(result.code_mode_result()) // 결과 JSON을 셀의 Promise로 resolve
펼쳐보기: 모델이 읽는 exec 설명 템플릿 + 도구 TS 시그니처
// code-mode-protocol/src/description.rsconst EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.- All nested tools are available on the global `tools` object, e.g. `await tools.exec_command(...)`.- Nested tool methods take either a string or an object as their input argument.- Runs raw JavaScript -- no Node, no file system, no network access, no console.- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.- Optional first-line pragma: `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.- `store(key, value)` / `load(key)`: 같은 세션의 다음 exec에서 값 공유.- `yield_control()`: 누적 출력을 모델에 즉시 양보하고 스크립트는 계속.- `exit()`: 스크립트를 즉시 성공 종료."#;
// description.rs::render_code_mode_sample 가 생성하는 형태 (테스트 검증)declare const tools: { weather_tool(args: { // look up weather for a given list of locations weather: Array<{ location: string; }>; }): Promise<{ // human readable weather forecast forecast: string; }>;};
펼쳐보기: 직접 구현 시 최소 골격 (Rust)
// 1) 도구를 코드모드용으로 노출let defs = codex_tools::collect_code_mode_tool_definitions(&nested_tool_specs);// 2) exec 요청 구성let req = codex_code_mode::ExecuteRequest { tool_call_id: call_id.clone(), enabled_tools: defs, source: parsed.code, // parse_exec_source로 프래그마 분리한 순수 JS yield_time_ms: parsed.yield_time_ms, max_output_tokens: parsed.max_output_tokens,};// 3) 세션 실행 → 초기 응답 대기 → 후처리let started = service.execute(req).await?; // StartedCellservice.mark_cell_ready_for_dispatch(&started.cell_id);let resp = started.initial_response().await?; // RuntimeResponselet output = handle_runtime_response(&exec, resp, max_tokens, started_at).await?;
직접 만들 때 체크리스트
exec는 freeform/Custom으로 등록(JSON 아님, raw JS). 잘못 보내면 “expects raw JavaScript source text”.
첫 줄 프래그마는 parse_exec_source로 분리. 허용 키 yield_time_ms, max_output_tokens 뿐.
nested 도구는 Function(객체) vs Freeform(문자열) 구분해 payload 빌드.
도구 이름은 normalize_code_mode_identifier로 JS 식별자화, namespace는 code_mode_name_for_tool_name.
CodeModeSessionDelegate 3개 구현: invoke_tool, notify, cell_closed.
Yielded 응답이면 cell_id 노출 + wait 경로 제공.
세션 단위 store/load 격리, 종료 시 shutdown()으로 셀 cancel + isolate terminate.
V8 샌드박스: no Node/fs/network/console(테스트 v8_console_is_not_exposed_on_global_this). 원격 이미지 URL 거부, base64 data URI만.
요약 & 셀프체크
3줄 요약
Code Mode는 도구를 하나씩 부르는 대신 exec 한 도구에 JS 셀을 써서 여러 도구를 한 번에 엮어 모델 왕복을 줄인다.
셀은 격리된 V8 안에서 돌고, 코드 속 tools.X()는 실제 도구 라우터로 디스패치되어 결과가 Promise로 돌아온다.
오래 걸리면 Yielded로 양보하고 모델이 wait로 이어받으며, store/load는 같은 세션 안에서만 값을 공유한다.
스스로 답해보기
일반 도구 호출 대비 Code Mode가 토큰·속도를 아끼는 이유를 “왕복” 개념으로 설명할 수 있는가?
exec와 wait는 각각 언제 쓰이며, Yielded 응답이 오면 모델은 무엇을 해야 하는가?