@opengeni/runtime 0.3.0 → 0.4.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.
@@ -95,7 +95,7 @@ import {
95
95
  timeoutAgentError,
96
96
  timeoutControlResponse,
97
97
  verifyStreamToken
98
- } from "../chunk-D5KU3QUC.js";
98
+ } from "../chunk-HGQ252FL.js";
99
99
  export {
100
100
  ActiveBackendUnresolvableError,
101
101
  CAPABILITY_DESCRIPTORS,
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@opengeni/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
+ "license": "Apache-2.0",
5
6
  "main": "./dist/index.js",
6
7
  "module": "./dist/index.js",
7
8
  "types": "./dist/index.d.ts",
@@ -25,22 +26,19 @@
25
26
  },
26
27
  "scripts": {
27
28
  "build": "tsup",
28
- "typecheck": "tsc --noEmit"
29
+ "typecheck": "tsc --noEmit",
30
+ "prepublishOnly": "bash ../../scripts/prepublish-guard"
29
31
  },
30
32
  "dependencies": {
31
33
  "@opengeni/agent-proto": "^0.2.1",
32
- "@opengeni/config": "^0.2.4",
33
- "@opengeni/contracts": "^0.6.0",
34
+ "@opengeni/config": "^0.3.0",
35
+ "@opengeni/contracts": "^0.9.0",
34
36
  "@openai/agents": "^0.11.6",
35
37
  "@openai/agents-extensions": "^0.11.6",
36
38
  "modal": "^0.7.4",
37
39
  "openai": "6.36.0",
38
40
  "@opengeni/codex": "^0.2.1"
39
41
  },
