@opengeni/runtime 3.0.2-canary.38894290271001 → 3.1.2

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.
@@ -47,7 +47,8 @@ export declare function jsonSerializedUtf8ByteLength(value: unknown): number;
47
47
  /** Count raw UTF-8 bytes using the standard three-byte lone-surrogate replacement. */
48
48
  export declare function utf8ByteLength(value: string): number;
49
49
  export declare const UNKNOWN_IMAGE_TOKENS = 4096;
50
- export declare const MAX_NATIVE_IMAGE_TOKENS = 8192;
50
+ /** OpenAI rejects images above 30,000 of its patches (about 36,000 tokens). */
51
+ export declare const MAX_NATIVE_IMAGE_TOKENS = 36000;
51
52
  export type NativeImageEstimateReason = "dimensions" | "bounded_fallback";
52
53
  export type NativeImageTokenEstimate = {
53
54
  tokens: number;
@@ -265,7 +266,8 @@ export declare function prepareCompactionPromptInput(items: readonly CompactionI
265
266
  /**
266
267
  * Build the active history after compaction:
267
268
  * the newest user/system input messages that fit one cumulative, model-bounded budget
268
- * (prior summaries excluded, retained images preserved) plus one marked summary item.
269
+ * (prior summaries excluded) plus one marked summary item. Only the newest
270
+ * user message with attachments keeps its images inline.
269
271
  * Each distinct session goal context is kept once, and answered transient
270
272
  * machine-status batches are left to the summary.
271
273
  * Attachments of messages that were not retained are listed in one dated
@@ -281,8 +283,8 @@ export declare function isRetainedRemoteV2Message(item: unknown): boolean;
281
283
  * newest retained user/system/developer messages within the CLI 64k budget plus the
282
284
  * opaque `{ type: "compaction", encrypted_content }` item.
283
285
  *
284
- * Both modes preserve retained image parts. Charge their projected image
285
- * tokens, including uploads represented by durable refs, against the budget.
286
+ * Both modes keep attachments inline only on the newest user message that has
287
+ * them, and charge its projected image tokens against the budget.
286
288
  */
287
289
  export declare function buildRemoteV2ReplacementHistory(items: readonly CompactionItem[], compactionItem: CompactionItem, retainedItemTokens?: (item: CompactionItem) => number, attachmentUploadTimes?: AttachmentUploadTimes): CompactionItem[];
288
290
  /** Upload time of each referenced file, frozen into a catalog when compaction creates it. */
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type InteractionToolErrorResult } from "./interaction-tools.js";
2
2
  export { preparedCompactionRequest, queuePreparedCompaction } from "./prepared-compaction-request.js";
3
3
  export { createAnthropicCompactionSizer, fitCompactionPrefix, compactionPrefixCuts, } from "./anthropic-compaction.js";
4
4
  export { AnthropicProviderRejection } from "./anthropic-messages.js";
5
+ export { createModelImageSizer, type ModelImageSizerOptions, type SizedImage, } from "./model-image-sizing.js";
5
6
  export { runSingleModelCall, SingleModelCallProviderError, SingleModelCallUnsupportedError, type SingleModelCallContentPart, type SingleModelCallFinishReason, type SingleModelCallMessage, type SingleModelCallOptions, type SingleModelCallOutputFormat, type SingleModelCallRequest, type SingleModelCallResult, type SingleModelCallTarget, } from "./single-model-call.js";
6
7
  import type { ModelProviderApi, ResolvedModelProvider, Settings } from "@opengeni/config";
7
8
  import { RunMcpCredentials } from "./mcp-run-credentials.js";
package/dist/index.js CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  modelResponseHasVisibleText,
118
118
  modelToolResultFits,
119
119
  modelToolResultOverflowError,
120
+ nativeCuaSchema,
120
121
  normalizeComputerCallsFilter,
121
122
  observeMcpOperation,
122
123
  oneShotGenesisTitleInputFilter,
@@ -186,7 +187,7 @@ import {
186
187
  workspaceEnvironmentInstructions,
187
188
  wrapAttemptToolDefinitions,
188
189
  wrapAttemptToolExecute
189
- } from "./chunk-JDNBDSQV.js";
190
+ } from "./chunk-TE3JIUL6.js";
190
191
  import {
191
192
  ActiveBackendUnresolvableError,
192
193
  BROWSER_CONTROL_ADMIN_TOKEN_FILE,
@@ -491,6 +492,7 @@ import {
491
492
  compactionSummaryOutputTokens,
492
493
  compactionThresholdTokens,
493
494
  configureRuntimeMetricsHooks,
495
+ createModelImageSizer,
494
496
  decideCompaction,
495
497
  describeCompactionProviderRejection,
496
498
  elideSupersededViewImagePairs,
@@ -556,7 +558,7 @@ import {
556
558
  toolCallIdFromSdkItem,
557
559
  vercelGatewayRoutingFetch,
558
560
  withClaudeUsageObserver
559
- } from "./chunk-EQQ3YOKR.js";
561
+ } from "./chunk-7OUG7WBV.js";
560
562
  import {
561
563
  UnsupportedProtocolJsonValueError,
562
564
  normalizeProtocolJsonValue
@@ -999,6 +1001,7 @@ export {
999
1001
  createFirstPartyInteractionAttemptToolDefinitions,
1000
1002
  createInteractionAttemptToolDefinitions,
1001
1003
  createMockSelfhostedOpStream,
1004
+ createModelImageSizer,
1002
1005
  createModelVisibleContextCaptureFilter,
1003
1006
  createProductionAgentRuntime,
1004
1007
  createSandboxClient,
@@ -1143,6 +1146,7 @@ export {
1143
1146
  modelToolResultFits,
1144
1147
  modelToolResultOverflowError,
1145
1148
  modelUsageTokenCountOrNull,
1149
+ nativeCuaSchema,
1146
1150
  negotiateCapabilities,
1147
1151
  negotiateSelfhostedCapabilities,
1148
1152
  nextDurableOpId,
@@ -1,4 +1,4 @@
1
- import { InteractionIntervention, type FirstPartyMcpToolName, type Permission, type AttemptToolResult as AttemptToolResultValue } from "@opengeni/contracts";
1
+ import { InteractionIntervention, type FirstPartyMcpToolName, type Permission, type AttemptToolJsonSchema, type AttemptToolResult as AttemptToolResultValue } from "@opengeni/contracts";
2
2
  import { type Settings } from "@opengeni/config";
3
3
  import type { AttemptToolDefinition, AttemptToolScope } from "@opengeni/codemode";
4
4
  import { type InteractionControlFailure, type InteractionTransport } from "@opengeni/sdk";
@@ -18,6 +18,16 @@ export type CreateInteractionAttemptToolsInput = {
18
18
  interventionResume?: InteractionInterventionResume | null;
19
19
  };
20
20
  export declare function createInteractionAttemptToolDefinitions(input: CreateInteractionAttemptToolsInput): AttemptToolDefinition[];
21
+ /**
22
+ * One top-level object schema for every platform variant of a native CUA tool.
23
+ * Model providers require a plain object at the top level of a function schema
24
+ * (a top-level anyOf is rejected or ignored, so the model loses every argument
25
+ * type). Properties are the union across platforms; a property whose platform
26
+ * schemas differ in more than wording keeps those alternatives at property
27
+ * level. Only properties every platform requires stay required. The session's
28
+ * CUA worker still validates arguments against its own exact platform schema.
29
+ */
30
+ export declare function nativeCuaSchema(variants: Record<string, unknown>[]): AttemptToolJsonSchema;
21
31
  export type CreateFirstPartyInteractionAttemptToolsInput = Omit<CreateInteractionAttemptToolsInput, "transport" | "workspaceId" | "sessionId"> & {
22
32
  settings: Settings;
23
33
  scope: AttemptToolScope;
@@ -4,7 +4,7 @@ import {
4
4
  SingleModelCallUnsupportedError,
5
5
  normalizeModelCallUsage,
6
6
  runSingleModelCall
7
- } from "./chunk-EQQ3YOKR.js";
7
+ } from "./chunk-7OUG7WBV.js";
8
8
  import "./chunk-5ULGMFKR.js";
9
9
  import "./chunk-KCPW4K7L.js";
10
10
  import "./chunk-7LJ7KDPI.js";
@@ -1,4 +1,4 @@
1
- type SizedImage = {
1
+ export type SizedImage = {
2
2
  data: string;
3
3
  mediaType: string;
4
4
  originalWidth: number;
@@ -6,6 +6,12 @@ type SizedImage = {
6
6
  width: number;
7
7
  height: number;
8
8
  };
9
- /** A bounded, run-local cache. Image count never changes an image's projection. */
10
- export declare function createModelImageSizer(maxDimension: number, maxEncodedBytes?: number): (data: string, mediaType: string) => Promise<SizedImage>;
11
- export {};
9
+ export type ModelImageSizerOptions = {
10
+ /**
11
+ * Re-encode a resized JPEG as JPEG at this quality instead of starting with
12
+ * lossless PNG. Photos then stay small without a PNG encode per image.
13
+ */
14
+ jpegQuality?: number;
15
+ };
16
+ /** A bounded cache. Image count never changes an image's projection. */
17
+ export declare function createModelImageSizer(maxDimension: number, maxEncodedBytes?: number, options?: ModelImageSizerOptions): (data: string, mediaType: string) => Promise<SizedImage>;
@@ -3,10 +3,10 @@ import {
3
3
  prepareAgentTools,
4
4
  secretArgumentMode,
5
5
  sessionMcpApprovalConnectionId
6
- } from "./chunk-JDNBDSQV.js";
6
+ } from "./chunk-TE3JIUL6.js";
7
7
  import "./chunk-462CH77G.js";
8
8
  import "./chunk-4KFT2G3W.js";
9
- import "./chunk-EQQ3YOKR.js";
9
+ import "./chunk-7OUG7WBV.js";
10
10
  import "./chunk-5ULGMFKR.js";
11
11
  import "./chunk-XQ23VCZ4.js";
12
12
  import "./chunk-RSHQSLEJ.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/runtime",
3
- "version": "3.0.2-canary.38894290271001",
3
+ "version": "3.1.2",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -75,19 +75,19 @@
75
75
  "@openai/agents": "0.14.3",
76
76
  "@openai/agents-core": "0.14.3",
77
77
  "@openai/agents-extensions": "0.14.3",
78
- "@opengeni/agent-proto": "3.0.2-canary.38894290271001",
79
- "@opengeni/capabilities": "3.0.2-canary.38894290271001",
80
- "@opengeni/codemode": "3.0.2-canary.38894290271001",
81
- "@opengeni/codex": "3.0.2-canary.38894290271001",
82
- "@opengeni/config": "3.0.2-canary.38894290271001",
83
- "@opengeni/contracts": "3.0.2-canary.38894290271001",
84
- "@opengeni/interaction": "3.0.2-canary.38894290271001",
85
- "@opengeni/network": "3.0.2-canary.38894290271001",
86
- "@opengeni/observability": "3.0.2-canary.38894290271001",
87
- "@opengeni/sdk": "3.0.2-canary.38894290271001",
88
- "@opengeni/tool-gateway": "3.0.2-canary.38894290271001",
89
- "@opengeni/vaults": "3.0.2-canary.38894290271001",
90
- "@opengeni/xai-subscription": "3.0.2-canary.38894290271001",
78
+ "@opengeni/agent-proto": "3.1.2",
79
+ "@opengeni/capabilities": "3.1.2",
80
+ "@opengeni/codemode": "3.1.2",
81
+ "@opengeni/codex": "3.1.2",
82
+ "@opengeni/config": "3.1.2",
83
+ "@opengeni/contracts": "3.1.2",
84
+ "@opengeni/interaction": "3.1.2",
85
+ "@opengeni/network": "3.1.2",
86
+ "@opengeni/observability": "3.1.2",
87
+ "@opengeni/sdk": "3.1.2",
88
+ "@opengeni/tool-gateway": "3.1.2",
89
+ "@opengeni/vaults": "3.1.2",
90
+ "@opengeni/xai-subscription": "3.1.2",
91
91
  "debug": "4.4.3",
92
92
  "modal": "0.9.0",
93
93
  "openai": "6.47.0",
@@ -61,6 +61,45 @@ say which designs are possible and ask before building.
61
61
  - Keep secrets, Opengeni access keys, OAuth tokens, workspace ids, API base
62
62
  URLs, and signed URLs out of the source and generated HTML.
63
63
 
64
+ ## Make new Sites readable by default
65
+
66
+ Choose a clear type scale before building. For new Sites without an explicit
67
+ type scale, start main reading text at 16–18px, controls and table content at
68
+ 14–16px, and reserve smaller text for genuinely secondary metadata, not
69
+ descriptions, navigation, labels needed to use a control, or key results.
70
+ Use comfortable line heights (about 1.5–1.7 for prose) and reading widths of
71
+ roughly 60–75 characters. Check the chosen font at its rendered size: a large
72
+ headline does not make small body copy readable.
73
+
74
+ Use `rem`/`em` for text, keep the browser's root font size, and allow browser
75
+ zoom and user text scaling. Do not shrink primary copy on mobile to fit more
76
+ columns; reflow the layout, wrap content, or scroll a wide table instead.
77
+ Keep form text readable and let controls grow with their labels.
78
+
79
+ These are authoring defaults, not a mandatory theme or runtime CSS override.
80
+ Honor a user's deliberate design, retain an existing app's type system, and
81
+ leave embedded components such as `SessionConversation` on their own styles.
82
+ Do not inject a blanket font-size rule into existing Sites or use `!important`
83
+ to force this scale onto a component library. If `frontend-design` is available,
84
+ use it for visual direction alongside these readability defaults.
85
+
86
+ For a new page, this optional starting point applies only to elements you mark
87
+ with these classes; adapt it to the design rather than importing a global reset.
88
+ The sizes below assume the usual 16px browser default and scale with it.
89
+
90
+ ```css
91
+ .site-copy { font-size: 1.0625rem; line-height: 1.6; max-width: 70ch; }
92
+ .site-control { font: inherit; font-size: 1rem; line-height: 1.5; }
93
+ .site-table { font-size: 0.9375rem; line-height: 1.5; }
94
+ .site-meta { font-size: 0.8125rem; line-height: 1.5; }
95
+ ```
96
+
97
+ Before publishing, inspect actual paragraphs, labels, inputs, buttons, table
98
+ cells and empty/error states at desktop and narrow mobile widths, then with
99
+ text enlarged to 200%. Check computed sizes and look for clipped or overlapping
100
+ text; fix layout pressure rather than making the content smaller. A fixture can
101
+ demonstrate a type scale, but does not prove every generated Site follows it.
102
+
64
103
  ## Use normal Bun + React
65
104
 
66
105
  Use the repository's pinned Bun version and ordinary package scripts. A minimal
@@ -672,6 +711,8 @@ with `textContent` or React text, never `innerHTML`.
672
711
  - The compiled runtime is one self-contained HTML document within the published
673
712
  size limit.
674
713
  - Desktop and mobile interactions were exercised in the sandbox-local preview.
714
+ - Reading text, controls and tables follow the chosen readable type scale;
715
+ mobile and 200% text scaling do not clip or shrink primary content.
675
716
  - Exercise live tools and SDK operations locally through `/__opengeni/site-tools/*`
676
717
  wherever the attempt's Codemode authority permits. Report remaining
677
718
  viewer-only checks explicitly; those do not by themselves block publication.
@@ -98,6 +98,43 @@ export function isHarnessControlMcpToolName(registryId: string, modelName: strin
98
98
  );
99
99
  }
100
100
 
101
+ /**
102
+ * First-party interaction tools that are only useful together. Disclosing one
103
+ * member through search discloses the whole working set, so an agent that asks
104
+ * for "a browser" gets open, observe, act, screenshot and tab control in one
105
+ * search instead of rediscovering each schema in its own model round trip.
106
+ * Members are matched by their name after the server separator and only ever
107
+ * drawn from the already-authorized searchable pool.
108
+ */
109
+ const INTERACTION_MCP_SERVER_ID = "interaction";
110
+ const INTERACTION_TOOL_FAMILIES: readonly (readonly string[])[] = [
111
+ ["browser_open", "browser_observe", "browser_act", "browser_screenshot", "browser_tabs"],
112
+ ["computer_open", "computer_observe", "computer_act"],
113
+ ];
114
+
115
+ /** The interaction tool's name after its server prefix, or null for any other tool. */
116
+ function interactionToolName(name: string): string | null {
117
+ const prefix = `${INTERACTION_MCP_SERVER_ID}${MCP_TOOL_NAME_SEPARATOR}`;
118
+ return name.startsWith(prefix) ? name.slice(prefix.length) : null;
119
+ }
120
+
121
+ /** The other members of every family a disclosed tool belongs to, in family order. */
122
+ function familyCompanions<T extends Tool & { name: string }>(disclosed: T[], pool: T[]): T[] {
123
+ const disclosedShort = new Set(
124
+ disclosed.map((tool) => interactionToolName(tool.name)).filter((name) => name !== null),
125
+ );
126
+ const wanted = INTERACTION_TOOL_FAMILIES.filter((family) =>
127
+ family.some((member) => disclosedShort.has(member)),
128
+ ).flat();
129
+ const companions: T[] = [];
130
+ for (const member of wanted) {
131
+ if (disclosedShort.has(member)) continue;
132
+ const tool = pool.find((candidate) => interactionToolName(candidate.name) === member);
133
+ if (tool && !companions.includes(tool)) companions.push(tool);
134
+ }
135
+ return companions;
136
+ }
137
+
101
138
  /**
102
139
  * True when a prepared MCP function tool is one of the harness control tools
103
140
  * served by the authorized first-party `opengeni` server. These stay in the
@@ -353,5 +390,16 @@ export function searchToolPool(availableTools: Tool[], rawArguments: unknown): T
353
390
  bounded.push(tool);
354
391
  if (bounded.length >= limit) break;
355
392
  }
393
+ // Complete any interaction family a result belongs to. Companions come after
394
+ // the ranked results, so they never displace them, and they still respect the
395
+ // per-tool and total disclosure byte budgets (but not the result count limit).
396
+ for (const tool of familyCompanions(bounded as (Tool & { name: string })[], searchable)) {
397
+ if (bounded.includes(tool)) continue;
398
+ const toolBytes = mcpSerializedSizeBytes(tool);
399
+ if (toolBytes > MCP_MAX_TOOL_DEFINITION_BYTES) continue;
400
+ if (disclosedBytes + toolBytes > MCP_MAX_TOOL_SEARCH_DISCLOSURE_BYTES) continue;
401
+ disclosedBytes += toolBytes;
402
+ bounded.push(tool);
403
+ }
356
404
  return bounded;
357
405
  }
@@ -22,6 +22,7 @@ import {
22
22
  FileResourceRef,
23
23
  MODEL_ATTACHMENT_CATALOG_MARKER,
24
24
  MODEL_ATTACHMENT_CATALOG_RENDERED_FIELD,
25
+ MODEL_ATTACHMENT_IMAGE_MAX_DIMENSION_FIELD,
25
26
  MODEL_ATTACHMENT_REFS_FIELD,
26
27
  collapseRepeatedSessionGoalContexts,
27
28
  expandUnchangedSessionGoalContexts,
@@ -482,13 +483,15 @@ function jsonStringUtf8ByteLength(value: string): number {
482
483
  }
483
484
 
484
485
  const IMAGE_LOW_DETAIL_TOKENS = 85;
485
- const IMAGE_HIGH_DETAIL_BASE_TOKENS = 85;
486
- const IMAGE_HIGH_DETAIL_TILE_TOKENS = 170;
487
- const IMAGE_HIGH_DETAIL_TILE_SIZE = 512;
488
- const IMAGE_HIGH_DETAIL_MAX_SIDE = 2_048;
489
- const IMAGE_HIGH_DETAIL_TARGET_SHORT_SIDE = 768;
486
+ // Current vision models price an image by the patches it covers at the
487
+ // resolution it is sent. One token per 28 px patch is a safe bound for both
488
+ // families in use: OpenAI's 32 px patches cost about 1.2 tokens each, which is
489
+ // slightly less per pixel. A photo sent at full resolution therefore costs
490
+ // many thousands of tokens, not a few hundred.
491
+ const IMAGE_PATCH_SIZE = 28;
490
492
  export const UNKNOWN_IMAGE_TOKENS = 4_096;
491
- export const MAX_NATIVE_IMAGE_TOKENS = 8_192;
493
+ /** OpenAI rejects images above 30,000 of its patches (about 36,000 tokens). */
494
+ export const MAX_NATIVE_IMAGE_TOKENS = 36_000;
492
495
 
493
496
  export type NativeImageEstimateReason = "dimensions" | "bounded_fallback";
494
497
  export type NativeImageTokenEstimate = {
@@ -701,27 +704,10 @@ function dimensionsFromImageSource(source: unknown): [number, number] | null {
701
704
  );
702
705
  }
703
706
 
704
- function highDetailImageTokens(width: number, height: number): number {
705
- let scaledWidth = width;
706
- let scaledHeight = height;
707
- const longest = Math.max(scaledWidth, scaledHeight);
708
- if (longest > IMAGE_HIGH_DETAIL_MAX_SIDE) {
709
- const scale = IMAGE_HIGH_DETAIL_MAX_SIDE / longest;
710
- scaledWidth *= scale;
711
- scaledHeight *= scale;
712
- }
713
- const shortest = Math.min(scaledWidth, scaledHeight);
714
- if (shortest > IMAGE_HIGH_DETAIL_TARGET_SHORT_SIDE) {
715
- const scale = IMAGE_HIGH_DETAIL_TARGET_SHORT_SIDE / shortest;
716
- scaledWidth *= scale;
717
- scaledHeight *= scale;
718
- }
719
- const tiles =
720
- Math.ceil(scaledWidth / IMAGE_HIGH_DETAIL_TILE_SIZE) *
721
- Math.ceil(scaledHeight / IMAGE_HIGH_DETAIL_TILE_SIZE);
707
+ function patchImageTokens(width: number, height: number): number {
722
708
  return Math.min(
723
709
  MAX_NATIVE_IMAGE_TOKENS,
724
- IMAGE_HIGH_DETAIL_BASE_TOKENS + IMAGE_HIGH_DETAIL_TILE_TOKENS * Math.max(1, tiles),
710
+ Math.max(1, Math.ceil(width / IMAGE_PATCH_SIZE) * Math.ceil(height / IMAGE_PATCH_SIZE)),
725
711
  );
726
712
  }
727
713
 
@@ -755,7 +741,7 @@ export function estimateNativeImageTokens(input: {
755
741
  };
756
742
  }
757
743
  return {
758
- tokens: highDetailImageTokens(width, height),
744
+ tokens: patchImageTokens(width, height),
759
745
  width,
760
746
  height,
761
747
  detail,
@@ -1680,10 +1666,41 @@ function isPortableRetentionCandidate(item: CompactionItem): boolean {
1680
1666
  return isPortableInputMessage(item) && !isCompactionSummary(item) && !isAttachmentCatalog(item);
1681
1667
  }
1682
1668
 
1669
+ function hasAttachmentRefs(item: CompactionItem): boolean {
1670
+ const raw = item[MODEL_ATTACHMENT_REFS_FIELD];
1671
+ return Array.isArray(raw) && raw.length > 0;
1672
+ }
1673
+
1674
+ /**
1675
+ * Only the newest user message that carries attachments keeps them inline
1676
+ * after compaction. Older retained messages drop their references, so their
1677
+ * files are listed in the dated attachment catalog instead of being replayed
1678
+ * as images on every later request. Their text is unchanged and they are
1679
+ * charged only for that text.
1680
+ */
1681
+ function withOnlyNewestAttachmentsInline(items: readonly CompactionItem[]): CompactionItem[] {
1682
+ let newest = -1;
1683
+ for (let index = items.length - 1; index >= 0; index -= 1) {
1684
+ const item = items[index]!;
1685
+ if (itemRole(item) === "user" && !isAttachmentCatalog(item) && hasAttachmentRefs(item)) {
1686
+ newest = index;
1687
+ break;
1688
+ }
1689
+ }
1690
+ return items.map((item, index) => {
1691
+ if (index === newest || isAttachmentCatalog(item) || !hasAttachmentRefs(item)) return item;
1692
+ const stripped: CompactionItem = { ...item };
1693
+ delete stripped[MODEL_ATTACHMENT_REFS_FIELD];
1694
+ delete stripped[MODEL_ATTACHMENT_IMAGE_MAX_DIMENSION_FIELD];
1695
+ return stripped;
1696
+ });
1697
+ }
1698
+
1683
1699
  /**
1684
1700
  * Build the active history after compaction:
1685
1701
  * the newest user/system input messages that fit one cumulative, model-bounded budget
1686
- * (prior summaries excluded, retained images preserved) plus one marked summary item.
1702
+ * (prior summaries excluded) plus one marked summary item. Only the newest
1703
+ * user message with attachments keeps its images inline.
1687
1704
  * Each distinct session goal context is kept once, and answered transient
1688
1705
  * machine-status batches are left to the summary.
1689
1706
  * Attachments of messages that were not retained are listed in one dated
@@ -1701,7 +1718,10 @@ export function buildCompactionReplacementHistory(
1701
1718
  0,
1702
1719
  Math.min(COMPACT_USER_MESSAGE_MAX_TOKENS, retainedMessageBudgetTokens),
1703
1720
  );
1704
- const prepared = prepareRetainedInputs(items, isPortableRetentionCandidate);
1721
+ const prepared = prepareRetainedInputs(
1722
+ withOnlyNewestAttachmentsInline(items),
1723
+ isPortableRetentionCandidate,
1724
+ );
1705
1725
  for (let index = prepared.charged.length - 1; index >= 0 && remaining > 0; index -= 1) {
1706
1726
  const item = prepared.charged[index]!;
1707
1727
  if (!isPortableRetentionCandidate(item) || prepared.skip(index)) {
@@ -1763,8 +1783,8 @@ export function isRetainedRemoteV2Message(item: unknown): boolean {
1763
1783
  * newest retained user/system/developer messages within the CLI 64k budget plus the
1764
1784
  * opaque `{ type: "compaction", encrypted_content }` item.
1765
1785
  *
1766
- * Both modes preserve retained image parts. Charge their projected image
1767
- * tokens, including uploads represented by durable refs, against the budget.
1786
+ * Both modes keep attachments inline only on the newest user message that has
1787
+ * them, and charge its projected image tokens against the budget.
1768
1788
  */
1769
1789
  export function buildRemoteV2ReplacementHistory(
1770
1790
  items: readonly CompactionItem[],
@@ -1780,7 +1800,7 @@ export function buildRemoteV2ReplacementHistory(
1780
1800
  const retainedReversed: CompactionItem[] = [];
1781
1801
  let remaining = REMOTE_V2_RETAINED_MESSAGE_TOKEN_BUDGET;
1782
1802
  const prepared = prepareRetainedInputs(
1783
- items,
1803
+ withOnlyNewestAttachmentsInline(items),
1784
1804
  (item) => readSkillCatalogContext(item) === null && isRetainedRemoteV2Message(item),
1785
1805
  );
1786
1806
  for (let index = prepared.charged.length - 1; index >= 0 && remaining > 0; index -= 1) {
@@ -35,6 +35,7 @@ import {
35
35
  import {
36
36
  MODEL_ATTACHMENT_CATALOG_MARKER,
37
37
  MODEL_ATTACHMENT_CATALOG_RENDERED_FIELD,
38
+ MODEL_ATTACHMENT_IMAGE_MAX_DIMENSION_FIELD,
38
39
  MODEL_ATTACHMENT_REFS_FIELD,
39
40
  MODEL_TIMELINE_ANNOTATIONS_FIELD,
40
41
  } from "@opengeni/contracts";
@@ -340,6 +341,7 @@ export function stripInternalModelMetadata<T extends HistoryItem>(item: T): T {
340
341
  if (
341
342
  !(MODEL_ATTACHMENT_CATALOG_MARKER in withoutResume) &&
342
343
  !(MODEL_ATTACHMENT_CATALOG_RENDERED_FIELD in withoutResume) &&
344
+ !(MODEL_ATTACHMENT_IMAGE_MAX_DIMENSION_FIELD in withoutResume) &&
343
345
  !(MODEL_ATTACHMENT_REFS_FIELD in withoutResume) &&
344
346
  !(MODEL_TIMELINE_ANNOTATIONS_FIELD in withoutResume)
345
347
  ) {
@@ -348,6 +350,7 @@ export function stripInternalModelMetadata<T extends HistoryItem>(item: T): T {
348
350
  const clone = { ...withoutResume } as Record<string, unknown>;
349
351
  delete clone[MODEL_ATTACHMENT_CATALOG_MARKER];
350
352
  delete clone[MODEL_ATTACHMENT_CATALOG_RENDERED_FIELD];
353
+ delete clone[MODEL_ATTACHMENT_IMAGE_MAX_DIMENSION_FIELD];
351
354
  delete clone[MODEL_ATTACHMENT_REFS_FIELD];
352
355
  delete clone[MODEL_TIMELINE_ANNOTATIONS_FIELD];
353
356
  return clone as T;
package/src/index.ts CHANGED
@@ -15,6 +15,11 @@ export {
15
15
  compactionPrefixCuts,
16
16
  } from "./anthropic-compaction";
17
17
  export { AnthropicProviderRejection } from "./anthropic-messages";
18
+ export {
19
+ createModelImageSizer,
20
+ type ModelImageSizerOptions,
21
+ type SizedImage,
22
+ } from "./model-image-sizing";
18
23
  import { runSingleModelCall, type SingleModelCallTarget } from "./single-model-call";
19
24
  export {
20
25
  runSingleModelCall,
@@ -33,6 +38,7 @@ import { instrumentedModelFetch } from "./model-provider-client";
33
38
  import type { ModelProviderApi, ResolvedModelProvider, Settings } from "@opengeni/config";
34
39
  import { isRunMcpCredentialError, RunMcpCredentials } from "./mcp-run-credentials";
35
40
  import {
41
+ mcpToolRecommendsApproval,
36
42
  normalizeCredentialProviderMcpUrl,
37
43
  toolPolicyActionName,
38
44
  toolReviewContextFromSchema,
@@ -3584,6 +3590,7 @@ function installMcpApprovalPolicy(
3584
3590
  inputSchema: unknown;
3585
3591
  title?: string;
3586
3592
  accountLabel?: string | undefined;
3593
+ recommendsApproval: boolean;
3587
3594
  readReview: ToolReviewReader;
3588
3595
  }
3589
3596
  >();
@@ -3602,6 +3609,7 @@ function installMcpApprovalPolicy(
3602
3609
  serverId: server.registryId,
3603
3610
  toolName: await server.unprefixedToolName(descriptor.name),
3604
3611
  inputSchema: descriptor.inputSchema,
3612
+ recommendsApproval: mcpToolRecommendsApproval(descriptor),
3605
3613
  readReview: (name, args) => server.reviewContext(name, args),
3606
3614
  ...(descriptor.title ? { title: descriptor.title } : {}),
3607
3615
  ...(accountLabel ? { accountLabel } : {}),
@@ -3622,8 +3630,12 @@ function installMcpApprovalPolicy(
3622
3630
  const unprefixed = identity?.toolName ?? tool.name.slice(policy.prefix.length);
3623
3631
  const originalNeedsApproval = tool.needsApproval.bind(tool);
3624
3632
  const originalInvoke = tool.invoke.bind(tool);
3625
- const legacyApproval =
3626
- !policy.connectorBacked && mcpToolRequiresApproval(policy.requireApproval, unprefixed);
3633
+ // The recommendation: the session's requireApproval or the tool's own
3634
+ // tools/list `_meta`. Either one asking makes Ask the default.
3635
+ const recommendsApproval =
3636
+ mcpToolRequiresApproval(policy.requireApproval, unprefixed) ||
3637
+ identity?.recommendsApproval === true;
3638
+ const legacyApproval = !policy.connectorBacked && recommendsApproval;
3627
3639
  if (!policy.connectorBacked && !legacyApproval && !connectorActionPolicy) {
3628
3640
  return tool;
3629
3641
  }
@@ -3645,9 +3657,7 @@ function installMcpApprovalPolicy(
3645
3657
  }),
3646
3658
  actionName: toolPolicyActionName(unprefixed, identity?.inputSchema, args),
3647
3659
  ...(legacyApproval ? { approvalMode: "session_mcp" as const } : {}),
3648
- defaultDecision: mcpToolRequiresApproval(policy.requireApproval, unprefixed)
3649
- ? "ask"
3650
- : "allow",
3660
+ defaultDecision: recommendsApproval ? "ask" : "allow",
3651
3661
  };
3652
3662
  };
3653
3663
  return {
@@ -3659,8 +3669,7 @@ function installMcpApprovalPolicy(
3659
3669
  ) => {
3660
3670
  if (!connectorActionPolicy) {
3661
3671
  return (
3662
- mcpToolRequiresApproval(policy.requireApproval, unprefixed) ||
3663
- (await originalNeedsApproval(runContext, parsedInput, callId))
3672
+ recommendsApproval || (await originalNeedsApproval(runContext, parsedInput, callId))
3664
3673
  );
3665
3674
  }
3666
3675
  if (!callId) {
@@ -3679,7 +3688,7 @@ function installMcpApprovalPolicy(
3679
3688
  // An explicit Allow is a decision, not an exemption from one of several floors.
3680
3689
  const requiresApproval = preparation.managed
3681
3690
  ? preparation.decision === "ask"
3682
- : mcpToolRequiresApproval(policy.requireApproval, unprefixed);
3691
+ : recommendsApproval;
3683
3692
  if (requiresApproval) approvalRequiredCallIds.add(callId);
3684
3693
  else approvalRequiredCallIds.delete(callId);
3685
3694
  return requiresApproval;
@@ -3936,7 +3945,9 @@ function installInteractionInterventionPolicy(
3936
3945
 
3937
3946
  /**
3938
3947
  * Translate the canonical decision into the model SDK interruption protocol.
3939
- * Legacy requireApproval is a recommendation only when no explicit choice exists.
3948
+ * Legacy requireApproval and a tool's `opengeni/recommendedPermission` `_meta`
3949
+ * are recommendations only when no explicit choice exists. Every external MCP
3950
+ * server is wrapped so a tool's own recommendation holds even without hooks.
3940
3951
  * Standalone runtimes without policy hooks retain their configured behavior;
3941
3952
  * they cannot execute an approval-gated call without the durable authority.
3942
3953
  * Prepared server/tool identities take precedence over longest-prefix legacy
@@ -3953,7 +3964,7 @@ function applyMcpApprovalPolicy(
3953
3964
  .filter(
3954
3965
  (server) =>
3955
3966
  Boolean(server.connectionRef) ||
3956
- Boolean(connectorActionPolicy && attemptToolSource(server.id) === "mcp") ||
3967
+ attemptToolSource(server.id) === "mcp" ||
3957
3968
  server.requireApproval === true ||
3958
3969
  (Array.isArray(server.requireApproval) && server.requireApproval.length > 0),
3959
3970
  )
@@ -5851,10 +5862,7 @@ function installAttemptConnectorActionGatewayLifecycle(
5851
5862
  definition.inputSchema,
5852
5863
  arguments_,
5853
5864
  ),
5854
- defaultDecision:
5855
- attemptToolApproval(config!, definition.identity.toolName) === "human"
5856
- ? "ask"
5857
- : "allow",
5865
+ defaultDecision: definition.approval === "human" ? "ask" : "allow",
5858
5866
  };
5859
5867
  }
5860
5868
  : (approvalId: string, arguments_: unknown): ConnectorActionToolCall => ({
@@ -6163,7 +6171,7 @@ async function prepareToolGatewayDefinitionsFromServers(
6163
6171
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
6164
6172
  ...(tool.icons ? { icons: tool.icons } : {}),
6165
6173
  source: attemptToolSource(server.registryId),
6166
- approval: attemptToolApproval(config, toolName),
6174
+ approval: attemptToolApproval(config, toolName, tool),
6167
6175
  effectAuthorityDigest: digestCanonicalJson({
6168
6176
  version: 1,
6169
6177
  serverId: server.registryId,
@@ -6331,10 +6339,12 @@ function attemptToolSource(serverId: string): ToolGatewayDefinition["source"] {
6331
6339
  function attemptToolApproval(
6332
6340
  config: Settings["mcpServers"][number],
6333
6341
  toolName: string,
6342
+ tool: object,
6334
6343
  ): ToolGatewayDefinition["approval"] {
6335
6344
  if (
6336
6345
  config.requireApproval === true ||
6337
- (Array.isArray(config.requireApproval) && config.requireApproval.includes(toolName))
6346
+ (Array.isArray(config.requireApproval) && config.requireApproval.includes(toolName)) ||
6347
+ (attemptToolSource(config.id) === "mcp" && mcpToolRecommendsApproval(tool))
6338
6348
  ) {
6339
6349
  return "human";
6340
6350
  }