第一轮 · 第 3 站 · 所在层:Core · 先用 15 分钟看懂,再读真实源码

Core 层的
start_or_steer_turn

上一站,App Server 已经把客户端请求翻译成 Core 能接收的内部对象。本课把“进入 Core 公开入口 → 通过 Session 队列 → 检查状态并选择分支 → 返回受理结果”这一整段叫作第三站。整段链路不调用模型、不执行工具;入口函数 start_or_steer_turn 本身不读取状态,私有函数 start_or_steer 组织三条主分支,而它调用的 Session::steer_input 才真正加锁读取活动 Turn 状态。

第一件事:先定位层级与上下游

start_or_steer_turn 位于 Core 层,但它只是本段 Core 路由链的公开入口,不是状态决策点。

App Server / turn_start_inner(上游) → CodexThread::start_or_steer_turn(公开入口) → start_or_steer(选择主分支) → Session::steer_input(读取和检查活动状态)

下游有两条主路:新开 Turn 时进入 RegularTask;追加时进入当前活动 Turn 的输入队列。拒绝时不进入任何执行任务。

本课通关目标:先区分“第三站的整段链路职责”和“公开入口函数的单个函数职责”,再说清它收到的“请求包裹”、Session 的“提交邮箱”,以及 Started / Steered / NotSubmitted 三种受理结果。

先分清三个尺度:对话、Turn、流式片段

你在界面里看到的东西源码中的准确尺度是否是一个新 Turn
侧边栏里的整段长期对话一个 Thread;里面可以长期追加很多个 Turn。不是,它是 Turn 的容器。
Thread 空闲时,你发送一次要求,Codex 一直工作到本次结束一个 Turn;其中可以有用户输入、多个 Item、工具调用及多次模型采样。是。
回答文字一个字、一小段一小段出现同一 Agent Message 的多条 item/agentMessage/delta 通知。不是。
当前 Turn 尚未结束时,你又追加输入可能成为 Steering,追加进现有活动 Turn。不是。
一个 Thread
├─ Turn A
│  ├─ turn/started
│  ├─ item/agentMessage/delta  「第 1 个流式片段」
│  ├─ item/agentMessage/delta  「第 2 个流式片段」
│  ├─ 工具调用、工具结果、更多 Item……
│  └─ turn/completed
└─ Turn B                         ← A 完成后再次发送,才新开 Turn

一句话:整段对话可以有多个 Turn;一次正常的“你发送 → 我工作 → 本次完成”通常是一个 Turn;流式输出只是这个 Turn 内的多条增量通知。

源码依据:turn/startedturn/completeditem/agentMessage/delta · 每条 delta 同时携带 threadId、turnId 与 itemId

Goal 模式与多智能体:不能按“整个目标 = 一个 Turn”计算

Goal 是跨 Turn 保存的目标状态

短任务若在首次执行中完成,整个目标碰巧只有一个 Turn;但这不是 Goal 的定义。源码明确要求 Goal persists across turns。只要目标仍为 Active,一个 Turn 结束、Thread 恢复空闲后,Goal Runtime 可以调用 start_turn_if_idle(...),以 turn_trigger: "goal" 自动启动下一个续作 Turn。

Goal G:完成一项代码功能
│
└─ 主控 Thread P
   ├─ Turn P1:理解需求、检查代码、开始实现 → completed
   ├─ Turn P2:Goal 自动续作、运行测试、修复问题 → completed
   └─ Turn P3:验收全部要求、把 Goal 标记 complete → completed

结论:一个 Goal 可以跨多个 Turn;每个 Turn 仍有独立 turnId。

主控与子控没有“共享 Turn”

每个子 Agent 都有自己的 Thread,因此也维护自己的 Turn 序列。主控调用 spawn_agent 只是主控当前 Turn 里的一个工具 Item;它同时创建子 Thread,并在子 Thread 中启动一个独立 Turn。子控完成后,结果再送回主控,但两边的 Turn 不会合并成同一个 Turn。

主控 Thread P:Turn P1
   spawn_agent(A) ───── wait ───── 收到 A 的结果 ───── 汇总
          │
          ▼
