@stratusagent/plugin-mcp 0.11.4 → 0.11.6

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/dist/normalize.js CHANGED
@@ -71,6 +71,273 @@ export const bridgedDescription = (raw) => {
71
71
  * thousand characters of parameter list is a page, not a parameter list.
72
72
  */
73
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
+ };
74
341
  /**
75
342
  * How deep a schema may nest before the bridge stops walking it. Sixty-four
76
343
  * levels is far past any parameter list a person wrote; a schema nested
@@ -205,6 +472,143 @@ const isObject = (value) => typeof value === 'object' && value !== null && !Arra
205
472
  // one turn) would produce the same path, and the first result's `files`
206
473
  // entry would silently point at the second call's bytes.
207
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);
208
612
  /**
209
613
  * Normalize one MCP `tools/call` result into a `JsonValue`.
210
614
  *
@@ -223,11 +627,55 @@ let fileSerial = 0;
223
627
  * model reads best — and anything richer returns an object.
224
628
  */
225
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) => {
226
661
  const shaped = isObject(result) ? result : {};
227
662
  const content = Array.isArray(shaped.content) ? shaped.content : [];
228
663
  const texts = [];
229
664
  const files = [];
230
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;
231
679
  // Settled before anything touches the disk: a failing result's binary
232
680
  // blocks would otherwise land as server-controlled files in the
233
681
  // workspace that nothing references, delivers, or cleans up.
@@ -237,23 +685,67 @@ export const normalizeCallResult = async (result, options) => {
237
685
  .filter((block) => block.type === 'text' && typeof block.text === 'string')
238
686
  .map((block) => block.text)
239
687
  .join('\n\n');
240
- throw new Error(message || `MCP server ${options.server} reported an error for ${options.tool} with no message.`);
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.`);
241
707
  }
242
- const writeBlock = async (data, mimeType) => {
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) => {
243
740
  if (typeof data !== 'string') {
244
741
  return;
245
742
  }
246
- if (!options.workspaceRoot) {
247
- texts.push(`[binary ${typeof mimeType === 'string' ? mimeType : 'content'} dropped: no workspaceRoot is configured for @stratusagent/plugin-mcp]`);
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]`);
248
746
  return;
249
747
  }
250
- // Canonical, because the ledger is keyed the way `fs.read` looks a
251
- // path up — through `realpath` — and a workspace root or agent
252
- // directory an operator moved behind a link would otherwise leave the
253
- // record under a spelling no read ever asks for.
254
- const lexical = path.join(options.workspaceRoot, options.agentId, 'mcp', options.server);
255
- await mkdir(lexical, { recursive: true });
256
- const directory = await realpath(lexical);
748
+ const directory = await binaryDirectory(workspace);
257
749
  const stamp = (options.now ?? Date.now)();
258
750
  fileSerial += 1;
259
751
  // The tool name is the server's own string, so it is folded to the
@@ -261,6 +753,16 @@ export const normalizeCallResult = async (result, options) => {
261
753
  // raw, a tool named `../../…` would be an arbitrary-directory write
262
754
  // steered by whoever runs the server.
263
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) => {
264
766
  // Recorded before the bytes land, like a tainted `fs.write`: a crash
265
767
  // between the two leaves a labelled path with no file, never a file
266
768
  // with no label.
@@ -298,7 +800,6 @@ export const normalizeCallResult = async (result, options) => {
298
800
  finally {
299
801
  await handle.close();
300
802
  }
301
- files.push(file);
302
803
  };
303
804
  for (const block of content) {
304
805
  if (!isObject(block)) {
@@ -312,7 +813,7 @@ export const normalizeCallResult = async (result, options) => {
312
813
  break;
313
814
  case 'image':
314
815
  case 'audio':
315
- await writeBlock(block.data, block.mimeType);
816
+ await planBlock(block.data, block.mimeType);
316
817
  break;
317
818
  case 'resource': {
318
819
  const resource = isObject(block.resource) ? block.resource : {};
@@ -321,7 +822,7 @@ export const normalizeCallResult = async (result, options) => {
321
822
  texts.push(uri ? `${uri}:\n${resource.text}` : resource.text);
322
823
  }
323
824
  else {
324
- await writeBlock(resource.blob, resource.mimeType);
825
+ await planBlock(resource.blob, resource.mimeType);
325
826
  }
326
827
  break;
327
828
  }
@@ -339,16 +840,156 @@ export const normalizeCallResult = async (result, options) => {
339
840
  break;
340
841
  }
341
842
  }
342
- const structured = isObject(shaped.structuredContent) ? shaped.structuredContent : undefined;
343
- const text = texts.length > 0 ? texts.join('\n\n') : undefined;
344
- if (structured === undefined && files.length === 0 && resources.length === 0) {
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) {
345
979
  return text ?? '';
346
980
  }
347
981
  return {
348
982
  ...(text !== undefined ? { text } : {}),
349
983
  ...(structured !== undefined ? { structured } : {}),
984
+ ...(structuredNote !== undefined ? { structuredText: structuredNote } : {}),
350
985
  ...(files.length > 0 ? { files } : {}),
351
- ...(resources.length > 0 ? { resources } : {}),
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
+ : {}),
352
993
  };
353
994
  };
354
995
  //# sourceMappingURL=normalize.js.map