@ixo/oracle-runtime-workers 0.35.1 → 0.36.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.
@@ -19,6 +19,7 @@ import {
19
19
  } from './run-buffer';
20
20
  import {
21
21
  attemptSeqBase,
22
+ decideOrphanRecovery,
22
23
  decideRecovery,
23
24
  patchRunRecord,
24
25
  runHasCheckpointed,
@@ -813,7 +814,9 @@ export class RunCoordinator {
813
814
  * boot is a new instance, so anything active that this coordinator does
814
815
  * not hold is an orphan: queued runs are re-opened (and started when
815
816
  * their session is idle); running ones get a recovery attempt scheduled
816
- * with the backoff, or are closed as interrupted after the cap.
817
+ * with the backoff, or are closed as interrupted after either cap
818
+ * (`decideOrphanRecovery`: consecutive attempts without progress, or
819
+ * recoveries in all).
817
820
  */
818
821
  async recoverOrphans(): Promise<void> {
819
822
  const active = await this.host.store.listActive();
@@ -826,7 +829,12 @@ export class RunCoordinator {
826
829
  continue;
827
830
  }
828
831
  const current = await this.host.checkpointIdOf(record.sessionId);
829
- const decision = decideRecovery(record, current, now, this.host.config);
832
+ const decision = decideOrphanRecovery(
833
+ record,
834
+ current,
835
+ now,
836
+ this.host.config,
837
+ );
830
838
  // Everything packed so far: the cursor continues after it (the row's
831
839
  // `lastSeq` is only written when a run ends) and the continuation note
832
840
  // shows the model what the user already read.
@@ -844,7 +852,9 @@ export class RunCoordinator {
844
852
  const startSeq = Math.max(lastPacked, attemptSeqBase(generation));
845
853
  if (decision.action === 'interrupt') {
846
854
  this.host.log.warn(
847
- `[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
855
+ decision.reason === 'total'
856
+ ? `[runs] ${record.runId}: recovered ${record.generation} time(s) in all (cap ${this.host.config.recoveryTotalAttempts}); closing as interrupted`
857
+ : `[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
848
858
  );
849
859
  const updatedAt = await this.host.store.update(record.runId, {
850
860
  generation,
@@ -7,7 +7,8 @@
7
7
  * closed when the run ends. It carries what a later incarnation of the
8
8
  * object needs to resume the run after a reset (the request), how many
9
9
  * recovery attempts were spent without progress, and the checkpoint id
10
- * seen at the last attempt (progress resets the counter).
10
+ * seen at the last attempt (progress resets the counter), and how many
11
+ * recoveries it got in all (`generation`, never reset).
11
12
  * - `turn_run_segments`: the run's output, packed by `RunBuffer` into one
12
13
  * row per flush, so a client can re-join after any cursor and a recovery
13
14
  * can restore exactly what the user had already seen. Deleted at cutover.
@@ -117,6 +118,7 @@ export interface RunRecord {
117
118
  * are numbered from `attemptSeqBase(g)`, so a client's cursor from an
118
119
  * earlier attempt — even one pointing at frames that were never packed
119
120
  * before the reset — is below every frame a later attempt produces.
121
+ * Also the count `decideOrphanRecovery` caps (`recoveryTotalAttempts`).
120
122
  */
121
123
  generation: number;
122
124
  /** When the next recovery attempt is due (ms since epoch), while `recovering`. */
@@ -204,6 +206,14 @@ export const DEFAULT_RECOVERY_DELAYS_MS: readonly number[] = [
204
206
  5_000, 15_000, 30_000, 60_000,
205
207
  ];
206
208
  export const DEFAULT_RECOVERY_ATTEMPTS = 4;
209
+ /**
210
+ * Recoveries a run gets in all, progress or not (`generation` is never
211
+ * reset). Each one is a restart of the object while the run was active; a
212
+ * turn that resets the isolate every time it makes a step (one checkpoint,
213
+ * then the next model call exceeds the memory limit) would otherwise be
214
+ * resumed for as long as each attempt checkpoints once.
215
+ */
216
+ export const DEFAULT_RECOVERY_TOTAL_ATTEMPTS = 8;
207
217
  export const DEFAULT_KEEPALIVE_MS = 20_000;
208
218
  export const DEFAULT_SEGMENT_FLUSH_MS = 2_000;
209
219
  export const DEFAULT_SEGMENT_BYTES = 16 * 1024;
@@ -213,6 +223,8 @@ export interface RunDurabilityConfig {
213
223
  segmentFlushMs: number;
214
224
  segmentBytes: number;
215
225
  recoveryAttempts: number;
226
+ /** Recoveries in all, progress or not (`DEFAULT_RECOVERY_TOTAL_ATTEMPTS`). */
227
+ recoveryTotalAttempts: number;
216
228
  recoveryDelaysMs: readonly number[];
217
229
  multitaskDefault: 'interrupt' | 'enqueue';
218
230
  }
@@ -244,6 +256,11 @@ export function runDurabilityConfig(
244
256
  DEFAULT_RECOVERY_ATTEMPTS,
245
257
  0,
246
258
  ),
259
+ recoveryTotalAttempts: int(
260
+ 'RUN_RECOVERY_TOTAL_ATTEMPTS',
261
+ DEFAULT_RECOVERY_TOTAL_ATTEMPTS,
262
+ 0,
263
+ ),
247
264
  recoveryDelaysMs: delays.length > 0 ? delays : DEFAULT_RECOVERY_DELAYS_MS,
248
265
  multitaskDefault: multitask === 'enqueue' ? 'enqueue' : 'interrupt',
249
266
  };
@@ -277,7 +294,16 @@ export function runHasCheckpointed(
277
294
 
278
295
  export type RecoveryDecision =
279
296
  | { action: 'schedule'; at: number; attempts: number }
280
- | { action: 'interrupt'; attempts: number };
297
+ | {
298
+ action: 'interrupt';
299
+ attempts: number;
300
+ /**
301
+ * `no-progress`: `recoveryAttempts` consecutive attempts left the
302
+ * checkpoint where it was. `total`: the run was recovered
303
+ * `recoveryTotalAttempts` times in all (`decideOrphanRecovery`).
304
+ */
305
+ reason: 'no-progress' | 'total';
306
+ };
281
307
 
282
308
  /**
283
309
  * What to do with an orphaned run found at boot (or an attempt that just
@@ -294,12 +320,36 @@ export function decideRecovery(
294
320
  const progressed = checkpointAdvanced(run.checkpointId, currentCheckpointId);
295
321
  const spent = progressed ? 0 : run.attempts;
296
322
  if (spent >= config.recoveryAttempts)
297
- return { action: 'interrupt', attempts: spent };
323
+ return { action: 'interrupt', attempts: spent, reason: 'no-progress' };
298
324
  const delays = config.recoveryDelaysMs;
299
325
  const delay = delays[Math.min(spent, delays.length - 1)] ?? 0;
300
326
  return { action: 'schedule', at: now + delay, attempts: spent + 1 };
301
327
  }
302
328
 
329
+ /**
330
+ * What to do with a run a previous incarnation left running or recovering:
331
+ * `decideRecovery`, once the run is still within its recoveries in all.
332
+ * `generation` counts every recovery the run got and is never reset, so a
333
+ * run that checkpoints once per attempt and then resets the object again
334
+ * (progress each time, so `attempts` stays at 1) is closed after
335
+ * `recoveryTotalAttempts` recoveries instead of being resumed until its
336
+ * client gives up. A deferral (`RunAttemptDeferred`) is not a restart and
337
+ * is bounded by `decideRecovery` alone.
338
+ */
339
+ export function decideOrphanRecovery(
340
+ run: Pick<RunRecord, 'attempts' | 'checkpointId' | 'generation'>,
341
+ currentCheckpointId: string | null,
342
+ now: number,
343
+ config: Pick<
344
+ RunDurabilityConfig,
345
+ 'recoveryAttempts' | 'recoveryTotalAttempts' | 'recoveryDelaysMs'
346
+ >,
347
+ ): RecoveryDecision {
348
+ if (run.generation >= config.recoveryTotalAttempts)
349
+ return { action: 'interrupt', attempts: run.attempts, reason: 'total' };
350
+ return decideRecovery(run, currentCheckpointId, now, config);
351
+ }
352
+
303
353
  type RunRow = {
304
354
  run_id: string;
305
355
  session_id: string;
@@ -16,7 +16,8 @@ import {
16
16
  import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
17
17
  import { ChannelOrigin, ChannelReplyReference } from '../channels/contract';
18
18
  import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
19
- import { planText } from '../delivery/schema';
19
+ import { planText, planTextForModel } from '../delivery/schema';
20
+ import { SEND_FILE_TOOL, sentFile } from '../plugins/sandbox/send-file';
20
21
  import type {
21
22
  ThreadMessageAnchor,
22
23
  ThreadMessageRow,
@@ -166,6 +167,7 @@ export async function transformTranscript(
166
167
  // The first listed reply that made each tool call: a tool result folds
167
168
  // into it (looked up, not searched, so long transcripts stay linear).
168
169
  const callers = new Map<string, MessageDto>();
170
+ const filesOnly = new Set<MessageDto>();
169
171
  for (const message of messages) {
170
172
  const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
171
173
  const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
@@ -248,6 +250,20 @@ export async function transformTranscript(
248
250
  contentToText(toolMsg.content),
249
251
  ),
250
252
  });
253
+ // A reply of files alone lists them by name: the link has expired.
254
+ if (
255
+ call?.name === SEND_FILE_TOOL &&
256
+ (filesOnly.has(el) || !el.content.trim())
257
+ ) {
258
+ const sent = sentFile(toolMsg.artifact);
259
+ if (sent) {
260
+ const named = planTextForModel({
261
+ parts: [{ kind: 'file', file: sent.file }],
262
+ });
263
+ el.content = el.content ? `${el.content}\n\n${named}` : named;
264
+ filesOnly.add(el);
265
+ }
266
+ }
251
267
  el.toolCalls = el.toolCalls?.map((t) =>
252
268
  t.id === toolCallId
253
269
  ? {
@@ -404,6 +404,7 @@ import { buildCreateArtifactTool } from '../artifacts/tool';
404
404
  import { resolveDeliveryProfile } from '../delivery/profile';
405
405
  import {
406
406
  draftReplyPlan,
407
+ failedRunFiles,
407
408
  materializeReplyPlan,
408
409
  turnSteps,
409
410
  } from '../delivery/plan';
@@ -6623,18 +6624,26 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6623
6624
  completed && delivery.kind === 'chat'
6624
6625
  ? await this.replyPlanOf(live, req, capture, delivery)
6625
6626
  : undefined;
6627
+ // `send_file` always needs another model step, which can fail. A
6628
+ // gateway reads a plan only off a `finished` run, so a run that failed
6629
+ // after sending files finishes with them; the row keeps the error.
6630
+ const filesPlan =
6631
+ outcome.status === 'failed' && delivery.kind === 'chat'
6632
+ ? await this.filesOfFailedRun(live, sessionId, delivery)
6633
+ : null;
6626
6634
  const text =
6627
6635
  (live.continuation ?? '') +
6628
6636
  (completed ? lastAiText(capture) : outcome.fullText);
6629
6637
  return {
6630
6638
  status:
6631
- outcome.status === 'completed'
6639
+ outcome.status === 'completed' || filesPlan
6632
6640
  ? 'finished'
6633
6641
  : outcome.status === 'aborted'
6634
6642
  ? 'aborted'
6635
6643
  : 'failed',
6636
6644
  text,
6637
6645
  ...(plan ? { plan } : {}),
6646
+ ...(filesPlan ? { plan: filesPlan } : {}),
6638
6647
  ...(messageId ? { messageId } : {}),
6639
6648
  toolCalls,
6640
6649
  ...(outcome.status === 'failed' ? { error: outcome.error } : {}),
@@ -6662,10 +6671,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6662
6671
  delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
6663
6672
  ): Promise<ReplyPlan> {
6664
6673
  const artifacts = this.artifacts?.canShare ? this.artifacts : null;
6665
- const { steps, toolResults } = turnSteps(capture);
6674
+ const { steps, toolResults, toolArtifacts } = turnSteps(capture);
6666
6675
  const draft = draftReplyPlan({
6667
6676
  steps,
6668
6677
  toolResults,
6678
+ toolArtifacts,
6669
6679
  continuation: live.continuation,
6670
6680
  limits: delivery.limits,
6671
6681
  canSpill: artifacts !== null,
@@ -6690,11 +6700,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6690
6700
  });
6691
6701
  await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
6692
6702
  console.log(
6693
- `[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts)`,
6703
+ `[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts, ${plan.parts.filter((p) => p.kind === 'file').length} files)`,
6694
6704
  );
6695
6705
  return plan;
6696
6706
  }
6697
6707
 
6708
+ /**
6709
+ * The plan of a chat run that failed after sending files, or null. It
6710
+ * reads the checkpoint, not `capture`: without the graph's own end,
6711
+ * `capture` is whatever node ended last with messages, and nothing when
6712
+ * an attempt failed early, which would lose an earlier attempt's files.
6713
+ * A read failure leaves the run failed, as it was.
6714
+ */
6715
+ private async filesOfFailedRun(
6716
+ live: LiveRun,
6717
+ sessionId: string,
6718
+ delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
6719
+ ): Promise<ReplyPlan | null> {
6720
+ try {
6721
+ const tuple = await this.saver!.getTuple({
6722
+ configurable: { thread_id: sessionId },
6723
+ });
6724
+ const channel: unknown = tuple?.checkpoint.channel_values.messages;
6725
+ const marks = await this.runStore!.listMarks(live.runId);
6726
+ const plan = await failedRunFiles(
6727
+ Array.isArray(channel) ? channel.filter(isBaseMessage) : [],
6728
+ new Set(marks.map((mark) => mark.toolCallId)),
6729
+ delivery.limits,
6730
+ );
6731
+ if (plan) {
6732
+ await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
6733
+ console.warn(
6734
+ `[delivery] run ${live.runId}: failed after sending ${plan.parts.filter((p) => p.kind === 'file').length} files; delivering them`,
6735
+ );
6736
+ }
6737
+ return plan;
6738
+ } catch (error) {
6739
+ console.warn(
6740
+ `[delivery] run ${live.runId}: could not read the files of a failed run: ${error instanceof Error ? error.name : 'unknown'}`,
6741
+ );
6742
+ return null;
6743
+ }
6744
+ }
6745
+
6698
6746
  private async runDirectRead(
6699
6747
  live: LiveRun,
6700
6748
  req: TurnRequest,
@@ -4,7 +4,8 @@
4
4
  * plain `m.text`, exactly as before chat delivery existed. With a plan it is
5
5
  * one `m.text` per part, in the thread, rendered to HTML with raw HTML from
6
6
  * the model escaped. Each part has its own transaction id, so a replayed
7
- * turn is deduplicated part by part.
7
+ * turn is deduplicated part by part. A file is a link line: rooms have no
8
+ * gateway to fetch it into an attachment, so it works until the link expires.
8
9
  */
9
10
  import { Marked } from 'marked';
10
11
  import type { TurnResult } from '../do/contracts';
@@ -60,6 +61,13 @@ export function replyPartContent(part: ReplyPart): {
60
61
  body: part.text,
61
62
  formattedBody: renderRoomMarkdown(part.text),
62
63
  };
64
+ if (part.kind === 'file') {
65
+ const { filename, url, caption } = part.file;
66
+ return {
67
+ body: [caption, filename, url].filter(Boolean).join('\n'),
68
+ formattedBody: `<p><strong>${escapeHtml(filename)}</strong>${caption ? `<br>${escapeHtml(caption)}` : ''}<br><a href="${escapeHtml(url)}">Open file</a></p>`,
69
+ };
70
+ }
63
71
  const { title, url } =
64
72
  part.kind === 'action'
65
73
  ? part.action
@@ -193,8 +193,12 @@ function pause(baseUrl: string, retryAfterMs: number | undefined): number {
193
193
 
194
194
  /* ── one POST ─────────────────────────────────────────────────────────── */
195
195
 
196
- /** A POST's own signal: no caller's abort reaches it. */
197
- const NEVER_ABORTED = new AbortController().signal;
196
+ /**
197
+ * A POST's own signal: no caller's abort reaches it. Made on first use:
198
+ * workerd refuses `new AbortController()` in global scope, so a module-level
199
+ * one stopped `wrangler dev` from loading the Worker.
200
+ */
201
+ let neverAborted: AbortSignal | undefined;
198
202
 
199
203
  /**
200
204
  * Asks the worker for `ids` (at most {@link MAX_BATCH_IDS}); the answers it
@@ -214,7 +218,7 @@ async function post(
214
218
  blocksyncUrl: '',
215
219
  user: `names|${target.baseUrl}|${target.clientId}`,
216
220
  deadline: Date.now() + POST_TIMEOUT_MS,
217
- signal: NEVER_ABORTED,
221
+ signal: (neverAborted ??= new AbortController().signal),
218
222
  fetch: fetcher,
219
223
  };
220
224
  try {
@@ -25,6 +25,7 @@ export {
25
25
  createSandboxWriteBlobTool,
26
26
  type CreateSandboxWriteBlobToolParams,
27
27
  } from './sandbox-write-blob';
28
+ export { buildSendFileTool, SEND_FILE_TOOL } from './send-file';
28
29
  export {
29
30
  SandboxPlugin,
30
31
  type SandboxMcpClientFactory,
@@ -1,6 +1,7 @@
1
1
  import { type ClientConfig } from '@langchain/mcp-adapters';
2
2
  import { withCallTimeout } from '../mcp-call-timeout';
3
3
  import { z } from 'zod';
4
+ import { deliversAttachments } from '../../delivery/prompt';
4
5
  import { OraclePlugin, type PluginEnv } from '../../plugin-api/oracle-plugin';
5
6
  import type {
6
7
  PluginManifest,
@@ -17,6 +18,13 @@ import {
17
18
  type SandboxAuthBuilder,
18
19
  } from './sandbox-mcp';
19
20
  import { createSandboxWriteBlobTool } from './sandbox-write-blob';
21
+ import {
22
+ buildSendFileTool,
23
+ CHAT_URL_NOTE,
24
+ PRESIGNED_URL_TOOL,
25
+ stripArtifactUrls,
26
+ URL_RESULT_TOOLS,
27
+ } from './send-file';
20
28
  import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
21
29
 
22
30
  /**
@@ -225,6 +233,11 @@ const SANDBOX_TOOL_DEFS_TTL_MS = 5 * 60 * 1000;
225
233
  *
226
234
  * Tools are discovered per request via {@link getRequestTools} because the
227
235
  * MCP client has to be authenticated as the in-flight user.
236
+ *
237
+ * On a chat surface the model cannot hold a signed link: the upstream
238
+ * `artifact_get_presigned_url` is replaced by `send_file`, and the URL fields
239
+ * of the other `artifact_*` lookups are removed. Portal and every other
240
+ * surface get the upstream tools unchanged.
228
241
  */
229
242
  export class SandboxPlugin extends OraclePlugin {
230
243
  /**
@@ -554,8 +567,8 @@ export class SandboxPlugin extends OraclePlugin {
554
567
  }
555
568
 
556
569
  /**
557
- * Shared tail of both paths: visibility filter, PluginTool mapping, and
558
- * the synthetic `sandbox_write_blob` companion.
570
+ * Shared tail of both paths: visibility filter, chat-surface file delivery,
571
+ * PluginTool mapping, and the synthetic `sandbox_write_blob` companion.
559
572
  */
560
573
  private toPluginTools(
561
574
  upstream: SandboxMcpTool[],
@@ -567,12 +580,32 @@ export class SandboxPlugin extends OraclePlugin {
567
580
  (t) => !t.name.startsWith(ORACLE_MANAGEMENT_TOOL_PREFIX),
568
581
  );
569
582
 
570
- const upstreamTools: PluginTool[] = filtered.map((tool) => ({
571
- name: tool.name,
572
- description: tool.description,
573
- schema: tool.schema,
574
- handler: async (args) => tool.invoke(args),
575
- }));
583
+ const surface = rtCtx.session.surface;
584
+ const chat = surface?.kind === 'chat';
585
+ const upstreamTools: PluginTool[] = filtered
586
+ .filter((tool) => !chat || tool.name !== PRESIGNED_URL_TOOL)
587
+ .map((tool) =>
588
+ chat && URL_RESULT_TOOLS.has(tool.name)
589
+ ? {
590
+ name: tool.name,
591
+ description: `${tool.description} ${CHAT_URL_NOTE}`,
592
+ schema: tool.schema,
593
+ handler: async (args) =>
594
+ stripArtifactUrls(await tool.invoke(args)),
595
+ }
596
+ : {
597
+ name: tool.name,
598
+ description: tool.description,
599
+ schema: tool.schema,
600
+ handler: async (args) => tool.invoke(args),
601
+ },
602
+ );
603
+
604
+ const presignTool = filtered.find((t) => t.name === PRESIGNED_URL_TOOL);
605
+ if (chat && presignTool)
606
+ upstreamTools.push(
607
+ buildSendFileTool(presignTool, deliversAttachments(surface.surface)),
608
+ );
576
609
 
577
610
  // sandbox_write_blob — companion to mint_invocation. Synthetic wrapper
578
611
  // that takes a server-stored blobId + a sandbox path, looks the value up