@opengeni/worker-bundle 0.27.0-canary.0 → 0.27.2-canary.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 (60) hide show
  1. package/dist/activities/agent-turn/agent-build.d.ts +3 -4
  2. package/dist/activities/agent-turn/governance-model.d.ts +2 -3
  3. package/dist/activities/agent-turn/skill-checkout.d.ts +21 -0
  4. package/dist/activities/agent-turn/skill-install.d.ts +12 -0
  5. package/dist/activities/agent-turn/skill-read.d.ts +15 -0
  6. package/dist/activities/agent-turn/skill-save.d.ts +26 -0
  7. package/dist/activities/agent-turn/skill-search.d.ts +16 -0
  8. package/dist/activities/agent-turn/skill-selection.d.ts +17 -0
  9. package/dist/activities/agent-turn/skill-tools.d.ts +20 -0
  10. package/dist/activities/agent-turn/skill-transfer.d.ts +26 -0
  11. package/dist/activities/agent-turn/tool-environment.d.ts +7 -0
  12. package/dist/activities/parent-wake.d.ts +12 -2
  13. package/dist/activities/run-input.d.ts +11 -5
  14. package/dist/activities/sandbox-lease.d.ts +5 -4
  15. package/dist/activities/types.d.ts +4 -2
  16. package/dist/activities/workflow-wake.d.ts +2 -4
  17. package/dist/{activities-control-VMA3XMHE.js → activities-control-WZRFEIJH.js} +103 -26
  18. package/dist/activities-control-WZRFEIJH.js.map +1 -0
  19. package/dist/{activities-turn-57AOUNYN.js → activities-turn-V7J34ZOF.js} +945 -532
  20. package/dist/activities-turn-V7J34ZOF.js.map +1 -0
  21. package/dist/{chunk-D4ZEFOYH.js → chunk-BARTLVBK.js} +1 -1
  22. package/dist/chunk-BARTLVBK.js.map +1 -0
  23. package/dist/{chunk-7LS42PHP.js → chunk-C54VDT6I.js} +590 -34
  24. package/dist/chunk-C54VDT6I.js.map +1 -0
  25. package/dist/index.js +6 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/model-context-contributions.d.ts +2 -0
  28. package/dist/observability-metrics.d.ts +1 -1
  29. package/dist/workflow-bundle.js +1 -1
  30. package/package.json +17 -17
  31. package/src/activities/agent-turn/agent-build.ts +8 -34
  32. package/src/activities/agent-turn/errors.ts +4 -0
  33. package/src/activities/agent-turn/governance-model.ts +15 -19
  34. package/src/activities/agent-turn/run.ts +4 -6
  35. package/src/activities/agent-turn/sandbox-provision.ts +9 -0
  36. package/src/activities/agent-turn/skill-checkout.ts +124 -0
  37. package/src/activities/agent-turn/skill-install.ts +57 -0
  38. package/src/activities/agent-turn/skill-read.ts +117 -0
  39. package/src/activities/agent-turn/skill-save.ts +124 -0
  40. package/src/activities/agent-turn/skill-search.ts +110 -0
  41. package/src/activities/agent-turn/skill-selection.ts +53 -0
  42. package/src/activities/agent-turn/skill-tools.ts +268 -0
  43. package/src/activities/agent-turn/skill-transfer.ts +137 -0
  44. package/src/activities/agent-turn/tool-environment.ts +93 -1
  45. package/src/activities/automations.ts +3 -1
  46. package/src/activities/context-compaction.ts +26 -2
  47. package/src/activities/parent-wake.ts +66 -5
  48. package/src/activities/run-input.ts +88 -12
  49. package/src/activities/sandbox-lease.ts +77 -20
  50. package/src/activities/scheduled-tasks.ts +2 -0
  51. package/src/activities/types.ts +4 -2
  52. package/src/activities/workflow-wake.ts +42 -5
  53. package/src/index.ts +3 -1
  54. package/src/model-context-contributions.ts +18 -2
  55. package/src/observability-metrics.ts +2 -0
  56. package/src/sandbox-routing.ts +42 -1
  57. package/dist/activities-control-VMA3XMHE.js.map +0 -1
  58. package/dist/activities-turn-57AOUNYN.js.map +0 -1
  59. package/dist/chunk-7LS42PHP.js.map +0 -1
  60. package/dist/chunk-D4ZEFOYH.js.map +0 -1
