@opengeni/worker-bundle 2.0.1 → 2.0.3-canary.36199476632001

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 (63) hide show
  1. package/dist/activities/agent-turn/agent-build.d.ts +2 -0
  2. package/dist/activities/agent-turn/code-search.d.ts +60 -0
  3. package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
  4. package/dist/activities/agent-turn/errors.d.ts +12 -1
  5. package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
  6. package/dist/activities/agent-turn/governance-model.d.ts +2 -0
  7. package/dist/activities/agent-turn/history.d.ts +2 -0
  8. package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
  9. package/dist/activities/agent-turn/session-title.d.ts +32 -2
  10. package/dist/activities/agent-turn/tool-environment.d.ts +4 -0
  11. package/dist/activities/context-compaction.d.ts +10 -2
  12. package/dist/activities/knowledge-indexing.d.ts +19 -0
  13. package/dist/activities/retained-screenshots.d.ts +7 -2
  14. package/dist/activities/sandbox-lease.d.ts +3 -4
  15. package/dist/{activities-control-QA6OLRQY.js → activities-control-I72CRMAE.js} +283 -35
  16. package/dist/activities-control-I72CRMAE.js.map +1 -0
  17. package/dist/{activities-turn-YVVKCGAK.js → activities-turn-CSDKK4N3.js} +531 -77
  18. package/dist/activities-turn-CSDKK4N3.js.map +1 -0
  19. package/dist/artifact-outbox-entry.js +3 -0
  20. package/dist/artifact-outbox-entry.js.map +1 -1
  21. package/dist/{chunk-M3NVMF2V.js → chunk-7M6G2SCF.js} +172 -17
  22. package/dist/chunk-7M6G2SCF.js.map +1 -0
  23. package/dist/{chunk-I7HKKMUJ.js → chunk-DSPP6CZL.js} +48 -3
  24. package/dist/chunk-DSPP6CZL.js.map +1 -0
  25. package/dist/index.js +10 -5
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability-metrics.d.ts +11 -1
  28. package/dist/sandbox-resume.d.ts +46 -0
  29. package/dist/workflow-bundle.js +80 -2
  30. package/dist/workflows/session.d.ts +30 -0
  31. package/package.json +20 -19
  32. package/src/activities/agent-turn/agent-build.ts +4 -0
  33. package/src/activities/agent-turn/code-search.ts +275 -0
  34. package/src/activities/agent-turn/codex-capacity.ts +37 -2
  35. package/src/activities/agent-turn/compaction-prep.ts +52 -16
  36. package/src/activities/agent-turn/errors.ts +67 -4
  37. package/src/activities/agent-turn/failure-settlement.ts +40 -8
  38. package/src/activities/agent-turn/governance-model.ts +17 -1
  39. package/src/activities/agent-turn/history.ts +14 -1
  40. package/src/activities/agent-turn/media-artifacts.ts +2 -1
  41. package/src/activities/agent-turn/run.ts +4 -0
  42. package/src/activities/agent-turn/sandbox-establish.ts +12 -1
  43. package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
  44. package/src/activities/agent-turn/session-title.ts +73 -3
  45. package/src/activities/agent-turn/stream-attempt.ts +27 -15
  46. package/src/activities/agent-turn/tool-environment.ts +65 -0
  47. package/src/activities/context-compaction.ts +49 -14
  48. package/src/activities/knowledge-indexing.ts +240 -15
  49. package/src/activities/retained-screenshots.ts +82 -11
  50. package/src/activities/sandbox-lease.ts +141 -21
  51. package/src/activities/scheduled-tasks.ts +18 -2
  52. package/src/activity-services.ts +16 -4
  53. package/src/editable-artifact-hint-broker.ts +3 -0
  54. package/src/index.ts +2 -2
  55. package/src/observability-metrics.ts +70 -1
  56. package/src/personal-github-git-credentials.ts +2 -0
  57. package/src/sandbox-resume.ts +211 -5
  58. package/src/workflows/activities.ts +14 -1
  59. package/src/workflows/session.ts +102 -6
  60. package/dist/activities-control-QA6OLRQY.js.map +0 -1
  61. package/dist/activities-turn-YVVKCGAK.js.map +0 -1
  62. package/dist/chunk-I7HKKMUJ.js.map +0 -1
  63. package/dist/chunk-M3NVMF2V.js.map +0 -1
