지식위키

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별 근거 파일

  • RegularTasktasks/regular.rs:72-87
  • ReviewTasktasks/review.rs:51-93,124-136 (자식 codex 스레드를 one-shot으로 띄워 그 안에서 run_turn이 돌고 이벤트를 부모로 중계)
  • CompactTasktasks/compact.rs:24-63 (run_compact_task, 일반 샘플링 루프 아님)
  • UserShellCommandTasktasks/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=true

auto-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)를 히스토리에 넣고 다음 샘플링에 포함
  • Completedend_turn == None 케이스 fallback 마련
  • 매 샘플링 후 토큰 한계 검사 → 루프 내부에서 compact 후 continue
  • spawn에 depth + 1 > max_depth 가드, 초과 시 모델에 에러를 되돌려 무한재귀 차단
  • cancellation_token을 stream/도구 future에 전파해 abort 시 빠르게 빠짐

요약 & 셀프체크

3줄 요약:

  1. 요청 하나는 Task로 포장돼 백그라운드에서 돌고, 그 안에서 run_turn이 “모델 호출 → 도구 실행 → 재호출”을 반복한다.
  2. 모델은 손을 쓰지 못하고 “이 도구 써달라”고 말만 하며, Codex가 실행한 결과를 히스토리에 붙여 다시 보여주는 것이 핵심 메커니즘이다.
  3. 대화가 길어지면 루프 도중 auto-compact로 요약하고, 자기복제는 depth 제한으로 막는다.

스스로 답해보기:

  • 한 “턴”이 끝나는 조건은? (힌트: 도구를 더 안 부르고 답만 할 때 = needs_follow_up false)
  • 모델이 직접 파일을 읽거나 명령을 실행하는가, 누가 대신 하는가?
  • 토큰 한계에 닿으면 루프가 멈추는가, 어떻게 계속 도는가?

연결

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.rsrun_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.rsRegularTask::runrun_turn 호출 loop (51-88)
  • core/src/tasks/mod.rsSessionTask 트레잇(209-247), spawn_task/start_task(307-444), on_task_finished(556-774), abort(799-873)
  • core/src/tasks/review.rsReviewTask, 자식 one-shot 스레드 + 이벤트 중계 (42-188)
  • core/src/tasks/compact.rsCompactTask (12-66)
  • core/src/stream_events_utils.rshandle_output_item_done, build_tool_callhandle_tool_call future 큐잉 (303-460)
  • core/src/client.rsModelClientSession::stream (1559-1608), new_session(374)
  • codex-api/src/common.rsResponseEvent enum (73-114)
  • core/src/session/handlers.rs — op→spawn_task(RegularTask) (259-264), Compact(446)
  • exec/src/lib.rsrun_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.rsagent_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_requestclient_session.stream(turn.rs:1828-1841) → ResponseEvent 수신(turn.rs:1861-1902) → OutputItemDone이면 handle_output_item_donebuild_tool_call→도구 future 큐잉(stream_events_utils.rs:413-443) → Completedend_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).