子控 Thread A:Turn A1
          编写代码 ───── 调用工具 ───── completed

这是 P1 和 A1 两个 Turn,并行协作;不是一个跨 Thread 的 Turn。
主控对子控的操作子控侧是否新建 Turn
spawn_agent 创建子 Thread 并交付首个任务是:子 Thread 启动自己的首个 Turn。
子控仍在运行时使用 send_message否:消息交给正在运行的子控,不触发新 Turn。
子控已经空闲时使用 followup_task是:在同一子 Thread 中触发下一 Turn。
主控调用 wait_agent 等待结果不影响计数:它只是主控当前 Turn 里的工具调用。

计数原则:先锁定一个 threadId,再数它下面的 turnId。跨 Thread 的一次“派活—回报”不是一个共享 Turn。

源码依据:Goal 明确跨 Turn 持续 · Thread 空闲后启动 Goal 续作 Turn · followup_tasksend_message 的 Turn 语义 · 对子 Thread 执行 start-or-steer

先说结论:第三站是一段“调度链”,不是一个函数

站级职责:第三站覆盖一整段协作过程——接收请求、排入 Session 队列、由状态拥有者作出判断、返回受理结果。

公开入口:start_or_steer_turn 只选择 StartOrSteer 路由模式并把请求继续交下去。
送到状态拥有者:请求经过 Session 的内存提交队列,由 submission_loop 收件。
检查与分支:turn_input::handle 根据模式调用私有函数 start_or_steer;后者先调用 steer_input 检查最新状态,再根据检查结果选择三条主路。
返回结果:新开为 Started,追加为 Steered,不能接收为 NotSubmitted

整段链路不做:不在这里请求模型、不在这里运行工具,也不等待答案生成。注意,这是“第三站整段链路”的边界描述,不能改写成“那 7 行公开函数亲自完成了全部判断”。

讨论范围准确职责它与状态决策的关系
课程第三站
一整段 Core 路由链
从公开入口接收请求,一路送去检查状态、选择分支并拿回受理结果。这是多个函数协作完成的站级职责,不能用“亲自”描述。
CodexThread::start_or_steer_turn
7 行公开函数
把请求和 TurnInputMode::StartOrSteer 交给下层,然后 .await 返回结果。只表达路由意图。函数体没有读取活动 Turn,也没有三分支 match
session::turn_input::start_or_steer
主分支协调函数
准备设置,先调用 steer_input,再把其结果映射为 Steered、Started 或 NotSubmitted。选择主分支。它有三分支 match,但不直接锁定和读取 active_turn
Session::steer_input
状态检查函数
锁定 active_turn,检查活动任务是否存在、是否可追加、输入是否为空、Schema 是否兼容。读取真实状态。它返回活动 Turn ID 或具体拒绝原因,供 start_or_steer 选择主分支。
第三站解决的问题为什么不能让 App Server 直接猜
用户点击发送时,也许 Thread 空闲,应新开 Turn。状态可能随时变化。若 App Server 先查询“空闲”,过一会儿再提交,查询结果可能已经过期。让拥有状态的 Session 在接件时统一判断,才能依据更近、更一致的状态做决定。
用户在任务运行中补一句话,也许应追加给当前 Turn。
当前是 Review / Compact,或追加内容不兼容,也许必须拒绝。

先拆开四个最容易混淆的东西

名字直白说是什么在这一站干什么
TurnInputRequest包裹里的完整办事材料装“要处理的输入”和随行的设置、上下文、元数据、追踪信息。
Submission快递外包装 / 投递单给内部操作加上提交 ID,并说明这次要做的操作是 Op::TurnInput
tx_sub → rx_sub内存里的投递口和取件口调用者从 tx_sub 投递;Session 的循环从 rx_sub 逐件取出处理。课程把这一整套比喻成“提交邮箱”。
reply_tx → reply_rx这一个包裹专用的回执通道Session 用它只回复一次:已新开、已追加、未受理,或系统错误。它不传模型最终答案。

“提交邮箱”只是教学比喻,不是电子邮件

