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.
- package/host/context-trim/surface.js +193 -9
- package/host/context-trim/view.js +12 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": [
|