Codex · 진입점과 에이전트 실행 루프 (run_turn / Task)
Codex · 진입점과 에이전트 실행 루프 (run_turn / Task)
한 줄 요약
Codex가 요청 하나를 “모델에 묻기 → 도구 실행 → 결과를 다시 모델에 묻기” 의 반복으로 처리하는 심장부가 run_turn 루프다.
왜 배우나 — 모든 AI 에이전트의 본질인 “관찰→행동의 반복”이 코드 레벨에서 어떻게 도는지가 여기 다 들어 있다.
그림
명령을 친 순간부터 답이 돌아올 때까지의 생명주기다.
flowchart TD
A["사용자 입력<br/>codex exec '프롬프트'"] --> B["하나의 작업(Task)으로 포장<br/>UserTurn으로 변환"]
B --> C["백그라운드 스레드<br/>spawn_task → RegularTask"]
C --> D["run_turn 루프 시작"]
D --> E["1) 대화 전체를<br/>모델에 보냄 (샘플링)"]
E --> F{"모델의 응답은?"}
F -->|"'이 도구 실행해줘'"| G["2) 도구 실제 실행<br/>(파일·셸·웹검색)"]
G --> H["3) 도구 결과를<br/>대화 기록에 붙임"]
H --> I{"토큰 한계?"}
I -->|"닿음"| J["대화 자동 요약<br/>auto-compact"]
J --> E
I -->|"여유"| E
F -->|"답만"| K["턴 종료<br/>마지막 답 반환"]
flowchart LR
subgraph 껍데기["Task = 생명주기 껍데기"]
T1["RegularTask<br/>일반 대화"]
T2["ReviewTask<br/>코드 리뷰"]
T3["CompactTask<br/>수동 요약"]
T4["UserShellCommandTask<br/>!cmd 직접 실행"]
end
T1 --> RT["run_turn<br/>(샘플링 반복 엔진)"]
T2 --> RT
T4 -.모드에 따라.-> RT
T3 -.루프 아님.-> CMP["run_compact_task"]
쉽게 풀기
비유: 비서에게 일을 시키는 것
“이번 분기 매출 보고서 정리해줘”라는 쪽지 한 장을 비서에게 건넸다고 하자.
- Task(작업) = 그 쪽지 한 장. “이 일을 처리하라”는 단위다. Codex는 요청 하나를 Task로 감싸 별도 책상(백그라운드 스레드)에서 처리한다.
- run_turn(턴 루프) = 비서가 일하는 방식. 한 번에 끝내지 않고 ① 묻고(모델 호출=샘플링) → ② 행동하고(도구 실행) → ③ 결과를 들고 다시 묻기를 반복한다.
- 이 ①②③을 모델이 “이제 됐어, 끝”이라고 할 때까지 반복하는 것이 “한 턴”이다.
핵심은 모델이 직접 손을 쓰지 못한다는 점이다. 모델은 “이 도구를 써달라”고 말만 하고, 실제 실행은 Codex가 한다. 그 결과를 꼬박꼬박 모델에게 다시 보여주는 “보여주고 다시 묻기”가 에이전트의 비결이다.
sequenceDiagram
participant 모델
participant Codex as Codex(비서)
participant 도구 as 파일/셸/웹
모델->>Codex: "이 도구 써줘" (말만)
Codex->>도구: 실제 실행
도구-->>Codex: 결과
Codex->>모델: 결과를 히스토리에 붙여 재질문
모델->>Codex: "끝" 또는 다음 도구 요청
끼어드는 두 안전장치
- 대화가 너무 길어지면 (auto-compact) — 토큰 한계에 닿으면 루프 한가운데서 지난 대화를 자동 요약해 자리를 비우고 계속 일한다.
- 무한 자기복제 방지 (depth 제한) — 모델이 또 다른 에이전트를 끝없이 부르지 못하도록 “몇 단계까지만”이라는 깊이 제한을 둔다.
Task의 네 껍데기
같은 run_turn 엔진을 쓰되 포장지가 네 종류다. 대부분은 RegularTask(일반 대화)이고, 나머지는 특수 상황용이다.
핵심 정리
| Task 종류 | 무엇을 하나 | run_turn |
|---|---|---|
RegularTask | 일반 대화 턴 (가장 흔함) | 직접 호출(loop) |
ReviewTask | 코드 리뷰, 자식 스레드 처리 | 간접 |
CompactTask | 수동 /compact 요약 전용 | |
UserShellCommandTask | !cmd 직접 셸 실행 | 모드에 따라 |
[!note]- 펼쳐보기: Task별 근거 파일
RegularTask→tasks/regular.rs:72-87ReviewTask→tasks/review.rs:51-93,124-136(자식 codex 스레드를 one-shot으로 띄워 그 안에서run_turn이 돌고 이벤트를 부모로 중계)CompactTask→tasks/compact.rs:24-63(run_compact_task, 일반 샘플링 루프 아님)UserShellCommandTask→tasks/user_shell.rs- 네 Task 모두
SessionTask트레잇 구현 →tasks/mod.rs:209-247
모델의 응답 스트림은 ResponseEvent 토막들로 들어온다. 루프가 실제로 신경 쓰는 것은 OutputItemDone(도구 호출이면 큐잉, 아니면 히스토리 기록)과 Completed(루프 1회 종료, end_turn==Some(false)면 한 번 더 돈다) 둘이다.
flowchart LR
S["모델 스트림"] --> E1["Created<br/>무시"]
S --> E2["OutputItemDone"]
S --> E3["Completed"]
E2 --> D{"도구 호출?"}
D -->|"예"| Q["도구 future 큐잉"]
D -->|"아니오"| H["메시지·추론<br/>히스토리 기록"]
E3 --> C{"end_turn?"}
C -->|"Some(false)"| F["needs_follow_up=true<br/>한 번 더"]
C -->|"true / None"| K["턴 종료"]
[!note]- 펼쳐보기: 전체 ResponseEvent 처리 + auto-compact 끼어드는 지점 이벤트 처리:
Created— 스트림 시작, 무시OutputItemAdded— 아이템 시작, 스트리밍 파서 준비OutputItemDone— 핵심. 도구 호출이면 future 큐잉, 아니면 메시지/추론 히스토리 기록OutputTextDelta— 어시스턴트 텍스트 증분을 클라이언트로 흘림ToolCallInputDelta/ReasoningSummaryDelta/ReasoningContentDelta— 인자·추론 증분RateLimits— 레이트리밋 기록Completed— 루프 1회 종료.end_turn==Some(false)면needs_follow_up=trueauto-compact 끼어드는 지점:
- 턴 시작 전:
run_pre_sampling_compact가 한계 초과면 미리 압축 (turn.rs:151,782-803)- 턴 도중: 매 샘플링 후
token_limit_reached && needs_follow_up이면run_auto_compact(... MidTurn)후continue(turn.rs:302-320). 한계 판단:auto_compact_scope_tokens >= auto_compact_scope_limit || full_context_window_limit_reached(turn.rs:768-769)- 압축은 프로바이더별 local / remote / remote_v2로 분기 (
turn.rs:891-947)
실제 예시
1) 모든 Task의 계약 — SessionTask 트레잇
// codex-rs/core/src/tasks/mod.rs
pub(crate) trait SessionTask: Send + Sync + 'static {
fn kind(&self) -> TaskKind;
fn span_name(&self) -> &'static str;
fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Option<String>> + Send;
// 기본 no-op. abort 시 정리용.
fn abort(&self, ...) -> impl std::future::Future<Output = ()> + Send { /* ... */ }
}
2) 루프 본체 시그니처 — run_turn
// codex-rs/core/src/session/turn.rs
pub(crate) async fn run_turn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_extension_data: Arc<codex_extension_api::ExtensionData>,
input: Vec<TurnInput>,
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String> // 반환: 마지막 어시스턴트 메시지
3) 무한 자기복제를 막는 depth 가드
// codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
}
깊이는 세션 출처에서 읽어 +1로 올리고(agent/registry.rs:63-73), 초과 시 도구 호출을 모델에 에러로 되돌려 차단한다. agent_max_depth는 config agents.max_depth(최소 1, config/mod.rs:842,3160-3165).
[!note]- 펼쳐보기: ResponseEvent enum 전문
// codex-rs/codex-api/src/common.rs pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), // ... Completed { response_id: String, token_usage: Option<TokenUsage>, /// 모델이 명시적으로 턴을 끝냈는가? 일부 프로바이더는 안 채워서 fallback 의존. end_turn: Option<bool>, }, OutputTextDelta(String), ToolCallInputDelta { item_id: String, call_id: Option<String>, delta: String }, // ... RateLimits(RateLimitSnapshot), ModelsEtag(String), }
[!note]- 펼쳐보기: 직접 하네스를 만든다면 — 한 턴 루프의 최소 골격 (의사 Rust)
// 한 턴 = 모델호출 → 도구실행 → 재호출 반복 async fn run_turn(history: &mut History, client: &mut Client, tools: &ToolRouter) -> Option<String> { let mut last_msg = None; loop { // 1) 히스토리 전체를 모델 입력으로 let prompt = build_prompt(history.for_prompt(), tools.specs()); // 2) 스트리밍 샘플링 let mut needs_follow_up = false; let mut in_flight = Vec::new(); let mut stream = client.stream(&prompt).await?; while let Some(ev) = stream.next().await { match ev? { ResponseEvent::OutputItemDone(item) => match build_tool_call(item.clone()) { Some(call) => { // 도구 호출 history.record(&item); // 호출 자체를 먼저 기록 in_flight.push(tools.handle(call)); needs_follow_up = true; } None => { last_msg = item.as_assistant_text(); history.record(&item); } }, ResponseEvent::OutputTextDelta(d) => emit_delta(d), ResponseEvent::Completed { end_turn, .. } => { if end_turn == Some(false) { needs_follow_up = true; } break; } _ => {} } } // 3) 도구 결과를 히스토리에 붙임 (다음 모델 입력이 됨) for fut in in_flight { history.record(&fut.await?.into()); } // 4) 토큰 한계면 루프 안에서 압축 if token_limit_reached(history) && needs_follow_up { auto_compact(history).await?; continue; } if !needs_follow_up { break; } // 도구 없이 답만 → 턴 종료 } last_msg }
[!tip] 직접 만들 때 체크리스트
- Task 껍데기와 turn 루프를 분리 (Task = 생명주기/이벤트, run_turn = 샘플링 반복)
OutputItemDone에서 도구 호출/메시지 분기, 도구 호출은 호출 자체를 먼저 기록- 도구 결과(
FunctionCallOutput)를 히스토리에 넣고 다음 샘플링에 포함Completed의end_turn == None케이스 fallback 마련- 매 샘플링 후 토큰 한계 검사 → 루프 내부에서 compact 후
continue- spawn에
depth + 1 > max_depth가드, 초과 시 모델에 에러를 되돌려 무한재귀 차단cancellation_token을 stream/도구 future에 전파해 abort 시 빠르게 빠짐
요약 & 셀프체크
3줄 요약:
- 요청 하나는 Task로 포장돼 백그라운드에서 돌고, 그 안에서 run_turn이 “모델 호출 → 도구 실행 → 재호출”을 반복한다.
- 모델은 손을 쓰지 못하고 “이 도구 써달라”고 말만 하며, Codex가 실행한 결과를 히스토리에 붙여 다시 보여주는 것이 핵심 메커니즘이다.
- 대화가 길어지면 루프 도중 auto-compact로 요약하고, 자기복제는 depth 제한으로 막는다.
스스로 답해보기:
- 한 “턴”이 끝나는 조건은? (힌트: 도구를 더 안 부르고 답만 할 때 =
needs_follow_upfalse) - 모델이 직접 파일을 읽거나 명령을 실행하는가, 누가 대신 하는가?
- 토큰 한계에 닿으면 루프가 멈추는가, 어떻게 계속 도는가?
연결
CX_개요 · _분석축_루브릭 · CX_20_prompt-and-context-assembly (히스토리→프롬프트 조립) · CX_30_tool-system (도구 실행) · CX_60_subagents-multi-agent (depth 제한·서브에이전트) · CX_90_persistence-and-memory (auto-compact·히스토리)
[!note]- 펼쳐보기: Codex 교차검증 (원본 분석 보존) 본 노트의 모든 동작은 다음 근거 파일에서 직접 확인했다.
core/src/session/turn.rs—run_turn(137-411),run_sampling_request(1030-1119),try_run_sampling_request/스트림 loop(1803-2283),build_prompt(1001-1018), auto-compact 분기(733-947), Mid-turn compact(302-320),drain_in_flight(1769-1793)core/src/tasks/regular.rs—RegularTask::run→run_turn호출 loop (51-88)core/src/tasks/mod.rs—SessionTask트레잇(209-247),spawn_task/start_task(307-444),on_task_finished(556-774), abort(799-873)core/src/tasks/review.rs—ReviewTask, 자식 one-shot 스레드 + 이벤트 중계 (42-188)core/src/tasks/compact.rs—CompactTask(12-66)core/src/stream_events_utils.rs—handle_output_item_done,build_tool_call→handle_tool_callfuture 큐잉 (303-460)core/src/client.rs—ModelClientSession::stream(1559-1608),new_session(374)codex-api/src/common.rs—ResponseEventenum (73-114)core/src/session/handlers.rs— op→spawn_task(RegularTask)(259-264), Compact(446)exec/src/lib.rs—run_exec_session,InitialOperation::UserTurn조립 (656-762)core/src/agent/registry.rs— depth 계산 (63-77)core/src/tools/handlers/multi_agents/spawn.rs— depth 한계 enforcement (66-72)core/src/config/mod.rs—agent_max_depth(842, 3160-3165)데이터 흐름(CLI exec → Session → Task → run_turn → 도구 → 재호출):
codex exec가 프롬프트를InitialOperation::UserTurn으로 만들고(exec/src/lib.rs:704-761), Session 핸들러에서sess.spawn_task(... RegularTask::new())호출(session/handlers.rs:259-264) →spawn_task가 기존 턴을abort_all_tasks(Replaced)로 정리 후start_task(tasks/mod.rs:307-316) →tokio::spawn으로 백그라운드 태스크 띄우고task.run()(tasks/mod.rs:394-427) →run_turn진입. 루프 1회: 히스토리for_prompt(turn.rs:219-225) →run_sampling_request→client_session.stream(turn.rs:1828-1841) →ResponseEvent수신(turn.rs:1861-1902) →OutputItemDone이면handle_output_item_done→build_tool_call→도구 future 큐잉(stream_events_utils.rs:413-443) →Completed시end_turn==Some(false)면 follow-up 강제(turn.rs:2108-2131) →drain_in_flight로 도구 결과 기록(turn.rs:2256) →needs_follow_up이면continue, 아니면 stop hook 후break(turn.rs:263-366).