dsh-agy-cli 0.0.0-stage → 0.12.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.
Files changed (91) hide show
  1. package/LICENSE +31 -0
  2. package/README.en.md +48 -0
  3. package/README.md +67 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +445 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +940 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +54 -0
  56. package/lib/index.js +267 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +994 -0
  91. package/package.json +115 -4
@@ -0,0 +1,596 @@
1
+ import { getAgyUserMessageByteLength } from '../agy/stream-protocol.js';
2
+ export const DEFAULT_INPUT_FRAME_LIMIT_BYTES = 256 * 1024;
3
+ // AGY's text input is practically truncated below the stream frame limit.
4
+ // Keep a margin below the observed ~192 KiB cutoff so the current turn and
5
+ // the DSH protocol cannot disappear from the tail of AGY's input.
6
+ export const DEFAULT_AGY_PROMPT_CONTENT_LIMIT_BYTES = 180 * 1024;
7
+ // AGY 1.1.18's DSH-owned structured envelope appeared to have an additional
8
+ // effective ceiling from the injected Agent/system contract: prompts around
9
+ // 61 KiB exited without a usable structured response, so 56 KiB was pinned as
10
+ // the largest tested safe band. AGY 1.3.2 no longer shows that ceiling — the
11
+ // envelope came back parseable at 58 KiB and again at 136 KiB of prompt
12
+ // (frame 141,879 bytes), both exit 0. The old bound is now smaller than the
13
+ // protocol text alone on a Desktop profile: oneOf branches over ~59 tools
14
+ // serialize to ~58 KiB, so every dsh-owned request started by tripping
15
+ // AGY_INPUT_TOO_LARGE and by dropping history messages instead. 128 KiB keeps
16
+ // ~2.2x headroom over that fixed cost and stays well under the 256 KiB frame.
17
+ export const DEFAULT_DSH_TOOL_PROMPT_CONTENT_LIMIT_BYTES = 128 * 1024;
18
+ export const DEFAULT_MAX_SINGLE_TOOL_RESULT_BYTES = 32 * 1024;
19
+ export const DEFAULT_MAX_HISTORICAL_TOOL_RESULT_BYTES = 96 * 1024;
20
+ const TOOL_RESULT_MARKER = '[DSH TOOL RESULT]';
21
+ const TRUNCATION_SUFFIX = '\n[DSH TOOL RESULT TRUNCATED]\n';
22
+ const OMITTED_TOOL_RESULT = `${TOOL_RESULT_MARKER}\n[DSH TOOL RESULT OMITTED FROM HISTORY BUDGET]\n`;
23
+ const DSH_PROTOCOL_MARKER = '=== DSH TOOL PROTOCOL V1 ===';
24
+ const HISTORY_COMPACTION_MARKER = '=== USER ===\n[DSH HISTORY COMPACTED: older messages were omitted to stay within AGY input limits. The latest request and tool result are authoritative; re-read files with DSH tools when needed.]';
25
+ const OMITTED_HISTORY_SECTION = '=== USER ===\n[DSH HISTORY COMPACTED: this message was omitted to stay within AGY input limits.]';
26
+ export class AgyPromptBudgetError extends Error {
27
+ actualBytes;
28
+ limitBytes;
29
+ code = 'AGY_INPUT_TOO_LARGE';
30
+ constructor(actualBytes, limitBytes) {
31
+ super(`AGY input exceeds the configured limit (${actualBytes} > ${limitBytes} bytes)`);
32
+ this.actualBytes = actualBytes;
33
+ this.limitBytes = limitBytes;
34
+ this.name = 'AgyPromptBudgetError';
35
+ }
36
+ }
37
+ /**
38
+ * Matches a serialized section header at the start of a line. The `m` flag is
39
+ * load-bearing: {@link splitSerializedHistoryWithOffsets} reuses this source with
40
+ * a `g` flag over a whole multi-section history, and without `m` the `^` anchor
41
+ * only matches offset 0, so the split silently returns a single section and every
42
+ * clip lands on the entire history instead of at a message boundary.
43
+ */
44
+ const SECTION_HEADER_PATTERN = /^(=== (?:USER|ASSISTANT|TOOL|SYSTEM) ===\n)/m;
45
+ function byteLength(value) {
46
+ return Buffer.byteLength(value, 'utf8');
47
+ }
48
+ function prefixForBytes(value, maxBytes) {
49
+ if (maxBytes <= 0)
50
+ return '';
51
+ if (byteLength(value) <= maxBytes)
52
+ return value;
53
+ let low = 0;
54
+ let high = value.length;
55
+ while (low < high) {
56
+ const middle = Math.ceil((low + high) / 2);
57
+ if (byteLength(value.slice(0, middle)) <= maxBytes)
58
+ low = middle;
59
+ else
60
+ high = middle - 1;
61
+ }
62
+ return value.slice(0, low);
63
+ }
64
+ function truncateUtf8(value, maxBytes) {
65
+ if (byteLength(value) <= maxBytes)
66
+ return value;
67
+ const suffixBytes = byteLength(TRUNCATION_SUFFIX);
68
+ if (maxBytes <= suffixBytes)
69
+ return prefixForBytes(value, maxBytes);
70
+ return `${prefixForBytes(value, maxBytes - suffixBytes)}${TRUNCATION_SUFFIX}`;
71
+ }
72
+ function suffixForBytes(value, maxBytes) {
73
+ if (maxBytes <= 0)
74
+ return '';
75
+ if (byteLength(value) <= maxBytes)
76
+ return value;
77
+ let low = 0;
78
+ let high = value.length;
79
+ while (low < high) {
80
+ const middle = Math.floor((low + high) / 2);
81
+ if (byteLength(value.slice(middle)) <= maxBytes)
82
+ high = middle;
83
+ else
84
+ low = middle + 1;
85
+ }
86
+ return value.slice(low);
87
+ }
88
+ /**
89
+ * Keep the newest content of a serialized history section when the section is
90
+ * too large to fit on its own. Side-channel requests (a session-title refresh
91
+ * over the whole conversation) arrive as a single section, so section-level
92
+ * eviction cannot help — only clipping that section can. The tail wins because
93
+ * in a conversation tail the newest turn lives; the marker tells AGY the head
94
+ * was clipped rather than that nothing was said.
95
+ */
96
+ function clampSectionToBytes(value, maxBytes) {
97
+ if (byteLength(value) <= maxBytes)
98
+ return value;
99
+ const headerMatch = SECTION_HEADER_PATTERN.exec(value);
100
+ const header = headerMatch?.[1] ?? '';
101
+ const marker = '[DSH OLDER CONTEXT CLIPPED FROM HEAD: THIS SECTION WAS TOO LARGE]\n';
102
+ const bodyBudget = maxBytes - byteLength(header) - byteLength(marker);
103
+ if (bodyBudget <= 0)
104
+ return suffixForBytes(value, maxBytes);
105
+ return `${header}${marker}${suffixForBytes(value.slice(header.length), bodyBudget)}`;
106
+ }
107
+ // Fraction of the budget a clipped prompt may occupy. The remainder is the
108
+ // margin that keeps a resume-mode bootstrap from being pinned to the exact
109
+ // limit; see `clampSectionsToBytes`.
110
+ const CLIP_TARGET_RATIO = 0.9;
111
+ function clipHeadroomBytes(maxPromptBytes) {
112
+ return Math.max(128, Math.floor(maxPromptBytes * CLIP_TARGET_RATIO));
113
+ }
114
+ /**
115
+ * Run a *fixed* byte budget over the serialized sections, keeping the newest
116
+ * bytes of the conversation. Unlike {@link compactSerializedHistory}'s
117
+ * section-level eviction — which drops a whole message as soon as one no longer
118
+ * fits — this clips inside a section. It is the fallback for the bootstrap of a
119
+ * resumed session: `resume` mode sends the entire DSH history as a single
120
+ * prompt, so by the time a conversation is long enough the request is clamped
121
+ * on *every* turn. Section-level eviction alone left it pinned at the exact
122
+ * limit with the newest message dropped, which made the next AGY request miss
123
+ * its upstream prompt cache and re-read the whole conversation.
124
+ *
125
+ * The cut is anchored to a message boundary, never to the limit. Anchoring to a
126
+ * message start means the clipped prompt only changes when a new message is
127
+ * appended, so the prefix AGY already cached stays byte-identical while the
128
+ * conversation grows within the message at the boundary.
129
+ */
130
+ function clampSectionsToBytes(sections, maxPromptBytes) {
131
+ if (sections.length === 0)
132
+ return { prompt: '', omitted: 0 };
133
+ const newestIndex = sections.length - 1;
134
+ const budget = clipHeadroomBytes(maxPromptBytes);
135
+ // Walk backwards from the newest message to find a window that fits the budget.
136
+ //
137
+ // Clipping the oldest rather than the newest is what keeps the answer possible:
138
+ // the newest section is the turn being asked, and a half-clipped question
139
+ // produces a wrong answer instead of a smaller one. Only the head of the
140
+ // retained window is sacrificial.
141
+ const firstKept = (() => {
142
+ let used = 0;
143
+ let start = newestIndex;
144
+ for (let index = newestIndex; index >= 0; index -= 1) {
145
+ const size = byteLength(sections[index].text);
146
+ const separator = index === newestIndex ? 0 : 2;
147
+ if (used + size + separator > budget && index !== newestIndex)
148
+ break;
149
+ used += size + separator;
150
+ start = index;
151
+ }
152
+ return start;
153
+ })();
154
+ // Snap the window start down to a power-of-two stride.
155
+ //
156
+ // Without this the start drifts outward by exactly one message per turn as the
157
+ // conversation grows, so the retained bytes differ on every request and the
158
+ // upstream prompt cache can never hit (measured: 101 bytes shared out of
159
+ // 165243, i.e. effectively nothing). Snapping to 1, 2, 4, 8, ... messages means
160
+ // the start only moves when the conversation nearly doubles the window, so
161
+ // between snaps every turn emits the previous bytes plus an appended tail —
162
+ // which is exactly what an append-only cache needs. It also keeps the window
163
+ // contiguous, so no message is ever skipped.
164
+ //
165
+ // The boundary is quantized *before* packing, not after: adjusting an
166
+ // already-packed window only moves its first kept message, which is the byte
167
+ // the cache comparison fails on.
168
+ const packedStart = firstKept;
169
+ const quantizedStart = (() => {
170
+ if (packedStart <= 0)
171
+ return 0;
172
+ let stride = 1;
173
+ while (stride * 2 <= packedStart)
174
+ stride *= 2;
175
+ return packedStart - (packedStart % stride);
176
+ })();
177
+ // Pack forward from the quantized boundary so the retained window always
178
+ // *starts* at that boundary.
179
+ //
180
+ // Walking backwards from the newest message instead packs as much of the tail
181
+ // as fits, and when the conversation grows the oldest surviving message moves
182
+ // by one every turn — so the first bytes of the prompt change on every request
183
+ // and the upstream cache can never match. Starting at the boundary makes the
184
+ // first kept message a function of the boundary alone, and the tail is what
185
+ // absorbs growth: within one stride the emitted bytes are the previous bytes
186
+ // plus an appended tail.
187
+ //
188
+ // The newest message is reserved first. It is the turn being asked, and it must
189
+ // survive whole even when older messages would otherwise take its budget; that
190
+ // also keeps the tail cut point still, because the newest message is the only
191
+ // section that grows between turns.
192
+ const newest = sections[newestIndex];
193
+ const newestWhole = byteLength(newest.text);
194
+ const olderBudget = Math.max(0, budget - newestWhole - 2);
195
+ const kept = [];
196
+ let used = 0;
197
+ for (let index = quantizedStart; index < newestIndex; index += 1) {
198
+ const section = sections[index];
199
+ const size = byteLength(section.text);
200
+ const separator = kept.length === 0 ? 0 : 2;
201
+ if (used + size + separator > olderBudget)
202
+ break;
203
+ used += size + separator;
204
+ kept.push(section.text);
205
+ }
206
+ // Nothing from the boundary fit (a single huge message sits right at it).
207
+ // Fall back to clipping that one message so the caller still gets an
208
+ // answerable prompt instead of an empty window.
209
+ const omitted = quantizedStart + kept.length;
210
+ const newestFits = used + newestWhole + (kept.length > 0 ? 2 : 0) <= budget;
211
+ const newestText = newestFits
212
+ ? newest.text
213
+ : clampSectionToBytes(newest.text, Math.max(1, budget - (kept.length > 0 ? byteLength(kept.join('\n\n')) + 2 : 0)));
214
+ const parts = [];
215
+ if (omitted > 0)
216
+ parts.push(OMITTED_HISTORY_SECTION);
217
+ parts.push(...kept, newestText);
218
+ return { prompt: parts.join('\n\n'), omitted };
219
+ }
220
+ function collectToolResultSegments(prompt) {
221
+ const segments = [];
222
+ let searchFrom = 0;
223
+ while (searchFrom < prompt.length) {
224
+ const start = prompt.indexOf(TOOL_RESULT_MARKER, searchFrom);
225
+ if (start < 0)
226
+ break;
227
+ const nextSection = prompt.indexOf('\n=== ', start + TOOL_RESULT_MARKER.length);
228
+ const end = nextSection < 0 ? prompt.length : nextSection;
229
+ segments.push({ start, end, text: prompt.slice(start, end) });
230
+ searchFrom = end;
231
+ }
232
+ return segments;
233
+ }
234
+ function replaceSegments(prompt, segments, replacements) {
235
+ if (segments.length === 0)
236
+ return prompt;
237
+ let output = '';
238
+ let cursor = 0;
239
+ segments.forEach((segment, index) => {
240
+ output += prompt.slice(cursor, segment.start);
241
+ output += replacements[index] ?? segment.text;
242
+ cursor = segment.end;
243
+ });
244
+ return output + prompt.slice(cursor);
245
+ }
246
+ function truncateToolResults(prompt, maxSingleToolResultBytes, maxHistoricalToolResultBytes) {
247
+ let candidate = prompt;
248
+ let truncatedCount = 0;
249
+ let segments = collectToolResultSegments(candidate);
250
+ if (segments.length === 0)
251
+ return { prompt: candidate, truncatedCount };
252
+ const replacements = segments.map(segment => {
253
+ const replacement = truncateUtf8(segment.text, maxSingleToolResultBytes);
254
+ if (replacement !== segment.text)
255
+ truncatedCount += 1;
256
+ return replacement;
257
+ });
258
+ candidate = replaceSegments(candidate, segments, replacements);
259
+ // Once each result has its deterministic per-result bound, enforce the
260
+ // aggregate budget by evicting complete oldest segments. Shrinking an
261
+ // already-retained segment in response to a later result changes the bytes
262
+ // of the old prefix on every turn and destroys upstream prompt-cache
263
+ // eligibility. The fixed omission marker keeps the serialized section
264
+ // shape valid while making the eviction decision stable and observable.
265
+ let totalBytes = collectToolResultSegments(candidate)
266
+ .reduce((total, segment) => total + byteLength(segment.text), 0);
267
+ while (totalBytes > maxHistoricalToolResultBytes) {
268
+ segments = collectToolResultSegments(candidate);
269
+ const segment = segments[0];
270
+ if (segment === undefined)
271
+ break;
272
+ const currentBytes = byteLength(segment.text);
273
+ const replacement = totalBytes - currentBytes + byteLength(OMITTED_TOOL_RESULT) <= maxHistoricalToolResultBytes
274
+ ? OMITTED_TOOL_RESULT
275
+ : '';
276
+ candidate = replaceSegments(candidate, [segment], [replacement]);
277
+ totalBytes -= currentBytes - byteLength(replacement);
278
+ truncatedCount += 1;
279
+ }
280
+ return { prompt: candidate, truncatedCount };
281
+ }
282
+ /** Return the immutable prefix before the first dynamic DSH history section. */
283
+ export function stableAgyPromptPrefix(prompt) {
284
+ const firstMessage = prompt.search(/\n\n=== (?:USER|ASSISTANT|TOOL) ===\n/);
285
+ if (firstMessage >= 0)
286
+ return prompt.slice(0, firstMessage).trimEnd();
287
+ if (/^=== (?:USER|ASSISTANT|TOOL) ===\n/.test(prompt))
288
+ return '';
289
+ return prompt;
290
+ }
291
+ function splitHistoryAndProtocol(prompt) {
292
+ const markerIndex = prompt.indexOf(DSH_PROTOCOL_MARKER);
293
+ if (markerIndex < 0)
294
+ return { history: prompt, protocol: '', protocolBeforeHistory: false };
295
+ const dynamicMarker = prompt.search(/\n\n=== (?:USER|ASSISTANT|TOOL) ===\n/);
296
+ // DSH-owned prompts place the immutable system/protocol prefix before the
297
+ // dynamic history so the prefix can remain byte-stable for upstream cache
298
+ // matching. Split that layout back into history + protected protocol for
299
+ // budgeting; otherwise a prefix protocol would make history appear empty.
300
+ if (dynamicMarker >= 0 && markerIndex < dynamicMarker) {
301
+ const stablePrefix = prompt.slice(0, markerIndex).trimEnd();
302
+ const protocolStart = markerIndex;
303
+ const protocolEndMarker = '\n=== END DSH TOOL PROTOCOL V1 ===';
304
+ const protocolEnd = prompt.indexOf(protocolEndMarker, protocolStart);
305
+ const protocolEndOffset = protocolEnd < 0
306
+ ? dynamicMarker
307
+ : protocolEnd + protocolEndMarker.length;
308
+ const protocol = prompt.slice(protocolStart, protocolEndOffset).trim();
309
+ const afterProtocol = prompt.slice(protocolEndOffset).trimStart();
310
+ const repairIndex = afterProtocol.indexOf('\n\n=== DSH TOOL PROTOCOL REPAIR ===');
311
+ const historyTail = repairIndex < 0
312
+ ? afterProtocol
313
+ : afterProtocol.slice(0, repairIndex).trimEnd();
314
+ const repair = repairIndex < 0 ? '' : afterProtocol.slice(repairIndex + 2).trim();
315
+ const history = [stablePrefix, historyTail].filter(value => value.length > 0).join('\n\n');
316
+ return {
317
+ history,
318
+ protocol: [protocol, repair].filter(value => value.length > 0).join('\n\n'),
319
+ protocolBeforeHistory: true,
320
+ };
321
+ }
322
+ // The protocol is intentionally treated as an immutable suffix. Repair
323
+ // instructions follow the V1 marker and therefore remain protected too.
324
+ const historyEnd = prompt.slice(0, markerIndex).trimEnd();
325
+ return {
326
+ history: historyEnd,
327
+ protocol: prompt.slice(markerIndex),
328
+ protocolBeforeHistory: false,
329
+ };
330
+ }
331
+ function splitSerializedHistory(history) {
332
+ return splitSerializedHistoryWithOffsets(history).map(section => section.text);
333
+ }
334
+ /**
335
+ * Split a serialized history into sections, reporting each section's byte
336
+ * offset from the start of `history`. The offsets are what makes a *stable*
337
+ * clip possible: clamping the newest section by its own start rather than by
338
+ * the limit produces identical bytes on every turn, so a long conversation
339
+ * keeps hitting AGY's upstream prompt cache instead of re-billing the prefix
340
+ * each time.
341
+ */
342
+ function splitSerializedHistoryWithOffsets(history) {
343
+ if (history.length === 0)
344
+ return [];
345
+ const sections = [];
346
+ // Rebuild with `gm`, not `g`: `.source` carries only the pattern, so the `m`
347
+ // flag on SECTION_HEADER_PATTERN is lost here and `^` would then match offset 0
348
+ // alone, collapsing the whole history into one section.
349
+ const header = new RegExp(SECTION_HEADER_PATTERN.source, 'gm');
350
+ let cursor = 0;
351
+ let match = header.exec(history);
352
+ while (match !== null) {
353
+ const start = match.index;
354
+ const leading = start === 0 ? 0 : 2;
355
+ if (start > cursor) {
356
+ sections.push({
357
+ text: history.slice(cursor, start - leading),
358
+ start: cursor,
359
+ startOfLine: true,
360
+ leadingBlankLine: leading === 2,
361
+ byteOffset: byteLength(history.slice(0, cursor)),
362
+ });
363
+ }
364
+ cursor = start;
365
+ match = header.exec(history);
366
+ }
367
+ if (cursor < history.length) {
368
+ sections.push({
369
+ text: history.slice(cursor),
370
+ start: cursor,
371
+ startOfLine: true,
372
+ leadingBlankLine: false,
373
+ byteOffset: byteLength(history.slice(0, cursor)),
374
+ });
375
+ }
376
+ return sections;
377
+ }
378
+ function joinHistorySections(sections, protocol, protocolBeforeHistory = false) {
379
+ const history = sections.join('\n\n');
380
+ if (protocol.length === 0)
381
+ return history;
382
+ if (history.length === 0)
383
+ return protocol;
384
+ if (!protocolBeforeHistory)
385
+ return `${history}\n\n${protocol}`;
386
+ if (/^=== (?:USER|ASSISTANT|TOOL) ===\n/.test(history)) {
387
+ return `${protocol}\n\n${history}`;
388
+ }
389
+ const firstDynamic = history.search(/\n\n=== (?:USER|ASSISTANT|TOOL) ===\n/);
390
+ if (firstDynamic < 0) {
391
+ return /^=== (?:USER|ASSISTANT|TOOL) ===\n/.test(history)
392
+ ? `${protocol}\n\n${history}`
393
+ : `${history}\n\n${protocol}`;
394
+ }
395
+ return `${history.slice(0, firstDynamic)}\n\n${protocol}\n\n${history.slice(firstDynamic + 2)}`;
396
+ }
397
+ function compactSerializedHistory(prompt, maxPromptBytes, oversizedSectionPolicy = 'reject') {
398
+ if (byteLength(prompt) <= maxPromptBytes) {
399
+ return { prompt, compacted: false, omittedMessageCount: 0 };
400
+ }
401
+ const { history, protocol, protocolBeforeHistory } = splitHistoryAndProtocol(prompt);
402
+ const sections = splitSerializedHistory(history);
403
+ if (sections.length === 0) {
404
+ throw new AgyPromptBudgetError(byteLength(prompt), maxPromptBytes);
405
+ }
406
+ const sectionAt = (index) => sections[index] ?? '';
407
+ const latestIndex = sections.length - 1;
408
+ const systemIndexes = sections
409
+ .map((section, index) => section.startsWith('=== SYSTEM ===\n') ? index : -1)
410
+ .filter(index => index >= 0);
411
+ const selected = new Set([latestIndex]);
412
+ // Keep the first system instruction when it fits. If it does not fit, the
413
+ // current turn still wins; the protocol is always kept separately.
414
+ const firstSystemIndex = systemIndexes[0];
415
+ if (firstSystemIndex !== undefined)
416
+ selected.add(firstSystemIndex);
417
+ const omittedMarker = HISTORY_COMPACTION_MARKER;
418
+ const fits = (indexes, includeMarker) => {
419
+ const ordered = [...indexes].sort((left, right) => left - right);
420
+ const candidateSections = includeMarker
421
+ ? [
422
+ ...ordered.filter(index => index !== latestIndex && index !== firstSystemIndex)
423
+ .map(sectionAt),
424
+ omittedMarker,
425
+ ...(firstSystemIndex !== undefined && ordered.includes(firstSystemIndex)
426
+ ? [sectionAt(firstSystemIndex)]
427
+ : []),
428
+ sectionAt(latestIndex),
429
+ ]
430
+ : ordered.map(sectionAt);
431
+ return byteLength(joinHistorySections(candidateSections, protocol, protocolBeforeHistory)) <= maxPromptBytes;
432
+ };
433
+ // Rebuild the mandatory set in a stable order. The marker is inserted as a
434
+ // synthetic user section so the model knows that older context was removed.
435
+ if (!fits(selected, true))
436
+ selected.delete(firstSystemIndex ?? -1);
437
+ if (!fits(selected, true))
438
+ selected.clear();
439
+ selected.add(latestIndex);
440
+ // Add recent history first, newest-first, and stop at the first message that
441
+ // does not fit. Stopping rather than skipping is what keeps the retained window
442
+ // contiguous: a gap would renumber the conversation and change every later
443
+ // byte, so the upstream cache could never hit.
444
+ for (let index = latestIndex - 1; index >= 0; index -= 1) {
445
+ const candidate = new Set(selected);
446
+ candidate.add(index);
447
+ if (!fits(candidate, true))
448
+ break;
449
+ selected.add(index);
450
+ }
451
+ // If the first system section was skipped by the recent-history pass, add it
452
+ // only when doing so does not displace the latest section or protocol.
453
+ if (firstSystemIndex !== undefined && !selected.has(firstSystemIndex)) {
454
+ const candidate = new Set(selected);
455
+ candidate.add(firstSystemIndex);
456
+ if (fits(candidate, true))
457
+ selected.add(firstSystemIndex);
458
+ }
459
+ const ordered = [...selected].sort((left, right) => left - right);
460
+ const preservedSections = ordered.map(sectionAt);
461
+ let omittedMessageCount = sections.length - selected.size;
462
+ const preservedWithoutMarker = preservedSections.filter(section => !section.startsWith('=== USER ===\n[DSH HISTORY COMPACTED:'));
463
+ const latestPreserved = preservedWithoutMarker[preservedWithoutMarker.length - 1];
464
+ const withMarker = latestPreserved === undefined
465
+ ? [omittedMarker]
466
+ : [
467
+ ...preservedWithoutMarker.slice(0, -1),
468
+ omittedMarker,
469
+ latestPreserved,
470
+ ];
471
+ // The marker is useful for diagnosis, but it must never cause the latest
472
+ // section or the protocol to be lost. If an unusually small test limit
473
+ // leaves no room, omit the marker rather than the current request.
474
+ let compactedPrompt = joinHistorySections(withMarker, protocol, protocolBeforeHistory);
475
+ if (byteLength(compactedPrompt) > maxPromptBytes) {
476
+ compactedPrompt = joinHistorySections(preservedSections, protocol, protocolBeforeHistory);
477
+ }
478
+ const overflowed = () => byteLength(compactedPrompt) > maxPromptBytes;
479
+ // Eviction packs the prompt right up to the limit and stops as soon as it is
480
+ // under it, so a long conversation lands on a prompt of 184192/184320 bytes
481
+ // with no headroom. That is still a miss for the upstream prompt cache: the
482
+ // next turn evicts one more message, which shifts the retained head and
483
+ // rewrites the whole prefix (measured: 7609 of 184192 bytes shared across one
484
+ // appended turn). Clipping to a fixed fraction of the budget leaves room for
485
+ // the next turn to grow into instead of evicting, and anchors the cut to a
486
+ // message boundary so the bytes before it stay put.
487
+ const withinHeadroom = () => byteLength(compactedPrompt) > clipHeadroomBytes(maxPromptBytes);
488
+ if ((overflowed() || withinHeadroom()) && oversizedSectionPolicy === 'clamp') {
489
+ // A side channel (a session-title or auto-review summary over the whole
490
+ // conversation) has no protocol to protect and arrives as one oversized
491
+ // dump, so section-level eviction cannot help. Clipping every retained
492
+ // section to what remains of the budget still yields a usable answer over
493
+ // the newest content, which beats AGY_INPUT_TOO_LARGE. With several
494
+ // sections retained this only builds an n×-budget intermediate string
495
+ // that the headroom pass below re-clamps anyway, so it runs for the
496
+ // single-section shape it was written for and defers the rest.
497
+ const historyBudget = Math.max(128, maxPromptBytes - byteLength(protocol) - 2);
498
+ if (selected.size <= 1) {
499
+ compactedPrompt = joinHistorySections([...selected].sort((left, right) => left - right)
500
+ .map(index => clampSectionToBytes(sectionAt(index), historyBudget)), protocol, protocolBeforeHistory);
501
+ }
502
+ }
503
+ if ((overflowed() || withinHeadroom()) && !protocolBeforeHistory) {
504
+ // A main turn that eviction cannot bring comfortably under the limit. Every
505
+ // request in `resume` mode ships the complete DSH history, so once a
506
+ // conversation is long enough this happens on *every* turn: eviction packs
507
+ // the prompt right up to the limit, the next turn evicts one more message,
508
+ // and the retained head shifts (measured: 7609 of 184192 bytes shared across
509
+ // one appended turn), so AGY never hits its upstream prompt cache. Clipping
510
+ // to a fixed fraction of the budget leaves room for the next turn to grow
511
+ // into instead of evicting, and anchors the cut to a message boundary so the
512
+ // bytes before it stay put.
513
+ //
514
+ // A prompt that carries the tool protocol before its history is the DSH-owned
515
+ // main turn. There the protocol must stay byte-stable and the user's own
516
+ // newest message must not be silently shortened, so the strict `reject`
517
+ // contract below still applies and a genuine overflow is a clean error.
518
+ const { history: rawHistory, protocol: historyProtocol, protocolBeforeHistory: protocolFirst } = splitHistoryAndProtocol(prompt);
519
+ const sections = splitSerializedHistoryWithOffsets(rawHistory);
520
+ // Only a multi-message history has a boundary to clip at. One oversized
521
+ // section is the shape the strict callers refuse on purpose, so leave it to
522
+ // the throw below rather than silently clipping the only thing there is.
523
+ if (sections.length > 1) {
524
+ const budget = Math.max(128, maxPromptBytes - byteLength(historyProtocol) - 2);
525
+ const clipped = clampSectionsToBytes(sections, budget);
526
+ const nextPrompt = joinHistorySections([clipped.prompt], historyProtocol, protocolFirst);
527
+ if (byteLength(nextPrompt) <= maxPromptBytes) {
528
+ compactedPrompt = nextPrompt;
529
+ omittedMessageCount = Math.max(omittedMessageCount, clipped.omitted);
530
+ }
531
+ }
532
+ }
533
+ if (byteLength(compactedPrompt) > maxPromptBytes) {
534
+ // The only way through is a clip inside the newest section, which the
535
+ // `reject` callers deliberately refuse.
536
+ throw new AgyPromptBudgetError(byteLength(compactedPrompt), maxPromptBytes);
537
+ }
538
+ return {
539
+ prompt: compactedPrompt,
540
+ compacted: true,
541
+ omittedMessageCount,
542
+ };
543
+ }
544
+ function fitPromptToFrame(prompt, maxFrameBytes) {
545
+ let candidate = prompt;
546
+ while (getAgyUserMessageByteLength(candidate) > maxFrameBytes) {
547
+ const frameBytes = getAgyUserMessageByteLength(candidate);
548
+ const segments = collectToolResultSegments(candidate);
549
+ const segment = segments.find(item => byteLength(item.text) > byteLength(TOOL_RESULT_MARKER));
550
+ if (segment === undefined)
551
+ throw new AgyPromptBudgetError(frameBytes, maxFrameBytes);
552
+ const currentBytes = byteLength(segment.text);
553
+ const targetBytes = Math.max(byteLength(TOOL_RESULT_MARKER), currentBytes - (frameBytes - maxFrameBytes));
554
+ const replacement = truncateUtf8(segment.text, targetBytes);
555
+ if (replacement === segment.text)
556
+ throw new AgyPromptBudgetError(frameBytes, maxFrameBytes);
557
+ candidate = replaceSegments(candidate, [segment], [replacement]);
558
+ }
559
+ return candidate;
560
+ }
561
+ /** Bound historical DSH tool output before the prompt is sent to AGY. */
562
+ export function boundAgyPrompt(prompt, options) {
563
+ if (typeof prompt !== 'string' || prompt.length === 0) {
564
+ throw new AgyPromptBudgetError(0, options.maxFrameBytes);
565
+ }
566
+ if (!Number.isSafeInteger(options.maxFrameBytes) || options.maxFrameBytes < 128) {
567
+ throw new RangeError('AGY input frame limit must be an integer of at least 128 bytes');
568
+ }
569
+ const maxSingleToolResultBytes = options.maxSingleToolResultBytes
570
+ ?? DEFAULT_MAX_SINGLE_TOOL_RESULT_BYTES;
571
+ const maxHistoricalToolResultBytes = options.maxHistoricalToolResultBytes
572
+ ?? DEFAULT_MAX_HISTORICAL_TOOL_RESULT_BYTES;
573
+ const maxPromptBytes = options.maxPromptBytes
574
+ ?? DEFAULT_AGY_PROMPT_CONTENT_LIMIT_BYTES;
575
+ if (!Number.isSafeInteger(maxPromptBytes) || maxPromptBytes < 128) {
576
+ throw new RangeError('AGY prompt content limit must be an integer of at least 128 bytes');
577
+ }
578
+ const compacted = truncateToolResults(prompt, maxSingleToolResultBytes, maxHistoricalToolResultBytes);
579
+ const history = compactSerializedHistory(compacted.prompt, maxPromptBytes, options.oversizedSectionPolicy ?? 'reject');
580
+ const boundedPrompt = fitPromptToFrame(history.prompt, options.maxFrameBytes);
581
+ const segments = collectToolResultSegments(boundedPrompt);
582
+ const frameBytes = getAgyUserMessageByteLength(boundedPrompt);
583
+ return {
584
+ prompt: boundedPrompt,
585
+ promptBytes: byteLength(boundedPrompt),
586
+ promptLimitBytes: maxPromptBytes,
587
+ frameBytes,
588
+ frameLimitBytes: options.maxFrameBytes,
589
+ toolResultCount: segments.length,
590
+ largestToolResultBytes: segments.reduce((largest, segment) => Math.max(largest, byteLength(segment.text)), 0),
591
+ truncatedToolResultCount: compacted.truncatedCount,
592
+ historyCompacted: history.compacted,
593
+ omittedMessageCount: history.omittedMessageCount,
594
+ };
595
+ }
596
+ //# sourceMappingURL=prompt-budget.js.map
@@ -0,0 +1,15 @@
1
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm';
2
+ export type AgyPromptErrorCode = 'EMPTY_PROMPT' | 'UNSUPPORTED_CONTENT';
3
+ export declare class AgyPromptError extends Error {
4
+ readonly code: AgyPromptErrorCode;
5
+ constructor(message: string, code: AgyPromptErrorCode);
6
+ }
7
+ /** Serialize the DSH request into a deterministic, text-only AGY prompt. */
8
+ export declare function serializeAgyPrompt(options: Pick<GenerateOptions, 'messages' | 'system'>): string;
9
+ /**
10
+ * Serialize only the messages added after the previous assistant turn.
11
+ * AGY already owns the earlier turns when `--conversation` is used; sending
12
+ * the complete DSH history again would duplicate context and spend quota.
13
+ */
14
+ export declare function serializeAgyTurnPrompt(options: Pick<GenerateOptions, 'messages'>): string;
15
+ //# sourceMappingURL=serialize.d.ts.map