源码里没有一个叫 SessionMailbox 的邮箱对象。这里实际是 async_channel 创建的进程内有界队列,加上持续收件的 submission_loop。它不经过邮箱服务器,不写入你的电子邮箱,也不是 App Server 的 JSON-RPC 网络请求。

把它放回完整链路

App Server
turn_start_inner
Core 门面
start_or_steer_turn
提交队列
tx_sub → rx_sub
Session 收件
submission_loop
主分支协调
start_or_steer
读取活动状态
steer_input
按检查结果
返回三种受理结果

注意:start_or_steer_turn 是对上游开放的入口;私有函数 start_or_steer 写着“追加、启动、拒绝”的主分支;steer_input 负责锁住并检查真实活动状态。三者都在 Core,但职责粒度不同。

先用你每天的体验理解三种结果

Started

当前没有活动 Turn

Core 创建新的 Turn 上下文,生成新的 Turn ID,并启动一个 RegularTask

新工作开始,但尚未执行完成。

Steered

已有可追加的普通 Turn

新输入进入当前 Turn 的待处理输入队列,不创建新 Turn,返回当前活动 Turn 的 ID。

例如任务运行中,你补一句“先不要改测试”。

NotSubmitted

当前状态不能安全接收

例如活动 Turn 正在 Review/Compact、追加的整个 UserInput 列表为空,或要求的输出 Schema 与活动 Turn 不一致。

这是明确拒绝,不会偷偷排队。

路由模拟器:当前 Thread 处于什么状态?

点击一种状态,观察 Core 的路由结果。

关键校正:四种“空”不能混为一谈

你在桌面端的观察是对的

此前若把规则概括成“text 不能为空”,这个说法过于绝对。准确规则必须同时说明:哪个客户端、哪种数据、Thread 当前是否有活动 Turn

你说的“空”实际检查对象当前源码中的结果
桌面编辑框没有可见文字只是界面现象不能仅凭界面断定请求中的 input 是不是空列表;桌面端也不是第 1 站那份 TUI ChatWidget。
TUI 的 user_message.text 为空文字字段仍可带本地图片或远程图片;只有文字和两类图片同时为空,TUI 才在第 128 行提前返回。
App Server 的 params.input[]Vec<UserInput> 的长度input 字段必须存在,但 Vec 可以有 0 项;turn_start_inner 只检查文字总量上限,没有检查下限。
Core 收到空 items结构化输入列表 + 活动状态没有活动 Turn 时仍可 Started;已有活动 Turn 时不能用空列表 Steering,返回 NotSubmitted(EmptyInput)

为什么空闲时没有新文字,模型仍然能运行?

Started 分支只在 items 非空时把新的 UserInput 放入 task_input,但无论是否为空,都会启动 RegularTask。后面的 run_turn 会从 sess.clone_history() 取得已有 Thread 历史来构造模型请求,所以“没有新的可见文字”不等于“模型拿到空白上下文”。它更像按了一次“基于现有上下文继续运行”。

不过,没有抓取那一次桌面端的 JSON-RPC 请求前,我们不能断言它送的是 input: [],还是某个界面不可见的结构化输入。源码能确定的是:这两种情况都可能让空闲 Thread 启动。

源码依据:TUI 同时检查文字与图片 · TurnStartParams.input 是 Vec · App Server 只校验文字上限 · 空闲时 Started · 活动 Turn 的空 Steering 被拒绝 · 模型请求读取 Thread 历史

运行中切换模型,会变成一次“空 Steering”吗?

不会。设置消息和用户输入消息是两条不同通道

当正在运行的 Turn 使用 sol,你只在界面里把模型选择改成 terra 时,当前 TUI 的正常路径不会重新构造 TurnStartParams,也不会伪造一条空文字。它发送的是另一种 JSON-RPC 请求:thread/settings/update

TUI 界面事件:AppEvent::UpdateModel("terra") 表示“用户改了模型设置”。这里的 AppEvent 仍然只是 TUI 内部向应用层交接的消息。
App Server 设置接口:TUI 发送 thread/settings/update { threadId, model: "terra" }。这个参数里没有 input,更不是 TurnStartParams
Core 设置操作:App Server 把它转换成 Op::ThreadSettings,Core 更新 Thread/Session 保存的模型设置。
生效边界:已经运行的当前 Turn 保留原有 Turn 上下文,所以继续使用 sol;下一次新建 Turn 时才使用 terra