@@ -1,6 +1,8 @@
1
1
  import { getWorkspaceConnectionModelRestrictions } from "@opengeni/db";
2
2
  import {
3
3
  beginConnectorActionExecution,
4
+ getWorkspaceVideoGenerationPolicy,
5
+ listSkillDescriptors,
4
6
  completeConnectorActionExecution,
5
7
  getScheduledVariableSetExpectedGenerationForAttempt,
6
8
  getWorkspaceModelPolicy,
@@ -86,6 +88,10 @@ import {
86
88
  } from "./session-title";
87
89
  import { resolveTurnSandboxAccess } from "./turn-sandbox-access";
88
90
  import { createListModelsAttemptToolDefinition } from "./list-models";
91
+ import { createWorkspaceSkillTools } from "./skill-tools";
92
+ import { loadConfiguredBundledSkills } from "./skill-selection";
93
+ import { guardSkillFilesystem } from "./skill-transfer";
94
+ import type { RuntimeSkillActivation } from "@opengeni/runtime";
89
95
 
90
96
  export type PrepareTurnToolPolicyDeps = {
91
97
  input: RunAgentTurnInput;
@@ -102,6 +108,7 @@ export type PrepareTurnToolPolicyDeps = {
102
108
  };
103
109
 
104
110
  export type PrepareTurnToolRuntimeDeps = {
111
+ selectedSkillActivations: readonly RuntimeSkillActivation[];
105
112
  input: RunAgentTurnInput;
106
113
  catalogSourceSettings: Settings;
107
114
  db: ActivityServices["db"];
@@ -555,7 +562,88 @@ export async function prepareTurnToolRuntime(deps: PrepareTurnToolRuntimeDeps) {
555
562
  outcome,
556
563
  }),
557
564
  };
565
+ const skillConfiguration = await getWorkspaceVideoGenerationPolicy(db, input.workspaceId);
566
+ const bundledSkills = loadConfiguredBundledSkills({
567
+ bundledSkillIds: session.bundledSkillIds,
568
+ firstPartyTools: selectedFirstPartyMcpTools,
569
+ videoGenerationEnabled:
570
+ skillConfiguration.defaultModelId !== null && skillConfiguration.enabledModelIds.length > 0,
571
+ });
572
+ const selectedSkills = [
573
+ ...bundledSkills,
574
+ ...deps.selectedSkillActivations.map((entry) => ({ id: entry.id, artifact: entry.artifact })),
575
+ ...session.skills.map((skill) => ({
576
+ id: `session:${session.id}:${skill.name}`,
577
+ artifact: skill,
578
+ })),
579
+ ];
580
+ const sharedSkillDescriptors = await listSkillDescriptors(db, {
581
+ accountId: input.accountId,
582
+ workspaceId: input.workspaceId,
583
+ ...(deps.fileAuthoritySubjectId ? { subjectId: deps.fileAuthoritySubjectId } : {}),
584
+ });
585
+ const skillCatalog = [
586
+ ...sharedSkillDescriptors
587
+ .filter((entry) => entry.activationMode === "workspace_managed")
588
+ .map((entry) => ({
589
+ id: entry.id,
590
+ name: entry.title,
591
+ description: entry.description,
592
+ })),
593
+ ...selectedSkills.map((entry) => ({
594
+ id: entry.id,
595
+ name: entry.artifact.name,
596
+ description: entry.artifact.description || entry.artifact.name,
597
+ })),
598
+ ];
599
+ const skillTools = createWorkspaceSkillTools({
600
+ db,
601
+ settings: runSettings,
602
+ accountId: input.accountId,
603
+ workspaceId: input.workspaceId,
604
+ ...(deps.fileAuthoritySubjectId ? { subjectId: deps.fileAuthoritySubjectId } : {}),
605
+ actor: {
606
+ kind: "agent",
607
+ sessionId: input.sessionId,
608
+ turnId: turn.id,
609
+ attemptId: input.attemptId,
610
+ executionGeneration: attempt.executionGeneration,
611
+ },
612
+ selected: selectedSkills,
613
+ filesystem: async () => {
614
+ throwIfWorkerShuttingDown();
615
+ throwIfTurnCancelled();
616
+ const access = await resolveTurnSandboxAccess(
617
+ sandboxState,
618
+ media.sdkOwnedSandboxSession,
619
+ "Skill checkout/publish requires a sandbox or Connected Machine.",
620
+ );
621
+ const machineRoot = sandboxState.machinePrimarySession?.workspaceRoot;
622
+ const runAs = sandboxRunAs(runSettings);
623
+ const channel = new SandboxChannelAService({
624
+ session: access.session,
625
+ workspaceRoot: machineRoot ?? "/workspace",
626
+ ...(machineRoot ? { providerPathMode: "workspace-relative" as const } : {}),
627
+ leaseEpoch: access.leaseEpoch,
628
+ emit: async (events) => {
629
+ await eventing.publish?.(events, true);
630
+ },
631
+ ...(runAs ? { runAs } : {}),
632
+ });
633
+ return guardSkillFilesystem(channel, {
634
+ assertActive: () => {
635
+ throwIfWorkerShuttingDown();
636
+ throwIfTurnCancelled();
637
+ },
638
+ runMutation: async (mutation) =>
639
+ access.sandbox && !routingOn
640
+ ? runWorkspaceMutationForSandbox(access.sandbox, "skillCheckout", mutation)
641
+ : mutation(),
642
+ });
643
+ },
644
+ });
558
645
  const attemptToolDefinitions = [
646
+ ...skillTools,
559
647
  createListModelsAttemptToolDefinition({
560
648
  currentModelId: turnExecutionPolicy.productModelId,
561
649
  load: async () => {
@@ -881,7 +969,11 @@ export async function prepareTurnToolRuntime(deps: PrepareTurnToolRuntimeDeps) {
881
969
  connectorActionPolicy,
882
970
  generateSessionTitleInParallel: titleToolPlan.generateTitleInParallel,
883
971
  postToolPreparationStartedAt,
884
- preparationIndependentToolNames: titleToolPlan.preparationIndependentToolNames,
972
+ preparationIndependentToolNames: [
973
+ ...titleToolPlan.preparationIndependentToolNames,
974
+ "skill_read",
975
+ ],
976
+ skillCatalog,
885
977
  };
886
978
  }
887
979
 
@@ -17,6 +17,7 @@ import {
17
17
  claimAutomationRun,
18
18
  settleAutomationRun,
19
19
  } from "@opengeni/db";
20
+ import { SessionSkills } from "@opengeni/contracts";
20
21
  import { agentRunAdmissionDenial } from "./agent-run-admission";
21
22
  import type {
22
23
  ControlActivityServices,
@@ -189,7 +190,8 @@ export function createAutomationActivities(
189
190
  workspaceId: input.workspaceId,
190
191
  initialMessage: accepted.initialMessage,
191
192
  resources: template.resources,
192
- skills: template.skills,
193
+ skills: SessionSkills.parse(template.skills),
194
+ bundledSkillIds: template.bundledSkillIds,
193
195
  tools: template.tools,
194
196
  toolPolicy: { mode: "explicit", inheritedFromSessionId: null },
195
197
  model,
@@ -362,7 +362,12 @@ async function compactContextRemoteV2(
362
362
  rewrittenToolOutputs = retry.rewrittenToolOutputs;
363
363
  compactionItem = await options.requestRemoteCompactionV2(settings, retry.input);
364
364
  }
365
- const replacementHistory = buildRemoteV2ReplacementHistory(canonicalItems, compactionItem);
365
+ const retainedTokens = await retentionTokenCounts(canonicalItems, projectForWire);
366
+ const replacementHistory = buildRemoteV2ReplacementHistory(
367
+ canonicalItems,
368
+ compactionItem,
369
+ (item) => retainedTokens.get(item) ?? estimateTokens([item]),
370
+ );
366
371
  const estimatedTokensAfter = estimateTokens(await projectForWire(replacementHistory));
367
372
  const replacementFingerprint = compactionReplacementFingerprint(replacementHistory);
368
373
  const tailItem = replacementHistory.at(-1);
@@ -433,7 +438,12 @@ async function compactContextPortable(
433
438
  const estimatedTokensBefore = estimateTokens(items);
434
439
  const summarized = await summarizeWithCodexOverflowTrimming(summarize, settings, items);
435
440
  const summaryBody = summarized.summaryBody;
436
- const replacementHistory = buildCompactionReplacementHistory(canonicalItems, summaryBody);
441
+ const retainedTokens = await retentionTokenCounts(canonicalItems, projectForWire);
442
+ const replacementHistory = buildCompactionReplacementHistory(
443
+ canonicalItems,
444
+ summaryBody,
445
+ (item) => retainedTokens.get(item) ?? estimateTokens([item]),
446
+ );
437
447
  const estimatedTokensAfter = estimateTokens(await projectForWire(replacementHistory));
438
448
  const replacementFingerprint = compactionReplacementFingerprint(replacementHistory);
439
449
  const previousReplacementFingerprint = latestCompactionReplacementFingerprint(canonicalItems);
@@ -579,3 +589,17 @@ export function isExactContextLengthExceeded(
579
589
  isExactContextLengthExceeded(record.diagnostics, seen)
580
590
  );
581
591
  }
592
+
593
+ /** Charge retained uploads by their projected image context, not reference text. */
594
+ async function retentionTokenCounts(
595
+ items: CompactionItem[],
596
+ project: (items: CompactionItem[]) => Promise<CompactionItem[]>,
597
+ ): Promise<Map<CompactionItem, number>> {
598
+ const counts = new Map<CompactionItem, number>();
599
+ for (const item of items) {
600
+ if (item.role === "user" || item.role === "developer") {
601
+ counts.set(item, estimateTokens(await project([item])));
602
+ }
603
+ }
604
+ return counts;
605
+ }
@@ -331,17 +331,50 @@ export async function reconcileAutomaticSessionTitleFanout(
331
331
  return { claimed: rows.length, delivered, failed };
332
332
  }
333
333
 
334
+ export type WorkflowWakeReconciliationResult = {
335
+ claimed: number;
336
+ /** Transport acceptance is not durable admission. */
337
+ signaled: number;
338
+ /** Revisions acknowledged by the durable admission guard. */
339
+ delivered: number;
340
+ pendingAdmission: number;
341
+ /** Legacy/embedding signalers may not return an acknowledgment receipt. */
342
+ unconfirmed: number;
343
+ failed: number;
344
+ pendingAdmissionBlockers: Partial<
345
+ Record<
346
+ Extract<
347
+ import("@opengeni/db").SessionWorkflowWakeDeliveryResult,
348
+ { action: "pending_admission" }
349
+ >["blocker"],
350
+ number
351
+ >
352
+ >;
353
+ };
354
+
334
355
  export async function reconcilePendingSessionWorkflowWakes(
335
356
  svc: NotifyServices,
336
357
  limit = 1_000,
337
358
  overrides: ReconcileSessionWorkflowWakeOverrides = {},
338
- ): Promise<{ claimed: number; delivered: number; failed: number }> {
359
+ ): Promise<WorkflowWakeReconciliationResult> {
339
360
  const claimPendingSessionWorkflowWakesFn =
340
361
  overrides.claimPendingSessionWorkflowWakes ?? claimPendingSessionWorkflowWakes;
341
362
  if (!svc.wakeSessionWorkflow) {
342
- return { claimed: 0, delivered: 0, failed: 0 };
363
+ return {
364
+ claimed: 0,
365
+ signaled: 0,
366
+ delivered: 0,
367
+ pendingAdmission: 0,
368
+ unconfirmed: 0,
369
+ failed: 0,
370
+ pendingAdmissionBlockers: {},
371
+ };
343
372
  }
344
373
  const repairs = await claimPendingSessionWorkflowWakesFn(svc.db, limit);
374
+ let signaled = 0;
375
+ let pendingAdmission = 0;
376
+ let unconfirmed = 0;
377
+ const pendingAdmissionBlockers: WorkflowWakeReconciliationResult["pendingAdmissionBlockers"] = {};
345
378
  let delivered = 0;
346
379
  let failed = 0;
347
380
  const queue = [...repairs];
@@ -349,16 +382,36 @@ export async function reconcilePendingSessionWorkflowWakes(
349
382
  for (;;) {
350
383
  const repair = queue.shift();
351
384
  if (!repair) return;
385
+ let signalAccepted = false;
386
+ const onSignalAccepted = () => {
387
+ if (signalAccepted) return;
388
+ signalAccepted = true;
389
+ signaled += 1;
390
+ };
352
391
  try {
353
- await svc.wakeSessionWorkflow!({
392
+ const receipt = await svc.wakeSessionWorkflow!({
354
393
  accountId: repair.accountId,
355
394
  workspaceId: repair.workspaceId,
356
395
  sessionId: repair.sessionId,
357
396
  workflowId: repair.temporalWorkflowId,
358
397
  wakeRevision: repair.wakeRevision,
359
398
  ...(repair.interruptionRequested ? { interruptionRequested: true } : {}),
399
+ onSignalAccepted,
360
400
  });
361
- delivered += 1;
401
+ // Older embeddings do not invoke the optional transport observer.
402
+ // Their successful return still proves a signal call, never an ACK.
403
+ onSignalAccepted();
404
+ if (receipt?.action === "acknowledged") {
405
+ delivered += 1;
406
+ } else if (receipt?.action === "pending_admission") {
407
+ pendingAdmission += 1;
408
+ pendingAdmissionBlockers[receipt.blocker] =
409
+ (pendingAdmissionBlockers[receipt.blocker] ?? 0) + 1;
410
+ } else {
411
+ // A successful legacy signal is not proof of acknowledgment. The
412
+ // committed outbox obligation remains owned by the existing guard.
413
+ unconfirmed += 1;
414
+ }
362
415
  } catch (error) {
363
416
  failed += 1;
364
417
  await markSessionWorkflowWakeFailed(
@@ -370,7 +423,15 @@ export async function reconcilePendingSessionWorkflowWakes(
370
423
  }
371
424
  });
372
425
  await Promise.all(workers);
373
- return { claimed: repairs.length, delivered, failed };
426
+ return {
427
+ claimed: repairs.length,
428
+ signaled,
429
+ delivered,
430
+ pendingAdmission,
431
+ unconfirmed,
432
+ failed,
433
+ pendingAdmissionBlockers,
434
+ };
374
435
  }
375
436
 
376
437
  function childCompletionPayload(
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MODEL_ATTACHMENT_REFS_FIELD,
3
+ MODEL_ATTACHMENT_CATALOG_MARKER,
3
4
  FileResourceRef,
4
5
  resourceMountPath,
5
6
  type FileAsset,
@@ -134,6 +135,20 @@ async function measureHistoryPreparationPhase<T>(
134
135
  }
135
136
 
136
137
  export const MAX_INLINE_MODEL_ATTACHMENT_BYTES = 20 * 1024 * 1024;
138
+ // Raw-byte allowance; base64 and request serialization add memory overhead.
139
+ // This is transport admission, never an instruction to rewrite old messages.
140
+ export const MAX_RETAINED_MODEL_ATTACHMENT_BYTES = 64 * 1024 * 1024;
141
+
142
+ export class RetainedAttachmentTransportLimitError extends Error {
143
+ constructor() {
144
+ super(
145
+ "Active uploaded images exceed the 64 MiB inline transport limit. " +
146
+ "History was preserved and no new image bytes were downloaded. " +
147
+ "Start a smaller conversation or fork before the image-heavy messages.",
148
+ );
149
+ this.name = "RetainedAttachmentTransportLimitError";
150
+ }
151
+ }
137
152
 
138
153
  export type ModelAttachmentContent = {
139
154
  kind: "image" | "file";
@@ -202,13 +217,16 @@ function modelAttachmentDescriptor(
202
217
  export async function modelAttachmentContentForFiles(
203
218
  files: FileAsset[],
204
219
  readFileBytes: (file: FileAsset) => Promise<Uint8Array>,
220
+ options: { retainAll?: boolean } = {},
205
221
  ): Promise<ModelAttachmentContent[]> {
206
222
  const selected: Array<{
207
223
  file: FileAsset;
208
224
  descriptor: Pick<ModelAttachmentContent, "kind" | "contentType">;
209
225
  checksum: string;
210
226
  }> = [];
211
- let remainingBytes = MAX_INLINE_MODEL_ATTACHMENT_BYTES;
227
+ let remainingBytes = options.retainAll
228
+ ? Number.POSITIVE_INFINITY
229
+ : MAX_INLINE_MODEL_ATTACHMENT_BYTES;
212
230
  for (const file of files) {
213
231
  const descriptor = modelAttachmentDescriptor(file.contentType);
214
232
  const checksum = file.sha256?.trim().toLowerCase() ?? "";
@@ -216,6 +234,7 @@ export async function modelAttachmentContentForFiles(
216
234
  file.status !== "ready" ||
217
235
  !descriptor ||
218
236
  descriptor.kind !== "image" ||
237
+ file.sizeBytes > MAX_INLINE_MODEL_ATTACHMENT_BYTES ||
219
238
  file.sizeBytes > remainingBytes ||
220
239
  !/^[a-f0-9]{64}$/.test(checksum)
221
240
  ) {
@@ -301,19 +320,26 @@ function attachmentReceiptText(ref: FileResourceRef, file: FileAsset | undefined
301
320
  }
302
321
 
303
322
  /**
304
- * Build one turn-scoped durable-attachment projector. Current attachment
305
- * metadata arrives already authorized; bytes are memoized for same-turn retry.
306
- * Historical projection has no database or object-storage path.
323
+ * Build one turn-scoped durable-attachment projector. Resolve active refs under
324
+ * current file authority and memoize metadata/bytes for retries and compaction.
325
+ * A turn boundary never changes an authorized attachment's representation.
307
326
  */
308
327
  export function createModelHistoryAttachmentProjector(
309
328
  policy: ModelAttachmentInputPolicy,
310
329
  readFileBytes?: (file: FileAsset) => Promise<Uint8Array>,
330
+ loadAuthorizedFiles?: (fileIds: readonly string[]) => Promise<readonly FileAsset[]>,
311
331
  ): ModelHistoryAttachmentProjector {
312
332
  const contentById = new Map<string, ModelAttachmentContent>();
313
333
  const attemptedContentIds = new Set<string>();
334
+ const fileById = new Map<string, FileAsset>();
335
+ const resolvedFileIds = new Set<string>();
336
+ const retainedByteSizes = new Map<string, number>();
314
337
 
315
338
  return async (items, options = {}) => {
316
- const currentFileById = new Map((options.inlineFiles ?? []).map((file) => [file.id, file]));
339
+ for (const file of options.inlineFiles ?? []) {
340
+ fileById.set(file.id, file);
341
+ resolvedFileIds.add(file.id);
342
+ }
317
343
  const refsByIndex = new Map<number, FileResourceRef[]>();
318
344
  const orderedFileIds: string[] = [];
319
345
  const seenFileIds = new Set<string>();
@@ -322,27 +348,76 @@ export function createModelHistoryAttachmentProjector(
322
348
  if (refs.length === 0) continue;
323
349
  refsByIndex.set(index, refs);
324
350
  for (const ref of refs) {
325
- if (seenFileIds.has(ref.fileId)) continue;
351
+ if (items[index]![MODEL_ATTACHMENT_CATALOG_MARKER] === true || seenFileIds.has(ref.fileId))
352
+ continue;
326
353
  seenFileIds.add(ref.fileId);
327
354
  orderedFileIds.push(ref.fileId);
328
355
  }
329
356
  }
330
357
  if (refsByIndex.size === 0) return items;
358
+ const unresolved = orderedFileIds.filter((id) => !resolvedFileIds.has(id));
359
+ if (loadAuthorizedFiles && unresolved.length > 0) {
360
+ const files = await loadAuthorizedFiles(unresolved);
361
+ for (const file of files) fileById.set(file.id, file);
362
+ for (const id of unresolved) resolvedFileIds.add(id);
363
+ }
331
364
 
332
365
  if (readFileBytes) {
333
- // Only the triggering message's attachments cross the provider byte
334
- // boundary. Historical messages retain compact durable receipts and can
335
- // recover bytes explicitly through the existing Files MCP + shell path.
366
+ const admitted = new Map(retainedByteSizes);
367
+ let requestImageBytes = 0;
368
+ for (const [index, refs] of refsByIndex) {
369
+ if (items[index]![MODEL_ATTACHMENT_CATALOG_MARKER] === true) continue;
370
+ for (const ref of refs) {
371
+ const file = fileById.get(ref.fileId);
372
+ if (
373
+ !file ||
374
+ !policy.supportsImageInput ||
375
+ file.status !== "ready" ||
376
+ file.sizeBytes > MAX_INLINE_MODEL_ATTACHMENT_BYTES ||
377
+ modelAttachmentDescriptor(file.contentType)?.kind !== "image" ||
378
+ !/^[a-f0-9]{64}$/i.test(file.sha256?.trim() ?? "")
379
+ )
380
+ continue;
381
+ // Repeated references share cached bytes but repeat in the wire body.
382
+ requestImageBytes += file.sizeBytes;
383
+ admitted.set(file.id, file.sizeBytes);
384
+ }
385
+ }
386
+ const turnImageBytes = [...admitted.values()].reduce((sum, bytes) => sum + bytes, 0);
387
+ if (
388
+ requestImageBytes > MAX_RETAINED_MODEL_ATTACHMENT_BYTES ||
389
+ turnImageBytes > MAX_RETAINED_MODEL_ATTACHMENT_BYTES
390
+ ) {
391
+ throw new RetainedAttachmentTransportLimitError();
392
+ }
393
+ for (const [id, bytes] of admitted) retainedByteSizes.set(id, bytes);
394
+ // Every active attachment uses the same authorized projection. Metadata
395
+ // and bytes are resolved once per turn, including compaction and retries.
336
396
  const readable = [...orderedFileIds]
337
397
  .reverse()
338
- .map((id) => currentFileById.get(id))
398
+ .map((id) => fileById.get(id))
339
399
  .filter((file): file is FileAsset => {
340
400
  if (!file || attemptedContentIds.has(file.id)) return false;
341
401
  const descriptor = modelAttachmentDescriptor(file.contentType);
342
402
  return Boolean(descriptor && descriptor.kind === "image" && policy.supportsImageInput);
343
403
  });
344
404
  for (const file of readable) attemptedContentIds.add(file.id);
345
- const content = await modelAttachmentContentForFiles(readable, readFileBytes);
405
+ const content = await modelAttachmentContentForFiles(readable, readFileBytes, {
406
+ retainAll: true,
407
+ });
408
+ const loadedIds = new Set(content.map((entry) => entry.fileId));
409
+ for (const file of readable) {
410
+ if (
411
+ file.status === "ready" &&
412
+ file.sizeBytes <= MAX_INLINE_MODEL_ATTACHMENT_BYTES &&
413
+ /^[a-f0-9]{64}$/i.test(file.sha256 ?? "") &&
414
+ !loadedIds.has(file.id)
415
+ ) {
416
+ // Do not let a retry silently proceed with pixels removed.
417
+ for (const candidate of readable) attemptedContentIds.delete(candidate.id);
418
+ throw new Error(`Retained attachment bytes unavailable or invalid: ${file.id}`);
419
+ }
420
+ }
346
421
  for (const attachment of content) contentById.set(attachment.fileId, attachment);
347
422
  }
348
423
 
@@ -353,7 +428,8 @@ export function createModelHistoryAttachmentProjector(
353
428
  ? [...original.content]
354
429
  : [{ type: "input_text", text: String(original.content ?? "") }];
355
430
  const attachmentParts = refs.flatMap((ref) => {
356
- const currentFile = currentFileById.get(ref.fileId);
431
+ const currentFile =
432
+ original[MODEL_ATTACHMENT_CATALOG_MARKER] === true ? undefined : fileById.get(ref.fileId);
357
433
  const attachment = currentFile ? contentById.get(ref.fileId) : undefined;
358
434
  const receipt = {
359
435
  type: "input_text",