@ixo/oracle-runtime-workers 0.21.1 → 0.22.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
@@ -90,6 +90,7 @@ Every variable, binding and migration is explained in
90
90
  | `GET /matrix/status`, `POST /matrix/start` | Gateway status and start |
91
91
  | `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
92
92
  | `GET /runs/:runId`, `GET /sessions/:id/messages` | Re-join a durable run; one page of a transcript |
93
+ | `GET /sessions/:id/run`, `GET /runs/active` | A session's active run; every active run of the user |
93
94
  | `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
94
95
  | `POST /channels/turn` | IXO Channels ingress; refusals carry a `code` |
95
96
  | `POST /messages/:sessionId/:messageId/feedback` | Anonymous feedback on a reply (when configured) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.21.1",
3
+ "version": "0.22.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": {
package/src/core/env.ts CHANGED
@@ -63,6 +63,10 @@ export const LLM_STREAM_RETRIES_DEFAULT = 1;
63
63
  export const LLM_STREAM_RETRIES_MAX = 5;
64
64
  /** The turn's wall-clock deadline when `TURN_TIMEOUT_MS` is unset. */
65
65
  export const TURN_TIMEOUT_MS_DEFAULT = 600_000;
66
+ /** How long a frontend call waits unsent for a browser when `FRONTEND_PARK_TIMEOUT_MS` is unset. */
67
+ export const FRONTEND_PARK_TIMEOUT_MS_DEFAULT = 300_000;
68
+ /** The longest delay `setTimeout` honours (2^31 − 1 ms); a larger one fires at once. */
69
+ export const MAX_TIMER_MS = 2_147_483_647;
66
70
 
67
71
  /** An https URL, or http on localhost / 127.0.0.1 (local harness). */
68
72
  const secureUrlSchema = z.string().refine((raw) => isSecureUrl(raw), {
@@ -220,6 +224,20 @@ export const baseEnvSchema = z.object({
220
224
  .int()
221
225
  .positive()
222
226
  .default(2_000),
227
+ /**
228
+ * How long a browser tool / AG-UI call waits, unsent, for a socket of its
229
+ * session that can run it (ms) before it fails as never sent. The turn's
230
+ * own deadline (`TURN_TIMEOUT_MS`) still ends it first when sooner. At
231
+ * most `MAX_TIMER_MS` (a timer holds it). The user object reads it
232
+ * through this field (`frontendParkTimeoutMs`,
233
+ * src/realtime/realtime-endpoint.ts).
234
+ */
235
+ FRONTEND_PARK_TIMEOUT_MS: z.coerce
236
+ .number()
237
+ .int()
238
+ .positive()
239
+ .max(MAX_TIMER_MS)
240
+ .default(FRONTEND_PARK_TIMEOUT_MS_DEFAULT),
223
241
 
224
242
  // --- decisions ----------------------------------------------------------
225
243
  // Bounded semantic Decision provider (`DECISION_PROVIDER`, `DECISION_MODEL`,
package/src/core/index.ts CHANGED
@@ -802,5 +802,11 @@ export {
802
802
  export type { TurnLimits, TurnUsage } from './turn-budget';
803
803
  export { budgetedLlm } from './budgeted-llm';
804
804
  export type { BudgetedLlm, BudgetedLlmOptions } from './budgeted-llm';
805
- export { ToolScheduler } from './tool-scheduler';
806
- export type { ToolLane, ToolSchedulerOptions } from './tool-scheduler';
805
+ export { sessionLaneKey, ToolScheduler } from './tool-scheduler';
806
+ export type {
807
+ LaneOccupancy,
808
+ SessionToolLane,
809
+ ToolLane,
810
+ ToolSchedulerOptions,
811
+ ToolSchedulerSnapshot,
812
+ } from './tool-scheduler';
@@ -218,6 +218,14 @@ export interface MainAgentBuildResult {
218
218
  * turn, `write` never does). Declared by the plugin, else by name.
219
219
  */
220
220
  toolEffects: Map<string, 'read' | 'write'>;
221
+ /**
222
+ * The plugin tools the turn can run that act in the user's browser
223
+ * (`PluginTool.surface`), a sub-agent's inner tools included: scheduled
224
+ * in their session's `frontend` lane. A tool absent from the map (meta,
225
+ * domain and dispatch tools, every server-side tool, and a name two tools
226
+ * disagree on) is scheduled by its effect.
227
+ */
228
+ toolSurfaces: Map<string, 'frontend'>;
221
229
  /** Names of the sub-agent dispatch tools (`call_*_agent`): scheduled in their own lane. */
222
230
  subAgentToolNames: ReadonlySet<string>;
223
231
  /**
@@ -235,6 +235,7 @@ export async function createMainAgent(
235
235
  systemPrompt,
236
236
  boundToolNames: [],
237
237
  toolEffects: new Map(),
238
+ toolSurfaces: new Map(),
238
239
  subAgentToolNames: new Set(),
239
240
  context: { user: requestCtx.user, session: requestCtx.session },
240
241
  };
@@ -556,6 +557,28 @@ export async function createMainAgent(
556
557
  for (const { tool } of allTools)
557
558
  toolEffects.set(tool.name, toolEffectOf(tool));
558
559
  for (const t of subAgentTools) toolEffects.set(t.name, 'write');
560
+ // The plugin tools that act in the user's browser (`PluginTool.surface`),
561
+ // a sub-agent's inner tools included (the execution middleware schedules
562
+ // them too, by name): scheduled in their session's frontend lane. A name
563
+ // is listed only when every tool of that name declares it; meta, domain
564
+ // and dispatch tools never are.
565
+ const surfaceOf = new Map<string, PluginTool['surface']>();
566
+ const declareSurface = (tool: PluginTool): void => {
567
+ const known = surfaceOf.get(tool.name);
568
+ surfaceOf.set(
569
+ tool.name,
570
+ surfaceOf.has(tool.name) && known !== tool.surface
571
+ ? undefined
572
+ : tool.surface,
573
+ );
574
+ };
575
+ for (const { tool } of [...turnTools, ...allTools]) declareSurface(tool);
576
+ for (const { subAgent } of subAgentEntries)
577
+ for (const tool of Array.isArray(subAgent.tools) ? subAgent.tools : [])
578
+ declareSurface(tool);
579
+ const toolSurfaces = new Map<string, 'frontend'>();
580
+ for (const [name, surface] of surfaceOf)
581
+ if (surface === 'frontend') toolSurfaces.set(name, surface);
559
582
  // Tools whose identical call is a new action (a browser step), which the
560
583
  // repetition guard caps like a read whatever their effect.
561
584
  const repeatableToolNames = new Set(
@@ -909,6 +932,7 @@ export async function createMainAgent(
909
932
  systemPrompt,
910
933
  boundToolNames: tools.map((t) => t.name),
911
934
  toolEffects,
935
+ toolSurfaces,
912
936
  subAgentToolNames: new Set(subAgentTools.map((t) => t.name)),
913
937
  context: runConfig.context,
914
938
  ...(domain
@@ -3,9 +3,11 @@
3
3
  * run: charged to the turn's budget, scheduled in its lane, and — for a
4
4
  * write — claimed in the run ledger for as long as its outcome is unknown.
5
5
  *
6
- * The claim is a fingerprint of the tool name and its canonical arguments,
7
- * recorded before the write starts. A returned result (success or a
8
- * reported failure) releases it; a failure that says nothing about whether
6
+ * The claim is a fingerprint of the tool name and its canonical arguments
7
+ * (and, for a frontend write, its session), recorded before the write
8
+ * starts; a write is claimed by its effect, whichever lane it runs in. A
9
+ * returned result (success or a reported failure) releases it, and so does
10
+ * a frontend call that was never sent; a failure that says nothing about whether
9
11
  * the write happened — abort, deadline, a dropped connection, a 5xx —
10
12
  * leaves it in place, whether the tool threw it or caught it and returned
11
13
  * it as an error result (many plugin tools and adapters never throw to the
@@ -30,6 +32,7 @@ import { type AgentMiddleware, createMiddleware } from 'langchain';
30
32
  import type { Logger } from '../../plugin-api/types';
31
33
  import type { ToolLane, ToolScheduler } from '../tool-scheduler';
32
34
  import { findProviderStall } from '../../llm/stream-liveness';
35
+ import { isFrontendCallNotSent } from '../../realtime/frontend-call-registry';
33
36
  import { isHarnessLimitError, type TurnBudget } from '../turn-budget';
34
37
  import { NOOP_LOGGER } from '../utils';
35
38
 
@@ -51,8 +54,18 @@ export interface WriteClaimStore {
51
54
  export interface ToolExecutionOptions {
52
55
  budget: TurnBudget;
53
56
  scheduler: ToolScheduler;
54
- /** Lane of a tool by name (`read`, `write`, or `subagent` for a dispatch). */
57
+ /**
58
+ * Lane of a tool by name: `read` / `write` (user-wide, by effect),
59
+ * `frontend` for a tool acting in the user's browser
60
+ * (`PluginTool.surface`), `subagent` for a dispatch. The two per-session
61
+ * lanes are keyed by `sessionId` below.
62
+ */
55
63
  laneOf: (toolName: string) => ToolLane;
64
+ /**
65
+ * Effect of a tool by name. A `write` is claimed in the ledger whatever
66
+ * its lane (a frontend write too); a sub-agent dispatch never is.
67
+ */
68
+ effectOf: (toolName: string) => 'read' | 'write';
56
69
  runId: string;
57
70
  sessionId: string;
58
71
  /** Omitted → writes are scheduled and charged but not claimed (stateless builds, tests). */
@@ -77,12 +90,21 @@ export function canonicalArguments(value: unknown): string {
77
90
  return JSON.stringify(value) ?? 'null';
78
91
  }
79
92
 
80
- /** SHA-256 of `name:args`, hex — the write's fingerprint in the ledger. */
93
+ /**
94
+ * SHA-256 of `name:args`, hex — the write's fingerprint in the ledger. With
95
+ * `sessionId` (a frontend write: it acts in that session's own browser, so
96
+ * the same call in another chat is a different action) the session is part
97
+ * of it: `name@<sessionId>:args`.
98
+ */
81
99
  export async function operationKey(
82
100
  name: string,
83
101
  args: unknown,
102
+ sessionId?: string,
84
103
  ): Promise<string> {
85
- const bytes = new TextEncoder().encode(`${name}:${canonicalArguments(args)}`);
104
+ const scope = sessionId === undefined ? '' : `@${sessionId}`;
105
+ const bytes = new TextEncoder().encode(
106
+ `${name}${scope}:${canonicalArguments(args)}`,
107
+ );
86
108
  const digest = await crypto.subtle.digest('SHA-256', bytes);
87
109
  return Array.from(new Uint8Array(digest), (b) =>
88
110
  b.toString(16).padStart(2, '0'),
@@ -141,12 +163,16 @@ const isServerStatus = (status: unknown): boolean =>
141
163
  /**
142
164
  * `true` when a thrown error says nothing about whether the tool's side
143
165
  * effect happened: the turn was aborted or hit its deadline, the transport
144
- * failed, or the service answered with a server error.
166
+ * failed, or the service answered with a server error. A frontend call that
167
+ * was never sent (`FrontendCallNotSentError`: parked until the turn aborted
168
+ * or its park deadline passed) is a known outcome even on an aborted turn:
169
+ * no browser saw it.
145
170
  */
146
171
  export function isUncertainOutcome(
147
172
  error: unknown,
148
173
  signal?: AbortSignal,
149
174
  ): boolean {
175
+ if (isFrontendCallNotSent(error)) return false;
150
176
  if (signal?.aborted) return true;
151
177
  if (isHarnessLimitError(error)) return true;
152
178
  // A tool whose own model call stalled (stream-liveness.ts) stopped at an
@@ -267,6 +293,10 @@ export function createToolExecutionMiddleware(
267
293
  const { toolCall } = request;
268
294
  const toolName = toolCall.name;
269
295
  const lane = options.laneOf(toolName);
296
+ // Claimed by effect, whatever the lane: a frontend write too. A
297
+ // dispatch is never fingerprinted (its inner writes are, by name).
298
+ const claimed =
299
+ lane !== 'subagent' && options.effectOf(toolName) === 'write';
270
300
  const callId = toolCall.id ?? '';
271
301
  const attempt = (attempts.get(callId) ?? 0) + 1;
272
302
  attempts.set(callId, attempt);
@@ -290,51 +320,62 @@ export function createToolExecutionMiddleware(
290
320
  );
291
321
  return output;
292
322
  };
293
- return scheduler.run(lane, options.signal, async () => {
294
- budget.reserveTool(options.signal);
295
- if (lane !== 'write' || !claims) return run();
296
- const fingerprint = await operationKey(toolName, toolCall.args);
297
- const claim = await claims.claimWrite({
298
- fingerprint,
299
- toolName,
300
- runId: options.runId,
301
- sessionId: options.sessionId,
302
- });
303
- if (claim.status === 'blocked') {
304
- logger.warn(
305
- `[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
323
+ return scheduler.run(
324
+ lane,
325
+ options.signal,
326
+ async () => {
327
+ budget.reserveTool(options.signal);
328
+ if (!claimed || !claims) return run();
329
+ // The ledger is user-wide; a frontend write's fingerprint names its
330
+ // session, so the same browser action in two chats never collides.
331
+ const fingerprint = await operationKey(
332
+ toolName,
333
+ toolCall.args,
334
+ lane === 'frontend' ? options.sessionId : undefined,
306
335
  );
307
- return new ToolMessage({
308
- tool_call_id: toolCall.id ?? '',
309
- name: toolName,
310
- content: uncertainWriteToolResult(toolName),
311
- status: 'error',
336
+ const claim = await claims.claimWrite({
337
+ fingerprint,
338
+ toolName,
339
+ runId: options.runId,
340
+ sessionId: options.sessionId,
312
341
  });
313
- }
314
- try {
315
- const output = await run();
316
- const uncertain = uncertainResultReason(output);
317
- if (uncertain !== null) {
342
+ if (claim.status === 'blocked') {
318
343
  logger.warn(
319
- `[tool-execution] ${toolName}: returned a failure that leaves its outcome unknown (${uncertain}); its claim stays in the ledger`,
344
+ `[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
320
345
  );
321
- } else {
322
- await claims.releaseWrite(fingerprint, options.runId);
346
+ return new ToolMessage({
347
+ tool_call_id: toolCall.id ?? '',
348
+ name: toolName,
349
+ content: uncertainWriteToolResult(toolName),
350
+ status: 'error',
351
+ });
323
352
  }
324
- return output;
325
- } catch (error) {
326
- if (isUncertainOutcome(error, options.signal)) {
327
- logger.warn(
328
- `[tool-execution] ${toolName}: outcome unknown (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); its claim stays in the ledger`,
329
- );
330
- } else {
331
- await claims
332
- .releaseWrite(fingerprint, options.runId)
333
- .catch(() => undefined);
353
+ try {
354
+ const output = await run();
355
+ const uncertain = uncertainResultReason(output);
356
+ if (uncertain !== null) {
357
+ logger.warn(
358
+ `[tool-execution] ${toolName}: returned a failure that leaves its outcome unknown (${uncertain}); its claim stays in the ledger`,
359
+ );
360
+ } else {
361
+ await claims.releaseWrite(fingerprint, options.runId);
362
+ }
363
+ return output;
364
+ } catch (error) {
365
+ if (isUncertainOutcome(error, options.signal)) {
366
+ logger.warn(
367
+ `[tool-execution] ${toolName}: outcome unknown (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); its claim stays in the ledger`,
368
+ );
369
+ } else {
370
+ await claims
371
+ .releaseWrite(fingerprint, options.runId)
372
+ .catch(() => undefined);
373
+ }
374
+ throw error;
334
375
  }
335
- throw error;
336
- }
337
- });
376
+ },
377
+ options.sessionId,
378
+ );
338
379
  },
339
380
  });
340
381
  }
@@ -12,6 +12,11 @@
12
12
  * gets a synthetic result saying the outcome is unknown, so it can tell
13
13
  * the user or verify with a read;
14
14
  * - a started, unfinished **read** call runs again (it has no effect);
15
+ * - a started, unfinished **frontend** call (`PluginTool.surface`) that
16
+ * was never sent — it was parked waiting for a browser socket when the
17
+ * object restarted, so no browser saw it — runs again whatever its
18
+ * effect. Once sent (`markSent`, recorded by the frontend bridge when a
19
+ * socket receives it) it follows the read / write rules above;
15
20
  * - a call that already finished is not executed again either (its result
16
21
  * is in the checkpoint's pending writes; reaching here means LangGraph
17
22
  * could not replay it) — writes get the unknown-outcome result, reads run.
@@ -41,6 +46,8 @@ export interface ToolMarkStore {
41
46
  effect: ToolEffect;
42
47
  }): Promise<ToolMark | undefined>;
43
48
  bumpMark(runId: string, toolCallId: string): Promise<void>;
49
+ /** The call was handed to its executor outside the object (a browser socket). */
50
+ markSent(runId: string, toolCallId: string): Promise<void>;
44
51
  finishMark(
45
52
  runId: string,
46
53
  toolCallId: string,
@@ -53,6 +60,12 @@ export interface ToolMarksOptions {
53
60
  store: ToolMarkStore;
54
61
  /** The effect of a tool by name; unknown tools are writes (see `toolEffectOf`). */
55
62
  effectOf: (toolName: string) => ToolEffect;
63
+ /**
64
+ * `frontend` for a tool that runs in the user's browser
65
+ * (`PluginTool.surface`); such a call that was never sent before a restart
66
+ * runs again whatever its effect. Omitted → no tool is a frontend tool.
67
+ */
68
+ surfaceOf?: (toolName: string) => 'frontend' | undefined;
56
69
  /**
57
70
  * The reply text the user already received before the run was cut off.
58
71
  * Set only on a resumed attempt; consumed by the first model call.
@@ -84,18 +97,32 @@ export function continuationNote(partialText: string): string {
84
97
  );
85
98
  }
86
99
 
87
- const READ_PREFIXES = [
88
- 'list_',
89
- 'get_',
90
- 'search_',
91
- 'read_',
92
- 'preview_',
93
- 'describe_',
94
- 'check_',
95
- 'explain_',
96
- 'validate_',
97
- 'view_',
100
+ /**
101
+ * Verbs a read-only tool name starts with, in either convention: snake case
102
+ * (`get_entity`) or camel case (`getEntity`, as browser tools are named).
103
+ */
104
+ const READ_VERBS = [
105
+ 'list',
106
+ 'get',
107
+ 'find',
108
+ 'search',
109
+ 'read',
110
+ 'preview',
111
+ 'describe',
112
+ 'check',
113
+ 'explain',
114
+ 'validate',
115
+ 'view',
98
116
  ];
117
+
118
+ function startsWithReadVerb(name: string): boolean {
119
+ for (const verb of READ_VERBS) {
120
+ if (!name.startsWith(verb)) continue;
121
+ const next = name.charAt(verb.length);
122
+ if (next === '_' || (next >= 'A' && next <= 'Z')) return true;
123
+ }
124
+ return false;
125
+ }
99
126
  const READ_NAMES = new Set([
100
127
  'requirements',
101
128
  'flow_status',
@@ -125,9 +152,7 @@ export function toolEffectOf(tool: {
125
152
  ? tool.name.slice(tool.name.lastIndexOf('__') + 2)
126
153
  : tool.name;
127
154
  if (READ_NAMES.has(bare)) return 'read';
128
- for (const prefix of READ_PREFIXES)
129
- if (bare.startsWith(prefix)) return 'read';
130
- return 'write';
155
+ return startsWithReadVerb(bare) ? 'read' : 'write';
131
156
  }
132
157
 
133
158
  /** Whether a tool call's output is an error result (`ToolMessage` status `error`). */
@@ -170,7 +195,16 @@ export function createToolMarksMiddleware(
170
195
  effect,
171
196
  });
172
197
  if (existing) {
173
- if (effect === 'write') {
198
+ const neverSent =
199
+ !existing.doneAt &&
200
+ existing.sentAt === null &&
201
+ options.surfaceOf?.(toolName) === 'frontend';
202
+ if (neverSent) {
203
+ logger.log(
204
+ `[tool-marks] ${toolName} (${toolCallId}) was parked and never sent to a browser in an earlier attempt of run ${options.runId}; running it again`,
205
+ );
206
+ await options.store.bumpMark(options.runId, toolCallId);
207
+ } else if (effect === 'write') {
174
208
  logger.warn(
175
209
  `[tool-marks] ${toolName} (${toolCallId}) was ${existing.doneAt ? 'already executed' : 'interrupted mid-execution'} in an earlier attempt of run ${options.runId}; not run again`,
176
210
  );
@@ -185,11 +219,12 @@ export function createToolMarksMiddleware(
185
219
  content: unknownOutcomeToolResult(toolName),
186
220
  status: 'error',
187
221
  });
222
+ } else {
223
+ logger.log(
224
+ `[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
225
+ );
226
+ await options.store.bumpMark(options.runId, toolCallId);
188
227
  }
189
- logger.log(
190
- `[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
191
- );
192
- await options.store.bumpMark(options.runId, toolCallId);
193
228
  }
194
229
  try {
195
230
  const output = await handler(request);
@@ -202,6 +202,12 @@ export interface AmbientServices {
202
202
  onTurnEnd?: (dispose: () => void | Promise<void>) => void;
203
203
  /** Host keep-alive for un-awaited work (`ctx.waitUntil` on Workers). */
204
204
  background?: (work: Promise<unknown>) => void;
205
+ /**
206
+ * Records that the tool call `toolCallId` of this turn left the object
207
+ * (`ctx.markDispatched`). Set per turn by a host that keeps write-ahead
208
+ * tool marks.
209
+ */
210
+ markDispatched?: (toolCallId: string) => void;
205
211
  }
206
212
 
207
213
  // ── Scoped emitter ──────────────────────────────────────────────────────────
@@ -434,6 +440,8 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
434
440
  };
435
441
 
436
442
  const delegation = user.ucanDelegation;
443
+ const toolCallId = runConfig.toolCall?.id;
444
+ const { markDispatched } = ambient;
437
445
 
438
446
  const ctx: RuntimeContext<TConfig> = {
439
447
  user,
@@ -527,7 +535,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
527
535
  ...(ambient.onTurnEnd ? { onTurnEnd: ambient.onTurnEnd } : {}),
528
536
  ...(ambient.background ? { background: ambient.background } : {}),
529
537
  shared: EMPTY_SHARED,
530
- ...(runConfig.toolCall?.id ? { toolCallId: runConfig.toolCall.id } : {}),
538
+ ...(toolCallId ? { toolCallId } : {}),
539
+ ...(toolCallId && markDispatched
540
+ ? { markDispatched: () => markDispatched(toolCallId) }
541
+ : {}),
531
542
  };
532
543
  if (sharedFactory) {
533
544
  ctx.shared = sharedFactory(ctx);
@@ -730,5 +741,8 @@ export function createNoopAmbient(
730
741
  logger: overrides.logger ?? NOOP_LOGGER,
731
742
  ...(overrides.decisions ? { decisions: overrides.decisions } : {}),
732
743
  ...(overrides.kv ? { kv: overrides.kv } : {}),
744
+ ...(overrides.markDispatched
745
+ ? { markDispatched: overrides.markDispatched }
746
+ : {}),
733
747
  };
734
748
  }
package/src/core/state.ts CHANGED
@@ -21,6 +21,8 @@ export interface BrowserToolCall {
21
21
  name: string;
22
22
  description: string;
23
23
  schema: Record<string, unknown>;
24
+ /** The tool's effect as the client declares it (`'read'` / `'write'`). */
25
+ effect?: 'read' | 'write';
24
26
  }
25
27
 
26
28
  /** AG-UI action descriptor injected per request from the client. */