界面立即显示 terra,不等于当前正在执行的 sol 请求被换掉。它表示这个 Thread 的“下一轮默认模型”已经改成 terra。

活动 Turn 中追加用户内容

turn/start(兼具 start-or-steer 语义)携带非空 input → Core 的 start_or_steerSteered

这是一次输入提交。

活动 Turn 中只切换模型

thread/settings/update 携带模型设置 → Op::ThreadSettings → 更新后续 Turn 的设置。

这不是 Steering,也不新建 Turn。

进阶区别:源码里还有一个实验性的 turn/settings/update

这个接口专门修改某个正在运行的 Turn 的设置,但它依然不是 turn/start,也不进入 Steering。即使启用,它也只能影响该 Turn 后续尚未捕获设置的模型步骤;已经发出去、正在采样的模型请求不会在半途中从 sol 变成 terra,而且这一轮不保证一定还会发生下一次模型调用。

你当前安装的 Codex 桌面包中可以检索到 thread/settings/update,没有检索到 turn/settings/update。这支持“当前模型选择器更新下一轮模型”的判断;若要证明某一次具体点击发出的原始消息,则需要抓取那次 JSON-RPC 请求。

源码依据:TUI 的 AppEvent::UpdateModel · thread/settings/update 请求构造 · 设置用于 subsequent turns · App Server 转成 Op::ThreadSettings · 实验性的 turn/settings/update

真实代码精读 ①:公开入口为什么只有 7 行?

pub async fn start_or_steer_turn(
    &self,
    request: TurnInputRequest,
) -> CodexResult<TurnInputSubmission> {
    self.submit_turn_input_with_mode(request, TurnInputMode::StartOrSteer)
        .await
}
代码片段逐词含义架构意义
pub公开可见这是 CodexThread 提供给 App Server 等调用方的 Core API。
async fn异步函数调用方用 .await 等待 Core 的路由决定。
&self借用当前 CodexThread不会取得 Thread 所有权,后续仍可共享使用。
request: TurnInputRequest参数名与类型App Server 已经把协议输入整理成 Core 的 Turn 输入信封。
CodexResult<...>Result<..., CodexErr> 的别名外层区分 Core 内部错误与正常路由结果。
TurnInputMode::StartOrSteer枚举类型的一个变体它把“路由策略”作为数据传下去:空闲则启动,忙碌且可追加则 Steering。
.await 后没有分号这是函数体最后一个表达式Rust 直接把下游返回值作为本函数返回值;加分号后会变成 (),类型就不对了。

薄函数不等于没价值

它隐藏了 Thread 内部状态,让上游只表达意图:“请你自己判断启动还是追加”。这叫门面式 API。状态判断集中在 Core 内部,App Server 不需要先查询状态再决定,避免查询后状态又变化的竞态窗口。

源码依据:CodexThread::start_or_steer_turn

TurnInputRequest:不是一句话,而是一整包内部请求

先和上一站的 TurnStartParams 分开

TurnStartParams 是 App Server 对外协议里的“客户申请表”;TurnInputRequest 是 App Server 校验、转换以后,交给 Core 的“内部办事包”。两者表达的是同一次发送,但所处层级、字段类型和使用者不同。

pub struct TurnInputRequest {
    pub input: TurnInput,
    pub thread_settings: ThreadSettingsOverrides,
    pub start: TurnStartOptions,
    pub additional_context: BTreeMap<String, AdditionalContextEntry>,
    pub responsesapi_client_metadata: Option<HashMap<String, String>>,
    pub trace: Option<W3cTraceContext>,
}

真正难懂的不是“它有六个字段”,而是这些字段有层次。普通文字“帮我检查登录模块”实际被包了三层:

