dsh-agy-cli 0.0.0-stage → 0.12.0
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/LICENSE +30 -0
- package/README.en.md +27 -0
- package/README.md +94 -2
- package/agents/dsh-agy-image-view.md +17 -0
- package/agents/dsh-agy-mcp-executor.md +32 -0
- package/agents/dsh-agy-read-only.md +20 -0
- package/agents/dsh-agy-tool-free.md +16 -0
- package/agents/dsh-agy-workspace-write.md +23 -0
- package/bin/dsh-agy-cli.js +9 -0
- package/bin/win32-x64/agy-launcher.exe +0 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +4259 -0
- package/lib/agent-installer.d.ts +66 -0
- package/lib/agent-installer.js +147 -0
- package/lib/agent-presets.d.ts +42 -0
- package/lib/agent-presets.js +130 -0
- package/lib/agents-cli.d.ts +3 -0
- package/lib/agents-cli.js +110 -0
- package/lib/agy/diagnostics.d.ts +29 -0
- package/lib/agy/diagnostics.js +109 -0
- package/lib/agy/errors.d.ts +20 -0
- package/lib/agy/errors.js +77 -0
- package/lib/agy/experimental-transport.d.ts +79 -0
- package/lib/agy/experimental-transport.js +612 -0
- package/lib/agy/keepalive.d.ts +140 -0
- package/lib/agy/keepalive.js +424 -0
- package/lib/agy/limiter.d.ts +32 -0
- package/lib/agy/limiter.js +111 -0
- package/lib/agy/log.d.ts +214 -0
- package/lib/agy/log.js +503 -0
- package/lib/agy/models.d.ts +49 -0
- package/lib/agy/models.js +203 -0
- package/lib/agy/overrides.d.ts +17 -0
- package/lib/agy/overrides.js +73 -0
- package/lib/agy/parser.d.ts +102 -0
- package/lib/agy/parser.js +492 -0
- package/lib/agy/persistent-transport.d.ts +101 -0
- package/lib/agy/persistent-transport.js +753 -0
- package/lib/agy/process.d.ts +144 -0
- package/lib/agy/process.js +452 -0
- package/lib/agy/redact.d.ts +3 -0
- package/lib/agy/redact.js +24 -0
- package/lib/agy/stream-protocol.d.ts +50 -0
- package/lib/agy/stream-protocol.js +156 -0
- package/lib/agy/windows-launcher.d.ts +21 -0
- package/lib/agy/windows-launcher.js +76 -0
- package/lib/diagnostics.d.ts +78 -0
- package/lib/diagnostics.js +162 -0
- package/lib/doctor.d.ts +138 -0
- package/lib/doctor.js +923 -0
- package/lib/dsh/context.d.ts +133 -0
- package/lib/dsh/context.js +352 -0
- package/lib/dsh-home.d.ts +6 -0
- package/lib/dsh-home.js +11 -0
- package/lib/index.d.ts +43 -0
- package/lib/index.js +256 -0
- package/lib/mcp/agy-registration.d.ts +70 -0
- package/lib/mcp/agy-registration.js +245 -0
- package/lib/mcp/bridge.d.ts +73 -0
- package/lib/mcp/bridge.js +295 -0
- package/lib/mcp/endpoint.d.ts +41 -0
- package/lib/mcp/endpoint.js +142 -0
- package/lib/mcp/protocol.d.ts +107 -0
- package/lib/mcp/protocol.js +197 -0
- package/lib/mcp/tool-projection.d.ts +64 -0
- package/lib/mcp/tool-projection.js +168 -0
- package/lib/provider/agy.d.ts +153 -0
- package/lib/provider/agy.js +2159 -0
- package/lib/provider/config.d.ts +226 -0
- package/lib/provider/config.js +294 -0
- package/lib/provider/error-codes.d.ts +22 -0
- package/lib/provider/error-codes.js +29 -0
- package/lib/provider/image-bridge.d.ts +60 -0
- package/lib/provider/image-bridge.js +290 -0
- package/lib/provider/mock.d.ts +15 -0
- package/lib/provider/mock.js +78 -0
- package/lib/provider/prompt-budget.d.ts +45 -0
- package/lib/provider/prompt-budget.js +596 -0
- package/lib/provider/serialize.d.ts +15 -0
- package/lib/provider/serialize.js +82 -0
- package/lib/provider/tool-protocol.d.ts +80 -0
- package/lib/provider/tool-protocol.js +848 -0
- package/lib/provider/tool-schema-file.d.ts +11 -0
- package/lib/provider/tool-schema-file.js +27 -0
- package/lib/session/file-store.d.ts +56 -0
- package/lib/session/file-store.js +186 -0
- package/lib/session/store.d.ts +49 -0
- package/lib/session/store.js +62 -0
- package/lib/web/router.d.ts +19 -0
- package/lib/web/router.js +906 -0
- package/package.json +112 -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
|