40
- "devDependencies": {
41
- "tsup": "^8.5.0",
42
- "typescript": "^6.0.3"
43
- },
44
42
  "repository": {
45
43
  "type": "git",
46
44
  "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
@@ -8,6 +8,8 @@
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";
12
+
11
13
  export type CompactionItem = Record<string, unknown>;
12
14
 
13
15
  /**
@@ -18,7 +20,10 @@ export const COMPACTION_SUMMARY_MARKER = "opengeni_context_summary";
18
20
 
19
21
  export const SUMMARY_BUFFER_TOKENS = 20_000;
20
22
  export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
21
- export const CLIENT_COMPACTION_TRIGGER_FRACTION = 0.9;
23
+ export const DEFAULT_COMPACTION_THRESHOLD_RATIO = 0.6;
24
+ export const MIN_COMPACTION_THRESHOLD_RATIO = 0.3;
25
+ export const MAX_COMPACTION_THRESHOLD_RATIO = 0.9;
26
+ export const COMPACTION_FALLBACK_TARGET_RATIO = 0.5;
22
27
 
23
28
  // Verbatim from Codex CLI:
24
29
  // codex-rs/prompts/templates/compact/prompt.md
@@ -42,12 +47,7 @@ export const SUMMARY_PREFIX =
42
47
  export const USER_MESSAGE_TRUNCATION_MARKER =
43
48
  "\n[... middle truncated for context compaction ...]\n";
44
49
 
45
- const RESULT_TYPE_BY_CALL_TYPE: Record<string, string> = {
46
- function_call: "function_call_result",
47
- computer_call: "computer_call_result",
48
- shell_call: "shell_call_output",
49
- apply_patch_call: "apply_patch_call_output",
50
- };
50
+ const RESULT_TYPE_BY_CALL_TYPE = TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE;
51
51
  const RESULT_TYPES = new Set(Object.values(RESULT_TYPE_BY_CALL_TYPE));
52
52
 
53
53
  function itemType(item: unknown): string | undefined {
@@ -101,15 +101,19 @@ export function estimateTokens(items: readonly CompactionItem[]): number {
101
101
  return total;
102
102
  }
103
103
 
104
+ export function clampCompactionThresholdRatio(value: number | undefined | null): number {
105
+ const numeric = typeof value === "number" && Number.isFinite(value)
106
+ ? value
107
+ : DEFAULT_COMPACTION_THRESHOLD_RATIO;
108
+ return Math.min(MAX_COMPACTION_THRESHOLD_RATIO, Math.max(MIN_COMPACTION_THRESHOLD_RATIO, numeric));
109
+ }
110
+
104
111
  export function clientCompactionThresholdTokens(input: {
105
112
  contextWindowTokens: number;
106
113
  contextReservedOutputTokens: number;
114
+ contextCompactionThresholdRatio?: number | null;
107
115
  }): number {
108
- const available = Math.max(
109
- 0,
110
- input.contextWindowTokens - input.contextReservedOutputTokens - SUMMARY_BUFFER_TOKENS,
111
- );
112
- return Math.floor(available * CLIENT_COMPACTION_TRIGGER_FRACTION);
116
+ return Math.floor(Math.max(0, input.contextWindowTokens) * clampCompactionThresholdRatio(input.contextCompactionThresholdRatio));
113
117
  }
114
118
 
115
119
  export type ClientCompactionDecision = {
@@ -124,6 +128,7 @@ export function decideClientCompaction(input: {
124
128
  lastInputTokens?: number | null;
125
129
  contextWindowTokens: number;
126
130
  contextReservedOutputTokens: number;
131
+ contextCompactionThresholdRatio?: number | null;
127
132
  force?: boolean;
128
133
  }): ClientCompactionDecision {
129
134
  const thresholdTokens = clientCompactionThresholdTokens(input);
@@ -272,6 +277,70 @@ export function buildCompactionReplacementHistory(
272
277
  return history;
273
278
  }
274
279
 
280
+ export type DeterministicFallbackCompactionInput = {
281
+ items: readonly CompactionItem[];
282
+ cause?: string;
283
+ targetTokens?: number;
284
+ };
285
+
286
+ export function buildDeterministicFallbackCompactionHistory(input: DeterministicFallbackCompactionInput): CompactionItem[] {
287
+ const sourceTokens = Math.max(1, estimateTokens(input.items));
288
+ const requestedTarget = typeof input.targetTokens === "number" && input.targetTokens > 0
289
+ ? Math.floor(input.targetTokens)
290
+ : Math.floor(sourceTokens * COMPACTION_FALLBACK_TARGET_RATIO);
291
+ const targetTokens = Math.max(1, Math.min(requestedTarget, Math.max(1, sourceTokens - 1)));
292
+ const summaryItem = buildSummaryItem([
293
+ "Non-LLM context compaction fallback.",
294
+ input.cause ? `Summarizer failure: ${input.cause}` : "Summarizer failure: unavailable.",
295
+ "Older assistant, tool, reasoning, and image history was dropped deterministically.",
296
+ "Retained context above is limited to initial instructions and the most recent user messages that fit the fallback budget; oversized retained messages may be middle-truncated.",
297
+ ].join("\n"));
298
+ const summaryTokens = estimateItemTokens(summaryItem);
299
+ let remaining = Math.max(0, targetTokens - summaryTokens);
300
+ const retainedInstructions: CompactionItem[] = [];
301
+
302
+ for (const item of input.items) {
303
+ if (!isInitialInstructionMessage(item)) {
304
+ if (isUserMessage(item)) {
305
+ break;
306
+ }
307
+ continue;
308
+ }
309
+ if (remaining <= 0) {
310
+ break;
311
+ }
312
+ const capped = compactMessageToEstimatedItemBudget(item, Math.min(2_000, remaining));
313
+ if (!capped) {
314
+ continue;
315
+ }
316
+ const tokens = estimateItemTokens(capped);
317
+ if (tokens <= remaining) {
318
+ retainedInstructions.push(capped);
319
+ remaining -= tokens;
320
+ }
321
+ }
322
+
323
+ const retainedUsers: CompactionItem[] = [];
324
+ for (let index = input.items.length - 1; index >= 0 && remaining > 0; index -= 1) {
325
+ const item = input.items[index]!;
326
+ if (!isUserMessage(item) || isCompactionSummary(item)) {
327
+ continue;
328
+ }
329
+ const capped = compactMessageToEstimatedItemBudget(item, Math.min(COMPACT_USER_MESSAGE_MAX_TOKENS, remaining));
330
+ if (!capped) {
331
+ continue;
332
+ }
333
+ const tokens = estimateItemTokens(capped);
334
+ if (tokens <= remaining) {
335
+ retainedUsers.push(capped);
336
+ remaining -= tokens;
337
+ }
338
+ }
339
+ retainedUsers.reverse();
340
+
341
+ return [...retainedInstructions, ...retainedUsers, summaryItem];
342
+ }
343
+
275
344
  /**
276
345
  * Build the synthetic summary item (a plain user message) appended to the
277
346
  * rebuilt active history.
@@ -287,16 +356,41 @@ export function buildSummaryItem(summaryBody: string): CompactionItem {
287
356
  }
288
357
 
289
358
  function compactUserMessage(item: CompactionItem): CompactionItem {
359
+ return compactMessageToTokenBudget(item, COMPACT_USER_MESSAGE_MAX_TOKENS);
360
+ }
361
+
362
+ function compactMessageToTokenBudget(item: CompactionItem, maxTokens: number): CompactionItem {
290
363
  const text = messageText(item);
291
364
  const next = { ...item };
292
- if (estimatedTextTokens(text) > COMPACT_USER_MESSAGE_MAX_TOKENS) {
293
- next.content = truncateMiddleByEstimatedTokens(text, COMPACT_USER_MESSAGE_MAX_TOKENS);
365
+ if (estimatedTextTokens(text) > maxTokens) {
366
+ next.content = truncateMiddleByEstimatedTokens(text, maxTokens);
294
367
  return next;
295
368
  }
296
369
  next.content = contentWithoutImages(item);
297
370
  return next;
298
371
  }
299
372
 
373
+ function compactMessageToEstimatedItemBudget(item: CompactionItem, maxItemTokens: number): CompactionItem | null {
374
+ if (maxItemTokens <= 0) {
375
+ return null;
376
+ }
377
+ const overheadTokens = estimateItemTokens({ ...item, content: "" });
378
+ let contentBudget = Math.max(0, maxItemTokens - overheadTokens);
379
+ for (let attempt = 0; attempt < 6; attempt += 1) {
380
+ const capped = compactMessageToTokenBudget(item, contentBudget);
381
+ const estimated = estimateItemTokens(capped);
382
+ if (estimated <= maxItemTokens) {
383
+ return capped;
384
+ }
385
+ const overage = estimated - maxItemTokens;
386
+ if (contentBudget <= 0) {
387
+ break;
388
+ }
389
+ contentBudget = Math.max(0, contentBudget - Math.max(1, overage));
390
+ }
391
+ return null;
392
+ }
393
+
300
394
  function estimatedTextTokens(text: string): number {
301
395
  return Math.ceil(text.length / 4);
302
396
  }
@@ -353,8 +447,42 @@ function messageText(item: CompactionItem): string {
353
447
  return "";
354
448
  }
355
449
 
356
- export function renderCompactionPromptInputForChat(input: readonly CompactionItem[]): string {
357
- return input.map(renderItem).join("\n");
450
+ function isInitialInstructionMessage(item: unknown): boolean {
451
+ const type = itemType(item);
452
+ const role = itemRole(item);
453
+ return type === "message" && (role === "system" || role === "developer");
454
+ }
455
+
456
+ export type RenderCompactionTranscriptOptions = {
457
+ /**
458
+ * Hard cap for a rendered transcript. When set, oldest rendered items are
459
+ * dropped first while preserving the final checkpoint prompt; if the retained
460
+ * item itself is too large, it is middle-truncated instead of looping.
461
+ */
462
+ maxEstimatedTokens?: number;
463
+ };
464
+
465
+ export function renderCompactionPromptInputForChat(
466
+ input: readonly CompactionItem[],
467
+ options: RenderCompactionTranscriptOptions = {},
468
+ ): string {
469
+ const rendered = input.map(renderItem);
470
+ if (typeof options.maxEstimatedTokens !== "number" || options.maxEstimatedTokens <= 0) {
471
+ return rendered.join("\n");
472
+ }
473
+ const maxChars = Math.max(1, Math.floor(options.maxEstimatedTokens * 4));
474
+ const last = rendered.at(-1);
475
+ const prefix = rendered.slice(0, -1);
476
+ let kept = prefix.slice();
477
+ while (kept.length > 0 && transcriptLength([...kept, last].filter((line): line is string => line !== undefined)) > maxChars) {
478
+ kept.shift();
479
+ }
480
+ const lines = [...kept, last].filter((line): line is string => line !== undefined);
481
+ const joined = lines.join("\n");
482
+ if (joined.length <= maxChars) {
483
+ return joined;
484
+ }
485
+ return truncateMiddleByChars(joined, maxChars);
358
486
  }
