diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 23c4ea4142..a9d00bc3b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449 -2026-07-05-reconstructable-requests.zh.md: 7b8a9df65b60f975bc3ae60b2c1b0c3a8cc22e95 +2026-07-05-reconstructable-requests.md: 3786de02d06c0b6c094297ae89ac3f84053e408d +2026-07-05-reconstructable-requests.zh.md: 851045aca7dababd0da859f3b04b721c65382fc3 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 3f49ba71a6..3786de02d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. +`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. +Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. @@ -42,6 +42,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. - **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation. +- **A lightweight series marker referencing the previous header**: reduced repeated prompt and tool bytes, but a window beginning at that marker could not render or reconstruct the request without fetching its predecessor. A self-contained full snapshot preserves one representation for persistence, partial history, and snapshot pinning. - **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values. ## Consequences @@ -52,5 +53,5 @@ Like MiniCode, the conversation advances append-only and resets only when model- - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. - Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction. -- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. -- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. +- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. +- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 7b8a9df65b..851045aca7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -22,9 +22,9 @@ Status: implemented **消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 +每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 @@ -42,6 +42,7 @@ Status: implemented - **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因违规必须在接口层面不可表达而否决。 - **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。 - **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。 +- **引用前一个 header 的轻量 series 标记**:减少重复的提示词与工具字节,但从该标记开始的窗口若不再读取前序,就无法渲染或重建请求。自包含的完整快照让持久化、局部历史和快照固定共用一种表示。 - **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。 ## 后果 @@ -52,5 +53,5 @@ Status: implemented - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 - 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 - 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。 -- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 -- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 +- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 +- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 \ No newline at end of file diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index e7aa813ad5..eea93dc866 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648 -2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77 +2026-08-09-client-conversation-node-assembly.md: ea2505d4a72f483a9df6fcd78d7e5c9a96b02f5c +2026-08-09-client-conversation-node-assembly.zh.md: b87f127d753cadf2805ed5cd948fc58ad01830aa diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index e6c0e790a3..ea2505d4a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -260,6 +260,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid | Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` | | Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set | | Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering | +| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes | | Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data | | Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` | | Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence | @@ -276,6 +277,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid |---|---|---|---| | Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices | | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key | +| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor | | Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation | | Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key | | Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key | @@ -288,6 +290,8 @@ Page size, the number of history loads, and RAF coalescing affect only when evid Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. +Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history. + Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes `assistant-step` data for each Step; Turn Tail derives `turn-tail` data from those Step values; Deliverables independently maintains `deliverables` data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 702ddba001..b87f127d75 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -260,6 +260,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 | Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice | | Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 | | Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering | +| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 | | Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data | | Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent Map;Dispatch Event 用 `rootCallId` 精确路由 | | Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 | @@ -276,6 +277,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 |---|---|---|---| | Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 | | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | +| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 | | Assistant | chunk 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | | Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key | | Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key | @@ -288,6 +290,8 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。 + Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 `assistant-step` data;Turn Tail 从这些 Step values 计算 `turn-tail` data;Deliverables 独立维护同一 Turn 的 `deliverables` data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。 diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.i18n.yaml new file mode 100644 index 0000000000..3af8585b6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md +2026-08-17-web-system-prompt-opaque-body.md: 9b1992dd8eb1062a3b35666b332178985aa91653 +2026-08-17-web-system-prompt-opaque-body.zh.md: 6f2fbba9d3f286e8c3073197a144d347f732cbc1 diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md new file mode 100644 index 0000000000..9b1992dd8e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md @@ -0,0 +1,29 @@ +# Agent Note: System prompt expands into the opaque context body + +Status: implemented + +English | [中文](2026-08-17-web-system-prompt-opaque-body.zh.md) + +## Problem + +The Chat `System prompt` row shares `DisclosureRow` chrome with context injection and needs an expanded body for the request's system field. Rendering that field as Markdown would restyle it — headings, emphasis, lists — so a reader would see a rendered document the model never received. Context injection already solves the same job with a 141px code-block scrollport and `
` text that keeps the bytes and line breaks the model read, so the row needs that presentation, not a second one.
+
+## Decision
+
+`SystemPromptRow` mounts the same expanded body as an opaque context injection. It reuses `ContextInjectionRow.module.css` for the 141px Figma 10:2482 scrollport and renders the durable `request/header` system string through `OpaqueBody` as one text block, so the disclosure shows model-facing text with its real line breaks and the same 20_000-character display bound. The row stays collapsed by default and still has no streaming path. It does not grow a producer label, form marker, or source-field list: the system field is one joined string on the header, not a sourced `user/message`.
+
+## Alternatives considered
+
+**Render settled Markdown in a card-styled body.** The chrome could match, but Markdown rewrites what the model read. A heading or bold span is a different document from the request bytes.
+
+**Split the joined system string into snapshot sections.** The durable header stores only the assembled text. Inventing section boundaries in the client would attribute prose the log does not name, and a resumed or foreign header could not reconstruct them.
+
+**Render through `ContextInjectionRow` itself.** That row is for sourced user-role messages: it titles a role, shows a producer, and chooses a form body. The system field is a different durable fact and has none of those fields.
+
+## Consequences
+
+The two disclosures now share one expanded-body chrome and one text presentation, so a later change to the 141px scrollport or the opaque bound applies to both. The cost is that a long system prompt scrolls inside 141px instead of 360px, and Markdown markup in the prompt stays visible as characters.
+
+## Testing
+
+`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` expands and collapses the row and pins the opaque `[data-context-text]` bytes, including Markdown markers that must not become a heading. `apps/web/tests/replay-round-trip.e2e.ts` still opens the assembled disclosure and reads the persona line from that body.
diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md
new file mode 100644
index 0000000000..6f2fbba9d3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: System prompt expands into the opaque context body
+
+Status: implemented
+
+[English](2026-08-17-web-system-prompt-opaque-body.md) | 中文
+
+## Problem
+
+Chat 的 `系统提示词` 行和上下文注入共用 `DisclosureRow` 外壳,其展开内容区需要呈现请求的 system 字段。如果把该字段渲染成 Markdown——标题、强调、列表——读者看到的将是模型从未收到的排版文档。上下文注入已经用 141px 代码块滚动区和保留模型所见字节与换行的 `
` 文本解决了同一件事,因此该行需要复用这一呈现,而不是再造一套。
+
+## Decision
+
+`SystemPromptRow` 展开后挂载与不透明上下文注入相同的内容区。它复用 `ContextInjectionRow.module.css` 的 Figma 10:2482 的 141px 滚动区,并把持久 `request/header` 的 system 字符串作为一块文本交给 `OpaqueBody`,因此展开后看到的是带真实换行的模型可见文本,以及相同的 20_000 字符显示上限。该行默认折叠,仍然没有流式路径。它不增加生产者标签、form 标记或 source 字段列表:system 字段是 header 上的一段拼接字符串,不是带 source 的 `user/message`。
+
+## Alternatives considered
+
+**在卡片式内容区里渲染结算后的 Markdown。** 外壳可以对齐,但 Markdown 会改写模型读到的内容。标题或加粗是另一份文档,不是请求里的字节。
+
+**把拼接后的 system 字符串拆成 snapshot 分段。** 持久 header 只保存组装后的文本。客户端臆造分段边界会把日志未命名的正文归到某个子系统,恢复或外来 header 也无法重建这些分段。
+
+**直接走 `ContextInjectionRow`。** 那一行面向带 source 的 user-role 消息:它标角色、显示生产者,并按 form 选内容区。system 字段是另一件持久事实,没有这些字段。
+
+## Consequences
+
+两处展开现在共用同一套内容区外壳和同一套文本展示,因此之后改 141px 滚动区或不透明显示上限会同时作用到两边。代价是较长的系统提示词在 141px 而不是 360px 内滚动,提示词里的 Markdown 标记会以字符形式可见。
+
+## Testing
+
+`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` 会展开并折叠该行,并钉住不透明 `[data-context-text]` 字节,包括不得变成标题的 Markdown 标记。`apps/web/tests/replay-round-trip.e2e.ts` 仍会打开组装后的展开行,并从该内容区读出 persona 行。
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
index 48800b1c08..68511a8efe 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
-2026-07-12-simplify-session-log-representation.md: 3efb531d3c0822d7444d1270eac4da2617c12447
-2026-07-12-simplify-session-log-representation.zh.md: 8c05ac6512a8aa8e7fefc56ba410bc81dc3be277
+2026-07-12-simplify-session-log-representation.md: a0c86b66af78b4c94991d38656f03609297e2314
+2026-07-12-simplify-session-log-representation.zh.md: ceaf90236a47c141e3a4bb2cc78d415b3b9ac2ba
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
index 3efb531d3c..a0c86b66af 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
@@ -18,7 +18,7 @@ The implementation retains append and replacement `sourceEventSeqs`, the `tool/c
 
 `SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. The complete `foldSurface()` read used by session-query returns the same number-array representation plus replacement metadata without making the incremental manager retain history. Tool-pairing balance and compaction use event sequences and surface positions; the compact-owned per-cut balance cache does not depend on node links.
 
-Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
+Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a full snapshot with reason `series`. Ordinary append-only later Turns, further Steps, and retries in that model-message series inherit the latest snapshot. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
 
 `SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts.
 
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
index 8c05ac6512..ceaf90236a 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
@@ -18,7 +18,7 @@ Status: implemented
 
 `SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩(compaction)使用事件序号与 surface 位置;由 compact 拥有的每个切点的 balance cache 不依赖 node 链接。
 
-请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
+请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `series` 的完整快照。普通的仅追加后续 Turn、同一模型消息序列内的后续 Step 与重试沿用最新快照。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
 
 `SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一失败即报错的边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为固定的完整请求头和完整可读提示词。
 
diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
index 3804ea77b3..390b157a76 100644
--- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
+++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
@@ -45,12 +45,13 @@
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"}]}}
@@ -58,9 +59,10 @@
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":3,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
 {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
-{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[59,60],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[61,62],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":3,"step":1}}
 {"type":"turn/end","data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
 {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}
diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
index 71d23de033..b724c08ef5 100644
--- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
+++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
@@ -35,15 +35,16 @@
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}
 {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}}
-{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[43],"surfaceOp":"append"}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"}]}}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
@@ -54,6 +55,6 @@
 {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}}
 {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":2}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts
index 8130e70308..e18af7c791 100644
--- a/apps/web/tests/chat-continuous-conversation.e2e.ts
+++ b/apps/web/tests/chat-continuous-conversation.e2e.ts
@@ -330,6 +330,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
     expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
       event.type === 'turn/end' && event.data.reason.kind === 'completed'
     ))).toHaveLength(TURN_COUNT)
+    expect(sessionEvents.flatMap(event =>
+      event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
+    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
+      timeout: 10_000,
+    }).toBe(1)
     expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
     expect(sessionEvents.filter(event => (
       event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md
index 0f4902439c..738a1b11a3 100644
--- a/apps/web/tests/expected/github-ready-review/conversation.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md
@@ -20,6 +20,10 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
 - button "Context injection webhook github webhook handled by review-pr-when-ready":
   - img
   - img
diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
index 0aa6eeb091..b06b3aae82 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -9,6 +9,10 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md
index 7084523979..c201520e8d 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -9,6 +9,10 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - button "Copy":
   - img
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index 96da765b9b..885143a0f6 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -9,6 +9,10 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - button "Copy":
   - img
diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts
index 346593529b..54af9f2da9 100644
--- a/apps/web/tests/goal-multi-turn-actions.e2e.ts
+++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts
@@ -151,6 +151,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
       .toEqual([1, 2])
     expect(goalRounds(sessionEvents)).toEqual([1, 2])
+    expect(sessionEvents.flatMap(event =>
+      event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
+    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
+      timeout: 15_000,
+    }).toBe(2)
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
     await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
     expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts
index 30644607f5..6325e202b2 100644
--- a/apps/web/tests/replay-round-trip.e2e.ts
+++ b/apps/web/tests/replay-round-trip.e2e.ts
@@ -150,6 +150,25 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
 
+  it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
+    const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
+    const body = page.locator('[data-system-prompt-body]')
+    await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+    expect(await body.count()).toBe(0)
+
+    await disclosure.click()
+    await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
+    const opaque = body.locator('[data-context-text]')
+    await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1)
+    expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.')
+
+    await disclosure.click()
+    await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+    await expect.poll(() => body.count()).toBe(0)
+  })
+
   it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
     // Interaction over the REAL wire-delivered transcript (the fixture-client
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
index 7207de464b..ebcb6163c7 100644
--- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -9,6 +9,10 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
 - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
 - button "Copy":
   - img
diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml
index 0bd71efcb6..7e3e47b3ac 100644
--- a/docs/agent-lifecycle.i18n.yaml
+++ b/docs/agent-lifecycle.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/agent-lifecycle.md
-agent-lifecycle.md: 30509e17ce24ff2d078f86b6cc2a24b77ae3e4fa
-agent-lifecycle.zh.md: 693824913b2b9fcb627591a98804778a09e968a6
+agent-lifecycle.md: 9d1b66888e35d840c95ee9f2bd589dad3aac66f6
+agent-lifecycle.zh.md: f1648792fa15495f878ae2ec362bb760ccf2dc22
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 30509e17ce..9d1b66888e 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -75,7 +75,7 @@ The `assistant/message` event records every successful provider call, including
 
 `dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
 
-The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
+The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
 
 SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.
 
diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md
index 693824913b..f1648792fa 100644
--- a/docs/agent-lifecycle.zh.md
+++ b/docs/agent-lifecycle.zh.md
@@ -77,7 +77,7 @@ sequenceDiagram
 
 `dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。
 