TurnInputRequest // 整个 Core 内部请求包 ├─ input: TurnInput // 主件:这次到底交什么 │ └─ UserInput { // TurnInput 的一种形态 │ ├─ content: Vec<UserInput> // 一组用户输入项 │ │ └─ UserInput::Text { │ │ text: "帮我检查登录模块", │ │ text_elements: [] │ │ } │ └─ client_id: None // 客户端可选的消息 ID │ } ├─ thread_settings // Thread 级运行设置覆盖 ├─ start // 只有新开 Turn 才采用的选项 ├─ additional_context // 调用方补充的上下文 ├─ responsesapi_client_metadata // 模型服务请求的附加标签 └─ trace // 串联日志的追踪号

最绕的命名:TurnInput 里面又有 UserInput

TurnInputRequest.input 不是一段字符串,它的类型是 TurnInputTurnInput 可以表示普通用户输入、已有的 Responses Item 或智能体间通信;但本课的 start_or_steer 只接受其中的 UserInput 形态。这个形态里又有 content: Vec<UserInput>,所以一条消息可以同时装文字、图片、音频、Skill 或 Mention 等多个输入项。

字段它回答什么问题普通情况下怎么理解
input这次交来什么?这是主件。普通发送是 TurnInput::UserInput,里面的 content 列表才装文字、图片等实际内容。Started 时它成为新 Turn 的输入;Steered 时进入活动 Turn 的待处理输入。
thread_settings这个 Thread 今后按什么设置工作?模型、推理强度、审批、权限、沙箱、环境、协作模式等“覆盖值”。没传的字段通常保留原值。请求若被接受,这些覆盖会写入 Thread 设置;Steered 时不会倒改已经运行的 Turn,主要供后续 Turn 使用。
start如果新开 Turn,这一轮有什么专属要求?包含触发来源、结构化输出 Schema、单轮服务档位、父/根 Turn 关系等。Started 才正式记录;若正在 Steering,输出 Schema 和 lineage 等会用于检查是否与活动 Turn 兼容。
additional_context除了用户消息,还要补哪些背景?按来源 ID 保存的补充上下文,每项有文本值和来源类别。无论 Started 还是 Steered,成功时都会合并进输入。
responsesapi_client_metadata调用模型服务时带哪些客户端标签?可选的字符串键值表,随 Responses API 请求传递。它是元数据,不是用户正文,也不是让模型执行的自然语言指令。
trace日志里怎样认出这是同一次请求?可选的 W3C Trace Context。它像物流追踪号,让跨 App Server、Session 队列和后续处理的日志能够关联;它不决定模型回答内容。

高效记法:input 是主件;thread_settings 是 Thread 今后的运行规则;start 是“真开新 Turn 才落地”的本轮选项;其余三个分别是补充材料、服务标签和追踪号。

展开:为什么代码先 new,再连续 with...
TurnInputRequest::new(TurnInput::UserInput { ... })
    .with_thread_settings(thread_settings)
    .on_start(TurnStartOptions { ... })
    .with_additional_context(additional_context)
    .with_responses_metadata(metadata)
    .with_trace(trace)

new(...) 强制先给出不可缺少的主件 input,并把其余字段设为默认值:设置和启动选项为空覆盖,补充上下文为空表,元数据与追踪信息为 None。后面的 Builder 方法每次补一类可选材料,再把整个对象返回,所以能连着写。

这也说明:六个字段都存在于结构体中,不等于客户端每次都显式填写六份内容。很多字段只是保持默认值。

源码依据:TurnInputRequest 与 Builder

真实代码精读 ②:“提交邮箱”到底是什么、为什么需要它?

先回答“是什么”

每个运行中的 Session 会创建一对内存通道:tx_sub 是发送端,rx_sub 是接收端。CodexThread 内部的 I/O 句柄拿着发送端投递 Submission;后台的 submission_loop 守着接收端,收到一件就查看其中的 op,再交给对应处理函数。

因此,“提交邮箱”不是某一个变量,而是发送端 + 有界队列 + 接收端 + 持续收件循环这套机制的通俗合称。

一次 Turn 输入实际怎样穿过它?

let id = new_submission_id();
let (reply_tx, reply_rx) = oneshot::channel();

self.submit_with_id(Submission {
    id,
    op: Op::TurnInput {
        request: Box::new(request),
        mode,
        reply: reply_tx,
    },
    ...
}).await?;

reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))

上面是寄件方。下面是 Session 收件方的关键代码:

while let Ok(sub) = rx_sub.recv().await {
    match sub.op {
        Op::TurnInput { request, mode, reply } => {
            let result = turn_input::handle(...).await;
            let _ = reply.send(result);
        }
        // Interrupt、审批回复、Shutdown 等其他内部操作也从这里分发
    }
}
new_submission_id() 给这次投递生成唯一 ID;如果最终新开 Turn,这个 ID 也成为新 Turn ID。
oneshot::channel() 创建一条只能回一次的专用回执线;reply_tx 随包裹寄走,reply_rx 留给调用者。
Submission 把 ID、操作 Op::TurnInput、请求、路由模式和回执发送端装在一起。
submit_with_id 最终执行 tx_sub.send(sub).await,把它放进 Session 的提交队列。
submission_looprx_sub 取出,调用 turn_input::handle 做状态路由,再用 reply.send(result) 寄回受理结果。
原调用者在 reply_rx.await 等到回执后返回;这时模型任务可能才刚启动。

为什么不直接调用状态函数?

同一 Session 还可能收到追加输入、中断、审批回复、设置更新和关闭等操作。它们若从不同调用方同时直接修改状态,就更容易发生先后冲突。统一投递后,submission_loop 按收件顺序分发,状态判断集中在拥有 Session 状态的位置。

排队是否等于所有任务串行执行?

不等于。串行的是“收件和路由决定”。当 Started 分支调用 spawn_task 后,模型与工具工作在后续异步任务中继续。本邮箱没有等完整 Turn 结束才收下一封。

容易误解的点准确含义
Submission 就是 TurnInputRequest 吗?不是。TurnInputRequest 是办事材料;Submission 是更外层的内部投递信封。它的 op 里才装着 boxed request、路由模式和回复端。
Box::new(request) 是发到网络吗?不是。它把较大的请求放到堆上,并把所有权交给 Op::TurnInput;仍在当前 Codex 进程内。
第一个 .await? 在等什么?等请求成功放入 Session 队列;若发送端已关闭,转成 InternalAgentDied 向上返回。
reply_rx.await 在等什么?等 Session 对这一次提交给出 Started / Steered / NotSubmitted 路由回执,不等模型回答。
为什么叫“有界队列”?源码创建队列时容量是 512。队列满时,发送方的 send(...).await 会等待空位,避免无限制地把提交堆在内存里。

最高效的记法:请求包是“做什么”;Submission 是“投递哪种内部操作”;提交队列是“按次序送到状态拥有者”;oneshot 是“只回一次受理结果”。

源码依据:Session 创建容量 512 的提交通道 · 发送 Op::TurnInput 并等待一次性回复 · Session submission loop

真实代码精读 ③:真正的三分支决策

match session.steer_input(...).await {
    Ok(turn_id) => {
        settings.apply_steered(...).await?;
        Ok(TurnInputSubmission::Steered { turn_id })
    }

    Err(NotSubmittedReason::NoActiveTurn) => {
        let turn_context = settings.apply_started(...).await?;
        session.spawn_task(turn_context, task_input, RegularTask::new()).await;
        Ok(TurnInputSubmission::Started { turn_id: submission_id })
    }

    Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
}

把“检查事实”和“选择动作”分开

Session::steer_input 加锁读取 active_turn,检查活动任务类型、空输入和 Schema 等条件,返回 Ok(turn_id) 或具体的 NotSubmittedReason。上面的 start_or_steer 不直接读取这些字段,而是根据返回值选择动作:追加成功、没有活动 Turn 就启动、其他原因就拒绝。

反直觉点:它是“先尝试追加,找不到活动 Turn 才新建”

NoActiveTurn 在这里只是内部控制信号,不是最终拒绝结果。只有其他无法追加的原因,才会作为 NotSubmitted 返回上游。

