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

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
@@ -42,11 +42,14 @@
42
42
  en: bbfb53c50100ab13
43
43
  zh: 303a8ee47f4f656a
44
44
  /deepseek-ai-dsh-session/understand-the-implementation/source-map:
45
- en: e87f7ef34effbad2
46
- zh: 774a98d23611f5f5
45
+ en: 1268738282846f1d
46
+ zh: 75a9fe0d5088ea09
47
47
  /deepseek-ai-dsh-session/understand-the-implementation/append-validation:
48
48
  en: 5c09583bc8e1ffc6
49
49
  zh: 3b25552de2d1f9b6
50
+ /deepseek-ai-dsh-session/understand-the-implementation/shared-recovery:
51
+ en: 76bb076736b2dd0f
52
+ zh: de8306afc1e5faa1
50
53
  /deepseek-ai-dsh-session/understand-the-implementation/derived-history:
51
54
  en: 0cc7a06f76bd7eca
52
55
  zh: 1db25185b15cad23
@@ -71,16 +74,16 @@
71
74
  /deepseek-ai-dsh-session/model-experience/derived-message-history/kv-cache-effect:
72
75
  en: 6c8079c7de029ed4
73
76
  zh: 634d6f93ce6373c4
74
- /deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results:
75
- en: 4aa585f38846e017
76
- zh: ef28f54125a9021d
77
- /deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/what-the-model-sees:
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:
78
81
  en: b2ae41b23519cdc6
79
- zh: 05ce1c9a8b8c3283
80
- /deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/token-effect:
81
- en: ac63e0f30175f72d
82
- zh: 20f63deeffa249f2
83
- /deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/kv-cache-effect:
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:
84
87
  en: f5d5f97a153f7c70
85
88
  zh: b84ef7f75f2e25d0
86
89
  /deepseek-ai-dsh-session/model-experience/logged-request-header:
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
 
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
 