359
487
 
360
488
  function renderItem(item: CompactionItem): string {
@@ -405,3 +533,23 @@ function truncateForTranscript(text: string, max: number): string {
405
533
  }
406
534
  return `${text.slice(0, max)}... (${text.length - max} more chars)`;
407
535
  }
536
+
537
+ function transcriptLength(lines: readonly string[]): number {
538
+ if (lines.length === 0) {
539
+ return 0;
540
+ }
541
+ return lines.reduce((sum, line) => sum + line.length, 0) + Math.max(0, lines.length - 1);
542
+ }
543
+
544
+ function truncateMiddleByChars(text: string, maxChars: number): string {
545
+ if (text.length <= maxChars) {
546
+ return text;
547
+ }
548
+ if (maxChars <= USER_MESSAGE_TRUNCATION_MARKER.length) {
549
+ return USER_MESSAGE_TRUNCATION_MARKER.slice(0, maxChars);
550
+ }
551
+ const keepChars = maxChars - USER_MESSAGE_TRUNCATION_MARKER.length;
552
+ const headChars = Math.ceil(keepChars / 2);
553
+ const tailChars = Math.floor(keepChars / 2);
554
+ return `${text.slice(0, headChars)}${USER_MESSAGE_TRUNCATION_MARKER}${text.slice(text.length - tailChars)}`;
555
+ }
@@ -27,6 +27,8 @@
27
27
  * filtered, keeping the persisted audit trail intact.
