@deepseek-ai/dsh-session 0.1.7-rc.1 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -1,6 +1,106 @@
1
- # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
- # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
- # after editing either side, bring the other along and re-record with:
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/core/session/README.md
5
- README.md: 06c4f1806995c2fdff78515f88a60c6e449e3a26
6
- README.zh.md: c52eee96af19bcd6f5a76f950026d2c191002732
5
+ /:
6
+ en: 54dedea99ec5492c
7
+ zh: 23b29cb5d738dadb
8
+ /deepseek-ai-dsh-session:
9
+ en: c019c0e8c7f9cba0
10
+ zh: 151e150825fda310
11
+ /deepseek-ai-dsh-session/summary:
12
+ en: 311239677afd230a
13
+ zh: f2c575945baaa2a2
14
+ /deepseek-ai-dsh-session/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /deepseek-ai-dsh-session/use-this-package:
18
+ en: 0882d4056c45aa75
19
+ zh: fcee78e9845b78b3
20
+ /deepseek-ai-dsh-session/use-this-package/create-and-inspect-sessions:
21
+ en: f80dab583fa3939d
22
+ zh: 5a9a79ebcef86119
23
+ /deepseek-ai-dsh-session/use-this-package/append-and-derive:
24
+ en: aa5f1951ecf3ac0c
25
+ zh: 62a599ace8f2bf15
26
+ /deepseek-ai-dsh-session/use-this-package/read-the-log:
27
+ en: 2b29cfeac7a2baa8
28
+ zh: b535b04dcbc29d3d
29
+ /deepseek-ai-dsh-session/use-this-package/fork-a-session:
30
+ en: 1c40d17a786059d3
31
+ zh: 81320ce118977c65
32
+ /deepseek-ai-dsh-session/use-this-package/flush-durable-state:
33
+ en: 70a7673145c851ad
34
+ zh: 0dd1667d9518993d
35
+ /deepseek-ai-dsh-session/understand-the-implementation:
36
+ en: ce7e776e9d516bdb
37
+ zh: 59d2751743db7795
38
+ /deepseek-ai-dsh-session/understand-the-implementation/design-concept:
39
+ en: dabbe938e288c55a
40
+ zh: 31c7ac2bee2a2846
41
+ /deepseek-ai-dsh-session/understand-the-implementation/request-headers:
42
+ en: bbfb53c50100ab13
43
+ zh: 303a8ee47f4f656a
44
+ /deepseek-ai-dsh-session/understand-the-implementation/source-map:
45
+ en: 1268738282846f1d
46
+ zh: 75a9fe0d5088ea09
47
+ /deepseek-ai-dsh-session/understand-the-implementation/append-validation:
48
+ en: 5c09583bc8e1ffc6
49
+ zh: 3b25552de2d1f9b6
50
+ /deepseek-ai-dsh-session/understand-the-implementation/shared-recovery:
51
+ en: 76bb076736b2dd0f
52
+ zh: de8306afc1e5faa1
53
+ /deepseek-ai-dsh-session/understand-the-implementation/derived-history:
54
+ en: 0cc7a06f76bd7eca
55
+ zh: 1db25185b15cad23
56
+ /deepseek-ai-dsh-session/understand-the-implementation/the-request-header:
57
+ en: 1349f47bea4dab00
58
+ zh: 20cc03f2c87ec492
59
+ /deepseek-ai-dsh-session/further-exploration:
60
+ en: e23d6c8b31295044
61
+ zh: 5105ac9ce8c5d90d
62
+ /deepseek-ai-dsh-session/model-experience:
63
+ en: 215e7ba838619b7b
64
+ zh: a311da3843709f90
65
+ /deepseek-ai-dsh-session/model-experience/derived-message-history:
66
+ en: 3005f28ded3b28ea
67
+ zh: daaddc6df470b396
68
+ /deepseek-ai-dsh-session/model-experience/derived-message-history/what-the-model-sees:
69
+ en: 30ccd8296306b132
70
+ zh: 19efa5a43745585c
71
+ /deepseek-ai-dsh-session/model-experience/derived-message-history/token-effect:
72
+ en: 05e088199dac7f98
73
+ zh: 35868b5fb768e240
74
+ /deepseek-ai-dsh-session/model-experience/derived-message-history/kv-cache-effect:
75
+ en: 6c8079c7de029ed4
76
+ zh: 634d6f93ce6373c4
77
+ /deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results:
78
+ en: 9c2efc3e9064e3c4
79
+ zh: 9a7783991b322750
80
+ /deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/what-the-model-sees:
81
+ en: b2ae41b23519cdc6
82
+ zh: 0e80c34e3267d098
83
+ /deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/token-effect:
84
+ en: 1339facbd3d7cee0
85
+ zh: 9d392a053ef90032
86
+ /deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/kv-cache-effect:
87
+ en: f5d5f97a153f7c70
88
+ zh: b84ef7f75f2e25d0
89
+ /deepseek-ai-dsh-session/model-experience/logged-request-header:
90
+ en: 5397f067293a55cd
91
+ zh: 1dcc30d0c214f175
92
+ /deepseek-ai-dsh-session/model-experience/logged-request-header/what-the-model-sees:
93
+ en: cfb4fd90e1982948
94
+ zh: fefc01118b2a5c07
95
+ /deepseek-ai-dsh-session/model-experience/logged-request-header/token-effect:
96
+ en: 310b8f36a86443e5
97
+ zh: 906c4da8d03c5966
98
+ /deepseek-ai-dsh-session/model-experience/logged-request-header/kv-cache-effect:
99
+ en: 5eb30cfb410d52c0
100
+ zh: da56365b8ab896da
101
+ /deepseek-ai-dsh-session/known-limitations-and-deferred-work:
102
+ en: daaf56dda2bcaff1
103
+ zh: 7d24de2deb1c5dc5
104
+ /deepseek-ai-dsh-session/known-limitations-and-deferred-work/dev-note:
105
+ en: e67f633dc6fe7773
106
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -100,13 +100,17 @@ The package is built on event sourcing: a `Session` is an append-only log of typ
100
100
  | [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` |
101
101
  | [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction |
102
102
  | [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots |
103
- | [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs |
103
+ | [`src/repair.ts`](src/repair.ts) | Shared tool-result recovery for failed steps, interrupted logs, and fork seeds |
104
104
  | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: seq, turn/step enclosure, tool call/result pairing |
105
105
 
106
106
  ### Append validation
107
107
 
108
108
  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.
109
109
 
110
+ ### Shared recovery
111
+
112
+ `ToolCallRecovery` tracks unanswered requests from committed events without retaining event history. AgentLoop observes live steps; crash recovery and fork-seed construction replay their prefixes through `openTurnClosers`. Live failures and crash recovery use interrupted-result wording by default; fork construction passes the fork cause to select its distinct retry guidance. The caller appends recovery results before closing the step ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md)).
113
+
110
114
  ### Derived history
111
115
 
112
116
  `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,7 +153,7 @@ Appended surface entries are resent on later steps. A `replace` surface operatio
149
153
 
150
154
  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.
151
155
 
152
- ### Crash-repair and fork results
156
+ ### Tool-result recovery and fork results
153
157
 
154
158
  #### What the model sees
155
159
 
@@ -157,7 +161,7 @@ If recovery finds an assistant tool request with no durable `tool/call`, its syn
157
161
 
158
162
  #### Token effect
159
163
 
160
- Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume.
164
+ Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text to later requests.
161
165
 
162
166
  #### KV Cache effect
163
167
 
@@ -167,7 +171,7 @@ Append-only; newly visible content follows the reusable request prefix and does
167
171
 
168
172
  #### What the model sees
169
173
 
170
- The session reconstructs the tool schemas and call config that the loop actually sent; the system prompt is part of `deriveMessages()` as surface node 0 and, after an in-history update, as the latest system node. Header events add no message to history and hold no copy of the prompt.
174
+ The session reconstructs active tool schemas and call config from request headers; provider-specific deferred and retained declarations are derived at dispatch. `toolHistory()` caches a stateful fold over committed headers and developer messages, consuming only unseen events on subsequent reads. It resolves additions through their historical `headerSeq`, returns immutable snapshots, and restarts declarations on an explicit request series or a changed retained definition. Restoring a retained name with an unchanged definition continues the series; the recorded addition re-offers it. Both loop requests and compaction calls use this state. Headers lacking matching update records use complete active declarations. Same-name schema changes are allowed and invalidate prefix reuse even on `in-history` routes; registration-time rejection against historical tools is deferred ([rationale](../../../.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md)). The system prompt remains part of `deriveMessages()`; header events add no message or prompt copy.
171
175
 
172
176
  #### Token effect
173
177
 
package/README.zh.md CHANGED
@@ -100,13 +100,17 @@ session.deriveMessages() // the derived model history
100
100
  | [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` |
101
101
  | [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 |
102
102
  | [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 |
103
- | [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 |
103
+ | [`src/repair.ts`](src/repair.ts) | 失败步骤、中断日志与 fork 种子共享的工具结果恢复 |
104
104
  | [`src/invariant.ts`](src/invariant.ts) | 不变式配套:序号、轮次/步骤闭合、工具调用/结果配对 |
105
105
 
106
106
  ### 追加校验
107
107
 
108
108
  每次追加都会使用共享的迭代式 `snapshotJsonValue()` 流程,对每个嵌套值只读取、校验并复制一次,因此有状态的 getter 无法给校验提供一个值、给存储提供另一个值。非无损 JSON 载荷(BigInt、循环、稀疏数组、`-0`、特殊原型)会在追加位置被拒绝,先于任何后端刷新。追加路径会构造每个 `SessionSeq`;surface 事件还会校验标记形态、被引用的源事件序号,以及替换的完整遮蔽节点覆盖。
109
109
 
110
+ ### 共享恢复逻辑
111
+
112
+ `ToolCallRecovery` 从已提交事件中跟踪尚无结果的请求,不保留事件历史。AgentLoop 观察实时步骤;崩溃恢复与 fork 种子构造通过 `openTurnClosers` 回放各自的前缀。实时失败与崩溃恢复默认使用中断结果文案;fork 构造传入 fork 原因,以选择其专用的重试指引。调用方在关闭步骤之前追加恢复结果([决策](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md))。
113
+
110
114
  ### 派生历史
111
115
 
112
116
  `deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。surface 事件类型(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的 system 和 developer 节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
@@ -149,17 +153,15 @@ session.deriveMessages() // the derived model history
149
153
 
150
154
  追加的 surface 条目会保留可复用前缀。即使底层事件日志保持仅追加,`replace` 操作也会从首条被遮蔽消息起使缓存复用失效。
151
155
 
152
- ### 崩溃修复结果
156
+ ### 工具结果恢复与 fork 结果
153
157
 
154
158
  #### 模型看到什么
155
159
 
156
- 如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `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.`。
157
-
158
- Fork 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
160
+ 如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `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 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
159
161
 
160
162
  #### Token 影响
161
163
 
162
- 未受损会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。
164
+ 未受损会话的 token 增量为零。每个修复后的调用都会向后续请求添加保留的、针对具体风险的错误文本。
163
165
 
164
166
  #### KV Cache 影响
165
167
 
@@ -169,7 +171,7 @@ Fork 生成的结果只描述继承记录:父会话可能已经在所选事件
169
171
 
170
172
  #### 模型看到什么
171
173
 
172
- 会话会重建循环实际发送的工具 schema 与调用配置;系统提示词作为 surface 第 0 号节点、并在历史内更新之后作为最新的系统节点,属于 `deriveMessages()` 的一部分。请求头事件不向历史加入任何消息,也不持有提示词的副本。
174
+ 会话从请求头重建有效工具模式和调用配置;提供方专用的延迟声明与保留声明在分发时派生。`toolHistory()` 缓存对已提交请求头和 developer 消息的有状态归约,后续读取仅处理新增事件。它通过历史 `headerSeq` 解析添加定义,返回不可变快照,并在显式新请求序列或保留定义发生变化时重新建立声明。以未变化的定义恢复保留名称时序列继续,由记录的添加重新提供该工具。循环请求和压缩调用均使用此状态。 请求头缺少对应更新记录时,使用完整有效声明。 允许同名工具的 schema 变化,即使在 `in-history` 路由上也会使前缀复用失效;注册时对照历史工具进行拒绝检查留待后续实现([原因](../../../.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md))。系统提示词仍属于 `deriveMessages()`;请求头事件不添加消息或提示词副本。
173
175
 
174
176
  #### Token 影响
175
177
 
package/lib/index.js CHANGED
@@ -637,14 +637,86 @@ function foldRequestHeader(events, from) {
637
637
  return state;
638
638
  }
639
639
  //#endregion
640
+ //#region lib/types/tool-history.js
641
+ /** Stateful reconstruction of historical tool definitions for request projection. */
642
+ /** Folds committed headers and developer messages independently of model capability. */
643
+ var ToolHistoryProjection = class {
644
+ /** Historical declarations indexed by the header sequence referenced by additions. */
645
+ headers = /* @__PURE__ */ new Map();
646
+ /** Definitions retained in the current declaration series, including removed tools. */
647
+ declared = /* @__PURE__ */ new Map();
648
+ /** Active definitions from the latest request header. */
649
+ active = [];
650
+ /** Active names reconstructed from the baseline and recorded updates. */
651
+ available = /* @__PURE__ */ new Set();
652
+ /** Header that starts the current declaration series; absent before the first header. */
653
+ baselineSeq;
654
+ /** Immutable request snapshot of the current baseline and resolved updates. */
655
+ history = deepFreeze({
656
+ tools: [],
657
+ updates: []
658
+ });
659
+ /**
660
+ * Consume the next committed event in log order.
661
+ * @param event - a session event, including inherited events during restoration.
662
+ */
663
+ apply(event) {
664
+ if (event.type === "request/header") {
665
+ const tools = event.data.header.tools ?? [];
666
+ this.headers.set(event.seq, tools);
667
+ const redeclared = tools.some((tool) => {
668
+ const before = this.declared.get(tool.name);
669
+ return before !== void 0 && JSON.stringify(before) !== JSON.stringify(tool);
670
+ });
671
+ if (this.baselineSeq === void 0 || event.data.reason === "series" || event.data.startsSeries || redeclared) {
672
+ this.baselineSeq = event.seq;
673
+ this.declared = new Map(tools.map((tool) => [tool.name, tool]));
674
+ this.history = deepFreeze({
675
+ tools,
676
+ updates: []
677
+ });
678
+ this.available = new Set(tools.map((tool) => tool.name));
679
+ }
680
+ this.active = tools;
681
+ } else if (event.type === "developer/message") {
682
+ const { message, headerSeq } = event.data;
683
+ const definitions = headerSeq === void 0 ? [] : this.headers.get(headerSeq);
684
+ const additions = message.content.flatMap((block) => {
685
+ if (block.type !== "tool-addition") return [];
686
+ const tool = definitions?.find((tool) => tool.name === block.toolName);
687
+ if (tool === void 0) throw new Error(`tool history: missing definition for ${block.toolName}`);
688
+ return [tool];
689
+ });
690
+ for (const tool of additions) this.declared.set(tool.name, tool);
691
+ for (const block of message.content) if (block.type === "tool-addition") this.available.add(block.toolName);
692
+ else if (block.type === "tool-removal") this.available.delete(block.toolName);
693
+ this.history = deepFreeze({
694
+ tools: this.history.tools,
695
+ updates: [...this.history.updates, {
696
+ messageId: message.id,
697
+ additions
698
+ }]
699
+ });
700
+ }
701
+ }
702
+ /**
703
+ * Read an immutable snapshot; subsequent events do not mutate it.
704
+ * @returns initial declarations and historically resolved additions for the current series.
705
+ */
706
+ snapshot() {
707
+ if (this.active.length !== this.available.size || this.active.some((tool) => !this.available.has(tool.name))) return deepFreeze({
708
+ tools: this.active,
709
+ updates: []
710
+ });
711
+ return this.history;
712
+ }
713
+ };
714
+ //#endregion
640
715
  //#region lib/types/repair.js
641
716
  /**
642
- * Synthetic closer events that balance a session log whose tail turn is open.
643
- * Two producers share the mechanism: crash recovery closes an interrupted
644
- * persisted log on reload, and fork-seed construction closes a prefix cut
645
- * inside the source's open turn. Both preserve every fully written event and
646
- * close the unfinished step and turn. Calls in already closed steps remain
647
- * unchanged, including any missing results.
717
+ * Pending tool-result recovery shared by failed live steps, interrupted logs,
718
+ * and fork seeds. Tail repair preserves closed steps and supplies only missing
719
+ * tool results and lifecycle boundaries, with cause-specific retry guidance.
648
720
  * @module @deepseek-ai/dsh-session/repair
649
721
  */
650
722
  /** Recovery code for an assistant tool request that never reached a recorded call start. */
@@ -664,99 +736,43 @@ const CLOSER_TEXT = {
664
736
  };
665
737
  /**
666
738
  * Return deterministic synthetic events that close an open tail turn. Unmatched
667
- * calls in its open step receive error results, followed by `step/end` and a
668
- * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
669
- * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
670
- * events.
671
- *
672
- * Package-internal: each cause has exactly one owner, so external callers go
673
- * through {@link interruptedTurnClosers} (persistence crash recovery) or
674
- * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
739
+ * calls receive error results first, followed by an open `step/end` and a
740
+ * cause-specific `turn/end`; sequences continue the log and timestamps reuse the
741
+ * last real event. A balanced or empty log returns no events.
675
742
  *
676
- * @param events - the log to scan: a valid committed prefix, possibly ending
677
- * inside an open turn (a crash tail or a mid-turn fork cut).
678
- * @param cause - why the turn is being closed; selects the `turn/end` reason
679
- * and the model-visible wording of synthetic error tool results.
743
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
744
+ * @param cause - the owning close operation: selects result wording and the turn-ending reason.
680
745
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
681
746
  */
682
747
  function openTurnClosers(events, cause) {
683
748
  let openTurn = null;
684
749
  let openStep = null;
685
- const pendingCalls = /* @__PURE__ */ new Map();
686
- for (const event of events) switch (event.type) {
687
- case "turn/start":
688
- openTurn = event.data.turn;
689
- openStep = null;
690
- pendingCalls.clear();
691
- break;
692
- case "turn/end":
693
- openTurn = null;
694
- openStep = null;
695
- pendingCalls.clear();
696
- break;
697
- case "step/start":
698
- openStep = event.data.step;
699
- break;
700
- case "step/end":
701
- pendingCalls.clear();
702
- openStep = null;
703
- break;
704
- case "assistant/message":
705
- for (const block of event.data.message.content) if (block.type === "tool-call") pendingCalls.set(block.id, { step: event.data.step });
706
- break;
707
- case "tool/call":
708
- {
709
- const entry = pendingCalls.get(event.data.callId);
710
- if (entry) entry.callSeq = event.seq;
711
- }
712
- break;
713
- case "tool/result":
714
- pendingCalls.delete(event.data.message.source.callId);
715
- break;
716
- default: break;
750
+ const recovery = new ToolCallRecovery(cause);
751
+ for (const event of events) {
752
+ recovery.observe(event);
753
+ switch (event.type) {
754
+ case "turn/start":
755
+ openTurn = event.data.turn;
756
+ openStep = null;
757
+ break;
758
+ case "turn/end":
759
+ openTurn = null;
760
+ openStep = null;
761
+ break;
762
+ case "step/start":
763
+ openStep = event.data.step;
764
+ break;
765
+ case "step/end":
766
+ openStep = null;
767
+ break;
768
+ default: break;
769
+ }
717
770
  }
718
771
  const last = events.at(-1);
719
772
  if (openTurn === null || last === void 0) return [];
720
- let seq = last.seq + 1;
773
+ const closers = recovery.results();
774
+ let seq = last.seq + closers.length + 1;
721
775
  const time = last.time;
722
- const closers = [];
723
- const text = CLOSER_TEXT[cause.kind];
724
- for (const [callId, { step, callSeq }] of pendingCalls) {
725
- const started = callSeq !== void 0;
726
- const message = deepFreeze({
727
- id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
728
- role: "tool",
729
- toolCallId: callId,
730
- isError: true,
731
- source: {
732
- kind: "tool",
733
- callId
734
- },
735
- content: [{
736
- type: "text",
737
- text: started ? text.started : text.notStarted
738
- }]
739
- });
740
- closers.push({
741
- type: "tool/result",
742
- seq: SessionSeq(seq++),
743
- time,
744
- data: {
745
- turn: openTurn,
746
- step,
747
- message,
748
- error: started ? {
749
- name: "ToolOutcomeUnknownError",
750
- code: TOOL_OUTCOME_UNKNOWN
751
- } : {
752
- name: "ToolNotStartedError",
753
- code: TOOL_NOT_STARTED
754
- }
755
- },
756
- surfaceOp: "append",
757
- ...started ? { sourceEventSeqs: [callSeq] } : {}
758
- });
759
- }
760
776
  if (openStep !== null) closers.push({
761
777
  type: "step/end",
762
778
  seq: SessionSeq(seq++),
@@ -778,10 +794,109 @@ function openTurnClosers(events, cause) {
778
794
  return closers;
779
795
  }
780
796
  /**
797
+ * Track unanswered assistant tool requests from one Session's committed events.
798
+ * Observe from the start of the owned step or replay prefix, and recover before
799
+ * its step closes. This state retains pending identities, not event history.
800
+ */
801
+ var ToolCallRecovery = class {
802
+ cause;
803
+ pendingCalls = /* @__PURE__ */ new Map();
804
+ last;
805
+ /** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
806
+ constructor(cause = { kind: "interrupted" }) {
807
+ this.cause = cause;
808
+ }
809
+ /**
810
+ * Consume the next committed event; closed steps and turn boundaries discard pending requests.
811
+ * @param event - the next event from the same Session, in sequence order.
812
+ */
813
+ observe(event) {
814
+ this.last = {
815
+ seq: event.seq,
816
+ time: event.time
817
+ };
818
+ switch (event.type) {
819
+ case "turn/start":
820
+ case "turn/end":
821
+ case "step/end":
822
+ this.pendingCalls.clear();
823
+ break;
824
+ case "assistant/message":
825
+ for (const block of event.data.message.content) if (block.type === "tool-call") this.pendingCalls.set(block.id, {
826
+ turn: event.data.turn,
827
+ step: event.data.step
828
+ });
829
+ break;
830
+ case "tool/call": {
831
+ const entry = this.pendingCalls.get(event.data.callId);
832
+ if (entry) entry.callSeq = event.seq;
833
+ break;
834
+ }
835
+ case "tool/result": {
836
+ const callId = event.data.message.source.callId;
837
+ const entry = this.pendingCalls.get(callId);
838
+ if (event.surfaceOp === "append" && entry !== void 0 && entry.turn === event.data.turn && entry.step === event.data.step) this.pendingCalls.delete(callId);
839
+ break;
840
+ }
841
+ default: break;
842
+ }
843
+ }
844
+ /**
845
+ * Build conservative error results in assistant order without changing tracked state.
846
+ * Sequences follow the latest observed event and timestamps reuse its time.
847
+ * Callers commit the results and observe those commits before recovering again.
848
+ * @returns pending tool-result events, empty when no request remains unanswered.
849
+ */
850
+ results() {
851
+ if (this.last === void 0) return [];
852
+ let seq = this.last.seq + 1;
853
+ const time = this.last.time;
854
+ const results = [];
855
+ const text = CLOSER_TEXT[this.cause.kind];
856
+ for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
857
+ const started = callSeq !== void 0;
858
+ const message = deepFreeze({
859
+ id: brandString(`${this.cause.kind}-tool-result-${callId}-${seq}`),
860
+ role: "tool",
861
+ toolCallId: callId,
862
+ isError: true,
863
+ source: {
864
+ kind: "tool",
865
+ callId
866
+ },
867
+ content: [{
868
+ type: "text",
869
+ text: started ? text.started : text.notStarted
870
+ }]
871
+ });
872
+ results.push({
873
+ type: "tool/result",
874
+ seq: SessionSeq(seq++),
875
+ time,
876
+ data: {
877
+ turn,
878
+ step,
879
+ message,
880
+ error: started ? {
881
+ name: "ToolOutcomeUnknownError",
882
+ code: TOOL_OUTCOME_UNKNOWN
883
+ } : {
884
+ name: "ToolNotStartedError",
885
+ code: TOOL_NOT_STARTED
886
+ }
887
+ },
888
+ surfaceOp: "append",
889
+ ...started ? { sourceEventSeqs: [callSeq] } : {}
890
+ });
891
+ }
892
+ return results;
893
+ }
894
+ };
895
+ /**
781
896
  * Crash-recovery entry point: synthetic closers that balance a persisted log
782
897
  * whose tail turn was interrupted. Used by crash-recovery callers; fork
783
898
  * seeds receive their `forked`-cause closers through `buildForkSeed` in
784
- * `./fork.ts`, and cause selection stays internal to those two owners.
899
+ * `./fork.ts`.
785
900
  *
786
901
  * @param events - the persisted log to scan, possibly ending inside an open turn.
787
902
  * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
@@ -1398,6 +1513,20 @@ var Session = class Session {
1398
1513
  }
1399
1514
  return this.contextFold;
1400
1515
  }
1516
+ /** Cached historical tool definitions and updates for request projection. */
1517
+ toolHistoryProjection = new ToolHistoryProjection();
1518
+ /** Index of the next committed event not yet consumed by the tool-history fold. */
1519
+ toolHistorySeq = 0;
1520
+ /**
1521
+ * Fold unseen committed events into capability-independent tool history.
1522
+ * Initial access reconstructs inherited history; later reads consume only new events.
1523
+ * @returns an immutable snapshot for LLM request projection, including historical addition definitions.
1524
+ */
1525
+ toolHistory() {
1526
+ for (const event of this.log.slice(this.toolHistorySeq)) this.toolHistoryProjection.apply(event);
1527
+ this.toolHistorySeq = this.log.length;
1528
+ return this.toolHistoryProjection.snapshot();
1529
+ }
1401
1530
  /** The derived-message cache: frozen projections, extended per unseen node. */
1402
1531
  derived = [];
1403
1532
  /** Surface position (nodes projected) the cache has reached. */
@@ -1801,4 +1930,4 @@ var SessionStore = class extends Service {
1801
1930
  }
1802
1931
  };
1803
1932
  //#endregion
1804
- export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, adoptSessionEvent, buildForkSeed, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, snapshotSessionEvent };
1933
+ export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, ToolCallRecovery, adoptSessionEvent, buildForkSeed, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, snapshotSessionEvent };
@@ -12,12 +12,13 @@ import { SessionLogOffset, SessionSeq } from './types.ts';
12
12
  import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol';
13
13
  import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts';
14
14
  import type { SessionSurface, SessionMessageProjection } from './surface.ts';
15
+ import type { ToolHistory } from '@deepseek-ai/dsh-llm';
15
16
  export { buildForkSeed } from './fork.ts';
16
17
  export * from './types.ts';
17
18
  export { SessionPreparation } from './preparation.ts';
18
19
  export type { SessionPreparationOptions } from './preparation.ts';
19
20
  export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm';
20
- export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
21
+ export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
21
22
  export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts';
22
23
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts';
23
24
  export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts';
@@ -265,6 +266,16 @@ export declare class Session {
265
266
  * @returns the latest immutable route metadata.
266
267
  */
267
268
  requestContext(): RequestContext | undefined;
269
+ /** Cached historical tool definitions and updates for request projection. */
270
+ private readonly toolHistoryProjection;
271
+ /** Index of the next committed event not yet consumed by the tool-history fold. */
272
+ private toolHistorySeq;
273
+ /**
274
+ * Fold unseen committed events into capability-independent tool history.
275
+ * Initial access reconstructs inherited history; later reads consume only new events.
276
+ * @returns an immutable snapshot for LLM request projection, including historical addition definitions.
277
+ */
278
+ toolHistory(): ToolHistory;
268
279
  /** The derived-message cache: frozen projections, extended per unseen node. */
269
280
  private derived;
270
281
  /** Surface position (nodes projected) the cache has reached. */
@@ -13,11 +13,12 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope';
13
13
  import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from "./types.js";
14
14
  import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from "./surface.js";
15
15
  import { foldRequestHeader } from "./request-header.js";
16
+ import { ToolHistoryProjection } from "./tool-history.js";
16
17
  import { buildForkSeed } from "./fork.js";
17
18
  export { buildForkSeed } from "./fork.js";
18
19
  export * from "./types.js";
19
20
  export { SessionPreparation } from "./preparation.js";
20
- export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
21
+ export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
21
22
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from "./surface.js";
22
23
  export { canonicalHeader, foldRequestHeader, headerEquals } from "./request-header.js";
23
24
  export { KNOWN_SESSION_EVENT_TYPES } from "./known-event-types.js";
@@ -654,6 +655,21 @@ export class Session {
654
655
  }
655
656
  return this.contextFold;
656
657
  }
658
+ /** Cached historical tool definitions and updates for request projection. */
659
+ toolHistoryProjection = new ToolHistoryProjection();
660
+ /** Index of the next committed event not yet consumed by the tool-history fold. */
661
+ toolHistorySeq = 0;
662
+ /**
663
+ * Fold unseen committed events into capability-independent tool history.
664
+ * Initial access reconstructs inherited history; later reads consume only new events.
665
+ * @returns an immutable snapshot for LLM request projection, including historical addition definitions.
666
+ */
667
+ toolHistory() {
668
+ for (const event of this.log.slice(this.toolHistorySeq))
669
+ this.toolHistoryProjection.apply(event);
670
+ this.toolHistorySeq = this.log.length;
671
+ return this.toolHistoryProjection.snapshot();
672
+ }
657
673
  /** The derived-message cache: frozen projections, extended per unseen node. */
658
674
  derived = [];
659
675
  /** Surface position (nodes projected) the cache has reached. */
@@ -1,10 +1,7 @@
1
1
  /**
2
- * Synthetic closer events that balance a session log whose tail turn is open.
3
- * Two producers share the mechanism: crash recovery closes an interrupted
4
- * persisted log on reload, and fork-seed construction closes a prefix cut
5
- * inside the source's open turn. Both preserve every fully written event and
6
- * close the unfinished step and turn. Calls in already closed steps remain
7
- * unchanged, including any missing results.
2
+ * Pending tool-result recovery shared by failed live steps, interrupted logs,
3
+ * and fork seeds. Tail repair preserves closed steps and supplies only missing
4
+ * tool results and lifecycle boundaries, with cause-specific retry guidance.
8
5
  * @module @deepseek-ai/dsh-session/repair
9
6
  */
10
7
  import type { SessionEvent } from './types.ts';
@@ -28,27 +25,44 @@ export type OpenTurnCloseCause = {
28
25
  };
29
26
  /**
30
27
  * Return deterministic synthetic events that close an open tail turn. Unmatched
31
- * calls in its open step receive error results, followed by `step/end` and a
32
- * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
33
- * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
34
- * events.
28
+ * calls receive error results first, followed by an open `step/end` and a
29
+ * cause-specific `turn/end`; sequences continue the log and timestamps reuse the
30
+ * last real event. A balanced or empty log returns no events.
35
31
  *
36
- * Package-internal: each cause has exactly one owner, so external callers go
37
- * through {@link interruptedTurnClosers} (persistence crash recovery) or
38
- * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
39
- *
40
- * @param events - the log to scan: a valid committed prefix, possibly ending
41
- * inside an open turn (a crash tail or a mid-turn fork cut).
42
- * @param cause - why the turn is being closed; selects the `turn/end` reason
43
- * and the model-visible wording of synthetic error tool results.
32
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
33
+ * @param cause - the owning close operation: selects result wording and the turn-ending reason.
44
34
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
45
35
  */
46
36
  export declare function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[];
37
+ /**
38
+ * Track unanswered assistant tool requests from one Session's committed events.
39
+ * Observe from the start of the owned step or replay prefix, and recover before
40
+ * its step closes. This state retains pending identities, not event history.
41
+ */
42
+ export declare class ToolCallRecovery {
43
+ private readonly cause;
44
+ private readonly pendingCalls;
45
+ private last;
46
+ /** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
47
+ constructor(cause?: OpenTurnCloseCause);
48
+ /**
49
+ * Consume the next committed event; closed steps and turn boundaries discard pending requests.
50
+ * @param event - the next event from the same Session, in sequence order.
51
+ */
52
+ observe(event: SessionEvent): void;
53
+ /**
54
+ * Build conservative error results in assistant order without changing tracked state.
55
+ * Sequences follow the latest observed event and timestamps reuse its time.
56
+ * Callers commit the results and observe those commits before recovering again.
57
+ * @returns pending tool-result events, empty when no request remains unanswered.
58
+ */
59
+ results(): SessionEvent<'tool/result'>[];
60
+ }
47
61
  /**
48
62
  * Crash-recovery entry point: synthetic closers that balance a persisted log
49
63
  * whose tail turn was interrupted. Used by crash-recovery callers; fork
50
64
  * seeds receive their `forked`-cause closers through `buildForkSeed` in
51
- * `./fork.ts`, and cause selection stays internal to those two owners.
65
+ * `./fork.ts`.
52
66
  *
53
67
  * @param events - the persisted log to scan, possibly ending inside an open turn.
54
68
  * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
@@ -1,10 +1,7 @@
1
1
  /**
2
- * Synthetic closer events that balance a session log whose tail turn is open.
3
- * Two producers share the mechanism: crash recovery closes an interrupted
4
- * persisted log on reload, and fork-seed construction closes a prefix cut
5
- * inside the source's open turn. Both preserve every fully written event and
6
- * close the unfinished step and turn. Calls in already closed steps remain
7
- * unchanged, including any missing results.
2
+ * Pending tool-result recovery shared by failed live steps, interrupted logs,
3
+ * and fork seeds. Tail repair preserves closed steps and supplies only missing
4
+ * tool results and lifecycle boundaries, with cause-specific retry guidance.
8
5
  * @module @deepseek-ai/dsh-session/repair
9
6
  */
10
7
  import { brandString } from '@deepseek-ai/dsh-brand';
@@ -27,66 +24,35 @@ const CLOSER_TEXT = {
27
24
  };
28
25
  /**
29
26
  * Return deterministic synthetic events that close an open tail turn. Unmatched
30
- * calls in its open step receive error results, followed by `step/end` and a
31
- * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
32
- * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
33
- * events.
27
+ * calls receive error results first, followed by an open `step/end` and a
28
+ * cause-specific `turn/end`; sequences continue the log and timestamps reuse the
29
+ * last real event. A balanced or empty log returns no events.
34
30
  *
35
- * Package-internal: each cause has exactly one owner, so external callers go
36
- * through {@link interruptedTurnClosers} (persistence crash recovery) or
37
- * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
38
- *
39
- * @param events - the log to scan: a valid committed prefix, possibly ending
40
- * inside an open turn (a crash tail or a mid-turn fork cut).
41
- * @param cause - why the turn is being closed; selects the `turn/end` reason
42
- * and the model-visible wording of synthetic error tool results.
31
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
32
+ * @param cause - the owning close operation: selects result wording and the turn-ending reason.
43
33
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
44
34
  */
45
35
  export function openTurnClosers(events, cause) {
46
36
  let openTurn = null;
47
37
  let openStep = null;
48
- // Reset at each turn boundary so earlier calls cannot leak into tail repair.
49
- // Assistant blocks register calls; later `tool/call` events add their seqs to `sourceEventSeqs`.
50
- const pendingCalls = new Map();
38
+ const recovery = new ToolCallRecovery(cause);
51
39
  for (const event of events) {
40
+ recovery.observe(event);
52
41
  switch (event.type) {
53
42
  case 'turn/start':
54
43
  openTurn = event.data.turn;
55
44
  openStep = null;
56
- pendingCalls.clear();
57
45
  break;
58
46
  case 'turn/end':
59
47
  openTurn = null;
60
48
  openStep = null;
61
- pendingCalls.clear();
62
49
  break;
63
50
  case 'step/start':
64
51
  openStep = event.data.step;
65
52
  break;
66
53
  case 'step/end':
67
- pendingCalls.clear();
68
54
  openStep = null;
69
55
  break;
70
- case 'assistant/message':
71
- // The assistant message carries the tool-call blocks; each is pending
72
- // until a tool/result event with the same callId is logged.
73
- for (const block of event.data.message.content) {
74
- if (block.type === 'tool-call')
75
- pendingCalls.set(block.id, { step: event.data.step });
76
- }
77
- break;
78
- case 'tool/call':
79
- // Cite the `tool/call` seq from the synthetic result.
80
- {
81
- const entry = pendingCalls.get(event.data.callId);
82
- if (entry) {
83
- entry.callSeq = event.seq;
84
- }
85
- }
86
- break;
87
- case 'tool/result':
88
- pendingCalls.delete(event.data.message.source.callId);
89
- break;
90
56
  // Other event types do not move the turn/step boundary cursor.
91
57
  default:
92
58
  break;
@@ -100,53 +66,120 @@ export function openTurnClosers(events, cause) {
100
66
  // The last real event supplies the seq base and the timestamp for the
101
67
  // synthetic closers (reusing the last timestamp keeps them deterministic and
102
68
  // never invents a "future" time).
103
- let seq = last.seq + 1;
69
+ const closers = recovery.results();
70
+ let seq = last.seq + closers.length + 1;
104
71
  const time = last.time;
105
- const closers = [];
106
- // Close calls before their step; Map insertion order preserves transcript order.
107
- const text = CLOSER_TEXT[cause.kind];
108
- for (const [callId, { step, callSeq }] of pendingCalls) {
109
- const started = callSeq !== undefined;
110
- const message = deepFreeze({
111
- id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
112
- role: 'tool',
113
- toolCallId: callId,
114
- isError: true,
115
- source: { kind: 'tool', callId },
116
- content: [{
117
- type: 'text',
118
- text: started ? text.started : text.notStarted,
119
- }],
120
- });
121
- closers.push({
122
- type: 'tool/result',
123
- seq: SessionSeq(seq++),
124
- time,
125
- data: {
126
- turn: openTurn,
127
- step,
128
- message,
129
- error: started
130
- ? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
131
- : { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
132
- },
133
- surfaceOp: 'append',
134
- ...started ? { sourceEventSeqs: [callSeq] } : {},
135
- });
136
- }
137
- // Close an open step next — a turn/end while a step is open is an invariant
138
- // violation, so the step's boundary must be synthesized before the turn's.
72
+ // Close an open step before its turn.
139
73
  if (openStep !== null) {
140
74
  closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } });
141
75
  }
142
76
  closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } });
143
77
  return closers;
144
78
  }
79
+ /**
80
+ * Track unanswered assistant tool requests from one Session's committed events.
81
+ * Observe from the start of the owned step or replay prefix, and recover before
82
+ * its step closes. This state retains pending identities, not event history.
83
+ */
84
+ export class ToolCallRecovery {
85
+ cause;
86
+ pendingCalls = new Map();
87
+ last;
88
+ /** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
89
+ constructor(cause = { kind: 'interrupted' }) {
90
+ this.cause = cause;
91
+ }
92
+ /**
93
+ * Consume the next committed event; closed steps and turn boundaries discard pending requests.
94
+ * @param event - the next event from the same Session, in sequence order.
95
+ */
96
+ observe(event) {
97
+ this.last = { seq: event.seq, time: event.time };
98
+ switch (event.type) {
99
+ case 'turn/start':
100
+ case 'turn/end':
101
+ case 'step/end':
102
+ this.pendingCalls.clear();
103
+ break;
104
+ case 'assistant/message':
105
+ for (const block of event.data.message.content) {
106
+ if (block.type === 'tool-call') {
107
+ this.pendingCalls.set(block.id, { turn: event.data.turn, step: event.data.step });
108
+ }
109
+ }
110
+ break;
111
+ case 'tool/call': {
112
+ const entry = this.pendingCalls.get(event.data.callId);
113
+ if (entry)
114
+ entry.callSeq = event.seq;
115
+ break;
116
+ }
117
+ case 'tool/result': {
118
+ const callId = event.data.message.source.callId;
119
+ const entry = this.pendingCalls.get(callId);
120
+ if (event.surfaceOp === 'append' && entry !== undefined
121
+ && entry.turn === event.data.turn && entry.step === event.data.step) {
122
+ this.pendingCalls.delete(callId);
123
+ }
124
+ break;
125
+ }
126
+ // SessionEvent is merge-extensible; unrelated events retain pending requests.
127
+ default:
128
+ break;
129
+ }
130
+ }
131
+ /**
132
+ * Build conservative error results in assistant order without changing tracked state.
133
+ * Sequences follow the latest observed event and timestamps reuse its time.
134
+ * Callers commit the results and observe those commits before recovering again.
135
+ * @returns pending tool-result events, empty when no request remains unanswered.
136
+ */
137
+ results() {
138
+ if (this.last === undefined)
139
+ return [];
140
+ let seq = this.last.seq + 1;
141
+ const time = this.last.time;
142
+ const results = [];
143
+ const text = CLOSER_TEXT[this.cause.kind];
144
+ // Close calls before their step: providers reject dangling assistant calls,
145
+ // and Map insertion order preserves their transcript order.
146
+ for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
147
+ const started = callSeq !== undefined;
148
+ const message = deepFreeze({
149
+ id: brandString(`${this.cause.kind}-tool-result-${callId}-${seq}`),
150
+ role: 'tool',
151
+ toolCallId: callId,
152
+ isError: true,
153
+ source: { kind: 'tool', callId },
154
+ content: [{
155
+ type: 'text',
156
+ text: started ? text.started : text.notStarted,
157
+ }],
158
+ });
159
+ results.push({
160
+ type: 'tool/result',
161
+ seq: SessionSeq(seq++),
162
+ time,
163
+ data: {
164
+ turn,
165
+ step,
166
+ message,
167
+ error: started
168
+ ? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
169
+ : { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
170
+ },
171
+ surfaceOp: 'append',
172
+ ...started ? { sourceEventSeqs: [callSeq] } : {},
173
+ });
174
+ }
175
+ return results;
176
+ }
177
+ }
145
178
  /**
146
179
  * Crash-recovery entry point: synthetic closers that balance a persisted log
147
180
  * whose tail turn was interrupted. Used by crash-recovery callers; fork
148
181
  * seeds receive their `forked`-cause closers through `buildForkSeed` in
149
- * `./fork.ts`, and cause selection stays internal to those two owners.
182
+ * `./fork.ts`.
150
183
  *
151
184
  * @param events - the persisted log to scan, possibly ending inside an open turn.
152
185
  * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
@@ -0,0 +1,29 @@
1
+ /** Stateful reconstruction of historical tool definitions for request projection. */
2
+ import type { ToolHistory } from '@deepseek-ai/dsh-llm';
3
+ import type { SessionEvent } from './types.ts';
4
+ /** Folds committed headers and developer messages independently of model capability. */
5
+ export declare class ToolHistoryProjection {
6
+ /** Historical declarations indexed by the header sequence referenced by additions. */
7
+ private readonly headers;
8
+ /** Definitions retained in the current declaration series, including removed tools. */
9
+ private declared;
10
+ /** Active definitions from the latest request header. */
11
+ private active;
12
+ /** Active names reconstructed from the baseline and recorded updates. */
13
+ private available;
14
+ /** Header that starts the current declaration series; absent before the first header. */
15
+ private baselineSeq;
16
+ /** Immutable request snapshot of the current baseline and resolved updates. */
17
+ private history;
18
+ /**
19
+ * Consume the next committed event in log order.
20
+ * @param event - a session event, including inherited events during restoration.
21
+ */
22
+ apply(event: SessionEvent): void;
23
+ /**
24
+ * Read an immutable snapshot; subsequent events do not mutate it.
25
+ * @returns initial declarations and historically resolved additions for the current series.
26
+ */
27
+ snapshot(): ToolHistory;
28
+ }
29
+ //# sourceMappingURL=tool-history.d.ts.map
@@ -0,0 +1,76 @@
1
+ /** Stateful reconstruction of historical tool definitions for request projection. */
2
+ import { deepFreeze } from '@deepseek-ai/dsh-util-values';
3
+ /** Folds committed headers and developer messages independently of model capability. */
4
+ export class ToolHistoryProjection {
5
+ /** Historical declarations indexed by the header sequence referenced by additions. */
6
+ headers = new Map();
7
+ /** Definitions retained in the current declaration series, including removed tools. */
8
+ declared = new Map();
9
+ /** Active definitions from the latest request header. */
10
+ active = [];
11
+ /** Active names reconstructed from the baseline and recorded updates. */
12
+ available = new Set();
13
+ /** Header that starts the current declaration series; absent before the first header. */
14
+ baselineSeq;
15
+ /** Immutable request snapshot of the current baseline and resolved updates. */
16
+ history = deepFreeze({ tools: [], updates: [] });
17
+ /**
18
+ * Consume the next committed event in log order.
19
+ * @param event - a session event, including inherited events during restoration.
20
+ */
21
+ apply(event) {
22
+ if (event.type === 'request/header') {
23
+ const tools = event.data.header.tools ?? [];
24
+ this.headers.set(event.seq, tools);
25
+ // A retained name cannot acquire a different definition without losing
26
+ // prefix reuse; an unchanged restored name is re-offered by its recorded addition.
27
+ const redeclared = tools.some((tool) => {
28
+ const before = this.declared.get(tool.name);
29
+ return before !== undefined && JSON.stringify(before) !== JSON.stringify(tool);
30
+ });
31
+ if (this.baselineSeq === undefined || event.data.reason === 'series' || event.data.startsSeries || redeclared) {
32
+ this.baselineSeq = event.seq;
33
+ this.declared = new Map(tools.map(tool => [tool.name, tool]));
34
+ this.history = deepFreeze({ tools, updates: [] });
35
+ this.available = new Set(tools.map(tool => tool.name));
36
+ }
37
+ this.active = tools;
38
+ }
39
+ else if (event.type === 'developer/message') {
40
+ const { message, headerSeq } = event.data;
41
+ const definitions = headerSeq === undefined ? [] : this.headers.get(headerSeq);
42
+ const additions = message.content.flatMap((block) => {
43
+ if (block.type !== 'tool-addition')
44
+ return [];
45
+ const tool = definitions?.find(tool => tool.name === block.toolName);
46
+ if (tool === undefined)
47
+ throw new Error(`tool history: missing definition for ${block.toolName}`);
48
+ return [tool];
49
+ });
50
+ for (const tool of additions)
51
+ this.declared.set(tool.name, tool);
52
+ for (const block of message.content) {
53
+ if (block.type === 'tool-addition')
54
+ this.available.add(block.toolName);
55
+ else if (block.type === 'tool-removal')
56
+ this.available.delete(block.toolName);
57
+ }
58
+ this.history = deepFreeze({
59
+ tools: this.history.tools,
60
+ updates: [...this.history.updates, { messageId: message.id, additions }],
61
+ });
62
+ }
63
+ }
64
+ /**
65
+ * Read an immutable snapshot; subsequent events do not mutate it.
66
+ * @returns initial declarations and historically resolved additions for the current series.
67
+ */
68
+ snapshot() {
69
+ // Sessions written before tool-update emission have headers without matching updates.
70
+ if (this.active.length !== this.available.size || this.active.some(tool => !this.available.has(tool.name))) {
71
+ return deepFreeze({ tools: this.active, updates: [] });
72
+ }
73
+ return this.history;
74
+ }
75
+ }
76
+ //# sourceMappingURL=tool-history.js.map
@@ -241,9 +241,9 @@ export interface RequestContext {
241
241
  * Why a `request/header` snapshot was appended: `'initial'` — the log's first
242
242
  * header (a new conversation); `'resume'` — a loop instance's first request
243
243
  * over a log that already has header events (process restart, fork seed);
244
- * `'change'` — a later request used a different header, with `startsSeries`
245
- * preserving a coincident series boundary; `'series'` — an unchanged header
246
- * began an explicitly distinct message series or followed a surface replacement.
244
+ * `'change'` — a later request used a different header; `'series'` — an unchanged
245
+ * header began an explicitly distinct message series or followed a surface
246
+ * replacement. Other reasons carry `startsSeries` when a new series coincides.
247
247
  */
248
248
  export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series';
249
249
  /**
@@ -393,7 +393,7 @@ export interface SessionEventMap {
393
393
  'request/header': {
394
394
  header: EpochHeader;
395
395
  reason: RequestHeaderReason;
396
- /** A changed header also begins a distinct model-message series. */
396
+ /** This request begins a distinct model-message series, independently of the header reason. */
397
397
  startsSeries?: true;
398
398
  };
399
399
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session",
3
3
  "description": "Event-sourced session store for the DeepSeek Harness",
4
- "version": "0.1.7-rc.1",
4
+ "version": "0.2.0-rc.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -45,19 +45,19 @@
45
45
  ],
46
46
  "license": "MIT",
47
47
  "peerDependencies": {
48
- "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
48
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.1",
49
49
  "@deepseek-ai/cordis": "~4.0.4"
50
50
  },
51
51
  "devDependencies": {
52
- "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
53
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.1",
54
- "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
55
- "@deepseek-ai/dsh-typert-registry": "0.1.7-rc.1",
52
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
53
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.1",
54
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
55
+ "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.1",
56
56
  "@deepseek-ai/cordis": "~4.0.4"
57
57
  },
58
58
  "dependencies": {
59
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
60
- "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
61
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.1"
59
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-util-values": "0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1"
62
62
  }
63
63
  }