dsh-switchman 1.10.2 → 1.10.3

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.
@@ -75,6 +75,130 @@
75
75
 
76
76
  import { refOf, summaryMessageOf } from "./view.js";
77
77
 
78
+ // ---- tool-pair closure ------------------------------------------------------
79
+
80
+ /** Call ids OPENED by one message, across every wire shape the plugin can
81
+ * see: the DSH-native content block {type:'tool-call', id} (the shape that
82
+ * reaches real captures — packages/llm/llm/src/types.ts ToolCallBlock), the
83
+ * provider-shaped {type:'tool_use', id} block, and the OpenAI
84
+ * message.tool_calls array. Duplicate ids are kept — the caller only asks
85
+ * "is this id answered somewhere in the span". */
86
+ function toolCallIdsOf(message) {
87
+ const ids = [];
88
+ if (message === null || typeof message !== "object") return ids;
89
+ if (Array.isArray(message.tool_calls)) {
90
+ for (const call of message.tool_calls) {
91
+ if (typeof call?.id === "string" && call.id !== "") ids.push(call.id);
92
+ }
93
+ }
94
+ if (Array.isArray(message.content)) {
95
+ for (const block of message.content) {
96
+ if ((block?.type === "tool-call" || block?.type === "tool_use") && typeof block.id === "string" && block.id !== "") ids.push(block.id);
97
+ }
98
+ }
99
+ return ids;
100
+ }
101
+
102
+ /** Call ids ANSWERED by one message: the DSH-native role:'tool' message's
103
+ * toolCallId, the provider-shaped tool_call_id field, and tool_result /
104
+ * tool-result content blocks' tool_use_id. */
105
+ function toolResultIdsOf(message) {
106
+ const ids = [];
107
+ if (message === null || typeof message !== "object") return ids;
108
+ if (typeof message.toolCallId === "string" && message.toolCallId !== "") ids.push(message.toolCallId);
109
+ if (typeof message.tool_call_id === "string" && message.tool_call_id !== "") ids.push(message.tool_call_id);
110
+ if (Array.isArray(message.content)) {
111
+ for (const block of message.content) {
112
+ if (block?.type !== "tool_result" && block?.type !== "tool-result") continue;
113
+ const id = block.tool_use_id ?? block.toolCallId ?? block.tool_call_id;
114
+ if (typeof id === "string" && id !== "") ids.push(id);
115
+ }
116
+ }
117
+ return ids;
118
+ }
119
+
120
+ /** Dense node-index-aligned message list built from derivingPairsOf: entry i
121
+ * is the message surface node i derives, or null for a node that derives
122
+ * none (empty assistant step, ...). closeToolPairSpan indexes this list by
123
+ * SURFACE NODE INDEX while derivingPairsOf compacts that space — this is the
124
+ * bridge, and it reuses the already-resolved messages. */
125
+ function denseMessagesOf(pairs, nodeCount) {
126
+ const messages = new Array(nodeCount).fill(null);
127
+ for (const pair of pairs) messages[pair.nodeIndex] = pair.message;
128
+ return messages;
129
+ }
130
+
131
+ /** Close a surface span over complete tool pairs. A shipped fold must never
132
+ * separate an assistant tool call from its tool results: the provider wire
133
+ * serializers reject the orphan half ("DeepSeek Messages tool result has no
134
+ * matching call", llm-deepseek/src/serialize.ts:130-150), and the host's own
135
+ * compaction validates the same rule (dsh-compaction/lib/index.js:557).
136
+ * view.js segments pairwise, but a fold block's range can still be captured
137
+ * or resolved off a surface whose boundary sits between the two halves
138
+ * (trigger.js computeFoldableRange stops at a segment end; a pre-fix capture,
139
+ * a host compaction, or a later block all can leave the boundary there), so
140
+ * this pass re-closes the span against the LIVE node list.
141
+ *
142
+ * pairs: DENSE message list aligned to surface node indexes (null entries for
143
+ * nodes that derive no message); nodeCount: surface node count (indices past
144
+ * pairs.length are treated as absent). Returns { startIdx, endIdx } or null
145
+ * when the span cannot be closed without leaving the surface.
146
+ *
147
+ * FORWARD: every call opened INSIDE the span must be answered inside it;
148
+ * otherwise the results directly following the span end are pulled in (one
149
+ * node per iteration, so several consecutive role:'tool' nodes for one
150
+ * assistant step all land). A call whose next surface node does not answer it
151
+ * (the surface ends first, or a different message sits between) is
152
+ * unclosable: null.
153
+ *
154
+ * BACKWARD: a result at the span start whose call still lives earlier on the
155
+ * surface pulls the start back over that assistant node (and the run of
156
+ * results after it), so shipping the fold cannot orphan a result it leaves
157
+ * behind. When that call node is already shadowed/absent, the leading result
158
+ * is a PRE-EXISTING orphan: removing it is the healing direction and the
159
+ * span stays as captured (the earlier fold's shadowed set already owns the
160
+ * call). A node that both opens calls and carries results is handled by both
161
+ * passes defensively. */
162
+ function closeToolPairSpan(pairs, startIdx, endIdx, nodeCount) {
163
+ const count = Number.isInteger(nodeCount) ? nodeCount : pairs.length;
164
+ if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx)) return null;
165
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= count) return null;
166
+ const messageAt = (index) => (index >= 0 && index < count ? pairs[index] ?? null : null);
167
+ let start = startIdx;
168
+ let end = endIdx;
169
+
170
+ // Backward pass (bounded by count: every step moves start strictly earlier).
171
+ for (let guard = 0; guard <= count; guard++) {
172
+ const resultIds = toolResultIdsOf(messageAt(start));
173
+ if (resultIds.length === 0) break;
174
+ let owner = -1;
175
+ for (let i = start - 1; i >= 0; i--) {
176
+ const callIds = toolCallIdsOf(messageAt(i));
177
+ if (callIds.some((id) => resultIds.includes(id))) {
178
+ owner = i;
179
+ break;
180
+ }
181
+ }
182
+ if (owner < 0) break; // call already shadowed/absent: healing direction, span unchanged
183
+ start = owner;
184
+ }
185
+
186
+ // Forward pass: recompute the open calls over the current span each
187
+ // iteration, so a multi-result assistant step extends over all of them.
188
+ for (let guard = 0; guard <= count; guard++) {
189
+ const open = new Set();
190
+ for (let i = start; i <= end; i++) for (const id of toolCallIdsOf(messageAt(i))) open.add(id);
191
+ for (let i = start; i <= end; i++) for (const id of toolResultIdsOf(messageAt(i))) open.delete(id);
192
+ if (open.size === 0) return { startIdx: start, endIdx: end };
193
+ const next = end + 1;
194
+ if (next >= count) return null; // surface ends before the results: unclosable
195
+ const nextIds = toolResultIdsOf(messageAt(next));
196
+ if (!nextIds.some((id) => open.has(id))) return null; // next node answers a different call: unclosable
197
+ end = next;
198
+ }
199
+ return null;
200
+ }
201
+
78
202
  /** Config field reader (fail-open default), same pattern as index.js. */
