fix(agent-loop): settle pending tool results before failed steps close
This commit is contained in:
1 parent
719b86f870
commit
6a6f350b94
37 files changed
+1183
-240
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step:
|
||||
en: 8fd2a50a041b1b18
|
||||
zh: 01aed07a6425a322
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem:
|
||||
en: 1fa7525f83af9035
|
||||
zh: 401ce968e3dd5434
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision:
|
||||
en: 3d58367a05183b4a
|
||||
zh: f9ccc3a7011a6240
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered:
|
||||
en: 37da4d9c8533b736
|
||||
zh: e52a59763ae2b62e
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences:
|
||||
en: 1ea72421a98551d8
|
||||
zh: c01e02ddac8a3454
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Record unanswered tool results before closing a failed step
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-failed-step-tool-results.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume.
|
||||
|
||||
## Decision
|
||||
|
||||
The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch.
|
||||
|
||||
Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative.
|
||||
|
||||
This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects.
|
||||
|
||||
**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step.
|
||||
|
||||
**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching.
|
||||
|
||||
**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work.
|
||||
|
||||
## Consequences
|
||||
|
||||
Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change.
|
||||
|
||||
Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 在关闭失败步骤前补齐工具结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-failed-step-tool-results.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。
|
||||
|
||||
## 决策
|
||||
|
||||
AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。
|
||||
|
||||
恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。
|
||||
|
||||
本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。
|
||||
|
||||
**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。
|
||||
|
||||
**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。
|
||||
|
||||
**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。
|
||||
|
||||
## 影响
|
||||
|
||||
实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。
|
||||
|
||||
此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 532bb7268d9b93ce
|
||||
zh: ad47162233222ab0
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
|
||||
en: f0906d03fef98e7b
|
||||
zh: f9e43c4c832c9a05
|
||||
en: a485c530ea4366de
|
||||
zh: 6f9915d726de9ac7
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
|
||||
en: 16c4ee08b65ead23
|
||||
zh: df80d6e49982615b
|
||||
@@ -24,8 +24,8 @@
|
||||
en: eb5a6aa84980f0ab
|
||||
zh: eed2680a160e8ed2
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
|
||||
en: 44f8fb5851ba4e39
|
||||
zh: 4d75c937ac5277f7
|
||||
en: 17becc038bb7df7c
|
||||
zh: 673d9a9bdfa39edf
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
|
||||
en: 5e5b2a1c7d850f83
|
||||
zh: b63311f28eb69366
|
||||
en: a5508057bc03f4f6
|
||||
zh: 96b46c59402474c3
|
||||
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
|
||||
|
||||
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
|
||||
|
||||
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
|
||||
An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md).
|
||||
|
||||
PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
|
||||
|
||||
@@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook.
|
||||
|
||||
**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
|
||||
**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls.
|
||||
|
||||
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
||||
|
||||
@@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
|
||||
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs.
|
||||
|
||||
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
|
||||
|
||||
@@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o
|
||||
|
||||
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
|
||||
|
||||
A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
|
||||
A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
|
||||
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
|
||||
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
|
||||
中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。
|
||||
|
||||
PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
|
||||
|
||||
@@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
**公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。
|
||||
|
||||
**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
|
||||
**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。
|
||||
|
||||
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
|
||||
|
||||
@@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
|
||||
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。
|
||||
|
||||
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
|
||||
|
||||
@@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
|
||||
|
||||
终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
|
||||
终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 214c3c81c21df6f9
|
||||
zh: 75d2cdc3c9fc9930
|
||||
/deepseek-harness-architecture/turn-flow:
|
||||
en: e13500f6d5b614d6
|
||||
zh: 7e6acb96a520ded2
|
||||
en: badc0ffea030cdce
|
||||
zh: 3241f0b37c3ec067
|
||||
/deepseek-harness-architecture/session-log:
|
||||
en: e1e3a44140036f25
|
||||
zh: 84c70c969e5ad216
|
||||
|
||||
@@ -114,6 +114,8 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo
|
||||
|
||||
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
|
||||
|
||||
Failed steps [record missing tool results](../packages/core/agent-loop/README.md#understand-the-implementation).
|
||||
|
||||
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).
|
||||
|
||||
## Session log
|
||||
|
||||
@@ -118,6 +118,8 @@ turn/end
|
||||
|
||||
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
|
||||
|
||||
失败步骤会[记录缺失的工具结果](../packages/core/agent-loop/README.zh.md#understand-the-implementation)。
|
||||
|
||||
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
|
||||
|
||||
## 会话日志
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
en: ed5137ef858b3424
|
||||
zh: af2ff3bc15576456
|
||||
/deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation:
|
||||
en: 6bd69c9cf8790e14
|
||||
zh: e2812f6bb4c26f10
|
||||
en: 548b6ee23c3a1e5d
|
||||
zh: e89428f427b4462e
|
||||
/deepseek-ai-dsh-agent-loop/further-exploration:
|
||||
en: 6c73cd2390e24b86
|
||||
zh: ba042ca93cc0f595
|
||||
@@ -92,9 +92,21 @@
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/undispatched-calls-after-cancellation/kv-cache-effect:
|
||||
en: 1750eea3cc5bf2d8
|
||||
zh: 519d37b0cb4467a7
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure:
|
||||
en: 0f150799ee294e40
|
||||
zh: 4c283b07367dfa4a
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/what-the-model-sees:
|
||||
en: a62feae35f09ff13
|
||||
zh: d20a0172d86f6515
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/token-effect:
|
||||
en: cefdf878a21a681a
|
||||
zh: 96d8271b257478d6
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/kv-cache-effect:
|
||||
en: 83531f2a13cc1f0e
|
||||
zh: 81a780d637dd8c5d
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work:
|
||||
en: f658b8bc2935578f
|
||||
zh: 4d503701fa17b982
|
||||
en: 7f93fa258b0e39ba
|
||||
zh: 819075791542b01c
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -126,6 +126,8 @@ Prompt admission uses the actual `prepareCall()` result, not the preceding `requ
|
||||
|
||||
Final adapter selection, dispatch, and iteration failures arrive as terminal finishes and enter `agent/request-error`; a handling listener returns `{ kind: 'retry' }` without calling `next()`, while an unhandled failure is terminal. Middleware, result-processing, tool, and other extension failures remain thrown and close the turn directly — plugin failure ends the turn, not the loop. Undispatched model tool calls after cancellation receive synthetic `tool/call` plus `ABORTED_BEFORE_DISPATCH` result pairs. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the signal lifecycle.
|
||||
|
||||
Before closing a failed step, the driver records an error result for each unanswered assistant tool call. A recorded `tool/call` without a committed result receives `TOOL_OUTCOME_UNKNOWN`; a request without a call record receives `TOOL_NOT_STARTED`. Committed results remain intact, started dispatches settle before recovery, and the turn retains the original failure. These results let later requests use paired tool history without automatically retrying uncertain operations ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -189,6 +191,20 @@ One fixed error result per skipped call remains in history until compaction shad
|
||||
|
||||
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV Cache entries.
|
||||
|
||||
### Unanswered calls after step failure
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each unanswered tool call receives an error result in later history. For a recorded call, the result states `Its outcome is unknown.` and permits retries only for read-only or idempotent operations; possible side effects require checking external state or asking the user first. A call without a start record states `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`
|
||||
|
||||
#### Token effect
|
||||
|
||||
One recovery result per unanswered call remains in history until compaction shadows it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Recovery results append after the existing history and preserve its reusable prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
@@ -197,6 +213,7 @@ Append-only; each synthetic result follows the reusable request prefix and does
|
||||
These limits define when the loop needs special care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **Previously closed inconsistent history** — failed-step recovery does not rewrite unanswered calls in already-closed historical turns.
|
||||
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona and tool composition are available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle extension point such as `agent/turn-stopping`.
|
||||
|
||||
@@ -126,6 +126,8 @@ const handle = await ctx.agents.create({
|
||||
|
||||
最终适配器选择、分发与迭代失败以终止结束的形式到达并进入 `agent/request-error`;处理该失败的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`,未被处理的失败则是终态。Middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次——插件失败结束的是轮次,不是循环。取消后未分发的模型工具调用会收到合成的 `tool/call` 加 `ABORTED_BEFORE_DISPATCH` 结果对。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)拥有信号生命周期。
|
||||
|
||||
关闭失败步骤之前,驱动器为每个尚无结果的 assistant 工具调用记录错误结果。已有 `tool/call` 记录但尚无已提交结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;没有调用记录的请求获得 `TOOL_NOT_STARTED`。已提交的结果保持完整,已启动的派发先结算再恢复,轮次保留原始失败。这些结果让后续请求使用配对完整的工具历史,而不自动重试结果不明的操作([决策](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md))。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -189,6 +191,20 @@ const handle = await ctx.agents.create({
|
||||
|
||||
仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### 步骤失败后尚无结果的调用
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
每个尚无结果的工具调用都会在后续历史中获得错误结果。对于已有记录的调用,结果说明 `Its outcome is unknown.`,且仅允许重试只读或幂等操作;可能存在副作用时,必须先核验外部状态或询问用户。没有启动记录的调用说明 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个尚无结果的调用都会在历史中保留一个恢复结果,直到压缩将其遮蔽。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
恢复结果追加在既有历史之后,保留其可复用前缀。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
@@ -197,6 +213,7 @@ const handle = await ctx.agents.create({
|
||||
这些限制说明循环何时需要特别留意。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占([原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md))。
|
||||
- **此前已关闭的不一致历史**:失败步骤恢复不会改写已关闭历史轮次中尚无结果的调用。
|
||||
- **配置标签默认对应新会话**:省略 `sessionId` 时,每次启动都会创建新的 `${id}-session-<uuid>`;如需确切的恢复或创建行为,必须显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。
|
||||
- **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona 与工具组合。
|
||||
- **没有内置轮次预算**:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如 `agent/turn-stopping`)执行取消。
|
||||
|
||||
@@ -28,7 +28,7 @@ import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session'
|
||||
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -328,6 +328,10 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
this.session.append('step/start', { turn, step })
|
||||
phase.step = step
|
||||
const toolRecovery = new ToolCallRecovery()
|
||||
const stopRecovery = this.ctx.on('session/event', (session, event) => {
|
||||
if (session === this.session) toolRecovery.observe(event)
|
||||
})
|
||||
try {
|
||||
// max-tokens is sticky: once any step hits the ceiling, later steps
|
||||
// that complete normally must not downgrade the turn outcome.
|
||||
@@ -335,7 +339,20 @@ export class ReactLoopAgent implements Agent {
|
||||
// max-tokens stays sticky: a later completed step must not
|
||||
// downgrade the turn outcome.
|
||||
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
for (const event of toolRecovery.results()) {
|
||||
this.session.append('tool/result', event.data, {
|
||||
surfaceOp: 'append',
|
||||
...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
|
||||
})
|
||||
}
|
||||
} catch (recoveryError: unknown) {
|
||||
throw new AggregateError([error, recoveryError], 'Step failed and its pending tool results could not be recorded', { cause: error })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
stopRecovery()
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* and drains started calls.
|
||||
*
|
||||
* Abort records synthetic error results for skipped calls so replay stays
|
||||
* valid. A terminal scheduler failure preserves already-recorded `tool/call`
|
||||
* events without fabricating results.
|
||||
* valid. A terminal scheduler failure rejects after draining; the owning step
|
||||
* records conservative recovery results before closing.
|
||||
* @module dsh-agent-loop/tool-calls
|
||||
*/
|
||||
|
||||
@@ -45,8 +45,8 @@ interface GroupOutcome {
|
||||
* the signal still aborted after accepting started-call context through the
|
||||
* caller-supplied acceptor (the machine stages it in its next-step inbox for the
|
||||
* step boundary). An internal scheduler failure stops new dispatches, drains
|
||||
* already-started dispatches, and rejects with the first failure without
|
||||
* fabricating tool results.
|
||||
* already-started dispatches, and rejects with the first failure. The owning
|
||||
* step supplies error results for requests without a committed outcome.
|
||||
* The committed step's AgentLoop driver boundary supplies the initiating Agent
|
||||
* that becomes each explicit {@link ToolExecutionInput.agent}.
|
||||
*
|
||||
@@ -116,8 +116,8 @@ function parseArguments(raw: string): unknown {
|
||||
* drain and remains for the caller's next barrier. Results and contexts commit
|
||||
* in model order. Abort stops starts, drains and commits started calls, accepts
|
||||
* their contexts into the owning batch, records results for skipped calls, and
|
||||
* returns an aborted outcome. Scheduler failure drains dispatches without
|
||||
* committing synthetic recovery results.
|
||||
* returns an aborted outcome. Scheduler failure drains dispatches and rejects
|
||||
* for the owning step to record recovery results.
|
||||
*/
|
||||
async function runGroup(
|
||||
ctx: Context,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import ToolRuntime, { defineContentToolFixture, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
@@ -976,6 +976,71 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx1.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['resume', 'fork'] as const)('%s continues persisted history after a live scheduler failure', async (mode) => {
|
||||
const firstAdapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('started'), name: 'probe', arguments: '{}' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('pending'), name: 'probe', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]])
|
||||
const { ctx: first, root } = await persistentHarness(firstAdapter)
|
||||
let next: Context | undefined
|
||||
try {
|
||||
first.tools.register(defineContentToolFixture({
|
||||
name: 'probe', description: 'probe', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'unexpected execution' }] },
|
||||
}))
|
||||
first.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw new Error('scheduler prepare failed') }
|
||||
const sessionId = SessionId('failed-tool-history')
|
||||
const initial = await first.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const failed = waitForIdle(first, initial.agent)
|
||||
initial.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'try tools' }], source: { kind: 'user' } }))
|
||||
await failed
|
||||
await initial.dispose()
|
||||
await first.fiber.dispose()
|
||||
|
||||
const nextAdapter = new MockAdapter([textResponse('continued after failure')])
|
||||
next = await mountPersistentHarness(root, nextAdapter)
|
||||
const stored = await readStoredEvents(next, sessionId)
|
||||
expect(stored.filter(event => event.type === 'tool/result').map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([[ToolCallId('started'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]])
|
||||
expect(stored.at(-1)).toMatchObject({
|
||||
type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'scheduler prepare failed' } } },
|
||||
})
|
||||
|
||||
const continued = mode === 'resume'
|
||||
? await next.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
: await next.agents.create({
|
||||
sessionId: SessionId('failed-tool-history-fork'), seed: stored,
|
||||
meta: { parentSession: sessionId, isSeeded: true },
|
||||
inheritedEventCount: SessionLogOffset(stored.length),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const done = waitForIdle(next, continued.agent)
|
||||
continued.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await done
|
||||
|
||||
expect(nextAdapter.requests[0]?.messages.flatMap((message): string[] => {
|
||||
if (message.role === 'tool') return [`result:${message.toolCallId}`]
|
||||
return message.content.flatMap((block): string[] => {
|
||||
if (block.type === 'tool-call') return [`call:${block.id}`]
|
||||
if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`]
|
||||
return []
|
||||
})
|
||||
})).toEqual(['user:try tools', 'call:started', 'call:pending', 'result:started', 'result:pending', 'user:continue'])
|
||||
await continued.dispose()
|
||||
const reopened = await readStoredEvents(next, continued.agent.session.id)
|
||||
expect(reopened.slice(0, stored.length)).toEqual(stored)
|
||||
expect(reopened.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(reopened.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } })
|
||||
} finally {
|
||||
await next?.fiber.dispose()
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* ACP expected outputs own transcript-facing coverage.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage, ToolCallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionEvent, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
@@ -631,6 +631,180 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
})
|
||||
|
||||
describe('tool-call scheduler: failure quiescence', () => {
|
||||
it.each(['execution-mode', 'prepare', 'finalize'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
{ id: 'c1', name: 'p', args: { id: '1' } },
|
||||
{ id: 'c2', name: 'p', args: { id: '2' } },
|
||||
{ id: 'c3', name: 'p', args: { id: '3' } },
|
||||
]),
|
||||
textResponse('continued'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const executed: string[] = []
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'p', description: 'exclusive', parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
executed.push(args.id)
|
||||
return [{ type: 'text', text: `done-${args.id}` }]
|
||||
},
|
||||
}))
|
||||
const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER]
|
||||
const executionMode = ctx.tools.executionMode.bind(ctx.tools)
|
||||
const prepare = scheduler.prepare.bind(scheduler)
|
||||
const finalize = scheduler.finalize.bind(scheduler)
|
||||
const failure = new Error(`${phase} failed`)
|
||||
if (phase === 'execution-mode') {
|
||||
ctx.tools.executionMode = (exec) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return executionMode(exec)
|
||||
}
|
||||
} else if (phase === 'prepare') {
|
||||
scheduler.prepare = (exec) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return prepare(exec)
|
||||
}
|
||||
} else {
|
||||
scheduler.finalize = (exec, result) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return finalize(exec, result)
|
||||
}
|
||||
}
|
||||
const agent = await ctx.agentLoop.create(SessionId(`failure-${phase}`), { provider: 'mock', model: 'mock' })
|
||||
const listenerCount = () => ctx.events.dispatch('emit', ['session/event', agent.session, events(agent).at(-1)]).length
|
||||
const idleListenerCount = listenerCount()
|
||||
const failedTurn = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await failedTurn
|
||||
|
||||
const failedEvents = events(agent)
|
||||
const calls = failedEvents.filter(event => event.type === 'tool/call')
|
||||
const results = failedEvents.filter(event => event.type === 'tool/result')
|
||||
expect(executed).toEqual(phase === 'finalize' ? ['1', '2'] : ['1'])
|
||||
expect(results.map(event => [event.data.message.toolCallId, event.data.error?.code])).toEqual([
|
||||
[ToolCallId('c1'), undefined],
|
||||
[ToolCallId('c2'), phase === 'execution-mode' ? TOOL_NOT_STARTED : TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c3'), TOOL_NOT_STARTED],
|
||||
])
|
||||
expect(results[0]?.data.message).toMatchObject({
|
||||
role: 'tool', toolCallId: ToolCallId('c1'), isError: false, content: [{ type: 'text', text: 'done-1' }],
|
||||
})
|
||||
expect(results.slice(1).map(event => event.data.message.isError)).toEqual([true, true])
|
||||
expect(results.map(event => event.sourceEventSeqs)).toEqual([
|
||||
[calls[0]?.seq],
|
||||
phase === 'execution-mode' ? undefined : [calls[1]?.seq],
|
||||
undefined,
|
||||
])
|
||||
expect(failedEvents.slice(-3).map(event => event.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
|
||||
expect(failedEvents.at(-1)).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } } },
|
||||
})
|
||||
expect(listenerCount()).toBe(idleListenerCount)
|
||||
|
||||
ctx.tools.executionMode = executionMode
|
||||
scheduler.prepare = prepare
|
||||
scheduler.finalize = finalize
|
||||
const nextTurn = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await nextTurn
|
||||
|
||||
expect(adapter.requests[1]?.messages.flatMap((message): string[] => {
|
||||
if (message.role === 'tool') return [`result:${message.toolCallId}`]
|
||||
return message.content.flatMap((block): string[] => {
|
||||
if (block.type === 'tool-call') return [`call:${block.id}`]
|
||||
if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`]
|
||||
return []
|
||||
})
|
||||
})).toEqual(['user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', 'user:continue'])
|
||||
expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(3)
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed')
|
||||
expect(listenerCount()).toBe(idleListenerCount)
|
||||
})
|
||||
|
||||
it('preserves the scheduler error when recording recovery results also fails', async () => {
|
||||
const adapter = new MockAdapter([multiCall([{ id: 'c1', name: 'p', args: {} }])])
|
||||
const ctx = await harness(adapter)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const failure = new Error('prepare failed')
|
||||
const recoveryFailure = new Error('recovery result rejected')
|
||||
ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw failure }
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/event' && (args[1] as SessionEvent).type === 'tool/result') throw recoveryFailure
|
||||
})
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', ({ error }) => { errors.push(error) })
|
||||
const agent = await ctx.agentLoop.create(SessionId('recovery-result-failure'), { provider: 'mock', model: 'mock' })
|
||||
const done = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await done
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBeInstanceOf(AggregateError)
|
||||
expect(errors[0]).toMatchObject({ cause: failure, errors: [failure, recoveryFailure] })
|
||||
expect(events(agent).filter(event => event.type === 'tool/result')).toEqual([])
|
||||
expect(events(agent).at(-1)).toMatchObject({ data: { reason: { kind: 'error', error: { code: 'UNKNOWN' } } } })
|
||||
})
|
||||
|
||||
it('isolates pending requests from child agents and other sessions in the same scope', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([{ id: 'shared', name: 'p', args: {} }, { id: 'pending', name: 'p', args: {} }]),
|
||||
multiCall([{ id: 'shared', name: 'p', args: {} }]),
|
||||
textResponse('second agent done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const enteredDispatch = Promise.withResolvers<undefined>()
|
||||
const releaseDispatch = Promise.withResolvers<undefined>()
|
||||
onTestFinished(async () => {
|
||||
releaseDispatch.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'p', description: 'probe', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'completed' }] },
|
||||
}))
|
||||
const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER]
|
||||
const dispatch = scheduler.dispatch.bind(scheduler)
|
||||
scheduler.dispatch = async (exec) => {
|
||||
if (exec.agent?.id === SessionId('first-agent')) {
|
||||
enteredDispatch.resolve(undefined)
|
||||
await releaseDispatch.promise
|
||||
throw new Error('first agent dispatch failed')
|
||||
}
|
||||
return dispatch(exec)
|
||||
}
|
||||
const first = await ctx.agentLoop.create(SessionId('first-agent'), { provider: 'mock', model: 'mock' })
|
||||
const { agent: second } = await first.ctx.agents.create({
|
||||
sessionId: SessionId('second-agent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
parentAgent: first,
|
||||
})
|
||||
const firstDone = waitForIdle(ctx, first)
|
||||
first.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }))
|
||||
await enteredDispatch.promise
|
||||
const secondDone = waitForIdle(ctx, second)
|
||||
second.followup(createUserMessage({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }))
|
||||
await secondDone
|
||||
const side = first.ctx.sessions.create(SessionId('same-scope-session'))
|
||||
side.append('turn/start', { turn: 1 })
|
||||
side.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'side session' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
||||
side.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
releaseDispatch.resolve(undefined)
|
||||
await firstDone
|
||||
|
||||
const firstResults = events(first).filter(event => event.type === 'tool/result')
|
||||
expect(firstResults.map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([[ToolCallId('shared'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]])
|
||||
expect(firstResults.map(event => event.sourceEventSeqs)).toEqual([
|
||||
[events(first).find(event => event.type === 'tool/call')?.seq], undefined,
|
||||
])
|
||||
const secondResults = events(second).filter(event => event.type === 'tool/result')
|
||||
expect(secondResults).toHaveLength(1)
|
||||
expect(secondResults[0]?.data.message).toMatchObject({ role: 'tool', toolCallId: ToolCallId('shared'), isError: false, content: [{ type: 'text', text: 'completed' }] })
|
||||
expect(events(second).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('stops new dispatches and drains started bodies before surfacing the first failure', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
@@ -660,6 +834,12 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
const schedulerError = new Error('scheduler exploded')
|
||||
const drainedError = new Error('sibling failed while draining')
|
||||
let rejectFirst: ((error: Error) => void) | undefined
|
||||
onTestFinished(async () => {
|
||||
prepareGate.resolve(undefined)
|
||||
rejectFirst?.(schedulerError)
|
||||
for (const id of gated.pending()) gated.release(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
scheduler.dispatch = exec => exec.callId === ToolCallId('c1')
|
||||
? new Promise((_resolve, reject) => { rejectFirst = reject })
|
||||
: dispatch(exec).then(() => { throw drainedError })
|
||||
@@ -677,13 +857,22 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
const startedBeforeDrain = [...gated.started]
|
||||
const idleBeforeDrain = idle
|
||||
const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end')
|
||||
const resultsBeforeDrain = events(agent).filter(event => event.type === 'tool/result')
|
||||
for (const id of gated.pending()) gated.release(id)
|
||||
await idlePromise
|
||||
|
||||
expect(startedBeforeDrain).toEqual(['2'])
|
||||
expect(idleBeforeDrain).toBe(false)
|
||||
expect(turnEndBeforeDrain).toBeUndefined()
|
||||
expect(resultsBeforeDrain).toEqual([])
|
||||
expect(gated.pending()).toEqual([])
|
||||
expect(events(agent).filter(event => event.type === 'tool/result').map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([
|
||||
[ToolCallId('c1'), TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c2'), TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c3'), TOOL_OUTCOME_UNKNOWN],
|
||||
])
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
|
||||
})
|
||||
|
||||
@@ -1,103 +1,6 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# 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 packages/core/session/README.md
|
||||
/:
|
||||
en: 54dedea99ec5492c
|
||||
zh: 23b29cb5d738dadb
|
||||
/deepseek-ai-dsh-session:
|
||||
en: c019c0e8c7f9cba0
|
||||
zh: 151e150825fda310
|
||||
/deepseek-ai-dsh-session/summary:
|
||||
en: 311239677afd230a
|
||||
zh: f2c575945baaa2a2
|
||||
/deepseek-ai-dsh-session/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-session/use-this-package:
|
||||
en: 0882d4056c45aa75
|
||||
zh: fcee78e9845b78b3
|
||||
/deepseek-ai-dsh-session/use-this-package/create-and-inspect-sessions:
|
||||
en: f80dab583fa3939d
|
||||
zh: 5a9a79ebcef86119
|
||||
/deepseek-ai-dsh-session/use-this-package/append-and-derive:
|
||||
en: aa5f1951ecf3ac0c
|
||||
zh: 62a599ace8f2bf15
|
||||
/deepseek-ai-dsh-session/use-this-package/read-the-log:
|
||||
en: 2b29cfeac7a2baa8
|
||||
zh: b535b04dcbc29d3d
|
||||
/deepseek-ai-dsh-session/use-this-package/fork-a-session:
|
||||
en: 1c40d17a786059d3
|
||||
zh: 81320ce118977c65
|
||||
/deepseek-ai-dsh-session/use-this-package/flush-durable-state:
|
||||
en: 70a7673145c851ad
|
||||
zh: 0dd1667d9518993d
|
||||
/deepseek-ai-dsh-session/understand-the-implementation:
|
||||
en: ce7e776e9d516bdb
|
||||
zh: 59d2751743db7795
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/design-concept:
|
||||
en: dabbe938e288c55a
|
||||
zh: 31c7ac2bee2a2846
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/request-headers:
|
||||
en: bbfb53c50100ab13
|
||||
zh: 303a8ee47f4f656a
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/source-map:
|
||||
en: e87f7ef34effbad2
|
||||
zh: 774a98d23611f5f5
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/append-validation:
|
||||
en: 5c09583bc8e1ffc6
|
||||
zh: 3b25552de2d1f9b6
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/derived-history:
|
||||
en: 0cc7a06f76bd7eca
|
||||
zh: 1db25185b15cad23
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/the-request-header:
|
||||
en: 1349f47bea4dab00
|
||||
zh: 20cc03f2c87ec492
|
||||
/deepseek-ai-dsh-session/further-exploration:
|
||||
en: e23d6c8b31295044
|
||||
zh: 5105ac9ce8c5d90d
|
||||
/deepseek-ai-dsh-session/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
/deepseek-ai-dsh-session/model-experience/derived-message-history:
|
||||
en: 3005f28ded3b28ea
|
||||
zh: daaddc6df470b396
|
||||
/deepseek-ai-dsh-session/model-experience/derived-message-history/what-the-model-sees:
|
||||
en: 30ccd8296306b132
|
||||
zh: 19efa5a43745585c
|
||||
/deepseek-ai-dsh-session/model-experience/derived-message-history/token-effect:
|
||||
en: 05e088199dac7f98
|
||||
zh: 35868b5fb768e240
|
||||
/deepseek-ai-dsh-session/model-experience/derived-message-history/kv-cache-effect:
|
||||
en: 6c8079c7de029ed4
|
||||
zh: 634d6f93ce6373c4
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results:
|
||||
en: 4aa585f38846e017
|
||||
zh: ef28f54125a9021d
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/what-the-model-sees:
|
||||
en: b2ae41b23519cdc6
|
||||
zh: 05ce1c9a8b8c3283
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/token-effect:
|
||||
en: ac63e0f30175f72d
|
||||
zh: 20f63deeffa249f2
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/kv-cache-effect:
|
||||
en: f5d5f97a153f7c70
|
||||
zh: b84ef7f75f2e25d0
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header:
|
||||
en: 5397f067293a55cd
|
||||
zh: 1dcc30d0c214f175
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header/what-the-model-sees:
|
||||
en: cfb4fd90e1982948
|
||||
zh: fefc01118b2a5c07
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header/token-effect:
|
||||
en: 310b8f36a86443e5
|
||||
zh: 906c4da8d03c5966
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header/kv-cache-effect:
|
||||
en: 5eb30cfb410d52c0
|
||||
zh: da56365b8ab896da
|
||||
/deepseek-ai-dsh-session/known-limitations-and-deferred-work:
|
||||
en: daaf56dda2bcaff1
|
||||
zh: 7d24de2deb1c5dc5
|
||||
/deepseek-ai-dsh-session/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
README.md: 3b0a602191142bf1796380eea455501c2844c28c
|
||||
README.zh.md: acf5b372edeec4230faf717359c4ba06844b5e4d
|
||||
@@ -100,13 +100,17 @@ The package is built on event sourcing: a `Session` is an append-only log of typ
|
||||
| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` |
|
||||
| [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction |
|
||||
| [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots |
|
||||
| [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs |
|
||||
| [`src/repair.ts`](src/repair.ts) | Shared tool-result recovery for failed steps and interrupted logs |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: seq, turn/step enclosure, tool call/result pairing |
|
||||
|
||||
### Append validation
|
||||
|
||||
Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, validates, and copies each nested value once, so a stateful getter cannot supply one value to validation and another to storage. Non-lossless-JSON payloads (BigInt, cycles, sparse arrays, `-0`, exotic prototypes) are rejected at the append site, before any backend flush. The append path constructs each `SessionSeq`; surface events additionally validate marker shape, cited source-event sequences, and complete shadowed-node coverage for replacements.
|
||||
|
||||
### Shared recovery
|
||||
|
||||
`ToolCallRecovery` tracks unanswered requests from committed events without retaining event history. `interruptedTurnClosers` uses it for cold tails, and AgentLoop uses a step-local observer for live failures; both distinguish missing call records from uncommitted outcomes. The caller appends recovery results before closing the step ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md)).
|
||||
|
||||
### Derived history
|
||||
|
||||
`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The surface event types (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; empty-content system and developer nodes project to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
|
||||
@@ -149,15 +153,17 @@ Appended surface entries are resent on later steps. A `replace` surface operatio
|
||||
|
||||
Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only.
|
||||
|
||||
### Crash-repair and fork results
|
||||
### Tool-result recovery and fork results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.` Fork-generated results describe only the inherited records: the parent may have started or completed a call after the selected event. `TOOL_NOT_STARTED` means the prefix contains no start record; `TOOL_OUTCOME_UNKNOWN` means it contains a start but no result. Both tell the model to retry only read-only or idempotent operations without further checks; operations with side effects require external verification or user input. See the [fork decision](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md).
|
||||
If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`
|
||||
|
||||
Fork-generated results describe only the inherited records: the parent may have started or completed a call after the selected event. `TOOL_NOT_STARTED` means the prefix contains no start record; `TOOL_OUTCOME_UNKNOWN` means it contains a start but no result. Both tell the model to retry only read-only or idempotent operations without further checks; operations with side effects require external verification or user input. See the [fork decision](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md).
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume.
|
||||
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text to later requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -100,13 +100,17 @@ session.deriveMessages() // the derived model history
|
||||
| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` |
|
||||
| [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 |
|
||||
| [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 |
|
||||
| [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 |
|
||||
| [`src/repair.ts`](src/repair.ts) | 失败步骤与中断日志共享的工具结果恢复 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 不变式配套:序号、轮次/步骤闭合、工具调用/结果配对 |
|
||||
|
||||
### 追加校验
|
||||
|
||||
每次追加都会使用共享的迭代式 `snapshotJsonValue()` 流程,对每个嵌套值只读取、校验并复制一次,因此有状态的 getter 无法给校验提供一个值、给存储提供另一个值。非无损 JSON 载荷(BigInt、循环、稀疏数组、`-0`、特殊原型)会在追加位置被拒绝,先于任何后端刷新。追加路径会构造每个 `SessionSeq`;surface 事件还会校验标记形态、被引用的源事件序号,以及替换的完整遮蔽节点覆盖。
|
||||
|
||||
### 共享恢复逻辑
|
||||
|
||||
`ToolCallRecovery` 从已提交事件中跟踪尚无结果的请求,不保留事件历史。`interruptedTurnClosers` 用它处理冷日志尾部,AgentLoop 用步骤本地观察器处理实时失败;两者都区分缺失调用记录与未提交的执行结果。调用方在关闭步骤之前追加恢复结果([决策](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md))。
|
||||
|
||||
### 派生历史
|
||||
|
||||
`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。surface 事件类型(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的 system 和 developer 节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
|
||||
@@ -149,7 +153,7 @@ session.deriveMessages() // the derived model history
|
||||
|
||||
追加的 surface 条目会保留可复用前缀。即使底层事件日志保持仅追加,`replace` 操作也会从首条被遮蔽消息起使缓存复用失效。
|
||||
|
||||
### 崩溃修复结果
|
||||
### 工具结果恢复与 fork 结果
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
@@ -159,7 +163,7 @@ Fork 生成的结果只描述继承记录:父会话可能已经在所选事件
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未受损会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。
|
||||
未受损会话的 token 增量为零。每个修复后的调用都会向后续请求添加保留的、针对具体风险的错误文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export * from './types.ts'
|
||||
export { SessionPreparation } from './preparation.ts'
|
||||
export type { SessionPreparationOptions } from './preparation.ts'
|
||||
export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts'
|
||||
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Synthetic closer events that balance a session log whose tail turn is open.
|
||||
* Two producers share the mechanism: crash recovery closes an interrupted
|
||||
* persisted log on reload, and fork-seed construction closes a prefix cut
|
||||
* inside the source's open turn. Both preserve every fully written event and
|
||||
* close the unfinished step and turn. Calls in already closed steps remain
|
||||
* unchanged, including any missing results.
|
||||
* Pending tool-result recovery shared by failed live steps, interrupted logs,
|
||||
* and fork seeds. Tail repair preserves closed steps and supplies only missing
|
||||
* tool results and lifecycle boundaries, with cause-specific retry guidance.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -45,65 +42,35 @@ const CLOSER_TEXT = {
|
||||
|
||||
/**
|
||||
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
||||
* calls in its open step receive error results, followed by `step/end` and a
|
||||
* `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
|
||||
* Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
|
||||
* events.
|
||||
* calls receive error results first, followed by an open `step/end` and a
|
||||
* cause-specific `turn/end`; sequences continue the log and timestamps reuse the
|
||||
* last real event. A balanced or empty log returns no events.
|
||||
*
|
||||
* Package-internal: each cause has exactly one owner, so external callers go
|
||||
* through {@link interruptedTurnClosers} (persistence crash recovery) or
|
||||
* `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
|
||||
*
|
||||
* @param events - the log to scan: a valid committed prefix, possibly ending
|
||||
* inside an open turn (a crash tail or a mid-turn fork cut).
|
||||
* @param cause - why the turn is being closed; selects the `turn/end` reason
|
||||
* and the model-visible wording of synthetic error tool results.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @param cause - the owning close operation: selects result wording and the turn-ending reason.
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
let openStep: number | null = null
|
||||
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
||||
// Assistant blocks register calls; later `tool/call` events add their seqs to `sourceEventSeqs`.
|
||||
const pendingCalls = new Map<ToolCallId, { step: number; callSeq?: SessionSeqType }>()
|
||||
const recovery = new ToolCallRecovery(cause)
|
||||
for (const event of events) {
|
||||
recovery.observe(event)
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
openTurn = event.data.turn
|
||||
openStep = null
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'turn/end':
|
||||
openTurn = null
|
||||
openStep = null
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'step/start':
|
||||
openStep = event.data.step
|
||||
break
|
||||
case 'step/end':
|
||||
pendingCalls.clear()
|
||||
openStep = null
|
||||
break
|
||||
case 'assistant/message':
|
||||
// The assistant message carries the tool-call blocks; each is pending
|
||||
// until a tool/result event with the same callId is logged.
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
|
||||
}
|
||||
break
|
||||
case 'tool/call':
|
||||
// Cite the `tool/call` seq from the synthetic result.
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
entry.callSeq = event.seq
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool/result':
|
||||
pendingCalls.delete(event.data.message.source.callId)
|
||||
break
|
||||
// Other event types do not move the turn/step boundary cursor.
|
||||
default:
|
||||
break
|
||||
@@ -118,44 +85,11 @@ export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurn
|
||||
// The last real event supplies the seq base and the timestamp for the
|
||||
// synthetic closers (reusing the last timestamp keeps them deterministic and
|
||||
// never invents a "future" time).
|
||||
let seq = last.seq + 1
|
||||
const closers: SessionEvent[] = recovery.results()
|
||||
let seq = last.seq + closers.length + 1
|
||||
const time = last.time
|
||||
const closers: SessionEvent[] = []
|
||||
|
||||
// Close calls before their step; Map insertion order preserves transcript order.
|
||||
const text = CLOSER_TEXT[cause.kind]
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
const started = callSeq !== undefined
|
||||
const message: ToolResultMessage = deepFreeze({
|
||||
id: brandString<MessageId>(`${cause.kind}-tool-result-${callId}-${seq}`),
|
||||
role: 'tool',
|
||||
toolCallId: callId,
|
||||
isError: true,
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: started ? text.started : text.notStarted,
|
||||
}],
|
||||
})
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
seq: SessionSeq(seq++),
|
||||
time,
|
||||
data: {
|
||||
turn: openTurn,
|
||||
step,
|
||||
message,
|
||||
error: started
|
||||
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
||||
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...started ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
// Close an open step next — a turn/end while a step is open is an invariant
|
||||
// violation, so the step's boundary must be synthesized before the turn's.
|
||||
// Close an open step before its turn.
|
||||
if (openStep !== null) {
|
||||
closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
|
||||
}
|
||||
@@ -163,11 +97,110 @@ export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurn
|
||||
return closers
|
||||
}
|
||||
|
||||
/**
|
||||
* Track unanswered assistant tool requests from one Session's committed events.
|
||||
* Observe from the start of the owned step or replay prefix, and recover before
|
||||
* its step closes. This state retains pending identities, not event history.
|
||||
*/
|
||||
export class ToolCallRecovery {
|
||||
private readonly pendingCalls = new Map<ToolCallId, { turn: number; step: number; callSeq?: SessionSeqType }>()
|
||||
private last: Pick<SessionEvent, 'seq' | 'time'> | undefined
|
||||
|
||||
/** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
|
||||
constructor(private readonly cause: OpenTurnCloseCause = { kind: 'interrupted' }) {}
|
||||
|
||||
/**
|
||||
* Consume the next committed event; closed steps and turn boundaries discard pending requests.
|
||||
* @param event - the next event from the same Session, in sequence order.
|
||||
*/
|
||||
observe(event: SessionEvent): void {
|
||||
this.last = { seq: event.seq, time: event.time }
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
case 'turn/end':
|
||||
case 'step/end':
|
||||
this.pendingCalls.clear()
|
||||
break
|
||||
case 'assistant/message':
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') {
|
||||
this.pendingCalls.set(block.id, { turn: event.data.turn, step: event.data.step })
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool/call': {
|
||||
const entry = this.pendingCalls.get(event.data.callId)
|
||||
if (entry) entry.callSeq = event.seq
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.source.callId
|
||||
const entry = this.pendingCalls.get(callId)
|
||||
if (event.surfaceOp === 'append' && entry?.turn === event.data.turn && entry.step === event.data.step) {
|
||||
this.pendingCalls.delete(callId)
|
||||
}
|
||||
break
|
||||
}
|
||||
// SessionEvent is merge-extensible; unrelated events retain pending requests.
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build conservative error results in assistant order without changing tracked state.
|
||||
* Sequences follow the latest observed event and timestamps reuse its time.
|
||||
* Callers commit the results and observe those commits before recovering again.
|
||||
* @returns pending tool-result events, empty when no request remains unanswered.
|
||||
*/
|
||||
results(): SessionEvent<'tool/result'>[] {
|
||||
if (this.last === undefined) return []
|
||||
let seq = this.last.seq + 1
|
||||
const time = this.last.time
|
||||
const results: SessionEvent<'tool/result'>[] = []
|
||||
|
||||
const text = CLOSER_TEXT[this.cause.kind]
|
||||
// Close calls before their step: providers reject dangling assistant calls,
|
||||
// and Map insertion order preserves their transcript order.
|
||||
for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
|
||||
const started = callSeq !== undefined
|
||||
const message: ToolResultMessage = deepFreeze({
|
||||
id: brandString<MessageId>(`${this.cause.kind}-tool-result-${callId}-${seq}`),
|
||||
role: 'tool',
|
||||
toolCallId: callId,
|
||||
isError: true,
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: started ? text.started : text.notStarted,
|
||||
}],
|
||||
})
|
||||
results.push({
|
||||
type: 'tool/result',
|
||||
seq: SessionSeq(seq++),
|
||||
time,
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
error: started
|
||||
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
||||
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...started ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-recovery entry point: synthetic closers that balance a persisted log
|
||||
* whose tail turn was interrupted. Used by crash-recovery callers; fork
|
||||
* seeds receive their `forked`-cause closers through `buildForkSeed` in
|
||||
* `./fork.ts`, and cause selection stays internal to those two owners.
|
||||
* `./fork.ts`.
|
||||
*
|
||||
* @param events - the persisted log to scan, possibly ending inside an open turn.
|
||||
* @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
|
||||
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, ToolCallRecovery } from '../src/index.ts'
|
||||
import { openTurnClosers as closeOpenTurn, type OpenTurnCloseCause } from '../src/repair.ts'
|
||||
import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
|
||||
@@ -34,6 +34,92 @@ function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCa
|
||||
const userTurnStart = (turn: number, seq: number): SessionEvent =>
|
||||
({ type: 'turn/start', seq, time: seq, data: { turn } })
|
||||
|
||||
function liveToolRequests(): LogicalSessionEvent<'assistant/message'> {
|
||||
return {
|
||||
type: 'assistant/message', seq: SessionSeq(21), time: 110, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 3, step: 2, stream: [],
|
||||
message: createMessage({
|
||||
role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
content: [
|
||||
{ type: 'tool-call', id: ToolCallId('started'), name: 'bash', arguments: '{}' },
|
||||
{ type: 'tool-call', id: ToolCallId('pending'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToolCallRecovery', () => {
|
||||
it('observes a live step without earlier history and retains repairs until their commits arrive', () => {
|
||||
const recovery = new ToolCallRecovery()
|
||||
expect(recovery.results()).toEqual([])
|
||||
recovery.observe({ type: 'step/start', seq: SessionSeq(20), time: 100, data: { turn: 3, step: 2 } })
|
||||
recovery.observe(liveToolRequests())
|
||||
recovery.observe({
|
||||
type: 'tool/call', seq: SessionSeq(22), time: 120,
|
||||
data: { turn: 3, step: 2, callId: ToolCallId('started'), name: 'bash', arguments: '{}' },
|
||||
})
|
||||
|
||||
const results = recovery.results()
|
||||
expect(results.map(event => ({
|
||||
seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step,
|
||||
callId: event.data.message.toolCallId, code: event.data.error?.code, sourceEventSeqs: event.sourceEventSeqs,
|
||||
}))).toEqual([
|
||||
{ seq: 23, time: 120, turn: 3, step: 2, callId: 'started', code: TOOL_OUTCOME_UNKNOWN, sourceEventSeqs: [22] },
|
||||
{ seq: 24, time: 120, turn: 3, step: 2, callId: 'pending', code: TOOL_NOT_STARTED, sourceEventSeqs: undefined },
|
||||
])
|
||||
expect(recovery.results()).toEqual(results)
|
||||
for (const [index, event] of results.entries()) {
|
||||
recovery.observe(event)
|
||||
expect(recovery.results()).toEqual(results.slice(index + 1))
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ kind: 'replacement', turn: 3, step: 2 },
|
||||
{ kind: 'other turn', turn: 2, step: 2 },
|
||||
{ kind: 'other step', turn: 3, step: 1 },
|
||||
])('does not acknowledge a pending call with $kind result reusing its id', ({ kind, turn, step }) => {
|
||||
const recovery = new ToolCallRecovery()
|
||||
recovery.observe(liveToolRequests())
|
||||
recovery.observe({
|
||||
type: 'tool/result', seq: SessionSeq(22), time: 120,
|
||||
surfaceOp: kind === 'replacement' ? { op: 'replace', startSeq: SessionSeq(10), endSeq: SessionSeq(10) } : 'append',
|
||||
...kind === 'replacement' ? { sourceEventSeqs: [SessionSeq(10)] } : {},
|
||||
data: {
|
||||
turn, step,
|
||||
message: createToolResultMessage({ callId: ToolCallId('started'), content: [{ type: 'text', text: 'old result' }], isError: false }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(recovery.results().map(event => event.data.message.toolCallId)).toEqual([ToolCallId('started'), ToolCallId('pending')])
|
||||
})
|
||||
|
||||
it('ignores historical assistant replacements when tracking current requests', () => {
|
||||
const recovery = new ToolCallRecovery()
|
||||
const current = liveToolRequests()
|
||||
recovery.observe(current)
|
||||
recovery.observe({
|
||||
...current, seq: SessionSeq(22),
|
||||
surfaceOp: { op: 'replace', startSeq: SessionSeq(10), endSeq: SessionSeq(10) },
|
||||
data: {
|
||||
...current.data, turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
content: [
|
||||
...current.data.message.content,
|
||||
{ type: 'tool-call', id: ToolCallId('old-only'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(recovery.results().map(event => [event.data.message.toolCallId, event.data.turn, event.data.step]))
|
||||
.toEqual([[ToolCallId('started'), 3, 2], [ToolCallId('pending'), 3, 2]])
|
||||
})
|
||||
})
|
||||
|
||||
const causes: OpenTurnCloseCause[] = [{ kind: 'interrupted' }, { kind: 'forked' }]
|
||||
|
||||
describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
@@ -72,7 +158,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -115,7 +201,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -128,7 +214,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
},
|
||||
}),
|
||||
} },
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('call-1'),
|
||||
@@ -146,7 +232,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -174,7 +260,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -187,7 +273,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
},
|
||||
}),
|
||||
} },
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('old-call'),
|
||||
@@ -199,7 +285,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
userTurnStart(2, 6),
|
||||
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 8, data: {
|
||||
{ type: 'assistant/message', seq: 8, time: 8, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -223,7 +309,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -238,7 +324,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
}),
|
||||
} },
|
||||
// call-a got answered before the cut; call-b did not.
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('call-a'),
|
||||
@@ -257,7 +343,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 8e99640e4936332d
|
||||
zh: 729d5de1e0ad2869
|
||||
/python-contributor-workflows/validate-the-sdk:
|
||||
en: fbdd6bb379edd159
|
||||
zh: f3a803a5d99a7b92
|
||||
en: 0830603b2ed0ecc0
|
||||
zh: 252809ba6cad5848
|
||||
/python-contributor-workflows/run-against-node-source:
|
||||
en: 4d30c74632781443
|
||||
zh: 13191cb05f99dd31
|
||||
|
||||
@@ -36,6 +36,8 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
|
||||
|
||||
SDK scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. `sdk-minimal-in-history` reuses the persistent-shell and editor scenario with a section that changes after the first successful shell call. `minimal-in-history/prompt-history.json` pins both prompt versions, the unchanged leading prompt in later requests, appended SDK system-message events, and `request/context.systemPromptUpdate`; tool schemas stay fixed and the editor file is checked independently. `sdk-dynamic-tools` pins native tool additions and removals in `dynamic-tools/tool-history.json`, including historical header references and agreement between Python SDK results, notifications, and persisted events. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it.
|
||||
|
||||
`scheduler-recovery/` records a failed tool turn and a subsequent completed turn. It preserves the original `UNKNOWN` turn error, the first tool's completed result, and `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` results for the remaining calls. `sdk-snapshot` and `all` include this case; `--scenario sdk-recovery` runs it alone.
|
||||
|
||||
The `advanced` and `restart` comparisons match native delivery qualifiers against each input's own Session generation, including SDK events and notifications. Captured and other delivery generations retain their numeric values. Each side must use one generation across its Session roles, and fresh logs must identify the current writer. Comparison never rewrites committed recordings.
|
||||
|
||||
Trusted pull requests and master pushes also run `--scenario sdk-live --installed-wheel` on each selected native target. That scenario performs two tool-using turns against `https://api.deepseek.com`: it checks the created file immediately, replaces its content with a host-only random challenge, and requires the second turn to copy the changed content into a fresh receipt without modifying the source. Both turns must complete with the exact sentinel answer and a model-requested tool call; external byte comparisons check the files. Missing repository secrets fail instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key.
|
||||
|
||||
@@ -36,6 +36,8 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
|
||||
|
||||
SDK 场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。`sdk-minimal-in-history` 复用持久 shell 与编辑器场景,并在首次 shell 调用成功后更改一个分段。`minimal-in-history/prompt-history.json` 固定两个提示词版本、后续请求中不变的首条提示词、追加的 SDK system-message 事件及 `request/context.systemPromptUpdate`;工具 schema 保持不变,并独立检查编辑器创建的文件。`sdk-dynamic-tools` 在 `dynamic-tools/tool-history.json` 中固定原生工具的新增与移除,包括历史 header 引用,以及 Python SDK 结果、通知与持久事件的一致性。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。
|
||||
|
||||
`scheduler-recovery/` 记录一个失败的工具轮次及后续完成的轮次。它保留原始的 `UNKNOWN` 轮次错误、第一个工具已完成的结果,以及其余调用的 `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` 结果。`sdk-snapshot` 和 `all` 包含此场景;`--scenario sdk-recovery` 单独运行它。
|
||||
|
||||
`advanced` 与 `restart` 的比较按每份输入自身的 Session 代际匹配原生 delivery 限定值,包括 SDK 事件与通知。捕获的代际及其他 delivery 代际保留数值。每一侧的 Session 角色必须使用同一个代际,新日志必须标识当前写入器。比较绝不重写已提交的记录。
|
||||
|
||||
可信拉取请求与 master 推送还会在各自选定的原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次:立即检查已创建文件,将其内容替换为仅宿主知道的随机挑战值,并要求第二轮将变更后的内容复制到全新的回执文件,且不修改源文件。两个轮次都必须完成、返回精确的哨兵答案并由模型请求调用工具;文件通过外部逐字节比较验证。仓库密钥缺失时失败,而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。
|
||||
|
||||
@@ -315,6 +315,34 @@ def test_snapshot_value_scrubs_system_nodes_without_erasing_header_fields() -> N
|
||||
assert normalize(empty, []) == empty
|
||||
|
||||
|
||||
def test_snapshot_value_preserves_deterministic_tool_repair_ids() -> None:
|
||||
normalize = SMOKE["normalize_snapshot_value"]
|
||||
repair = {
|
||||
"type": "tool/result", "seq": 19, "time": 123,
|
||||
"data": {"message": {
|
||||
"id": "interrupted-tool-result-scheduler-unstarted-19",
|
||||
"role": "tool", "source": {"kind": "tool", "callId": "scheduler-unstarted"},
|
||||
"toolCallId": "scheduler-unstarted", "isError": True,
|
||||
"content": [{"type": "text", "text": "The tool did not start."}],
|
||||
}},
|
||||
}
|
||||
normalized = normalize(repair, [])
|
||||
assert normalized["data"]["message"]["id"] == repair["data"]["message"]["id"]
|
||||
assert normalized["seq"] == 19
|
||||
assert normalized["time"] == 0
|
||||
ordinary = {
|
||||
"id": "521f38bb-fb22-45cb-af5f-a9cc370a1814", "role": "tool",
|
||||
"toolCallId": "scheduler-complete",
|
||||
"source": {"kind": "tool", "callId": "scheduler-complete"}, "content": [],
|
||||
}
|
||||
assert normalize(ordinary, [])["id"] == "{{messageId}}"
|
||||
wrong_call = {**repair["data"]["message"], "toolCallId": "other"}
|
||||
assert normalize(wrong_call, [])["id"] == "{{messageId}}"
|
||||
historical = {**repair["data"]["message"], "role": "user"}
|
||||
historical.pop("toolCallId")
|
||||
assert normalize(historical, [])["id"] == repair["data"]["message"]["id"]
|
||||
|
||||
|
||||
def test_snapshot_value_normalizes_embedded_assistant_stream_timing() -> None:
|
||||
normalize = SMOKE["normalize_snapshot_value"]
|
||||
event = {
|
||||
|
||||
@@ -105,6 +105,11 @@ RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn."
|
||||
RESTART_SECOND_TEXT = "PROCESS_TWO_OK"
|
||||
RESTART_FIRST_SESSION_ID = "process-one"
|
||||
RESTART_SECOND_SESSION_ID = "process-two"
|
||||
RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call."
|
||||
RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools."
|
||||
RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK"
|
||||
RECOVERY_SESSION_ID = "scheduler-recovery"
|
||||
RECOVERY_CALL_IDS = ("scheduler-complete", "scheduler-fail", "scheduler-unstarted")
|
||||
SNAPSHOT_WORKFLOW_SCRIPT = (
|
||||
"phase('Delegate')\n"
|
||||
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
|
||||
@@ -136,6 +141,10 @@ RESTART_SNAPSHOT_DIRECTORY = (
|
||||
RESTART_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "requests.json", "session.1.v3.jsonl", "session.2.v3.jsonl",
|
||||
)
|
||||
RECOVERY_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "scheduler-recovery"
|
||||
)
|
||||
RECOVERY_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.v4.jsonl")
|
||||
MCP_SERVER_SCRIPT = """\
|
||||
import json
|
||||
import os
|
||||
@@ -316,6 +325,32 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
recovery_prompt = next((
|
||||
block.get("text")
|
||||
for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
and block.get("text") in {RECOVERY_PROMPT, RECOVERY_CONTINUE_PROMPT}
|
||||
), None)
|
||||
if recovery_prompt == RECOVERY_PROMPT:
|
||||
assert_advertised_tool(body, "todo_write")
|
||||
chunks = [message_start()]
|
||||
for index, call_id in enumerate(RECOVERY_CALL_IDS):
|
||||
call = tool_call_chunks(call_id, "todo_write", {"todos": []})
|
||||
chunks.extend({**chunk, "index": index} for chunk in call[1:-2])
|
||||
chunks.extend(call[-2:])
|
||||
return chunks
|
||||
if recovery_prompt == RECOVERY_CONTINUE_PROMPT:
|
||||
results = [
|
||||
block for message in messages
|
||||
for block in message.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
if [result.get("tool_use_id") for result in results] != list(RECOVERY_CALL_IDS):
|
||||
raise AssertionError(f"recovered history lost tool-result pairing: {results}")
|
||||
if [result.get("is_error", False) for result in results] != [False, True, True]:
|
||||
raise AssertionError(f"recovered history changed completed or pending outcomes: {results}")
|
||||
return text_chunks(RECOVERY_TEXT)
|
||||
|
||||
tool_results = [
|
||||
block for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
@@ -743,7 +778,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
@@ -762,10 +797,10 @@ def main() -> None:
|
||||
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
|
||||
if args.installed_wheel:
|
||||
args.exe = assert_installed_wheel_environment()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-restart, sdk-authoring, or all")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, recovery, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-recovery, sdk-restart, sdk-authoring, or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
@@ -817,6 +852,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-snapshot", "sdk-recovery"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_scheduler_recovery(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-restart"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
@@ -1641,6 +1679,94 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
)
|
||||
|
||||
|
||||
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
first_request = len(MockModelHandler.requests)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-scheduler-recovery-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
dsh_home = root / "home"
|
||||
sessions = dsh_home / "sessions"
|
||||
base_patch = write_advanced_profile_patch(root, "base.patch.yml", sessions)
|
||||
patch = write_profile_patch(root, "recovery.patch.yml", sessions, [
|
||||
{"id": "tool-todo", "disabled": False},
|
||||
{"id": "session-title-llm", "disabled": True},
|
||||
{"id": "session-log-deepseek", "config": {"enabled": False}},
|
||||
{"insert": [{
|
||||
"id": "scheduler-failure-fixture",
|
||||
"name": (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs"
|
||||
).as_uri(),
|
||||
}]},
|
||||
])
|
||||
|
||||
def connect() -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
provider="deepseek-official", model="smoke-model", cwd=str(root),
|
||||
dsh_bin=str(executable), dsh_home=str(dsh_home),
|
||||
patches=(str(base_patch), str(patch)),
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60,
|
||||
)
|
||||
|
||||
with connect() as harness:
|
||||
failed = harness.run(RECOVERY_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
continued = harness.run(RECOVERY_CONTINUE_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
results = [failed, continued]
|
||||
if [result.finish_reason for result in results] != ["error", "completed"]:
|
||||
outcomes = [
|
||||
event["data"]["reason"] for result in results
|
||||
for event in result.events if event.get("type") == "turn/end"
|
||||
]
|
||||
raise AssertionError(f"scheduler recovery did not preserve turn outcomes: {outcomes}")
|
||||
if continued.final_response != RECOVERY_TEXT:
|
||||
raise AssertionError("scheduler recovery lost the continued response")
|
||||
requests = MockModelHandler.requests[first_request:]
|
||||
if len(requests) != 2:
|
||||
raise AssertionError(f"scheduler recovery expected two serialized model requests: {requests}")
|
||||
logs = read_session_logs(sessions)
|
||||
records = logs[RECOVERY_SESSION_ID]
|
||||
calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"]
|
||||
if calls != list(RECOVERY_CALL_IDS[:2]):
|
||||
raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}")
|
||||
tool_results = [
|
||||
record["data"]["message"]
|
||||
for record in records if record.get("type") == "tool/result"
|
||||
]
|
||||
if [(message.get("role"), message.get("toolCallId"), message.get("isError", False)) for message in tool_results] != [
|
||||
("tool", RECOVERY_CALL_IDS[0], False), ("tool", RECOVERY_CALL_IDS[1], True), ("tool", RECOVERY_CALL_IDS[2], True),
|
||||
]:
|
||||
raise AssertionError(f"scheduler recovery duplicated or lost tool outcomes: {tool_results}")
|
||||
if sum(record.get("type") == "todo/write" for record in records) != 1:
|
||||
raise AssertionError("scheduler recovery executed an unstarted todo update")
|
||||
replacements = [(str(root), "{{cwd}}"), (RECOVERY_SESSION_ID, "{{parent}}")]
|
||||
result_value = [{
|
||||
"session_id": result.session_id,
|
||||
"final_response": result.final_response,
|
||||
"finish_reason": result.finish_reason,
|
||||
"eventTypes": [event.get("type") for event in result.events],
|
||||
"notificationMethods": [notification.method for notification in result.notifications],
|
||||
} for result in results]
|
||||
request_value = [{
|
||||
"model": request.get("model"),
|
||||
"messages": restart_request_messages(request),
|
||||
} for request in requests]
|
||||
normalized_records = project_session_snapshot([
|
||||
normalize_snapshot_value(record, replacements) for record in records
|
||||
])
|
||||
content = render_jsonl(normalized_records)
|
||||
files = {
|
||||
"result.json": json.dumps(normalize_snapshot_value(result_value, replacements), indent=2) + "\n",
|
||||
"requests.json": json.dumps(normalize_snapshot_value(request_value, replacements), indent=2) + "\n",
|
||||
snapshot_session_filename(0, session_header_version(content, "scheduler recovery")): content,
|
||||
}
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, RECOVERY_SNAPSHOT_DIRECTORY, RECOVERY_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
|
||||
|
||||
def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
@@ -2433,7 +2559,19 @@ def normalize_snapshot_value(
|
||||
if isinstance(dt, list):
|
||||
member["dt"] = [0] * len(dt)
|
||||
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool", "developer"):
|
||||
if not normalized["id"].startswith("{{message:"):
|
||||
# Released artifact validation binds unstarted-result ids to their call and event sequence.
|
||||
source = normalized.get("source")
|
||||
call_id = normalized.get("toolCallId") if normalized.get("role") == "tool" else (
|
||||
source.get("callId") if isinstance(source, dict) and source.get("kind") == "tool" else None
|
||||
)
|
||||
repair_prefix = (
|
||||
f"interrupted-tool-result-{call_id}-" if isinstance(call_id, str) else None
|
||||
)
|
||||
repair_id = (
|
||||
repair_prefix is not None and normalized["id"].startswith(repair_prefix)
|
||||
and normalized["id"][len(repair_prefix):].isdigit()
|
||||
)
|
||||
if not normalized["id"].startswith("{{message:") and not repair_id:
|
||||
normalized["id"] = "{{messageId}}"
|
||||
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
|
||||
data = normalized.get("data")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"model": "smoke-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Run three todo updates; the scheduler will reject the second call."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically \u2014 do not request sandbox escalation (do not set `sandbox_permissions`)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "smoke-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Run three todo updates; the scheduler will reject the second call."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically \u2014 do not request sandbox escalation (do not set `sandbox_permissions`)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-complete",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-fail",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-unstarted",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-complete",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Updated todo list: 0 pending, 0 in progress, 0 completed."
|
||||
}
|
||||
],
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-fail",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."
|
||||
}
|
||||
],
|
||||
"is_error": true
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-unstarted",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."
|
||||
}
|
||||
],
|
||||
"is_error": true
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Continue after the scheduler failure without repeating any tools."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"session_id": "{{parent}}",
|
||||
"final_response": "",
|
||||
"finish_reason": "error",
|
||||
"eventTypes": [
|
||||
"agent/inbox/spliced",
|
||||
"turn/start",
|
||||
"agent/inbox/spliced",
|
||||
"step/start",
|
||||
"system/message",
|
||||
"user/message",
|
||||
"user/message",
|
||||
"request/header",
|
||||
"request/context",
|
||||
"session/title",
|
||||
"assistant/message",
|
||||
"tool/call",
|
||||
"todo/write",
|
||||
"tool/result",
|
||||
"tool/call",
|
||||
"tool/result",
|
||||
"tool/result",
|
||||
"step/end",
|
||||
"turn/end"
|
||||
],
|
||||
"notificationMethods": [
|
||||
"session.event",
|
||||
"session.status",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"session_id": "{{parent}}",
|
||||
"final_response": "SCHEDULER_RECOVERY_OK",
|
||||
"finish_reason": "completed",
|
||||
"eventTypes": [
|
||||
"agent/inbox/spliced",
|
||||
"turn/start",
|
||||
"agent/inbox/spliced",
|
||||
"step/start",
|
||||
"user/message",
|
||||
"assistant/message",
|
||||
"step/end",
|
||||
"turn/end"
|
||||
],
|
||||
"notificationMethods": [
|
||||
"session.event",
|
||||
"session.status",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.status"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
{"type":"session","version":3,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"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: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","subagent","todo_write","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Run three todo updates; the","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"todo/write","data":{"todos":[]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"scheduler-complete"},"content":[{"type":"tool-result","toolCallId":"scheduler-complete","content":[{"type":"text","text":"Updated todo list: 0 pending, 0 in progress, 0 completed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-fail-18","role":"user","source":{"kind":"tool","callId":"scheduler-fail"},"content":[{"type":"tool-result","toolCallId":"scheduler-fail","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"user","source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"tool-result","toolCallId":"scheduler-unstarted","isError":true,"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"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":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -128,6 +128,9 @@ const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
|
||||
'dynamic-tool-updates': {
|
||||
expectedFinalResponse: 'DONE',
|
||||
},
|
||||
'tool-scheduler-recovery': {
|
||||
patches: [fileURLToPath(new URL('./tool-scheduler-recovery/runtime.cordis.yml', import.meta.url))],
|
||||
},
|
||||
'tool-error-details': {
|
||||
patches: [fileURLToPath(new URL('./tool-error-details/runtime.cordis.yml', import.meta.url))],
|
||||
expectedFinalResponse: 'ERROR_DETAILS_OK',
|
||||
@@ -879,6 +882,25 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
|
||||
toolCallId: 'call_dynamic_ping', isError: false, content: [{ type: 'text', text: 'pong' }],
|
||||
} } })
|
||||
}
|
||||
if (scenario.name === 'tool-scheduler-recovery') {
|
||||
expect(results).toHaveLength(2)
|
||||
const events = results.flatMap(result => result.events)
|
||||
expect(results[1]?.finalResponse, JSON.stringify(events.filter(event => event.type === 'turn/end')))
|
||||
.toBe('SCHEDULER_RECOVERY_OK')
|
||||
expect(events.filter(event => event.type === 'turn/end').map(event => event.data['reason']))
|
||||
.toEqual([
|
||||
{ kind: 'error', error: { message: 'Snapshot scheduler preparation failed', code: 'UNKNOWN' } },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(events.filter(event => event.type === 'tool/call').map(event => event.data['callId']))
|
||||
.toEqual(['scheduler-complete', 'scheduler-fail'])
|
||||
const toolResults = events.filter(event => event.type === 'tool/result')
|
||||
.map(event => event.data['message'] as { role: string; toolCallId: string; isError: boolean })
|
||||
expect(toolResults.map(result => [result.role, result.toolCallId, result.isError])).toEqual([
|
||||
['tool', 'scheduler-complete', false], ['tool', 'scheduler-fail', true], ['tool', 'scheduler-unstarted', true],
|
||||
])
|
||||
expect(events.filter(event => event.type === 'todo/write')).toHaveLength(1)
|
||||
}
|
||||
if (scenario.name === 'subagent-activation-limit') {
|
||||
expect(ordered).toHaveLength(2)
|
||||
const denied = records(ordered[0]!.content).find(record => record.type === 'tool/result'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Run three todo updates; the","messageSeqs":[8],"source":{"kind":"fallback"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"},{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"},{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\":[]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":2864,"outputTokens":12},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":12}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"todo/write","seq":15,"time":0,"data":{"todos":[]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"scheduler-complete"},"content":[{"type":"tool-result","toolCallId":"scheduler-complete","content":[{"type":"text","text":"Updated todo list: 0 pending, 0 in progress, 0 completed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":17,"time":0,"data":{"turn":1,"step":1,"callId":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-fail-18","role":"user","source":{"kind":"tool","callId":"scheduler-fail"},"content":[{"type":"tool-result","toolCallId":"scheduler-fail","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"sourceEventSeqs":[17],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"user","source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"tool-result","toolCallId":"scheduler-unstarted","isError":true,"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":23,"time":0,"data":{"turn":2}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":2,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"{{sessionId}}","finalResponse":"SCHEDULER_RECOVERY_OK"}
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: scheduler-failure-fixture
|
||||
name: './scheduler-failure.mjs'
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Reject one scheduler preparation to exercise the terminal internal-failure path. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned runtime. */
|
||||
export function apply(ctx) {
|
||||
// Inspect the active instance's key so the fixture cannot introduce a second tools module.
|
||||
const key = Object.getOwnPropertySymbols(ctx.tools)
|
||||
.find(symbol => symbol.description === '@deepseek-ai/dsh-tools.scheduler')
|
||||
if (key === undefined) throw new Error('Scheduler failure fixture requires the active tool scheduler')
|
||||
const scheduler = ctx.tools[key]
|
||||
const prepare = scheduler.prepare
|
||||
ctx.effect(() => {
|
||||
scheduler.prepare = async input => {
|
||||
if (input.callId === 'scheduler-fail') {
|
||||
scheduler.prepare = prepare
|
||||
throw new Error('Snapshot scheduler preparation failed')
|
||||
}
|
||||
return prepare.call(scheduler, input)
|
||||
}
|
||||
return () => { scheduler.prepare = prepare }
|
||||
}, 'scheduler failure fixture')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{"type":"session","version":3,"id":"{{session:1}}","createdAt":1786348800000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"workspace-write"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
|
||||
{"type":"approval/policy","data":{"policy":"ask"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"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: \"{{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:3}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Run three todo updates; the","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"},{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"},{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\":[]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2864,"outputTokens":12},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","name":"todo_write","args":["{\"todos\":[]}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\":[]}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":12}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-complete","name":"todo_write","arguments":"{\"todos\":[]}"}}
|
||||
{"type":"todo/write","data":{"todos":[]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"scheduler-complete"},"content":[{"type":"tool-result","toolCallId":"scheduler-complete","content":[{"type":"text","text":"Updated todo list: 0 pending, 0 in progress, 0 completed."}],"isError":false}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-fail","name":"todo_write","arguments":"{\"todos\":[]}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-fail-18","role":"user","source":{"kind":"tool","callId":"scheduler-fail"},"content":[{"type":"tool-result","toolCallId":"scheduler-fail","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"user","source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"tool-result","toolCallId":"scheduler-unstarted","isError":true,"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"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":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
version: 1
|
||||
scenario: tool-scheduler-recovery
|
||||
profile: sdk
|
||||
composition: sdk-default
|
||||
recording: authored
|
||||
header:
|
||||
class: sdk-default
|
||||
Reference in new issue
Block a user