package/lib/index.js CHANGED
@@ -714,12 +714,9 @@ var ToolHistoryProjection = class {
714
714
  //#endregion
715
715
  //#region lib/types/repair.js
716
716
  /**
717
- * Synthetic closer events that balance a session log whose tail turn is open.
718
- * Two producers share the mechanism: crash recovery closes an interrupted
719
- * persisted log on reload, and fork-seed construction closes a prefix cut
720
- * inside the source's open turn. Both preserve every fully written event and
721
- * close the unfinished step and turn. Calls in already closed steps remain
722
- * 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.
723
720
  * @module @deepseek-ai/dsh-session/repair
724
721
  */
725
722
  /** Recovery code for an assistant tool request that never reached a recorded call start. */
@@ -739,99 +736,43 @@ const CLOSER_TEXT = {
739
736
  };
740
737
  /**
741
738
  * Return deterministic synthetic events that close an open tail turn. Unmatched
742
- * calls in its open step receive error results, followed by `step/end` and a
743
- * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
744
- * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
745
- * events.
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.
746
742
  *
747
- * Package-internal: each cause has exactly one owner, so external callers go
748
- * through {@link interruptedTurnClosers} (persistence crash recovery) or
749
- * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
750
- *
751
- * @param events - the log to scan: a valid committed prefix, possibly ending
752
- * inside an open turn (a crash tail or a mid-turn fork cut).
753
- * @param cause - why the turn is being closed; selects the `turn/end` reason
754
- * 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.
755
745
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
756
746
  */
757
747
  function openTurnClosers(events, cause) {
758
748
  let openTurn = null;
759
749
  let openStep = null;
760
- const pendingCalls = /* @__PURE__ */ new Map();
761
- for (const event of events) switch (event.type) {
762
- case "turn/start":
763
- openTurn = event.data.turn;
764
- openStep = null;
765
- pendingCalls.clear();
766
- break;
767
- case "turn/end":
768
- openTurn = null;
769
- openStep = null;
770
- pendingCalls.clear();
771
- break;
772
- case "step/start":
773
- openStep = event.data.step;
774
- break;
775
- case "step/end":
776
- pendingCalls.clear();
777
- openStep = null;
778
- break;
779
- case "assistant/message":
780
- for (const block of event.data.message.content) if (block.type === "tool-call") pendingCalls.set(block.id, { step: event.data.step });
781
- break;
782
- case "tool/call":
783
- {
784
- const entry = pendingCalls.get(event.data.callId);
785
- if (entry) entry.callSeq = event.seq;
786
- }
787
- break;
788
- case "tool/result":
789
- pendingCalls.delete(event.data.message.source.callId);
790
- break;
791
- 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
+ }
792
770
  }
793
771
  const last = events.at(-1);
794
772
  if (openTurn === null || last === void 0) return [];
795
- let seq = last.seq + 1;
773
+ const closers = recovery.results();
774
+ let seq = last.seq + closers.length + 1;
796
775
  const time = last.time;
797
- const closers = [];
798
- const text = CLOSER_TEXT[cause.kind];
799
- for (const [callId, { step, callSeq }] of pendingCalls) {
800
- const started = callSeq !== void 0;
801
- const message = deepFreeze({
802
- id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
803
- role: "tool",
804
- toolCallId: callId,
805
- isError: true,
806
- source: {
807
- kind: "tool",
808
- callId
809
- },
810
- content: [{
811
- type: "text",
812
- text: started ? text.started : text.notStarted
813
- }]
814
- });
815
- closers.push({
816
- type: "tool/result",
817
- seq: SessionSeq(seq++),
818
- time,
819
- data: {
820
- turn: openTurn,
821
- step,
822
- message,
823
- error: started ? {
824
- name: "ToolOutcomeUnknownError",
825
- code: TOOL_OUTCOME_UNKNOWN
826
- } : {
827
- name: "ToolNotStartedError",
828
- code: TOOL_NOT_STARTED
829
- }
830
- },
831
- surfaceOp: "append",
832
- ...started ? { sourceEventSeqs: [callSeq] } : {}
833
- });
834
- }
835
776
  if (openStep !== null) closers.push({
836
777
  type: "step/end",
837
778
  seq: SessionSeq(seq++),
@@ -853,10 +794,109 @@ function openTurnClosers(events, cause) {
853
794
  return closers;
854
795
  }
855
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
+ /**
856
896
  * Crash-recovery entry point: synthetic closers that balance a persisted log
857
897
  * whose tail turn was interrupted. Used by crash-recovery callers; fork
858
898
  * seeds receive their `forked`-cause closers through `buildForkSeed` in
859
- * `./fork.ts`, and cause selection stays internal to those two owners.
899
+ * `./fork.ts`.
860
900
  *
861
901
  * @param events - the persisted log to scan, possibly ending inside an open turn.
862
902
  * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
@@ -1890,4 +1930,4 @@ var SessionStore = class extends Service {
1890
1930
  }
1891
1931
  };
1892
1932
  //#endregion
1893
- 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 };
@@ -18,7 +18,7 @@ export * from './types.ts';
18
18
  export { SessionPreparation } from './preparation.ts';
19
19
  export type { SessionPreparationOptions } from './preparation.ts';
20
20
  export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm';
21
- export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
21
+ export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
22
22
  export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts';
23
23
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts';
24
24
  export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts';
@@ -18,7 +18,7 @@ import { buildForkSeed } from "./fork.js";
18
18
  export { buildForkSeed } from "./fork.js";
19
19
  export * from "./types.js";
20
20
  export { SessionPreparation } from "./preparation.js";
21
- export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
21
+ export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
22
22
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from "./surface.js";
23
23
  export { canonicalHeader, foldRequestHeader, headerEquals } from "./request-header.js";
24
24
  export { KNOWN_SESSION_EVENT_TYPES } from "./known-event-types.js";
@@ -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.
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.2",
4
+ "version": "0.2.0-rc.2",
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.2",
49
- "@deepseek-ai/cordis": "~4.0.4"
48
+ "@deepseek-ai/cordis": "~4.0.4",
49
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2"
50
50
  },
51
51
  "devDependencies": {
52
- "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
53
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
54
- "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
55
- "@deepseek-ai/dsh-typert-registry": "0.1.7-rc.2",
56
- "@deepseek-ai/cordis": "~4.0.4"
52
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
55
+ "@deepseek-ai/cordis": "~4.0.4",
56
+ "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2"
57
57
  },
58
58
  "dependencies": {
59
- "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
60
- "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
61
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.2"
59
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-util-values": "0.2.0-rc.2"
62
62
  }
63
63
  }