-以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
+以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息与 `startsRequestSeries`,除非有意替换。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
 
 需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。
 
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 82e57fd2f7..164230cbd6 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: add615c252948adab8db7dec7059f94b8f45e52c
-architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748
+architecture.md: c6e01b8c30486d292694cbc26836e83522e3e760
+architecture.zh.md: 21d60d0c962097ee6853bf7a3831a2c0b727e9c9
diff --git a/docs/architecture.md b/docs/architecture.md
index add615c252..c6e01b8c30 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -79,7 +79,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
 turn/start
   claim next-step input plus one queued message
   assemble prompt sections + tool schemas
-  -> agent/pre-step                   reject | enter(messages)
+  -> agent/pre-step                   reject | enter(messages, startsRequestSeries?)
      reject, or a first enter rewritten empty -> close the turn with no step
      step/start
      append entered messages as user/message
@@ -96,7 +96,7 @@ turn/end
 
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
-`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Each step reads the prompt sections and tool schemas that plugins registered.
+`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
 
 Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
 
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 48baf14d6a..21d60d0c96 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -83,7 +83,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
 turn/start
   claim next-step input plus one queued message
   assemble prompt sections + tool schemas
-  -> agent/pre-step                   reject | enter(messages)
+  -> agent/pre-step                   reject | enter(messages, startsRequestSeries?)
      reject, or a first enter rewritten empty -> close the turn with no step
      step/start
      append entered messages as user/message
@@ -100,7 +100,7 @@ turn/end
 
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
-`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。每个步骤读取插件注册的提示词片段和工具 schema。
+`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
 
 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
 
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index c87c5ac974..24a16e691a 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 586316e90992447d45ce2b0f0d67c306689f95cb
-event-producer-consumer.zh.md: 4aebaa2f10e4975df238639a1dac40694f50bed2
+event-producer-consumer.md: de2a94abb5e4d16433eae71e34e329fcf0042ede
+event-producer-consumer.zh.md: 7a9e825750213b2d0a67d9c022bffe031194c8ba
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 586316e909..de2a94abb5 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:199`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:207`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:188`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:246`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 4aebaa2f10..7a9e825750 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -11,18 +11,18 @@
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 30b1894dbe..04ad9a33d7 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 893ffef71be98afe2356419dcb6ca0d871f26649
-persistence-catalog.zh.md: e34ce2b4b67746f9ce79f3d61add5e7f59e1aa22
+persistence-catalog.md: 12558eeadc009b498c9a178cfcc79116bf1b7c2b
+persistence-catalog.zh.md: f855d6969aa2dcade159ac8d6549e5f0350a7f0f
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 893ffef71b..12558eeadc 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -90,7 +90,7 @@ export type SessionEvent = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:396`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/inter
 
 Types: [StreamChunk](subsystems/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
 
 
 
@@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/
 
 Types: [TokenUsage](subsystems/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -563,7 +563,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s
 'request/context': RequestContext
 ```
 
-Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts)
 
 
 
@@ -574,10 +574,15 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/
  * Full header for the next request, appended inside its step before dispatch.
  * It is log-only; the latest snapshot reconstructs the request header.
  */
-'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+'request/header': {
+  header: EpochHeader
+  reason: RequestHeaderReason
+  /** A changed header also begins a distinct model-message series. */
+  startsSeries?: true
+}
 ```
 
-Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -652,7 +657,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
 'session/end-seed': Record
 ```
 
-Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
 
 
 
@@ -712,7 +717,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:26`](../packages/se
 'step/end': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
 
 
 
@@ -723,7 +728,7 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/
 'step/start': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
 
 ### `subagent/*`
 
@@ -851,7 +856,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
 
 Types: [CallId](subsystems/core.md)
 
-Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
 
 
 
@@ -926,7 +931,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 
@@ -1006,7 +1011,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
 
 Types: [TurnEndReason](subsystems/session.md)
 
-Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
 
 
 
@@ -1022,7 +1027,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/
 'turn/start': { turn: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -1041,7 +1046,7 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/
 'user/message': UserMessage
 ```
 
-Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
 
 ### `web/*`
 
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index e34ce2b4b6..f855d6969a 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -576,7 +576,12 @@ export type SessionEvent = {
  * Full header for the next request, appended inside its step before dispatch.
  * It is log-only; the latest snapshot reconstructs the request header.
  */
-'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+'request/header': {
+  header: EpochHeader
+  reason: RequestHeaderReason
+  /** A changed header also begins a distinct model-message series. */
+  startsSeries?: true
+}
 ```
 
 来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml
index fb5dd11fbb..6825069021 100644
--- a/docs/subsystems/core.i18n.yaml
+++ b/docs/subsystems/core.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: c53bb94fa5918c3a91ee9aedbb2416d0b101b240
-core.zh.md: 93ee45fb88cc100eb77673f2b70e86483c7ed29f
+core.md: d3564b6d50e0087be25f5dd1abc7b19507fd1c21
+core.zh.md: 0f7e49141e27b18ec8b75e944460d3ac04a88c0a
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index c53bb94fa5..d3564b6d50 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -224,7 +224,12 @@ It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complet
 /** Whether and with which messages the loop enters a proposed step. */
 type PreStepDecision =
   | { kind: 'reject' }
-  | { kind: 'enter'; messages: UserMessage[] }
+  | {
+    kind: 'enter'
+    messages: UserMessage[]
+    /** Start a distinct model-message series before this step's admitted messages. */
+    startsRequestSeries?: true
+  }
 ```
 
 `agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal.
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 93ee45fb88..0f7e49141e 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -232,7 +232,12 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
 /** Whether and with which messages the loop enters a proposed step. */
 type PreStepDecision =
   | { kind: 'reject' }
-  | { kind: 'enter'; messages: UserMessage[] }
+  | {
+    kind: 'enter'
+    messages: UserMessage[]
+    /** Start a distinct model-message series before this step's admitted messages. */
+    startsRequestSeries?: true
+  }
 ```
 
 `agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。
diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index e3d112e468..732d6ee6c6 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/docs/subsystems/session.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 23b3f8535ac432c297595bdf621cad5cecf717d4
-session.zh.md: ad73efb2d1ec8a2a7df3463518f172103f107296
+session.md: dc0f823cbc529b64d1f19abb3a07ffd39f849904
+session.zh.md: 640d3ee279f2fde140a5a89ee614f4db21485171
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index 23b3f8535a..dc0f823cbc 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -94,7 +94,12 @@ interface SessionEventMap {
    * Full header for the next request, appended inside its step before dispatch.
    * It is log-only; the latest snapshot reconstructs the request header.
    */
-  'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+  'request/header': {
+    header: EpochHeader
+    reason: RequestHeaderReason
+    /** A changed header also begins a distinct model-message series. */
+    startsSeries?: true
+  }
   /**
    * Route metadata for the next request, logged only when the route or capacity
    * changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
 
 ### The request header event: `request/header`
 
-The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
+The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
 
 ```ts type-equiv
 /**
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index ad73efb2d1..640d3ee279 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -94,7 +94,12 @@ interface SessionEventMap {
    * Full header for the next request, appended inside its step before dispatch.
    * It is log-only; the latest snapshot reconstructs the request header.
    */
