DSH / Atlas
2026-07-30implementedarchitecture

Follow-up enqueue and owned run boundaries

follow-up 入队与自有运行边界

`Agent.followup()` identifies and queues a user message, but one follow-up does not own the activity that follows it. Steering, injected context, tool continuations, recovery, and later queued messages can all contribute before the agent next becomes idle. A `MessageId` can therefore prove inbox admission, but it cannot identify which assistant message or `turn/end` is the result of that input. The [one-send-one-turn

English

Problem

Agent.followup() identifies and queues a user message, but one follow-up does not own the activity that follows it. Steering, injected context, tool continuations, recovery, and later queued messages can all contribute before the agent next becomes idle. A MessageId can therefore prove inbox admission, but it cannot identify which assistant message or turn/end is the result of that input.

The one-send-one-turn decision already rejects a per-send completion handle in the core API. Protocol and SDK layers that pair one prompt request with a turn result manufacture that missing relationship downstream. The pairing becomes ambiguous as soon as activity admits more input, and it exposes turn mechanics as if they were a prompt-level outcome.

Decision

Keep Agent.followup(message): void as an enqueue-only operation. Agent.whenIdle() and agent/status remain whole-agent lifecycle observations; neither settles an individual message. Inbox durability records the identified message and its admission or cancellation, without assigning later output to it.

The low-level SDK protocol answers session/prompt as soon as enqueue succeeds with { messageId }. It streams durable facts through session.event, publishes whole-agent transitions through session.status, and has no session.finished. A low-level client may observe that receipt and later idleness, but receives no prompt result.

High-level automation APIs return a RunResult only when they explicitly own an activity interval. The TypeScript and Python SDK run() methods collect from the submitted message's durable inbox receipt through the next whole-agent idle; their final response is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The Python SDK also reports the last root turn's reason kind as the run-level finish_reason, without attributing it to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run.

ACP must return a protocol stopReason. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic end_turn. Token-limit endings are not attributed to the prompt: they settle as end_turn. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as cancelled at idle alongside explicit ACP cancellation or disposal.

Goal continuation retains MessageId only to recognize its durable queued and admitted goal message. It advances from durable goal state at whole-agent idle, without mapping the message to a turn result.

Alternatives considered

Map MessageId to the turn that admits it. A turn may consume steering and injected context and may continue through multiple model/tool steps. The mapping identifies admission, not causal ownership of the resulting output or stop reason.

Return a per-follow-up completion handle. A handle would imply a result boundary that the shared agent lifecycle does not have. It would either omit work that influenced the activity or silently absorb unrelated later input.

Use the last turn/end observed before idle. This is a useful run-level observation for an explicitly owned interval, but naming it as the submitted message's outcome recreates the false causal claim.

Verification

  • Agent and inbox tests pin enqueue-only follow-up, durable admission or cancellation, and whole-agent idle observation.
  • SDK protocol, TypeScript SDK, and Python SDK tests pin the { messageId } receipt, session.status, the absence of session.finished, and receipt-to-idle RunResult collection without prompt-level status or reason; Python SDK tests separately pin its run-level finish_reason observation.
  • ACP, one-shot CLI, goal continuation, and subagent tests pin the distinct activity ownership each integration possesses.
  • Consumer tests pin that no production integration derives a follow-up result by correlating MessageId with turn/end.

Consequences

An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from SDK and ACP results; callers may inspect run-level or durable event facts without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result.

中文

问题

Agent.followup() 会标识一条用户消息并将其排入队列,但单次 follow-up 并不拥有随后发生的活动。在 agent(智能体)下一次进入 idle 前,steering(中途引导)、注入的上下文、工具续行、恢复和后续排队消息都可能参与活动。因此,MessageId 可以证明消息已获 inbox 准入,但不能标识哪一条 assistant 消息或哪一个 turn/end 是该输入的结果。

one-send-one-turn 决策 已经在核心 API 中排除了按 send 返回完成句柄的设计。凡是把一项提示词请求与一个轮次结果配对的协议层和 SDK 层,都会在下游人为构造这一缺失的关系。一旦活动准入更多输入,该配对就会产生歧义,还会把轮次机制暴露为提示词级结果。

决策

保留 Agent.followup(message): void,使其仅执行入队。Agent.whenIdle()agent/status 仍用于观察整个 agent 的生命周期;二者都不结算单条消息。Inbox 持久性会记录已标识消息及其准入或取消,但不会把后续输出归属于该消息。

底层 SDK 协议在入队成功后立即以 { messageId } 响应 session/prompt。它通过 session.event 流式传输持久事实,通过 session.status 发布整个 agent 的状态转换,且不包含 session.finished。底层客户端可以观察该回执和之后的 idle,但不会收到提示词结果。

只有明确拥有一个活动区间时,高层自动化 API 才返回 RunResult。TypeScript 和 Python SDK 的 run() 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 idle;其最终响应是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。Python SDK 还把根会话最后一个轮次的结束原因 kind 作为运行级 finish_reason 返回,但不会将其归因于已提交的提示词。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。

ACP(Agent Client Protocol)必须返回协议规定的 stopReason。其桥接层对每个 ACP 会话中的提示词进行串行处理,确保一次只有一个提示词正在处理,等待整个 agent 进入 idle,其他情况均报告通用的 end_turn。token 上限的轮次结束不归因于提示词:它们以 end_turn 结算。与该提示词关联的轮次上的模型错误会立即以该错误拒绝提示词(错误按其所属轮次归因),而无轮次的 slot(准入已丢弃提示词)会在 idle 时以 cancelled 结算,与显式 ACP 取消或 dispose(资源释放)并列。

Goal 续行只保留 MessageId,用于识别持久排队和已准入的 goal 消息。它在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。

考虑过的替代方案

MessageId 映射到准入它的轮次。 一个轮次可能使用 steering 和注入的上下文,还可能经过多个模型/工具步骤继续执行。该映射只能标识准入,不能确立结果输出或停止原因的因果归属。

返回按 follow-up 区分的完成句柄。 这样的句柄暗示共享 agent 生命周期中存在并不实际成立的结果边界。它要么遗漏影响活动的工作,要么在不作说明的情况下吸收后续无关输入。

使用进入 idle 前观察到的最后一个 turn/end 对于明确拥有的区间,这是一项有用的运行级观测;但如果将其命名为已提交消息的结果,就会再次作出错误的因果声明。

验证

  • Agent 与 inbox 测试固定 follow-up 仅入队、持久准入或取消以及整个 agent 的 idle 观测。
  • SDK 协议、TypeScript SDK 和 Python SDK 测试固定 { messageId } 回执、session.status、不存在 session.finished,以及不含提示词级 statusreason 的回执到 idle RunResult 收集;Python SDK 测试另行固定其运行级 finish_reason 观测。
  • ACP、单次 CLI、goal 续行和 subagent 测试固定各集成实际拥有的不同活动边界。
  • 消费方测试固定生产集成都不会通过关联 MessageIdturn/end 来推导 follow-up 结果。

后果

自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。SDK 和 ACP 结果仍不包含提示词级模型错误和 token 上限分类;调用方可以检查运行级或持久事件事实,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。