@orboto/mcp 0.178.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*/
|
|
43
|
+
import { randomUUID } from 'node:crypto';
|
|
44
|
+
import { z } from 'zod';
|
|
45
|
+
/** Default cap for a tool that does not declare its own. */
|
|
46
|
+
export const DEFAULT_BUDGET_CHARS = 4000;
|
|
47
|
+
/**
|
|
48
|
+
* Per-tool overrides.
|
|
49
|
+
*
|
|
50
|
+
* The default (4k) suits cards and lists: a response that answers
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
export const TOOL_BUDGET_CHARS = {
|
|
67
|
+
orboto_get_project_primer: 16_000,
|
|
68
|
+
orboto_get_doc: 16_000,
|
|
69
|
+
orboto_get_doc_revision: 16_000,
|
|
70
|
+
orboto_export_doc_md: 16_000,
|
|
71
|
+
orboto_ask_docs: 12_000,
|
|
72
|
+
orboto_wiki_ask: 12_000,
|
|
73
|
+
orboto_customer_report: 16_000,
|
|
74
|
+
orboto_requirements_spec: 16_000,
|
|
75
|
+
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
|
+
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
|
+
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
|
+
orboto_api_call: 8_000,
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* 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
|
+
*/
|
|
105
|
+
export const PROTECTED_PATHS = {
|
|
106
|
+
orboto_session_start: ['rules'],
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* 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
|
+
*/
|
|
128
|
+
export const PROTECT_TEXT_META = 'orboto/protectText';
|
|
129
|
+
function takeProtectTextFlag(result) {
|
|
130
|
+
const meta = result._meta;
|
|
131
|
+
if (!meta || meta[PROTECT_TEXT_META] !== true)
|
|
132
|
+
return false;
|
|
133
|
+
delete meta[PROTECT_TEXT_META];
|
|
134
|
+
if (Object.keys(meta).length === 0)
|
|
135
|
+
delete result._meta;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Never cut a string shorter than this. Keeps identifiers, keys, urls,
|
|
140
|
+
* hashes and short labels intact - the things a caller feeds back into
|
|
141
|
+
* another call, and the things `outputSchema` validators constrain.
|
|
142
|
+
*/
|
|
143
|
+
const MIN_STRING_CUT = 200;
|
|
144
|
+
/** A cut string always keeps at least this much of its head. */
|
|
145
|
+
const MIN_STRING_KEEP = 160;
|
|
146
|
+
/** An array is never cut below this many items. */
|
|
147
|
+
const MIN_ARRAY_KEEP = 1;
|
|
148
|
+
/** Arrays shorter than this are left alone (cutting 2 to 1 buys nothing). */
|
|
149
|
+
const MIN_ARRAY_CUT = 3;
|
|
150
|
+
/** Bound the cut loop; each pass removes the current largest offender. */
|
|
151
|
+
const MAX_CUT_PASSES = 64;
|
|
152
|
+
export const HANDLE_TTL_MS = 15 * 60 * 1000;
|
|
153
|
+
export const MAX_HANDLES = 16;
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Configuration
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
/**
|
|
158
|
+
* `ORBOTO_MCP_RESPONSE_BUDGET=off` disables the cap entirely (escape
|
|
159
|
+
* hatch for a client that genuinely wants everything);
|
|
160
|
+
* `ORBOTO_MCP_RESPONSE_BUDGET_CHARS=<n>` overrides the default cap.
|
|
161
|
+
* Per-tool overrides above still apply on top of a custom default.
|
|
162
|
+
*/
|
|
163
|
+
export function budgetEnabled(env = process.env) {
|
|
164
|
+
return (env.ORBOTO_MCP_RESPONSE_BUDGET ?? '').toLowerCase() !== 'off';
|
|
165
|
+
}
|
|
166
|
+
export function budgetFor(toolName, env = process.env) {
|
|
167
|
+
const perTool = TOOL_BUDGET_CHARS[toolName];
|
|
168
|
+
if (perTool !== undefined)
|
|
169
|
+
return perTool;
|
|
170
|
+
const raw = Number(env.ORBOTO_MCP_RESPONSE_BUDGET_CHARS);
|
|
171
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_BUDGET_CHARS;
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Measurement - what the client actually pays for
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function textOf(part) {
|
|
177
|
+
if (part && typeof part === 'object' && 'text' in part) {
|
|
178
|
+
const t = part.text;
|
|
179
|
+
if (typeof t === 'string')
|
|
180
|
+
return t;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 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
|
+
*/
|
|
195
|
+
export function measureHalves(result) {
|
|
196
|
+
let textChars = 0;
|
|
197
|
+
for (const part of result.content ?? []) {
|
|
198
|
+
const t = textOf(part);
|
|
199
|
+
textChars += t !== null ? t.length : safeStringify(part).length;
|
|
200
|
+
}
|
|
201
|
+
const structuredChars = result.structuredContent === undefined
|
|
202
|
+
? 0
|
|
203
|
+
: safeStringify(result.structuredContent).length;
|
|
204
|
+
return { textChars, structuredChars };
|
|
205
|
+
}
|
|
206
|
+
/** Characters the heaviest single client pays for this result. */
|
|
207
|
+
export function measureResult(result) {
|
|
208
|
+
const { textChars, structuredChars } = measureHalves(result);
|
|
209
|
+
return Math.max(textChars, structuredChars);
|
|
210
|
+
}
|
|
211
|
+
function safeStringify(value) {
|
|
212
|
+
try {
|
|
213
|
+
return JSON.stringify(value) ?? '';
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const handles = new Map();
|
|
220
|
+
function pruneHandles(now) {
|
|
221
|
+
for (const [key, value] of handles) {
|
|
222
|
+
if (now - value.storedAt > HANDLE_TTL_MS)
|
|
223
|
+
handles.delete(key);
|
|
224
|
+
}
|
|
225
|
+
// Map iteration order is insertion order, so the oldest entries go first.
|
|
226
|
+
while (handles.size > MAX_HANDLES) {
|
|
227
|
+
const oldest = handles.keys().next();
|
|
228
|
+
if (oldest.done)
|
|
229
|
+
break;
|
|
230
|
+
handles.delete(oldest.value);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export function storePayload(toolName, payload, now = Date.now()) {
|
|
234
|
+
const handle = randomUUID().slice(0, 8);
|
|
235
|
+
handles.set(handle, { toolName, storedAt: now, ...payload });
|
|
236
|
+
pruneHandles(now);
|
|
237
|
+
return handle;
|
|
238
|
+
}
|
|
239
|
+
export function readPayload(handle, now = Date.now()) {
|
|
240
|
+
pruneHandles(now);
|
|
241
|
+
return handles.get(handle) ?? null;
|
|
242
|
+
}
|
|
243
|
+
/** Record what the shrink pass cut, so the expand tool can list the paths. */
|
|
244
|
+
export function annotatePayload(handle, omitted) {
|
|
245
|
+
const stored = handles.get(handle);
|
|
246
|
+
if (stored)
|
|
247
|
+
stored.omitted = omitted;
|
|
248
|
+
}
|
|
249
|
+
/** Test seam - the store is process-global by design. */
|
|
250
|
+
export function resetPayloadStore() {
|
|
251
|
+
handles.clear();
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Path addressing - `ticketBundle.primer.markdown`, `comments[3].body`
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
export function resolvePath(root, path) {
|
|
257
|
+
if (path === '' || path === '$')
|
|
258
|
+
return root;
|
|
259
|
+
let current = root;
|
|
260
|
+
// Split on `.` and `[i]` in one pass so both forms address the same tree.
|
|
261
|
+
for (const token of path.split(/\.|(?=\[)/)) {
|
|
262
|
+
if (current === null || current === undefined)
|
|
263
|
+
return undefined;
|
|
264
|
+
const arrayIndex = /^\[(\d+)\]$/.exec(token);
|
|
265
|
+
if (arrayIndex) {
|
|
266
|
+
if (!Array.isArray(current))
|
|
267
|
+
return undefined;
|
|
268
|
+
current = current[Number(arrayIndex[1])];
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (token === '')
|
|
272
|
+
continue;
|
|
273
|
+
if (typeof current !== 'object')
|
|
274
|
+
return undefined;
|
|
275
|
+
current = current[token];
|
|
276
|
+
}
|
|
277
|
+
return current;
|
|
278
|
+
}
|
|
279
|
+
/** Collect every cuttable leaf, largest first. `protected` paths are skipped
|
|
280
|
+
* entirely (see PROTECTED_PATHS) - including their subtrees, so protecting a
|
|
281
|
+
* parent protects what hangs off it. */
|
|
282
|
+
function collectCandidates(root, protectedPaths = []) {
|
|
283
|
+
const out = [];
|
|
284
|
+
const isProtected = (path) => protectedPaths.some((p) => path === p || path.startsWith(`${p}.`) || path.startsWith(`${p}[`));
|
|
285
|
+
const walk = (node, path) => {
|
|
286
|
+
if (Array.isArray(node)) {
|
|
287
|
+
node.forEach((item, index) => walk(item, `${path}[${index}]`));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!node || typeof node !== 'object')
|
|
291
|
+
return;
|
|
292
|
+
for (const [key, value] of Object.entries(node)) {
|
|
293
|
+
// Never cut the truncation metadata itself - it is the way back.
|
|
294
|
+
if (key === '__truncation')
|
|
295
|
+
continue;
|
|
296
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
297
|
+
if (isProtected(childPath))
|
|
298
|
+
continue;
|
|
299
|
+
if (typeof value === 'string') {
|
|
300
|
+
if (value.length >= MIN_STRING_CUT) {
|
|
301
|
+
out.push({
|
|
302
|
+
path: childPath, kind: 'string', size: value.length,
|
|
303
|
+
parent: node, key, value,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (Array.isArray(value)) {
|
|
309
|
+
if (value.length >= MIN_ARRAY_CUT) {
|
|
310
|
+
out.push({
|
|
311
|
+
path: childPath, kind: 'array', size: safeStringify(value).length,
|
|
312
|
+
parent: node, key, value,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
value.forEach((item, index) => walk(item, `${childPath}[${index}]`));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
walk(value, childPath);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
walk(root, '');
|
|
322
|
+
return out.sort((a, b) => b.size - a.size);
|
|
323
|
+
}
|
|
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
|
+
export const TruncationBlockSchema = z.object({
|
|
330
|
+
handle: z.string(),
|
|
331
|
+
budgetChars: z.number(),
|
|
332
|
+
originalChars: z.number(),
|
|
333
|
+
omittedChars: z.number(),
|
|
334
|
+
omitted: z.array(z.object({
|
|
335
|
+
path: z.string(),
|
|
336
|
+
kind: z.string(),
|
|
337
|
+
omittedChars: z.number().optional(),
|
|
338
|
+
omittedItems: z.number().optional(),
|
|
339
|
+
keptItems: z.number().optional(),
|
|
340
|
+
})),
|
|
341
|
+
howToGetTheRest: z.string(),
|
|
342
|
+
atFloor: z.boolean().optional(),
|
|
343
|
+
});
|
|
344
|
+
/**
|
|
345
|
+
* ORB-1805 - what registerWithMetrics actually advertises.
|
|
346
|
+
*
|
|
347
|
+
* `TruncationBlockSchema` above is the exact runtime shape (and stays the
|
|
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.
|
|
358
|
+
*/
|
|
359
|
+
export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
|
|
360
|
+
.passthrough()
|
|
361
|
+
.describe('Response was cut; pass `handle` to orboto_response_expand.');
|
|
362
|
+
function stringMarker(omitted, handle, path) {
|
|
363
|
+
return `... [truncated ${omitted} chars - full value: orboto_response_expand handle="${handle}" path="${path}"]`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Cap a tool result at its budget. Returns the (possibly rewritten)
|
|
367
|
+
* result plus the numbers `with-metrics.ts` reports to
|
|
368
|
+
* `/admin/mcp/instrument`, so budget pressure stays visible in the
|
|
369
|
+
* admin MCP-usage panel instead of needing another transcript audit.
|
|
370
|
+
*/
|
|
371
|
+
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
|
+
const protectText = takeProtectTextFlag(result);
|
|
376
|
+
const originalChars = measureResult(result);
|
|
377
|
+
if (!budgetEnabled(env)) {
|
|
378
|
+
return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
|
|
379
|
+
}
|
|
380
|
+
const budget = budgetFor(toolName, env);
|
|
381
|
+
if (originalChars <= budget) {
|
|
382
|
+
return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
return shrink(toolName, result, budget, originalChars, protectText);
|
|
386
|
+
}
|
|
387
|
+
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
|
+
return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
394
|
+
const fullText = (result.content ?? [])
|
|
395
|
+
.map((part) => textOf(part) ?? '')
|
|
396
|
+
.join('\n');
|
|
397
|
+
const handle = storePayload(toolName, {
|
|
398
|
+
structuredContent: result.structuredContent,
|
|
399
|
+
text: fullText,
|
|
400
|
+
});
|
|
401
|
+
const omitted = [];
|
|
402
|
+
const structured = result.structuredContent === undefined
|
|
403
|
+
? undefined
|
|
404
|
+
: 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
|
+
const RESERVE = 700;
|
|
408
|
+
const target = Math.max(MIN_STRING_KEEP, budget - RESERVE);
|
|
409
|
+
// Each half is capped against the same target - see measureHalves().
|
|
410
|
+
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
|
+
let currentTextLength = fullText.length;
|
|
414
|
+
let textOmitted = 0;
|
|
415
|
+
if (!protectText && fullText.length > target) {
|
|
416
|
+
const boundary = fullText.lastIndexOf('\n', target);
|
|
417
|
+
currentTextLength = boundary > MIN_STRING_KEEP ? boundary : target;
|
|
418
|
+
textOmitted = fullText.length - currentTextLength;
|
|
419
|
+
}
|
|
420
|
+
let passes = 0;
|
|
421
|
+
while (structured !== undefined && measure() > target && passes < MAX_CUT_PASSES) {
|
|
422
|
+
passes += 1;
|
|
423
|
+
const candidates = collectCandidates(structured, PROTECTED_PATHS[toolName] ?? []);
|
|
424
|
+
const largest = candidates[0];
|
|
425
|
+
if (!largest)
|
|
426
|
+
break;
|
|
427
|
+
const overshoot = measure() - target;
|
|
428
|
+
if (largest.kind === 'string') {
|
|
429
|
+
const value = largest.value;
|
|
430
|
+
const marker = stringMarker(0, handle, largest.path).length + 8;
|
|
431
|
+
const keep = Math.max(MIN_STRING_KEEP, value.length - overshoot - marker);
|
|
432
|
+
if (keep >= value.length)
|
|
433
|
+
break;
|
|
434
|
+
const omittedChars = value.length - keep;
|
|
435
|
+
largest.parent[largest.key] =
|
|
436
|
+
value.slice(0, keep) + stringMarker(omittedChars, handle, largest.path);
|
|
437
|
+
omitted.push({ path: largest.path, kind: 'string', omittedChars });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
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
|
+
let keptItems = items.length;
|
|
444
|
+
while (keptItems > MIN_ARRAY_KEEP) {
|
|
445
|
+
const trial = items.slice(0, keptItems - 1);
|
|
446
|
+
keptItems -= 1;
|
|
447
|
+
if (safeStringify(trial).length <= Math.max(0, safeStringify(items).length - overshoot))
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (keptItems >= items.length)
|
|
451
|
+
break;
|
|
452
|
+
largest.parent[largest.key] = items.slice(0, keptItems);
|
|
453
|
+
omitted.push({
|
|
454
|
+
path: largest.path,
|
|
455
|
+
kind: 'array',
|
|
456
|
+
keptItems,
|
|
457
|
+
omittedItems: items.length - keptItems,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const allOmitted = textOmitted > 0
|
|
461
|
+
? [...omitted, { path: '$text', kind: 'string', omittedChars: textOmitted }]
|
|
462
|
+
: omitted;
|
|
463
|
+
annotatePayload(handle, allOmitted);
|
|
464
|
+
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
|
+
const notice = allOmitted.length > 0
|
|
470
|
+
? `[Response truncated to the MCP response budget (${budget} chars) - it would otherwise cost `
|
|
471
|
+
+ `${originalChars} chars on EVERY later request in this session. Omitted content is not lost: `
|
|
472
|
+
+ `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted.]`
|
|
473
|
+
: `[Response is ${originalChars} chars, over the MCP response budget (${budget} chars), and nothing in it `
|
|
474
|
+
+ 'could be cut safely - it is protected or uncuttable content. NOTHING was omitted; there is no remainder to fetch.]';
|
|
475
|
+
if (structured !== undefined && structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
|
|
476
|
+
const block = {
|
|
477
|
+
handle,
|
|
478
|
+
budgetChars: budget,
|
|
479
|
+
originalChars,
|
|
480
|
+
omittedChars: 0, // filled in below, once the final size is known
|
|
481
|
+
omitted: allOmitted,
|
|
482
|
+
howToGetTheRest: `Call orboto_response_expand { handle: "${handle}", path: "<one of omitted[].path>" } for the omitted `
|
|
483
|
+
+ 'remainder. Omit `path` to list what is available. The handle expires 15 minutes after this call.',
|
|
484
|
+
...(atFloor ? { atFloor: true } : {}),
|
|
485
|
+
};
|
|
486
|
+
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
|
+
const finalChars = Math.max(safeStringify(structured).length, currentTextLength + notice.length + 2);
|
|
490
|
+
block.omittedChars = Math.max(0, originalChars - finalChars);
|
|
491
|
+
}
|
|
492
|
+
// Rebuild the content parts: the (possibly shortened) text plus the notice.
|
|
493
|
+
const shortenedText = currentTextLength >= fullText.length
|
|
494
|
+
? fullText
|
|
495
|
+
: fullText.slice(0, currentTextLength);
|
|
496
|
+
const content = fullText.length > 0
|
|
497
|
+
? [{ type: 'text', text: `${shortenedText}\n\n${notice}` }]
|
|
498
|
+
: [{ 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
|
+
const nonText = (result.content ?? []).filter((part) => textOf(part) === null);
|
|
502
|
+
const shrunk = {
|
|
503
|
+
...result,
|
|
504
|
+
content: [...content, ...nonText],
|
|
505
|
+
...(structured === undefined ? {} : { structuredContent: structured }),
|
|
506
|
+
};
|
|
507
|
+
const responseChars = measureResult(shrunk);
|
|
508
|
+
return {
|
|
509
|
+
result: shrunk,
|
|
510
|
+
responseChars,
|
|
511
|
+
originalChars,
|
|
512
|
+
truncatedChars: Math.max(0, originalChars - responseChars),
|
|
513
|
+
handle,
|
|
514
|
+
};
|
|
515
|
+
}
|