@ixo/oracle-runtime-workers 0.11.1 → 0.13.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.1",
3
+ "version": "0.13.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.4.0",
36
+ "@ixo/matrix-bot-workers-sdk": "0.7.0",
37
37
  "@ixo/matrix-crdt": "^1.2.5",
38
38
  "@langchain/core": "1.1.48",
39
39
  "@langchain/langgraph": "1.3.2",
@@ -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,
@@ -73,6 +73,34 @@ export interface OracleWorkerEnv {
73
73
  MATRIX_HOT_ROOMS?: string;
74
74
  /** Cap on events replayed per room after a restart (SDK default 0 = unbounded). */
75
75
  MATRIX_BACKFILL_MAX_EVENTS?: string;
76
+ /**
77
+ * Gateway memory guards (`@ixo/matrix-bot-workers-sdk` 0.5+): events per
78
+ * room per `/sync` batch (default 30), the largest `/sync` response held
79
+ * in memory (default 16 MiB; over it the batch is re-requested at timeline
80
+ * limit 1 and the rooms catch up through `/messages`), events per catch-up
81
+ * page (default 100), the largest catch-up page (default 10 MiB; a bigger
82
+ * one is fetched again smaller) and the largest ciphertext the bot will
83
+ * decrypt (default 0 = no cap). A cap of `0` switches that guard off.
84
+ */
85
+ MATRIX_SYNC_TIMELINE_LIMIT?: string;
86
+ MATRIX_SYNC_BYTES_CAP?: string;
87
+ MATRIX_CATCHUP_PAGE_SIZE?: string;
88
+ MATRIX_PAGE_BYTES_CAP?: string;
89
+ MATRIX_MAX_DECRYPT_BYTES?: string;
90
+ /**
91
+ * `true` restores every room key the account's key backup holds on a
92
+ * device's first start (SDK 0.6+), capped at `..._MAX_KEYS` (default
93
+ * 5,000; a larger backup is skipped and keys stay on demand). Off by
94
+ * default: the gateway reads keys from the backup one session at a time.
95
+ */
96
+ MATRIX_BACKUP_BULK_RESTORE?: string;
97
+ MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS?: string;
98
+ /**
99
+ * Keep-alive fuse (SDK 0.7+): while the gateway object works, its alarm is
100
+ * re-armed this far ahead so a host drain brings it back within the fuse
101
+ * (default 10 s, floor 2 s, `0` = off).
102
+ */
103
+ MATRIX_KEEPALIVE_FUSE_MS?: string;
76
104
 
77
105
  // --- storage ------------------------------------------------------------
78
106
  /**
@@ -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,
@@ -200,6 +200,7 @@ import {
200
200
  } from './run-store';
201
201
  import {
202
202
  runSummaryOf,
203
+ clientSurfaceFor,
203
204
  storedRunRequest,
204
205
  type StoredRunRequest,
205
206
  } from './run-request';
@@ -3011,7 +3012,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3011
3012
  // (docs/plans/durable-runs.md). The response is one subscriber of the
3012
3013
  // run's buffer; the run itself outlives it.
3013
3014
  this.replayToRoom(req, body.message, 'user');
3014
- const agActionNames = (body.agActions ?? []).map((a) => a.name);
3015
3015
  const { live, queued } = await this.runs!.begin({
3016
3016
  runId: crypto.randomUUID(),
3017
3017
  sessionId,
@@ -3020,7 +3020,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3020
3020
  request: JSON.stringify(
3021
3021
  storedRunRequest(req, {
3022
3022
  timezone: body.timezone,
3023
- agActionNames,
3023
+ tools: body.tools,
3024
+ agActions: body.agActions,
3024
3025
  stream: true,
3025
3026
  }),
3026
3027
  ),
@@ -3138,6 +3139,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3138
3139
  message: req.message,
3139
3140
  timezone: stored.timezone ?? req.identity.timezone,
3140
3141
  model: req.model,
3142
+ tools: stored.tools,
3143
+ agActions: stored.agActions,
3141
3144
  ...(resumed ? {} : { attachments: req.attachments }),
3142
3145
  },
3143
3146
  {
@@ -3178,7 +3181,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3178
3181
  abortController: live.abort,
3179
3182
  mirror: (eventName, payload) =>
3180
3183
  this.events.emitToTaps(eventName, payload),
3181
- agActionNames: new Set(stored.agActionNames ?? []),
3184
+ agActionNames: new Set((stored.agActions ?? []).map((a) => a.name)),
3182
3185
  byoProvider,
3183
3186
  toolOutputCapChars,
3184
3187
  messageIdOf: () => lastAiMessageId(capture),
@@ -3858,20 +3861,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3858
3861
  const hostRoomTitle = opts.hooks?.getRoomTitle;
3859
3862
  const hostSafetyModel = opts.hooks?.safetyModel;
3860
3863
 
3861
- // A resumed attempt reads the client's tool surface back from the
3862
- // checkpointed state (the run row does not store the catalogue).
3863
- const priorSurface = priorState as {
3864
- browserTools?: TurnBody['tools'];
3865
- agActions?: TurnBody['agActions'];
3866
- };
3867
- const browserTools =
3868
- body.tools ??
3869
- (run.resumed ? priorSurface.browserTools : undefined) ??
3870
- [];
3871
- const agActions =
3872
- body.agActions ??
3873
- (run.resumed ? priorSurface.agActions : undefined) ??
3874
- [];
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);
3875
3867
 
3876
3868
  // Write-ahead tool marks + the resume policy (tool-marks.ts). The
3877
3869
  // effect map is filled from the build below; the closure reads it at
@@ -3965,12 +3957,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3965
3957
  ...priorState,
3966
3958
  userPreferences,
3967
3959
  ...metadataBuildState(meta, priorMeta),
3968
- // The client-declared surface of THIS request: the portal and
3969
- // AG-UI plugins turn these into tools at build time
3970
- // (`getRequestTools` reads `history.state`), so they must be here
3971
- // and not only in the graph input.
3972
- browserTools,
3973
- 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,
3974
3965
  },
3975
3966
  checkpointer: saver,
3976
3967
  abortSignal: abortController.signal,
@@ -4036,8 +4027,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4036
4027
  config: { did: req.identity.userDid },
4037
4028
  client: req.client,
4038
4029
  ...metadataGraphInput(meta, priorMeta),
4039
- browserTools,
4040
- agActions,
4030
+ ...surface.input,
4041
4031
  };
4042
4032
  // LangSmith: metadata is attached unconditionally (inert without a
4043
4033
  // tracer); the explicit tracer only when this turn is traced (global
@@ -310,6 +310,23 @@ export class MatrixGatewayDO
310
310
  env.MATRIX_RECYCLE_AFTER_SENDS ?? String(DEFAULT_RECYCLE_AFTER_SENDS),
311
311
  MATRIX_HOT_ROOMS: env.MATRIX_HOT_ROOMS,
312
312
  MATRIX_BACKFILL_MAX_EVENTS: env.MATRIX_BACKFILL_MAX_EVENTS,
313
+ // Memory guards (SDK 0.5+): byte-capped /sync and catch-up pages and
314
+ // an optional ciphertext cap on decryption. Unset = the SDK defaults.
315
+ MATRIX_SYNC_TIMELINE_LIMIT: env.MATRIX_SYNC_TIMELINE_LIMIT,
316
+ MATRIX_SYNC_BYTES_CAP: env.MATRIX_SYNC_BYTES_CAP,
317
+ MATRIX_CATCHUP_PAGE_SIZE: env.MATRIX_CATCHUP_PAGE_SIZE,
318
+ MATRIX_PAGE_BYTES_CAP: env.MATRIX_PAGE_BYTES_CAP,
319
+ MATRIX_MAX_DECRYPT_BYTES: env.MATRIX_MAX_DECRYPT_BYTES,
320
+ // Opt-in bulk restore of the key backup on a device's first start
321
+ // (SDK 0.6+); off by default — the gateway fetches room keys from the
322
+ // backup on demand, one session at a time.
323
+ MATRIX_BACKUP_BULK_RESTORE: env.MATRIX_BACKUP_BULK_RESTORE,
324
+ MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS:
325
+ env.MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS,
326
+ // Keep-alive fuse (SDK 0.7+): the alarm is re-armed a few seconds
327
+ // ahead while the object works, so a host drain brings it back within
328
+ // the fuse instead of at the next cycle.
329
+ MATRIX_KEEPALIVE_FUSE_MS: env.MATRIX_KEEPALIVE_FUSE_MS,
313
330
  LOG_LEVEL: env.LOG_LEVEL,
314
331
  });
315
332
  return {
@@ -685,14 +702,13 @@ export class MatrixGatewayDO
685
702
  }
686
703
 
687
704
  /**
688
- * Every start of this instance — first request, keep-alive alarm, RPC —
689
- * goes through here. A start that actually brought the bot up is the
705
+ * After every successful start of this instance — a request, the
706
+ * keep-alive alarm bringing it back after a reset, a device rotation
707
+ * (the SDK's `onStarted` hook, detached from the caller). That is the
690
708
  * moment to re-dispatch the turns a previous incarnation left unfinished.
691
709
  */
692
- override async ensureStarted(): Promise<StartResult> {
693
- const result = await super.ensureStarted();
694
- if (result.started) this.scheduleInboxReplay();
695
- return result;
710
+ protected override async onStarted(_result: StartResult): Promise<void> {
711
+ this.scheduleInboxReplay();
696
712
  }
697
713
 
698
714
  private scheduleInboxReplay(): void {