@orboto/mcp 0.193.0 → 0.194.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/event-bridge.js +1 -47
- package/dist/event-bridge.test.js +0 -19
- package/dist/http-transport-binding.test.js +1 -4
- package/dist/http-transport.js +1 -213
- package/dist/http-transport.test.js +0 -56
- package/dist/index.js +1 -48
- package/dist/input-schema.js +2 -76
- package/dist/input-schema.test.js +1 -15
- package/dist/instructions-budget.test.js +0 -2
- package/dist/manifest-size.test.js +3 -72
- package/dist/oauth-bootstrap.js +0 -47
- package/dist/oauth-bootstrap.test.js +4 -16
- package/dist/orboto-client.js +0 -30
- package/dist/orboto-client.test.js +0 -12
- package/dist/prompts.js +0 -39
- package/dist/protect-text-usage.test.js +1 -9
- package/dist/required-rules-wrapper.test.js +0 -5
- package/dist/required-rules.js +0 -2
- package/dist/resources.js +0 -62
- package/dist/resources.test.js +0 -16
- package/dist/response-budget.js +2 -150
- package/dist/response-budget.test.js +1 -32
- package/dist/server.js +4 -180
- package/dist/session-nudge.js +0 -9
- package/dist/session-nudge.test.js +2 -10
- package/dist/session-nudge.transport.test.js +0 -23
- package/dist/tool-annotations.test.js +0 -44
- package/dist/tool-docs.js +1 -12
- package/dist/tool-docs.test.js +2 -8
- package/dist/tool-parameters.test.js +0 -19
- package/dist/tools/absence-writes.js +1 -17
- package/dist/tools/admin-translations.js +0 -11
- package/dist/tools/admin-writes.js +0 -28
- package/dist/tools/admin-writes.test.js +0 -1
- package/dist/tools/agent-coordination.js +2 -24
- package/dist/tools/agent-coordination.test.js +0 -2
- package/dist/tools/agent-instructions.js +0 -10
- package/dist/tools/agent-instructions.test.js +0 -2
- package/dist/tools/agent-messages.js +0 -1
- package/dist/tools/ai-status.js +0 -14
- package/dist/tools/ai-usage.js +0 -10
- package/dist/tools/analytics.js +0 -2
- package/dist/tools/api-call.js +0 -12
- package/dist/tools/api-call.test.js +1 -2
- package/dist/tools/api-search.js +1 -6
- package/dist/tools/approvals.js +0 -6
- package/dist/tools/attach.js +0 -25
- package/dist/tools/backup.js +0 -13
- package/dist/tools/backup.test.js +1 -2
- package/dist/tools/bulk-create.js +1 -25
- package/dist/tools/bulk-create.test.js +0 -4
- package/dist/tools/bulk-writes.js +69 -61
- package/dist/tools/bulk-writes.test.js +34 -4
- package/dist/tools/check-similar.js +0 -17
- package/dist/tools/checklist-writes.js +82 -27
- package/dist/tools/checklist-writes.test.js +69 -1
- package/dist/tools/claim.js +1 -71
- package/dist/tools/claim.test.js +0 -2
- package/dist/tools/critical-path.js +0 -5
- package/dist/tools/critical-path.test.js +0 -3
- package/dist/tools/cross-project-links.ee.js +1 -24
- package/dist/tools/doc-attachments.js +0 -28
- package/dist/tools/doc-attachments.test.js +1 -4
- package/dist/tools/doc-comments.js +1 -37
- package/dist/tools/doc-comments.test.js +1 -2
- package/dist/tools/doc-edits.js +1 -31
- package/dist/tools/doc-edits.test.js +0 -9
- package/dist/tools/doc-export.js +0 -24
- package/dist/tools/doc-export.test.js +0 -12
- package/dist/tools/doc-revisions.js +0 -23
- package/dist/tools/docs-ai.js +0 -34
- package/dist/tools/docs-ai.test.js +0 -11
- package/dist/tools/docs.js +1 -75
- package/dist/tools/docs.test.js +1 -3
- package/dist/tools/embedding-status.js +0 -13
- package/dist/tools/embedding-status.test.js +0 -1
- package/dist/tools/feedback.js +1 -15
- package/dist/tools/feedback.test.js +0 -1
- package/dist/tools/get-checklists.js +6 -14
- package/dist/tools/get-project-primer.js +1 -25
- package/dist/tools/get-project.js +0 -17
- package/dist/tools/get-ticket.js +1 -61
- package/dist/tools/get-timer.js +0 -11
- package/dist/tools/git-health-reasons.js +0 -1
- package/dist/tools/identity.js +0 -13
- package/dist/tools/list-projects.js +0 -20
- package/dist/tools/list-projects.test.js +0 -1
- package/dist/tools/list-tickets.js +10 -9
- package/dist/tools/milestones-crud.test.js +0 -4
- package/dist/tools/milestones.js +0 -43
- package/dist/tools/my-tickets.js +0 -7
- package/dist/tools/phase-b-tools.test.js +14 -21
- package/dist/tools/primer-facts.js +1 -49
- package/dist/tools/primer-facts.test.js +0 -20
- package/dist/tools/project-listings.js +0 -25
- package/dist/tools/query.js +1 -12
- package/dist/tools/query.test.js +0 -8
- package/dist/tools/raci.js +0 -11
- package/dist/tools/requirements-spec.js +1 -12
- package/dist/tools/response-expand.js +0 -19
- package/dist/tools/review-policy.js +0 -24
- package/dist/tools/review-policy.test.js +0 -2
- package/dist/tools/search.js +1 -13
- package/dist/tools/session-start.js +1 -136
- package/dist/tools/session-start.test.js +1 -44
- package/dist/tools/set-parent.js +1 -19
- package/dist/tools/shared.js +0 -41
- package/dist/tools/shared.test.js +0 -4
- package/dist/tools/similar-projection.js +0 -15
- package/dist/tools/ticket-attachments.js +1 -25
- package/dist/tools/ticket-schedules.js +0 -9
- package/dist/tools/ticket-schedules.test.js +0 -1
- package/dist/tools/ticket-writes.js +1 -110
- package/dist/tools/ticket-writes.test.js +1 -15
- package/dist/tools/time-writes.js +26 -46
- package/dist/tools/time-writes.test.js +18 -0
- package/dist/tools/update-project.js +1 -27
- package/dist/tools/update-project.test.js +1 -1
- package/dist/tools/wiki.js +0 -8
- package/dist/tools/work-next.js +0 -5
- package/dist/tools/work-sessions-start.test.js +0 -2
- package/dist/tools/work-sessions.js +0 -48
- package/dist/tools/work-sessions.test.js +0 -5
- package/dist/toolset.js +2 -81
- package/dist/toolset.test.js +0 -20
- package/dist/version-guard.test.js +0 -4
- package/dist/version.js +0 -12
- package/dist/with-metrics-output-schema.test.js +1 -14
- package/dist/with-metrics.js +2 -74
- package/dist/with-metrics.test.js +7 -29
- package/package.json +1 -1
package/dist/response-budget.js
CHANGED
|
@@ -1,44 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1697 - the central MCP response budget.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS
|
|
5
|
-
* ---------------
|
|
6
|
-
* An MCP tool result is not paid once. The client re-sends the whole
|
|
7
|
-
* conversation on every subsequent request, so a result costs its size
|
|
8
|
-
* TIMES the number of turns that follow it. The 2026-08-09 transcript
|
|
9
|
-
* audit (6.453 real calls, 32 transcripts - see doc ORB-D20) measured
|
|
10
|
-
* `list_agent_instructions` at 238 Mtok of carry cost from only THREE
|
|
11
|
-
* calls, because 37k characters landed 15 % into a session and rode
|
|
12
|
-
* along for the remaining 85 %.
|
|
13
|
-
*
|
|
14
|
-
* Two facts from that audit shape this module:
|
|
15
|
-
*
|
|
16
|
-
* 1. In Claude Code, only `structuredContent` reaches the model - the
|
|
17
|
-
* Markdown `content` block the handlers also build is dropped. So
|
|
18
|
-
* the budget MUST measure and cut the structured payload; cutting
|
|
19
|
-
* only the text would look like a fix and change nothing. Other
|
|
20
|
-
* clients do the opposite, so both are measured and both are cut.
|
|
21
|
-
* 2. Per-tool discipline does not hold across 168 registered tools.
|
|
22
|
-
* The cap is applied once, in `with-metrics.ts`, which every tool
|
|
23
|
-
* is already registered through.
|
|
24
|
-
*
|
|
25
|
-
* CONTRACT
|
|
26
|
-
* --------
|
|
27
|
-
* Truncation is never silent. An over-budget result comes back with a
|
|
28
|
-
* `__truncation` block naming every cut path, plus a handle, and the
|
|
29
|
-
* omitted remainder stays fetchable via `orboto_response_expand` for
|
|
30
|
-
* as long as the handle lives (in-process, 15 min, 16 payloads).
|
|
31
|
-
*
|
|
32
|
-
* Shrinking is shape-preserving: keys never disappear, arrays stay
|
|
33
|
-
* arrays, and strings stay strings. That matters because 7 tools
|
|
34
|
-
* declare an `outputSchema` the SDK validates the payload against.
|
|
35
|
-
* Adding `__truncation` is safe (no schema uses `.strict()`, and Zod v3
|
|
36
|
-
* accepts unknown keys), and identifier-shaped strings are protected by
|
|
37
|
-
* MIN_STRING_CUT - a uuid (36) or a ticket key is far below it, so
|
|
38
|
-
* `.uuid()` / `.url()` fields are never cut in half.
|
|
39
|
-
*
|
|
40
|
-
* This module never throws. A malformed payload returns unbudgeted
|
|
41
|
-
* rather than failing the tool call.
|
|
42
3
|
*/
|
|
43
4
|
import { randomUUID } from 'node:crypto';
|
|
44
5
|
import { z } from 'zod';
|
|
@@ -47,21 +8,7 @@ export const DEFAULT_BUDGET_CHARS = 4000;
|
|
|
47
8
|
/**
|
|
48
9
|
* Per-tool overrides.
|
|
49
10
|
*
|
|
50
|
-
*
|
|
51
|
-
* "which of these do I open?" does not need more. Two categories
|
|
52
|
-
* legitimately need more and are raised deliberately:
|
|
53
|
-
*
|
|
54
|
-
* - CONTENT READS. The caller explicitly asked for a document, a
|
|
55
|
-
* generated report or a primer. Cutting those to 4k would break the
|
|
56
|
-
* tool's purpose rather than remove waste.
|
|
57
|
-
*
|
|
58
|
-
* `orboto_session_start` used to be the third category at 48.000
|
|
59
|
-
* characters, and ORB-1818 removed that exemption: the digest now
|
|
60
|
-
* answers with a rules INDEX plus a hash instead of the full rule text,
|
|
61
|
-
* so the default answer fits the 4k default like every other tool. The
|
|
62
|
-
* two answers that DO carry the full rules (`forceRules: true`,
|
|
63
|
-
* `rulesOnly: true`) protect them instead of buying size - see
|
|
64
|
-
* PROTECTED_PATHS and PROTECT_TEXT_META below.
|
|
11
|
+
* @see ORB-1818
|
|
65
12
|
*/
|
|
66
13
|
export const TOOL_BUDGET_CHARS = {
|
|
67
14
|
orboto_get_project_primer: 16_000,
|
|
@@ -73,57 +20,18 @@ export const TOOL_BUDGET_CHARS = {
|
|
|
73
20
|
orboto_customer_report: 16_000,
|
|
74
21
|
orboto_requirements_spec: 16_000,
|
|
75
22
|
orboto_get_attachment: 16_000,
|
|
76
|
-
// The continuation tool itself: a chunk is served AT the budget, so a
|
|
77
|
-
// cap equal to the default would truncate the chunk it just sized.
|
|
78
23
|
orboto_response_expand: 8_000,
|
|
79
|
-
// ORB-1518 - detail mode returns a full endpoint schema (parameters +
|
|
80
|
-
// request body + responses), which is the content the caller asked
|
|
81
|
-
// for; 4k would cut most real schemas in half.
|
|
82
24
|
orboto_api_search: 8_000,
|
|
83
|
-
// ORB-1519 - the proxy envelope IS the content the caller asked for
|
|
84
|
-
// (an arbitrary endpoint's response); the proxy already caps it, this
|
|
85
|
-
// budget keeps the MCP-side carry cost bounded on top.
|
|
86
25
|
orboto_api_call: 8_000,
|
|
87
26
|
};
|
|
88
27
|
/**
|
|
89
28
|
* Paths the shrinker must never touch, per tool.
|
|
90
|
-
*
|
|
91
|
-
* The shrinker cuts the LARGEST leaf first, which is the right default and
|
|
92
|
-
* exactly wrong for one case: in `session_start` the largest string is the
|
|
93
|
-
* workspace's binding rules. Truncating a mandatory rule to save context is
|
|
94
|
-
* worse than paying for it - an agent that never sees the second half of a
|
|
95
|
-
* rule breaks it silently. Measured on 2026-08-09: a cold
|
|
96
|
-
* `session_start({ ticketKey })` is 47.694 characters, and without this
|
|
97
|
-
* list the budget cut 2.917 characters out of the rules block.
|
|
98
|
-
*
|
|
99
|
-
* A protected path is not exempt from the budget - the shrinker moves on to
|
|
100
|
-
* the next-largest cuttable leaf (for that call: the project primer, which
|
|
101
|
-
* is a digest with its own server-side budget and is safe to cut
|
|
102
|
-
* explicitly). If nothing else can be cut, the response reports
|
|
103
|
-
* `atFloor: true` and stays whole.
|
|
104
29
|
*/
|
|
105
30
|
export const PROTECTED_PATHS = {
|
|
106
31
|
orboto_session_start: ['rules'],
|
|
107
32
|
};
|
|
108
33
|
/**
|
|
109
34
|
* ORB-1818 - per-call protection of the TEXT half.
|
|
110
|
-
*
|
|
111
|
-
* PROTECTED_PATHS covers the structured half, which is what Claude Code
|
|
112
|
-
* pays for; a text-only client pays the Markdown block instead, and that
|
|
113
|
-
* half is cut on a line boundary with no notion of protected paths. For
|
|
114
|
-
* an answer whose whole payload IS the binding rules (`session_start`
|
|
115
|
-
* with `forceRules` or `rulesOnly`), cutting either half truncates a
|
|
116
|
-
* mandatory rule - the exact thing the protection above exists to
|
|
117
|
-
* prevent, just for the other client class.
|
|
118
|
-
*
|
|
119
|
-
* A handler marks such a result by setting `result._meta[PROTECT_TEXT_META]
|
|
120
|
-
* = true`. The flag is READ AND REMOVED here, so it never reaches the
|
|
121
|
-
* wire. It protects; it does not exempt: unprotected structured leaves in
|
|
122
|
-
* the same result are still cut normally.
|
|
123
|
-
*
|
|
124
|
-
* This is deliberately not a general "skip the budget" escape hatch -
|
|
125
|
-
* `protect-text-usage.test.ts` fails the build if a file other than the
|
|
126
|
-
* session-start tool sets it.
|
|
127
35
|
*/
|
|
128
36
|
export const PROTECT_TEXT_META = 'orboto/protectText';
|
|
129
37
|
function takeProtectTextFlag(result) {
|
|
@@ -151,9 +59,6 @@ const MIN_ARRAY_CUT = 3;
|
|
|
151
59
|
const MAX_CUT_PASSES = 64;
|
|
152
60
|
export const HANDLE_TTL_MS = 15 * 60 * 1000;
|
|
153
61
|
export const MAX_HANDLES = 16;
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
// Configuration
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
62
|
/**
|
|
158
63
|
* `ORBOTO_MCP_RESPONSE_BUDGET=off` disables the cap entirely (escape
|
|
159
64
|
* hatch for a client that genuinely wants everything);
|
|
@@ -170,9 +75,6 @@ export function budgetFor(toolName, env = process.env) {
|
|
|
170
75
|
const raw = Number(env.ORBOTO_MCP_RESPONSE_BUDGET_CHARS);
|
|
171
76
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_BUDGET_CHARS;
|
|
172
77
|
}
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
// Measurement - what the client actually pays for
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
78
|
function textOf(part) {
|
|
177
79
|
if (part && typeof part === 'object' && 'text' in part) {
|
|
178
80
|
const t = part.text;
|
|
@@ -183,14 +85,6 @@ function textOf(part) {
|
|
|
183
85
|
}
|
|
184
86
|
/**
|
|
185
87
|
* The two halves a result is made of, measured separately.
|
|
186
|
-
*
|
|
187
|
-
* A handler returns the SAME data twice: `structuredContent` for
|
|
188
|
-
* schema-aware clients and a Markdown text block for the rest. No known
|
|
189
|
-
* client charges for both - Claude Code stores only the structured JSON
|
|
190
|
-
* in its transcript (verified 2026-08-09), text-only clients see just the
|
|
191
|
-
* text - so summing them would double-count and cut twice as hard as the
|
|
192
|
-
* cost justifies. The budget is therefore enforced on each half, and the
|
|
193
|
-
* reported cost is the larger of the two: what one client actually pays.
|
|
194
88
|
*/
|
|
195
89
|
export function measureHalves(result) {
|
|
196
90
|
let textChars = 0;
|
|
@@ -222,7 +116,6 @@ function pruneHandles(now) {
|
|
|
222
116
|
if (now - value.storedAt > HANDLE_TTL_MS)
|
|
223
117
|
handles.delete(key);
|
|
224
118
|
}
|
|
225
|
-
// Map iteration order is insertion order, so the oldest entries go first.
|
|
226
119
|
while (handles.size > MAX_HANDLES) {
|
|
227
120
|
const oldest = handles.keys().next();
|
|
228
121
|
if (oldest.done)
|
|
@@ -250,14 +143,10 @@ export function annotatePayload(handle, omitted) {
|
|
|
250
143
|
export function resetPayloadStore() {
|
|
251
144
|
handles.clear();
|
|
252
145
|
}
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
// Path addressing - `ticketBundle.primer.markdown`, `comments[3].body`
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
146
|
export function resolvePath(root, path) {
|
|
257
147
|
if (path === '' || path === '$')
|
|
258
148
|
return root;
|
|
259
149
|
let current = root;
|
|
260
|
-
// Split on `.` and `[i]` in one pass so both forms address the same tree.
|
|
261
150
|
for (const token of path.split(/\.|(?=\[)/)) {
|
|
262
151
|
if (current === null || current === undefined)
|
|
263
152
|
return undefined;
|
|
@@ -290,7 +179,6 @@ function collectCandidates(root, protectedPaths = []) {
|
|
|
290
179
|
if (!node || typeof node !== 'object')
|
|
291
180
|
return;
|
|
292
181
|
for (const [key, value] of Object.entries(node)) {
|
|
293
|
-
// Never cut the truncation metadata itself - it is the way back.
|
|
294
182
|
if (key === '__truncation')
|
|
295
183
|
continue;
|
|
296
184
|
const childPath = path ? `${path}.${key}` : key;
|
|
@@ -321,11 +209,6 @@ function collectCandidates(root, protectedPaths = []) {
|
|
|
321
209
|
walk(root, '');
|
|
322
210
|
return out.sort((a, b) => b.size - a.size);
|
|
323
211
|
}
|
|
324
|
-
// ORB-1738 - the ADVERTISED zod shape of the marker, consumed centrally in
|
|
325
|
-
// registerWithMetrics: every declared outputSchema is extended with an
|
|
326
|
-
// optional `__truncation` so strict clients (which validate structured
|
|
327
|
-
// content against the advertised JSON schema, additionalProperties:false)
|
|
328
|
-
// accept over-budget responses. Keep in lockstep with TruncationBlock.
|
|
329
212
|
export const TruncationBlockSchema = z.object({
|
|
330
213
|
handle: z.string(),
|
|
331
214
|
budgetChars: z.number(),
|
|
@@ -344,17 +227,7 @@ export const TruncationBlockSchema = z.object({
|
|
|
344
227
|
/**
|
|
345
228
|
* ORB-1805 - what registerWithMetrics actually advertises.
|
|
346
229
|
*
|
|
347
|
-
*
|
|
348
|
-
* validator the tests assert against), but serialising all eight of its
|
|
349
|
-
* fields costs ~620 characters in EVERY tool that declares an
|
|
350
|
-
* outputSchema - 14 tools in the full manifest, 5 in the curated one -
|
|
351
|
-
* for a block a caller only ever reads. The advertised form declares the
|
|
352
|
-
* one field a caller acts on (`handle`) and stays open for the rest, so
|
|
353
|
-
* a strict client still accepts an over-budget payload (ORB-1738, the
|
|
354
|
-
* reason the marker is advertised at all) at a third of the bytes.
|
|
355
|
-
*
|
|
356
|
-
* Strictly more permissive than TruncationBlockSchema, so nothing that
|
|
357
|
-
* validated before can fail now.
|
|
230
|
+
* @see ORB-1738
|
|
358
231
|
*/
|
|
359
232
|
export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
|
|
360
233
|
.passthrough()
|
|
@@ -369,9 +242,6 @@ function stringMarker(omitted, handle, path) {
|
|
|
369
242
|
* admin MCP-usage panel instead of needing another transcript audit.
|
|
370
243
|
*/
|
|
371
244
|
export function applyResponseBudget(toolName, result, env = process.env) {
|
|
372
|
-
// ORB-1818 - read (and strip) the per-call text protection before
|
|
373
|
-
// anything else, so the flag never rides the wire even on the
|
|
374
|
-
// under-budget path.
|
|
375
245
|
const protectText = takeProtectTextFlag(result);
|
|
376
246
|
const originalChars = measureResult(result);
|
|
377
247
|
if (!budgetEnabled(env)) {
|
|
@@ -385,8 +255,6 @@ export function applyResponseBudget(toolName, result, env = process.env) {
|
|
|
385
255
|
return shrink(toolName, result, budget, originalChars, protectText);
|
|
386
256
|
}
|
|
387
257
|
catch {
|
|
388
|
-
// A budget must never break a tool call. Report the real size so the
|
|
389
|
-
// panel still shows the pressure, and hand back the untouched result.
|
|
390
258
|
return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
|
|
391
259
|
}
|
|
392
260
|
}
|
|
@@ -402,14 +270,9 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
402
270
|
const structured = result.structuredContent === undefined
|
|
403
271
|
? undefined
|
|
404
272
|
: JSON.parse(safeStringify(result.structuredContent));
|
|
405
|
-
// Reserve room for the truncation block and the text notice so the
|
|
406
|
-
// final measurement lands under budget, not just near it.
|
|
407
273
|
const RESERVE = 700;
|
|
408
274
|
const target = Math.max(MIN_STRING_KEEP, budget - RESERVE);
|
|
409
|
-
// Each half is capped against the same target - see measureHalves().
|
|
410
275
|
const measure = () => (structured === undefined ? 0 : safeStringify(structured).length);
|
|
411
|
-
// The text half: cut on a line boundary so a reader never gets half a
|
|
412
|
-
// sentence, and record how much was dropped.
|
|
413
276
|
let currentTextLength = fullText.length;
|
|
414
277
|
let textOmitted = 0;
|
|
415
278
|
if (!protectText && fullText.length > target) {
|
|
@@ -438,8 +301,6 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
438
301
|
continue;
|
|
439
302
|
}
|
|
440
303
|
const items = largest.value;
|
|
441
|
-
// Drop from the tail: list tools order by relevance, so the head is
|
|
442
|
-
// the part a caller reads first.
|
|
443
304
|
let keptItems = items.length;
|
|
444
305
|
while (keptItems > MIN_ARRAY_KEEP) {
|
|
445
306
|
const trial = items.slice(0, keptItems - 1);
|
|
@@ -462,10 +323,6 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
462
323
|
: omitted;
|
|
463
324
|
annotatePayload(handle, allOmitted);
|
|
464
325
|
const atFloor = measure() > target;
|
|
465
|
-
// ORB-1818 - the floor case gets its OWN notice. Telling an agent its
|
|
466
|
-
// response was truncated when nothing was cut sends it hunting for a
|
|
467
|
-
// remainder that does not exist - and that is exactly what a protected
|
|
468
|
-
// answer (the binding rules) produces every time.
|
|
469
326
|
const notice = allOmitted.length > 0
|
|
470
327
|
? `[Response truncated to the MCP response budget (${budget} chars) - it would otherwise cost `
|
|
471
328
|
+ `${originalChars} chars on EVERY later request in this session. Omitted content is not lost: `
|
|
@@ -484,20 +341,15 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
484
341
|
...(atFloor ? { atFloor: true } : {}),
|
|
485
342
|
};
|
|
486
343
|
structured.__truncation = block;
|
|
487
|
-
// The cost model is per-half (see measureHalves), so what was omitted
|
|
488
|
-
// is the shrinkage of the half that dominates the price.
|
|
489
344
|
const finalChars = Math.max(safeStringify(structured).length, currentTextLength + notice.length + 2);
|
|
490
345
|
block.omittedChars = Math.max(0, originalChars - finalChars);
|
|
491
346
|
}
|
|
492
|
-
// Rebuild the content parts: the (possibly shortened) text plus the notice.
|
|
493
347
|
const shortenedText = currentTextLength >= fullText.length
|
|
494
348
|
? fullText
|
|
495
349
|
: fullText.slice(0, currentTextLength);
|
|
496
350
|
const content = fullText.length > 0
|
|
497
351
|
? [{ type: 'text', text: `${shortenedText}\n\n${notice}` }]
|
|
498
352
|
: [{ type: 'text', text: notice }];
|
|
499
|
-
// Non-text parts (images, resources) are never cut - they are not the
|
|
500
|
-
// bloat this budget is about, and slicing their payload would corrupt them.
|
|
501
353
|
const nonText = (result.content ?? []).filter((part) => textOf(part) === null);
|
|
502
354
|
const shrunk = {
|
|
503
355
|
...result,
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1697 - the central response budget.
|
|
3
|
-
*
|
|
4
|
-
* The contract under test, in the order it matters:
|
|
5
|
-
* 1. a result inside its budget is returned byte-identical;
|
|
6
|
-
* 2. an over-budget result is CUT, and the cut is explicit;
|
|
7
|
-
* 3. the cut preserves the payload SHAPE (keys stay, arrays stay
|
|
8
|
-
* arrays, identifier-shaped strings stay whole) - 7 tools declare an
|
|
9
|
-
* outputSchema the SDK validates the payload against;
|
|
10
|
-
* 4. nothing is lost: the omitted remainder is fetchable via the handle;
|
|
11
|
-
* 5. text and structured content are budgeted as separate halves, since
|
|
12
|
-
* no known client charges for both.
|
|
13
3
|
*/
|
|
14
4
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
15
5
|
import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, } from './response-budget.js';
|
|
@@ -34,11 +24,7 @@ beforeEach(() => {
|
|
|
34
24
|
describe('budget configuration', () => {
|
|
35
25
|
it('defaults to 4k and honours per-tool overrides', () => {
|
|
36
26
|
expect(budgetFor('orboto_whatever', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
37
|
-
// A content read is deliberately higher.
|
|
38
27
|
expect(budgetFor('orboto_get_doc', {})).toBeGreaterThan(DEFAULT_BUDGET_CHARS);
|
|
39
|
-
// ORB-1818 - session_start lost its 48k exemption: its default answer
|
|
40
|
-
// carries a rules INDEX plus a hash, not the rule text, so it lives on
|
|
41
|
-
// the plain default like every other tool.
|
|
42
28
|
expect(budgetFor('orboto_session_start', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
43
29
|
});
|
|
44
30
|
it('reads a custom default from the env, per-tool overrides still win', () => {
|
|
@@ -65,7 +51,6 @@ describe('measurement', () => {
|
|
|
65
51
|
const { textChars, structuredChars } = measureHalves(r);
|
|
66
52
|
expect(textChars).toBe(300);
|
|
67
53
|
expect(structuredChars).toBeGreaterThan(1000);
|
|
68
|
-
// Not the sum: no known client pays for both halves.
|
|
69
54
|
expect(measureResult(r)).toBe(structuredChars);
|
|
70
55
|
});
|
|
71
56
|
});
|
|
@@ -95,8 +80,6 @@ describe('over budget', () => {
|
|
|
95
80
|
expect(block.omittedChars).toBeGreaterThan(0);
|
|
96
81
|
expect(String(block.howToGetTheRest)).toContain('orboto_response_expand');
|
|
97
82
|
expect(block.omitted).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'description', kind: 'string' })]));
|
|
98
|
-
// The text half carries the notice too, for clients that ignore
|
|
99
|
-
// structuredContent entirely.
|
|
100
83
|
const text = out.result.content[0].text;
|
|
101
84
|
expect(text).toContain('orboto_response_expand');
|
|
102
85
|
expect(text).toContain(out.handle);
|
|
@@ -144,7 +127,6 @@ describe('shape preservation (outputSchema tools must still validate)', () => {
|
|
|
144
127
|
const sc = out.result.structuredContent;
|
|
145
128
|
expect(sc.items.length).toBeLessThan(60);
|
|
146
129
|
expect(sc.items.length).toBeGreaterThan(0);
|
|
147
|
-
// Head-first: the first row survives, the tail is what goes.
|
|
148
130
|
expect(sc.items[0].key).toBe('ORB-0');
|
|
149
131
|
const entry = truncationOf(out.result).omitted
|
|
150
132
|
.find((e) => e.path === 'items');
|
|
@@ -181,17 +163,13 @@ describe('shape preservation (outputSchema tools must still validate)', () => {
|
|
|
181
163
|
});
|
|
182
164
|
describe('the floor case - a payload made only of uncuttable leaves', () => {
|
|
183
165
|
it('reports atFloor instead of looping or mangling identifiers', () => {
|
|
184
|
-
// 500 short strings: every one is below MIN_STRING_CUT, so there is
|
|
185
|
-
// nothing safe to cut. The budget must give up loudly, not spin.
|
|
186
166
|
const structured = Object.fromEntries(Array.from({ length: 500 }, (_, i) => [`field_${i}`, `v${i}`.padEnd(90, 'x')]));
|
|
187
167
|
const out = applyResponseBudget(TOOL, result(structured));
|
|
188
168
|
expect(out.originalChars).toBeGreaterThan(budgetFor(TOOL));
|
|
189
169
|
const block = truncationOf(out.result);
|
|
190
170
|
expect(block.atFloor).toBe(true);
|
|
191
|
-
// Every field survived intact - a cut that cannot be made safely is
|
|
192
|
-
// not made at all.
|
|
193
171
|
const sc = out.result.structuredContent;
|
|
194
|
-
expect(Object.keys(sc)).toHaveLength(501);
|
|
172
|
+
expect(Object.keys(sc)).toHaveLength(501);
|
|
195
173
|
expect(sc.field_0).toBe(structured.field_0);
|
|
196
174
|
});
|
|
197
175
|
});
|
|
@@ -262,10 +240,6 @@ describe('the handle store is bounded', () => {
|
|
|
262
240
|
});
|
|
263
241
|
describe('protected paths - a mandatory rule is never cut (ORB-1697)', () => {
|
|
264
242
|
it('cuts the primer, not the rules, on an over-budget session_start', () => {
|
|
265
|
-
// The real shape: rules are the LARGEST string, so the default
|
|
266
|
-
// largest-first rule would have cut exactly the wrong thing. Measured
|
|
267
|
-
// on 2026-08-09 before this guard existed: 2.917 characters gone from
|
|
268
|
-
// the binding rules of a cold session_start({ ticketKey }).
|
|
269
243
|
const rules = 'R'.repeat(24_000);
|
|
270
244
|
const out = applyResponseBudget('orboto_session_start', result({
|
|
271
245
|
rules,
|
|
@@ -289,12 +263,9 @@ describe('protected paths - a mandatory rule is never cut (ORB-1697)', () => {
|
|
|
289
263
|
it('protects the subtree, not just the exact key', () => {
|
|
290
264
|
const out = applyResponseBudget('orboto_session_start', result({
|
|
291
265
|
rules: 'short rules',
|
|
292
|
-
// A hypothetical nested shape under a protected root stays protected.
|
|
293
266
|
ticketBundle: { note: 'N'.repeat(80_000) },
|
|
294
267
|
}));
|
|
295
268
|
const sc = out.result.structuredContent;
|
|
296
|
-
// ticketBundle is NOT protected, so this one does get cut - the guard is
|
|
297
|
-
// path-scoped, not a blanket exemption.
|
|
298
269
|
expect(sc.ticketBundle.note.length).toBeLessThan(80_000);
|
|
299
270
|
});
|
|
300
271
|
});
|
|
@@ -315,10 +286,8 @@ describe('per-call text protection (ORB-1818)', () => {
|
|
|
315
286
|
expect(out.result.structuredContent.rules).toBe(rules);
|
|
316
287
|
const text = out.result.content[0].text;
|
|
317
288
|
expect(text).toContain(rules);
|
|
318
|
-
// The floor notice must not claim a truncation that did not happen.
|
|
319
289
|
expect(text).not.toContain('Response truncated');
|
|
320
290
|
expect(text).toContain('NOTHING was omitted');
|
|
321
|
-
// The marker is transport-internal - it never reaches the client.
|
|
322
291
|
expect(out.result._meta).toBeUndefined();
|
|
323
292
|
});
|
|
324
293
|
it('protects the text half only - unprotected structured leaves are still cut', () => {
|