任务类型与 Plan 模式 · 一轮里跑的不只是对话
会话同一时刻只跑一个任务,任务都实现 SessionTask:普通对话、压缩、审查与用户 shell 命令四种实现,共用同一套启动、结束与中断流程,中断时先给任务 100 毫秒自行收尾再强制终止。Plan 模式不是一种任务,而是协作模式的一个取值:它换了一套开发者指令,从回复里解析计划块,禁用 update_plan,让 request_user_input 可用;“不做改动”只写在提示词里,沙箱与审批并不因此收紧。
任务类型与 Plan 模式 · 一轮里跑的不只是对话
run_turn 主循环讲的是普通对话怎么一轮轮转起来。但会话里的“一轮”不一定是对话:/compact、/review,以及空闲时行首的 !,也都各占一轮。这一篇看这些轮次共用的任务框架,以及建立在普通对话之上的 Plan 模式。
用户看到的样子
平时发消息是普通对话;/compact 手动压缩,/review 起一次代码审查,输入框行首写 ! 直接执行一条本地 shell 命令;Esc 或 Ctrl+C 中断当前这一轮。/plan 或 Shift+Tab 切到 Plan 模式后,模型先探索、提问,最后给出一份完整计划,TUI 随即弹出 “Implement this plan?”,可以切回默认模式开始实现,或清空上下文带着计划开新线程。用法见斜杠命令与代码审查。
SessionTask:一轮里跑什么
/// Async task that drives a [`Session`] turn.
///
/// Implementations encapsulate a specific Codex workflow (regular chat,
/// reviews, ghost snapshots, etc.). Each task instance is owned by a
/// [`Session`] and executed on a background Tokio task. The trait is
/// intentionally small: implementers identify themselves via
/// [`SessionTask::kind`], perform their work in [`SessionTask::run`], and may
/// release resources in [`SessionTask::abort`].
pub(crate) trait SessionTask: Send + Sync + 'static {
/// Describes the type of work the task performs so the session can
/// surface it in telemetry and UI.
fn kind(&self) -> TaskKind;
// ...
fn run(
self: Arc<Self>,
session: Arc<Session>,
ctx: Arc<TurnContext>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = SessionTaskResult> + Send;(codex-rs/core/src/tasks/mod.rs:170)
run 返回可选的最后一条助手消息,abort 是可选的清理钩子。TaskKind(codex-rs/core/src/state/turn.rs:68)只有 Regular、Review、Compact 三个值,注释里提到的 ghost snapshots 已经不再生成(配置代码的注释说 ghost_snapshot 只为兼容旧配置而保留);实现却有四个,用户 shell 命令报告的是 Regular:
| 实现 | 由谁发起 | 做什么 |
|---|---|---|
RegularTask | 用户消息、其他 agent 发来的待办 | 反复调用 run_turn,直到没有待处理输入 |
CompactTask | /compact | 按提供方能力选远程压缩 V2 或本地压缩,见上下文压缩 |
ReviewTask | /review | 以审查提示词为基础指令,起一个一次性的子会话,关掉网络搜索与多 agent 工具 |
UserShellCommandTask | 行首 !(会话空闲时) | 不经模型,直接执行命令并把结果记进历史 |
启动、结束与中断
会话的 active_turn 里最多挂一个 RunningTask:取消令牌、完成通知、tokio 任务句柄,以及这一轮的 TurnContext。spawn_task 总是先以 Replaced 为由中止正在跑的任务,再调用 start_task;已知空闲的路径(例如邮箱唤醒)直接调用 start_task。start_task 把邮箱里已到达的消息并入这一轮的待处理输入,通知扩展“一轮开始了”,然后起一个 tokio 任务执行 run。run 结束后先刷新 rollout,没被取消就交给 on_task_finished:没用完的待处理输入照样写进历史,统计本轮 token 用量,发出带 last_agent_message 与 error 的 TurnComplete,清空 active_turn,最后看邮箱里有没有要求开新一轮的消息。
中断走另一条路。Op::Interrupt 以 Interrupted 为由中止任务,handle_task_abort 的核心是:
trace!(task_kind = ?task.kind, sub_id, "aborting running task");
task.cancellation_token.cancel();
// ...
let session_task = task.task;
select! {
_ = task.done.notified() => {
},
_ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => {
warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS);
}
}
task.handle.abort();
session_task
.abort(Arc::clone(self), Arc::clone(&task.turn_context))
.await;(codex-rs/core/src/tasks/mod.rs:922)
先取消令牌,最多等 100 毫秒(GRACEFULL_INTERRUPTION_TIMEOUT_MS)让任务自己收尾,然后终止 tokio 任务(已经结束的不受影响),再调任务自己的 abort。如果是用户中断,接着往历史里写一条中断标记:默认是 <turn_aborted> 包裹的 user 片段,告诉模型用户是有意中断的、后台进程可能还在运行、被中止的工具可能只执行了一半;多 agent V2 下改为 developer 消息;配置 agents.interrupt_message = false 可以关掉。标记先落盘,再发 TurnAborted,因为按注释的说法,有些客户端收到这个事件会立刻重读 rollout。
行首的 !:不进沙箱的一轮
run_user_shell_command(codex-rs/core/src/session/handlers.rs:96)分两种情况:会话空闲时起一个 UserShellCommandTask,自成一轮;已经有一轮在跑时,不打断它,而是借用当前轮的上下文与取消令牌另起一个 tokio 任务执行,输出并入这一轮的待处理输入,下一次采样时模型就能看到(那一轮已经结束的话就直接写进历史)。执行请求是这样构造的:
let permission_profile = PermissionProfile::Disabled;
let exec_env = ExecRequest {
command: exec_command.clone(),
cwd: cwd.clone().into(),
env: exec_env_map,
exec_server_env_config: None,
exec_server_shell_snapshot: None,
// `/shell` is the explicit full-access escape hatch, so it must not
// inherit a managed proxy from the surrounding session or turn.
network: None,
network_environment_id: None,
expiration: timeout_ms.unwrap_or(USER_SHELL_TIMEOUT_MS).into(),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox: SandboxType::None,(codex-rs/core/src/tasks/user_shell.rs:213)
权限配置是 Disabled,沙箱类型是 None,不走审批,也不继承托管的网络代理,默认超时 1 小时(USER_SHELL_TIMEOUT_MS)。命令在当前环境的 shell 里执行,保留管道、重定向等语法;结果连同退出码、耗时与按模型截断策略截断后的输出,包进 <user_shell_command> 片段写入历史,所以模型之后能看到用户自己跑过什么。TUI 里的 ! 本身就是经 app-server 的 thread/shellCommand 发出的,协议文档写明它 “runs unsandboxed with full access”。
《Codex 中文手册》的斜杠命令页写着行首 ! “遵循当前的审批与沙箱设置”。按 0.158.0 的代码,这条命令不经审批、不进沙箱,以完全访问权限执行,与 app-server 协议文档的说法一致。
Plan 模式:同一个 RegularTask,换一套约束
Plan 模式不是一种任务。协作模式 ModeKind 只有 Default 与 Plan 两个值,它是每步设置的一部分,Plan 模式下跑的仍是 RegularTask。代码层面它改变了这几件事:
- 指令:内置预设把推理强度设为
medium,开发者指令换成plan.md(9,312 字节,默认模式的default.md只有 1,307 字节),以<collaboration_mode>片段注入(见指令从哪来)。 - 输出解析:每条助手消息都经过一个流式解析器,独占一行的
<proposed_plan>与</proposed_plan>之间的内容被抽出来,以PlanDelta事件流给客户端,最终成为一个TurnItem::Plan,ID 是轮次 ID 加-plan;标签以外的文字照常作为助手消息。只有计划、没有其他文字时,不会出现一条空的助手消息。 - 工具:
update_plan直接返回 “update_plan is a TODO/checklist tool and is not allowed in Plan mode”;request_user_input只允许根线程调用,Plan 模式下总是可用、请求标记为阻塞,默认模式要打开开发中的default_mode_request_user_input才能用。 - 自动开轮:以“空闲才开轮”方式提交、又不是用户消息的输入(直接注入的响应条目、agent 间消息)属于
Automatic,按注释的说法,它“既不能离开已有的 Plan 模式,也不能进入它”,开轮前的当前设置与拟用设置都要过这道检查:
/// Why input is starting a turn; shared by admission and input delivery.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TurnStartKind {
User,
Automatic,
Recovery,
}
impl TurnStartKind {
fn permits_mode(self, mode: ModeKind) -> bool {
match self {
Self::User | Self::Recovery => true,
Self::Automatic => mode != ModeKind::Plan,
}
}(codex-rs/core/src/session/turn_input.rs:57)
反过来,代码没有做的是:Plan 模式下沙箱、审批与可用的 shell 和改文件工具都不变。“只做不改动环境的探索、不做会改动的操作”写在 plan.md 里(“You must not perform mutating actions.”),是提示词层面的约定。用户选 “Yes, implement this plan” 时,TUI 以默认模式发送一条 “Implement the plan.”;选 “Yes, clear context and implement” 则开一个新线程,把一段说明连同计划原文作为第一条消息。
和《从 LLM 到 Coding Agent》对照
权限系统把 plan 设计成最安全的一档权限模式:权限层对写操作一律拒绝,模型根本动不了手。Codex 的 Plan 模式走的是另一条路:它是协作模式而不是权限模式,靠提示词约束模型只做探索,代码只拦下 update_plan;想要硬性的只读,要靠沙箱与审批设置(见沙箱、审批与安全)。中断与转向要求一根取消信号贯穿到底、为悬空的工具调用合成结果、写一条“用户中断”的标记并停在当前回合,这几点 Codex 都有对应:取消令牌以子令牌的形式一路传下去,没有输出的调用在构造请求时补上 aborted(见上下文与历史),中断标记写进历史,中断本身走 TurnAborted 而不是错误事件;中断后也不会自动继续,除非邮箱里有要求开轮的 agent 消息。不同的是转向:那篇把追加的消息接到下一轮,Codex 则把它并入当前这一轮,在下一次采样时交给模型(见 run_turn 主循环)。
上一篇:模型目录与提供方 · 一个 CLI 接多家后端 · 下一篇:工具系统总览 · 暴露、注册、路由与并行