@ixo/oracle-runtime-workers 0.11.0 → 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -46,6 +46,7 @@ import {
46
46
  composePrompt,
47
47
  formatTimeContext,
48
48
  formatUserPreferences,
49
+ PORTAL_CAPABILITY,
49
50
  } from './prompt-composer';
50
51
  import { formatByPlugin, type ManifestRegistry } from './registries';
51
52
  import {
@@ -54,7 +55,7 @@ import {
54
55
  type RunConfig,
55
56
  type RuntimeStateInput,
56
57
  } from './runtime-context';
57
- import { MainAgentGraphState } from './state';
58
+ import { MainAgentGraphState, type BrowserToolCall } from './state';
58
59
  import { toolEffectOf } from './middlewares/tool-marks';
59
60
  import { collectSubAgentsWithFallback } from './sub-agent-fallback';
60
61
  import { computeSubAgentToolName } from './subagent-as-tool';
@@ -97,6 +98,21 @@ function selectByVisibility(
97
98
  });
98
99
  }
99
100
 
101
+ /**
102
+ * Names of the browser tools the client declared on this request. The array
103
+ * comes straight off the wire, so an entry without a usable name is skipped
104
+ * rather than rendered as "undefined".
105
+ */
106
+ function browserToolNames(
107
+ browserTools: BrowserToolCall[] | undefined,
108
+ ): string[] {
109
+ return (browserTools ?? []).flatMap((entry) =>
110
+ typeof entry?.name === 'string' && entry.name.length > 0
111
+ ? [entry.name]
112
+ : [],
113
+ );
114
+ }
115
+
100
116
  /**
101
117
  * Build a compiled main agent from the runtime's registries plus per-request
102
118
  * context. Same pipeline as the Node runtime — tools from the registries
@@ -472,9 +488,20 @@ export async function createMainAgent(
472
488
 
473
489
  const customInstructions = identity.prompt?.customInstructions?.trim() ?? '';
474
490
 
491
+ // Browser tools never surface through `search_skills` / `list_capabilities`,
492
+ // so the prompt names them per turn and says whether the capability gate
493
+ // still hides them (portal is on-demand by default).
494
+ const browserTools = {
495
+ names: browserToolNames(state.browserTools),
496
+ bound:
497
+ manifestViz.get(PORTAL_CAPABILITY) === 'always' ||
498
+ loadedSet.has(PORTAL_CAPABILITY),
499
+ };
500
+
475
501
  const systemPrompt = await composePrompt({
476
502
  identity,
477
503
  capabilityBlock: tier1.block,
504
+ browserTools,
478
505
  customInstructions,
479
506
  operationalMode: hooks?.operationalMode ?? DEFAULT_OPERATIONAL_MODE,
480
507
  userPreferencesContext: formatUserPreferences(state.userPreferences),
@@ -76,6 +76,13 @@ export interface ComposePromptInput {
76
76
  * mandate a discovery flow the model cannot run.
77
77
  */
78
78
  capabilityDiscovery?: boolean;
79
+ /**
80
+ * Browser tools the client declared for this turn (`state.browserTools`),
81
+ * by name, plus whether they are already callable: `bound` is true when the
82
+ * portal plugin is `always` visible or has been loaded on this thread.
83
+ * Omit (or pass empty `names`) on non-Portal turns — nothing renders.
84
+ */
85
+ browserTools?: { names: string[]; bound: boolean };
79
86
  /** User preferences rendered as bullets. Empty when not set. */
80
87
  userPreferencesContext: string;
81
88
  /** Memory-engine context blocks. */
