@opengeni/api-router 0.5.7 → 0.7.3

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.
@@ -1,89 +1,32 @@
1
1
  /**
2
- * Byte/token caps for the cross-session read tools (`session_events`,
3
- * `session_get`) exposed to manager-style agents over MCP.
2
+ * Model-facing projections for cross-session monitoring tools.
4
3
  *
5
- * A long-lived manager session monitors its spawned workers by reading their
6
- * event timeline. A worker's events carry verbatim model output and, worse,
7
- * verbatim TOOL OUTPUTS (`agent.toolCall.output.payload.output`) and raw tool
8
- * call items (`agent.toolCall.created.payload.raw` / `.arguments`). Those are
9
- * sized for the worker's own context, not the manager's: a single
10
- * `session_events` page (the DB limit caps event COUNT, not BYTES) can return
11
- * tens of thousands of characters, and a manager that pages a busy worker piles
12
- * hundreds of thousands of characters into its own context in one monitoring
13
- * turn — the exact "parent ingests child" blow-up that bricks the manager.
14
- *
15
- * The manager rarely needs a worker's full message deltas / tool outputs
16
- * verbatim; it needs status + recent progress. So these tools cap what they
17
- * hand back in two stages, both pure and exhaustively testable here:
18
- *
19
- * 1. PER-EVENT FIELD TRIM (`capEventPayload` / `capPayloadValue`): walk each
20
- * event's payload and clamp any over-long string (and any over-large nested
21
- * object, by serializing then clamping) to a per-field budget, leaving an
22
- * explicit `…N chars truncated…` marker. Type-agnostic: it targets whatever
23
- * field is fat (`text`, `output`, `arguments`, `raw`, `delta`, …) without
24
- * enumerating event types, so a new fat event type is capped automatically.
25
- *
26
- * 2. HEAD+TAIL PAGE BUDGET (`capEventPage`): after per-event trim, if the page
27
- * still exceeds the total token budget, keep a HEAD (oldest, for entry
28
- * context) and a TAIL (newest, for recent progress) of events and drop the
29
- * middle, inserting one synthetic marker event that says how many were
30
- * dropped and how to get them (page with `after`/`limit`, or read the
31
- * notebook). Pagination semantics are preserved: `nextAfter` is still the
32
- * real highest `sequence` returned, so the next page starts exactly where
33
- * this one ended.
34
- *
35
- * Worker-side and UI consumers never go through here — they call the DB
36
- * functions or the REST routes directly. This module only shapes the MCP tool
37
- * result a manager model reads, and it is intentionally dependency-free (no DB)
38
- * so the cap logic can be unit-tested in isolation.
4
+ * `session_events` reads are selected tail/forward and filtered in PostgreSQL;
5
+ * this module is the independent final guard before JSON enters a manager
6
+ * model's context. It measures the exact pretty-printed MCP text, trims fat
7
+ * payload fields, and—only if still required—removes rows from the pagination
8
+ * edge while preserving a usable cursor. It never manufactures an event or
9
+ * advances across an event it did not return.
39
10
  */
40
11
 
41
- import type { SessionEvent } from "@opengeni/contracts";
42
-
43
- // ~4 chars per token is the same coarse estimate the runtime compaction path
44
- // uses; we only need an order-of-magnitude budget, not exact tokenization.
45
- const CHARS_PER_TOKEN = 4;
46
-
47
- export function estimateTokensFromChars(chars: number): number {
48
- return Math.ceil(chars / CHARS_PER_TOKEN);
49
- }
50
-
51
- function estimateValueTokens(value: unknown): number {
52
- return estimateTokensFromChars(safeStringify(value).length);
53
- }
54
-
55
- export type EventCapConfig = {
56
- // Per-event cap: max characters any single string field (or serialized
57
- // nested object) inside an event payload may contribute before it is clamped
58
- // with a truncation marker.
59
- perFieldChars: number;
60
- // Total page cap: max estimated tokens the whole returned event array may
61
- // occupy. When the per-event-trimmed page still exceeds this, head+tail
62
- // selection drops the middle.
63
- pageTokenBudget: number;
64
- // When head+tail selection kicks in, how many events to keep at each end.
65
- headEvents: number;
66
- tailEvents: number;
67
- };
12
+ import type {
13
+ Rig,
14
+ Session,
15
+ SessionEvent,
16
+ SessionEventPayloadMode,
17
+ SessionEventReadDirection,
18
+ SessionEventReadMode,
19
+ } from "@opengeni/contracts";
20
+ import { measureSessionEventJson } from "@opengeni/contracts";
68
21
 
69
- // ~2k chars (~500 tokens) per fat field keeps a status glance readable without
70
- // shipping a worker's whole tool output. ~10k-token page budget sits in the
71
- // 8–12k target band; head/tail of 8 keeps entry context plus recent progress.
72
- export const DEFAULT_EVENT_CAP: EventCapConfig = {
73
- perFieldChars: 2_000,
74
- pageTokenBudget: 10_000,
75
- headEvents: 8,
76
- tailEvents: 8,
77
- };
78
-
79
- // ~6k chars (~1.5k tokens) for a single session detail blob: resources/tools/
80
- // metadata are normally tiny, but agent-set metadata is unbounded, so clamp it.
22
+ export const SESSION_EVENT_MCP_MAX_BYTES = 64 * 1024;
23
+ export const SESSION_EVENT_MCP_FIELD_MAX_CHARS = 4_000;
81
24
  export const DEFAULT_SESSION_DETAIL_CHARS = 6_000;
