@opengeni/runtime 0.7.1 → 0.13.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.
Files changed (61) hide show
  1. package/THIRD_PARTY_NOTICES +32 -0
  2. package/dist/agent-UMUIWWD2.js +8 -0
  3. package/dist/agent-UMUIWWD2.js.map +1 -0
  4. package/dist/chunk-4YJ5Z52Z.js +1789 -0
  5. package/dist/chunk-4YJ5Z52Z.js.map +1 -0
  6. package/dist/chunk-65UDUFUO.js +22133 -0
  7. package/dist/chunk-65UDUFUO.js.map +1 -0
  8. package/dist/chunk-DPULLAJG.js +215 -0
  9. package/dist/chunk-DPULLAJG.js.map +1 -0
  10. package/dist/chunk-HAN5D27J.js +292 -0
  11. package/dist/chunk-HAN5D27J.js.map +1 -0
  12. package/dist/chunk-K4V4QYCL.js +37683 -0
  13. package/dist/chunk-K4V4QYCL.js.map +1 -0
  14. package/dist/chunk-PZ5AY32C.js +10 -0
  15. package/dist/chunk-PZ5AY32C.js.map +1 -0
  16. package/dist/{index-DIiiwQY2.d.ts → index-DGUrY_yR.d.ts} +171 -18
  17. package/dist/index.d.ts +404 -125
  18. package/dist/index.js +8857 -412
  19. package/dist/index.js.map +1 -1
  20. package/dist/localSources-T3HZ6KY4.js +15 -0
  21. package/dist/localSources-T3HZ6KY4.js.map +1 -0
  22. package/dist/mcp-network.d.ts +78 -0
  23. package/dist/mcp-network.js +62 -0
  24. package/dist/mcp-network.js.map +1 -0
  25. package/dist/sandbox/index.d.ts +1 -1
  26. package/dist/sandbox/index.js +42 -5
  27. package/dist/skill-library.d.ts +74 -0
  28. package/dist/skill-library.js +18 -0
  29. package/dist/skill-library.js.map +1 -0
  30. package/package.json +18 -5
  31. package/src/bundled_hashicorp_terraform_skills/README.md +3 -3
  32. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +1 -1
  33. package/src/codex-tool-search.ts +20 -1
  34. package/src/context-compaction.ts +421 -24
  35. package/src/history-sanitizer.ts +14 -5
  36. package/src/index.ts +1806 -347
  37. package/src/mcp-network.ts +330 -0
  38. package/src/sandbox/channel-a.ts +526 -113
  39. package/src/sandbox/display-stack.ts +860 -23
  40. package/src/sandbox/exec-banner.ts +67 -0
  41. package/src/sandbox/index.ts +43 -2
  42. package/src/sandbox/providers/modal.ts +29 -2
  43. package/src/sandbox/routing/routing-session.ts +10 -2
  44. package/src/sandbox/run-credentials.ts +818 -0
  45. package/src/sandbox/select.ts +3 -3
  46. package/src/sandbox/selfhosted/capabilities.ts +1 -1
  47. package/src/sandbox/selfhosted/op-stream.ts +40 -0
  48. package/src/sandbox/selfhosted/op-testing.ts +50 -3
  49. package/src/sandbox/selfhosted/session.ts +17 -4
  50. package/src/sandbox/selfhosted/testing.ts +1 -1
  51. package/src/sandbox/stream-port.ts +1 -1
  52. package/src/sandbox/stream-token.ts +1 -1
  53. package/src/sandbox/terminal-server.ts +6 -2
  54. package/src/sandbox/toolspace-token.ts +147 -0
  55. package/src/sandbox/turn-tool-cancellation.ts +940 -0
  56. package/src/sandbox-computer.ts +3 -1
  57. package/src/skill-library.ts +323 -0
  58. package/src/usage-telemetry.ts +495 -51
  59. package/dist/chunk-RYDCVTMW.js +0 -5531
  60. package/dist/chunk-RYDCVTMW.js.map +0 -1
  61. /package/src/{bundled_hashicorp_terraform_skills → bundled_skill_library}/azure-verified-modules/SKILL.md +0 -0
@@ -8,7 +8,12 @@
8
8
  * from the active model-facing history; the database audit rows remain.
9
9
  */
10
10
 