当前状态Core 结果Turn ID是否创建 RegularTask
没有活动 Turn,输入非空Started新的 submission_id
没有活动 Turn,items 为空Started新的 submission_id是;没有新增用户输入项,仍可基于已有上下文运行
普通 Turn 活动中,输入非空且兼容Steered原活动 Turn ID否,输入加入当前 Turn
Review 或 Compact Turn 活动中NotSubmitted(ActiveTurnNotSteerable)无新 ID
活动 Turn + 空输入NotSubmitted(EmptyInput)无新 ID
活动 Turn 的输出 Schema 不兼容NotSubmitted(ActiveTurnOutputSchemaMismatch)无新 ID

源码依据:路由入口与 start_or_steer · steer_input 的活动 Turn 检查

最重要的类型区别:拒绝不一定是系统错误

CodexResult<TurnInputSubmission>

// 系统/程序级错误
Err(CodexErr::InternalAgentDied)

// 系统正常运行,但业务路由明确拒绝
Ok(TurnInputSubmission::NotSubmitted {
    reason: NotSubmittedReason::EmptyInput,
})

这对产品设计非常重要:系统故障和状态不允许是两类问题。前者需要重试、报警或恢复;后者应该给用户可理解的原因和下一步操作,而不是统一显示“发生未知错误”。

StartedSteered 也只表示 Core 接受了输入。源码明确说明:它们不等待 Prompt Hook、模型上下文更新、持久化或模型采样,更不代表 Turn 完成。

源码依据:TurnInputSubmission 与拒绝原因

面向最终目标:GEODYNA 现在已有一个相似的雏形

只读观察,没有修改 GEODYNA:当前 LLMCommandBar.tsx 已有 pendingSmartInput,并通过 shouldContinuePendingSmartCommand 判断用户的新文字是在补充待完成指令,还是在发起另一条命令。
CodexGEODYNA 当前雏形未来智能体应补齐
活动 TurnpendingSmartInput 表示尚缺参数的智能指令显式的 Agent Task 状态与任务 ID
Steered把补充文字与之前的用户指令合并后重新分类把兼容的新约束安全注入正在运行的任务,并保留审计记录
Started把输入作为一条新 Smart Command 分类和执行新建任务上下文,经过预览、确认和正式 Command API 执行
NotSubmitted(reason)needsInputunsupported、确认与撤销失败提示结构化拒绝原因:不可追加、冲突、危险操作待确认、项目状态过期

产品判断:工程软件不能把所有“运行中再次输入”都默认为 Steering。若当前任务正处于不可逆提交边界,应明确拒绝或要求用户选择“追加约束、排队为下一任务、先中断当前任务”。

GEODYNA 只读依据:D:\GEODAMNEW\components\LLMCommandBar.tsx:318 的延续判断,以及 :692 开始的发送路由。

腾讯 AI 原生产品经理视角

需要定义的产品语义

  • 运行中再次输入,是追加、排队、中断还是拒绝?
  • 用户怎样知道新输入进入了哪个任务?
  • 哪些任务允许 Steering,哪些必须重新确认?
  • 拒绝时给出什么可行动的原因?

需要观测的指标

  • Started、Steered、NotSubmitted 的比例
  • Steering 后的任务成功率与撤回率
  • 错误 Steering 导致的纠正次数
  • 各类拒绝原因与用户后续行为

即时小测

1. 当前普通 Turn 正在运行,新输入非空且兼容,会创建新 Turn 吗?

2. start_or_steer 先尝试哪条路径?

3. Ok(NotSubmitted) 是否等于 Core 系统故障?

4. reply_rx.await 等到的是什么?

5. Thread 当前空闲,App Server 真的传入 input: [],Core 一定返回 EmptyInput 吗?

6. 普通文字真正放在 TurnInputRequest 的哪条嵌套路径里?

7. 课程说的“提交邮箱”,在源码里最准确地指什么?

最后用两句话交作业

按你的理解补完整,不要照抄。先说“东西是什么”,再说“流程做什么”:

对象:TurnInputRequest 是 ______;普通文字位于 input → ______ → content → ______。所谓“提交邮箱”实际是 ______,oneshot 通道只负责返回 ______。

职责:start_or_steer_turn 位于 ______ 层。它让 Session 根据最新状态决定 ______、______ 或 ______;这些结果都不表示 ______。