25
+ export const SESSION_DETAIL_MCP_MAX_BYTES = 64 * 1024;
26
+ export const RIG_DETAIL_MCP_MAX_BYTES = 64 * 1024;
82
27
 
83
28
  function safeStringify(value: unknown): string {
84
- if (typeof value === "string") {
85
- return value;
86
- }
29
+ if (typeof value === "string") return value;
87
30
  try {
88
31
  return JSON.stringify(value) ?? String(value);
89
32
  } catch {
@@ -92,177 +35,488 @@ function safeStringify(value: unknown): string {
92
35
  }
93
36
 
94
37
  function truncationMarker(droppedChars: number): string {
95
- return `…[${droppedChars} chars truncated page with after/limit on session_events, or read the session notebook for the full content]`;
38
+ return `…[${droppedChars} chars omitted from this model monitoring projection; request explicit forensic full mode for any retained audit preview; original source output may not have been retained]`;
96
39
  }
97
40
 
98
41
  function clampString(value: string, maxChars: number): string {
99
- if (value.length <= maxChars) {
100
- return value;
101
- }
102
- // Keep a head and a small tail of the field so both the start and the end
103
- // (often the most diagnostic part of a tool output / error) survive.
42
+ if (value.length <= maxChars) return value;
104
43
  const dropped = value.length - maxChars;
105
44
  const headChars = Math.max(0, Math.floor(maxChars * 0.7));
106
45
  const tailChars = Math.max(0, maxChars - headChars);
107
- const head = value.slice(0, headChars);
108
46
  const tail = tailChars > 0 ? value.slice(value.length - tailChars) : "";
109
- return `${head}${truncationMarker(dropped)}${tail}`;
47
+ return `${value.slice(0, headChars)}${truncationMarker(dropped)}${tail}`;
110
48
  }
111
49
 
112
- /**
113
- * Recursively clamp any over-budget string or nested value inside a payload.
114
- * Strings longer than `perFieldChars` are head+tail clamped. Nested objects /
115
- * arrays whose serialized form exceeds `perFieldChars` are recursed into so the
116
- * clamp lands on the actual fat leaf; if recursion cannot shrink them enough
117
- * (e.g. thousands of tiny fields), the whole branch is replaced by its clamped
118
- * serialization. Plain scalars pass through untouched. A depth guard makes the
119
- * walk safe against pathological / cyclic structures.
120
- */
50
+ /** Recursively clamp fat leaves and collapse pathological containers. */
121
51
  export function capPayloadValue(value: unknown, perFieldChars: number, depth = 0): unknown {
122
- if (typeof value === "string") {
123
- return clampString(value, perFieldChars);
124
- }
125
- if (value === null || typeof value !== "object") {
126
- return value;
127
- }
128
- // Guard against pathological / cyclic structures: past a reasonable depth,
129
- // collapse to a clamped serialization.
130
- if (depth >= 8) {
131
- return clampString(safeStringify(value), perFieldChars);
132
- }
52
+ if (typeof value === "string") return clampString(value, perFieldChars);
53
+ if (value === null || typeof value !== "object") return value;
54
+ if (depth >= 8) return clampString(safeStringify(value), perFieldChars);
133
55
  const serializedLength = safeStringify(value).length;
134
- if (serializedLength <= perFieldChars) {
135
- return value;
136
- }
56
+ if (serializedLength <= perFieldChars) return value;
137
57
  if (Array.isArray(value)) {
138
58
  const mapped = value.map((entry) => capPayloadValue(entry, perFieldChars, depth + 1));
139
- if (safeStringify(mapped).length <= perFieldChars * 2) {
140
- return mapped;
141
- }
142
- return clampString(safeStringify(value), perFieldChars);
59
+ return safeStringify(mapped).length <= perFieldChars * 2
60
+ ? mapped
61
+ : clampString(safeStringify(value), perFieldChars);
143
62
  }
144
63
  const out: Record<string, unknown> = {};
145
64
  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
146
65
  out[key] = capPayloadValue(entry, perFieldChars, depth + 1);
147
66
  }
148
- // If recursion still left the object fat (many small fields), fall back to a
149
- // clamped serialization so the page budget is respected.
150
- if (safeStringify(out).length <= perFieldChars * 4) {
151
- return out;
152
- }
153
- return clampString(safeStringify(value), perFieldChars);
67
+ return safeStringify(out).length <= perFieldChars * 4
68
+ ? out
69
+ : clampString(safeStringify(value), perFieldChars);
154
70
  }
155
71
 
156
72
  export function capEventPayload(event: SessionEvent, perFieldChars: number): SessionEvent {
157
73
  const cappedPayload = capPayloadValue(event.payload, perFieldChars);
158
- if (cappedPayload === event.payload) {
159
- return event;
160
- }
161
- return { ...event, payload: cappedPayload };
74
+ return cappedPayload === event.payload ? event : { ...event, payload: cappedPayload };
162
75
  }
163
76
 
164
- export type CappedEventPage = {
77
+ export type SessionEventMcpPageInput = {
78
+ events: readonly SessionEvent[];
79
+ mode: SessionEventReadMode;
80
+ payloadMode: SessionEventPayloadMode;
81
+ direction: SessionEventReadDirection;
82
+ sourceHasMore: boolean;
83
+ sourceTruncatedBy: "count" | "bytes" | null;
84
+ after: number;
85
+ before: number | null;
86
+ maxBytes?: number | undefined;
87
+ };
88
+
89
+ export type SessionEventMcpPage = {
90
+ mode: SessionEventReadMode;
91
+ payloadMode: SessionEventPayloadMode;
92
+ direction: SessionEventReadDirection;
165
93
  events: SessionEvent[];
166
- // The real highest `sequence` among the events the DB returned, so the caller
167
- // can advance the cursor correctly even when the middle was dropped. Null
168
- // when the page was empty.
94
+ coveredSequence: { first: number; last: number } | null;
169
95
  nextAfter: number | null;
96
+ nextBefore: number | null;
97
+ hasMore: boolean;
170
98
  truncated: boolean;
99
+ truncation?: {
100
+ reasons: Array<"source_count" | "source_bytes" | "model_payload" | "model_bytes">;
101
+ omittedSide: "before" | "after";
102
+ resumeCursor: number | null;
103
+ };
104
+ bytes: number;
105
+ maxBytes: number;
171
106
  };
172
107
 
108
+ function prettyJsonBytes(value: unknown): number {
109
+ return Buffer.byteLength(JSON.stringify(value, null, 2), "utf8");
110
+ }
111
+
112
+ function setMeasuredBytes(page: SessionEventMcpPage): number {
113
+ let measured = page.bytes;
114
+ for (let attempt = 0; attempt < 8; attempt += 1) {
115
+ page.bytes = measured;
116
+ const next = prettyJsonBytes(page);
117
+ if (next === measured) return next;
118
+ measured = next;
119
+ }
120
+ page.bytes = measured;
121
+ return prettyJsonBytes(page);
122
+ }
123
+
173
124
  /**
174
- * Build a synthetic marker event that stands in for the dropped middle. It is
175
- * NOT a real persisted event; its `id` is the zero UUID and its sequence sits
176
- * between the kept head and tail so ordering by sequence stays monotonic. It
177
- * never participates in pagination (the caller derives `nextAfter` from the
178
- * real events, not this marker). Typed `session.status.changed` so the
179
- * synthetic event still validates against the `SessionEvent` contract.
125
+ * Build the exact model-visible page. Returned `bytes` includes all metadata
126
+ * and pretty-printing used by the MCP JSON adapter.
180
127
  */
181
- function buildTruncationEvent(
182
- template: SessionEvent,
183
- droppedCount: number,
184
- firstDroppedSequence: number,
185
- lastDroppedSequence: number,
186
- markerSequence: number,
187
- ): SessionEvent {
128
+ export function boundSessionEventMcpPage(input: SessionEventMcpPageInput): SessionEventMcpPage {
129
+ const maxBytes = Math.max(8 * 1024, input.maxBytes ?? SESSION_EVENT_MCP_MAX_BYTES);
130
+ let payloadTrimmed = false;
131
+ const events = input.events.map((event) => {
132
+ const capped = capEventPayload(event, SESSION_EVENT_MCP_FIELD_MAX_CHARS);
133
+ if (capped !== event) payloadTrimmed = true;
134
+ return capped;
135
+ });
136
+ let modelRowsDropped = false;
137
+
138
+ const build = (): SessionEventMcpPage => {
139
+ const first = events[0]?.sequence ?? null;
140
+ const last = events.at(-1)?.sequence ?? null;
141
+ const reasons: NonNullable<SessionEventMcpPage["truncation"]>["reasons"] = [];
142
+ if (input.sourceHasMore) {
143
+ reasons.push(input.sourceTruncatedBy === "bytes" ? "source_bytes" : "source_count");
144
+ }
145
+ if (payloadTrimmed) reasons.push("model_payload");
146
+ if (modelRowsDropped) reasons.push("model_bytes");
147
+ const nextAfter = input.direction === "after" ? (last ?? input.after) : null;
148
+ const nextBefore = input.direction === "before" ? (first ?? input.before) : null;
149
+ const page: SessionEventMcpPage = {
150
+ mode: input.mode,
151
+ payloadMode: input.payloadMode,
152
+ direction: input.direction,
153
+ events: [...events],
154
+ coveredSequence: first === null || last === null ? null : { first, last },
155
+ nextAfter,
156
+ nextBefore,
157
+ hasMore: input.sourceHasMore || modelRowsDropped,
158
+ truncated: reasons.length > 0,
159
+ ...(reasons.length > 0
160
+ ? {
161
+ truncation: {
162
+ reasons,
163
+ omittedSide: input.direction,
164
+ resumeCursor: input.direction === "after" ? nextAfter : nextBefore,
165
+ },
166
+ }
167
+ : {}),
168
+ bytes: 0,
169
+ maxBytes,
170
+ };
171
+ setMeasuredBytes(page);
172
+ return page;
173
+ };
174
+
175
+ let page = build();
176
+ while (page.bytes > maxBytes && events.length > 0) {
177
+ if (input.direction === "before") events.shift();
178
+ else events.pop();
179
+ modelRowsDropped = true;
180
+ page = build();
181
+ }
182
+ if (page.bytes > maxBytes) {
183
+ throw new RangeError(`Session-event MCP metadata exceeds its ${maxBytes}-byte envelope`);
184
+ }
185
+ return page;
186
+ }
187
+
188
+ type MonitoringPreviewState = {
189
+ remainingStringBytes: number;
190
+ remainingNodes: number;
191
+ truncated: boolean;
192
+ details: string[];
193
+ };
194
+
195
+ type SessionDetailFieldFact = {
196
+ truncated: boolean;
197
+ originalBytes: number | null;
198
+ deliveredBytes: number;
199
+ originalCount?: number;
200
+ deliveredCount?: number;
201
+ measurementBounded?: boolean;
202
+ };
203
+
204
+ function modelStringProjection(
205
+ value: string,
206
+ maxBytes: number,
207
+ ): {
208
+ value: string;
209
+ fact: SessionDetailFieldFact & { originalChars: number };
210
+ } {
211
+ const originalBytes = Buffer.byteLength(value, "utf8");
212
+ if (originalBytes <= maxBytes) {
213
+ return {
214
+ value,
215
+ fact: {
216
+ truncated: false,
217
+ originalBytes,
218
+ deliveredBytes: originalBytes,
219
+ originalChars: value.length,
220
+ },
221
+ };
222
+ }
223
+ let omittedBytes = originalBytes - maxBytes;
224
+ let head = "";
225
+ let tail = "";
226
+ let marker = "";
227
+ for (let attempt = 0; attempt < 4; attempt += 1) {
228
+ marker = `…[${omittedBytes} UTF-8 bytes omitted from model monitoring projection]…`;
229
+ const contentBudget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
230
+ head = utf8Prefix(value, Math.floor(contentBudget * 0.7));
231
+ tail = utf8Suffix(value, contentBudget - Buffer.byteLength(head, "utf8"));
232
+ const exact = Math.max(
233
+ 0,
234
+ originalBytes - Buffer.byteLength(head, "utf8") - Buffer.byteLength(tail, "utf8"),
235
+ );
236
+ if (exact === omittedBytes) break;
237
+ omittedBytes = exact;
238
+ }
239
+ const projected = `${head}${marker}${tail}`;
188
240
  return {
189
- id: "00000000-0000-0000-0000-000000000000",
190
- workspaceId: template.workspaceId,
191
- sessionId: template.sessionId,
192
- sequence: markerSequence,
193
- type: "session.status.changed",
194
- payload: {
195
- _truncated: true,
196
- note: `${droppedCount} event(s) (sequence ${firstDroppedSequence}–${lastDroppedSequence}) omitted from this monitoring view to keep the response bounded. Page the gap with session_events after=${firstDroppedSequence - 1} limit=… if you need them verbatim, or read the worker's session notebook.`,
197
- droppedCount,
198
- omittedSequenceRange: [firstDroppedSequence, lastDroppedSequence],
241
+ value: projected,
242
+ fact: {
243
+ truncated: true,
244
+ originalBytes,
245
+ deliveredBytes: Buffer.byteLength(projected, "utf8"),
246
+ originalChars: value.length,
199
247
  },
200
- occurredAt: template.occurredAt,
201
- clientEventId: null,
202
- turnId: null,
203
248
  };
204
249
  }
205
250
 
206
- /**
207
- * Apply per-event field trim then, if the page is still over budget, keep a
208
- * head and a tail of events and drop the middle behind a marker. `events` is
209
- * assumed oldest-first (as `listSessionEvents` returns).
210
- */
211
- export function capEventPage(
212
- events: SessionEvent[],
213
- config: EventCapConfig = DEFAULT_EVENT_CAP,
214
- ): CappedEventPage {
215
- const realLast = events[events.length - 1];
216
- const nextAfter = realLast ? realLast.sequence : null;
251
+ function utf8Prefix(value: string, maxBytes: number): string {
252
+ let index = 0;
253
+ let bytes = 0;
254
+ while (index < value.length) {
255
+ const codePoint = value.codePointAt(index)!;
256
+ const character = String.fromCodePoint(codePoint);
257
+ const nextBytes = Buffer.byteLength(character, "utf8");
258
+ if (bytes + nextBytes > maxBytes) break;
259
+ bytes += nextBytes;
260
+ index += character.length;
261
+ }
262
+ return value.slice(0, index);
263
+ }
217
264
 
218
- const trimmed = events.map((event) => capEventPayload(event, config.perFieldChars));
265
+ function utf8Suffix(value: string, maxBytes: number): string {
266
+ let index = value.length;
267
+ let bytes = 0;
268
+ while (index > 0) {
269
+ const last = value.charCodeAt(index - 1);
270
+ const width = last >= 0xdc00 && last <= 0xdfff && index > 1 ? 2 : 1;
271
+ const character = value.slice(index - width, index);
272
+ const nextBytes = Buffer.byteLength(character, "utf8");
273
+ if (bytes + nextBytes > maxBytes) break;
274
+ bytes += nextBytes;
275
+ index -= width;
276
+ }
277
+ return value.slice(index);
278
+ }
219
279
 
220
- let runningTokens = 0;
221
- let overBudget = false;
222
- for (const event of trimmed) {
223
- runningTokens += estimateValueTokens(event);
224
- if (runningTokens > config.pageTokenBudget) {
225
- overBudget = true;
226
- break;
280
+ function previewMonitoringValue(
281
+ value: unknown,
282
+ state: MonitoringPreviewState,
283
+ path = "$",
284
+ depth = 0,
285
+ ): unknown {
286
+ if (state.remainingNodes <= 0 || depth >= 8) {
287
+ state.truncated = true;
288
+ if (state.details.length < 24) state.details.push(`${path}: traversal boundary`);
289
+ return "[nested value omitted from model monitoring projection]";
290
+ }
291
+ state.remainingNodes -= 1;
292
+ if (typeof value === "string") {
293
+ const projected = modelStringProjection(value, Math.min(1_000, state.remainingStringBytes));
294
+ state.remainingStringBytes = Math.max(
295
+ 0,
296
+ state.remainingStringBytes - projected.fact.deliveredBytes,
297
+ );
298
+ if (projected.fact.truncated) {
299
+ state.truncated = true;
300
+ if (state.details.length < 24) state.details.push(`${path}: string truncated`);
227
301
  }
302
+ return projected.value;
228
303
  }
229
-
230
- const keepCount = config.headEvents + config.tailEvents;
231
- if (!overBudget || trimmed.length <= keepCount + 1) {
232
- return { events: trimmed, nextAfter, truncated: overBudget && trimmed.length > keepCount + 1 };
304
+ if (value === null || typeof value === "boolean" || typeof value === "number") return value;
305
+ if (typeof value !== "object") {
306
+ state.truncated = true;
307
+ if (state.details.length < 24) state.details.push(`${path}: non-JSON value omitted`);
308
+ return `[${typeof value} value omitted from model monitoring projection]`;
233
309
  }
310
+ if (Array.isArray(value)) {
311
+ const keep = Math.min(24, value.length);
312
+ const out = value
313
+ .slice(0, keep)
314
+ .map((entry, index) => previewMonitoringValue(entry, state, `${path}[${index}]`, depth + 1));
315
+ if (keep < value.length) {
316
+ state.truncated = true;
317
+ if (state.details.length < 24) {
318
+ state.details.push(`${path}: ${value.length - keep} array entries omitted`);
319
+ }
320
+ out.push({ omittedEntries: value.length - keep });
321
+ }
322
+ return out;
323
+ }
324
+ const out: Record<string, unknown> = {};
325
+ const entries = Object.entries(value as Record<string, unknown>);
326
+ const keep = Math.min(24, entries.length);
327
+ for (let index = 0; index < keep; index += 1) {
328
+ const [rawKey, entry] = entries[index]!;
329
+ const keyProjection = modelStringProjection(rawKey, 128).value;
330
+ const key = Object.prototype.hasOwnProperty.call(out, keyProjection)
331
+ ? `${keyProjection}#${index}`
332
+ : keyProjection;
333
+ out[key] = previewMonitoringValue(entry, state, `${path}.${keyProjection}`, depth + 1);
334
+ }
335
+ if (keep < entries.length) {
336
+ state.truncated = true;
337
+ if (state.details.length < 24) {
338
+ state.details.push(`${path}: ${entries.length - keep} object fields omitted`);
339
+ }
340
+ out.omittedFields = entries.length - keep;
341
+ }
342
+ return out;
343
+ }
234
344
 
235
- const head = trimmed.slice(0, config.headEvents);
236
- const tail = trimmed.slice(trimmed.length - config.tailEvents);
237
- const droppedStart = config.headEvents;
238
- const droppedEnd = trimmed.length - config.tailEvents - 1;
239
- const droppedCount = droppedEnd - droppedStart + 1;
240
- const firstDroppedSequence = trimmed[droppedStart]!.sequence;
241
- const lastDroppedSequence = trimmed[droppedEnd]!.sequence;
242
- // Marker sequence sits between the kept head and tail; reusing the last head
243
- // sequence keeps the returned page monotonic non-decreasing by sequence.
244
- const markerSequence = head[head.length - 1]!.sequence;
245
- const marker = buildTruncationEvent(
246
- realLast!,
247
- droppedCount,
248
- firstDroppedSequence,
249
- lastDroppedSequence,
250
- markerSequence,
251
- );
252
-
345
+ function projectMonitoringContainer(
346
+ value: unknown,
347
+ stringBytes: number,
348
+ ): { value: unknown; fact: SessionDetailFieldFact; details: string[] } {
349
+ const measurement = measureSessionEventJson(value);
350
+ const state: MonitoringPreviewState = {
351
+ remainingStringBytes: stringBytes,
352
+ remainingNodes: 128,
353
+ truncated: false,
354
+ details: [],
355
+ };
356
+ const preview = previewMonitoringValue(value, state);
357
+ const deliveredBytes = Buffer.byteLength(JSON.stringify(preview), "utf8");
358
+ const originalCount = Array.isArray(value)
359
+ ? value.length
360
+ : value !== null && typeof value === "object"
361
+ ? Object.keys(value).length
362
+ : undefined;
363
+ const deliveredCount = originalCount === undefined ? undefined : Math.min(24, originalCount);
364
+ const originalBytes = measurement.bytes;
365
+ const truncated =
366
+ state.truncated || originalBytes === null || (originalBytes ?? 0) !== deliveredBytes;
253
367
  return {
254
- events: [...head, marker, ...tail],
255
- nextAfter,
256
- truncated: true,
368
+ value: preview,
369
+ fact: {
370
+ truncated,
371
+ originalBytes,
372
+ deliveredBytes,
373
+ ...(originalCount === undefined ? {} : { originalCount }),
374
+ ...(deliveredCount === undefined ? {} : { deliveredCount }),
375
+ ...(measurement.bytes === null ? { measurementBounded: true } : {}),
376
+ },
377
+ details: state.details,
257
378
  };
258
379
  }
259
380
 
260
- /**
261
- * Clamp a single session-detail object for `session_get`. Only the unbounded
262
- * agent-controlled fields (`metadata`, and defensively `initialMessage`) can
263
- * grow large; everything else is small and structural. Returns a shallow copy
264
- * with those fields capped when over budget, otherwise the original reference.
265
- */
381
+ /** Purpose-built, flat, model-facing detail projection for `session_get`. */
382
+ export function boundSessionDetailMcp(
383
+ session: Session,
384
+ effectiveControl: unknown = session.effectiveControl,
385
+ maxBytes = SESSION_DETAIL_MCP_MAX_BYTES,
386
+ ) {
387
+ const title = session.title === null ? null : modelStringProjection(session.title, 512);
388
+ const initialMessage = modelStringProjection(session.initialMessage, 4_000);
389
+ const instructions =
390
+ session.instructions === null ? null : modelStringProjection(session.instructions, 4_000);
391
+ const metadata = projectMonitoringContainer(session.metadata, 3_000);
392
+ const resources = projectMonitoringContainer(session.resources, 3_000);
393
+ const tools = projectMonitoringContainer(session.tools, 4_000);
394
+ const mcpServers = projectMonitoringContainer(session.mcpServers, 3_000);
395
+ const permissions = projectMonitoringContainer(session.firstPartyMcpPermissions, 1_500);
396
+ const control = projectMonitoringContainer(effectiveControl, 2_000);
397
+ const fieldFacts: Record<string, SessionDetailFieldFact> = {
398
+ title: title?.fact ?? {
399
+ truncated: false,
400
+ originalBytes: 0,
401
+ deliveredBytes: 0,
402
+ },
403
+ initialMessage: initialMessage.fact,
404
+ instructions: instructions?.fact ?? {
405
+ truncated: false,
406
+ originalBytes: 0,
407
+ deliveredBytes: 0,
408
+ },
409
+ metadata: metadata.fact,
410
+ resources: resources.fact,
411
+ tools: tools.fact,
412
+ mcpServers: mcpServers.fact,
413
+ firstPartyMcpPermissions: permissions.fact,
414
+ effectiveControl: control.fact,
415
+ };
416
+ const details = [
417
+ ...metadata.details,
418
+ ...resources.details,
419
+ ...tools.details,
420
+ ...mcpServers.details,
421
+ ...permissions.details,
422
+ ...control.details,
423
+ ].slice(0, 32);
424
+ const result = {
425
+ id: session.id,
426
+ workspaceId: session.workspaceId,
427
+ accountId: session.accountId,
428
+ status: session.status,
429
+ title: title?.value ?? null,
430
+ titleSource: session.titleSource,
431
+ initialMessage: initialMessage.value,
432
+ instructions: instructions?.value ?? null,
433
+ resources: resources.value,
434
+ tools: tools.value,
435
+ metadata: metadata.value,
436
+ model: modelStringProjection(session.model, 512).value,
437
+ sandboxBackend: modelStringProjection(session.sandboxBackend, 128).value,
438
+ sandboxOs: session.sandboxOs,
439
+ sandboxGroupId: session.sandboxGroupId,
440
+ activeSandboxId: session.activeSandboxId,
441
+ activeEpoch: session.activeEpoch,
442
+ variableSetId: session.variableSetId,
443
+ environmentId: session.environmentId,
444
+ rigId: session.rigId,
445
+ rigVersionId: session.rigVersionId,
446
+ firstPartyMcpPermissions: permissions.value,
447
+ mcpServers: mcpServers.value,
448
+ parentSessionId: session.parentSessionId,
449
+ createIdempotencyKey:
450
+ session.createIdempotencyKey === null
451
+ ? null
452
+ : modelStringProjection(session.createIdempotencyKey, 512).value,
453
+ temporalWorkflowId:
454
+ session.temporalWorkflowId === null
455
+ ? null
456
+ : modelStringProjection(session.temporalWorkflowId, 512).value,
457
+ activeTurnId: session.activeTurnId,
458
+ lastInputTokens: session.lastInputTokens,
459
+ queueVersion: session.queueVersion,
460
+ queueHeadPosition: session.queueHeadPosition,
461
+ queueTailPosition: session.queueTailPosition,
462
+ effectiveControl: control.value,
463
+ lastSequence: session.lastSequence,
464
+ codexPinnedCredentialId: session.codexPinnedCredentialId,
465
+ codexLastCredentialId: session.codexLastCredentialId,
466
+ pinned: session.pinned,
467
+ pinnedAt: session.pinnedAt,
468
+ pinVersion: session.pinVersion,
469
+ ...(session.treeStats === undefined ? {} : { treeStats: session.treeStats }),
470
+ createdAt: session.createdAt,
471
+ updatedAt: session.updatedAt,
472
+ projection: {
473
+ truncated: Object.values(fieldFacts).some((fact) => fact.truncated),
474
+ fields: fieldFacts,
475
+ details,
476
+ bytes: 0,
477
+ maxBytes,
478
+ },
479
+ };
480
+ for (let attempt = 0; attempt < 8; attempt += 1) {
481
+ const measured = prettyJsonBytes(result);
482
+ if (result.projection.bytes === measured) break;
483
+ result.projection.bytes = measured;
484
+ }
485
+ const mutable = result as Record<string, any> & {
486
+ projection: typeof result.projection;
487
+ };
488
+ const fallbackContainers: Array<[string, SessionDetailFieldFact]> = [
489
+ ["tools", fieldFacts.tools!],
490
+ ["resources", fieldFacts.resources!],
491
+ ["metadata", fieldFacts.metadata!],
492
+ ["mcpServers", fieldFacts.mcpServers!],
493
+ ["effectiveControl", fieldFacts.effectiveControl!],
494
+ ["firstPartyMcpPermissions", fieldFacts.firstPartyMcpPermissions!],
495
+ ];
496
+ for (const [field, fact] of fallbackContainers) {
497
+ if (result.projection.bytes <= maxBytes) break;
498
+ const omission = {
499
+ preview: `[${field} preview omitted at final session_get byte boundary]`,
500
+ ...(fact.originalCount === undefined ? {} : { originalCount: fact.originalCount }),
501
+ };
502
+ mutable[field] = omission;
503
+ fact.truncated = true;
504
+ fact.deliveredBytes = Buffer.byteLength(JSON.stringify(omission), "utf8");
505
+ fact.deliveredCount = 0;
506
+ result.projection.truncated = true;
507
+ for (let attempt = 0; attempt < 8; attempt += 1) {
508
+ const measured = prettyJsonBytes(result);
509
+ if (result.projection.bytes === measured) break;
510
+ result.projection.bytes = measured;
511
+ }
512
+ }
513
+ if (result.projection.bytes > maxBytes) {
514
+ throw new RangeError(`session_get projection exceeds its ${maxBytes}-byte envelope`);
515
+ }
516
+ return result;
517
+ }
518
+
519
+ /** @deprecated use boundSessionDetailMcp for the actual MCP response. */
266
520
  export function capSessionDetail<T extends { metadata?: unknown; initialMessage?: unknown }>(
267
521
  session: T,
268
522
  perFieldChars: number = DEFAULT_SESSION_DETAIL_CHARS,
@@ -285,3 +539,168 @@ export function capSessionDetail<T extends { metadata?: unknown; initialMessage?
285
539
  }
286
540
  return changed ? out : session;
287
541
  }
542
+
543
+ /** Bounded current definition plus compact-by-query historical rig summaries. */
544
+ export function boundRigDetailMcp(
545
+ rig: Rig,
546
+ versionsPage: { versions: unknown[]; total: number; hasMore: boolean },
547
+ changesPage: { changes: unknown[]; total: number; hasMore: boolean },
548
+ maxBytes = RIG_DETAIL_MCP_MAX_BYTES,
549
+ ) {
550
+ const name = modelStringProjection(rig.name, 512);
551
+ const description =
552
+ rig.description === null ? null : modelStringProjection(rig.description, 2_000);
553
+ const active = rig.activeVersion;
554
+ const activeSetup =
555
+ active?.setupScript === null || active?.setupScript === undefined
556
+ ? null
557
+ : modelStringProjection(active.setupScript, 8_000);
558
+ const activeImage =
559
+ active?.image === null || active?.image === undefined
560
+ ? null
561
+ : modelStringProjection(active.image, 1_000);
562
+ const activeChangelog =
563
+ active?.changelog === null || active?.changelog === undefined
564
+ ? null
565
+ : modelStringProjection(active.changelog, 2_000);
566
+ const activeChecks = projectMonitoringContainer(active?.checks ?? [], 5_000);
567
+ const activeHooks = projectMonitoringContainer(active?.credentialHooks ?? [], 1_500);
568
+ const activeVariableSets = projectMonitoringContainer(active?.defaultVariableSetIds ?? [], 1_500);
569
+ const versions = projectMonitoringContainer(versionsPage.versions, 4_000);
570
+ const changes = projectMonitoringContainer(changesPage.changes, 4_000);
571
+ const fieldFacts: Record<string, SessionDetailFieldFact> = {
572
+ name: name.fact,
573
+ description: description?.fact ?? {
574
+ truncated: false,
575
+ originalBytes: 0,
576
+ deliveredBytes: 0,
577
+ },
578
+ activeSetupScript: activeSetup?.fact ?? {
579
+ truncated: false,
580
+ originalBytes: 0,
581
+ deliveredBytes: 0,
582
+ },
583
+ activeImage: activeImage?.fact ?? {
584
+ truncated: false,
585
+ originalBytes: 0,
586
+ deliveredBytes: 0,
587
+ },
588
+ activeChangelog: activeChangelog?.fact ?? {
589
+ truncated: false,
590
+ originalBytes: 0,
591
+ deliveredBytes: 0,
592
+ },
593
+ activeChecks: activeChecks.fact,
594
+ activeCredentialHooks: activeHooks.fact,
595
+ activeDefaultVariableSetIds: activeVariableSets.fact,
596
+ versions: versions.fact,
597
+ changes: changes.fact,
598
+ };
599
+ const result = {
600
+ rig: {
601
+ id: rig.id,
602
+ accountId: rig.accountId,
603
+ workspaceId: rig.workspaceId,
604
+ name: name.value,
605
+ description: description?.value ?? null,
606
+ createdBy: rig.createdBy === null ? null : modelStringProjection(rig.createdBy, 512).value,
607
+ activeVersion: active
608
+ ? {
609
+ id: active.id,
610
+ rigId: active.rigId,
611
+ version: active.version,
612
+ image: activeImage?.value ?? null,
613
+ setupScript: activeSetup?.value ?? null,
614
+ checks: activeChecks.value,
615
+ credentialHooks: activeHooks.value,
616
+ defaultVariableSetIds: activeVariableSets.value,
617
+ changelog: activeChangelog?.value ?? null,
618
+ createdBy:
619
+ active.createdBy === null ? null : modelStringProjection(active.createdBy, 512).value,
620
+ active: active.active,
621
+ createdAt: active.createdAt,
622
+ }
623
+ : null,
624
+ activeVersionHealth: rig.activeVersionHealth,
625
+ versionCount: rig.versionCount,
626
+ createdAt: rig.createdAt,
627
+ updatedAt: rig.updatedAt,
628
+ },
629
+ versions: versions.value,
630
+ versionsTotal: versionsPage.total,
631
+ versionsTruncated: versionsPage.hasMore || versions.fact.truncated,
632
+ changes: changes.value,
633
+ changesTotal: changesPage.total,
634
+ changesTruncated: changesPage.hasMore || changes.fact.truncated,
635
+ projection: {
636
+ truncated:
637
+ versionsPage.hasMore ||
638
+ changesPage.hasMore ||
639
+ Object.values(fieldFacts).some((fact) => fact.truncated),
640
+ fields: fieldFacts,
641
+ details: [
642
+ ...activeChecks.details,
643
+ ...activeHooks.details,
644
+ ...activeVariableSets.details,
645
+ ...versions.details,
646
+ ...changes.details,
647
+ ].slice(0, 32),
648
+ bytes: 0,
649
+ maxBytes,
650
+ },
651
+ };
652
+ for (let attempt = 0; attempt < 8; attempt += 1) {
653
+ const measured = prettyJsonBytes(result);
654
+ if (result.projection.bytes === measured) break;
655
+ result.projection.bytes = measured;
656
+ }
657
+ const mutable = result as Record<string, any> & {
658
+ projection: typeof result.projection;
659
+ };
660
+ const rigFallbacks: Array<{
661
+ target: Record<string, unknown>;
662
+ field: string;
663
+ fact: SessionDetailFieldFact;
664
+ }> = [
665
+ {
666
+ target: mutable.rig.activeVersion ?? {},
667
+ field: "checks",
668
+ fact: fieldFacts.activeChecks!,
669
+ },
670
+ { target: mutable, field: "versions", fact: fieldFacts.versions! },
671
+ { target: mutable, field: "changes", fact: fieldFacts.changes! },
672
+ {
673
+ target: mutable.rig.activeVersion ?? {},
674
+ field: "credentialHooks",
675
+ fact: fieldFacts.activeCredentialHooks!,
676
+ },
677
+ {
678
+ target: mutable.rig.activeVersion ?? {},
679
+ field: "defaultVariableSetIds",
680
+ fact: fieldFacts.activeDefaultVariableSetIds!,
681
+ },
682
+ ];
683
+ for (const fallback of rigFallbacks) {
684
+ if (result.projection.bytes <= maxBytes) break;
685
+ const omission = {
686
+ preview: `[${fallback.field} preview omitted at final rig_get byte boundary]`,
687
+ ...(fallback.fact.originalCount === undefined
688
+ ? {}
689
+ : { originalCount: fallback.fact.originalCount }),
690
+ };
691
+ fallback.target[fallback.field] = omission;
692
+ fallback.fact.truncated = true;
693
+ fallback.fact.deliveredBytes = Buffer.byteLength(JSON.stringify(omission), "utf8");
694
+ fallback.fact.deliveredCount = 0;
695
+ result.projection.truncated = true;
696
+ for (let attempt = 0; attempt < 8; attempt += 1) {
697
+ const measured = prettyJsonBytes(result);
698
+ if (result.projection.bytes === measured) break;
699
+ result.projection.bytes = measured;
700
+ }
701
+ }
702
+ if (result.projection.bytes > maxBytes) {
703
+ throw new RangeError(`rig_get projection exceeds its ${maxBytes}-byte envelope`);
704
+ }
705
+ return result;
706
+ }