@@ -1,28 +1,138 @@
1
1
  import { configuredStaticUsageLimits } from "@opengeni/config";
2
+ import { documentEmbeddingCostMicros, paidDocumentEmbedding } from "@opengeni/core";
2
3
  import {
4
+ applyCreditDebitAfterUse,
3
5
  appendKnowledgeIndexChunks,
4
6
  claimKnowledgeIndexJobs,
5
7
  completeKnowledgeIndexJob,
6
8
  continueKnowledgeIndexJob,
7
9
  deferKnowledgeIndexJob,
10
+ freezeKnowledgeIndexBillingMode,
8
11
  getBillingBalance,
12
+ guardPaidKnowledgeIndexPublication,
13
+ knowledgeIndexBillingActivationTime,
9
14
  readKnowledgeIndexSource,
10
15
  recordUsageEvent,
11
16
  sumUsageQuantity,
12
17
  withWorkspaceUsageLock,
18
+ waitKnowledgeIndexForFunding,
13
19
  } from "@opengeni/db";
14
20
  import type { DocumentServices } from "@opengeni/documents";
15
21
  import type { ControlActivityServices } from "./types";
16
22
 
23
+ /** The configured monthly indexed-chunk limit, not a provider failure. */
24
+ export class KnowledgeIndexUsageLimitError extends Error {
25
+ constructor() {
26
+ super("monthly document indexing limit reached");
27
+ this.name = "KnowledgeIndexUsageLimitError";
28
+ }
29
+ }
30
+
31
+ export type KnowledgeIndexFailureStage = "embedding" | "processing";
32
+
33
+ /**
34
+ * Content-free classification for a deferred Knowledge index batch. Only
35
+ * protocol constants, an HTTP status, and a PostgreSQL SQLSTATE are retained;
36
+ * provider messages, bodies, SQL, and identifiers never leave the process.
37
+ * Outside the embedding call, only a PostgreSQL error in the cause chain is
38
+ * attributed to the database; any other failure stays a worker failure.
39
+ */
40
+ export function knowledgeIndexFailureDiagnostic(
41
+ stage: KnowledgeIndexFailureStage,
42
+ error: unknown,
43
+ ): {
44
+ errorClass: "KnowledgeIndexOperationError";
45
+ errorCode:
46
+ | "knowledge_index_usage_limit_reached"
47
+ | "knowledge_index_embedding_failed"
48
+ | "knowledge_index_persistence_failed"
49
+ | "knowledge_index_failed";
50
+ origin: "worker" | "db";
51
+ status?: number;
52
+ sqlState?: string;
53
+ } {
54
+ if (error instanceof KnowledgeIndexUsageLimitError) {
55
+ return {
56
+ errorClass: "KnowledgeIndexOperationError",
57
+ errorCode: "knowledge_index_usage_limit_reached",
58
+ origin: "worker",
59
+ };
60
+ }
61
+ if (stage === "embedding") {
62
+ const status = ownValue(error, "status");
63
+ return {
64
+ errorClass: "KnowledgeIndexOperationError",
65
+ errorCode: "knowledge_index_embedding_failed",
66
+ origin: "worker",
67
+ ...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599
68
+ ? { status }
69
+ : {}),
70
+ };
71
+ }
72
+ const postgres = postgresErrorState(error);
73
+ if (!postgres) {
74
+ return {
75
+ errorClass: "KnowledgeIndexOperationError",
76
+ errorCode: "knowledge_index_failed",
77
+ origin: "worker",
78
+ };
79
+ }
80
+ return {
81
+ errorClass: "KnowledgeIndexOperationError",
82
+ errorCode: "knowledge_index_persistence_failed",
83
+ origin: "db",
84
+ ...(postgres.sqlState ? { sqlState: postgres.sqlState } : {}),
85
+ };
86
+ }
87
+
88
+ function ownValue(value: unknown, key: string): unknown {
89
+ try {
90
+ if (!value || typeof value !== "object") return undefined;
91
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
92
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /** The nearest PostgreSQL error in a short cause chain, with its SQLSTATE
99
+ * when that is a well-formed five-character code. */
100
+ function postgresErrorState(error: unknown): { sqlState?: string } | undefined {
101
+ let current = error;
102
+ for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
103
+ if (ownValue(current, "name") === "PostgresError") {
104
+ const code = ownValue(current, "code");
105
+ return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code) ? { sqlState: code } : {};
106
+ }
107
+ current = ownValue(current, "cause");
108
+ }
109
+ return undefined;
110
+ }
111
+
17
112
  export function createKnowledgeIndexingActivities(
18
113
  services: () => Promise<ControlActivityServices>,
19
114
  resolveDocumentServices?: () => Promise<DocumentServices>,
20
115
  ) {
116
+ // Unpaid modes retain a conservative first-poll cutoff; paid mode uses the
117
+ // operator's explicit timestamp so process restarts cannot change eligibility.
118
+ let activationTime: Promise<Date> | undefined;
21
119
  return {
22
120
  indexKnowledge: async () => {
23
121
  const result = { completed: 0, advanced: 0, deferred: 0, unavailable: 0 };
24
122
  if (!resolveDocumentServices) return result;
25
123
  const { db, settings, observability } = await services();
124
+ let policyActivatedAt: Date;
125
+ if (paidDocumentEmbedding(settings)) {
126
+ if (!settings.documentEmbeddingCreditsActivatedAt)
127
+ throw new Error("paid Knowledge embedding requires an activation cutoff");
128
+ policyActivatedAt = new Date(settings.documentEmbeddingCreditsActivatedAt);
129
+ } else {
130
+ activationTime ??= knowledgeIndexBillingActivationTime(db).catch((error) => {
131
+ activationTime = undefined;
132
+ throw error;
133
+ });
134
+ policyActivatedAt = await activationTime;
135
+ }
26
136
  const { embedder } = await resolveDocumentServices();
27
137
  const { knowledgeIndexChunks } = await import("@opengeni/documents");
28
138
  const claims = await claimKnowledgeIndexJobs(db, {
@@ -31,15 +141,16 @@ export function createKnowledgeIndexingActivities(
31
141
  limit: 2,
32
142
  });
33
143
  for (const claim of claims) {
144
+ let stage: KnowledgeIndexFailureStage = "processing";
34
145
  try {
35
146
  const source = await readKnowledgeIndexSource(db, claim);
36
147
  if (!source) {
37
148
  result.unavailable++;
38
149
  continue;
39
150
  }
40
- // Reuse the existing embedding budget and usage ledger. Each batch's
41
- // checkpoint and charge commit together, so a restart never charges
42
- // twice for an accepted projection. Canonical text is never changed.
151
+ // The checkpoint, usage and post-use debit commit together. A new
152
+ // generation requires funding once; committed batches may finish even
153
+ // if their accumulated cost takes the balance below zero.
43
154
  await withWorkspaceUsageLock(db, source.billingWorkspaceId, async (lockedDb) => {
44
155
  const current = await readKnowledgeIndexSource(lockedDb, claim);
45
156
  if (!current) {
@@ -57,9 +168,43 @@ export function createKnowledgeIndexingActivities(
57
168
  chunks.push(chunk);
58
169
  }
59
170
  if (chunks.length) {
60
- if (settings.billingMode === "stripe" || settings.usageLimitsMode === "managed") {
171
+ const frozenPolicy = await freezeKnowledgeIndexBillingMode(
172
+ lockedDb,
173
+ claim,
174
+ // Deterministic embeddings incur no provider charge; a valid
175
+ // credits-mode config with zero tariff must still index them.
176
+ // OpenAI shadow keeps its price snapshot for internal estimates.
177
+ settings.documentEmbeddingProvider === "openai"
178
+ ? (settings.documentEmbeddingBillingMode ?? "usage_only")
179
+ : "usage_only",
180
+ policyActivatedAt,
181
+ settings.documentEmbeddingRateMicrosPerMillionBytes ?? 0,
182
+ );
183
+ if (frozenPolicy.mode === "awaiting_review") {
184
+ result.deferred++;
185
+ return;
186
+ }
187
+ if (frozenPolicy.mode === "obsolete") {
188
+ result.unavailable++;
189
+ return;
190
+ }
191
+ // An already-priced generation cannot become free when the
192
+ // operator turns off paid embedding. Retain its checkpoint and
193
+ // retry only after paid mode is restored; never call the provider
194
+ // or append a batch that the live policy would not debit.
195
+ if (frozenPolicy.mode === "credits" && !paidDocumentEmbedding(settings)) {
196
+ await deferKnowledgeIndexJob(lockedDb, claim);
197
+ result.deferred++;
198
+ return;
199
+ }
200
+ const paid = frozenPolicy.mode === "credits";
201
+ if (paid && current.nextIndex === 0) {
61
202
  const balance = await getBillingBalance(lockedDb, claim.accountId);
62
- if (balance.balanceMicros <= 0) throw new Error("insufficient OpenGeni credits");
203
+ if (balance.balanceMicros <= 0) {
204
+ await waitKnowledgeIndexForFunding(lockedDb, claim);
205
+ result.deferred++;
206
+ return;
207
+ }
63
208
  }
64
209
  if (settings.usageLimitsMode === "static" || settings.usageLimitsMode === "managed") {
65
210
  const limit =
@@ -71,13 +216,29 @@ export function createKnowledgeIndexingActivities(
71
216
  eventType: "document.indexed",
72
217
  since: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
73
218
  });
74
- if (used + chunks.length > limit)
75
- throw new Error("monthly document indexing limit reached");
219
+ if (used + chunks.length > limit) throw new KnowledgeIndexUsageLimitError();
76
220
  }
77
221
  }
78
- const vectors = await embedder.embedMany(chunks.map((chunk) => chunk.embeddingInput));
222
+ const inputs = chunks.map((chunk) => chunk.embeddingInput);
223
+ const bytes = inputs.reduce(
224
+ (sum, input) => sum + Buffer.byteLength(input, "utf8"),
225
+ 0,
226
+ );
227
+ stage = "embedding";
228
+ const vectors = await embedder.embedMany(inputs);
79
229
  if (vectors.length !== chunks.length)
80
230
  throw new Error("Incomplete Knowledge embeddings");
231
+ stage = "processing";
232
+ // A reviewer may have rejected this revision during the provider
233
+ // call. The DB guard holds its publication row through settlement.
234
+ if (paid) {
235
+ const publication = await guardPaidKnowledgeIndexPublication(lockedDb, claim);
236
+ if (publication !== "published") {
237
+ if (publication === "obsolete") result.unavailable++;
238
+ else result.deferred++;
239
+ return;
240
+ }
241
+ }
81
242
  const appended = await appendKnowledgeIndexChunks(
82
243
  lockedDb,
83
244
  claim,
@@ -98,6 +259,63 @@ export function createKnowledgeIndexingActivities(
98
259
  sourceResourceId: claim.revisionId,
99
260
  idempotencyKey: `knowledge.indexed:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
100
261
  });
262
+ await recordUsageEvent(lockedDb, {
263
+ accountId: claim.accountId,
264
+ workspaceId: current.billingWorkspaceId,
265
+ eventType: "document.embedding_bytes",
266
+ quantity: bytes,
267
+ unit: "byte",
268
+ sourceResourceType: "knowledge_revision",
269
+ sourceResourceId: claim.revisionId,
270
+ idempotencyKey: `knowledge.embedding_bytes:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
271
+ });
272
+ if (frozenPolicy.mode === "shadow" && frozenPolicy.rateMicrosPerMillionBytes > 0) {
273
+ const estimate = documentEmbeddingCostMicros(
274
+ {
275
+ ...settings,
276
+ documentEmbeddingRateMicrosPerMillionBytes:
277
+ frozenPolicy.rateMicrosPerMillionBytes,
278
+ },
279
+ bytes,
280
+ );
281
+ if (estimate > 0)
282
+ await recordUsageEvent(lockedDb, {
283
+ accountId: claim.accountId,
284
+ workspaceId: current.billingWorkspaceId,
285
+ eventType: "document.embedding_shadow_estimate",
286
+ quantity: estimate,
287
+ unit: "micro_usd",
288
+ sourceResourceType: "knowledge_revision",
289
+ sourceResourceId: claim.revisionId,
290
+ idempotencyKey: `knowledge.embedding_shadow:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
291
+ });
292
+ }
293
+ if (paid) {
294
+ const cost = documentEmbeddingCostMicros(
295
+ {
296
+ ...settings,
297
+ documentEmbeddingRateMicrosPerMillionBytes:
298
+ frozenPolicy.rateMicrosPerMillionBytes,
299
+ },
300
+ bytes,
301
+ );
302
+ if (cost > 0)
303
+ await applyCreditDebitAfterUse(lockedDb, {
304
+ accountId: claim.accountId,
305
+ workspaceId: current.billingWorkspaceId,
306
+ type: "document_embedding_debit",
307
+ amountMicros: cost,
308
+ sourceType: "knowledge_revision",
309
+ sourceId: claim.revisionId,
310
+ idempotencyKey: `knowledge.embedding:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
311
+ metadata: {
312
+ model: claim.model,
313
+ bytes,
314
+ chunks: chunks.length,
315
+ rateMicrosPerMillionBytes: frozenPolicy.rateMicrosPerMillionBytes,
316
+ },
317
+ });
318
+ }
101
319
  }
102
320
  if (more) {
103
321
  await continueKnowledgeIndexJob(lockedDb, claim);
@@ -112,16 +330,23 @@ export function createKnowledgeIndexingActivities(
112
330
  else result.unavailable++;
113
331
  }
114
332
  });
115
- } catch {
333
+ } catch (error) {
116
334
  // Provider failures retain the last completed projection. The durable
117
335
  // queue owns retry/backoff; do not retry an entire activity implicitly.
118
- await deferKnowledgeIndexJob(db, claim).catch(() => undefined);
119
- result.deferred++;
120
- observability.warn("Knowledge indexing batch deferred", {
121
- accountId: claim.accountId,
122
- entryId: claim.entryId,
123
- revisionId: claim.revisionId,
336
+ // The stored reason stays the SQL lifecycle's fixed code; the log
337
+ // carries the content-free class/code of the actual cause.
338
+ observability.warn(
339
+ "Knowledge indexing batch deferred",
340
+ knowledgeIndexFailureDiagnostic(stage, error),
341
+ );
342
+ await deferKnowledgeIndexJob(db, claim).catch((deferError: unknown) => {
343
+ const deferDiagnostic = knowledgeIndexFailureDiagnostic("processing", deferError);
344
+ observability.warn("Knowledge indexing batch deferral failed", {
345
+ ...deferDiagnostic,
346
+ errorCode: "knowledge_index_defer_failed",
347
+ });
124
348
  });
349
+ result.deferred++;
125
350
  }
126
351
  }
127
352
  return result;
@@ -6,13 +6,16 @@ import {
6
6
  COMPUTER_SCREENSHOT_WORKSPACE_QUOTA_BYTES,
7
7
  RetainedArtifactMetadataSchema,
8
8
  retainedScreenshotReferenceFromFile,
9
+ retainedSessionScreenshotKindFromObjectKey,
9
10
  type RetainedArtifactMetadata,
10
11
  type RetainedArtifactReference,
11
12
  type RetainedOutputUnavailableReason,
13
+ type RetainedSessionScreenshotKind,
12
14
  } from "@opengeni/contracts";
13
15
  import {
14
16
  RetainedScreenshotQuotaExceededError,
15
17
  getRetainedScreenshotArtifact,
18
+ getRetainedScreenshotArtifactForToolCall,
16
19
  isDatabasePersistenceFailure,
17
20
  isSessionEventPersistenceError,
18
21
  prepareRetainedScreenshotArtifact,
@@ -209,14 +212,26 @@ export function toolOutputContainsInlineImage(output: unknown): boolean {
209
212
  return (
210
213
  (record.type === "input_image" &&
211
214
  sdkImageSourceContainsInlineImage(record.image ?? record.image_url ?? record.imageUrl)) ||
212
- (record.type === "image" && decodeInlineMcpImage(record.data, record.mimeType) !== null)
215
+ (record.type === "image" && isDeclaredInlineMcpImage(record))
213
216
  );
214
217
  });
215
218
  }
216
219
  if (!output || typeof output !== "object") return false;
217
220
  const record = output as Record<string, unknown>;
218
221
  if (Array.isArray(record.content)) return toolOutputContainsInlineImage(record.content);
219
- return record.type === "image" && sdkImageSourceContainsInlineImage(record.image);
222
+ return (
223
+ record.type === "image" &&
224
+ (sdkImageSourceContainsInlineImage(record.image) || isDeclaredInlineMcpImage(record))
225
+ );
226
+ }
227
+
228
+ /** Detect image-bearing MCP blocks even when decoding rejects oversized or malformed bytes. */
229
+ function isDeclaredInlineMcpImage(record: Record<string, unknown>): boolean {
230
+ return (
231
+ typeof record.data === "string" &&
232
+ typeof record.mimeType === "string" &&
233
+ record.mimeType.toLowerCase().startsWith("image/")
234
+ );
220
235
  }
221
236
 
222
237
  function decodeInlineMcpImage(
@@ -520,9 +535,14 @@ export function retainedScreenshotIdentity(input: {
520
535
  attemptId: string;
521
536
  toolCallId: string;
522
537
  toolOutputId: string;
538
+ kind?: RetainedSessionScreenshotKind | undefined;
523
539
  }): { artifactId: string; settlementKey: string } {
524
540
  const settlementKey = createHash("sha256")
525
- .update("opengeni:computer-screenshot:v1\0")
541
+ .update(
542
+ input.kind === "browser_screenshot"
543
+ ? "opengeni:browser-screenshot:v1\0"
544
+ : "opengeni:computer-screenshot:v1\0",
545
+ )
526
546
  .update(input.sessionId)
527
547
  .update("\0")
528
548
  .update(input.turnId)
@@ -551,12 +571,13 @@ export function unavailableRetainedSessionImage(input: {
551
571
  toolCallId: string;
552
572
  toolOutputId: string;
553
573
  reason: RetainedOutputUnavailableReason;
574
+ kind?: RetainedSessionScreenshotKind | undefined;
554
575
  }): RetainedArtifactMetadata {
555
576
  const identity = retainedScreenshotIdentity(input);
556
577
  return unavailable(identity.artifactId, input.reason);
557
578
  }
558
579
 
559
- export async function retainComputerScreenshot(input: {
580
+ export async function retainSessionScreenshot(input: {
560
581
  db: Database;
561
582
  objectStorage: ObjectStorage | null;
562
583
  accountId: string;
@@ -565,6 +586,7 @@ export async function retainComputerScreenshot(input: {
565
586
  turnId: string;
566
587
  attemptId: string;
567
588
  output: TypedScreenshotToolOutput;
589
+ kind?: RetainedSessionScreenshotKind | undefined;
568
590
  now?: Date;
569
591
  retentionMs?: number;
570
592
  workspaceQuotaBytes?: number;
@@ -575,6 +597,7 @@ export async function retainComputerScreenshot(input: {
575
597
  attemptId: input.attemptId,
576
598
  toolCallId: input.output.callId,
577
599
  toolOutputId: input.output.toolOutputId,
600
+ kind: input.kind,
578
601
  });
579
602
  let screenshot: ValidatedSessionImage;
580
603
  try {
@@ -594,7 +617,8 @@ export async function retainComputerScreenshot(input: {
594
617
  const retentionExpiresAt = new Date(
595
618
  now.getTime() + (input.retentionMs ?? COMPUTER_SCREENSHOT_RETENTION_MS),
596
619
  );
597
- const objectKey = `workspaces/${input.workspaceId}/files/${identity.artifactId}/retained/session-image.${screenshot.extension}`;
620
+ const objectName = input.kind === "browser_screenshot" ? "browser-screenshot" : "session-image";
621
+ const objectKey = `workspaces/${input.workspaceId}/files/${identity.artifactId}/retained/${objectName}.${screenshot.extension}`;
598
622
  let prepared: RetainedScreenshotArtifact;
599
623
  try {
600
624
  prepared = (
@@ -697,6 +721,9 @@ export async function retainComputerScreenshot(input: {
697
721
  }
698
722
  }
699
723
 
724
+ /** Existing computer callers keep their stable entry point. */
725
+ export const retainComputerScreenshot = retainSessionScreenshot;
726
+
700
727
  /** Replace only new screenshot bytes with a compact receipt before first persistence. */
701
728
  export function compactRetainedScreenshotHistory(
702
729
  history: Array<Record<string, unknown>>,
@@ -874,6 +901,26 @@ async function materializeRetainedScreenshotHistoryWithCache(
874
901
  cache: Map<string, string>,
875
902
  ): Promise<Array<Record<string, unknown>>> {
876
903
  const now = input.now ?? new Date();
904
+ const receiptForMarker = async (
905
+ marker: unknown,
906
+ callId: string | null,
907
+ ): Promise<RetainedArtifactMetadata | null> => {
908
+ if (!isRetainedImageMarker(marker)) return null;
909
+ const receipt = retainedReceipt(marker.artifact);
910
+ if (receipt) return receipt;
911
+ // Older canonical history could truncate the artifact UUID and reason as
912
+ // ordinary text. Recover only from this exact session and an unambiguous
913
+ // tool call; otherwise fail before constructing a malformed model image.
914
+ if (!callId) throw new Error("Retained screenshot receipt has no tool call identity");
915
+ const artifact = await getRetainedScreenshotArtifactForToolCall(
916
+ input.db,
917
+ input.workspaceId,
918
+ input.sessionId,
919
+ callId,
920
+ );
921
+ if (!artifact) throw new Error("Retained screenshot receipt cannot be recovered");
922
+ return reference(artifact);
923
+ };
877
924
  const dataUrlForReceipt = async (receipt: RetainedArtifactMetadata): Promise<string> => {
878
925
  let dataUrl = cache.get(receipt.artifactId);
879
926
  if (!dataUrl) {
@@ -921,8 +968,12 @@ async function materializeRetainedScreenshotHistoryWithCache(
921
968
  }
922
969
  return dataUrl;
923
970
  };
924
- const materializeEntry = async (entry: unknown): Promise<unknown> => {
925
- const receipt = retainedReceiptFromImageContent(entry);
971
+ const materializeEntry = async (entry: unknown, callId: string | null): Promise<unknown> => {
972
+ const image =
973
+ entry && typeof entry === "object" && !Array.isArray(entry)
974
+ ? (entry as Record<string, unknown>).image
975
+ : null;
976
+ const receipt = await receiptForMarker(image, callId);
926
977
  if (!receipt) return entry;
927
978
  const dataUrl = await dataUrlForReceipt(receipt);
928
979
  return { ...(entry as Record<string, unknown>), image: dataUrl };
@@ -930,7 +981,8 @@ async function materializeRetainedScreenshotHistoryWithCache(
930
981
 
931
982
  const materialized: Array<Record<string, unknown>> = [];
932
983
  for (const item of input.history) {
933
- const directReceipt = retainedReceiptFromDirectOutput(item.output);
984
+ const callId = historyCallId(item);
985
+ const directReceipt = await receiptForMarker(item.output, callId);
934
986
  if (directReceipt) {
935
987
  materialized.push({
936
988
  ...item,
@@ -954,7 +1006,7 @@ async function materializeRetainedScreenshotHistoryWithCache(
954
1006
  let changed = false;
955
1007
  const output: unknown[] = [];
956
1008
  for (const entry of outputEntries) {
957
- const next = await materializeEntry(entry);
1009
+ const next = await materializeEntry(entry, callId);
958
1010
  changed ||= next !== entry;
959
1011
  output.push(next);
960
1012
  }
@@ -1059,12 +1111,15 @@ function reference(artifact: RetainedScreenshotArtifact): RetainedArtifactRefere
1059
1111
  if (!artifact.sessionId) {
1060
1112
  throw new Error(`Ready retained screenshot is detached: ${artifact.artifactId}`);
1061
1113
  }
1114
+ const kind = retainedSessionScreenshotKindFromObjectKey(artifact.file.objectKey);
1115
+ if (!kind) throw new Error(`Ready retained screenshot has unknown key: ${artifact.artifactId}`);
1062
1116
  const value = retainedScreenshotReferenceFromFile({
1063
1117
  ...artifact.file,
1064
1118
  sessionId: artifact.sessionId,
1065
1119
  width: artifact.width,
1066
1120
  height: artifact.height,
1067
1121
  expiresAt: artifact.retentionExpiresAt.toISOString(),
1122
+ kind,
1068
1123
  });
1069
1124
  if (!value)
1070
1125
  throw new Error(`Ready retained screenshot receipt is invalid: ${artifact.artifactId}`);
@@ -1110,6 +1165,18 @@ function retainedReceiptFromImageContent(entry: unknown): RetainedArtifactMetada
1110
1165
  return retainedReceipt(marker.artifact);
1111
1166
  }
1112
1167
 
1168
+ function isRetainedImageMarker(value: unknown): value is {
1169
+ type: "retained_artifact";
1170
+ artifact: unknown;
1171
+ } {
1172
+ return Boolean(
1173
+ value &&
1174
+ typeof value === "object" &&
1175
+ !Array.isArray(value) &&
1176
+ (value as Record<string, unknown>).type === RETAINED_IMAGE_MARKER,
1177
+ );
1178
+ }
1179
+
1113
1180
  function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetadata | null {
1114
1181
  if (!output || typeof output !== "object" || Array.isArray(output)) return null;
1115
1182
  const marker = output as Record<string, unknown>;
@@ -1120,7 +1187,11 @@ function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetad
1120
1187
  function retainedReceipt(value: unknown): RetainedArtifactMetadata | null {
1121
1188
  const parsed = RetainedArtifactMetadataSchema.safeParse(value);
1122
1189
  if (!parsed.success) return null;
1123
- return parsed.data.available && parsed.data.kind !== "computer_screenshot" ? null : parsed.data;
1190
+ return parsed.data.available &&
1191
+ parsed.data.kind !== "computer_screenshot" &&
1192
+ parsed.data.kind !== "browser_screenshot"
1193
+ ? null
1194
+ : parsed.data;
1124
1195
  }
1125
1196
 
1126
1197
  function isInlineImageContent(entry: unknown): boolean {
@@ -1130,7 +1201,7 @@ function isInlineImageContent(entry: unknown): boolean {
1130
1201
  (record.type === "input_image" &&
1131
1202
  typeof record.image === "string" &&
1132
1203
  record.image.startsWith("data:image/")) ||
1133
- (record.type === "image" && decodeInlineMcpImage(record.data, record.mimeType) !== null)
1204
+ (record.type === "image" && isDeclaredInlineMcpImage(record))
1134
1205
  );
1135
1206
  }
1136
1207