@@ -331,13 +338,40 @@ export function buildOracleSection(input: {
331
338
  return `You are ${oracleName}, an AI agent built on QiForge.`;
332
339
  }
333
340
 
341
+ /** The plugin that binds the client-declared browser tools. */
342
+ export const PORTAL_CAPABILITY = 'portal';
343
+
344
+ /**
345
+ * Render the per-turn "Browser tools this turn" section. Empty string when the
346
+ * client declared no browser tools, so non-Portal turns pay nothing for it.
347
+ * The load line appears only while the tools sit behind the capability gate
348
+ * (portal is `on-demand` and not yet in `loadedPlugins`).
349
+ */
350
+ function buildBrowserToolsBlock(
351
+ browserTools: ComposePromptInput['browserTools'],
352
+ ): string {
353
+ if (!browserTools || browserTools.names.length === 0) return '';
354
+ const lines = [
355
+ '## Browser tools this turn',
356
+ '',
357
+ `The Portal exposed these browser-side tools for this turn (they act on the user's screen): ${browserTools.names.join(', ')}`,
358
+ ];
359
+ if (!browserTools.bound) {
360
+ lines.push(
361
+ `They are bound behind the \`${PORTAL_CAPABILITY}\` capability — call \`load_capability({ names: ['${PORTAL_CAPABILITY}'] })\` once before using them.`,
362
+ );
363
+ }
364
+ return lines.join('\n');
365
+ }
366
+
334
367
  /**
335
368
  * The discovery mandate. Rendered on every turn whose surface can actually
336
369
  * grow — i.e. wherever the meta-tools are bound.
337
370
  */
338
- const DISCOVERY_PRINCIPLE = `- **Search first, build second.** Before doing any non-trivial task, you MUST run discovery before producing the answer yourself. That means:
339
- 1. Call \`search_skills\` against the user's request whenever a packaged skill could plausibly do the job (anything that involves generating a file, document, report, page, integration, calculation, lookup, or multi-step workflow).
340
- 2. Call \`list_capabilities\` to scan loaded + on-demand plugins when the task could be served by a plugin you haven't loaded yet. Then call \`load_capability({ names: [...] })\` with ALL the capabilities you need in a single call — it accepts an array, so never make multiple \`load_capability\` calls in the same turn.
371
+ const DISCOVERY_PRINCIPLE = `- **Search first, build second.** Before doing any non-trivial task, you MUST run discovery before producing the answer yourself. Capabilities live in three lanes, each found differently:
372
+ 1. **Packaged skills** — call \`search_skills\` against the user's request whenever a skill could plausibly do the job (generating a file, document, report, or page; calculations; lookups; multi-step workflows).
373
+ 2. **Server-side plugins and third-party integrations** (memory, sandbox, email, calendar, web, files, external services) — call \`list_capabilities\` to scan loaded + on-demand plugins, then call \`load_capability({ names: [...] })\` ONCE with everything you need — it accepts an array, so never make multiple \`load_capability\` calls in the same turn.
374
+ 3. **Browser tools** — the \`[Portal]\`-prefixed tools already in your tool list. They run on the user's screen inside the Portal: navigate, open a URL or page, create pages/rooms, fill forms, grant access, change what the user sees. Use them for anything that happens on the user's side. They NEVER appear in \`search_skills\` or \`list_capabilities\` results — check your tool list (and the "Browser tools this turn" section when present) directly.
341
375
  Do this proactively — even when the task wording doesn't literally match a capability's \`whenToUse\`. Tell the user in ONE short sentence what you're checking ("Checking the skills registry for an invoice generator…") before the search call, not after. If something fits, use it (load + run). If nothing fits after the search, say so in one short sentence and only THEN build from scratch. Reusing a vetted capability is almost always better than reinventing it; silently skipping discovery is the worst failure mode.`;
342
376
 
343
377
  /**
@@ -357,6 +391,10 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
357
391
  {{{CAPABILITY_BLOCK}}}
358
392
 
359
393
  {{/CAPABILITY_BLOCK}}
394
+ {{#BROWSER_TOOLS_BLOCK}}
395
+ {{{BROWSER_TOOLS_BLOCK}}}
396
+
397
+ {{/BROWSER_TOOLS_BLOCK}}
360
398
  ## Operating principles
361
399
 
362
400
  - The user's current message is your primary instruction. Background context (what you already know about them) is for adapting tone and suggestions, not for overriding their intent.
@@ -436,6 +474,7 @@ export async function composePrompt(
436
474
  ORACLE_SECTION: oracleSection,
437
475
  CAPABILITIES_NOTE: capabilitiesNote,
438
476
  CAPABILITY_BLOCK: input.capabilityBlock,
477
+ BROWSER_TOOLS_BLOCK: buildBrowserToolsBlock(input.browserTools),
439
478
  DISCOVERY_PRINCIPLE:
440
479
  input.capabilityDiscovery === false ? '' : DISCOVERY_PRINCIPLE,
441
480
  CUSTOM_INSTRUCTIONS: input.customInstructions,
@@ -439,6 +439,8 @@ export interface StorageStatus {
439
439
  pages: number;
440
440
  ownerEtag?: string;
441
441
  lastFlushAt?: number;
442
+ /** When the dirty copy is due to be uploaded (absent when clean). */
443
+ nextFlushAt?: number;
442
444
  dirty: boolean;
443
445
  flushInFlight: boolean;
444
446
  /** VFS write generation now vs. at the last successful upload. */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether a housekeeping wake should upload the working copy to the owner
3
+ * store. The object's single alarm serves several clients (realtime
4
+ * heartbeats, the durable-run keep-alive, task runs, compaction, the R2
5
+ * tier, idle eviction), so a wake says nothing about WHY it happened. The
6
+ * flush has its own persisted deadline (`meta:flushAt`, armed by the first
7
+ * write after an upload and replaced by the retry delay after a failure)
8
+ * and runs only when that deadline is due — every other wake leaves a
9
+ * dirty copy alone. Two exceptions upload right away: a copy about to be
10
+ * evicted (the wipe must never drop unsaved turns) and a copy marked dirty
11
+ * before the deadline existed (older objects; one upload, then the
12
+ * debounce applies).
13
+ */
14
+ export interface FlushScheduleInput {
15
+ /** The object holds writes the owner store has not seen (and a database is open). */
16
+ dirty: boolean;
17
+ /** The persisted flush deadline (undefined = none recorded). */
18
+ flushAt: unknown;
19
+ now: number;
20
+ /** The idle-eviction path is about to wipe the working copy. */
21
+ idle: boolean;
22
+ }
23
+
24
+ export type FlushScheduleDecision =
25
+ | { action: 'skip'; reason: 'clean' }
26
+ | { action: 'flush'; reason: 'due' | 'idle' | 'no-deadline' }
27
+ | { action: 'wait'; at: number };
28
+
29
+ /** Alarms fire a hair early; a deadline this close counts as due. */
30
+ export const FLUSH_DUE_SLACK_MS = 1000;
31
+
32
+ export function decideFlush(input: FlushScheduleInput): FlushScheduleDecision {
33
+ if (!input.dirty) return { action: 'skip', reason: 'clean' };
34
+ if (input.idle) return { action: 'flush', reason: 'idle' };
35
+ if (typeof input.flushAt !== 'number' || !Number.isFinite(input.flushAt))
36
+ return { action: 'flush', reason: 'no-deadline' };
37
+ if (input.flushAt <= input.now + FLUSH_DUE_SLACK_MS)
38
+ return { action: 'flush', reason: 'due' };
39
+ return { action: 'wait', at: input.flushAt };
40
+ }
@@ -1,39 +1,75 @@
1
1
  /**
2
2
  * What a run row stores so a later incarnation of the user object can
3
3
  * rebuild the attempt: the `TurnRequest` (minus attachments — they are in
4
- * the checkpointed first message) and the stream options the frame producer
5
- * needs. The browser tool catalogue and the AG-UI action schemas are NOT
6
- * stored: they live in the graph state (`browserTools` / `agActions`), which
7
- * a resumed attempt reads back from the checkpoint.
4
+ * the checkpointed first message), the client-declared tool surface of the
5
+ * turn (browser tools + AG-UI actions: the portal and agui plugins bind
6
+ * them at build time, so an attempt cannot recover them from anywhere
7
+ * else), and the stream options the frame producer needs.
8
8
  */
9
9
  import type { RunSummary, TurnRequest } from './contracts';
10
10
  import type { RunRecord } from './run-store';
11
+ import type { TurnBody } from './turn-body';
11
12
 
12
- export interface StoredRunRequest {
13
+ /** The client-declared surface of a turn, as the body carries it. */
14
+ export type ClientSurfaceBody = Pick<TurnBody, 'tools' | 'agActions'>;
15
+
16
+ export interface StoredRunRequest extends ClientSurfaceBody {
13
17
  turn: Omit<TurnRequest, 'attachments'> & {
14
18
  attachments?: TurnRequest['attachments'];
15
19
  };
16
20
  timezone?: string;
17
- /** Names the client declared as AG-UI actions (rendered as `action_call`). */
18
- agActionNames?: string[];
19
21
  stream?: boolean;
20
22
  }
21
23
 
22
24
  export function storedRunRequest(
23
25
  req: TurnRequest,
24
- extra: Pick<StoredRunRequest, 'timezone' | 'agActionNames' | 'stream'> = {},
26
+ extra: Pick<
27
+ StoredRunRequest,
28
+ 'timezone' | 'tools' | 'agActions' | 'stream'
29
+ > = {},
25
30
  ): StoredRunRequest {
26
31
  const { attachments, ...turn } = req;
27
32
  return {
28
33
  turn: { ...turn, ...(attachments?.length ? { attachments } : {}) },
29
34
  ...(extra.timezone ? { timezone: extra.timezone } : {}),
30
- ...(extra.agActionNames?.length
31
- ? { agActionNames: extra.agActionNames }
32
- : {}),
35
+ ...(extra.tools?.length ? { tools: extra.tools } : {}),
36
+ ...(extra.agActions?.length ? { agActions: extra.agActions } : {}),
33
37
  ...(extra.stream !== undefined ? { stream: extra.stream } : {}),
34
38
  };
35
39
  }
36
40
 
41
+ /** The surface as the graph state stores it (`browserTools` / `agActions`). */
42
+ export interface ClientSurfaceState {
43
+ browserTools?: TurnBody['tools'];
44
+ agActions?: TurnBody['agActions'];
45
+ }
46
+
47
+ /**
48
+ * The client-declared surface of one attempt, by the Node agent-builder's
49
+ * rule: what the body carries, else what the thread's checkpoint holds.
50
+ *
51
+ * `state` feeds the agent build (the portal / agui plugins read it from
52
+ * `history.state`, so it must be complete). `input` is what the graph input
53
+ * writes back to the checkpoint: only what THIS body declared, so a turn
54
+ * without a catalogue (a resumed attempt, a Matrix turn, a task run) never
55
+ * erases the one the client sent earlier.
56
+ */
57
+ export function clientSurfaceFor(
58
+ body: ClientSurfaceBody,
59
+ prior: ClientSurfaceState,
60
+ ): { state: Required<ClientSurfaceState>; input: ClientSurfaceState } {
61
+ return {
62
+ state: {
63
+ browserTools: body.tools ?? prior.browserTools ?? [],
64
+ agActions: body.agActions ?? prior.agActions ?? [],
65
+ },
66
+ input: {
67
+ ...(body.tools !== undefined ? { browserTools: body.tools } : {}),
68
+ ...(body.agActions !== undefined ? { agActions: body.agActions } : {}),
69
+ },
70
+ };
71
+ }
72
+
37
73
  export function runSummaryOf(record: RunRecord): RunSummary {
38
74
  return {
39
75
  runId: record.runId,
@@ -156,6 +156,7 @@ import {
156
156
  import { turnRecursionLimit } from './turn-config';
157
157
  import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
158
158
  import { decideBootDirty } from './boot-dirty';
159
+ import { decideFlush } from './flush-schedule';
159
160
  import { mirrorTxnId, RoomMirror } from './room-mirror';
160
161
  import { prefixSpeaker } from './speaker-prefix';
161
162
  import {
@@ -199,6 +200,7 @@ import {
199
200
  } from './run-store';
200
201
  import {
201
202
  runSummaryOf,
203
+ clientSurfaceFor,
202
204
  storedRunRequest,
203
205
  type StoredRunRequest,
204
206
  } from './run-request';
@@ -320,6 +322,15 @@ const TIER_PASS_INTERVAL_MS = 6 * 60 * 60_000;
320
322
  * silently stale.
321
323
  */
322
324
  const META_DIRTY = 'meta:dirty';
325
+ /**
326
+ * When the dirty working copy is due to be uploaded. Armed by the first
327
+ * write after an upload (`FLUSH_DEBOUNCE_MS` later), replaced by the retry
328
+ * delay after a failed upload, cleared with the dirty mark. The alarm is
329
+ * shared with the realtime heartbeat, the durable-run keep-alive, task runs
330
+ * and compaction, so a wake alone never means "upload now" — the tick
331
+ * consults this deadline (`do/flush-schedule.ts`).
332
+ */
333
+ const META_FLUSH_AT = 'meta:flushAt';
323
334
  const META_OWNER_ETAG = 'meta:ownerEtag';
324
335
  const META_LAST_FLUSH = 'meta:lastFlushAt';
325
336
  const META_LAST_CHECKSUM = 'meta:lastChecksum';
@@ -1083,9 +1094,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1083
1094
  console.warn(
1084
1095
  `[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
1085
1096
  );
1086
- // Sooner than the 24 h debounce `markDirty` armed: the system of
1087
- // record is still empty for this user.
1088
- this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
1097
+ // The failed upload armed the 10-minute retry (`flushOnce`), well
1098
+ // before the 24 h debounce `markDirty` set: the system of record
1099
+ // is still empty for this user.
1089
1100
  }
1090
1101
  }
1091
1102
  await this.reconcileDirtyOnBoot(liveDb, userDid);
@@ -1278,6 +1289,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1278
1289
  META_OWNER_ETAG,
1279
1290
  META_LAST_CHECKSUM,
1280
1291
  META_LAST_FLUSH,
1292
+ META_FLUSH_AT,
1281
1293
  META_UPLOADED_GEN,
1282
1294
  META_FLUSH_FAILURES,
1283
1295
  META_LAST_VACUUM,
@@ -1383,7 +1395,30 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1383
1395
  private markDirty(): void {
1384
1396
  this.dirty = true;
1385
1397
  void this.ctx.storage.put(META_DIRTY, true);
1386
- this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
1398
+ void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
1399
+ }
1400
+
1401
+ /**
1402
+ * Record the flush deadline and arm the alarm for it. An earlier
1403
+ * deadline already on disk wins, so a burst of writes uploads once,
1404
+ * `FLUSH_DEBOUNCE_MS` after the first of them, never later.
1405
+ */
1406
+ private async armFlush(at: number): Promise<void> {
1407
+ const existing = await this.ctx.storage.get<number>(META_FLUSH_AT);
1408
+ const deadline =
1409
+ typeof existing === 'number' && existing <= at ? existing : at;
1410
+ if (deadline !== existing)
1411
+ await this.ctx.storage.put(META_FLUSH_AT, deadline);
1412
+ this.requestAlarm(deadline);
1413
+ }
1414
+
1415
+ /**
1416
+ * After a failed upload: the next attempt is `at`, replacing the deadline
1417
+ * that just came due (which would otherwise make every wake retry).
1418
+ */
1419
+ private async scheduleFlushRetry(at: number): Promise<void> {
1420
+ await this.ctx.storage.put(META_FLUSH_AT, at);
1421
+ this.requestAlarm(at);
1387
1422
  }
1388
1423
 
1389
1424
  /** Arm the object's single alarm no later than `at` (multiplexed). */
@@ -1481,15 +1516,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1481
1516
  deadlines.push(Math.max(nextRecovery, now + 1000));
1482
1517
  }
1483
1518
 
1484
- if (this.dirty && this.db) {
1519
+ const lastAccess =
1520
+ (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
1521
+ const idle = now - lastAccess > IDLE_EVICT_MS;
1522
+
1523
+ // Owner-store flush: only when its own deadline is due, or the copy
1524
+ // is about to be evicted below. A wake for anything else (a run's
1525
+ // keep-alive, a heartbeat round that fell through, a compaction or
1526
+ // tier re-arm) leaves a dirty copy alone — the upload is a full-file
1527
+ // transfer and is meant to happen once a day, not once a turn.
1528
+ const flush = decideFlush({
1529
+ dirty: this.dirty && this.db !== null,
1530
+ flushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
1531
+ now,
1532
+ idle,
1533
+ });
1534
+ if (flush.action === 'flush') {
1485
1535
  try {
1486
1536
  await this.flushToOwnerStore();
1487
1537
  } catch (err) {
1488
1538
  console.error(
1489
1539
  `[user-do] flush failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1490
1540
  );
1541
+ // `flushOnce` arms the retry on an upload failure; anything that
1542
+ // failed before the upload gets the same retry window here.
1543
+ await this.scheduleFlushRetry(now + FLUSH_RETRY_DELAY_MS);
1491
1544
  deadlines.push(now + FLUSH_RETRY_DELAY_MS);
1492
1545
  }
1546
+ } else if (flush.action === 'wait') {
1547
+ deadlines.push(Math.max(flush.at, now + 1000));
1493
1548
  }
1494
1549
 
1495
1550
  if (this.taskScheduler) {
@@ -1522,9 +1577,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1522
1577
  }
1523
1578
  }
1524
1579
 
1525
- const lastAccess =
1526
- (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
1527
-
1528
1580
  // Free-page reclaim on a quiet object (policy in sqlite/vacuum-policy.ts):
1529
1581
  // runs after the flush above so the upload holds the pre-rebuild file,
1530
1582
  // and marks dirty so the smaller file goes up on the next tick.
@@ -1551,7 +1603,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1551
1603
  // evict while tasks are scheduled: their runs ARE activity. The flush
1552
1604
  // above ran first; the wipe additionally verifies the upstream copy
1553
1605
  // matches the working copy, else the copy stays until it does.
1554
- const idle = now - lastAccess > IDLE_EVICT_MS;
1555
1606
  if (
1556
1607
  idle &&
1557
1608
  !this.dirty &&
@@ -2052,9 +2103,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2052
2103
  writeGeneration: db.writeGeneration,
2053
2104
  });
2054
2105
  if (decision === 'flagged') {
2055
- // The mark is on disk already (its flush alarm too); just adopt it.
2106
+ // The mark is on disk already (its deadline too); adopt it and make
2107
+ // sure the alarm is armed for that deadline.
2056
2108
  this.dirty = true;
2057
- this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
2109
+ void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
2058
2110
  } else if (decision === 'behind-upload') {
2059
2111
  console.log(
2060
2112
  `[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
@@ -2431,6 +2483,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2431
2483
  const line = `[user-do] owner-store flush failed (${failures} in a row) for ${this.userDid} — working copy kept dirty, retrying in ${FLUSH_RETRY_DELAY_MS / 60_000} min: ${detail}`;
2432
2484
  if (failures >= FLUSH_FAILURES_ERROR_THRESHOLD) console.error(line);
2433
2485
  else console.warn(line);
2486
+ await this.scheduleFlushRetry(Date.now() + FLUSH_RETRY_DELAY_MS);
2434
2487
  throw err;
2435
2488
  }
2436
2489
  await this.ctx.storage.put(META_OWNER_ETAG, saved.etag);
@@ -2510,18 +2563,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2510
2563
  }
2511
2564
 
2512
2565
  private async clearDirty(): Promise<void> {
2513
- // A write that landed while the snapshot was being uploaded already
2514
- // re-armed the alarm through `markDirty`; only clear when the file's
2515
- // generation still matches what was just recorded.
2566
+ // A write that landed while the snapshot was being uploaded keeps the
2567
+ // copy dirty; its upload is a full debounce away (the deadline the
2568
+ // write found on disk was the one that just ran).
2516
2569
  const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
2517
2570
  if (
2518
2571
  this.db &&
2519
2572
  uploadedGen !== undefined &&
2520
2573
  uploadedGen !== this.db.writeGeneration
2521
- )
2574
+ ) {
2575
+ const at = Date.now() + FLUSH_DEBOUNCE_MS;
2576
+ await this.ctx.storage.put(META_FLUSH_AT, at);
2577
+ this.requestAlarm(at);
2522
2578
  return;
2579
+ }
2523
2580
  this.dirty = false;
2524
- await this.ctx.storage.delete(META_DIRTY);
2581
+ await this.ctx.storage.delete([META_DIRTY, META_FLUSH_AT]);
2525
2582
  }
2526
2583
 
2527
2584
  async resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }> {
@@ -2650,6 +2707,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2650
2707
  pages: Math.ceil(fileBytes / 4096),
2651
2708
  ownerEtag: await this.ctx.storage.get<string>(META_OWNER_ETAG),
2652
2709
  lastFlushAt: await this.ctx.storage.get<number>(META_LAST_FLUSH),
2710
+ nextFlushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
2653
2711
  dirty: this.dirty,
2654
2712
  flushInFlight: this.flushInFlight !== null,
2655
2713
  writeGeneration: db?.writeGeneration,
@@ -2954,7 +3012,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2954
3012
  // (docs/plans/durable-runs.md). The response is one subscriber of the
2955
3013
  // run's buffer; the run itself outlives it.
2956
3014
  this.replayToRoom(req, body.message, 'user');
2957
- const agActionNames = (body.agActions ?? []).map((a) => a.name);
2958
3015
  const { live, queued } = await this.runs!.begin({
2959
3016
  runId: crypto.randomUUID(),
2960
3017
  sessionId,
@@ -2963,7 +3020,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2963
3020
  request: JSON.stringify(
2964
3021
  storedRunRequest(req, {
2965
3022
  timezone: body.timezone,
2966
- agActionNames,
3023
+ tools: body.tools,
3024
+ agActions: body.agActions,
2967
3025
  stream: true,
2968
3026
  }),
2969
3027
  ),
@@ -3081,6 +3139,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3081
3139
  message: req.message,
3082
3140
  timezone: stored.timezone ?? req.identity.timezone,
3083
3141
  model: req.model,
3142
+ tools: stored.tools,
3143
+ agActions: stored.agActions,
3084
3144
  ...(resumed ? {} : { attachments: req.attachments }),
3085
3145
  },
3086
3146
  {
@@ -3121,7 +3181,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3121
3181
  abortController: live.abort,
3122
3182
  mirror: (eventName, payload) =>
3123
3183
  this.events.emitToTaps(eventName, payload),
3124
- agActionNames: new Set(stored.agActionNames ?? []),
3184
+ agActionNames: new Set((stored.agActions ?? []).map((a) => a.name)),
3125
3185
  byoProvider,
3126
3186
  toolOutputCapChars,
3127
3187
  messageIdOf: () => lastAiMessageId(capture),
@@ -3801,20 +3861,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3801
3861
  const hostRoomTitle = opts.hooks?.getRoomTitle;
3802
3862
  const hostSafetyModel = opts.hooks?.safetyModel;
3803
3863
 
3804
- // A resumed attempt reads the client's tool surface back from the
3805
- // checkpointed state (the run row does not store the catalogue).
3806
- const priorSurface = priorState as {
3807
- browserTools?: TurnBody['tools'];
3808
- agActions?: TurnBody['agActions'];
3809
- };
3810
- const browserTools =
3811
- body.tools ??
3812
- (run.resumed ? priorSurface.browserTools : undefined) ??
3813
- [];
3814
- const agActions =
3815
- body.agActions ??
3816
- (run.resumed ? priorSurface.agActions : undefined) ??
3817
- [];
3864
+ // The client-declared tool surface: this body's, else the thread's
3865
+ // checkpointed one (run-request.ts).
3866
+ const surface = clientSurfaceFor(body, priorState);
3818
3867
 
3819
3868
  // Write-ahead tool marks + the resume policy (tool-marks.ts). The
3820
3869
  // effect map is filled from the build below; the closure reads it at
@@ -3908,12 +3957,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3908
3957
  ...priorState,
3909
3958
  userPreferences,
3910
3959
  ...metadataBuildState(meta, priorMeta),
3911
- // The client-declared surface of THIS request: the portal and
3912
- // AG-UI plugins turn these into tools at build time
3913
- // (`getRequestTools` reads `history.state`), so they must be here
3914
- // and not only in the graph input.
3915
- browserTools,
3916
- agActions,
3960
+ // The client-declared surface: the portal and AG-UI plugins turn
3961
+ // these into tools at build time (`getRequestTools` reads
3962
+ // `history.state`), so they must be here and not only in the
3963
+ // graph input.
3964
+ ...surface.state,
3917
3965
  },
3918
3966
  checkpointer: saver,
3919
3967
  abortSignal: abortController.signal,
@@ -3979,8 +4027,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3979
4027
  config: { did: req.identity.userDid },
3980
4028
  client: req.client,
3981
4029
  ...metadataGraphInput(meta, priorMeta),
3982
- browserTools,
3983
- agActions,
4030
+ ...surface.input,
3984
4031
  };
3985
4032
  // LangSmith: metadata is attached unconditionally (inert without a
3986
4033
  // tracer); the explicit tracer only when this turn is traced (global