@stratusagent/plugin-mcp 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -8
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -18
- package/dist/index.js.map +1 -1
- package/dist/normalize.d.ts +157 -6
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +809 -18
- package/dist/normalize.js.map +1 -1
- package/package.json +3 -3
package/dist/normalize.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { mkdir, open, realpath } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { BIDI_CONTROL_CHARACTERS } from '@stratusagent/core';
|
|
4
5
|
import { nameIdentifiesHandle } from '@stratusagent/plugins';
|
|
5
6
|
/**
|
|
6
7
|
* Turning what an MCP server sends back into what a Stratus tool returns:
|
|
@@ -26,6 +27,422 @@ export const sanitizeToolSegment = (raw) => {
|
|
|
26
27
|
};
|
|
27
28
|
/** Server keys are operator-chosen and become a name segment; held to the segment shape outright. */
|
|
28
29
|
export const SERVER_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
30
|
+
/**
|
|
31
|
+
* The longest tool description a server gets to put in front of the model.
|
|
32
|
+
* The same bound a skill's description has (`SKILL_DESCRIPTION_MAX_LENGTH`
|
|
33
|
+
* in `@stratusagent/agents`), for the same reason: a description is prose
|
|
34
|
+
* the model reads on every turn, written by a party the operator did not
|
|
35
|
+
* author, and a paragraph is a description while a page is instructions.
|
|
36
|
+
*/
|
|
37
|
+
export const BRIDGED_DESCRIPTION_MAX_LENGTH = 1024;
|
|
38
|
+
/** Control characters and the Unicode `Bidi_Control` set, spelled out. */
|
|
39
|
+
const DESCRIPTION_CONTROLS = new RegExp(`[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u2028\\u2029${BIDI_CONTROL_CHARACTERS}]`, 'g');
|
|
40
|
+
const spelledOut = (raw) => raw.replace(DESCRIPTION_CONTROLS, (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
41
|
+
/**
|
|
42
|
+
* A server's tool description as it reaches the registry — and, through
|
|
43
|
+
* it, every provider's tool block on every turn. The text is the server's,
|
|
44
|
+
* re-read on every reconnect, and `tools/list` is the one channel a
|
|
45
|
+
* server has that arrives looking like part of the harness rather than
|
|
46
|
+
* like a result. Two bounds, neither a filter: control characters and the
|
|
47
|
+
* Unicode bidi controls are spelled out the way memory entries are (a
|
|
48
|
+
* right-to-left override in a description is a description that reads
|
|
49
|
+
* differently to a person than to the model — escaped, it reads as what it
|
|
50
|
+
* is; the set is Unicode's `Bidi_Control` property, marks and the Arabic
|
|
51
|
+
* letter mark included, not only the overrides and isolates), and the
|
|
52
|
+
* length is capped with the cut announced. Newlines and tabs stay: a
|
|
53
|
+
* description is allowed to be several lines.
|
|
54
|
+
*/
|
|
55
|
+
export const bridgedDescription = (raw) => {
|
|
56
|
+
const escaped = spelledOut(raw);
|
|
57
|
+
// Counted and cut in code points, not UTF-16 units: an emoji is one
|
|
58
|
+
// character, and a cut inside a surrogate pair is a malformed string a
|
|
59
|
+
// provider may reject.
|
|
60
|
+
const characters = Array.from(escaped);
|
|
61
|
+
if (characters.length <= BRIDGED_DESCRIPTION_MAX_LENGTH) {
|
|
62
|
+
return escaped;
|
|
63
|
+
}
|
|
64
|
+
const marker = ` … [description truncated by stratus: ${characters.length} characters]`;
|
|
65
|
+
return `${characters.slice(0, BRIDGED_DESCRIPTION_MAX_LENGTH - Array.from(marker).length).join('')}${marker}`;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The longest input schema a server gets to put in front of the model, in
|
|
69
|
+
* characters of its JSON. A schema is read by the provider as structure,
|
|
70
|
+
* but every string in it lands in the tool block as text, and sixteen
|
|
71
|
+
* thousand characters of parameter list is a page, not a parameter list.
|
|
72
|
+
*/
|
|
73
|
+
export const BRIDGED_SCHEMA_MAX_LENGTH = 16_384;
|
|
74
|
+
/**
|
|
75
|
+
* The most of one `tools/call` result a server gets to put into the
|
|
76
|
+
* transcript, in characters, counting the joined text and the JSON of
|
|
77
|
+
* `structuredContent` separately.
|
|
78
|
+
*
|
|
79
|
+
* Every first-party tool caps what it returns — `fs.read` at 64 KB,
|
|
80
|
+
* `shell.run` and `browser.read` at 100 KB, `web.fetch` at 400 KB — and
|
|
81
|
+
* a bridged result was the one that did not. Binary blocks already land on
|
|
82
|
+
* disk rather than in the result, so this is about text, and text is the
|
|
83
|
+
* part that is *durable*: a tool result is saved into the session and
|
|
84
|
+
* replayed to the provider on every later turn of that conversation, so a
|
|
85
|
+
* server that answers `list_files` with twenty megabytes does not cost one
|
|
86
|
+
* turn, it costs every turn until the session ends — and survives restarts,
|
|
87
|
+
* because the transcript does. The stdio transport bounds a single message
|
|
88
|
+
* at its reader's buffer (10 MB by default) and the HTTP transports bound
|
|
89
|
+
* nothing, so "the transport will stop it" was never a cap.
|
|
90
|
+
*
|
|
91
|
+
* 100 KB matches `shell.run`, which is the closest comparison: somebody
|
|
92
|
+
* else's program, writing as much as it likes. Counted in characters
|
|
93
|
+
* rather than bytes, like the two bounds above and unlike the tool packs':
|
|
94
|
+
* what is scarce here is the model's context, which is measured in tokens,
|
|
95
|
+
* and a token follows a character more closely than it follows a byte.
|
|
96
|
+
*/
|
|
97
|
+
export const BRIDGED_RESULT_MAX_LENGTH = 100_000;
|
|
98
|
+
/**
|
|
99
|
+
* The smallest `maxResultChars` that means anything, and the floor every
|
|
100
|
+
* smaller one is raised to.
|
|
101
|
+
*
|
|
102
|
+
* A cap has to be able to hold an account of what it cut. The four
|
|
103
|
+
* annotations a result can carry — a marker for the text, one for the
|
|
104
|
+
* structured payload, and the two truncation notes — come to a little over
|
|
105
|
+
* four hundred characters with the keys they arrive under, and shortening
|
|
106
|
+
* them is not on the table: an announcement is the whole reason a cap is
|
|
107
|
+
* safe to have, and a cut with nothing saying it happened is the failure
|
|
108
|
+
* this bound exists to prevent.
|
|
109
|
+
*
|
|
110
|
+
* So below this a cap cannot be honoured, only approximated — `1` yielded
|
|
111
|
+
* 73 characters for a truncated text and 104 for a dropped resource link —
|
|
112
|
+
* and approximating it silently is worse than raising it. Raised rather
|
|
113
|
+
* than refused, and raised to the floor rather than back to the default,
|
|
114
|
+
* because an operator who asked for the smallest possible result should
|
|
115
|
+
* get the smallest possible result. The markers name the cap that was
|
|
116
|
+
* actually applied.
|
|
117
|
+
*/
|
|
118
|
+
export const BRIDGED_RESULT_MIN_LENGTH = 512;
|
|
119
|
+
/**
|
|
120
|
+
* A configured `maxResultChars` as it actually applies: the default when
|
|
121
|
+
* unset, never below the floor. One implementation because it has two
|
|
122
|
+
* consumers — `normalizeCallResult` for a result, and the call path for a
|
|
123
|
+
* `tools/call` that fails before there is a result to normalize. A second
|
|
124
|
+
* copy would leave the floor applying to one and not the other, which is
|
|
125
|
+
* how a cap of 1 came to name itself in a protocol error's marker.
|
|
126
|
+
*/
|
|
127
|
+
export const boundedResultLimit = (configured) => Math.max(BRIDGED_RESULT_MIN_LENGTH, configured ?? BRIDGED_RESULT_MAX_LENGTH);
|
|
128
|
+
/**
|
|
129
|
+
* Stratus's own account of a cut, in the three shapes it takes. One home
|
|
130
|
+
* each, because the reservation below has to know what they cost and a
|
|
131
|
+
* second copy of the wording would drift from the one that ships.
|
|
132
|
+
*/
|
|
133
|
+
const truncationMarker = (what, cap, sent) => `\n… [${what} truncated by stratus at ${cap} characters; the server sent ${sent}]`;
|
|
134
|
+
const resourcesTruncatedNote = (count, cap) => `${count} more resource link${count === 1 ? ' was' : 's were'} not included: the result reached its ${cap}-character cap.`;
|
|
135
|
+
const filesTruncatedNote = (count, cap) => `${count} more attachment${count === 1 ? ' was' : 's were'} not saved: the result reached its ${cap}-character cap.`;
|
|
136
|
+
/**
|
|
137
|
+
* The longest `what` a marker is built with. `spend` is reached with
|
|
138
|
+
* `result`, `structured result` and — on the `isError` path — `error
|
|
139
|
+
* message`, and the reservation has to hold the widest of them.
|
|
140
|
+
*/
|
|
141
|
+
const WIDEST_MARKER_SUBJECT = 'structured result';
|
|
142
|
+
/**
|
|
143
|
+
* A worst-case count for a number one of those interpolates, so the
|
|
144
|
+
* reservation is measured against the wording rather than against a guess
|
|
145
|
+
* at how many digits a server can provoke.
|
|
146
|
+
*/
|
|
147
|
+
const WIDEST_COUNT = Number.MAX_SAFE_INTEGER;
|
|
148
|
+
/** What a value under `key` adds to the result's JSON: `"key":"",`. */
|
|
149
|
+
const keyOverhead = (key) => key.length + 6;
|
|
150
|
+
const markerReserve = (key, limit) => serializedLength(truncationMarker(WIDEST_MARKER_SUBJECT, limit, WIDEST_COUNT)) + keyOverhead(key);
|
|
151
|
+
const noteReserveFor = (content, hasStructured, hasWorkspaceRoot, limit) => {
|
|
152
|
+
const kinds = content.filter(isObject).map((block) => block.type);
|
|
153
|
+
// Only where the result can actually produce text. `resource_link` never
|
|
154
|
+
// does, so a list of links was reserving room for a marker that had
|
|
155
|
+
// nothing to mark — and at a small cap that reservation was enough to
|
|
156
|
+
// drop the links it was withheld from.
|
|
157
|
+
const producesText = kinds.some((kind) => kind === 'text' || kind === 'resource')
|
|
158
|
+
// A binary block contributes text in exactly one case: there is
|
|
159
|
+
// nowhere to write it, so it says so instead. With a workspace root
|
|
160
|
+
// configured that cannot happen, and reserving for it costs the
|
|
161
|
+
// attachment the room its own path needs.
|
|
162
|
+
|| (!hasWorkspaceRoot && kinds.some((kind) => kind === 'image' || kind === 'audio'));
|
|
163
|
+
const text = producesText ? markerReserve('text', limit) : 0;
|
|
164
|
+
const structured = hasStructured ? markerReserve('structuredText', limit) : 0;
|
|
165
|
+
const resources = kinds.includes('resource_link')
|
|
166
|
+
? serializedLength(resourcesTruncatedNote(WIDEST_COUNT, limit)) + keyOverhead('resourcesTruncated')
|
|
167
|
+
: 0;
|
|
168
|
+
const files = kinds.some((kind) => kind === 'image' || kind === 'audio' || kind === 'resource')
|
|
169
|
+
? serializedLength(filesTruncatedNote(WIDEST_COUNT, limit)) + keyOverhead('filesTruncated')
|
|
170
|
+
: 0;
|
|
171
|
+
return { text, structured, resources, files, total: text + structured + resources + files };
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* `raw`'s length in code points, walked rather than materialized:
|
|
175
|
+
* `Array.from` is how the two bounds above count, because they measure a
|
|
176
|
+
* description against a fixed 1024, while these measure whatever a server
|
|
177
|
+
* sent. Turning ten megabytes of result into ten million one-character
|
|
178
|
+
* strings, to decide it is too long, would spend more memory on the check
|
|
179
|
+
* than the string it guards against.
|
|
180
|
+
*/
|
|
181
|
+
const codePointLength = (raw) => {
|
|
182
|
+
let count = 0;
|
|
183
|
+
for (const _character of raw) {
|
|
184
|
+
count += 1;
|
|
185
|
+
}
|
|
186
|
+
return count;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Whether `raw` is within `limit` code points, without counting further
|
|
190
|
+
* than it has to. The cheap test first — a string's UTF-16 length is never
|
|
191
|
+
* below its code-point count, so anything whose `length` fits is under the
|
|
192
|
+
* limit — which is every ordinary string and costs one property read.
|
|
193
|
+
*/
|
|
194
|
+
const withinLimit = (raw, limit) => raw.length <= limit || codePointLength(raw) <= limit;
|
|
195
|
+
/**
|
|
196
|
+
* What an already-serialized string costs the result, in the code points
|
|
197
|
+
* the cap is counted in. The cheap `length` first, which is never below
|
|
198
|
+
* the code-point count, so only a string carrying astral characters is
|
|
199
|
+
* walked. One rule, because everything that adds to the total has to agree
|
|
200
|
+
* with everything that tests it — measuring one in UTF-16 units and the
|
|
201
|
+
* other in code points is how a result that fitted was cut anyway.
|
|
202
|
+
*/
|
|
203
|
+
const charged = (value) => Math.min(value.length, codePointLength(value));
|
|
204
|
+
/**
|
|
205
|
+
* What one character costs the transcript, which is JSON.
|
|
206
|
+
*
|
|
207
|
+
* A tool result is stored and replayed as a JSON string, so the characters
|
|
208
|
+
* a server writes are not the characters the transcript pays for: `"` and
|
|
209
|
+
* `\` escape to two, the five shorthand controls to two, and every other
|
|
210
|
+
* control character to six — `\u0000` for a NUL. Counting raw code points
|
|
211
|
+
* therefore under-charged a hostile payload by up to sixfold: 100,000 NULs
|
|
212
|
+
* passed a 100,000-character cap and weighed 596,546 in the session and in
|
|
213
|
+
* every request that replayed it.
|
|
214
|
+
*
|
|
215
|
+
* Lone surrogates cost six for the same reason, and can be present in a
|
|
216
|
+
* server's text even though nothing here ever produces one.
|
|
217
|
+
*
|
|
218
|
+
* Ordinary prose is unaffected — a log file pays for its newlines, a JSON
|
|
219
|
+
* document for its quotes, both a couple of percent. The cap is still
|
|
220
|
+
* counted in characters rather than bytes, as
|
|
221
|
+
* {@link BRIDGED_RESULT_MAX_LENGTH} describes; these are simply the
|
|
222
|
+
* characters that actually land.
|
|
223
|
+
*/
|
|
224
|
+
const serializedCostOf = (character) => {
|
|
225
|
+
const code = character.codePointAt(0);
|
|
226
|
+
if (code === 0x22 || code === 0x5c) {
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
|
|
230
|
+
return 2;
|
|
231
|
+
}
|
|
232
|
+
if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) {
|
|
233
|
+
return 6;
|
|
234
|
+
}
|
|
235
|
+
return 1;
|
|
236
|
+
};
|
|
237
|
+
/** `raw` measured as the transcript will carry it. */
|
|
238
|
+
const serializedLength = (raw) => {
|
|
239
|
+
let count = 0;
|
|
240
|
+
for (const character of raw) {
|
|
241
|
+
count += serializedCostOf(character);
|
|
242
|
+
}
|
|
243
|
+
return count;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Whether `raw` serializes within `limit`, without counting further than it
|
|
247
|
+
* has to. A character costs at most six, so anything six times whose UTF-16
|
|
248
|
+
* length fits is under the limit — one multiply for every ordinary result —
|
|
249
|
+
* and the walk that settles the rest stops as soon as it has overspent,
|
|
250
|
+
* rather than measuring ten megabytes to decide they are too many.
|
|
251
|
+
*/
|
|
252
|
+
const withinSerialized = (raw, limit) => {
|
|
253
|
+
if (raw.length * 6 <= limit) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
let count = 0;
|
|
257
|
+
for (const character of raw) {
|
|
258
|
+
count += serializedCostOf(character);
|
|
259
|
+
if (count > limit) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* `raw`, cut so that what it costs the transcript is within `limit`, with
|
|
267
|
+
* the cut announced — or `raw` when it already fits.
|
|
268
|
+
*
|
|
269
|
+
* The marker matters as much as the cut: the model has to be able to tell
|
|
270
|
+
* a directory listing that ended from one that was stopped, or it reports
|
|
271
|
+
* the truncated answer as the whole answer — which is the failure mode of
|
|
272
|
+
* a silent cap, and worse than the size. It names the original size for
|
|
273
|
+
* the same reason `bridgedDescription`'s does: the number is what tells an
|
|
274
|
+
* operator whether to raise `maxResultChars` or fix the call, and it is
|
|
275
|
+
* given in what the transcript pays so that both numbers in the sentence
|
|
276
|
+
* are in the same units.
|
|
277
|
+
*
|
|
278
|
+
* The cut never lands inside a surrogate pair — half an astral character
|
|
279
|
+
* is a malformed string a provider may refuse — and the exact size is only
|
|
280
|
+
* paid for on the oversized path, `withinSerialized` having already
|
|
281
|
+
* settled the ordinary one without walking the whole string.
|
|
282
|
+
*
|
|
283
|
+
* A `limit` smaller than the marker yields just the marker, which is
|
|
284
|
+
* longer than the limit. Inside a result budget that case is what
|
|
285
|
+
* {@link noteReserveFor} sets room aside for, so the marker arriving alone
|
|
286
|
+
* does not push the result past its cap; the alternative would be a cut
|
|
287
|
+
* with nothing saying it happened.
|
|
288
|
+
*/
|
|
289
|
+
const cutToLimit = (raw, limit, what, cap = limit) => {
|
|
290
|
+
if (withinSerialized(raw, limit)) {
|
|
291
|
+
return raw;
|
|
292
|
+
}
|
|
293
|
+
const characters = serializedLength(raw);
|
|
294
|
+
// `cap`, not `limit`: the number an operator can act on is the one they
|
|
295
|
+
// set, and `limit` is what was left of it after the reservation and
|
|
296
|
+
// whatever the result already spent. Naming the remainder would tell
|
|
297
|
+
// someone reading the transcript to raise a setting that does not exist.
|
|
298
|
+
const marker = truncationMarker(what, cap, characters);
|
|
299
|
+
return `${firstWithinCost(raw, Math.max(0, limit - serializedLength(marker)))}${marker}`;
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* The first `count` CODE POINTS of `raw`.
|
|
303
|
+
*
|
|
304
|
+
* Not `raw.slice(0, allowance)`, which is the obvious thing and is wrong
|
|
305
|
+
* twice over: `slice` indexes UTF-16 code units, so an emoji-heavy result
|
|
306
|
+
* would keep about half of what it was allowed, and the allowance is spent
|
|
307
|
+
* in what the transcript charges rather than in characters, so an escaped
|
|
308
|
+
* one has to pay its escape. Walking the string settles both, and because
|
|
309
|
+
* it only ever advances by whole code points it can never cut inside a
|
|
310
|
+
* surrogate pair — nor leave a lone one behind for `JSON.stringify` to
|
|
311
|
+
* spend six characters on.
|
|
312
|
+
*
|
|
313
|
+
* Bounded by `allowance`, not by the length of `raw`: the input is a
|
|
314
|
+
* result that has already been found too long, and the point of stopping
|
|
315
|
+
* early is not walking ten megabytes to keep a hundred thousand
|
|
316
|
+
* characters.
|
|
317
|
+
*/
|
|
318
|
+
const firstWithinCost = (raw, allowance) => {
|
|
319
|
+
if (allowance <= 0) {
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
let units = 0;
|
|
323
|
+
let spent = 0;
|
|
324
|
+
for (const character of raw) {
|
|
325
|
+
// Compared before committing, never after: a character that would take
|
|
326
|
+
// the total past the allowance is not taken. An equality test would
|
|
327
|
+
// never be true against a fractional allowance and the loop would run
|
|
328
|
+
// to the end of the string, returning the entire payload with a
|
|
329
|
+
// truncation marker on it — larger than what came in. The callers are
|
|
330
|
+
// guarded (see `asPositiveInteger`); this is the layer that must not
|
|
331
|
+
// depend on them being right.
|
|
332
|
+
const next = spent + serializedCostOf(character);
|
|
333
|
+
if (next > allowance) {
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
spent = next;
|
|
337
|
+
units += character.length;
|
|
338
|
+
}
|
|
339
|
+
return raw.slice(0, units);
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* How deep a schema may nest before the bridge stops walking it. Sixty-four
|
|
343
|
+
* levels is far past any parameter list a person wrote; a schema nested
|
|
344
|
+
* deeper is a stack-overflow attempt dressed as a tool, and a recursive
|
|
345
|
+
* walk that blew the stack during discovery would take the whole server
|
|
346
|
+
* down as unreachable, and again on every reconnect.
|
|
347
|
+
*/
|
|
348
|
+
export const BRIDGED_SCHEMA_MAX_DEPTH = 64;
|
|
349
|
+
/**
|
|
350
|
+
* The schema keys whose values are prose the model reads, at any depth —
|
|
351
|
+
* `$comment` included: the spec calls it a note for schema authors, but
|
|
352
|
+
* the provider forwards the whole schema, so it is one more place a page
|
|
353
|
+
* of instructions can ride into every tool block.
|
|
354
|
+
*/
|
|
355
|
+
const SCHEMA_ANNOTATION_KEYS = new Set(['description', 'title', '$comment']);
|
|
356
|
+
/**
|
|
357
|
+
* The longest name segment a server's tool may bridge under. The segment
|
|
358
|
+
* is the tail of `mcp.<server>.<segment>`, sent as the tool's name in
|
|
359
|
+
* every model request, and a name is the one string a server writes that
|
|
360
|
+
* no description bound touches — a tool called `a` three thousand times
|
|
361
|
+
* over is a page in the tool block by another route. Sixty-four is the
|
|
362
|
+
* longest name the strictest provider accepts whole.
|
|
363
|
+
*/
|
|
364
|
+
export const BRIDGED_SEGMENT_MAX_LENGTH = 64;
|
|
365
|
+
/**
|
|
366
|
+
* The schema keys whose values are data, not schema: a member named
|
|
367
|
+
* `description` inside an `enum` entry is a value the model will send
|
|
368
|
+
* back, not prose, and rewriting it would make the schema offer a value
|
|
369
|
+
* the server does not accept. These subtrees are copied through verbatim.
|
|
370
|
+
*/
|
|
371
|
+
const SCHEMA_LITERAL_KEYS = new Set(['enum', 'const', 'default', 'examples']);
|
|
372
|
+
class SchemaTooDeepError extends Error {
|
|
373
|
+
constructor() {
|
|
374
|
+
super(`Schema nests deeper than ${BRIDGED_SCHEMA_MAX_DEPTH} levels.`);
|
|
375
|
+
this.name = 'SchemaTooDeepError';
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The schema keywords whose value is a map from a *name* to a schema. The
|
|
380
|
+
* names are the server's — a parameter called `default` or `enum` is an
|
|
381
|
+
* ordinary parameter — so the keys of these maps are never read as
|
|
382
|
+
* keywords, and every value under them is a schema again. Without this
|
|
383
|
+
* distinction a description hidden under `properties.default` would pass
|
|
384
|
+
* through as a literal, unbounded. Draft-07's `dependencies` is here too:
|
|
385
|
+
* its values are schemas or lists of property names, and a list walked as
|
|
386
|
+
* a schema comes back untouched.
|
|
387
|
+
*/
|
|
388
|
+
const SCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas', 'dependencies']);
|
|
389
|
+
const boundedSchemaValue = (value, depth, position) => {
|
|
390
|
+
if (depth > BRIDGED_SCHEMA_MAX_DEPTH) {
|
|
391
|
+
throw new SchemaTooDeepError();
|
|
392
|
+
}
|
|
393
|
+
if (Array.isArray(value)) {
|
|
394
|
+
return value.map((entry) => boundedSchemaValue(entry, depth + 1, 'schema'));
|
|
395
|
+
}
|
|
396
|
+
if (typeof value === 'object' && value !== null) {
|
|
397
|
+
if (position === 'map') {
|
|
398
|
+
return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, boundedSchemaValue(entry, depth + 1, 'schema')]));
|
|
399
|
+
}
|
|
400
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
401
|
+
key,
|
|
402
|
+
SCHEMA_LITERAL_KEYS.has(key)
|
|
403
|
+
? entry
|
|
404
|
+
: SCHEMA_ANNOTATION_KEYS.has(key) && typeof entry === 'string'
|
|
405
|
+
? bridgedDescription(entry)
|
|
406
|
+
: boundedSchemaValue(entry, depth + 1, SCHEMA_MAP_KEYS.has(key) ? 'map' : 'schema'),
|
|
407
|
+
]));
|
|
408
|
+
}
|
|
409
|
+
return value;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* A server's input schema as it reaches the registry — and, through it,
|
|
413
|
+
* the tool block of every turn, the same way the description does. The
|
|
414
|
+
* top-level description is not the only prose in `tools/list`: a
|
|
415
|
+
* `description` or `title` on any property, at any depth, is text the
|
|
416
|
+
* model reads too, and it was copied through unbounded. Each is bounded
|
|
417
|
+
* like the tool's own description. Nothing else in the schema is touched:
|
|
418
|
+
* a property name, an enum value, a const or a pattern is what the model
|
|
419
|
+
* sends back in a call, and the bridge forwards arguments to the server
|
|
420
|
+
* as the model wrote them — a value spelled out here would arrive at the
|
|
421
|
+
* server as a different value, and the schema would be a lie in the
|
|
422
|
+
* direction that breaks calls. An annotation key *inside* a literal (an
|
|
423
|
+
* `enum` member with a `description` field) is data too, and is left
|
|
424
|
+
* alone — while a *parameter* named `enum` or `default` is a schema like
|
|
425
|
+
* any other, because the keys of `properties` and `$defs` are names, not
|
|
426
|
+
* keywords. `undefined` when the bounded schema is still longer than
|
|
427
|
+
* {@link BRIDGED_SCHEMA_MAX_LENGTH} characters, or nests deeper than
|
|
428
|
+
* {@link BRIDGED_SCHEMA_MAX_DEPTH}: that tool is not bridged, and the
|
|
429
|
+
* caller names it.
|
|
430
|
+
*/
|
|
431
|
+
export const bridgedSchema = (schema) => {
|
|
432
|
+
try {
|
|
433
|
+
const bounded = boundedSchemaValue(schema, 0, 'schema');
|
|
434
|
+
// The literal subtrees were not walked, so the serialisation is the
|
|
435
|
+
// one place a bottomless `default` can still blow the stack — a
|
|
436
|
+
// RangeError there is the same answer as a schema too deep to walk.
|
|
437
|
+
return Array.from(JSON.stringify(bounded)).length <= BRIDGED_SCHEMA_MAX_LENGTH ? bounded : undefined;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof SchemaTooDeepError || error instanceof RangeError) {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
29
446
|
/** The registered name a server's tool bridges to: `mcp.<server>.<segment>`. */
|
|
30
447
|
export const bridgedToolName = (server, segment) => `mcp.${server}.${segment}`;
|
|
31
448
|
const EXTENSION_BY_MIME = {
|
|
@@ -55,6 +472,143 @@ const isObject = (value) => typeof value === 'object' && value !== null && !Arra
|
|
|
55
472
|
// one turn) would produce the same path, and the first result's `files`
|
|
56
473
|
// entry would silently point at the second call's bytes.
|
|
57
474
|
let fileSerial = 0;
|
|
475
|
+
/**
|
|
476
|
+
* One result's character allowance, spent across everything server-written
|
|
477
|
+
* that reaches the transcript.
|
|
478
|
+
*
|
|
479
|
+
* A budget rather than a cap per field because the transcript pays the
|
|
480
|
+
* sum: text, a structured payload and a list of resource links are three
|
|
481
|
+
* places a server can put bytes in one result, and three separate caps
|
|
482
|
+
* would let it spend the allowance three times.
|
|
483
|
+
*/
|
|
484
|
+
const createResultBudget = (limit, initialReserve) => {
|
|
485
|
+
// One, not zero: the result is a flat object, so its JSON is `{`, its
|
|
486
|
+
// fields joined by commas, and `}`. Charging every field as
|
|
487
|
+
// `"key":value,` — comma included — overcounts by exactly one comma,
|
|
488
|
+
// which is what pays for the closing brace; the opening one is this.
|
|
489
|
+
//
|
|
490
|
+
// The point of doing it this way is that the sum stops being an
|
|
491
|
+
// approximation of `JSON.stringify(result).length` and becomes equal to
|
|
492
|
+
// it. Every round of review so far has found another part of the
|
|
493
|
+
// envelope nobody was charged for — separators between links, the
|
|
494
|
+
// `"resources":[…]` around them, the keys themselves — because a budget
|
|
495
|
+
// that adds up *some* of what the transcript carries will always have
|
|
496
|
+
// one more piece missing. This adds up all of it.
|
|
497
|
+
let spent = 1;
|
|
498
|
+
let reserve = initialReserve;
|
|
499
|
+
// `limit` is what the whole result may weigh; this is what a *server*
|
|
500
|
+
// may spend of it. Stratus's own truncation markers and notes come out
|
|
501
|
+
// of the difference, so that announcing a cut cannot itself push the
|
|
502
|
+
// result past the cut. See {@link noteReserveFor}.
|
|
503
|
+
const remaining = () => Math.max(0, limit - reserve - spent);
|
|
504
|
+
/**
|
|
505
|
+
* What is left if the room held for one particular annotation is not
|
|
506
|
+
* held — the allowance a value gets to prove it needs no annotation at
|
|
507
|
+
* all. Releasing that room is safe precisely when the value fits in it,
|
|
508
|
+
* because then the annotation it was held for will never be written.
|
|
509
|
+
*/
|
|
510
|
+
const withoutOwn = (ownReserve) => Math.max(0, limit - spent - (reserve - ownReserve));
|
|
511
|
+
// Two ways in, because a result carries two kinds of string. `fits` and
|
|
512
|
+
// `charge` take a value that is ALREADY in the form the transcript holds
|
|
513
|
+
// — a `JSON.stringify`d link or path, whose escapes are characters in it
|
|
514
|
+
// already — so they count it as it stands. `spend` takes a server's raw
|
|
515
|
+
// text, which becomes a JSON string *value* and is escaped on the way,
|
|
516
|
+
// so it is charged for what that escaping costs. Measuring either one the
|
|
517
|
+
// other way is wrong in a direction that matters: plainly, and a NUL
|
|
518
|
+
// costs a sixth of what it weighs; serialized, and every quote in a link
|
|
519
|
+
// is paid for twice.
|
|
520
|
+
return {
|
|
521
|
+
limit,
|
|
522
|
+
/** Whether `value`, already serialized, fits in what is left. */
|
|
523
|
+
fits: (value) => withinLimit(value, remaining()),
|
|
524
|
+
/** Charge `value`, already serialized; the caller has checked it fits. */
|
|
525
|
+
charge: (value) => {
|
|
526
|
+
spent += charged(value);
|
|
527
|
+
},
|
|
528
|
+
/**
|
|
529
|
+
* `value`, already serialized, taken whole if it fits once the room
|
|
530
|
+
* held for the note that would report *dropping* it is released. The
|
|
531
|
+
* note reports what did not fit; a collection that fits entirely never
|
|
532
|
+
* produces one, so it should not be charged for one — a single
|
|
533
|
+
* resource link that would have fitted in a 512-character result was
|
|
534
|
+
* being dropped to hold room for the sentence saying it was dropped.
|
|
535
|
+
*/
|
|
536
|
+
takeWhole: (value, ownReserve) => {
|
|
537
|
+
// `value` here already carries its own key and separator — the
|
|
538
|
+
// callers build `"resources":[…],` and `"structured":…,` — so there
|
|
539
|
+
// is no envelope to add.
|
|
540
|
+
if (!withinLimit(value, withoutOwn(ownReserve))) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
spent += charged(value);
|
|
544
|
+
reserve = Math.max(0, reserve - ownReserve);
|
|
545
|
+
return true;
|
|
546
|
+
},
|
|
547
|
+
/**
|
|
548
|
+
* Give back room held for an annotation this result will not carry.
|
|
549
|
+
* Clamped at zero, because a share given back twice is room the cap
|
|
550
|
+
* never had — the budget would then let the fields below it overspend
|
|
551
|
+
* by exactly the size of the note nobody is writing.
|
|
552
|
+
*/
|
|
553
|
+
release: (ownReserve) => {
|
|
554
|
+
reserve = Math.max(0, reserve - ownReserve);
|
|
555
|
+
},
|
|
556
|
+
/**
|
|
557
|
+
* Whether everything still to come fits with no room held back at all
|
|
558
|
+
* — the question that decides whether anything has to be cut, and so
|
|
559
|
+
* whether any of it has to be announced.
|
|
560
|
+
*/
|
|
561
|
+
fitsUntouched: (cost) => spent + cost <= limit,
|
|
562
|
+
/** Nothing will be cut, so nothing will be announced. */
|
|
563
|
+
releaseAll: () => {
|
|
564
|
+
reserve = 0;
|
|
565
|
+
},
|
|
566
|
+
spend: (value, what, ownReserve, envelope) => {
|
|
567
|
+
// `envelope` is what the value costs beyond its own characters — the
|
|
568
|
+
// key it arrives under, its quotes and its separator. Part of what it
|
|
569
|
+
// costs, so part of what it has to fit inside.
|
|
570
|
+
if (withinSerialized(value, withoutOwn(ownReserve) - envelope)) {
|
|
571
|
+
spent += serializedLength(value) + envelope;
|
|
572
|
+
reserve = Math.max(0, reserve - ownReserve);
|
|
573
|
+
return value;
|
|
574
|
+
}
|
|
575
|
+
// Cut against the allowance that does *not* hold this marker's room,
|
|
576
|
+
// because the cut is what writes the marker: `cutToLimit` subtracts
|
|
577
|
+
// it from whatever it is given, so cutting against the reduced
|
|
578
|
+
// allowance charged it twice and threw away a second marker's worth
|
|
579
|
+
// of the server's text for nothing. A 511-character result came back
|
|
580
|
+
// as 406 of its 512.
|
|
581
|
+
const allowance = Math.max(0, withoutOwn(ownReserve) - envelope);
|
|
582
|
+
const bounded = cutToLimit(value, allowance, what, limit);
|
|
583
|
+
const cost = serializedLength(bounded) + envelope;
|
|
584
|
+
// Released only when the marker did fit inside that allowance. When
|
|
585
|
+
// it did not — the bare-marker case, at a cap too small to hold one
|
|
586
|
+
// — the room stays held, and it is what covers the overspend, so the
|
|
587
|
+
// result still weighs no more than the cap either way.
|
|
588
|
+
if (cost <= withoutOwn(ownReserve)) {
|
|
589
|
+
reserve = Math.max(0, reserve - ownReserve);
|
|
590
|
+
}
|
|
591
|
+
spent += cost;
|
|
592
|
+
return bounded;
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Server-written text, bounded for the transcript — the same treatment a
|
|
598
|
+
* result's text gets, exported because a `tools/call` can fail in ways that
|
|
599
|
+
* never produce a result at all: a JSON-RPC error, or a transport failure
|
|
600
|
+
* carrying an HTTP body. Those reach the agent as a thrown message, which
|
|
601
|
+
* `DefaultExecutor` copies into `ToolResult.error` and the session persists
|
|
602
|
+
* and replays exactly like output.
|
|
603
|
+
*
|
|
604
|
+
* Bounded to the cap *minus the envelope it is replayed in*, for the same
|
|
605
|
+
* reason a result's fields are: it does not travel as a bare string but as
|
|
606
|
+
* a JSON string value under a key, and a message cut to exactly the cap
|
|
607
|
+
* arrives a dozen characters over it. The marker still names the cap the
|
|
608
|
+
* operator set rather than the number left after that deduction.
|
|
609
|
+
*/
|
|
610
|
+
export const THROWN_MESSAGE_ENVELOPE = `{"error":""}`.length;
|
|
611
|
+
export const boundServerText = (raw, limit, what) => cutToLimit(raw, Math.max(0, limit - THROWN_MESSAGE_ENVELOPE), what, limit);
|
|
58
612
|
/**
|
|
59
613
|
* Normalize one MCP `tools/call` result into a `JsonValue`.
|
|
60
614
|
*
|
|
@@ -73,11 +627,55 @@ let fileSerial = 0;
|
|
|
73
627
|
* model reads best — and anything richer returns an object.
|
|
74
628
|
*/
|
|
75
629
|
export const normalizeCallResult = async (result, options) => {
|
|
630
|
+
// Bounded here as well as at the config boundary, so that a host
|
|
631
|
+
// embedding the normalizer directly gets the same floor an operator
|
|
632
|
+
// does — `NormalizeOptions.maxResultChars` is reachable without going
|
|
633
|
+
// through `mcpPlugin`'s validation at all.
|
|
634
|
+
const resultLimit = boundedResultLimit(options.maxResultChars);
|
|
635
|
+
try {
|
|
636
|
+
return await normalizeAdmittedResult(result, options, resultLimit);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
// Every way out of this function lands in the same place. A returned
|
|
640
|
+
// result is spent against the budget field by field, but a *thrown*
|
|
641
|
+
// one is copied into `ToolResult.error`, persisted on the session and
|
|
642
|
+
// replayed on every later turn — the same durable channel, with none
|
|
643
|
+
// of the accounting. So the cap is applied once more on the way out,
|
|
644
|
+
// where it covers the paths the budget never saw: a failed save
|
|
645
|
+
// quoting the path it could not write (a server naming
|
|
646
|
+
// `image/<430 chars>` produced a 544-character error against a
|
|
647
|
+
// 512-character cap), and the synthesized message for an `isError`
|
|
648
|
+
// result that carried no text at all.
|
|
649
|
+
//
|
|
650
|
+
// A no-op for the `isError` throw below, which already spent its
|
|
651
|
+
// message against the budget at `THROWN_MESSAGE_ENVELOPE - 1` over
|
|
652
|
+
// the brace the budget starts holding: that leaves it at exactly the
|
|
653
|
+
// allowance this bound measures against, so it is never cut twice or
|
|
654
|
+
// marked twice.
|
|
655
|
+
throw error instanceof Error
|
|
656
|
+
? new Error(boundServerText(error.message, resultLimit, 'error message'), { cause: error })
|
|
657
|
+
: error;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
const normalizeAdmittedResult = async (result, options, resultLimit) => {
|
|
76
661
|
const shaped = isObject(result) ? result : {};
|
|
77
662
|
const content = Array.isArray(shaped.content) ? shaped.content : [];
|
|
78
663
|
const texts = [];
|
|
79
664
|
const files = [];
|
|
80
665
|
const resources = [];
|
|
666
|
+
// ONE budget for the whole result, spent in order — not a separate cap
|
|
667
|
+
// per carrier. A result can hold text, a structured payload, a list of
|
|
668
|
+
// resource links, and a path per binary block; capped separately, a
|
|
669
|
+
// server spends the allowance once per kind, while what reaches the
|
|
670
|
+
// transcript is their sum.
|
|
671
|
+
//
|
|
672
|
+
// Created before the content loop because the loop spends it: a binary
|
|
673
|
+
// block's *bytes* go to disk, but the path it returns is a string in the
|
|
674
|
+
// durable result like any other, and a thousand tiny images is a
|
|
675
|
+
// thousand paths replayed on every later turn.
|
|
676
|
+
const reserve = noteReserveFor(content, isObject(shaped.structuredContent), options.workspace !== undefined, resultLimit);
|
|
677
|
+
const budget = createResultBudget(resultLimit, reserve.total);
|
|
678
|
+
let skippedBinary = 0;
|
|
81
679
|
// Settled before anything touches the disk: a failing result's binary
|
|
82
680
|
// blocks would otherwise land as server-controlled files in the
|
|
83
681
|
// workspace that nothing references, delivers, or cleans up.
|
|
@@ -87,23 +685,67 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
87
685
|
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
88
686
|
.map((block) => block.text)
|
|
89
687
|
.join('\n\n');
|
|
90
|
-
|
|
688
|
+
// Bounded like a successful result, because it ends up in the same
|
|
689
|
+
// place: the executor copies a thrown message into `ToolResult.error`,
|
|
690
|
+
// which is persisted on the session and replayed to the provider on
|
|
691
|
+
// every later turn exactly as output is. A server that cannot answer
|
|
692
|
+
// must not get an unbounded channel into the transcript by failing
|
|
693
|
+
// instead of succeeding.
|
|
694
|
+
// This branch discards the structured payload, the links and the
|
|
695
|
+
// blocks — only the message survives, so only the marker that would
|
|
696
|
+
// announce cutting *it* can ever be written. Room held for the others
|
|
697
|
+
// comes out of the message instead, and a 280-character error came
|
|
698
|
+
// back as 156 for a payload that weighs 292 of a 512-character cap.
|
|
699
|
+
budget.release(reserve.total - reserve.text);
|
|
700
|
+
throw new Error(
|
|
701
|
+
// A failing call throws rather than returning a result, so what is
|
|
702
|
+
// replayed is the message inside `ToolResult.error` — the same
|
|
703
|
+
// envelope `boundServerText` deducts for, less the brace the budget
|
|
704
|
+
// starts out holding against a result object that is never built.
|
|
705
|
+
budget.spend(message, 'error message', reserve.text, THROWN_MESSAGE_ENVELOPE - 1)
|
|
706
|
+
|| `MCP server ${options.server} reported an error for ${options.tool} with no message.`);
|
|
91
707
|
}
|
|
92
|
-
|
|
708
|
+
// Resolved once for the whole result rather than per block. The inputs
|
|
709
|
+
// are fixed for the call, and a server answering with ten thousand tiny
|
|
710
|
+
// images would otherwise pay two syscalls apiece to be told there is no
|
|
711
|
+
// room left — which is the case the cap exists for. Scoped to this one
|
|
712
|
+
// result, so it cannot hand a later call another agent's directory the
|
|
713
|
+
// way a setup-time cache would.
|
|
714
|
+
let resolvedDirectory;
|
|
715
|
+
const binaryDirectory = async (workspace) => {
|
|
716
|
+
if (resolvedDirectory === undefined) {
|
|
717
|
+
// Canonical, because the ledger is keyed the way `fs.read` looks a
|
|
718
|
+
// path up — through `realpath` — and a workspace an operator moved
|
|
719
|
+
// behind a link would otherwise leave the record under a spelling no
|
|
720
|
+
// read ever asks for.
|
|
721
|
+
const lexical = path.join(workspace, 'mcp', options.server);
|
|
722
|
+
await mkdir(lexical, { recursive: true });
|
|
723
|
+
resolvedDirectory = await realpath(lexical);
|
|
724
|
+
}
|
|
725
|
+
return resolvedDirectory;
|
|
726
|
+
};
|
|
727
|
+
/**
|
|
728
|
+
* A binary block's name, worked out without writing anything.
|
|
729
|
+
*
|
|
730
|
+
* Two phases, because a block cannot be judged on its own: the cost of
|
|
731
|
+
* an attachment list is only known once every name in it is, and a block
|
|
732
|
+
* refused against a half-known list was being dropped when the whole
|
|
733
|
+
* result would have fitted. So every name is planned first, the list is
|
|
734
|
+
* weighed as a list, and only the blocks that fit are written. Nothing
|
|
735
|
+
* reaches the disk before that decision, which is the property the
|
|
736
|
+
* single-phase version was built around and this keeps.
|
|
737
|
+
*/
|
|
738
|
+
const planned = [];
|
|
739
|
+
const planBlock = async (data, mimeType) => {
|
|
93
740
|
if (typeof data !== 'string') {
|
|
94
741
|
return;
|
|
95
742
|
}
|
|
96
|
-
|
|
97
|
-
|
|
743
|
+
const workspace = options.workspace?.();
|
|
744
|
+
if (workspace === undefined || workspace.length === 0) {
|
|
745
|
+
texts.push(`[binary ${typeof mimeType === 'string' ? mimeType : 'content'} dropped: @stratusagent/plugin-mcp has no workspace for ${options.agentId} — the host supplied neither a workspaces seam nor a workspaceRoot]`);
|
|
98
746
|
return;
|
|
99
747
|
}
|
|
100
|
-
|
|
101
|
-
// path up — through `realpath` — and a workspace root or agent
|
|
102
|
-
// directory an operator moved behind a link would otherwise leave the
|
|
103
|
-
// record under a spelling no read ever asks for.
|
|
104
|
-
const lexical = path.join(options.workspaceRoot, options.agentId, 'mcp', options.server);
|
|
105
|
-
await mkdir(lexical, { recursive: true });
|
|
106
|
-
const directory = await realpath(lexical);
|
|
748
|
+
const directory = await binaryDirectory(workspace);
|
|
107
749
|
const stamp = (options.now ?? Date.now)();
|
|
108
750
|
fileSerial += 1;
|
|
109
751
|
// The tool name is the server's own string, so it is folded to the
|
|
@@ -111,6 +753,16 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
111
753
|
// raw, a tool named `../../…` would be an arbitrary-directory write
|
|
112
754
|
// steered by whoever runs the server.
|
|
113
755
|
const file = path.join(directory, `${sanitizeToolSegment(options.tool) ?? 'tool'}-${stamp}-${fileSerial}.${extensionFor(typeof mimeType === 'string' ? mimeType : undefined)}`);
|
|
756
|
+
planned.push({ file, data });
|
|
757
|
+
};
|
|
758
|
+
/**
|
|
759
|
+
* What an attachment adds to the result: the first entry pays for the
|
|
760
|
+
* key, the brackets and the field's own separator, each one after it for
|
|
761
|
+
* the comma joining it to the last. The total is exactly what
|
|
762
|
+
* `"files":[…],` weighs.
|
|
763
|
+
*/
|
|
764
|
+
const fileEntryCost = (file, first) => (first ? `"files":[${JSON.stringify(file)}],` : `,${JSON.stringify(file)}`);
|
|
765
|
+
const commitBlock = async (file, data) => {
|
|
114
766
|
// Recorded before the bytes land, like a tainted `fs.write`: a crash
|
|
115
767
|
// between the two leaves a labelled path with no file, never a file
|
|
116
768
|
// with no label.
|
|
@@ -148,7 +800,6 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
148
800
|
finally {
|
|
149
801
|
await handle.close();
|
|
150
802
|
}
|
|
151
|
-
files.push(file);
|
|
152
803
|
};
|
|
153
804
|
for (const block of content) {
|
|
154
805
|
if (!isObject(block)) {
|
|
@@ -162,7 +813,7 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
162
813
|
break;
|
|
163
814
|
case 'image':
|
|
164
815
|
case 'audio':
|
|
165
|
-
await
|
|
816
|
+
await planBlock(block.data, block.mimeType);
|
|
166
817
|
break;
|
|
167
818
|
case 'resource': {
|
|
168
819
|
const resource = isObject(block.resource) ? block.resource : {};
|
|
@@ -171,7 +822,7 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
171
822
|
texts.push(uri ? `${uri}:\n${resource.text}` : resource.text);
|
|
172
823
|
}
|
|
173
824
|
else {
|
|
174
|
-
await
|
|
825
|
+
await planBlock(resource.blob, resource.mimeType);
|
|
175
826
|
}
|
|
176
827
|
break;
|
|
177
828
|
}
|
|
@@ -189,16 +840,156 @@ export const normalizeCallResult = async (result, options) => {
|
|
|
189
840
|
break;
|
|
190
841
|
}
|
|
191
842
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
843
|
+
// What the text will cost beyond its own characters. A result with
|
|
844
|
+
// nothing but text is returned as the string itself rather than as an
|
|
845
|
+
// object, so it pays for its two quotes and nothing else — the `- 1`
|
|
846
|
+
// gives back the brace the budget starts out holding.
|
|
847
|
+
const collapsesToText = planned.length === 0
|
|
848
|
+
&& resources.length === 0
|
|
849
|
+
&& !isObject(shaped.structuredContent);
|
|
850
|
+
// Measured as its own serialized string rather than by walking the
|
|
851
|
+
// object: what the provider is sent is the JSON, so the JSON is the cost.
|
|
852
|
+
// A structured payload that does not fit arrives as *text* — it stops
|
|
853
|
+
// being a parseable object, which is the honest outcome, since a
|
|
854
|
+
// truncated object is not one. The key says which it is, so a caller
|
|
855
|
+
// reading `structured` never finds a half-object there.
|
|
856
|
+
const structuredRaw = isObject(shaped.structuredContent) ? shaped.structuredContent : undefined;
|
|
857
|
+
const structuredJson = structuredRaw === undefined ? undefined : JSON.stringify(structuredRaw);
|
|
858
|
+
// Nothing needs announcing if nothing needs cutting, so ask that first.
|
|
859
|
+
//
|
|
860
|
+
// The reservation is held per annotation and released per annotation,
|
|
861
|
+
// which is right when something does have to be cut — but it still
|
|
862
|
+
// charged each field for the *other* fields' unspent room, and a field
|
|
863
|
+
// that would have fitted got cut to make space for a sentence about a
|
|
864
|
+
// different field that was never going to be written either. Four
|
|
865
|
+
// hundred characters of text beside a three-character structured payload
|
|
866
|
+
// came back cut at a 512-character cap, for a result that weighed 434
|
|
867
|
+
// whole.
|
|
868
|
+
//
|
|
869
|
+
// So the untouched result is weighed once, against the cap with no room
|
|
870
|
+
// held back at all. If it fits, every reservation is released and each
|
|
871
|
+
// field below finds itself whole. Weighed rather than built: these are
|
|
872
|
+
// the same sums the fields are charged, so a ten-megabyte result is
|
|
873
|
+
// measured, not materialized.
|
|
874
|
+
const joinedText = texts.length > 0 ? texts.join('\n\n') : undefined;
|
|
875
|
+
const plannedFiles = planned.map((block, index) => fileEntryCost(block.file, index === 0)).join('');
|
|
876
|
+
// Counted the way `charge` counts, in code points — `.length` is UTF-16
|
|
877
|
+
// units, so a structured payload or a link description carrying emoji
|
|
878
|
+
// weighed twice what it is charged, and a result that fitted was judged
|
|
879
|
+
// not to and had its *text* cut for the difference.
|
|
880
|
+
const untouched = charged(plannedFiles)
|
|
881
|
+
+ (joinedText === undefined
|
|
882
|
+
? 0
|
|
883
|
+
: serializedLength(joinedText) + (collapsesToText ? 2 - 1 : keyOverhead('text')))
|
|
884
|
+
+ (structuredJson === undefined ? 0 : charged(`"structured":${structuredJson},`))
|
|
885
|
+
+ (resources.length === 0 ? 0 : charged(`"resources":${JSON.stringify(resources)},`));
|
|
886
|
+
const nothingCut = budget.fitsUntouched(untouched);
|
|
887
|
+
if (nothingCut) {
|
|
888
|
+
budget.releaseAll();
|
|
889
|
+
}
|
|
890
|
+
// The attachments are decided here rather than as each block arrived,
|
|
891
|
+
// because what one costs depends on how many came before it, and a block
|
|
892
|
+
// judged against a half-known list was refused while the whole result
|
|
893
|
+
// would have fitted. Charged in order and written only once admitted, so
|
|
894
|
+
// a refusal still leaves nothing on disk.
|
|
895
|
+
// When something else forces a cut, the list still gets the try the
|
|
896
|
+
// resource links get: weighed whole, at the allowance it has once the
|
|
897
|
+
// room for `filesTruncated` is not held. A list that fits entirely never
|
|
898
|
+
// produces that note, so holding room for it dropped attachments that
|
|
899
|
+
// belonged in the result — an image beside a text that did have to be
|
|
900
|
+
// cut was refused, for a result weighing 389 of its 512.
|
|
901
|
+
const wholeListFits = !nothingCut
|
|
902
|
+
&& planned.length > 0
|
|
903
|
+
&& budget.takeWhole(plannedFiles, reserve.files);
|
|
904
|
+
for (const block of planned) {
|
|
905
|
+
const cost = fileEntryCost(block.file, files.length === 0);
|
|
906
|
+
if (!nothingCut && !wholeListFits && !budget.fits(cost)) {
|
|
907
|
+
skippedBinary += 1;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
await commitBlock(block.file, block.data);
|
|
911
|
+
// Already charged as a list, so not charged again as entries.
|
|
912
|
+
if (!wholeListFits) {
|
|
913
|
+
budget.charge(cost);
|
|
914
|
+
}
|
|
915
|
+
files.push(block.file);
|
|
916
|
+
}
|
|
917
|
+
if (skippedBinary === 0 && !nothingCut && !wholeListFits) {
|
|
918
|
+
// No `filesTruncated` to pay for, and everything below spends after
|
|
919
|
+
// this point, so holding its room would come out of the text. Only
|
|
920
|
+
// when it is still held: the other two paths have already given it
|
|
921
|
+
// back, and giving it back twice is room the cap never had.
|
|
922
|
+
budget.release(reserve.files);
|
|
923
|
+
}
|
|
924
|
+
const text = joinedText !== undefined
|
|
925
|
+
? budget.spend(joinedText, 'result', reserve.text, collapsesToText ? 2 - 1 : keyOverhead('text'))
|
|
926
|
+
: undefined;
|
|
927
|
+
// Tried whole first, at the allowance it gets once the room for its own
|
|
928
|
+
// marker is not held — the same release the links get, and for the same
|
|
929
|
+
// reason: a payload that survives intact never produces the marker that
|
|
930
|
+
// room was held for. Weighed as the field it becomes, key and separator
|
|
931
|
+
// included, rather than as the bare JSON.
|
|
932
|
+
const structuredFits = structuredJson !== undefined
|
|
933
|
+
&& budget.takeWhole(`"structured":${structuredJson},`, reserve.structured);
|
|
934
|
+
const structured = structuredFits ? structuredRaw : undefined;
|
|
935
|
+
// Only ever reached when the object form did not fit, and the string
|
|
936
|
+
// form is strictly larger — it escapes every quote in that JSON — so
|
|
937
|
+
// this always cuts, and `structuredText` can never come back holding an
|
|
938
|
+
// untruncated payload under a key that says it was truncated.
|
|
939
|
+
const structuredNote = structuredJson !== undefined && !structuredFits
|
|
940
|
+
? budget.spend(structuredJson, 'structured result', reserve.structured, keyOverhead('structuredText'))
|
|
941
|
+
: undefined;
|
|
942
|
+
// Resource links are server-controlled strings too, and a list of them is
|
|
943
|
+
// as unbounded as a paragraph is: `uri`, `name`, `title` and
|
|
944
|
+
// `description` all land in the durable result and are replayed with it.
|
|
945
|
+
// Kept whole while the budget lasts and then stopped, rather than each
|
|
946
|
+
// one cut — half a URI is no use to anybody, while nine links and a note
|
|
947
|
+
// saying there were ninety is.
|
|
948
|
+
const kept = [];
|
|
949
|
+
// The whole list first, at the allowance it gets when the room held for
|
|
950
|
+
// `resourcesTruncated` is not held. A list that fits entirely never
|
|
951
|
+
// produces that note, so being charged for it is how a result came to
|
|
952
|
+
// drop the one link it could comfortably have carried.
|
|
953
|
+
if (resources.length > 0 && budget.takeWhole(`"resources":${JSON.stringify(resources)},`, reserve.resources)) {
|
|
954
|
+
kept.push(...resources);
|
|
955
|
+
}
|
|
956
|
+
for (const link of kept.length === resources.length ? [] : resources) {
|
|
957
|
+
// Charged as the list costs, not as the link costs. A link measured on
|
|
958
|
+
// its own leaves the comma joining it to the last one unpaid, and the
|
|
959
|
+
// key and brackets the collection arrives in unpaid entirely — so
|
|
960
|
+
// thousands of minimal links each pass the check while the array they
|
|
961
|
+
// serialize into runs thousands of characters past the cap. The first
|
|
962
|
+
// link pays for the envelope, each one after it pays for its
|
|
963
|
+
// separator, and the total is exactly what `"resources":[…]` weighs
|
|
964
|
+
// in the result.
|
|
965
|
+
const serialized = JSON.stringify(link);
|
|
966
|
+
const cost = kept.length === 0 ? `"resources":[${serialized}],` : `,${serialized}`;
|
|
967
|
+
if (!budget.fits(cost)) {
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
budget.charge(cost);
|
|
971
|
+
kept.push(link);
|
|
972
|
+
}
|
|
973
|
+
const droppedLinks = resources.length - kept.length;
|
|
974
|
+
if (structured === undefined
|
|
975
|
+
&& structuredNote === undefined
|
|
976
|
+
&& files.length === 0
|
|
977
|
+
&& resources.length === 0
|
|
978
|
+
&& skippedBinary === 0) {
|
|
195
979
|
return text ?? '';
|
|
196
980
|
}
|
|
197
981
|
return {
|
|
198
982
|
...(text !== undefined ? { text } : {}),
|
|
199
983
|
...(structured !== undefined ? { structured } : {}),
|
|
984
|
+
...(structuredNote !== undefined ? { structuredText: structuredNote } : {}),
|
|
200
985
|
...(files.length > 0 ? { files } : {}),
|
|
201
|
-
...(
|
|
986
|
+
...(kept.length > 0 ? { resources: kept } : {}),
|
|
987
|
+
...(droppedLinks > 0
|
|
988
|
+
? { resourcesTruncated: resourcesTruncatedNote(droppedLinks, budget.limit) }
|
|
989
|
+
: {}),
|
|
990
|
+
...(skippedBinary > 0
|
|
991
|
+
? { filesTruncated: filesTruncatedNote(skippedBinary, budget.limit) }
|
|
992
|
+
: {}),
|
|
202
993
|
};
|
|
203
994
|
};
|
|
204
995
|
//# sourceMappingURL=normalize.js.map
|