11
- import { TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE } from "./history-sanitizer";
11
+ import {
12
+ TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE,
13
+ sanitizeHistoryItemsForModel,
14
+ } from "./history-sanitizer";
15
+ import { boundModelToolOutputItem } from "@opengeni/codex";
16
+ import { createHash } from "node:crypto";
12
17
 
13
18
  export type CompactionItem = Record<string, unknown>;
14
19
 
@@ -53,6 +58,19 @@ export const USER_MESSAGE_TRUNCATION_MARKER =
53
58
 
54
59
  const RESULT_TYPE_BY_CALL_TYPE = TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE;
55
60
  const RESULT_TYPES = new Set(Object.values(RESULT_TYPE_BY_CALL_TYPE));
61
+ const MODEL_GENERATED_ITEM_TYPES = new Set([
62
+ "reasoning",
63
+ "function_call",
64
+ "custom_tool_call",
65
+ "tool_search_call",
66
+ "web_search_call",
67
+ "image_generation_call",
68
+ "computer_call",
69
+ "shell_call",
70
+ "apply_patch_call",
71
+ "compaction",
72
+ "context_compaction",
73
+ ]);
56
74
 
57
75
  function itemType(item: unknown): string | undefined {
58
76
  if (!item || typeof item !== "object") {
@@ -88,9 +106,24 @@ export function isEphemeralInternalContext(item: unknown): boolean {
88
106
  }
89
107
 
90
108
  /**
91
- * Rough token estimate for an item: char/4 over its serialized text. Used for
92
- * the pre-first-call signal and the retained user-message budget.
109
+ * Conservative tokenizer-independent text estimate used for every local input
110
+ * budget. Preserve the stable ASCII `chars / 4` heuristic, but never discount
111
+ * Unicode as UTF-16 length/4: each non-ASCII code unit costs at least one token
112
+ * (CJK ≈ 1/code point; an astral emoji's surrogate pair ≈ 2). This intentionally
113
+ * errs toward compaction rather than letting multilingual current input exceed
114
+ * a provider context window before an authoritative usage anchor exists.
93
115
  */
116
+ export function estimateTextTokens(text: string): number {
117
+ let asciiCodeUnits = 0;
118
+ let nonAsciiCodeUnits = 0;
119
+ for (let index = 0; index < text.length; index += 1) {
120
+ if (text.charCodeAt(index) <= 0x7f) asciiCodeUnits += 1;
121
+ else nonAsciiCodeUnits += 1;
122
+ }
123
+ return nonAsciiCodeUnits + Math.ceil(asciiCodeUnits / 4);
124
+ }
125
+
126
+ /** Serialized-item estimate for pre-call accounting and retained user budgets. */
94
127
  export function estimateItemTokens(item: CompactionItem): number {
95
128
  let text: string;
96
129
  try {
@@ -98,7 +131,7 @@ export function estimateItemTokens(item: CompactionItem): number {
98
131
  } catch {
99
132
  text = String(item);
100
133
  }
101
- return Math.ceil(text.length / 4);
134
+ return estimateTextTokens(text);
102
135
  }
103
136
 
104
137
  export function estimateTokens(items: readonly CompactionItem[]): number {
@@ -109,6 +142,109 @@ export function estimateTokens(items: readonly CompactionItem[]): number {
109
142
  return total;
110
143
  }
111
144
 
145
+ export function estimateSerializedValueTokens(value: unknown): number {
146
+ let serialized: string;
147
+ try {
148
+ serialized = typeof value === "string" ? value : JSON.stringify(value);
149
+ } catch {
150
+ serialized = String(value);
151
+ }
152
+ return estimateTextTokens(serialized ?? "");
153
+ }
154
+
155
+ export type CompleteModelInputFootprint = {
156
+ input: readonly CompactionItem[];
157
+ instructionsTokens: number;
158
+ toolSchemaTokens: number;
159
+ };
160
+
161
+ export type ProviderContextTokenSignal = {
162
+ /** Monotonic within one sampled run; advances after every model response. */
163
+ revision: number;
164
+ /** Provider total tokens after that response (input + generated output). */
165
+ totalTokens: number;
166
+ };
167
+
168
+ export type CompleteModelInputEstimate = {
169
+ tokens: number;
170
+ source: "complete_estimate" | "provider_plus_local";
171
+ inputTokens: number;
172
+ instructionsTokens: number;
173
+ toolSchemaTokens: number;
174
+ appendedAfterModelTokens: number;
175
+ };
176
+
177
+ /**
178
+ * Match Codex history accounting: after one provider response, start from its
179
+ * authoritative TOTAL token count and add only local items placed after the
180
+ * newest model-generated item. System instructions and tool schemas are
181
+ * compared with the exact request footprint that produced the provider count;
182
+ * positive growth is added. Without a bound anchor, estimate the entire
183
+ * outgoing request rather than trusting stale usage from an earlier turn.
184
+ */
185
+ export function estimateCompleteModelInput(input: {
186
+ current: CompleteModelInputFootprint;
187
+ provider?: ProviderContextTokenSignal | null;
188
+ providerRequestFootprint?: CompleteModelInputFootprint | null;
189
+ }): CompleteModelInputEstimate {
190
+ const inputTokens = estimateTokens(input.current.input);
191
+ const instructionsTokens = input.current.instructionsTokens;
192
+ const toolSchemaTokens = input.current.toolSchemaTokens;
193
+ if (!input.provider || !input.providerRequestFootprint || input.provider.totalTokens <= 0) {
194
+ return {
195
+ tokens: inputTokens + instructionsTokens + toolSchemaTokens,
196
+ source: "complete_estimate",
197
+ inputTokens,
198
+ instructionsTokens,
199
+ toolSchemaTokens,
200
+ appendedAfterModelTokens: 0,
201
+ };
202
+ }
203
+
204
+ const appended = itemsAfterLastModelGeneratedItem(input.current.input);
205
+ const appendedAfterModelTokens = estimateTokens(appended);
206
+ const instructionGrowth = Math.max(
207
+ 0,
208
+ instructionsTokens - input.providerRequestFootprint.instructionsTokens,
209
+ );
210
+ const toolSchemaGrowth = Math.max(
211
+ 0,
212
+ toolSchemaTokens - input.providerRequestFootprint.toolSchemaTokens,
213
+ );
214
+ return {
215
+ tokens:
216
+ input.provider.totalTokens + appendedAfterModelTokens + instructionGrowth + toolSchemaGrowth,
217
+ source: "provider_plus_local",
218
+ inputTokens,
219
+ instructionsTokens,
220
+ toolSchemaTokens,
221
+ appendedAfterModelTokens,
222
+ };
223
+ }
224
+
225
+ export function itemsAfterLastModelGeneratedItem(
226
+ items: readonly CompactionItem[],
227
+ ): CompactionItem[] {
228
+ for (let index = items.length - 1; index >= 0; index -= 1) {
229
+ if (isModelGeneratedItem(items[index])) {
230
+ return items.slice(index + 1);
231
+ }
232
+ }
233
+ // Codex treats a provider token anchor without any model-generated item as
234
+ // unbound. Callers therefore fall back to a complete estimate in that case.
235
+ return items.slice();
236
+ }
237
+
238
+ export function hasModelGeneratedItem(items: readonly CompactionItem[]): boolean {
239
+ return items.some(isModelGeneratedItem);
240
+ }
241
+
242
+ function isModelGeneratedItem(item: unknown): boolean {
243
+ const type = itemType(item);
244
+ if (type === "message") return itemRole(item) === "assistant";
245
+ return MODEL_GENERATED_ITEM_TYPES.has(type ?? "");
246
+ }
247
+
112
248
  export function clampCompactionThresholdRatio(value: number | undefined | null): number {
113
249
  const numeric =
114
250
  typeof value === "number" && Number.isFinite(value)
@@ -159,17 +295,41 @@ export function decideCompaction(input: {
159
295
  typeof input.lastInputTokens === "number" && input.lastInputTokens > 0
160
296
  ? input.lastInputTokens
161
297
  : 0;
162
- const signalTokens = recorded > 0 ? recorded : estimateTokens(input.items);
298
+ const activeHistoryEstimate = estimateTokens(input.items);
299
+ // A durable provider count belongs to an earlier request. The full active
300
+ // estimate is a conservative cross-turn floor until an exact same-run anchor
301
+ // is available in the per-call guard.
302
+ const signalTokens = Math.max(recorded, activeHistoryEstimate);
163
303
  if (input.items.length === 0) {
164
- return { shouldCompact: false, reason: "no_history", signalTokens, thresholdTokens };
304
+ return {
305
+ shouldCompact: false,
306
+ reason: "no_history",
307
+ signalTokens,
308
+ thresholdTokens,
309
+ };
165
310
  }
166
311
  if (input.force) {
167
- return { shouldCompact: true, reason: "force", signalTokens, thresholdTokens };
312
+ return {
313
+ shouldCompact: true,
314
+ reason: "force",
315
+ signalTokens,
316
+ thresholdTokens,
317
+ };
168
318
  }
169
319
  if (signalTokens >= thresholdTokens) {
170
- return { shouldCompact: true, reason: "above_threshold", signalTokens, thresholdTokens };
320
+ return {
321
+ shouldCompact: true,
322
+ reason: "above_threshold",
323
+ signalTokens,
324
+ thresholdTokens,
325
+ };
171
326
  }
172
- return { shouldCompact: false, reason: "below_threshold", signalTokens, thresholdTokens };
327
+ return {
328
+ shouldCompact: false,
329
+ reason: "below_threshold",
330
+ signalTokens,
331
+ thresholdTokens,
332
+ };
173
333
  }
174
334
 
175
335
  export class CompactionNeededError extends Error {
@@ -198,6 +358,48 @@ export class CompactionNeededError extends Error {
198
358
  }
199
359
  }
200
360
 
361
+ export class EmptyCompactionSummaryError extends Error {
362
+ readonly diagnostics: Record<string, unknown>;
363
+
364
+ constructor(diagnostics: Record<string, unknown> = {}) {
365
+ const compact = JSON.stringify(diagnostics).slice(0, 2_000);
366
+ super(
367
+ `Compaction summarizer returned no assistant text; active history was preserved${compact ? ` (${compact})` : ""}`,
368
+ );
369
+ this.name = "EmptyCompactionSummaryError";
370
+ this.diagnostics = diagnostics;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * The checkpoint model request ended in a provider/transport failure rather
376
+ * than a successful response with an empty assistant message. Diagnostics are
377
+ * deliberately bounded and content-free so this error can be persisted on a
378
+ * turn without copying provider messages or conversation input into events.
379
+ */
380
+ export class CompactionProviderResponseError extends Error {
381
+ readonly diagnostics: Record<string, unknown>;
382
+ readonly status?: number;
383
+ readonly code?: string;
384
+ readonly type?: string;
385
+ override readonly cause?: unknown;
386
+
387
+ constructor(diagnostics: Record<string, unknown> = {}, cause?: unknown) {
388
+ const compact = JSON.stringify(diagnostics).slice(0, 2_000);
389
+ super(
390
+ `Compaction provider request failed; active history was preserved${compact ? ` (${compact})` : ""}`,
391
+ );
392
+ this.name = "CompactionProviderResponseError";
393
+ this.diagnostics = diagnostics;
394
+ if (typeof diagnostics.httpStatus === "number") this.status = diagnostics.httpStatus;
395
+ if (typeof diagnostics.code === "string") this.code = diagnostics.code;
396
+ if (typeof diagnostics.type === "string") this.type = diagnostics.type;
397
+ if (cause !== undefined) {
398
+ Object.defineProperty(this, "cause", { value: cause, enumerable: false });
399
+ }
400
+ }
401
+ }
402
+
201
403
  export function findCompactionNeededError(
202
404
  error: unknown,
203
405
  seen = new WeakSet<object>(),
@@ -233,6 +435,98 @@ export function buildCompactionPromptInput(items: readonly CompactionItem[]): Co
233
435
  ];
234
436
  }
235
437
 
438
+ export type PreparedCompactionPromptInput = {
439
+ input: CompactionItem[];
440
+ estimatedInputTokens: number;
441
+ rewrittenToolOutputs: number;
442
+ droppedHistoryItems: number;
443
+ };
444
+
445
+ /**
446
+ * Fit the explicit checkpoint request without mutating canonical history.
447
+ *
448
+ * Codex first replaces oversized tool outputs in its temporary remote-
449
+ * compaction input. OpenGeni does the same oldest-first, preserving the most
450
+ * recent tool detail for the plaintext summary. If that is still insufficient,
451
+ * whole oldest user-delimited work units are removed and the remaining suffix
452
+ * is protocol-sanitized so no call/result/reasoning fragment is orphaned.
453
+ * The raw active history is still the source for the eventual replacement and
454
+ * remains unchanged if the provider call fails.
455
+ */
456
+ export function prepareCompactionPromptInput(
457
+ items: readonly CompactionItem[],
458
+ maxInputTokens: number,
459
+ ): PreparedCompactionPromptInput {
460
+ const budget = Math.max(0, Math.floor(maxInputTokens));
461
+ let history = items.slice();
462
+ let estimatedInputTokens = estimateTokens(buildCompactionPromptInput(history));
463
+ let rewrittenToolOutputs = 0;
464
+
465
+ for (let index = 0; index < history.length && estimatedInputTokens > budget; index += 1) {
466
+ const current = history[index]!;
467
+ const replacement = minimalToolResultForCompaction(current);
468
+ if (replacement === current) continue;
469
+ const before = estimateItemTokens(current);
470
+ const after = estimateItemTokens(replacement);
471
+ if (after >= before) continue;
472
+ history[index] = replacement;
473
+ estimatedInputTokens -= before - after;
474
+ rewrittenToolOutputs += 1;
475
+ }
476
+
477
+ const beforeDropLength = history.length;
478
+ if (estimatedInputTokens > budget && history.length > 0) {
479
+ const prefixTokens = new Array<number>(history.length + 1).fill(0);
480
+ for (let index = 0; index < history.length; index += 1) {
481
+ prefixTokens[index + 1] = prefixTokens[index]! + estimateItemTokens(history[index]!);
482
+ }
483
+ const cuts = oldestLogicalUnitCuts(history);
484
+ const cut =
485
+ cuts.find((candidate) => estimatedInputTokens - prefixTokens[candidate]! <= budget) ??
486
+ history.length;
487
+ history = sanitizeHistoryItemsForModel(history.slice(cut));
488
+ estimatedInputTokens = estimateTokens(buildCompactionPromptInput(history));
489
+ // The synthesized checkpoint instruction is the irreducible floor. The
490
+ // real model budgets are far above it, but keep the helper total for tests
491
+ // and malformed configuration instead of constructing invalid fragments.
492
+ if (estimatedInputTokens > budget) {
493
+ history = [];
494
+ estimatedInputTokens = estimateTokens(buildCompactionPromptInput(history));
495
+ }
496
+ }
497
+
498
+ return {
499
+ input: buildCompactionPromptInput(history),
500
+ estimatedInputTokens,
501
+ rewrittenToolOutputs,
502
+ droppedHistoryItems: beforeDropLength - history.length,
503
+ };
504
+ }
505
+
506
+ function minimalToolResultForCompaction(item: CompactionItem): CompactionItem {
507
+ const type = itemType(item);
508
+ if (!type || !RESULT_TYPES.has(type)) return item;
509
+ if (type === "tool_search_output" && Array.isArray(item.tools) && item.tools.length > 0) {
510
+ return { ...item, tools: [] };
511
+ }
512
+ return boundModelToolOutputItem(item, 0);
513
+ }
514
+
515
+ function oldestLogicalUnitCuts(items: readonly CompactionItem[]): number[] {
516
+ const userMessageIndexes: number[] = [];
517
+ for (let index = 0; index < items.length; index += 1) {
518
+ if (isUserMessage(items[index])) userMessageIndexes.push(index);
519
+ }
520
+ const cuts: number[] = [];
521
+ if (userMessageIndexes.length === 0) {
522
+ return [items.length];
523
+ }
524
+ if (userMessageIndexes[0]! > 0) cuts.push(userMessageIndexes[0]!);
525
+ for (const index of userMessageIndexes.slice(1)) cuts.push(index);
526
+ cuts.push(items.length);
527
+ return cuts;
528
+ }
529
+
236
530
  /**
237
531
  * Build the active history after compaction:
238
532
  * the newest real user messages that fit one cumulative 20k-token budget
@@ -249,7 +543,7 @@ export function buildCompactionReplacementHistory(
249
543
  if (!isUserMessage(item) || isCompactionSummary(item)) {
250
544
  continue;
251
545
  }
252
- const textTokens = estimatedTextTokens(messageText(item));
546
+ const textTokens = estimateTextTokens(messageText(item));
253
547
  retainedReversed.push(compactMessageToTokenBudget(item, remaining));
254
548
  if (textTokens > remaining) {
255
549
  remaining = 0;
@@ -262,12 +556,46 @@ export function buildCompactionReplacementHistory(
262
556
  return history;
263
557
  }
264
558
 
559
+ export function compactionReplacementFingerprint(items: readonly CompactionItem[]): string {
560
+ // PostgreSQL JSONB does not preserve JavaScript object-key insertion order.
561
+ // Canonicalize recursively so a replacement has the same identity before
562
+ // and after its durable round trip.
563
+ const serialized = JSON.stringify(canonicalJsonValue(items));
564
+ return createHash("sha256").update(serialized, "utf8").digest("hex");
565
+ }
566
+
567
+ function canonicalJsonValue(value: unknown): unknown {
568
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
569
+ if (!value || typeof value !== "object") return value;
570
+ return Object.fromEntries(
571
+ Object.entries(value as Record<string, unknown>)
572
+ .filter(([, entry]) => entry !== undefined)
573
+ .sort(([left], [right]) => left.localeCompare(right))
574
+ .map(([key, entry]) => [key, canonicalJsonValue(entry)]),
575
+ );
576
+ }
577
+
578
+ /** Fingerprint the latest durable replacement prefix in active history. */
579
+ export function latestCompactionReplacementFingerprint(
580
+ items: readonly CompactionItem[],
581
+ ): string | null {
582
+ for (let index = items.length - 1; index >= 0; index -= 1) {
583
+ if (isCompactionSummary(items[index])) {
584
+ return compactionReplacementFingerprint(items.slice(0, index + 1));
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+
265
590
  /**
266
591
  * Build the synthetic summary item (a plain user message) appended to the
267
592
  * rebuilt active history.
268
593
  */
269
594
  export function buildSummaryItem(summaryBody: string): CompactionItem {
270
- const trimmed = summaryBody.trim() || "(no summary available)";
595
+ const trimmed = summaryBody.trim();
596
+ if (!trimmed) {
597
+ throw new EmptyCompactionSummaryError({ stage: "build_summary_item" });
598
+ }
271
599
  return {
272
600
  type: "message",
273
601
  role: "user",
@@ -279,7 +607,7 @@ export function buildSummaryItem(summaryBody: string): CompactionItem {
279
607
  function compactMessageToTokenBudget(item: CompactionItem, maxTokens: number): CompactionItem {
280
608
  const text = messageText(item);
281
609
  const next = { ...item };
282
- if (estimatedTextTokens(text) > maxTokens) {
610
+ if (estimateTextTokens(text) > maxTokens) {
283
611
  next.content = truncateMiddleByEstimatedTokens(text, maxTokens);
284
612
  return next;
285
613
  }
@@ -287,22 +615,91 @@ function compactMessageToTokenBudget(item: CompactionItem, maxTokens: number): C
287
615
  return next;
288
616
  }
289
617
 
290
- function estimatedTextTokens(text: string): number {
291
- return Math.ceil(text.length / 4);
292
- }
293
-
294
618
  function truncateMiddleByEstimatedTokens(text: string, maxTokens: number): string {
295
- const maxChars = Math.max(0, maxTokens * 4);
296
- if (text.length <= maxChars) {
619
+ const budget = Math.max(0, Math.floor(maxTokens));
620
+ if (estimateTextTokens(text) <= budget) {
297
621
  return text;
298
622
  }
299
- if (maxChars <= USER_MESSAGE_TRUNCATION_MARKER.length) {
300
- return USER_MESSAGE_TRUNCATION_MARKER.slice(0, maxChars);
623
+ if (estimateTextTokens(USER_MESSAGE_TRUNCATION_MARKER) > budget) {
624
+ return tokenBoundedPrefix(USER_MESSAGE_TRUNCATION_MARKER, budget);
625
+ }
626
+
627
+ let low = 0;
628
+ let high = text.length;
629
+ let best = USER_MESSAGE_TRUNCATION_MARKER;
630
+ while (low <= high) {
631
+ const keepCodeUnits = Math.floor((low + high) / 2);
632
+ const candidate = middleTruncationCandidate(text, keepCodeUnits);
633
+ if (estimateTextTokens(candidate) <= budget) {
634
+ best = candidate;
635
+ low = keepCodeUnits + 1;
636
+ } else {
637
+ high = keepCodeUnits - 1;
638
+ }
639
+ }
640
+ return best;
641
+ }
642
+
643
+ function middleTruncationCandidate(text: string, keepCodeUnits: number): string {
644
+ const headTarget = Math.ceil(keepCodeUnits / 2);
645
+ const tailTarget = Math.floor(keepCodeUnits / 2);
646
+ const headEnd = validPrefixBoundary(text, headTarget);
647
+ const tailStart = validSuffixBoundary(text, text.length - tailTarget);
648
+ return `${text.slice(0, headEnd)}${USER_MESSAGE_TRUNCATION_MARKER}${text.slice(tailStart)}`;
649
+ }
650
+
651
+ function tokenBoundedPrefix(text: string, maxTokens: number): string {
652
+ let low = 0;
653
+ let high = text.length;
654
+ let best = "";
655
+ while (low <= high) {
656
+ const target = Math.floor((low + high) / 2);
657
+ const end = validPrefixBoundary(text, target);
658
+ const candidate = text.slice(0, end);
659
+ if (estimateTextTokens(candidate) <= maxTokens) {
660
+ best = candidate;
661
+ low = target + 1;
662
+ } else {
663
+ high = target - 1;
664
+ }
301
665
  }
302
- const keepChars = maxChars - USER_MESSAGE_TRUNCATION_MARKER.length;
303
- const headChars = Math.ceil(keepChars / 2);
304
- const tailChars = Math.floor(keepChars / 2);
305
- return `${text.slice(0, headChars)}${USER_MESSAGE_TRUNCATION_MARKER}${text.slice(text.length - tailChars)}`;
666
+ return best;
667
+ }
668
+
669
+ /** Largest boundary at or below target that does not split a surrogate pair. */
670
+ function validPrefixBoundary(text: string, target: number): number {
671
+ let boundary = Math.max(0, Math.min(text.length, target));
672
+ if (
673
+ boundary > 0 &&
674
+ boundary < text.length &&
675
+ isHighSurrogate(text.charCodeAt(boundary - 1)) &&
676
+ isLowSurrogate(text.charCodeAt(boundary))
677
+ ) {
678
+ boundary -= 1;
679
+ }
680
+ return boundary;
681
+ }
682
+
683
+ /** Smallest boundary at or above target that does not split a surrogate pair. */
684
+ function validSuffixBoundary(text: string, target: number): number {
685
+ let boundary = Math.max(0, Math.min(text.length, target));
686
+ if (
687
+ boundary > 0 &&
688
+ boundary < text.length &&
689
+ isHighSurrogate(text.charCodeAt(boundary - 1)) &&
690
+ isLowSurrogate(text.charCodeAt(boundary))
691
+ ) {
692
+ boundary += 1;
693
+ }
694
+ return boundary;
695
+ }
696
+
697
+ function isHighSurrogate(codeUnit: number): boolean {
698
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
699
+ }
700
+
701
+ function isLowSurrogate(codeUnit: number): boolean {
702
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
306
703
  }
307
704
 
308
705
  function contentWithoutImages(item: CompactionItem): unknown {
@@ -28,6 +28,10 @@
28
28
  */
29
29
 
30
30
  import { SCREENSHOT_FAILURE_CARD_IMAGE_URL } from "./screenshot-error-card";
31
+ import {
32
+ boundModelToolOutputItems,
33
+ DEFAULT_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
34
+ } from "@opengeni/codex";
31
35
 
32
36
  /** A history item is any JSON object; we only inspect a few discriminator fields. */
33
37
  export type HistoryItem = Record<string, unknown>;
@@ -224,7 +228,10 @@ export function elideSupersededViewImagePairs<T extends HistoryItem>(items: read
224
228
  * types exist with that id, the call appearing before the result. Calls and
225
229
  * results that satisfy that survive untouched.
226
230
  */
227
- export function sanitizeHistoryItemsForModel<T extends HistoryItem>(items: readonly T[]): T[] {
231
+ export function sanitizeHistoryItemsForModel<T extends HistoryItem>(
232
+ items: readonly T[],
233
+ toolOutputTruncationTokens = DEFAULT_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
234
+ ): T[] {
228
235
  if (items.length === 0) {
229
236
  return [];
230
237
  }
@@ -314,10 +321,12 @@ export function sanitizeHistoryItemsForModel<T extends HistoryItem>(items: reado
314
321
  }
315
322
  }
316
323
 
317
- if (dropped.size === 0) {
318
- return items.map(stripInternalResumeMarker);
319
- }
320
- return items.filter((_item, index) => !dropped.has(index)).map(stripInternalResumeMarker);
324
+ const paired =
325
+ dropped.size === 0 ? items.slice() : items.filter((_item, index) => !dropped.has(index));
326
+ return boundModelToolOutputItems(
327
+ paired.map(stripInternalResumeMarker),
328
+ toolOutputTruncationTokens,
329
+ );
321
330
  }
322
331
 
323
332
  /**