Codex가 계층형 멀티 에이전트(서브 에이전트) 시스템(코드네임 “Collab”)을 구현하는 방식을 심층적으로 살펴봅니다.
Codex가 계층형 멀티 에이전트(서브 에이전트) 시스템을 구현하는 방식을 심층적으로 살펴봅니다. 이 시스템의 코드네임은 **"Collab"**입니다.
Codex의 멀티 에이전트 시스템은 부모 에이전트가 자식 에이전트(서브 에이전트)를 **스폰(생성)**하여 독립적인 스레드로 실행할 수 있게 합니다. 각 자식 에이전트는 자체 LLM 대화 컨텍스트, 도구 접근 권한, 샌드박스를 가지지만, 핵심 설정은 부모로부터 상속받습니다. 이 시스템은 Feature::Collab 플래그로 게이트되며, LLM에 다섯 개의 도구 함수 spawn_agent, send_input, wait, resume_agent, close_agent를 노출합니다.
graph TB User([User]) --> MainAgent[Main Agent<br/>Thread 0 - depth 0] MainAgent -->|spawn_agent| ChildA[Child Agent 'Ash'<br/>Thread 1 - depth 1] MainAgent -->|spawn_agent| ChildB[Child Agent 'Elm'<br/>Thread 2 - depth 1] ChildA -->|spawn_agent| GrandchildA[Grandchild 'Yew'<br/>Thread 3 - depth 2] MainAgent -.->|wait / send_input| ChildA MainAgent -.->|wait / send_input| ChildB ChildA -.->|wait / send_input| GrandchildA
style MainAgent fill:#4a90d9,color:#fff
style ChildA fill:#67b7dc,color:#fff
style ChildB fill:#67b7dc,color:#fff
style GrandchildA fill:#a3d4f7,color:#000
graph LR subgraph "User Session" AC[AgentControl] G[Guards<br/>spawn slots + nicknames] TMS[ThreadManagerState<br/>thread registry] end
subgraph "Thread 0 (Parent)"
S0[Session]
TC0[TurnContext]
MAH[MultiAgentHandler]
end
subgraph "Thread 1 (Child)"
S1[Session]
TC1[TurnContext]
Tools1[Tool Handlers]
end
AC --> G
AC -.->|Weak ref| TMS
TMS -->|owns| S0
TMS -->|owns| S1
S0 --> AC
S1 --> AC
MAH -->|spawn_agent| AC
AC -->|spawn_new_thread| TMS
AC -->|send_op| TMS
style AC fill:#e6a23c,color:#fff
style G fill:#f56c6c,color:#fff
style TMS fill:#409eff,color:#fff
중앙 오케스트레이터는 사용자 세션 내 모든 에이전트가 공유하는 **AgentControl**입니다. 여기에는 다음이 포함됩니다:
Weak<ThreadManagerState> 참조(참조 사이클을 방지)Arc<Guards>출처:codex-rs/core/src/agent/control.rs:30-43
stateDiagram-v2 [] --> PendingInit: spawn_agent called PendingInit --> Running: initial prompt submitted Running --> Running: processing turns Running --> Completed: task finished Running --> Errored: error occurred Running --> Shutdown: close_agent / user shutdown Completed --> [] Errored --> [] Shutdown --> [] Shutdown --> Running: resume_agent (from rollout) Completed --> Running: resume_agent (from rollout)
AgentStatus enum (codex-rs/protocol/src/protocol.rs에서):
| 상태 | 설명 |
|---|---|
PendingInit | 스레드가 생성되었고 첫 프롬프트를 기다리는 중 |
Running | 턴을 활발히 처리 중 |
Completed(Option<String>) | 성공적으로 완료, 선택적 최종 메시지 포함 가능 |
Errored(String) | 오류 메시지와 함께 실패 |
Shutdown | 정상적으로 종료됨 |
NotFound | 스레드가 레지스트리에서 더 이상 존재하지 않음 |
Feature::Collab가 활성화되면, MultiAgentHandler를 통해 LLM에 다섯 개의 도구가 등록됩니다:
graph TD LLM[LLM Model] -->|tool_call| MAH{MultiAgentHandler} MAH -->|"spawn_agent"| SPAWN[spawn::handle] MAH -->|"send_input"| SEND[send_input::handle] MAH -->|"wait"| WAIT[wait::handle] MAH -->|"resume_agent"| RESUME[resume_agent::handle] MAH -->|"close_agent"| CLOSE[close_agent::handle]
SPAWN -->|returns| R1["{ agent_id, nickname }"]
SEND -->|returns| R2["{ submission_id }"]
WAIT -->|returns| R3["{ status: {}, timed_out }"]
RESUME -->|returns| R4["{ status }"]
CLOSE -->|returns| R5["{ status }"]
style MAH fill:#e6a23c,color:#fff
style LLM fill:#4a90d9,color:#fff
| 도구 | 파라미터 | 반환 |
|---|---|---|
spawn_agent | message 또는 items, 선택적으로 agent_type | `{ agent_id: string, nickname: string |
send_input | id, message 또는 items, interrupt?: bool | { submission_id: string } |
wait | ids: string[], timeout_ms?: number | { status: HashMap<ThreadId, AgentStatus>, timed_out: bool } |
resume_agent | id | { status: AgentStatus } |
close_agent | id | { status: AgentStatus } |
출처:codex-rs/core/src/tools/handlers/multi_agents.rs:40-91
sequenceDiagram participant LLM as Parent LLM participant MAH as MultiAgentHandler participant AC as AgentControl participant Guards as Guards participant TMS as ThreadManagerState participant Child as Child Thread
LLM->>MAH: spawn_agent(message, agent_type)
MAH->>MAH: Validate depth < agent_max_depth
MAH->>MAH: Emit CollabAgentSpawnBeginEvent
MAH->>MAH: build_agent_spawn_config()
MAH->>MAH: apply_role_to_config()
MAH->>MAH: apply_spawn_agent_overrides()
MAH->>AC: spawn_agent(config, items, source)
AC->>Guards: reserve_spawn_slot(max_threads)
Guards-->>AC: SpawnReservation
AC->>Guards: reserve_agent_nickname(["Ash","Elm",...])
Guards-->>AC: nickname = "Ash"
AC->>TMS: spawn_new_thread_with_source(config, source)
TMS-->>AC: new CodexThread
AC->>AC: reservation.commit(thread_id)
AC->>TMS: notify_thread_created()
AC->>TMS: send_input(thread_id, initial_items)
AC->>AC: maybe_start_completion_watcher()
AC-->>MAH: thread_id
MAH->>MAH: Emit CollabAgentSpawnEndEvent
MAH-->>LLM: { agent_id, nickname: "Ash" }
Note over AC,Child: Background: completion watcher<br/>monitors child status via<br/>tokio::watch channel
자식 에이전트가 스폰될 때, 중요한 오버라이드가 적용됩니다(apply_spawn_agent_overrides):
approval_policy = Never — 자식 에이전트는 사용자 승인을 요청할 수 없으며, 부모가 모든 승인을 처리합니다.child_depth + 1 > agent_max_depth이면, 자식에 대해 Feature::Collab가 비활성화되어 더 이상의 중첩을 막습니다.출처:codex-rs/core/src/tools/handlers/multi_agents.rs:940-945
모든 에이전트 간 이벤트는 세션 이벤트 시스템을 통해 방출되는 프로토콜 레벨 메시지입니다. 이러한 이벤트는 TUI 및 기타 클라이언트가 에이전트 활동을 시각화하는 데 사용됩니다.
graph TD subgraph "Spawn Events" SB[CollabAgentSpawnBeginEvent] SE[CollabAgentSpawnEndEvent] end
subgraph "Interaction Events"
IB[CollabAgentInteractionBeginEvent]
IE[CollabAgentInteractionEndEvent]
end
subgraph "Wait Events"
WB[CollabWaitingBeginEvent]
WE[CollabWaitingEndEvent]
end
subgraph "Lifecycle Events"
CB[CollabCloseBeginEvent]
CE[CollabCloseEndEvent]
RB[CollabResumeBeginEvent]
RE[CollabResumeEndEvent]
end
SB --> SE
IB --> IE
WB --> WE
CB --> CE
RB --> RE
**CollabAgentSpawnEndEvent**에는 다음이 담깁니다:
call_id — 도구 호출로 되돌려 연결sender_thread_id — 부모new_thread_id — 자식(성공한 경우)new_agent_nickname — 자동 할당된 이름(예: "Ash")new_agent_role — 지정된 경우 agent_typestatus — 초기 에이전트 상태**CollabWaitingEndEvent**에는 다음이 담깁니다:
agent_statuses — 에이전트별 닉네임/역할/상태를 담은 CollabAgentStatusEntry 벡터statuses — 프로그램적 접근을 위한 HashMap<ThreadId, AgentStatus>출처:codex-rs/protocol/src/protocol.rs
graph TB subgraph "Parent Config" PM[model] PP[model_provider] PRE[reasoning_effort] PRS[reasoning_summary] PDI[developer_instructions] PCP[compact_prompt] PSEP[shell_environment_policy] PSP[sandbox_policy] PCWD[cwd] PLSE[codex_linux_sandbox_exe] PBI[base_instructions] end
subgraph "Child Config (inherited)"
CM[model ✓]
CP[model_provider ✓]
CRE[reasoning_effort ✓]
CRS[reasoning_summary ✓]
CDI[developer_instructions ✓]
CCP[compact_prompt ✓]
CSEP[shell_environment_policy ✓]
CSP[sandbox_policy ✓]
CCWD[cwd ✓]
CLSE[codex_linux_sandbox_exe ✓]
CBI[base_instructions ✓]
end
subgraph "Child Overrides"
CO1[approval_policy = Never]
CO2["Collab disabled if depth+1 > max"]
CO3["Role config merged (if agent_type set)"]
end
PM --> CM
PP --> CP
PRE --> CRE
PRS --> CRS
PDI --> CDI
PCP --> CCP
PSEP --> CSEP
PSP --> CSP
PCWD --> CCWD
PLSE --> CLSE
PBI --> CBI
CO1 -.-> CM
CO2 -.-> CM
CO3 -.-> CM
style CO1 fill:#f56c6c,color:#fff
style CO2 fill:#f56c6c,color:#fff
style CO3 fill:#e6a23c,color:#fff
핵심 격리 속성:
approval_policy가 강제로 Never가 됩니다.cwd)를 공유출처:codex-rs/core/src/tools/handlers/multi_agents.rs:893-945
에이전트는 역할 시스템(agent_type 파라미터)을 통해 전문화할 수 있습니다. 역할은 TOML 설정 레이어를 통해 자식의 설정을 수정합니다.
graph LR subgraph "Built-in Roles" D["default<br/>(no config changes)"] E["explorer<br/>(read-only codebase queries)"] W["worker<br/>(execution tasks)"] A["awaiter<br/>(long-running command monitoring)"] end
subgraph "User-Defined Roles"
UR["custom roles from<br/>.codex/agents/ or config"]
end
SPAWN["spawn_agent(agent_type='explorer')"] --> ROLE["apply_role_to_config()"]
ROLE --> LOOKUP{"Role lookup"}
LOOKUP -->|user-defined| UR
LOOKUP -->|built-in| E
E --> MERGE["Merge TOML config layer<br/>into child config"]
style SPAWN fill:#4a90d9,color:#fff
style ROLE fill:#e6a23c,color:#fff
| 역할 | 설정 파일 | 핵심 설정 |
|---|---|---|
default | 없음 | 기본 설정에 변경 없음 |
explorer | explorer.toml | 코드베이스를 빠르게 읽도록 최적화 |
worker | 없음 | 전체 실행 기능, 작업 소유권 |
awaiter | awaiter.toml | background_terminal_max_timeout=3600000, model_reasoning_effort="low", 폴링을 위한 특화된 시스템 프롬프트 |
출처:codex-rs/core/src/agent/role.rs:147-217
graph TD subgraph "Depth Limit (default: 3)" D0["Depth 0 — Main Agent<br/>Collab: ✅"] -->|spawn| D1["Depth 1 — Child<br/>Collab: ✅"] D1 -->|spawn| D2["Depth 2 — Grandchild<br/>Collab: ✅"] D2 -->|spawn| D3["Depth 3 — Great-grandchild<br/>Collab: ✅"] D3 -->|"spawn ❌"| D4["Depth 4 — BLOCKED<br/>Collab disabled at depth 3+1"] end
style D4 fill:#f56c6c,color:#fff
사용자 세션당 공유되는 Guards 구조체는 다음을 강제합니다:
agent_max_threads) — 원자 카운터를 통해 전체 동시 서브 에이전트 수를 제한agent_max_depth, 기본: 3) — 무한 중첩을 방지Nickname pool: Ash, Elm, Yew, Fir, Oak, Pine, Spruce, Cedar, Birch, Maple,
Beech, Alder, Willow, Poplar, Aspen, Larch, Juniper, Cypress, ... (87 total)
모든 닉네임이 소진되면, 풀은 (nickname_reset_count를 통해) 리셋됩니다.
출처:codex-rs/core/src/agent/guards.rs
자식 에이전트가 최종 상태에 도달하면, 주입된 메시지를 통해 부모에게 자동으로 통지됩니다.
sequenceDiagram participant Child as Child Agent participant Watcher as Completion Watcher<br/>(tokio task) participant Parent as Parent Agent
Note over Watcher: Spawned during spawn_agent()
Watcher->>Child: subscribe_status(child_id)
Child-->>Watcher: watch::Receiver<AgentStatus>
loop Poll status changes
Watcher->>Watcher: status_rx.changed().await
end
Child->>Watcher: Status → Completed("done")
Watcher->>Watcher: is_final(status) == true
Watcher->>Parent: inject_user_message_without_turn()
Note over Parent: Message injected into context:<br/><subagent_notification><br/>{"agent_id":"...","status":"Completed"}<br/></subagent_notification>
이 알림은 시스템이 실제 사용자 메시지와 구분할 수 있도록 XML 유사 태그를 사용합니다:
<subagent_notification> {"agent_id":"abc-123","status":{"Completed":"task finished"}} </subagent_notification>
이는 inject_user_message_without_turn()를 통해 주입됩니다. 즉, 부모의 대화 컨텍스트에 새 사용자 턴 경계를 만들지 않고 나타납니다.
출처:codex-rs/core/src/agent/control.rs:258-304, codex-rs/core/src/session_prefix.rs:27-34
wait 도구는 부모가 하나 이상의 자식 에이전트가 최종 상태에 도달할 때까지 블로킹할 수 있게 합니다.
sequenceDiagram participant Parent as Parent LLM participant Wait as wait::handle() participant AC as AgentControl participant C1 as Child 1 participant C2 as Child 2
Parent->>Wait: wait(ids=[child1, child2], timeout_ms=60000)
Wait->>Wait: Emit CollabWaitingBeginEvent
Wait->>AC: subscribe_status(child1)
AC-->>Wait: watch::Receiver
Wait->>AC: subscribe_status(child2)
AC-->>Wait: watch::Receiver
Note over Wait: FuturesUnordered — races<br/>all status watchers
par Wait for first completion
Wait->>C1: watching...
Wait->>C2: watching...
end
C1-->>Wait: Status → Completed
Note over Wait: First agent done → break
Wait->>Wait: Drain remaining ready futures
Wait->>Wait: Emit CollabWaitingEndEvent
Wait-->>Parent: { status: {child1: Completed}, timed_out: false }
| 상수 | 값 | 설명 |
|---|---|---|
MIN_WAIT_TIMEOUT_MS | 10,000 (10초) | 과도하게 촘촘한 폴링 루프를 방지 |
DEFAULT_WAIT_TIMEOUT_MS | 30,000 (30초) | timeout_ms가 생략될 때 사용 |
MAX_WAIT_TIMEOUT_MS | 3,600,000 (1시간) | 상한 |
대기는 감시 중인 에이전트 중 어느 하나라도 최종 상태에 도달하는 즉시 반환됩니다. 타임아웃이 경과할 때까지 완료가 없으면 timed_out: true가 반환됩니다.
출처:codex-rs/core/src/tools/handlers/multi_agents.rs:455-663
닫힌 에이전트는 resume_agent로 다시 가져올 수 있으며, 디스크의 rollout 파일에서 복원됩니다.
로딩
sequenceDiagram participant Parent as Parent LLM participant Resume as resume_agent::handle() participant AC as AgentControl participant DB as StateDB (SQLite) participant Disk as Rollout File
Parent->>Resume: resume_agent(id=thread_42)
Resume->>AC: get_status(thread_42)
AC-->>Resume: NotFound
Note over Resume: 에이전트가 닫힘 → 복원 시도
Resume->>AC: resume_agent_from_rollout(config, thread_42, source)
AC->>AC: reserve_spawn_slot()
AC->>DB: get_thread(thread_42)
DB-->>AC: { nickname: "Ash", role: "explorer" }
AC->>AC: reserve_agent_nickname_with_preference("Ash")
AC->>Disk: find rollout by thread_id
Disk-->>AC: rollout_path
AC->>AC: resume_thread_from_rollout_with_source()
AC->>AC: reservation.commit()
AC->>AC: notify_thread_created()
AC->>AC: maybe_start_completion_watcher()
AC-->>Resume: thread_id
Resume-->>Parent: { status: Running }
재개 메커니즘은 다음을 수행합니다:
NotFound인지 확인(이미 닫힘)codex_home 아래에서 thread_id에 해당하는 rollout 파일을 디스크에서 찾음출처:codex-rs/core/src/agent/control.rs:104-169
TUI는 모든 에이전트 스레드의 이벤트를 단일 UI 이벤트 루프로 멀티플렉싱하는 다층 이벤트 파이프라인을 통해 서브 에이전트 활동을 렌더링합니다. 이 섹션은 에이전트 스레드에서 픽셀까지의 전체 경로를 추적합니다.
flowchart LR subgraph "Core Layer" PT[Parent Thread<br/>CodexThread] -->|"next_event()"| PEV[Protocol Events] CT1[Child Thread 1<br/>CodexThread] -->|"next_event()"| CEV1[Protocol Events] CT2[Child Thread 2<br/>CodexThread] -->|"next_event()"| CEV2[Protocol Events] end
subgraph "Thread Manager"
BC[broadcast::channel<br/>thread_created_tx]
end
subgraph "TUI App Event Loop"
PEV -->|"AppEventSender"| AER[app_event_rx<br/>mpsc channel]
CEV1 -->|"spawned listener task"| TEC1[ThreadEventChannel 1<br/>mpsc + store]
CEV2 -->|"spawned listener task"| TEC2[ThreadEventChannel 2<br/>mpsc + store]
BC -->|"subscribe()"| TCR[thread_created_rx]
AER --> SEL{tokio::select!}
TEC1 -.->|"if active"| ATR[active_thread_rx]
TEC2 -.->|"if active"| ATR
ATR --> SEL
TCR --> SEL
end
subgraph "Rendering"
SEL --> CW[ChatWidget<br/>dispatch_event_msg]
CW --> HC[HistoryCell<br/>ratatui Lines]
HC --> SCREEN[Terminal Screen]
end
style SEL fill:#e6a23c,color:#fff
style CW fill:#4a90d9,color:#fff
AgentControl::spawn_agent()가 새 자식 스레드를 만들면, ThreadManagerState가 broadcast::channel을 통해 새로운 ThreadId를 브로드캐스트합니다:
// codex-rs/core/src/thread_manager.rs:552 pub(crate) fn notify_thread_created(&self, thread_id: ThreadId) { let _ = self.thread_created_tx.send(thread_id); }
TUI 메인 루프는 시작 시 이 브로드캐스트 채널을 구독합니다:
// codex-rs/tui/src/app.rs:1445 let mut thread_created_rx = thread_manager.subscribe_thread_created();
출처:codex-rs/core/src/thread_manager.rs:276-277, codex-rs/tui/src/app.rs:1445
TUI가 thread_created 알림을 받으면, handle_thread_created()가 실행됩니다:
Loading
sequenceDiagram participant TMS as ThreadManagerState participant EvLoop as TUI Event Loop<br/>(tokio::select!) participant App as App participant Store as ThreadEventStore participant Listener as Spawned Listener<br/>(tokio::spawn) participant Child as Child CodexThread
TMS->>EvLoop: broadcast thread_created(thread_id)
EvLoop->>App: handle_thread_created(thread_id)
App->>App: server.get_thread(thread_id)
App->>App: upsert_agent_picker_thread(nickname, role)
App->>App: Create ThreadEventChannel(sender, receiver, store)
App->>Listener: tokio::spawn(listener loop)
loop Event drain loop
Listener->>Child: thread.next_event().await
Child-->>Listener: Event
Listener->>Store: store.lock().push_event(event)
alt store.active == true
Listener->>App: sender.send(event).await
else store.active == false
Note over Listener: 이벤트는 store에만 버퍼링되고,<br/>채널로는 전송되지 않음
end
end
핵심 설계: 각 자식 스레드는 다음으로 구성된 ThreadEventChannel을 갖습니다:
| 구성요소 | 타입 | 목적 |
|---|---|---|
sender | mpsc::Sender<Event> | 활성 채널로 라이브 이벤트를 전송 |
receiver | Option<mpsc::Receiver<Event>> | 스레드가 활성화될 때 가져감(taken) |
store | Arc<Mutex<ThreadEventStore>> | 모든 이벤트를 영속화(스레드 전환 시 리플레이용) |
store.active 플래그는 이벤트를 채널로 포워딩할지, 아니면 store에만 버퍼링할지를 제어합니다. 스레드가 활성 뷰가 아닐 때도 이벤트는 나중에 리플레이할 수 있도록 store에 계속 캡처되지만, mpsc 채널로는 푸시되지 않습니다.
출처:codex-rs/tui/src/app.rs:251-352, 2863-2926
tokio::select!)TUI의 메인 루프는 네 가지 이벤트 소스를 멀티플렉싱합니다:
Loading
flowchart TD subgraph "tokio::select! arms" A1["app_event_rx.recv()<br/>Primary thread events<br/>(via AppEventSender)"] A2["active_thread_rx.recv()<br/>Currently viewed thread<br/>(child or primary)"] A3["tui_events.next()<br/>Keyboard/mouse input"] A4["thread_created_rx.recv()<br/>New child thread spawned"] end
A1 -->|"AppEvent::CodexEvent"| ENQ["enqueue_primary_event()"]
ENQ --> ETE["enqueue_thread_event()"]
ETE --> STORE["ThreadEventStore.push_event()"]
ETE -->|"if active"| SEND["sender.try_send()"]
A2 -->|Event| HATE["handle_active_thread_event()"]
HATE --> HCEN["handle_codex_event_now()"]
HCEN --> CW["chat_widget.handle_codex_event()"]
CW --> DEM["dispatch_event_msg()"]
A4 -->|ThreadId| HTC["handle_thread_created()"]
style A1 fill:#67b7dc,color:#fff
style A2 fill:#4a90d9,color:#fff
style A3 fill:#909399,color:#fff
style A4 fill:#e6a23c,color:#fff
// codex-rs/tui/src/app.rs:1449-1489 (simplified) loop { select! { // Arm 1: Events from primary thread (via agent.rs spawn) Some(event) = app_event_rx.recv() => { app.handle_event(tui, event).await? } // Arm 2: Events from whichever thread is currently "active" (viewed) active = active_thread_rx.recv() => { app.handle_active_thread_event(tui, event).await?; } // Arm 3: Terminal input (keyboard, mouse) Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } // Arm 4: New child thread spawned by Collab Ok(thread_id) = thread_created_rx.recv() => { app.handle_thread_created(thread_id).await?; } } }
출처:codex-rs/tui/src/app.rs:1449-1489
이벤트는 기본(부모) 스레드에서 왔는지, 자식 스레드에서 왔는지에 따라 다른 경로를 탑니다:
Loading
flowchart TD PE[Primary Thread Event] --> AES[AppEventSender.send] AES --> AER["app_event_rx (select! arm 1)"] AER --> HE[handle_event] HE --> ENQ[enqueue_primary_event] ENQ --> ETE[enqueue_thread_event] ETE --> STORE1[ThreadEventStore.push_event] ETE -->|"if primary is active view"| MPSC1[sender.try_send] MPSC1 --> ATR1["active_thread_rx (select! arm 2)"] ATR1 --> HATE[handle_active_thread_event] HATE --> HCEN[handle_codex_event_now] HCEN --> CW1[ChatWidget.handle_codex_event]
CE[Child Thread Event] --> LISTENER[Spawned listener task]
LISTENER --> STORE2[ThreadEventStore.push_event]
LISTENER -->|"if child is active view"| MPSC2[sender.send]
MPSC2 --> ATR2["active_thread_rx (select! arm 2)"]
ATR2 --> HATE2[handle_active_thread_event]
HATE2 --> HCEN2[handle_codex_event_now]
HCEN2 --> CW2[ChatWidget.handle_codex_event]
style PE fill:#4a90d9,color:#fff
style CE fill:#67b7dc,color:#fff
style ATR1 fill:#e6a23c,color:#fff
style ATR2 fill:#e6a23c,color:#fff
기본 스레드 이벤트는 한 번 더 홉을 거칩니다: AppEventSender ->app_event_rx ->enqueue_primary_event() ->ThreadEventChannel. 이는 기본 스레드의 리스너가 chatwidget/agent.rs::spawn_agent()에서 설정되며, ThreadEventChannel을 직접 사용하지 않고 AppEventSender를 통해 이벤트를 포워딩하기 때문입니다.
자식 스레드 이벤트는 AppEventSender를 건너뛰고, handle_thread_created()에서 설정되는 ThreadEventChannel을 통해 바로 전달됩니다.
두 경로는 모두 active_thread_rx(select! arm 2)에서 합류하며, 이벤트는 ChatWidget.handle_codex_event()로 포워딩됩니다.
출처:codex-rs/tui/src/app.rs:811-865
ChatWidget.handle_codex_event()는 dispatch_event_msg()에 위임하며, 이는 EventMsg 변형(variant)에 대해 패턴 매칭을 수행합니다. Collab 이벤트는 multi_agents 모듈에 의해 PlainHistoryCell 객체로 변환됩니다:
// codex-rs/tui/src/chatwidget.rs:4314-4327 EventMsg::CollabAgentSpawnBegin() => {} // no-op (begin events are silent) EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(multi_agents::spawn_end(ev)), EventMsg::CollabAgentInteractionBegin() => {} EventMsg::CollabAgentInteractionEnd(ev) => { self.on_collab_event(multi_agents::interaction_end(ev)) } EventMsg::CollabWaitingBegin(ev) => { self.on_collab_event(multi_agents::waiting_begin(ev)) } EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(multi_agents::waiting_end(ev)), EventMsg::CollabCloseBegin(_) => {} EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)),
스폰, 상호작용, 종료에 대한 Begin 이벤트는 no-op라는 점에 유의하세요. TUI는 최종 상태를 담는 End 이벤트만 렌더링합니다. WaitingBegin과 ResumeBegin은 진행 중 상태를 보여주기 위해 렌더링됩니다.
출처:codex-rs/tui/src/chatwidget.rs:4142-4327
on_collab_event()는 진행 중인 답변 스트림을 분리선과 함께 플러시하고, 셀을 채팅 히스토리에 추가한 뒤 리드로우를 요청합니다:
// codex-rs/tui/src/chatwidget.rs:2182-2186 fn on_collab_event(&mut self, cell: PlainHistoryCell) { self.flush_answer_stream_with_separator(); self.add_to_history(cell); self.request_redraw(); }
multi_agents 모듈(codex-rs/tui/src/multi_agents.rs)은 프로토콜 이벤트를 스타일이 적용된 ratatui Line 및 Span 객체로 변환합니다:
Loading
flowchart LR EV[CollabAgentSpawnEndEvent] --> FN["multi_agents::spawn_end()"] FN --> CELL[PlainHistoryCell] CELL --> LINES["Vec<Line>"]
LINES --> L1["• **Spawned** Ash [explorer]"]
LINES --> L2[" └ Find all API endpoints..."]
style EV fill:#67b7dc,color:#fff
style CELL fill:#4a90d9,color:#fff
| 이벤트 | 제목 | 상세 |
|---|---|---|
SpawnEnd | "Spawned Ash [explorer]" | 프롬프트 미리보기(최대 160자) |
InteractionEnd | "Sent input to Ash [explorer]" | 메시지 미리보기(최대 160자) |
WaitingBegin | "Waiting for Ash [explorer]" 또는 "Waiting for N agents" | 에이전트별 라벨(에이전트가 2개 이상이면) |
WaitingEnd | "Finished waiting" | 색상 인디케이터가 있는 에이전트별 상태 |
CloseEnd | "Closed Ash [explorer]" | (없음) |
ResumeBegin | "Resuming Ash [explorer]" | (없음) |
ResumeEnd | "Resumed Ash [explorer]" | 상태 요약 |
상태 색상:
Running — 시안(cyan) 굵게Completed — 녹색, 응답 미리보기(최대 240자)Errored — 빨강, 오류 미리보기(최대 160자)Shutdown / PendingInit — 흐린 회색NotFound — 빨강에이전트 닉네임은 연한 파란색 굵게, 역할은 흐린 회색 대괄호로 렌더링됩니다.
출처:codex-rs/tui/src/multi_agents.rs
사용자는 에이전트 피커를 통해 에이전트 스레드 간 전환을 수행하여 각 대화를 볼 수 있습니다:
Loading
sequenceDiagram participant User participant App as App participant OldCW as ChatWidget (old) participant NewCW as ChatWidget (new) participant Store as ThreadEventStore participant Listener as Child Listener
User->>App: OpenAgentPicker
App->>App: Refresh thread statuses
App->>App: Show SelectionView popup<br/>(sorted: active first, then closed)
User->>App: SelectAgentThread(child_thread_id)
App->>App: store_active_thread_receiver()<br/>(return old receiver, set active=false)
App->>Store: snapshot() → ThreadEventSnapshot
App->>NewCW: Create new ChatWidget
App->>NewCW: reset_for_thread_switch()<br/>(clear scrollback, transcript)
App->>NewCW: replay_thread_snapshot(snapshot)<br/>(replay all stored events)
App->>App: drain_active_thread_events()<br/>(catch up any events queued since snapshot)
App->>Listener: store.active = true<br/>(resume forwarding to channel)
Note over NewCW: 사용자는 이제 자식 에이전트의<br/>전체 대화 이력을 봄
스레드를 전환할 때:
mpsc::Receiver는 해당 ThreadEventChannel로 반환되고 store.active는 false로 설정됩니다(이벤트는 계속 버퍼링되지만 포워딩되지는 않음).ThreadEventStore.snapshot()을 수행합니다 — 이는 SessionConfigured 이벤트를 포함해 모든 과거 이벤트를 캡처합니다.ChatWidget**이 생성되고, 저장된 모든 이벤트가 handle_codex_event_replay()를 통해 리플레이되어 대화를 재구성합니다.drain_active_thread_events()가 스냅샷과 채널 활성화 사이에 도착한 이벤트를 따라잡습니다.CodexThread가 없음) 있으면, 리플레이는 읽기 전용이며 안내 메시지가 표시됩니다.출처:codex-rs/tui/src/app.rs:867-938, 965-1021
┌─────────────────────────────────────────────────────┐
│ • Spawned Ash [explorer] │
│ └ Find all API endpoints in the codebase │
│ │
│ • Spawned Elm [worker] │
│ └ Implement the new authentication module │
│ │
│ • Waiting for 2 agents │
│ └ Ash [explorer] │
│ Elm [worker] │
│ │
│ • Finished waiting │
│ └ Ash [explorer]: Completed - Found 42 endpoints │
│ Elm [worker]: Completed - Auth module ready │
│ │
│ ─── Agent Picker (Ctrl+A) ─────────────────────── │
│ 🟢 Main [default] │
│ 🟢 Ash [explorer] │
│ 🟢 Elm [worker] │
│ ⚫ Yew [awaiter] (closed) │
└─────────────────────────────────────────────────────┘
flowchart TB subgraph "Invisible to User" SPAWN_CORE["AgentControl.spawn_agent()"] --> THREAD["New CodexThread"] THREAD --> BROADCAST["broadcast thread_created"] BROADCAST --> ATTACH["TUI attaches listener"] ATTACH --> BUFFER["Events buffered in ThreadEventStore"] end
subgraph "Visible to User (Parent Chat)"
SPAWN_EV["• Spawned Ash [explorer]"] --> WAIT_EV["• Waiting for Ash"]
WAIT_EV --> DONE_EV["• Finished waiting<br/> └ Ash: Completed - results..."]
end
subgraph "Visible to User (Agent Picker)"
PICKER["Agent Picker shows all threads<br/>with status dots (green/gray)"]
SWITCH["User switches to Ash's thread<br/>→ Full conversation replayed"]
end
BUFFER -.->|"if parent is active"| SPAWN_EV
BUFFER -.->|"on thread switch"| SWITCH
style SPAWN_EV fill:#4a90d9,color:#fff
style WAIT_EV fill:#e6a23c,color:#fff
style DONE_EV fill:#67b168,color:#fff
핵심 인사이트: 부모 에이전트의 채팅 뷰에는 요약 셀(스폰, 대기, 상호작용 이벤트)만 표시됩니다. 자식 에이전트의 전체 스트리밍 출력(추론, 도구 호출, 파일 편집, 터미널 출력)은 해당 ThreadEventStore에 캡처되며, 사용자가 에이전트 피커로 그 스레드로 전환할 때만 보이게 됩니다.
sequenceDiagram actor User participant Main as Main Agent (depth 0) participant Explorer as Explorer "Ash" (depth 1) participant Worker as Worker "Elm" (depth 1)
User->>Main: "Add caching to the API"
Main->>Main: Plan: need to understand current code, then implement
Main->>Explorer: spawn_agent("Find all API route handlers", agent_type="explorer")
Main->>Worker: spawn_agent("Implement Redis caching layer", agent_type="worker")
Note over Main: 두 에이전트가 병렬로 스폰됨
Main->>Main: wait(ids=[Ash, Elm], timeout_ms=120000)
par Parallel execution
Explorer->>Explorer: Read files, grep for routes
Explorer->>Explorer: Completed("Found 12 routes in src/api/")
Explorer-->>Main: <subagent_notification> Completed
Worker->>Worker: Create cache module
Worker->>Worker: Edit route handlers
Worker->>Worker: Completed("Caching added")
Worker-->>Main: <subagent_notification> Completed
end
Main->>Main: wait returns: both Completed
Main->>Main: send_input(Elm, "Also add cache invalidation")
Worker->>Worker: Add invalidation logic
Worker->>Worker: Completed("Invalidation added")
Worker-->>Main: <subagent_notification> Completed
Main->>Main: close_agent(Ash)
Main->>Main: close_agent(Elm)
Main->>User: "Done! Added Redis caching with invalidation to all 12 API routes."
| 파일 | 목적 |
|---|---|
codex-rs/core/src/tools/handlers/multi_agents.rs | Collab 도구 핸들러 5개 전체(spawn, send_input, wait, resume, close) |
codex-rs/core/src/agent/control.rs | AgentControl — 스폰과 메시징을 위한 중앙 오케스트레이터 |
codex-rs/core/src/agent/guards.rs | Guards — 깊이 제한, 스레드 수 제한, 닉네임 할당 |
codex-rs/core/src/agent/role.rs | 역할 시스템 — 내장 및 사용자 정의 에이전트 타입 |
codex-rs/core/src/agent/builtins/awaiter.toml | Awaiter 역할 설정 |
codex-rs/core/src/agent/builtins/explorer.toml | Explorer 역할 설정 |
codex-rs/core/src/agent/agent_names.txt | 87개 식물학적 닉네임 풀 |
codex-rs/core/src/session_prefix.rs | 서브 에이전트 알림 포매팅(<subagent_notification> 태그) |
codex-rs/protocol/src/protocol.rs | 프로토콜 이벤트(CollabAgent*Event) 및 AgentStatus enum |
codex-rs/tui/src/chatwidget/agent.rs | TUI에서 에이전트 스폰/상호작용 이벤트 렌더링 |
codex-rs/core/src/tools/spec.rs | 도구 등록(Feature::Collab 조건부) |