79
203
  function readBool(config, key, fallback) {
80
204
  try {
@@ -429,10 +553,11 @@ export function createSurfaceFoldPass(deps = {}) {
429
553
  const session = sessionOf(sessionId);
430
554
  if (!surfaceCapable(session)) return false;
431
555
  const nodes = session.surface.nodes.slice();
432
- const located = locateOnSurface(derivingPairsOf(session, nodes), block);
556
+ const pairs = derivingPairsOf(session, nodes);
557
+ const located = locateOnSurface(pairs, block);
433
558
  if (located === null) return false;
434
559
  let startIdx = located[0];
435
- const endIdx = located[1];
560
+ let endIdx = located[1];
436
561
  // Head clamp at CAPTURE time: the stored range must never include
437
562
  // the leading system message, so a block captured here stays clean
438
563
  // even when it ships from a later process. surfaceHeadClamped tells
@@ -443,6 +568,13 @@ export function createSurfaceFoldPass(deps = {}) {
443
568
  startIdx = 1;
444
569
  block.surfaceHeadClamped = true;
445
570
  }
571
+ // NOTE: the capture stores the LOCATED span only — it must not be
572
+ // pre-closed over tool pairs. The ship-time closure runs after
573
+ // validateStoredSeqs re-checks the stored endpoints against the
574
+ // block's refRange, so an extended capture (whose endpoints no
575
+ // longer derive the ref ids) would fail validation, fall back to
576
+ // live location and tombstone as a relocated range — a silently
577
+ // unshippable block. The sweep is the authoritative closure pass.
446
578
  block.surfaceSeqs = nodes.slice(startIdx, endIdx + 1);
447
579
  try {
448
580
  store.persist(store.ledgerOf(sessionId));
@@ -517,14 +649,22 @@ export function createSurfaceFoldPass(deps = {}) {
517
649
  skip(sessionId, "compaction-lock", via, `blocks=${pending.length}`);
518
650
  return 0;
519
651
  }
520
- const nodes = surface.nodes.slice();
521
- const pairs = derivingPairsOf(session, nodes);
522
- const liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
652
+ // Live surface snapshot — REFRESHED after every successful ship: the
653
+ // shadowed seqs leave surface.nodes, so the next pending block must
654
+ // resolve/close against the new node list (a stale snapshot validated
655
+ // or closed a span that no longer exists).
656
+ let nodes = surface.nodes.slice();
657
+ let pairs = derivingPairsOf(session, nodes);
658
+ let nodeMessages = denseMessagesOf(pairs, nodes.length);
659
+ // A ship is a replace op, which bumps surface.contentGeneration: refresh
660
+ // it with the snapshot so a tombstone written later in this sweep keys
661
+ // on the post-ship generation instead of re-opening on the next sweep.
662
+ let liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
523
663
  // The host rejects a replace op over node 0 only when node 0 actually
524
664
  // carries the system prompt (assertSystemHeadRewrite). Resolve that
525
665
  // head role once per sweep — sessions without a leading system
526
666
  // message fold their head node like any other.
527
- const headIsSystem = headNodeIsSystem(session, nodes);
667
+ let headIsSystem = headNodeIsSystem(session, nodes);
528
668
  let shipped = 0;
529
669
  for (const block of pending) {
530
670
  // Resolve the shadow range: stored capture first (validated dense
@@ -536,10 +676,12 @@ export function createSurfaceFoldPass(deps = {}) {
536
676
  // are simply gone): never a silent re-target onto a different range.
537
677
  let startSeq = null;
538
678
  let endSeq = null;
679
+ let startIdx = null;
680
+ let endIdx = null;
539
681
  let seqs = null;
540
682
  const stored = Array.isArray(block.surfaceSeqs) ? validateStoredSeqs(session, nodes, block.surfaceSeqs, block) : null;
541
683
  if (stored !== null) {
542
- ({ startSeq, endSeq, seqs } = stored);
684
+ ({ startIdx, endIdx, startSeq, endSeq, seqs } = stored);
543
685
  } else {
544
686
  const located = locateOnSurface(pairs, block);
545
687
  if (located === null) {
@@ -578,10 +720,43 @@ export function createSurfaceFoldPass(deps = {}) {
578
720
  skip(sessionId, "incomplete-seqs", via, `block=${block.id} overlap`, false);
579
721
  continue;
580
722
  }
581
- startSeq = nodes[located[0]];
582
- endSeq = nodes[located[1]];
723
+ startIdx = located[0];
724
+ endIdx = located[1];
725
+ startSeq = nodes[startIdx];
726
+ endSeq = nodes[endIdx];
583
727
  seqs = locatedSeqs;
584
728
  }
729
+ // Tool-pair closure over the LIVE surface (both resolution paths):
730
+ // a shipped range must never separate an assistant's tool call from
731
+ // its results — the provider wire serializers reject the orphan half
732
+ // ("DeepSeek Messages tool result has no matching call",
733
+ // llm-deepseek/src/serialize.ts:130-150). Extend forward over the
734
+ // results of calls opened inside the range, and backward over the
735
+ // live call of an orphan result at the range start. Unclosable (a
736
+ // call with no result on this surface, a range whose next node
737
+ // answers a different call) tombstones as tool-pair-unclosed — the
738
+ // same safe hard-skip family as incomplete-seqs: never ship a
739
+ // pair-splitting range. The block re-opens on the next surface
740
+ // content change (tool-pair-unclosed is generation-keyed like every
741
+ // other skip).
742
+ const closed = closeToolPairSpan(nodeMessages, startIdx, endIdx, nodes.length);
743
+ if (closed === null) {
744
+ block.surfaceSkip = { reason: "tool-pair-unclosed", contentGeneration: liveGeneration, at: Date.now() };
745
+ try {
746
+ store.persist(ledger);
747
+ } catch {
748
+ /* best effort */
749
+ }
750
+ skip(sessionId, "tool-pair-unclosed", via, `block=${block.id}`);
751
+ continue;
752
+ }
753
+ if (closed.startIdx !== startIdx || closed.endIdx !== endIdx) {
754
+ startIdx = closed.startIdx;
755
+ endIdx = closed.endIdx;
756
+ startSeq = nodes[startIdx];
757
+ endSeq = nodes[endIdx];
758
+ seqs = nodes.slice(startIdx, endIdx + 1);
759
+ }
585
760
  // System head clamp (ship time, BOTH resolution paths): when the
586
761
  // first surface node carries the system prompt, the v4 validator
587
762
  // rejects any prune whose shadowedSeqs contain it
@@ -658,6 +833,15 @@ export function createSurfaceFoldPass(deps = {}) {
658
833
  delete block.surfaceSkip;
659
834
  rememberReplaced(key);
660
835
  shipped += 1;
836
+ // The shadowed seqs have just left surface.nodes: refresh the
837
+ // snapshot every later pending block resolves against, so its
838
+ // dense validation / re-location / pair closure reads the
839
+ // POST-ship surface instead of a range that no longer exists.
840
+ nodes = surface.nodes.slice();
841
+ pairs = derivingPairsOf(session, nodes);
842
+ nodeMessages = denseMessagesOf(pairs, nodes.length);
843
+ headIsSystem = headNodeIsSystem(session, nodes);
844
+ liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
661
845
  decisionLog(`session=${sessionId} surface-replace startSeq=${startSeq} endSeq=${endSeq} seqs=${seqs.length} tokens=${Number.isFinite(block.approxTokens) ? Math.round(block.approxTokens) : "?"} generation=${liveGeneration} via=${via}`);
662
846
  try {
663
847
  store.persist(ledger);
@@ -163,18 +163,26 @@ export function estimateTokensOf(messages, startIdx = 0, endIdx = messages.lengt
163
163
 
164
164
  // ---- tool-pair segmentation -------------------------------------------------
165
165
 
166
- /** True when the message OPENS tool calls (either wire shape). */
166
+ /** True when the message OPENS tool calls. Covers the DSH-native shape
167
+ * (assistant content block {type:'tool-call', id} — packages/llm/llm/src/
168
+ * types.ts ToolCallBlock) plus the provider-shaped variants: the OpenAI
169
+ * message.tool_calls array and the Anthropic {type:'tool_use'} block. The
170
+ * DSH-native shape is what actually reaches this plugin's captures; missing
171
+ * it made every native assistant tool call look like a plain message, so
172
+ * segmentBounds below split the call from its results. */
167
173
  function opensToolCalls(message) {
168
174
  if (message?.role !== "assistant") return false;
169
175
  if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true;
170
- if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use");
176
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use" || block?.type === "tool-call");
171
177
  return false;
172
178
  }
173
179
 
174
- /** True when the message CARRIES tool results (either wire shape). */
180
+ /** True when the message CARRIES tool results. DSH-native results are
181
+ * role:'tool' messages (with their call id on the message); provider-shaped
182
+ * variants keep a tool_result/tool-result content block. */
175
183
  function carriesToolResults(message) {
176
184
  if (message?.role === "tool") return true;
177
- if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result");
185
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result" || block?.type === "tool-result");
178
186
  return false;
179
187
  }
180
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.10.2",
3
+ "version": "1.10.3",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [