@ixo/oracle-runtime-workers 0.4.0 → 0.9.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/README.md CHANGED
@@ -109,7 +109,11 @@ lifetime are in [operations](docs/operations.md).
109
109
  for production (0.1.0–0.1.2 could send plaintext into an encrypted room
110
110
  after a lazy-member sync delta, and queued session markers behind
111
111
  background replays — both fixed in 0.1.3 and re-measured, see
112
- [load-tests](docs/load-tests.md#session-creation-is-the-one-funnel)).
112
+ [load-tests](docs/load-tests.md#session-creation-is-the-one-funnel));
113
+ 0.3.1 brings the streamed media calls the legacy-copy import and the
114
+ attachments now use (a 50 MB Node-era checkpoint no longer resets the
115
+ user object's isolate) and the crypto-snapshot flush before every keys
116
+ upload.
113
117
  - Open: the memory engine's recall ranking on accounts with a long history;
114
118
  the storage cost of task-holding users at scale (R2 page tier, see
115
119
  [architecture](docs/architecture.md#storage-cost-planning)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.4.0",
3
+ "version": "0.9.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": {
@@ -33,7 +33,7 @@
33
33
  "@composio/core": "0.10.0",
34
34
  "@composio/langchain": "0.9.2",
35
35
  "@ixo/editor": "6.0.1",
36
- "@ixo/matrix-bot-workers-sdk": "0.1.3",
36
+ "@ixo/matrix-bot-workers-sdk": "0.4.0",
37
37
  "@ixo/matrix-crdt": "^1.2.5",
38
38
  "@langchain/core": "1.1.48",
39
39
  "@langchain/langgraph": "1.3.2",
@@ -52,7 +52,7 @@
52
52
  "matrix-js-sdk": "^42.2.0",
53
53
  "superjson": "1.13.3",
54
54
  "wa-sqlite": "1.0.0",
55
- "yjs": "^13.6.27",
55
+ "yjs": "13.6.32",
56
56
  "zod": "^4.4.3",
57
57
  "@ixo/ucan": "2.2.0"
58
58
  },
@@ -52,15 +52,20 @@ export function validateUrlTarget(targetUrl: string): void {
52
52
  * Durable Object in the user object; tests pass an in-memory fake.
53
53
  */
54
54
  export interface MatrixMediaSource {
55
- /** Raw bytes behind an `mxc://` URI (authenticated media download). */
56
- downloadMxc(mxc: string): Promise<Uint8Array>;
55
+ /**
56
+ * Raw bytes behind an `mxc://` URI (authenticated media download), the
57
+ * transfer cut off once it passes `maxBytes`.
58
+ */
59
+ downloadMxc(mxc: string, maxBytes?: number): Promise<Uint8Array>;
57
60
  /**
58
61
  * Bytes of the media event `eventId` in `roomId`, decrypted when the event
59
- * carries an encrypted `file`. Null when the event does not exist.
62
+ * carries an encrypted `file`, the transfer cut off once it passes
63
+ * `maxBytes`. Null when the event does not exist.
60
64
  */
61
65
  downloadEvent(
62
66
  roomId: string,
63
67
  eventId: string,
68
+ maxBytes?: number,
64
69
  ): Promise<{
65
70
  bytes: Uint8Array;
66
71
  mimetype?: string;
@@ -104,6 +109,33 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array {
104
109
  return out;
105
110
  }
106
111
 
112
+ /**
113
+ * Collect a stream into bytes with a running size check: the moment the
114
+ * total passes `maxBytes` the stream is cancelled and an error thrown, so an
115
+ * oversized file never lands in memory whole.
116
+ */
117
+ export async function readBytesCapped(
118
+ stream: ReadableStream<Uint8Array>,
119
+ maxBytes: number,
120
+ ): Promise<Uint8Array> {
121
+ const reader = stream.getReader();
122
+ const chunks: Uint8Array[] = [];
123
+ let total = 0;
124
+ for (;;) {
125
+ const { done, value } = await reader.read();
126
+ if (done) break;
127
+ total += value.byteLength;
128
+ if (total > maxBytes) {
129
+ void reader.cancel();
130
+ throw new Error(
131
+ `File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
132
+ );
133
+ }
134
+ chunks.push(value);
135
+ }
136
+ return concat(chunks, total);
137
+ }
138
+
107
139
  /**
108
140
  * Download an http(s) URL: every redirect hop is SSRF-validated, the body is
109
141
  * streamed with a running size check so an oversized file never lands in
@@ -150,24 +182,9 @@ export async function downloadFromUrl(
150
182
  `File too large: server reports ${Math.round(parseInt(contentLength, 10) / 1024 / 1024)} MB (limit: ${Math.round(maxBytes / 1024 / 1024)} MB)`,
151
183
  );
152
184
  }
153
- const reader = response.body?.getReader();
154
- if (!reader) throw new Error('Response body is not readable');
155
- const chunks: Uint8Array[] = [];
156
- let total = 0;
157
- for (;;) {
158
- const { done, value } = await reader.read();
159
- if (done) break;
160
- total += value.byteLength;
161
- if (total > maxBytes) {
162
- void reader.cancel();
163
- throw new Error(
164
- `File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
165
- );
166
- }
167
- chunks.push(value);
168
- }
185
+ if (!response.body) throw new Error('Response body is not readable');
169
186
  return {
170
- data: concat(chunks, total),
187
+ data: await readBytesCapped(response.body, maxBytes),
171
188
  contentType,
172
189
  ...(currentUrl !== url ? { finalUrl: currentUrl } : {}),
173
190
  };
@@ -206,12 +223,16 @@ export async function loadAttachmentBytes(
206
223
  throw new Error(
207
224
  `Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
208
225
  );
209
- const media = await source.downloadEvent(roomId, attachment.eventId);
226
+ const media = await source.downloadEvent(
227
+ roomId,
228
+ attachment.eventId,
229
+ maxBytes,
230
+ );
210
231
  if (!media) throw new Error(`Matrix event ${attachment.eventId} not found`);
211
232
  bytes = media.bytes;
212
233
  httpType = media.mimetype;
213
234
  } else if (attachment.mxcUri!.startsWith('mxc://')) {
214
- bytes = await source.downloadMxc(attachment.mxcUri!);
235
+ bytes = await source.downloadMxc(attachment.mxcUri!, maxBytes);
215
236
  } else {
216
237
  const result = await downloadFromUrl(attachment.mxcUri!, {
217
238
  ...opts,
@@ -8,6 +8,7 @@ export {
8
8
  downloadFromUrl,
9
9
  loadAttachmentBytes,
10
10
  MAX_FILE_SIZE,
11
+ readBytesCapped,
11
12
  MAX_TOTAL_SIZE,
12
13
  validateUrlTarget,
13
14
  type DownloadOptions,
package/src/core/env.ts CHANGED
@@ -21,6 +21,14 @@ import { NOOP_LOGGER } from './utils';
21
21
  * other non-string bindings on the Worker `env` object never reach plugins
22
22
  * through `ctx.config`.
23
23
  */
24
+ /**
25
+ * Three times the Node runtime's hard-coded `recursionLimit: 200`. A tool-call
26
+ * round trip costs about 6 steps with the bundled middlewares, so 600 is
27
+ * roughly 100 chained tool calls — room for long research turns without an
28
+ * env override.
29
+ */
30
+ export const TURN_RECURSION_LIMIT_DEFAULT = 600;
31
+
24
32
  export const baseEnvSchema = z.object({
25
33
  // --- identity -----------------------------------------------------------
26
34
  ORACLE_NAME: z.string().min(1),
@@ -86,6 +94,17 @@ export const baseEnvSchema = z.object({
86
94
  * token, at some cost to hard multi-step reasoning.
87
95
  */
88
96
  MAIN_REASONING_EFFORT: z.enum(['low', 'medium', 'high']).default('medium'),
97
+ /**
98
+ * LangGraph `recursionLimit` for one turn — the super-steps (a model call,
99
+ * a batch of tool calls, a middleware hook) a turn may take before it fails
100
+ * with `GraphRecursionError`. Default 600 (the Node runtime hard-codes 200);
101
+ * see `TURN_RECURSION_LIMIT_DEFAULT`.
102
+ */
103
+ TURN_RECURSION_LIMIT: z.coerce
104
+ .number()
105
+ .int()
106
+ .min(1)
107
+ .default(TURN_RECURSION_LIMIT_DEFAULT),
89
108
 
90
109
  // --- misc ---------------------------------------------------------------
91
110
  LOG_LEVEL: z.string().default('info'),
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createAgent,
3
+ ToolInvocationError,
3
4
  toolRetryMiddleware,
4
5
  type StructuredTool,
5
6
  } from 'langchain';
@@ -16,12 +17,28 @@ import { buildMetaTools } from './meta-tools';
16
17
  import {
17
18
  createByoHistorySanitizerMiddleware,
18
19
  createCapabilityGateMiddleware,
20
+ createDanglingToolCallRepairMiddleware,
19
21
  createPageContextMiddleware,
20
22
  createSafetyGuardrailMiddleware,
21
23
  createSummarizationMiddleware,
22
24
  createToolRepetitionGuardMiddleware,
23
25
  createToolValidationMiddleware,
24
26
  } from './middlewares';
27
+
28
+ /**
29
+ * The model's arguments failed the tool's schema (LangChain's ToolNode
30
+ * throws `ToolInvocationError` before the tool runs). Matched by class AND by
31
+ * name / message shape: a bundle can carry the class twice (ESM and CJS
32
+ * entry points), and then `instanceof` is false for the very error it names.
33
+ */
34
+ function isToolInvocationError(error: unknown): boolean {
35
+ if (error instanceof ToolInvocationError) return true;
36
+ if (!(error instanceof Error)) return false;
37
+ return (
38
+ error.name === 'ToolInvocationError' ||
39
+ /^Error invoking tool '.+' with kwargs /.test(error.message)
40
+ );
41
+ }
25
42
  import {
26
43
  composePrompt,
27
44
  formatTimeContext,
@@ -295,13 +312,21 @@ export async function createMainAgent(
295
312
  const resolveModel = hooks?.resolveModel ?? ambient.llm.get.bind(ambient.llm);
296
313
 
297
314
  const middleware = [
298
- // Outermost, as on Node: rewrite cross-provider reasoning residue before
299
- // the summarizer condenses (a well-formed) history.
315
+ // Outermost: a turn that died between a tool call and its result (abort,
316
+ // object reset) must not poison every later model request on the thread.
317
+ createDanglingToolCallRepairMiddleware({ logger: ambient.logger }),
318
+ // Then, as on Node: rewrite cross-provider reasoning residue before the
319
+ // summarizer condenses (a well-formed) history.
300
320
  createByoHistorySanitizerMiddleware({ logger: ambient.logger }),
301
321
  // Condense long threads before everything tool-related.
302
322
  // Without this, thread state — reloaded, re-serialized, and re-uploaded to
303
323
  // the owner store on every turn — grows without bound.
304
- createSummarizationMiddleware({ model: resolveModel('routing') }),
324
+ // The summarizer's own model call is internal: it must never stream its
325
+ // tokens into the user's reply (belt: no streaming here; braces: the SSE
326
+ // stream drops `lc_source: 'summarization'` events).
327
+ createSummarizationMiddleware({
328
+ model: resolveModel('routing', { disableStreaming: true }),
329
+ }),
305
330
  createCapabilityGateMiddleware({
306
331
  pluginByToolName,
307
332
  visibilityByToolName,
@@ -312,7 +337,34 @@ export async function createMainAgent(
312
337
  logger: ambient.logger,
313
338
  }),
314
339
  createToolRepetitionGuardMiddleware({ logger: ambient.logger }),
315
- toolRetryMiddleware({ onFailure: (error) => error.message }),
340
+ // Retries are for transient failures. A ToolInvocationError is the
341
+ // model's arguments failing the tool's schema: the same call again gives
342
+ // the same rejection, so it is not retried — and it is logged, because
343
+ // the error ToolMessage it becomes is otherwise invisible in the logs
344
+ // (the tool-validation middleware above never sees it; ToolNode throws
345
+ // it before the tool runs).
346
+ toolRetryMiddleware({
347
+ retryOn: (error) => !isToolInvocationError(error),
348
+ onFailure: (error) => {
349
+ const rejected = isToolInvocationError(error);
350
+ ambient.logger.warn(
351
+ `[tool-retry] ${rejected ? 'tool call rejected by the tool schema' : 'tool failed after retries'}: ${error.message.split('\n')[0] ?? error.message}`,
352
+ );
353
+ if (!rejected) return error.message;
354
+ // The model (and the Portal's card) get the reason, not the kwargs
355
+ // dump — `Error invoking tool 'x' with kwargs {…} with error: Error:
356
+ // Received tool input did not match expected schema` stays in the tail.
357
+ const toolName = /^Error invoking tool '([^']+)'/.exec(
358
+ error.message,
359
+ )?.[1];
360
+ const reason =
361
+ error.message
362
+ .split(' with error: ')
363
+ .at(-1)
364
+ ?.replace(/^Error: /, '') ?? error.message;
365
+ return `Invalid arguments for ${toolName ?? 'the tool'}: ${reason}. Check the tool's parameter schema and call it again with corrected arguments.`;
366
+ },
367
+ }),
316
368
  // Same host-gated pair as the Node runtime: the page-context block needs a
317
369
  // title lookup, the safety guardrail a classification model.
318
370
  ...(hooks?.getRoomTitle
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A turn that dies between the model's tool call and the tool's result — the
3
+ * user aborted it, or the runtime was reset while the tool ran — leaves the
4
+ * checkpoint with an assistant message whose `tool_calls` have no
5
+ * `ToolMessage`. OpenRouter chat completions tolerate that history; the
6
+ * ChatGPT-subscription lane (Responses API) rejects every later turn on the
7
+ * session with `400 No tool output found for function call …`. This
8
+ * middleware answers each unanswered call, for the model request only, with
9
+ * a tool result that says the call was interrupted. The checkpoint is left as
10
+ * it is: nothing is rewritten in place, and the model is free to call the
11
+ * tool again.
12
+ */
13
+ import {
14
+ type BaseMessage,
15
+ isAIMessage,
16
+ isToolMessage,
17
+ ToolMessage,
18
+ } from '@langchain/core/messages';
19
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
20
+ import type { Logger } from '../../plugin-api/types';
21
+ import { NOOP_LOGGER } from '../utils';
22
+
23
+ export interface DanglingToolCallRepairMiddlewareOptions {
24
+ /** Optional logger; defaults to a no-op. */
25
+ logger?: Logger;
26
+ }
27
+
28
+ /** The result handed to the model for a tool call that never returned. */
29
+ export function interruptedToolResult(toolName: string): string {
30
+ return `The call to ${toolName} was interrupted before it returned a result (the turn was aborted or the runtime restarted while it ran), so nothing from it was recorded. Call it again if its result is still needed.`;
31
+ }
32
+
33
+ export interface DanglingToolCallRepair {
34
+ /** The original array when nothing was missing, otherwise a repaired copy. */
35
+ messages: BaseMessage[];
36
+ /** How many tool calls were answered. */
37
+ repaired: number;
38
+ }
39
+
40
+ /**
41
+ * Insert a synthetic `ToolMessage` for every `tool_calls` entry of an
42
+ * assistant message that has no `ToolMessage` among the tool messages that
43
+ * directly follow it. The synthetic results go right after the real ones,
44
+ * before the next non-tool message, which is the position every provider's
45
+ * request converter requires.
46
+ */
47
+ export function repairDanglingToolCalls(
48
+ messages: BaseMessage[],
49
+ ): DanglingToolCallRepair {
50
+ const out: BaseMessage[] = [];
51
+ let repaired = 0;
52
+ for (let i = 0; i < messages.length; i += 1) {
53
+ const message = messages[i]!;
54
+ out.push(message);
55
+ if (!isAIMessage(message)) continue;
56
+ const toolCalls = message.tool_calls ?? [];
57
+ if (toolCalls.length === 0) continue;
58
+
59
+ const answered = new Set<string>();
60
+ let next = i + 1;
61
+ while (next < messages.length && isToolMessage(messages[next]!)) {
62
+ const toolMessage = messages[next] as ToolMessage;
63
+ answered.add(toolMessage.tool_call_id);
64
+ out.push(toolMessage);
65
+ next += 1;
66
+ }
67
+ for (const call of toolCalls) {
68
+ if (!call.id || answered.has(call.id)) continue;
69
+ out.push(
70
+ new ToolMessage({
71
+ tool_call_id: call.id,
72
+ name: call.name,
73
+ content: interruptedToolResult(call.name),
74
+ status: 'error',
75
+ }),
76
+ );
77
+ repaired += 1;
78
+ }
79
+ i = next - 1;
80
+ }
81
+ return { messages: repaired === 0 ? messages : out, repaired };
82
+ }
83
+
84
+ export const createDanglingToolCallRepairMiddleware = (
85
+ options?: DanglingToolCallRepairMiddlewareOptions,
86
+ ): AgentMiddleware => {
87
+ const logger = options?.logger ?? NOOP_LOGGER;
88
+ return createMiddleware({
89
+ name: 'DanglingToolCallRepairMiddleware',
90
+ wrapModelCall: async (request, handler) => {
91
+ const { messages, repaired } = repairDanglingToolCalls(request.messages);
92
+ if (repaired === 0) return handler(request);
93
+ logger.warn(
94
+ `[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
95
+ );
96
+ return handler({ ...request, messages });
97
+ },
98
+ });
99
+ };
@@ -11,6 +11,11 @@ export {
11
11
  } from './summarization';
12
12
  export type { SummarizationMiddlewareOptions } from './summarization';
13
13
  export { createByoHistorySanitizerMiddleware } from './byo-history-sanitizer';
14
+ export {
15
+ createDanglingToolCallRepairMiddleware,
16
+ repairDanglingToolCalls,
17
+ } from './dangling-tool-calls';
18
+ export type { DanglingToolCallRepairMiddlewareOptions } from './dangling-tool-calls';
14
19
  export type { ByoHistorySanitizerMiddlewareOptions } from './byo-history-sanitizer';
15
20
  export { createPageContextMiddleware } from './page-context';
16
21
  export type { PageContextMiddlewareOptions } from './page-context';
@@ -8,6 +8,7 @@ import type {
8
8
  ChatOpenAIFields,
9
9
  Logger,
10
10
  MatrixEvent,
11
+ MatrixPostOpts,
11
12
  MergedConfig,
12
13
  MessageCacheInvalidationPayload,
13
14
  ModelRole,
@@ -60,7 +61,11 @@ export interface BlobStoreAdapter {
60
61
 
61
62
  /** Matrix adapter exposing only scoped operations a plugin should ever need. */
62
63
  export interface MatrixAdapter {
63
- postToRoom(roomId: string, content: unknown): Promise<string>;
64
+ postToRoom(
65
+ roomId: string,
66
+ content: unknown,
67
+ opts?: MatrixPostOpts,
68
+ ): Promise<string>;
64
69
  /**
65
70
  * Post a timeline event with a caller-chosen event type. `postToRoom` is
66
71
  * the `m.room.message` shorthand; this is the general form. Returns the
@@ -70,6 +75,7 @@ export interface MatrixAdapter {
70
75
  roomId: string,
71
76
  eventType: string,
72
77
  content: object,
78
+ opts?: MatrixPostOpts,
73
79
  ): Promise<string>;
74
80
  getRoomState(roomId: string): Promise<RoomStateSnapshot>;
75
81
  getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
@@ -165,6 +171,8 @@ export interface AmbientServices {
165
171
  /** Host attachment access for the current session, when provided. */
166
172
  attachments?: AttachmentViewSurface;
167
173
  onTurnEnd?: (dispose: () => void | Promise<void>) => void;
174
+ /** Host keep-alive for un-awaited work (`ctx.waitUntil` on Workers). */
175
+ background?: (work: Promise<unknown>) => void;
168
176
  }
169
177
 
170
178
  // ── Scoped emitter ──────────────────────────────────────────────────────────
@@ -401,11 +409,11 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
401
409
  ambient.blobStore.isValidBlobId(value),
402
410
  },
403
411
  matrix: {
404
- postToRoom: (roomId, content) =>
405
- ambient.matrix.postToRoom(roomId, content),
412
+ postToRoom: (roomId, content, opts) =>
413
+ ambient.matrix.postToRoom(roomId, content, opts),
406
414
  botCredentials: () => ambient.matrix.botCredentials(),
407
- postEvent: (roomId, eventType, content) =>
408
- ambient.matrix.postEvent(roomId, eventType, content),
415
+ postEvent: (roomId, eventType, content, opts) =>
416
+ ambient.matrix.postEvent(roomId, eventType, content, opts),
409
417
  getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
410
418
  getEventById: (roomId, eventId) =>
411
419
  ambient.matrix.getEventById(roomId, eventId),
@@ -444,6 +452,7 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
444
452
  ...(ambient.frontend ? { frontend: ambient.frontend } : {}),
445
453
  ...(ambient.attachments ? { attachments: ambient.attachments } : {}),
446
454
  ...(ambient.onTurnEnd ? { onTurnEnd: ambient.onTurnEnd } : {}),
455
+ ...(ambient.background ? { background: ambient.background } : {}),
447
456
  shared: EMPTY_SHARED,
448
457
  ...(runConfig.toolCall?.id ? { toolCallId: runConfig.toolCall.id } : {}),
449
458
  };
@@ -12,6 +12,7 @@ import {
12
12
  type AgentMiddleware,
13
13
  type StructuredTool,
14
14
  } from 'langchain';
15
+ import { createDanglingToolCallRepairMiddleware } from './middlewares/dangling-tool-calls';
15
16
  import { z } from 'zod';
16
17
  import type { Logger } from '../plugin-api/types';
17
18
  import { NOOP_LOGGER } from './utils';
@@ -220,6 +221,11 @@ export function createSubagentAsTool(
220
221
  },
221
222
  ...(parentContext ? { context: parentContext } : {}),
222
223
  runName: spec.name,
224
+ // The sub-agent's model calls are the parent's tool work, not the
225
+ // reply: the SSE stream drops model events carrying this tag so its
226
+ // tokens never surface as user-visible text (its result reaches the
227
+ // model as the tool's output).
228
+ tags: ['internal', `subagent:${spec.name}`],
223
229
  },
224
230
  );
225
231
  return result.messages as BaseMessage[];
@@ -269,7 +275,12 @@ export function createSubagentAsTool(
269
275
  model: spec.model,
270
276
  tools: innerTools,
271
277
  systemPrompt: spec.systemPrompt,
272
- middleware: spec.middleware ?? [],
278
+ // Sub-agent threads are checkpointed too, so an interrupted tool
279
+ // call in one would poison its later invocations the same way.
280
+ middleware: [
281
+ createDanglingToolCallRepairMiddleware(),
282
+ ...(spec.middleware ?? []),
283
+ ],
273
284
  checkpointer,
274
285
  });
275
286
 
package/src/do/ambient.ts CHANGED
@@ -131,10 +131,15 @@ export function createMatrixAdapter(
131
131
  gateway: DurableObjectStub<MatrixGatewayObject>,
132
132
  ): AmbientServices['matrix'] {
133
133
  return {
134
- postToRoom: (roomId, content) =>
135
- gateway.sendEvent(roomId, 'm.room.message', JSON.stringify(content)),
136
- postEvent: (roomId, eventType, content) =>
137
- gateway.sendEvent(roomId, eventType, JSON.stringify(content)),
134
+ postToRoom: (roomId, content, opts) =>
135
+ gateway.sendEvent(
136
+ roomId,
137
+ 'm.room.message',
138
+ JSON.stringify(content),
139
+ opts ?? {},
140
+ ),
141
+ postEvent: (roomId, eventType, content, opts) =>
142
+ gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
138
143
  botCredentials: () => gateway.botCredentials(),
139
144
  getRoomState: async (roomId): Promise<RoomStateSnapshot> => {
140
145
  const raw = await gateway.getRoomState(roomId);
@@ -221,6 +226,8 @@ export interface CreateAmbientInput {
221
226
  frontend?: import('../plugin-api/types').FrontendCallSurface;
222
227
  /** Per-turn cleanup registry (see `RuntimeContext.onTurnEnd`). */
223
228
  onTurnEnd?: (dispose: () => void | Promise<void>) => void;
229
+ /** Keep-alive for un-awaited work (see `RuntimeContext.background`). */
230
+ background?: (work: Promise<unknown>) => void;
224
231
  config: Record<string, unknown>;
225
232
  identity: OracleIdentity;
226
233
  availablePlugins: ReadonlySet<string>;
@@ -246,6 +253,8 @@ export function createAmbientServices(
246
253
  ...(input.tasks ? { tasks: input.tasks } : {}),
247
254
  ...(input.preferences ? { preferences: input.preferences } : {}),
248
255
  ...(input.frontend ? { frontend: input.frontend } : {}),
256
+ ...(input.onTurnEnd ? { onTurnEnd: input.onTurnEnd } : {}),
257
+ ...(input.background ? { background: input.background } : {}),
249
258
  config: input.config,
250
259
  identity: input.identity,
251
260
  availablePlugins: input.availablePlugins,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether a freshly opened working copy has writes the system of record has
3
+ * not seen. The dirty mark is normally set at the end of a turn; a turn that
4
+ * died in the middle (an object reset, a provider error, the user's stop)
5
+ * leaves LangGraph's per-step checkpoints committed in SQLite with no mark,
6
+ * and the upload waits for whatever dirties the object next — possibly
7
+ * never, and a reload from the system of record would drop those steps. The
8
+ * VFS records a write generation with the file and the object records the
9
+ * generation it last uploaded; a mismatch on boot is the missing mark.
10
+ */
11
+ export interface BootDirtyInput {
12
+ /** The object's in-memory flag (a warm boot already knows). */
13
+ dirtyInMemory: boolean;
14
+ /** The persisted `meta:dirty` flag. */
15
+ dirtyFlag: unknown;
16
+ /** The persisted generation of the last successful upload (undefined = never uploaded). */
17
+ uploadedGen: unknown;
18
+ /** The working copy's current write generation. */
19
+ writeGeneration: number;
20
+ }
21
+
22
+ export type BootDirtyDecision =
23
+ | 'already-dirty'
24
+ /** The persisted flag is set: adopt it (the flush alarm is armed already). */
25
+ | 'flagged'
26
+ /** Never uploaded: the first completed turn marks and flushes; nothing to reconcile. */
27
+ | 'never-uploaded'
28
+ /** Writes after the last upload with no mark: mark dirty now. */
29
+ | 'behind-upload'
30
+ | 'clean';
31
+
32
+ export function decideBootDirty(input: BootDirtyInput): BootDirtyDecision {
33
+ if (input.dirtyInMemory) return 'already-dirty';
34
+ if (input.dirtyFlag === true) return 'flagged';
35
+ if (typeof input.uploadedGen !== 'number') return 'never-uploaded';
36
+ if (input.uploadedGen !== input.writeGeneration) return 'behind-upload';
37
+ return 'clean';
38
+ }