28
28
  */
29
29
 
30
+ import { SCREENSHOT_FAILURE_CARD_IMAGE_URL } from "./screenshot-error-card";
31
+
30
32
  /** A history item is any JSON object; we only inspect a few discriminator fields. */
31
33
  export type HistoryItem = Record<string, unknown>;
32
34
 
@@ -36,7 +38,7 @@ export type HistoryItem = Record<string, unknown>;
36
38
  * the one observed live, the rest are included so the same pairing logic holds
37
39
  * for every tool-call kind the SDK can emit.
38
40
  */
39
- const RESULT_TYPE_BY_CALL_TYPE: Record<string, string> = {
41
+ export const TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE: Record<string, string> = {
40
42
  function_call: "function_call_result",
41
43
  computer_call: "computer_call_result",
42
44
  shell_call: "shell_call_output",
@@ -50,6 +52,7 @@ const RESULT_TYPE_BY_CALL_TYPE: Record<string, string> = {
50
52
  tool_search_call: "tool_search_output",
51
53
  };
52
54
 
55
+ const RESULT_TYPE_BY_CALL_TYPE = TOOL_CALL_RESULT_TYPE_BY_CALL_TYPE;
53
56
  const RESULT_TYPES = new Set(Object.values(RESULT_TYPE_BY_CALL_TYPE));
54
57
 
55
58
  function itemType(item: unknown): string | undefined {
@@ -594,42 +597,35 @@ export function rewriteComputerCallsToActionsOnly(body: unknown): boolean {
594
597
  }
595
598
 
596
599
  /**
597
- * The 1×1 transparent PNG placeholder used by the SDK for tool-approval-rejection
598
- * screenshots (`TOOL_APPROVAL_REJECTION_SCREENSHOT_DATA_URL` in agents-core
599
- * `toolExecution.mjs`). We reuse the exact same constant as a backstop for the
600
- * action-timeout 400: when an action times out the SDK's catch sets output='' and
601
- * builds `{type:"computer_call_output",output:{type:"computer_screenshot",image_url:""}}`.
602
- * Azure rejects `image_url:""` with "400 Invalid input[N].output.image_url". This
603
- * placeholder is a valid data URI the provider accepts, so the turn continues and
604
- * the model receives the next real screenshot on its following step.
605
- */
606
- const EMPTY_IMAGE_URL_PLACEHOLDER =
607
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
608
-
609
- /**
610
- * Backstop for the action-timeout 400: walk the `input` array of a serialized
611
- * Responses request body and replace any `computer_call_output` item whose
612
- * `output.image_url` is an empty string, null, undefined, or otherwise not a
613
- * non-empty string with the 1×1 transparent PNG placeholder data URI.
614
- *
615
- * WHY THIS IS NEEDED. When a computer ACTION (click/type/scroll/drag) times out
616
- * at the 15-second yield window `SandboxComputer.x()` throws `ComputerActionError`.
617
- * The agents-core SDK `toolExecution.mjs` catch block sets `output = ''` and then
618
- * builds the wire item:
619
- *
620
- * `{type:"computer_call_output", output:{type:"computer_screenshot", image_url:""}}`
621
- *
622
- * Azure rejects the whole request with:
623
- *
624
- * `400 Invalid 'input[N].output.image_url'. Expected a valid URL, but got a
625
- * value with an invalid format.`
626
- *
627
- * Our screenshot() fail-loud guard (which throws on empty frames) only runs when
628
- * the SDK calls screenshot() on a SUCCESS path — not on this action-error catch
629
- * path that sets output='' directly. This wire-level rewrite is the only seam that
630
- * catches both paths regardless of how the empty image_url was produced. It runs
631
- * in the same `computerCallNormalizingFetch` wrapper, so a single parse/rewrite
632
- * pass covers both the action/actions-only rewrite and this placeholder injection.
600
+ * Backstop for the empty `computer_call_output` image_url: walk the `input` array of
601
+ * a serialized Responses request body and replace any `computer_call_output` item
602
+ * whose `output.image_url` is empty/missing with a LEGIBLE "screen capture failed"
603
+ * error card ({@link SCREENSHOT_FAILURE_CARD_IMAGE_URL}).
604
+ *
605
+ * WHY A CARD, NOT A BLANK. An empty `image_url` reaches this seam ONLY when the
606
+ * computer op genuinely FAILED to produce a screen: agents-core's `toolExecution.mjs`
607
+ * catch sets `output = ''` when the action OR the follow-up `computer.screenshot()`
608
+ * throws, building `{type:"computer_call_output",output:{type:"computer_screenshot",
609
+ * image_url:""}}`. Azure then rejects the whole request with
610
+ * `400 Invalid 'input[N].output.image_url'`. The previous fix substituted a 1×1
611
+ * TRANSPARENT placeholder to dodge the 400 — but that reaches the model as a
612
+ * plausible BLANK DESKTOP it confidently reports ("the screen appears blank/empty"),
613
+ * turning a hard capture FAILURE into a silent, wrong observation. That is the worst
614
+ * failure mode for computer use, and it is exactly what the 0.1.3 TCC-denied incident
615
+ * produced. Substituting a legible error card instead makes the failure REACH THE
616
+ * MODEL as an error (the only channel the hosted `computer_use_preview` protocol has
617
+ * is the image), so the model stops and tells the user rather than hallucinating.
618
+ *
619
+ * WHY THIS IS SAFE (empty = failure, never an intentional blank). Post-af289e3 the
620
+ * intentional-blank cases carry a NON-empty data URI already: agents-core's
621
+ * tool-approval-rejection screenshot is its own non-empty 1×1 placeholder, and the
622
+ * SandboxComputer action-timeout now warn+returns to a REAL screenshot rather than an
623
+ * empty output. So an EMPTY image_url at this seam is unambiguously a capture/interact
624
+ * FAILURE — the error card is the correct substitution for every empty case, and this
625
+ * function never touches a non-empty (real screenshot OR intentional blank) output.
626
+ *
627
+ * The failure REASON (permission denied / null image / timeout / display down) is not
628
+ * on the card; it is logged worker-side by `NativeDesktopComputer.screenshot()`.
633
629
  *
634
630
  * Mutates `body` in place (the caller has already JSON.parsed a private copy).
635
631
  * Returns `true` iff at least one image_url was replaced.
@@ -657,9 +653,11 @@ export function rewriteEmptyComputerCallOutputImageUrls(body: unknown): boolean
657
653
  }
658
654
  const out = output as Record<string, unknown>;
659
655
  const imageUrl = out.image_url;
660
- // Replace the image_url when it is not a non-empty string (covers: "", null, undefined, missing).
656
+ // Replace the image_url when it is not a non-empty string (covers: "", null,
657
+ // undefined, missing) — an empty output is always a genuine capture failure, so
658
+ // it becomes the legible error card, never a silent blank.
661
659
  if (typeof imageUrl !== "string" || imageUrl.length === 0) {
662
- out.image_url = EMPTY_IMAGE_URL_PLACEHOLDER;
660
+ out.image_url = SCREENSHOT_FAILURE_CARD_IMAGE_URL;
663
661
  changed = true;
664
662
  }
665
663
  }