-  'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+  'request/header': {
+    header: EpochHeader
+    reason: RequestHeaderReason
+    /** A changed header also begins a distinct model-message series. */
+    startsSeries?: true
+  }
   /**
    * Route metadata for the next request, logged only when the route or capacity
    * changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
 
 ### 请求头事件:`request/header`
 
-请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
+请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
 
 ```ts type-equiv
 /**
diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts
index c2b7b03e61..f96c66b464 100644
--- a/packages/api/session-controller/src/agent.ts
+++ b/packages/api/session-controller/src/agent.ts
@@ -291,12 +291,18 @@ export class ApiSessionAgentController {
     const selection: InstalledSelection = {
       get current(): AgentModelSelection {
         if (picked !== undefined) return picked
-        const logged = agent.session.requestHeader()?.config
-        if (logged === undefined) return defaultModel.currentSelection()
+        const loggedHeader = agent.session.requestHeader()
+        if (loggedHeader === undefined) return defaultModel.currentSelection()
+        const logged = loggedHeader.config
         return {
           provider: logged.provider,
           model: logged.model,
-          ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
+          // An effort the adapter defaulted is not a conversation choice: restoring
+          // it as one would make an unchanged default read as a request change.
+          ...(logged.reasoningEffort === undefined
+            || loggedHeader.adapterDefaults?.reasoningEffort === true
+            ? {}
+            : { reasoningEffort: logged.reasoningEffort }),
         }
       },
       set current(next: AgentModelSelection) {
diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts
index 65b9f5f7e7..d203585459 100644
--- a/packages/api/session-controller/tests/session-models.host.spec.ts
+++ b/packages/api/session-controller/tests/session-models.host.spec.ts
@@ -12,13 +12,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import AttachmentStore from '@deepseek-ai/dsh-attachment'
 import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type {
-  GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
-  LlmResolvedModelInfo, StreamChunk,
+  GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo,
+  LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
   UserMessage,
 } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
+import { ApiSessionAgentController } from '../src/agent.ts'
 import { buildModelCatalog } from '../src/catalog.ts'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
@@ -86,6 +87,7 @@ async function harness(logged?: {
   provider: string
   model: string
   reasoningEffort?: ReasoningEffortId
+  adapterDefaults?: LlmCallConfigAdapterDefaults
 }): Promise<{
   ctx: Context
   agent: Agent
@@ -121,7 +123,11 @@ async function harness(logged?: {
   ]))
   const session = ctx.sessions.create()
   if (logged !== undefined) {
-    session.append('request/header', { header: { config: logged }, reason: 'initial' })
+    const { adapterDefaults, ...config } = logged
+    session.append('request/header', {
+      header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } },
+      reason: 'initial',
+    })
   }
   const agent = {
     id: session.id,
@@ -488,6 +494,23 @@ describe('Web session model selection', () => {
     await ctx.fiber.dispose()
   })
 
+  it('does not reinterpret an adapter-owned reasoning default as an explicit Web selection', async () => {
+    const { ctx, agent } = await harness({
+      provider: 'deepseek-official',
+      model: 'deepseek-chat',
+      reasoningEffort: ReasoningEffortId('high'),
+      adapterDefaults: { reasoningEffort: true },
+    })
+    createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }),
+      cwd: '/tmp',
+    })
+
+    expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
+      .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
+    await ctx.fiber.dispose()
+  })
+
   it('saves an accepted selection as the default and survives a storage failure', async () => {
     const { ctx, sessionId } = await harness()
     const saved: unknown[] = []
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 4859153e08..883760c733 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288
-README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17
+README.md: ef9dc65de0d6b990fd0066c387518dc932bd4d2e
+README.zh.md: c4de06b18077485d7d65734b9bb38ff7745a4d67
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 5253cb95b0..ef9dc65de0 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
 
 The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
 
+## System prompt row
+
+Chat contributes a `System prompt` row for a non-empty initial or resumed request, an explicit series start, or an actual system-field change; same-series config-only or tool-only changes, tool steps, and retries do not duplicate it. Chat places the first header in a step at that request's message boundary — turn start for step one, step start thereafter — before the user-role messages sent with the request, matching the provider envelope's system-before-messages order; when the preceding header is outside a partial window, a non-initial header stays at its own Event and renders conservatively until prepend supplies that predecessor. The row stays collapsed by default and mounts the complete prompt in the same 141px code-block body as an opaque context injection — model-facing text with its real line breaks, not Markdown — only while expanded; it has no streaming path. Systemless headers produce no row.
+
 ## Model Experience
 
 None, as this package renders logged conversation state in the browser and registers nothing model-facing.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 8cd2d0d581..c4de06b180 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -4,6 +4,10 @@
 
 Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。
 
+## 系统提示词行
+
+Chat 会为非空的初始或恢复请求、显式序列起点,或 system 字段真实变化贡献一行 `系统提示词`;同一序列内仅配置变化或仅工具变化、工具 step 和重试不会重复该行。Chat 会把一个 step 中的首条 header 放在该请求的消息边界——step one 使用 turn start,其余 step 使用 step start——位于该请求发送的 user-role 消息之前,与提供方信封「system 在 messages 之前」的顺序一致;部分窗口未包含前序 header 时,非 initial header 会保留在自身 Event 并保守渲染,直到 prepend 补入前序 header。该行默认折叠,仅在展开期间把完整提示词挂到与不透明上下文注入相同的 141px 代码块内容区——保留模型所见真实换行的模型可见文本,而非 Markdown;它没有流式路径。无系统提示词的 header 不生成行。
+
 ## 模型体验
 
 无,因为本包在浏览器中渲染已记录的对话状态,不注册任何面向模型的内容。
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index a647ad5c18..118214fcb6 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -13,6 +13,7 @@ import { formatRunDuration } from './message-chrome.ts'
 import css from './ChatView.module.css'
 
 const FOLLOW_THRESHOLD = 24
+const MAX_PAGING_ANCHOR_PROBES = 64
 
 /** Active column host when present; otherwise the view-local scroller. */
 function scrollerOf(from: HTMLElement): HTMLElement {
@@ -26,7 +27,7 @@ interface PagingAnchor {
   top: number
 }
 
-/** Find an already-rendered settled row without interpolating a selector. */
+/** Find an already-rendered row without interpolating a selector. */
 function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
   for (const row of list.querySelectorAll('[data-chat-anchor-key]')) {
     if (row.dataset.chatAnchorKey === key) return row
@@ -45,17 +46,24 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
   const viewport = scrollport.getBoundingClientRect()
   const composer = scrollport.querySelector('[data-composer-seat]')
   const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
-  // Scroll events are hot: hit-test a few points through the stretched flow
-  // rows before considering the full mounted set. The fallback keeps jsdom
-  // and pre-layout states deterministic; a virtualizer naturally bounds it.
+  // Scroll events are hot: walk down one hit-test line and stop at the first
+  // hit row with layout before considering the full mounted set. Starting at the
+  // viewport edge preserves the reader's leading row when a later row is
+  // inserted between already-visible messages. The fallback keeps jsdom and
+  // pre-layout states deterministic; a virtualizer naturally bounds it.
   if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
     const content = list.getBoundingClientRect()
     const left = Math.max(viewport.left, content.left)
     const right = Math.min(viewport.right, content.right)
     const x = left + Math.max(0, right - left) / 2
     const height = visibleBottom - viewport.top
-    const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
-    for (const offset of points) {
+    let probes = 0
+    for (
+      let offset = 1;
+      offset < height && probes < MAX_PAGING_ANCHOR_PROBES;
+      offset = offset === 1 ? 16 : offset + 16
+    ) {
+      probes++
       for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
         const row = element instanceof HTMLElement
           ? element.closest('[data-chat-anchor-key]')
diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
index e72bd594a2..32f88c135c 100644
--- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
+++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
@@ -1,4 +1,5 @@
-/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
+/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap.
+   SystemPromptRow reuses this sheet so both disclosures share one body. */
 
 .root {
   min-width: 0;
diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
index 8be42c0f42..37f08dc2f5 100644
--- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
+++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
@@ -1,6 +1,6 @@
 import { useState } from 'react'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
+import { DisclosureRow, IconContextInjectionOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ContextMessageNode } from '../contract/snapshot.ts'
 import { contextBody } from './ContextBody.tsx'
 import css from './ContextInjectionRow.module.css'
@@ -39,7 +39,7 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
       className={css.root}
       icon={provenance.role === 'recall'
         ? 
-        : }
+        : }
       chevronClassName={css.chevron}
       title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
       collapsedContent={provenance.label === null ? undefined : (
diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx
new file mode 100644
index 0000000000..26c43b1793
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx
@@ -0,0 +1,47 @@
+import { memo, useState } from 'react'
+import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
+import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { OpaqueBody } from './ContextBody.tsx'
+import css from './ContextInjectionRow.module.css'
+
+/** Props for one complete system prompt disclosure. */
+export interface SystemPromptRowProps {
+  /** Complete model-visible prompt text. */
+  text: string
+  /** The owning view's locale seat. */
+  t: ChatViewSlotProps['t']
+}
+
+/**
+ * Render one complete system prompt as a collapsed disclosure whose expanded
+ * body is the same opaque context chrome: 141px code-block scrollport and
+ * model-facing text with its real line breaks.
+ * @param props - Complete prompt text and the locale seat.
+ * @returns The system-prompt disclosure row.
+ */
+export function SystemPromptRow({ text, t }: SystemPromptRowProps) {
+  const [open, setOpen] = useState(false)
+  return (
+    }
+      chevronClassName={css.chevron}
+      title={t('message.systemPrompt')}
+      open={open}
+      expandable
+      expandOnRowClick
+      onToggle={() => { setOpen(value => !value) }}
+    >
+      
+ +
+
+ ) +} + +/** System-prompt keyed Chat renderer. */ +export const SystemPromptNodeView = memo(function SystemPromptNodeView({ + node, t, +}: Pick, 'node' | 't'>) { + return +}) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index 826e1344f2..748d75a364 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -7,6 +7,7 @@ import { TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' import { TurnTailNodeView } from './TurnTailNodeView.tsx' +import { SystemPromptNodeView } from './SystemPromptRow.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. @@ -19,6 +20,8 @@ export function registerChatNodeRenderers(ctx: Context): void { { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index f70e657014..21ed2c13f1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -305,6 +305,8 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { running: null, } case 'turn-tail': + case 'system-prompt': + // These known Chat rows intentionally make no legacy timeline contribution. return EMPTY_CONTRIBUTION default: return EMPTY_CONTRIBUTION diff --git a/packages/client/ui-chat/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts index 5086253e81..d40fb1b00a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/register.ts @@ -6,6 +6,7 @@ import { registerCompactionConversationNode } from './compaction.ts' import { registerUnknownConversationFallback } from './fallback.ts' import { registerInboxConversationNodes } from './inbox.ts' import { registerMessageConversationNode } from './message.ts' +import { registerRequestPromptConversationNode } from './request-prompt.ts' import { registerRetryConversationNode } from './retry.ts' import { registerToolConversationNode } from './tool.ts' import { registerTurnErrorConversationNode } from './turn-error.ts' @@ -19,6 +20,7 @@ import { registerTurnTailConversationNode } from './turn-tail.ts' export function registerConversationNodes(ctx: Context): void { registerInboxConversationNodes(ctx) registerMessageConversationNode(ctx) + registerRequestPromptConversationNode(ctx) registerAssistantConversationNode(ctx) registerToolConversationNode(ctx) registerCommandConversationNode(ctx) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts new file mode 100644 index 0000000000..c7f25cf6db --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -0,0 +1,87 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationMatch, ConversationNodeDefinition, RequestPromptInspector, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { chatNode } from './common.ts' + +declare module '../contract/chat-nodes.ts' { + interface ChatNodeDataMap { + /** Complete system prompt rendered for one model request. */ + 'system-prompt': { readonly text: string } + } +} + +interface RequestPromptState extends ReturnType { + readonly anchorSeq: number + readonly showsPrompt: boolean + readonly turn?: number + readonly step?: number +} + +/** Place a request's system field at the start of its visible message series. */ +function requestPromptAnchor( + match: ConversationMatch, + previous: Readonly | undefined, + isInitial: boolean, +): number { + if (match.location.kind !== 'step') return match.event.seq + if (previous === undefined && !isInitial) return match.event.seq + if (previous?.turn === match.location.turn.turn + && previous.step === match.location.step.step) return match.event.seq + return match.location.step.step === 1 + ? match.location.turn.start?.seq ?? match.location.step.start?.seq ?? match.event.seq + : match.location.step.start?.seq ?? match.event.seq +} + +/** + * Request-header prompt Definition for the Chat target. + * @param inspect - the shared prompt interpretation, supplied by the + * uiConversation service (a client bundle cannot value-import it). + * @returns the Chat request-prompt Definition. + */ +export function requestPromptDefinition(inspect: RequestPromptInspector): ConversationNodeDefinition { + return { + kind: 'request-prompt', + target: 'chat', + match: event => event.type === 'request/header' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'request/header') { + throw new Error('request-prompt start requires request/header') + } + const previous = reader.previous('request-prompt')?.state + const location = match.location.kind === 'step' + ? { turn: match.location.turn.turn, step: match.location.step.step } + : {} + const inspection = inspect(previous?.prompt, match.event) + const change = inspection.change?.kind + return { + anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'), + showsPrompt: previous === undefined + || match.event.data.reason !== 'change' + || match.event.data.startsSeries === true + || change === 'system' + || change === 'system-and-tools', + ...location, + ...inspection, + } + }, + update: context => context.state, + buildViewNode: (context) => { + const state = context.state + if (state === undefined || !state.showsPrompt || state.prompt.system === '') return null + return chatNode(context, 'system-prompt', state.anchorSeq, { text: state.prompt.system }) + }, + } +} + +/** + * Register model-request system prompts in the Chat flow. + * @param ctx - Owning UI Conversation context. + */ +export function registerRequestPromptConversationNode(ctx: Context): void { + ctx.uiConversation.events.register(requestPromptDefinition( + (previous, event) => ctx.uiConversation.inspectRequestPrompt(previous, event), + )) +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index c04c2c588e..d9ce71f2d0 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -5,6 +5,7 @@ export type {} from './conversation-nodes/command.ts' export type {} from './conversation-nodes/compaction.ts' export type {} from './conversation-nodes/fallback.ts' export type {} from './conversation-nodes/message.ts' +export type {} from './conversation-nodes/request-prompt.ts' export type {} from './conversation-nodes/retry.ts' export type {} from './conversation-nodes/tool.ts' export type {} from './conversation-nodes/turn-error.ts' diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index be8a3521ac..d31f767e66 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -33,6 +33,7 @@ export const zh = { 'fileOpen.folderTitle': '无法打开文件夹', 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', + 'message.systemPrompt': '系统提示词', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', @@ -119,6 +120,7 @@ export const en = { 'fileOpen.folderTitle': 'Couldn’t open folder', 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', + 'message.systemPrompt': 'System prompt', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d112bd779c..a681f218c4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -452,6 +452,39 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) + it('bounds no-anchor hit testing before using the mounted-row fallback', () => { + const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint') + const hitTest = vi.fn((): Element[] => []) + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: hitTest, + }) + try { + const h = makeHarness({ nodes: [user(1, 'visible row')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement + installScrollMetrics(scroller, 4_000, 2_000) + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ + top: 0, bottom: 2_000, left: 0, right: 1_000, + } as DOMRect) + vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({ + top: 100, bottom: 140, left: 0, right: 1_000, + } as DOMRect) + + readerScroll(scroller, 100) + + expect(hitTest).toHaveBeenCalledTimes(64) + expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1') + } finally { + if (originalHitTest !== undefined) { + Object.defineProperty(document, 'elementsFromPoint', originalHitTest) + } else { + Reflect.deleteProperty(document, 'elementsFromPoint') + } + } + }) + it('renders the fixture main line as independently keyed business nodes', () => { const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')], diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 88b103ae66..04dbbeeb1f 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -15,6 +15,8 @@ import { compactionDefinition } from '../src/client/conversation-nodes/compactio import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../src/client/conversation-nodes/message.ts' +import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts' import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' @@ -28,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ nextTurnInboxDefinition, nextStepInboxDefinition, messageDefinition, + requestPromptDefinition(inspectRequestPrompt), assistantDefinition, toolDefinition, commandDefinition, @@ -121,6 +124,18 @@ function toolResult(callId: string, text: string, isError = false) { } describe('built-in conversation node Definitions', () => { + it('rejects an unrelated event passed directly to the request-prompt start', () => { + const input = at(1, 'turn/start', { turn: 1 }) + const invalidStart = { + ...input, + role: 'start' as const, + location: { kind: 'session' as const }, + } + + expect(() => requestPromptDefinition(inspectRequestPrompt).start({} as never, invalidStart, {} as never)) + .toThrow('request-prompt start requires request/header') + }) + it('keeps ordinary command-only history inactive for the Conversation shell', () => { const value = assembler([ at(1, 'command/run', { @@ -560,6 +575,223 @@ describe('built-in conversation node Definitions', () => { }) }) + it('materializes series starts and system changes but not same-series config or tool changes', () => { + const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }] + const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }] + const value = assembler([ + at(1, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Initial', tools }, + }), + at(2, 'request/header', { + reason: 'change', + header: { + config: { provider: 'fake', model: 'fake' }, + system: '# Initial', + tools: expandedTools, + }, + }), + at(3, 'request/header', { + reason: 'change', + header: { + config: { provider: 'fake', model: 'fake', maxTokens: 1_024 }, + system: '# Initial', + tools: expandedTools, + }, + }), + at(4, 'request/header', { + reason: 'change', + startsSeries: true, + header: { + config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, + system: '# Initial', + tools: expandedTools, + }, + }), + at(5, 'request/header', { + reason: 'resume', + header: { + config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, + system: '# Initial', + tools: expandedTools, + }, + }), + at(6, 'request/header', { + reason: 'change', + header: { + config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, + system: '# Updated', + tools: expandedTools, + }, + }), + ]) + + const prompts = snapshot(value).nodes.values() + .filter(candidate => candidate.kind === 'system-prompt') + expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([ + { anchorSeq: 1, data: { text: '# Initial' } }, + { anchorSeq: 4, data: { text: '# Initial' } }, + { anchorSeq: 5, data: { text: '# Initial' } }, + { anchorSeq: 6, data: { text: '# Updated' } }, + ]) + + const windowed = assembler([ + at(10, 'request/header', { + reason: 'resume', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' }, + }), + ], true) + const systemless = assembler([ + at(20, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' } }, + }), + ]) + expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' }) + expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined() + + windowed.prepend([ + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' }, + }), + ], false) + windowed.flush() + const restored = snapshot(windowed) + const restoredPrompts = restored.order.flatMap((key) => { + const candidate = restored.nodes.get(key) + return candidate?.kind === 'system-prompt' ? [candidate] : [] + }) + expect(restoredPrompts.map(prompt => prompt.data)).toEqual([ + { text: '# Original prompt' }, + { text: '# Resumed prompt' }, + ]) + }) + + it('orders the system field before the request messages while preserving message order', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + at(4, 'user/message', { + ...textMessage('runtime-context', 'runtime facts'), + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' }, + }, { surfaceOp: 'append' }), + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + }), + ]) + + const current = snapshot(value) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'system-prompt', + 'user', + 'context', + ]) + expect(node(current, 'system-prompt')?.anchorSeq).toBe(1) + }) + + it('keeps an append-only later user turn in the existing system-prompt series', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(4, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + }), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(7, 'turn/start', { turn: 2 }), + at(8, 'step/start', { turn: 2, step: 1 }), + at(9, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), + ]) + + const current = snapshot(value) + const ordered = current.order.flatMap((key) => { + const candidate = current.nodes.get(key) + return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : [] + }) + expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user']) + }) + + it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => { + const reasons = ['change', 'resume', 'series'] as const + for (const reason of reasons) { + const windowedSystem = reason === 'series' ? '# Original' : '# Windowed' + const windowed = assembler([ + at(5, 'turn/start', { turn: 2 }), + at(6, 'step/start', { turn: 2, step: 1 }), + at(7, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }), + at(8, 'request/header', { + reason, + header: { config: { provider: 'fake', model: 'fake' }, system: windowedSystem }, + }), + ], true) + + expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8) + + windowed.prepend([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }), + at(4, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Original' }, + }), + ], false) + windowed.flush() + + const restored = snapshot(windowed) + const prompts = restored.order.flatMap((key) => { + const candidate = restored.nodes.get(key) + return candidate?.kind === 'system-prompt' ? [candidate] : [] + }) + expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5]) + } + }) + + it('repeats an unchanged system prompt after a surface rewrite and before an explicit later series', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(4, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + }), + at(5, 'user/message', { + ...textMessage('compacted', 'summary'), + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), + at(6, 'request/header', { + reason: 'series', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(9, 'turn/start', { turn: 2 }), + at(10, 'step/start', { turn: 2, step: 1 }), + at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), + at(12, 'request/header', { + reason: 'series', + header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + }), + ]) + + const current = snapshot(value) + const ordered = current.order.flatMap((key) => { + const candidate = current.nodes.get(key) + return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : [] + }) + expect(ordered.map(candidate => candidate?.kind)).toEqual([ + 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user', + ]) + expect(ordered.filter(candidate => candidate?.kind === 'system-prompt') + .map(candidate => candidate?.anchorSeq)).toEqual([1, 6, 9]) + }) + it('associates each direct message with its immediately following session recall', () => { const value = assembler([ at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }), diff --git a/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx b/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx new file mode 100644 index 0000000000..e91524f633 --- /dev/null +++ b/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' +import { en } from '../src/client/locale.ts' + +afterEach(cleanup) + +describe('SystemPromptNodeView', () => { + it('mounts the opaque context body only while its row is expanded', () => { + const text = '# Agent rules\n\n- Read first\n- **Act carefully**' + const node: ChatNode<'system-prompt'> = { + key: 'request-prompt:1', + kind: 'system-prompt', + id: '1', + target: 'chat', + anchorSeq: 1, + location: { kind: 'unresolved' }, + visibility: 'visible', + data: { text }, + } + const { container } = render() + + const disclosure = screen.getByRole('button', { name: 'System prompt' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('[data-system-prompt-body]')).toBeNull() + expect(container.querySelector('[data-context-text]')).toBeNull() + + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-system-prompt-body]')).not.toBeNull() + expect(container.querySelector('[data-context-text]')?.textContent).toBe(text) + expect(screen.queryByRole('heading', { name: 'Agent rules' })).toBeNull() + + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('[data-system-prompt-body]')).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/src/client/contract/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts index 773b131ae2..486808745e 100644 --- a/packages/client/ui-conversation/src/client/contract/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -1,4 +1,5 @@ import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './records.ts' @@ -29,6 +30,61 @@ export interface RequestPromptChange { previous?: ConversationPromptSnapshot } +/** Canonical prompt snapshot and any model-visible change introduced by one request header. */ +export interface RequestPromptInspection { + /** Complete prompt state recorded by the header. */ + prompt: ConversationPromptSnapshot + /** System/tool change relative to the preceding loaded header. */ + change?: RequestPromptChange +} + +/** + * The {@link inspectRequestPrompt} signature as a value seam: Chat and + * Trajectory Definitions receive it from the uiConversation service because a + * client bundle cannot value-import another plugin's module. + */ +export type RequestPromptInspector = ( + previous: ConversationPromptSnapshot | undefined, + event: SessionEvent<'request/header'>, +) => RequestPromptInspection + +/** + * Canonicalize one request header and classify its model-visible prompt change. + * @param previous - Prompt from the preceding loaded request header, when available. + * @param event - Durable full request header to inspect. + * @returns The canonical prompt and an initial/system/tool change when it can be established. + */ +export function inspectRequestPrompt( + previous: ConversationPromptSnapshot | undefined, + event: SessionEvent<'request/header'>, +): RequestPromptInspection { + const header = event.data.header + const rawTools: unknown = header.tools + const prompt: ConversationPromptSnapshot = { + config: header.config, + system: header.system ?? '', + tools: Array.isArray(rawTools) ? rawTools as readonly ToolSchema[] : [], + } + if (previous === undefined && event.data.reason !== 'initial') return { prompt } + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous !== undefined && !systemChanged && !toolsChanged) return { prompt } + return { + prompt, + change: { + seq: event.seq, + time: event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged ? 'system' : 'tools', + ...(previous === undefined ? {} : { previous }), + }, + } +} + /** Lifecycle fields shared by ordinary generation and compaction requests. */ interface RequestViewBase { /** Sequence that opened the operation represented by this request. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 26801e161b..9a8a429292 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -14,6 +14,8 @@ import type { ConversationViewSnapshotStore, } from '../contract/conversation.ts' import type { ConversationSnapshot } from '../contract/snapshot.ts' +import type { ConversationPromptSnapshot, RequestPromptInspection } from '../contract/request-inspection.ts' +import { inspectRequestPrompt } from '../contract/request-inspection.ts' import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' @@ -217,6 +219,23 @@ export class UiConversation extends Service { return this.images.resolve(sessionId, attachment) } + /** + * Canonicalize one `request/header` event against the previous prompt state. + * + * A pure interpretation shared by the Chat and Trajectory Definitions, exposed + * as a service method because cross-plugin value imports are forbidden in + * client bundles. + * @param previous - prompt recorded by the preceding loaded header, if any. + * @param event - the `request/header` session event to interpret. + * @returns the canonical prompt snapshot and any model-visible change. + */ + inspectRequestPrompt( + previous: ConversationPromptSnapshot | undefined, + event: SessionEvent<'request/header'>, + ): RequestPromptInspection { + return inspectRequestPrompt(previous, event) + } + private drop(record: BindingRecord, releaseScope: boolean): void { if (this.bindings.get(record.source.sessionId) !== record) return this.bindings.delete(record.source.sessionId) diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 966135c043..587772b9d3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -28,8 +28,9 @@ export type { ContextProvenanceView, ContextRole, KnownContextForm, } from './contract/context-provenance.ts' export type { - ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, + ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestPromptInspection, RequestPromptInspector, RequestView, } from './contract/request-inspection.ts' +export { inspectRequestPrompt } from './contract/request-inspection.ts' export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts' export { ConversationNodeAssembler } from './conversation/assembler.ts' diff --git a/packages/client/ui-conversation/tests/request-inspection.client.spec.ts b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts new file mode 100644 index 0000000000..1f0471ce8f --- /dev/null +++ b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { inspectRequestPrompt } from '../src/client/contract/request-inspection.ts' + +const CONFIG = { provider: 'test', model: 'test' } + +function header( + seq: number, + reason: SessionEvent<'request/header'>['data']['reason'], + value: SessionEvent<'request/header'>['data']['header'], +): SessionEvent<'request/header'> { + return { + type: 'request/header', + seq, + time: 1_700_000_000_000 + seq, + data: { reason, header: value }, + } +} + +describe('inspectRequestPrompt', () => { + it('classifies the first complete header as the initial prompt', () => { + expect(inspectRequestPrompt(undefined, header(1, 'initial', { + config: CONFIG, + system: '# System\n\nFollow instructions.', + tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }], + }))).toMatchObject({ + prompt: { + config: CONFIG, + system: '# System\n\nFollow instructions.', + tools: [{ name: 'read' }], + }, + change: { seq: 1, time: 1_700_000_000_001, kind: 'initial' }, + }) + }) + + it('suppresses a resume header when the earlier prompt is outside the loaded window', () => { + expect(inspectRequestPrompt(undefined, header(2, 'resume', { + config: CONFIG, + system: 'same prompt', + }))).toEqual({ + prompt: { config: CONFIG, system: 'same prompt', tools: [] }, + }) + }) + + it('classifies system, tool, and combined changes against the previous prompt', () => { + const initial = inspectRequestPrompt(undefined, header(1, 'initial', { + config: CONFIG, + system: 'first', + tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }], + })).prompt + const system = inspectRequestPrompt(initial, header(2, 'change', { + config: CONFIG, + system: 'second', + tools: [...initial.tools], + })) + const tools = inspectRequestPrompt(system.prompt, header(3, 'change', { + config: CONFIG, + system: 'second', + tools: [{ name: 'write', description: 'Write', parameters: { type: 'object' } }], + })) + const combined = inspectRequestPrompt(tools.prompt, header(4, 'change', { + config: CONFIG, + system: 'third', + tools: [], + })) + + expect(system.change?.kind).toBe('system') + expect(tools.change?.kind).toBe('tools') + expect(combined.change?.kind).toBe('system-and-tools') + expect(combined.change?.previous).toBe(tools.prompt) + }) + + it('omits a change when the prompt and tools are unchanged', () => { + const previous = inspectRequestPrompt(undefined, header(1, 'initial', { + config: CONFIG, + system: 'same', + })).prompt + + expect(inspectRequestPrompt(previous, header(2, 'resume', { + config: { ...CONFIG, maxTokens: 1_024 }, + system: 'same', + }))).toEqual({ + prompt: { config: { ...CONFIG, maxTokens: 1_024 }, system: 'same', tools: [] }, + }) + }) +}) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 549dc31997..6f0a913f68 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -402,6 +402,22 @@ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_context_injection_outline_16 (figma extract): browse document frame with an open top and an arrow dropping in. */ +export const IconContextInjectionOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) + /** ic_ds_link_outline_14 */ export const IconLinkOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 9d14400e8a..5e4d7dd2a4 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 20 figma extracts + four product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(70) + it('exports the full icon set (46 deepsuite + 21 figma extracts + four product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(71) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index 0cbb9fee77..53b4176ce0 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -1,79 +1,55 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange, + ConversationNodeDefinition, RequestPromptInspector, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts' -function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot { - if (match.event.type !== 'request/header') { - throw new Error('trajectory-request-header start requires request/header') - } - const header = match.event.data.header - const tools: unknown = header.tools +/** + * Request-header fact Definition for the Trajectory target. + * @param inspect - the shared prompt interpretation, supplied by the + * uiConversation service (a client bundle cannot value-import it). + * @returns the Trajectory request-header Definition. + */ +function trajectoryRequestHeaderDefinition(inspect: RequestPromptInspector): ConversationNodeDefinition { return { - config: header.config, - system: header.system ?? '', - tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [], + kind: 'trajectory-request-header', + target: 'trajectory', + match: event => event.type === 'request/header' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'request/header') { + throw new Error('trajectory-request-header start requires request/header') + } + const previous = reader.previous('trajectory-request-header') + ?.state.prompt + const { prompt, change } = inspect(previous, match.event) + return { + seq: match.event.seq, + time: match.event.time, + prompt, + location: match.location, + ...(change === undefined ? {} : { change }), + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'request-header', + header: context.state, + }), } } -function promptChange( - previous: ConversationPromptSnapshot | undefined, - prompt: ConversationPromptSnapshot, - match: ConversationMatch, -): RequestPromptChange | undefined { - if (match.event.type !== 'request/header') return undefined - if (previous === undefined && match.event.data.reason !== 'initial') return undefined - const systemChanged = previous !== undefined && previous.system !== prompt.system - const toolsChanged = previous !== undefined - && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) - if (previous !== undefined && !systemChanged && !toolsChanged) return undefined - return { - seq: match.event.seq, - time: match.event.time, - kind: previous === undefined - ? 'initial' - : systemChanged && toolsChanged - ? 'system-and-tools' - : systemChanged ? 'system' : 'tools', - ...(previous === undefined ? {} : { previous }), - } -} - -const trajectoryRequestHeaderDefinition: ConversationNodeDefinition = { - kind: 'trajectory-request-header', - target: 'trajectory', - match: event => event.type === 'request/header' - ? { id: String(event.seq), role: 'start' } - : null, - start: (_context, match, reader) => { - const prompt = requestPrompt(match) - const previous = reader.previous('trajectory-request-header') - ?.state.prompt - const change = promptChange(previous, prompt, match) - return { - seq: match.event.seq, - time: match.event.time, - prompt, - location: match.location, - ...(change === undefined ? {} : { change }), - } - }, - update: context => context.state, - buildViewNode: context => context.state === undefined - ? null - : trajectoryNode(context, context.state.seq, { - kind: 'request-header', - header: context.state, - }), -} - /** * Register Trajectory request-header facts. * * @param ctx - Plugin context receiving the Definition. */ export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void { - ctx.uiConversation.events.register(trajectoryRequestHeaderDefinition) + ctx.uiConversation.events.register(trajectoryRequestHeaderDefinition( + (previous, event) => ctx.uiConversation.inspectRequestPrompt(previous, event), + )) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 4c00c47568..60aed23058 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, ConversationViewBuilder, - ConversationViewDefinition, RequestView, ToolCallBlock, + ConversationViewDefinition, RequestPromptChange, RequestView, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' import type { @@ -34,26 +34,38 @@ function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined : undefined } +interface StepHeaders { + /** Latest full request snapshot in the step. */ + latest: TrajectoryRequestHeaderState + /** Latest actual prompt change in the step, retained across a later series snapshot. */ + change?: RequestPromptChange +} + function headerFor( request: AssistantRequest, - headersByStep: ReadonlyMap, + headersByStep: ReadonlyMap, previous: TrajectoryRequestHeaderState | undefined, -): TrajectoryRequestHeaderState | undefined { +): StepHeaders | undefined { return headersByStep.get(stepKey(request.turn, request.step)) - ?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined) + ?? (previous !== undefined && previous.seq < request.startSeq + ? { + latest: previous, + ...(previous.change === undefined ? {} : { change: previous.change }), + } + : undefined) } function applyHeader( request: AssistantRequest, - header: TrajectoryRequestHeaderState | undefined, + header: StepHeaders | undefined, includeChange: boolean, ): AssistantRequest { return header === undefined ? request : { ...request, - prompt: header.prompt, - requestConfig: header.prompt.config, + prompt: header.latest.prompt, + requestConfig: header.latest.prompt.config, ...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}), } } @@ -174,11 +186,18 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< } private snapshot(): TrajectorySnapshot { - const headersByStep = new Map() + const headersByStep = new Map() for (const contribution of this.contributions) { if (contribution.data.kind !== 'request-header') continue const key = headerStepKey(contribution.data.header) - if (key !== undefined) headersByStep.set(key, contribution.data.header) + if (key === undefined) continue + const previous = headersByStep.get(key) + headersByStep.set(key, { + latest: contribution.data.header, + ...(contribution.data.header.change !== undefined + ? { change: contribution.data.header.change } + : previous?.change === undefined ? {} : { change: previous.change }), + }) } const finalized: ConversationNode[] = [] const eventLocations = new Map() @@ -213,13 +232,14 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const header = data.request === undefined ? undefined : headerFor(data.request, headersByStep, previousHeader) - if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) + if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.latest.prompt)) if (data.partial !== null) partial = data.partial if (data.request !== undefined) { - const includeChange = header?.change !== undefined - && !consumedPromptChanges.has(header.seq) + const change = header?.change + const includeChange = change !== undefined + && !consumedPromptChanges.has(change.seq) requests.push(applyHeader(data.request, header, includeChange)) - if (includeChange) consumedPromptChanges.add(header.seq) + if (includeChange) consumedPromptChanges.add(change.seq) } continue } diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index a454431d01..788b42f845 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' @@ -21,6 +21,7 @@ const registrationContext = { return () => {} }, }, + inspectRequestPrompt, }, } as unknown as Context diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index 59eec5f679..f472455e55 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -109,6 +109,65 @@ describe('TrajectorySnapshotBuilder', () => { : undefined)).toEqual(['initial', undefined]) }) + it('retains a same-step prompt change when a later series header supplies the latest snapshot', () => { + const initial = { + config: { provider: 'test', model: 'test' }, + system: 'initial prompt', + tools: [], + } + const changed = { ...initial, system: 'changed prompt' } + const nodes: TrajectoryConversationViewNode[] = [ + contribution('header:initial', 2, { + kind: 'request-header', + header: { + seq: 2, + time: 2, + prompt: initial, + change: { seq: 2, time: 2, kind: 'initial' }, + location: { kind: 'session' }, + }, + }), + contribution('assistant:1', 3, { + kind: 'assistant', + partial: null, + request: assistantRequest(3, 1), + }), + contribution('header:change', 5, { + kind: 'request-header', + header: { + seq: 5, + time: 5, + prompt: changed, + change: { seq: 5, time: 5, kind: 'system', previous: initial }, + location: stepLocation(1, 2), + }, + }), + contribution('header:series', 6, { + kind: 'request-header', + header: { + seq: 6, + time: 6, + prompt: changed, + location: stepLocation(1, 2), + }, + }), + contribution('assistant:2', 7, { + kind: 'assistant', + partial: null, + request: assistantRequest(7, 2), + }), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['initial prompt', 'changed prompt']) + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.promptChange?.seq + : undefined)).toEqual([2, 5]) + }) + it('indexes exact step headers and the active tool schema without backward scans', () => { const basePrompt = { config: { provider: 'test', model: 'base' }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 1b00960adb..ab68bce9d0 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -344,7 +344,7 @@ export function apply(ctx: Context, config: Config): void { // precedes it and the driver-appended runtime context follows it. const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message)) const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired) - return { kind: 'enter', messages: entered } + return { ...decision, messages: entered } }) ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 36d443d67a..c2b1adf3ea 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -107,7 +107,7 @@ export class SessionReferenceResolver extends TypertRemoteService { const decision = await next() if (decision.kind === 'reject') return decision return { - kind: 'enter', + ...decision, messages: await this.prepareDirectMessages(agent, decision.messages, signal), } }, { prepend: true }) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index a317544a3c..b8320c2d50 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void { browser, ) return { - kind: 'enter', + ...decision, messages: [ ...decision.messages, createUserMessage({ diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 10ac6a6ab6..0425bc69de 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -234,7 +234,7 @@ export function apply(ctx: Context, config: Config): void { if (previous !== undefined && previous.state === state) return decision const text = renderReading(location, turn) return { - kind: 'enter', + ...decision, messages: [ createUserMessage({ content: [{ type: 'text', text }], diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 88d4e6da7d..9fe9297155 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 1b233ae1203171930ef5b58de93ec67381ec4918 -README.zh.md: 81af654072f23c5280e2e14bc891972b5e1f37d5 +README.md: 8b35b970aac93ac3c20fe570c79c3524abbe079f +README.zh.md: 4a11d81e6cbdbce1c1e7997785a2cf4456609171 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1b233ae120..8b35b970aa 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -66,7 +66,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends an `interrupted: true` anchor when non-empty text or reasoning has reached the user. The anchor cites those chunk seqs and places the rendered prefix in derived message history, so the next request contains what the user saw. Undispatched tool calls are omitted, and an empty or tool-only stream produces no anchor; provider failures still commit no assistant content ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)). -After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. The loop appends a full snapshot for its first request, for a changed header, and when an unchanged header begins an explicitly declared message series or the first request after a surface replacement. A changed header that also begins a series carries `startsSeries: true`; further same-series Steps, ordinary later Turns, and retries with an unchanged header inherit the latest snapshot. Before the next waterfall, the loop removes adapter-marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule and appends a `resume` snapshot. Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 81af654072..4a11d81e6c 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -66,7 +66,7 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。轮次取消打断流式输出时,如果非空文本或推理内容已送达用户,循环也会追加一个带 `interrupted: true` 的锚点。该锚点引用对应的分片 seq,并把已渲染的前缀放入派生消息历史,使下一次请求包含用户看到的内容。未分派的工具调用会被省略,空流或只包含工具调用的流不会生成锚点;提供方故障也不提交 assistant 内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md))。 -在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器负责的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。循环会为实例的首个请求、发生变化的 header,以及显式声明的新消息序列或表层替换后的首个请求中内容未变的 header 追加完整快照。如果变化的 header 同时开启序列,它会携带 `startsSeries: true`;同一序列内 header 未变的后续 Step、普通后续 Turn 与重试继承最新快照。下一次 waterfall(瀑布式事件)前,循环会从提议中移除由适配器标记的字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则,并追加 `resume` 快照。 插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会以终止错误或中止结束的形式由 `ctx.llm` 传来,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前准入操作或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只影响报告方式,不影响如何处理在取消后完成终结的结果上下文。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md)规定生命周期与竞态约定。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6bf7517903..0d3af9663b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -49,7 +49,12 @@ type StepEndReason = Extract { + private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise { /* v8 ignore next -- private callers establish the running phase before executing a step */ if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`) const { turn, step, abort: { signal } } = this.phase @@ -337,9 +344,18 @@ export class ReactLoopAgent implements Agent { const system = renderPrompt(assembly) while (true) { + const surfaceGeneration = this.session.surface.replaceGeneration const { request, preparedCall } = await this.buildRequest( - turn, step, assembly.tools, system, this.session.deriveMessages(), signal, + turn, + step, + assembly.tools, + system, + this.session.deriveMessages(), + startsRequestSeries, + surfaceGeneration, + signal, ) + startsRequestSeries = false const assembler = new BlockAssembler() const chunkSeqs: number[] = [] try { @@ -429,6 +445,8 @@ export class ReactLoopAgent implements Agent { tools: GenerateOptions['tools'] & object, system: string, boundaryMessages: Message[], + startsRequestSeries: boolean, + surfaceGeneration: number, signal: AbortSignal, ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> { const { session } = this @@ -482,12 +500,21 @@ export class ReactLoopAgent implements Agent { ...tools.length > 0 ? { tools } : {}, }) const baseline = this.session.requestHeader() + const startsSeries = startsRequestSeries + || this.requestSurfaceGeneration !== surfaceGeneration if (!this.requestHeaderLogged) { this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { - this.session.append('request/header', { header, reason: 'change' }) + this.session.append('request/header', { + header, + reason: 'change', + ...startsSeries ? { startsSeries: true } : {}, + }) + } else if (startsSeries) { + this.session.append('request/header', { header, reason: 'series' }) } + this.requestSurfaceGeneration = surfaceGeneration const contextWindow = preparedCall?.context?.contextWindow const requestContext: RequestContext = { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 4082b43452..a8beb82622 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -435,7 +435,8 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system)) - expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => { diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 9d1c2a42c3..092ede6eb5 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -96,6 +96,8 @@ describe('agent/request-error', () => { expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) it('lets cancellation win over a retry action', async () => { diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 287e73303c..a53f0e50a5 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -109,6 +109,91 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) + }) + + it('starts a new request series only when the admitted step explicitly asks for one', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/pre-step', async ({ turn }, next) => { + const decision = await next() + return decision.kind === 'enter' && turn === 2 + ? { ...decision, startsRequestSeries: true } + : decision + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second series') + await waitForIdle(ctx, agent) + + expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) + }) + + it('retains the explicit series boundary when that request also changes its header', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/pre-step', async ({ turn }, next) => { + const decision = await next() + return decision.kind === 'enter' && turn === 2 + ? { ...decision, startsRequestSeries: true } + : decision + }) + ctx.on('agent/request', async ({ turn }, next) => { + const config = await next() + return turn === 2 ? { ...config, maxTokens: 1_024 } : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second series') + await waitForIdle(ctx, agent) + + expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + expect(agent.session.events.flatMap(event => event.type === 'request/header' + ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] + : [])).toEqual([ + { reason: 'initial', startsSeries: undefined }, + { reason: 'change', startsSeries: true }, + ]) + }) + + it('keeps the series declaration when an outer listener rebuilds the enter decision', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + // Context-appending wrapper in the tool-cordis / session-reference shape: + // it rebuilds the downstream decision, so it must spread it to keep fields + // it does not own — a bare `{ kind: 'enter', messages }` drops the series. + ctx.on('agent/pre-step', async (_payload, next) => { + const decision = await next() + if (decision.kind === 'reject') return decision + const appended = createUserMessage({ + content: [{ type: 'text', text: 'appended reference context' }], + source: { kind: 'plugin', plugin: 'outer-wrapper' }, + }) + return { ...decision, messages: [...decision.messages, appended] } + }, { prepend: true }) + ctx.on('agent/pre-step', async ({ turn }, next) => { + const decision = await next() + return decision.kind === 'enter' && turn === 2 + ? { ...decision, startsRequestSeries: true } + : decision + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second series') + await waitForIdle(ctx, agent) + + expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => { @@ -407,6 +492,10 @@ describe('request stability across the loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request', async ({ turn }, next) => { + const config = await next() + return turn === 2 ? { ...config, maxTokens: 1_024 } : config + }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -426,11 +515,49 @@ describe('request stability across the loop', () => { const second = adapter.requests[1]! // The rewritten history: summary replaces turn 1's user+assistant pair. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) - // No header event beyond the anchor: the replace is itself in the log. - expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) + expect(agent.session.events.flatMap(event => event.type === 'request/header' + ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] + : [])).toEqual([ + { reason: 'initial', startsSeries: undefined }, + { reason: 'change', startsSeries: true }, + ]) }) - it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { + it('starts a new request series when compaction rewrites a retry in the same step', async () => { + const adapter = new MockAdapter([ + () => { throw new LlmError('request is too large', 'CONTEXT_LENGTH') }, + textResponse('recovered'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('same-step-compaction'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/request-error', async ({ agent: subject }) => { + const first = subject.session.surface.nodes[0] + if (first === undefined) throw new Error('request has no surface message to compact') + subject.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '[summary for retry]' }], + source: { kind: 'plugin', plugin: 'test-compact' }, + }), { + surfaceOp: { op: 'replace', start: first, end: first }, + sourceEventSeqs: [first], + }) + return { kind: 'retry' } + }) + + send(agent, 'first series') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[1]?.messages[0]?.content).toContainEqual({ + type: 'text', text: '[summary for retry]', + }) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) + }) + + it('a real system-prompt change is a full changed-header snapshot; a stable new turn reuses it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -439,8 +566,8 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - // Identical assembly re-rendered per step is NOT a change. - expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) send(agent, 'third') @@ -556,9 +683,10 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // No changed snapshot was logged (nothing really changed), and the session's own - // fold is immutable state. - expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) + // The second turn reuses the same series and header; the session's own + // fold remains immutable state. + expect(agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) expect(adapter.requests[1]!.temperature).toBeUndefined() }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index a9d78f3fad..91cbc2a275 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 70b396d787de5d95332c379ff20ab92c64065857 -README.zh.md: fee72f3cd1fb456ae639d6444fe3fe914c41220a +README.md: b79a1e7270eaf5b50a05059ecbea760c0888bc1e +README.zh.md: aa4b6a471711a94665b171e06da638fc86c7a39c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 70b396d787..b79a1e7270 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -52,7 +52,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls. `agent/pre-step` receives a payload carrying the subject `agent`, the exclusive claimed `UserMessage[]`, and the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Agent-scoped turn extension points carry their explicit `AbortSignal` in the payload; the remaining turn-scoped extension points receive it through their request value. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. +`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch is the complete identified, frozen batch for the proposed step. `startsRequestSeries: true` declares that this admitted batch begins a distinct model-message series; ordinary follow-ups leave it absent. A listener that wraps downstream entry preserves both that declaration and the batch unless it intentionally replaces either one; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index fee72f3cd1..aa4b6a4717 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -54,7 +54,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收一个 payload,携带主体 `agent`、独占的已领取 `UserMessage[]` 以及拟进入的 `turn`、`step` 与取消 `signal`;当工具已经要求继续请求时,该批次可以为空。agent 作用域轮次扩展点在 payload 中携带显式 `AbortSignal`;其余轮次作用域扩展点通过其请求值接收它。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md#three-execution-boundaries-are-deliberately-one-way)拥有。 -`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 +`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。`startsRequestSeries: true` 声明该接纳批次会开启一个独立的模型消息序列;普通 follow-up 不设置它。包装下游 enter 的监听器会同时保留该声明和消息批次,除非有意替换其中一项;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。 diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 3f8f7c512b..c8bc08ecbb 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -55,7 +55,12 @@ export type AgentStatus = 'idle' | 'running' /** Whether and with which messages the loop enters a proposed step. */ export type PreStepDecision = | { kind: 'reject' } - | { kind: 'enter'; messages: UserMessage[] } + | { + kind: 'enter' + messages: UserMessage[] + /** Start a distinct model-message series before this step's admitted messages. */ + startsRequestSeries?: true + } /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 15704ada90..482c7c5c89 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 9f0b4023e897f66ec1bcbc22e908ab2bf1c0d2cc -README.zh.md: dcee2380802c6b7e416366a9388256f9b1d02091 +README.md: 0e3cdcb1e0135cda4d1ac469a0cbc2c2f44c3d94 +README.zh.md: 383777227fa5903c0e7285d31e8d70ee9ebb1eab diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 9f0b4023e8..0e3cdcb1e0 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -60,7 +60,7 @@ This package owns ordered surface projection, replacement validation, replay, an ### Request-header reconstruction (`request-header.ts`) -`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. `series` repeats an unchanged envelope when `agent/pre-step` explicitly starts a distinct model-message series or a surface replacement changes the model's message list; when that boundary coincides with an envelope change, the `change` snapshot carries `startsSeries: true` so both facts survive. Ordinary append-only later turns remain in the current series. Same-series steps and retries with an unchanged envelope keep using the latest snapshot. Repeating the complete system prompt and tool catalog grows the log linearly with message series, but keeps every header self-contained for partial-window rendering and exact request reconstruction; a lightweight reference marker would require predecessor availability and a second replay representation. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index dcee238080..383777227f 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -60,7 +60,7 @@ ### 请求头重建(`request-header.ts`) -`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 +`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume`、`change` 或 `series`。当 `agent/pre-step` 显式开启独立的模型消息序列,或表层替换改变模型消息列表时,`series` 会重复记录内容未变的封装;如果该边界与封装变化同时发生,`change` 快照会携带 `startsSeries: true`,从而同时保留这两个事实。普通的仅追加后续 turn 仍属于当前序列。同一序列内封装未变的 step 和重试继续使用最新快照。重复完整系统提示词和工具目录会使日志随消息序列线性增长,但能让每个 header 自包含,以支持局部窗口渲染和精确请求重建;轻量引用标记则会要求前序始终可用,并引入第二种回放表示。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 `user/message` 会直接存储完整的 `UserMessage`,其中包括收件箱路由或进入步骤前创建的标识。无论它是直接人类提示词、合成注入,还是已进入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message` 和 `tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到后续某次 pre-step 领取它,并在 enter 决策中返回它。 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 85ff73bf04..f331924b2f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -206,9 +206,11 @@ export interface RequestContext { * Why a `request/header` snapshot was appended: `'initial'` — the log's first * header (a new conversation); `'resume'` — a loop instance's first request * over a log that already has header events (process restart, fork seed); - * `'change'` — a later request used a different header. + * `'change'` — a later request used a different header, with `startsSeries` + * preserving a coincident series boundary; `'series'` — an unchanged header + * began an explicitly distinct message series or followed a surface replacement. */ -export type RequestHeaderReason = 'initial' | 'resume' | 'change' +export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series' /** * The merge-extensible, append-only source of truth for an agent interaction. @@ -286,7 +288,12 @@ export interface SessionEventMap { * Full header for the next request, appended inside its step before dispatch. * It is log-only; the latest snapshot reconstructs the request header. */ - 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + 'request/header': { + header: EpochHeader + reason: RequestHeaderReason + /** A changed header also begins a distinct model-message series. */ + startsSeries?: true + } /** * Route metadata for the next request, logged only when the route or capacity * changes. It does not participate in request reconstruction or header equality. diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c9f2019f62..189991fd0d 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -287,7 +287,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run', hookContext: 'string', slotInject: 'ChatNodeTurnDataInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', @@ -295,6 +295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat UserMessageNodeView key \'user\'', 'client-ui-chat UserMessageNodeView key \'steering\'', 'client-ui-chat ContextMessageNodeView key \'context\'', + 'client-ui-chat SystemPromptNodeView key \'system-prompt\'', 'client-ui-chat AssistantNodeView key \'assistant-step\'', 'client-ui-chat CommandNodeView key \'command\'', 'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 89b1cb6d77..66f5bd3058 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4171,7 +4171,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreStepDecision', - declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n};', + declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n startsRequestSeries?: true;\n};', }, { name: 'PreToolDecision', @@ -4259,7 +4259,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestHeaderReason', - declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', + declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\' | \'series\';', }, { name: 'RequestImageAttachment', @@ -4463,7 +4463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}', }, { name: 'SessionEventMetadataFilter', diff --git a/packages/extensions/tool-cordis/src/index.ts b/packages/extensions/tool-cordis/src/index.ts index e090eb993d..4c0915da60 100644 --- a/packages/extensions/tool-cordis/src/index.ts +++ b/packages/extensions/tool-cordis/src/index.ts @@ -398,7 +398,7 @@ export function apply(ctx: Context): void { source: { kind: 'plugin', plugin: name, form: 'instructions' }, }) }) - return { kind: 'enter', messages: [...decision.messages, ...contexts] } + return { ...decision, messages: [...decision.messages, ...contexts] } }) } diff --git a/packages/goal/goal-round-driver/README.i18n.yaml b/packages/goal/goal-round-driver/README.i18n.yaml index a56c3e9738..75e3337263 100644 --- a/packages/goal/goal-round-driver/README.i18n.yaml +++ b/packages/goal/goal-round-driver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/goal-round-driver/README.md -README.md: 34b11714b8ccf574549567f33b80204f3c0dde6a -README.zh.md: be41c12258214aa8aa5323c73ec144642fb121ff +README.md: b11fba9beaa50edf2627dc62b5910f3802efb10d +README.zh.md: edbf46d344a8b3d6ffdaac36d5e58a64ed305ac0 diff --git a/packages/goal/goal-round-driver/README.md b/packages/goal/goal-round-driver/README.md index 34b11714b8..b11fba9bea 100644 --- a/packages/goal/goal-round-driver/README.md +++ b/packages/goal/goal-round-driver/README.md @@ -21,7 +21,7 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def ## Round contract -When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; an accepted round sets `startsRequestSeries: true`, so that boundary is logged as `series` for an unchanged header or `startsSeries: true` on a coincident `change`. Chat renders the header before the round message to match the provider envelope order. Only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. `MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint. diff --git a/packages/goal/goal-round-driver/README.zh.md b/packages/goal/goal-round-driver/README.zh.md index be41c12258..edbf46d344 100644 --- a/packages/goal/goal-round-driver/README.zh.md +++ b/packages/goal/goal-round-driver/README.zh.md @@ -21,7 +21,7 @@ ## Round 约定 -当对应的活跃 agent(智能体)实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `` 提示词,并携带 `GoalMessageSource`。`agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 +当对应的活跃 agent(智能体)实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `` 提示词,并携带 `GoalMessageSource`。`agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;接纳的 Round 会设置 `startsRequestSeries: true`,因此未变化的 header 以 `series` 记录该边界,而同时发生的 `change` 则携带 `startsSeries: true`。Chat 会把该 header 渲染在 Round 消息之前,以匹配提供方信封顺序。只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 `MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。人类消息不消耗 goal 上限。如果人类工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有在该检查点之后才重新预留。 diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index b212f920de..4c4a20e7ee 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/packages/goal/goal-round-driver/src/index.ts @@ -410,7 +410,7 @@ export function apply(ctx: Context): void { requestDrive(state) return { kind: 'reject' } } - return decision + return { ...decision, startsRequestSeries: true } }) // Loading a lifecycle driver over existing agents never inherits hidden diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index 1bd2032600..2a4059c2ea 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -207,6 +207,8 @@ describe('same-session goal driving', () => { expect(rounds).toEqual([1, 2]) expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') + expect(test.agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) it('never adopts activation from an already-live driver and waits for explicit resume', async () => { @@ -325,6 +327,8 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') expect(requestText(test.adapter.requests[0]!)).not.toContain('') expect(requestText(test.adapter.requests[1]!)).toContain('') + expect(test.agent.session.events.flatMap(event => + event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) it('makes a reserved round stale when a listener queues human work behind it', async () => { diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index 79c2df194c..09594d18b6 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -229,7 +229,7 @@ export function apply(ctx: Context, config: Config): void { const ours = contextFrom(merged) if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'enter', + ...downstream, messages: [...downstream.messages, ours], } }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index a76965c970..2189fc9a28 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void { const ours = contextFrom(merged) if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'enter', + ...downstream, messages: [...downstream.messages, ours], } }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 103060ad44..ac0309f3a4 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 69826d76b437ea482655d91a930310185079414c -README.zh.md: 26d132a9a8e48c0833e6145b139226d765cb9709 +README.md: c96532a673d6b2e53ff1d55cf8a3ae4f7aac756f +README.zh.md: 4f5747a8fdcb96208ca454ddd8df603de094af71 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 69826d76b4..c96532a673 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -8,7 +8,7 @@ The API gateway shared by every client consists of the TypeScript API contract ( `ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. -A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created. +A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created. A logged reasoning effort marked as an adapter default remains absent from the restored selection, so the next model resolution does not promote that default into an explicit choice or record a false header change. `session.selectModel` saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved `ModelSelection`, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 26d132a9a8..4f5747a8fd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -8,7 +8,7 @@ `ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。 -会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。 +会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。若日志中的推理强度被标记为适配器默认值,恢复的选择仍不包含该强度,因此下一次模型解析不会把这个默认值提升为显式选择,也不会记录虚假的 header 变更。 `session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection`,包括适配器实体化的默认推理(reasoning)强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 596876b5a7..222e8a0ace 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -200,7 +200,7 @@ export function apply(ctx: Context, config: Config = {}): void { })) } if (injections.length === 0) return decision - return { kind: 'enter', messages: [...decision.messages, ...injections] } + return { ...decision, messages: [...decision.messages, ...injections] } }) // Register after the tool so reverse teardown removes guidance first. Exact definition @@ -231,19 +231,19 @@ export function apply(ctx: Context, config: Config = {}): void { if (history.visibleDigest === digest) { return existing === undefined ? decision - : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } + : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) } } if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision if (!history.published && skills.length === 0) { return existing === undefined ? decision - : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } + : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) } } const catalog = history.published ? renderCatalogUpdate(entries) : renderCatalogMessage(entries) return { - kind: 'enter', + ...decision, messages: existing === undefined ? [...decision.messages, catalog] : decision.messages.map(message => message.id === existing.message.id ? catalog : message), diff --git a/packages/test-support/session-snapshot/src/suite.ts b/packages/test-support/session-snapshot/src/suite.ts index 120359f92a..ad9c6e2ac3 100644 --- a/packages/test-support/session-snapshot/src/suite.ts +++ b/packages/test-support/session-snapshot/src/suite.ts @@ -378,6 +378,54 @@ export function fixtureContext(fixture: string): NormalizeContext { } } +interface NormalizedHeaderEvent { + readonly header: unknown + readonly reason: unknown +} + +/** Normalize request-header payloads while retaining the reason that selects a pin revision. */ +function normalizedHeaderEvents(rawLog: string, ctx: NormalizeContext): NormalizedHeaderEvent[] { + return normalizeSessionLog(rawLog, ctx) + .split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as { + type?: unknown + data?: { header?: unknown; reason?: unknown } + }) + .filter(record => record.type === 'request/header') + .map(record => ({ header: record.data?.header, reason: record.data?.reason })) +} + +/** + * Header revisions that own sidecar content. `series` reuses the current revision, while + * `resume` owns sidecars because its full snapshot may drift across the process boundary. + * Pinning fixtures therefore cover one loop instance; a mid-log `resume` fails their + * pin-count invariant. + */ +function pinningHeaderPayloads(rawLog: string, ctx: NormalizeContext): unknown[] { + return normalizedHeaderEvents(rawLog, ctx) + .filter(event => event.reason !== 'series') + .map(event => event.header) +} + +/** Extract every string system prompt from a normalized header sequence. */ +function systemPromptsFrom(headers: readonly unknown[]): string[] { + return headers.flatMap((header) => { + if (header === null || typeof header !== 'object') return [] + const system = (header as { system?: unknown }).system + return typeof system === 'string' ? [system] : [] + }) +} + +/** Extract every array-valued tool catalog from a normalized header sequence. */ +function toolSchemasFrom(headers: readonly unknown[]): unknown[][] { + return headers.flatMap((header) => { + if (header === null || typeof header !== 'object') return [] + const tools = (header as { tools?: unknown }).tools + return Array.isArray(tools) ? [tools] : [] + }) +} + /** * The `data.header` payload of every `request/header` event in a session * JSONL, in log order, with the log's volatile values scrubbed first @@ -390,12 +438,7 @@ export function fixtureContext(fixture: string): NormalizeContext { * @returns The normalized `data.header` payloads, in log order. */ export function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknown[] { - return normalizeSessionLog(rawLog, ctx) - .split('\n') - .filter(line => line.trim().length > 0) - .map(line => JSON.parse(line) as { type?: unknown; data?: { header?: unknown } }) - .filter(record => record.type === 'request/header') - .map(record => record.data?.header) + return normalizedHeaderEvents(rawLog, ctx).map(event => event.header) } /** @@ -408,11 +451,7 @@ export function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknow * @returns The normalized system prompts, in header order. */ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext): string[] { - return normalizedHeaders(rawLog, ctx).flatMap((header) => { - if (header === null || typeof header !== 'object') return [] - const system = (header as { system?: unknown }).system - return typeof system === 'string' ? [system] : [] - }) + return systemPromptsFrom(normalizedHeaders(rawLog, ctx)) } /** @@ -425,11 +464,7 @@ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext): * @returns The normalized initial tool-schema arrays, in header order. */ export function normalizedToolSchemas(rawLog: string, ctx: NormalizeContext): unknown[][] { - return normalizedHeaders(rawLog, ctx).flatMap((header) => { - if (header === null || typeof header !== 'object') return [] - const tools = (header as { tools?: unknown }).tools - return Array.isArray(tools) ? [tools] : [] - }) + return toolSchemasFrom(normalizedHeaders(rawLog, ctx)) } /** The structured contents of a tool-schema sidecar. */ @@ -1274,7 +1309,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog - const prompts = normalizedSystemPrompts(primary.content, ctx) + const pinningHeaders = pinningHeaderPayloads(primary.content, ctx) + const prompts = systemPromptsFrom(pinningHeaders) expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0) const promptSnapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)) /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ @@ -1283,7 +1319,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { claimSharedSnapshot(promptClaims, promptPath, scenario.name, promptSnapshot) await writeFile(promptPath, promptSnapshot) - const schemaSets = normalizedToolSchemas(primary.content, ctx) + const schemaSets = toolSchemasFrom(pinningHeaders) expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0) expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`) .toBe(prompts.length) @@ -1301,7 +1337,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const log = result.sessionLogs[index] expect(log, `${mode}: no child session log at index ${index} to snapshot schemas from`) .toBeDefined() - const schemaSets = normalizedToolSchemas((log as HarvestedLog).content, ctx) + const schemaSets = toolSchemasFrom(pinningHeaderPayloads( + (log as HarvestedLog).content, + ctx, + )) expect(schemaSets.length, `${mode}: child ${index} produced no tool schemas to snapshot`) .toBeGreaterThan(0) await writeFile(join(dir, childToolSchemasSnapshot(index)), formatToolSchemasSnapshot( @@ -1313,7 +1352,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const log = result.sessionLogs[index] expect(log, `${mode}: no child session log at index ${index} to snapshot a prompt from`) .toBeDefined() - const prompts = normalizedSystemPrompts((log as HarvestedLog).content, ctx) + const prompts = systemPromptsFrom(pinningHeaderPayloads( + (log as HarvestedLog).content, + ctx, + )) expect(prompts.length, `${mode}: child ${index} produced no system prompt to snapshot`) .toBeGreaterThan(0) await writeFile( @@ -1360,7 +1402,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? pinningScenario const pinningDir = join(snapshotsDir, pinningScenario.name) const pinnedFixture = await readFile(join(pinningDir, 'session.jsonl'), 'utf8') - const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture)) + const pinned = pinningHeaderPayloads(pinnedFixture, fixtureContext(pinnedFixture)) const promptSnapshot = await readFile( join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8', @@ -1400,7 +1442,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { : 0 expect(headerChangeCount(log.content), `session ${log.id}: changed request/header count`) .toBe(expectedChanges) - const headers = normalizedHeaders(scrubSystemPrompts(log.content), ctx) + const headerEvents = normalizedHeaderEvents(scrubSystemPrompts(log.content), ctx) + const headers = headerEvents.map(event => event.header) const prompts = normalizedSystemPrompts(log.content, ctx) const schemaSets = normalizedToolSchemas(log.content, ctx) expect(prompts.length, `session ${log.id}: every request/header must carry a string system prompt`) @@ -1409,13 +1452,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(headers.length) if (childSchemas !== undefined) { expect(childSchemas.length, `session ${log.id}: ${childToolSchemasSnapshot(logIndex)} has an unexpected tool-schema count`) - .toBe(schemaSets.length) + .toBe(1 + headerChangeCount(log.content)) } + let revision = 0 for (const [k, header] of headers.entries()) { - const classPin = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0] + if (headerEvents[k]?.reason === 'change') revision++ + const classPin = expectedChanges > 0 ? pinnedHeaders[revision] : pinnedHeaders[0] const expected = childSchemas === undefined ? classPin - : { ...classPin as Record, tools: childSchemas[k] } + : { ...classPin as Record, tools: childSchemas[revision] } expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) .toEqual(expected) if (expectedChanges === 0) { @@ -1430,14 +1475,17 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } if (scenario.pinsHeader === true && logIndex === 0) { + const pinningHeaders = pinningHeaderPayloads(log.content, ctx) + const pinningPrompts = systemPromptsFrom(pinningHeaders) + const pinningSchemas = toolSchemasFrom(pinningHeaders) expect(formatSystemPromptSnapshot( - prompts[0] as string, - prompts.slice(1), + pinningPrompts[0] as string, + pinningPrompts.slice(1), ), `session ${log.id}: changed system prompts diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(promptSnapshot) expect(formatToolSchemasSnapshot( - schemaSets[0] as unknown[], - schemaSets.slice(1), + pinningSchemas[0] as unknown[], + pinningSchemas.slice(1), ), `session ${log.id}: changed tool schemas diverged from ${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT}`) .toEqual(toolSchemasSnapshot) } @@ -1526,7 +1574,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { /* v8 ignore next -- registration guarantees every pin has resolved sources. */ const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8') - const headers = normalizedHeaders(fixture, fixtureContext(fixture)) + const headers = pinningHeaderPayloads(fixture, fixtureContext(fixture)) const promptSnapshot = await readFile( join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8', diff --git a/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/behavior.json b/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/behavior.json index 4de8f25b7e..bd19a95893 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/behavior.json +++ b/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/behavior.json @@ -6,7 +6,8 @@ { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, { "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } }, - { "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } } + { "type": "request/header", "seq": 2, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "series" } }, + { "type": "turn/start", "seq": 3, "time": 100, "data": { "turn": 1 } } ] }] } diff --git a/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/session.jsonl b/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/session.jsonl index 9cbb321e00..467616c82b 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/session.jsonl +++ b/packages/test-support/session-snapshot/tests/fixtures/suite/pin-turn/session.jsonl @@ -1,4 +1,5 @@ {"type":"session","id":"{{session:1}}","createdAt":7,"cwd":"/rec/pin-cwd","delegationDepth":0} {"type":"request/header","data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/header","data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} {"type":"turn/start","data":{"turn":1}} diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a564b3d633..150f3cf929 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1332,7 +1332,7 @@ function renderLifecycle(): string { '', '`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', - 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', + 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', '', 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.', '', diff --git a/snapshots/session/agent-instructions/session.jsonl b/snapshots/session/agent-instructions/session.jsonl index 553db05424..66216297a5 100644 --- a/snapshots/session/agent-instructions/session.jsonl +++ b/snapshots/session/agent-instructions/session.jsonl @@ -26,14 +26,15 @@ {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"},{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"{{message:10}}"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} @@ -44,6 +45,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/agent-instructions/snapshot.yml b/snapshots/session/agent-instructions/snapshot.yml index 0f0bc334dc..5ef4802b50 100644 --- a/snapshots/session/agent-instructions/snapshot.yml +++ b/snapshots/session/agent-instructions/snapshot.yml @@ -6,7 +6,7 @@ recording: authored header: class: agent-instructions pin: true - toolSchemasSource: text-turn + changes: 1 replay: override: true platform: posix diff --git a/snapshots/session/agent-instructions/system-prompt.expected.md b/snapshots/session/agent-instructions/system-prompt.expected.md index 676b6532e3..de3a7c52aa 100644 --- a/snapshots/session/agent-instructions/system-prompt.expected.md +++ b/snapshots/session/agent-instructions/system-prompt.expected.md @@ -5,6 +5,39 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + + + +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json new file mode 100644 index 0000000000..75be989751 --- /dev/null +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -0,0 +1,1392 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [ + [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ] + ] +} diff --git a/snapshots/session/compaction-recovery/session.jsonl b/snapshots/session/compaction-recovery/session.jsonl index 4848baa5fe..990419f159 100644 --- a/snapshots/session/compaction-recovery/session.jsonl +++ b/snapshots/session/compaction-recovery/session.jsonl @@ -26,11 +26,12 @@ {"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":7,"end":8},"shadowedSeqs":[7,8],"shadowedTokenCount":372,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} {"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"},"sourceEventSeqs":[23,24,7,8],"surfaceOp":{"op":"replace","start":7,"end":8}} {"type":"compaction/end","data":{"compactionId":"{{id:1}}","turn":1}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/compaction-recovery/snapshot.yml b/snapshots/session/compaction-recovery/snapshot.yml index f25c25166b..606d57ab7d 100644 --- a/snapshots/session/compaction-recovery/snapshot.yml +++ b/snapshots/session/compaction-recovery/snapshot.yml @@ -6,5 +6,4 @@ recording: authored header: class: compaction-recovery pin: true - systemPromptSource: text-turn - toolSchemasSource: text-turn + changes: 1 diff --git a/snapshots/session/compaction-recovery/system-prompt.expected.md b/snapshots/session/compaction-recovery/system-prompt.expected.md new file mode 100644 index 0000000000..dca396e141 --- /dev/null +++ b/snapshots/session/compaction-recovery/system-prompt.expected.md @@ -0,0 +1,63 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + + + +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json new file mode 100644 index 0000000000..75be989751 --- /dev/null +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -0,0 +1,1392 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [ + [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ] + ] +} diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 6e31924b5d..c22ede5c5b 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -426,8 +426,12 @@ async function verifyHeaders(scenario: HeadlessScenario, actualLogs: readonly Se const base = reconstructed[index] ?? reconstructed[0] const expected = selectedSchemas === undefined ? base : { ...base as JsonObject, tools: selectedSchemas } expect(header, `${scenario.name}: request header ${index + 1}`).toEqual(expected) - expect(formatSystemPromptSnapshot(prompts[index] as string), `${scenario.name}: system prompt ${index + 1}`) - .toBe(childPrompts.get(logIndex) ?? prompt) + } + if (prompts.length > 0) { + expect( + formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)), + `${scenario.name}: system prompts`, + ).toBe(childPrompts.get(logIndex) ?? prompt) } } } diff --git a/snapshots/session/session-sandbox-root/session.jsonl b/snapshots/session/session-sandbox-root/session.jsonl index 1318431a16..5c8cb8416c 100644 --- a/snapshots/session/session-sandbox-root/session.jsonl +++ b/snapshots/session/session-sandbox-root/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":0,"cwd":"/Users/cty/acp-snap-cwd-MABAjO","delegationDepth":0} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -7,7 +7,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"/Users/cty/acp-snap-cwd-MABAjO\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"/Users/cty/acp-snap-cwd-MABAjO\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -18,7 +18,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"/Users/cty/acp-snap-cwd-MABAjO/session-root.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index b48a5c6bcc..f4b07c037f 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index d9809ed53f..bcdd6b6c5d 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index cc05c3aed9..2586c609f4 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index c12ffc2a97..df302f69ed 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 5a1f64faf8..c7822c503d 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/goal-multi-turn-actions/session.jsonl b/snapshots/web/goal-multi-turn-actions/session.jsonl index 8b2e221682..78d92f9a19 100644 --- a/snapshots/web/goal-multi-turn-actions/session.jsonl +++ b/snapshots/web/goal-multi-turn-actions/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787543212737,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787640083383,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"goal","args":" 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","source":{"kind":"user"}}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787543212949,"updatedAt":1787543212949}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787640083556,"updatedAt":1787640083556}} {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Goal created\nStatus: active\nObjective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\nRounds: 0/256\nActivation: armed\n\nCommands: /goal edit , /goal pause, /goal clear"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":1},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} @@ -84,9 +84,9 @@ {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} +{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn.\n\nSo I should just output the file structure of this randomly picked package (packages/context/session-reference) and stop. I should NOT mark the goal complete since there are 2 turns and this is only turn 1. The objective says the system will open another turn — so I should leave the goal active for the next round.\n\nLet me output the file structure in a nice tree format."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Turn 1 / 2** — 随机选中的包:`packages/context/session-reference` 📦\n\n```\nsession-reference\n├── README.md\n├── package.json\n├── src\n│ ├── index.ts\n│ └── uri.ts\n└── tests\n └── session-reference.spec.ts\n```\n\n```\nsession-reference/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ ├── index.ts # 入口,导出公共 API\n│ └── uri.ts # 会话引用 URI 相关实现\n└── tests/\n └── session-reference.spec.ts # 单元测试\n```\n\n这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":318,"cacheReadTokens":9088,"reasoningTokens":123}}}} @@ -99,6 +99,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 2/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":2},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."}}}} @@ -107,9 +108,9 @@ {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\":\"python3 -c \\\"import random; from pathlib import Path; pkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json')); print(random.Random(1).choice(pkgs))\\\"\",\"description\":\"Randomly select another package with Python\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":389,"outputTokens":165,"cacheReadTokens":9216,"reasoningTokens":47}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."},{"type":"tool-call","id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\":\"python3 -c \\\"import random; from pathlib import Path; pkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json')); print(random.Random(1).choice(pkgs))\\\"\",\"description\":\"Randomly select another package with Python\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:16}}"},"usage":{"inputTokens":389,"outputTokens":165,"cacheReadTokens":9216,"reasoningTokens":47}},"sourceEventSeqs":[408,409,410,411,412,413,414,415],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."},{"type":"tool-call","id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\":\"python3 -c \\\"import random; from pathlib import Path; pkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json')); print(random.Random(1).choice(pkgs))\\\"\",\"description\":\"Randomly select another package with Python\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:16}}"},"usage":{"inputTokens":389,"outputTokens":165,"cacheReadTokens":9216,"reasoningTokens":47}},"sourceEventSeqs":[409,410,411,412,413,414,415,416],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\":\"python3 -c \\\"import random; from pathlib import Path; pkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json')); print(random.Random(1).choice(pkgs))\\\"\",\"description\":\"Randomly select another package with Python\"}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_wwDXszkz3z9JwKb8jUXj2737"},"content":[{"type":"tool-result","toolCallId":"call_00_wwDXszkz3z9JwKb8jUXj2737","content":[{"type":"text","text":"packages/context/session-reference\n"}],"isError":false}],"role":"user","id":"{{message:17}}"}},"sourceEventSeqs":[417],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_wwDXszkz3z9JwKb8jUXj2737"},"content":[{"type":"tool-result","toolCallId":"call_00_wwDXszkz3z9JwKb8jUXj2737","content":[{"type":"text","text":"packages/context/session-reference\n"}],"isError":false}],"role":"user","id":"{{message:17}}"}},"sourceEventSeqs":[418],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -120,9 +121,9 @@ {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":62,"outputTokens":164,"cacheReadTokens":9728,"reasoningTokens":46}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."},{"type":"tool-call","id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":62,"outputTokens":164,"cacheReadTokens":9728,"reasoningTokens":46}},"sourceEventSeqs":[421,422,423,424,425,426,427,428],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."},{"type":"tool-call","id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":62,"outputTokens":164,"cacheReadTokens":9728,"reasoningTokens":46}},"sourceEventSeqs":[422,423,424,425,426,427,428,429],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":2,"callId":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}} -{"type":"tool/result","data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_3K2lg9zGfGsTvuh19xv65220"},"content":[{"type":"tool-result","toolCallId":"call_00_3K2lg9zGfGsTvuh19xv65220","content":[{"type":"text","text":"packages/llm/token-meter\n"}],"isError":false}],"role":"user","id":"{{message:19}}"}},"sourceEventSeqs":[430],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_3K2lg9zGfGsTvuh19xv65220"},"content":[{"type":"tool-result","toolCallId":"call_00_3K2lg9zGfGsTvuh19xv65220","content":[{"type":"text","text":"packages/llm/token-meter\n"}],"isError":false}],"role":"user","id":"{{message:19}}"}},"sourceEventSeqs":[431],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"step/start","data":{"turn":2,"step":3}} {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -130,15 +131,15 @@ {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\":\"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | LC_ALL=C sort\",\"description\":\"List all files in token-meter package\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":96,"cacheReadTokens":9856,"reasoningTokens":0}}}} {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\":\"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | LC_ALL=C sort\",\"description\":\"List all files in token-meter package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:20}}"},"usage":{"inputTokens":119,"outputTokens":96,"cacheReadTokens":9856,"reasoningTokens":0}},"sourceEventSeqs":[434,435,436,437,438],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\":\"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | LC_ALL=C sort\",\"description\":\"List all files in token-meter package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:20}}"},"usage":{"inputTokens":119,"outputTokens":96,"cacheReadTokens":9856,"reasoningTokens":0}},"sourceEventSeqs":[435,436,437,438,439],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":3,"callId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\":\"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | LC_ALL=C sort\",\"description\":\"List all files in token-meter package\"}"}} -{"type":"tool/result","data":{"turn":2,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","content":[{"type":"text","text":"packages/llm/token-meter/README.md\npackages/llm/token-meter/package.json\npackages/llm/token-meter/src/index.ts\npackages/llm/token-meter/tests/token-meter.spec.ts\n"}],"isError":false}],"role":"user","id":"{{message:21}}"}},"sourceEventSeqs":[440],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","content":[{"type":"text","text":"packages/llm/token-meter/README.md\npackages/llm/token-meter/package.json\npackages/llm/token-meter/src/index.ts\npackages/llm/token-meter/tests/token-meter.spec.ts\n"}],"isError":false}],"role":"user","id":"{{message:21}}"}},"sourceEventSeqs":[441],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":3}} {"type":"step/start","data":{"turn":2,"step":4}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} +{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":""}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":"{}"}}} @@ -147,9 +148,9 @@ {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":152,"outputTokens":256,"cacheReadTokens":9984,"reasoningTokens":52}}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."},{"type":"text","text":"**Turn 2 / 2** — 随机选中的包:`packages/llm/token-meter` 📦\n\n```\ntoken-meter\n├── README.md\n├── package.json\n├── src\n│ └── index.ts\n└── tests\n └── token-meter.spec.ts\n```\n\n```\ntoken-meter/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ └── index.ts # 入口,导出公共 API(token 计量逻辑)\n└── tests/\n └── token-meter.spec.ts # 单元测试\n```\n\n两个 turn 都完成了(turn 1:`session-reference`,turn 2:`token-meter`)。这一轮到此结束。"},{"type":"tool-call","id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:22}}"},"usage":{"inputTokens":152,"outputTokens":256,"cacheReadTokens":9984,"reasoningTokens":52}},"sourceEventSeqs":[444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."},{"type":"text","text":"**Turn 2 / 2** — 随机选中的包:`packages/llm/token-meter` 📦\n\n```\ntoken-meter\n├── README.md\n├── package.json\n├── src\n│ └── index.ts\n└── tests\n └── token-meter.spec.ts\n```\n\n```\ntoken-meter/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ └── index.ts # 入口,导出公共 API(token 计量逻辑)\n└── tests/\n └── token-meter.spec.ts # 单元测试\n```\n\n两个 turn 都完成了(turn 1:`session-reference`,turn 2:`token-meter`)。这一轮到此结束。"},{"type":"tool-call","id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:22}}"},"usage":{"inputTokens":152,"outputTokens":256,"cacheReadTokens":9984,"reasoningTokens":52}},"sourceEventSeqs":[445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":4,"callId":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_AKGba6PMDSJdtJI2ZhAz7514"},"content":[{"type":"tool-result","toolCallId":"call_00_AKGba6PMDSJdtJI2ZhAz7514","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":1,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"active\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{message:23}}"}},"sourceEventSeqs":[676],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_AKGba6PMDSJdtJI2ZhAz7514"},"content":[{"type":"tool-result","toolCallId":"call_00_AKGba6PMDSJdtJI2ZhAz7514","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":1,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"active\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{message:23}}"}},"sourceEventSeqs":[677],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":4}} {"type":"step/start","data":{"turn":2,"step":5}} {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -160,20 +161,20 @@ {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}}}} {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"},{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:24}}"},"usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}},"sourceEventSeqs":[680,681,682,683,684,685,686,687],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"},{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:24}}"},"usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}},"sourceEventSeqs":[681,682,683,684,685,686,687,688],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":5,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787543212949,"updatedAt":1787543213546}} -{"type":"tool/result","data":{"turn":2,"step":5,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{message:25}}"}},"sourceEventSeqs":[689],"surfaceOp":"append"} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787640083556,"updatedAt":1787640084238}} +{"type":"tool/result","data":{"turn":2,"step":5,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{message:25}}"}},"sourceEventSeqs":[690],"surfaceOp":"append"} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"}]}} {"type":"step/end","data":{"turn":2,"step":5}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":6}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-filesystem`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-filesystem`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-filesystem`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-filesystem`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:27}}"},"usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}},"sourceEventSeqs":[697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-filesystem`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-filesystem`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:27}}"},"usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}},"sourceEventSeqs":[698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":6}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 80733e2719..90d92f9de2 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -14,6 +14,10 @@ - img - img - text: "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" +- button "System prompt": + - img + - img + - text: System prompt - button "Context injection goal": - img - img @@ -88,6 +92,10 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "System prompt": + - img + - img + - text: System prompt - button "Context injection goal": - img - img diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 4d3fe1aa9f..39060d7135 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 85a3661eb8..407235a379 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index 341ddf22db..870fc89ffe 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 7e6a7af832..34a5ce76cd 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 827faf4486..a923ae8387 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 7f4275344b..754c1af0ae 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index d4e15c0498..4c4403f11e 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 0419f0f1b1..5c82749291 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index e2e41dd34c..2d694d6162 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -10,7 +10,12 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: plan Plan mode on. Use /plan off to leave. +- button "System prompt": + - img + - img + - text: System prompt +- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": @@ -29,6 +34,10 @@ - img - img - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI" +- button "System prompt": + - img + - img + - text: System prompt - 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."': - img - img diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 7815286fe9..c516407e3d 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 150c6060fb..9ce8b0be96 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 74dcf76b8b..4c64c771f0 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index e7d6577325..666cb935b7 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -14,6 +14,10 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" +- button "System prompt": + - img + - img + - text: System prompt - button "Context injection goal": - img - img diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 09f677bcc8..0e13eb6e8d 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 2ae4f81331..48c85e44f4 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 4402a0c69b..e3c1eff67a 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 3f7148828e..d5907165b2 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 3ca7fba7ca..b1dbc8ffa7 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 6c54404742..ca0e12d5cd 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -7,6 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 9de5436d86..557e5bc0ff 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 528d53ced1..561e1a5342 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index dc26ca3e97..0b3d521d6d 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -14,6 +14,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img @@ -34,7 +38,12 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "System prompt": + - img + - img + - text: System prompt +- text: Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 378ebea7d0..24365b84e1 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -11,6 +11,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 8af07a3543..dc3cd57ff6 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index cbac0d4880..0a031aec85 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index c92014e916..746c5cff00 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 5ad87e217b..617a06bbf5 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,3 +1,7 @@ +- button "System prompt": + - img + - img + - text: System prompt - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy": - img