@ixo/oracle-runtime-workers 0.21.2 → 0.23.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.
Files changed (59) hide show
  1. package/README.md +16 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +107 -5
  20. package/src/core/index.ts +8 -2
  21. package/src/core/main-agent-types.ts +16 -0
  22. package/src/core/main-agent.ts +26 -0
  23. package/src/core/middlewares/tool-execution.ts +87 -46
  24. package/src/core/middlewares/tool-marks.ts +54 -19
  25. package/src/core/runtime-context.ts +27 -1
  26. package/src/core/state.ts +2 -0
  27. package/src/core/tool-scheduler.ts +129 -24
  28. package/src/delivery/plan.ts +43 -2
  29. package/src/delivery/schema.ts +25 -1
  30. package/src/delivery/types.ts +13 -2
  31. package/src/do/active-runs.ts +171 -0
  32. package/src/do/ambient.ts +6 -0
  33. package/src/do/contracts.ts +86 -3
  34. package/src/do/run-coordinator.ts +30 -0
  35. package/src/do/run-request.ts +15 -2
  36. package/src/do/run-store-test-do.ts +10 -0
  37. package/src/do/run-store.ts +66 -13
  38. package/src/do/turn-body.ts +13 -1
  39. package/src/do/user-oracle-do.ts +1420 -17
  40. package/src/hub-actions/client.ts +163 -0
  41. package/src/hub-actions/request.ts +110 -0
  42. package/src/hub-actions/store.ts +485 -0
  43. package/src/matrix/gateway-do.ts +176 -0
  44. package/src/matrix/reply-parts.ts +8 -2
  45. package/src/plugin-api/tool-helper.ts +6 -0
  46. package/src/plugin-api/types.ts +117 -7
  47. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  48. package/src/plugins/action-proposals/evidence.ts +67 -0
  49. package/src/plugins/agui/agui.plugin.ts +7 -5
  50. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  51. package/src/plugins/index.ts +6 -0
  52. package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
  53. package/src/plugins/portal/declared-tools.ts +12 -0
  54. package/src/plugins/portal/portal.plugin.ts +22 -8
  55. package/src/realtime/frontend-call-registry.ts +147 -27
  56. package/src/realtime/realtime-endpoint.ts +335 -41
  57. package/src/realtime/session-socket-hub.ts +90 -19
  58. package/src/shell/app.ts +218 -2
  59. package/src/sqlite/sessions-store.ts +22 -0
@@ -42,6 +42,14 @@ export const mainAgentRequestContextSchema = z.object({
42
42
  id: z.string(),
43
43
  client: z.enum(['portal', 'matrix', 'slack', 'channel']),
44
44
  requestId: z.string(),
45
+ runId: z.string().optional(),
46
+ channel: z
47
+ .object({
48
+ provider: z.string(),
49
+ bindingId: z.string(),
50
+ bindingRevision: z.number().int().positive(),
51
+ })
52
+ .optional(),
45
53
  wsId: z.string().optional(),
46
54
  roomId: z.string().optional(),
47
55
  }),
@@ -218,6 +226,14 @@ export interface MainAgentBuildResult {
218
226
  * turn, `write` never does). Declared by the plugin, else by name.
219
227
  */
220
228
  toolEffects: Map<string, 'read' | 'write'>;
229
+ /**
230
+ * The plugin tools the turn can run that act in the user's browser
231
+ * (`PluginTool.surface`), a sub-agent's inner tools included: scheduled
232
+ * in their session's `frontend` lane. A tool absent from the map (meta,
233
+ * domain and dispatch tools, every server-side tool, and a name two tools
234
+ * disagree on) is scheduled by its effect.
235
+ */
236
+ toolSurfaces: Map<string, 'frontend'>;
221
237
  /** Names of the sub-agent dispatch tools (`call_*_agent`): scheduled in their own lane. */
222
238
  subAgentToolNames: ReadonlySet<string>;
223
239
  /**
@@ -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
  };
@@ -299,6 +300,8 @@ export async function createMainAgent(
299
300
  id: requestCtx.session.id,
300
301
  client: requestCtx.session.client,
301
302
  requestId: requestCtx.session.requestId,
303
+ runId: requestCtx.session.runId,
304
+ channel: requestCtx.session.channel,
302
305
  wsId: requestCtx.session.wsId,
303
306
  roomId: requestCtx.session.roomId,
304
307
  ...(surface ? { surface } : {}),
@@ -556,6 +559,28 @@ export async function createMainAgent(
556
559
  for (const { tool } of allTools)
557
560
  toolEffects.set(tool.name, toolEffectOf(tool));
558
561
  for (const t of subAgentTools) toolEffects.set(t.name, 'write');
562
+ // The plugin tools that act in the user's browser (`PluginTool.surface`),
563
+ // a sub-agent's inner tools included (the execution middleware schedules
564
+ // them too, by name): scheduled in their session's frontend lane. A name
565
+ // is listed only when every tool of that name declares it; meta, domain
566
+ // and dispatch tools never are.
567
+ const surfaceOf = new Map<string, PluginTool['surface']>();
568
+ const declareSurface = (tool: PluginTool): void => {
569
+ const known = surfaceOf.get(tool.name);
570
+ surfaceOf.set(
571
+ tool.name,
572
+ surfaceOf.has(tool.name) && known !== tool.surface
573
+ ? undefined
574
+ : tool.surface,
575
+ );
576
+ };
577
+ for (const { tool } of [...turnTools, ...allTools]) declareSurface(tool);
578
+ for (const { subAgent } of subAgentEntries)
579
+ for (const tool of Array.isArray(subAgent.tools) ? subAgent.tools : [])
580
+ declareSurface(tool);
581
+ const toolSurfaces = new Map<string, 'frontend'>();
582
+ for (const [name, surface] of surfaceOf)
583
+ if (surface === 'frontend') toolSurfaces.set(name, surface);
559
584
  // Tools whose identical call is a new action (a browser step), which the
560
585
  // repetition guard caps like a read whatever their effect.
561
586
  const repeatableToolNames = new Set(
@@ -909,6 +934,7 @@ export async function createMainAgent(
909
934
  systemPrompt,
910
935
  boundToolNames: tools.map((t) => t.name),
911
936
  toolEffects,
937
+ toolSurfaces,
912
938
  subAgentToolNames: new Set(subAgentTools.map((t) => t.name)),
913
939
  context: runConfig.context,
914
940
  ...(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);
@@ -189,6 +189,8 @@ export interface AmbientServices {
189
189
  * `DecisionProviderUnavailableError`.
190
190
  */
191
191
  decisions?: DecisionEvaluator;
192
+ actionProposals?: RuntimeContext['actionProposals'];
193
+ hubActions?: RuntimeContext['hubActions'];
192
194
  /** Host task scheduler for the current user, when the host provides one. */
193
195
  tasks?: import('../plugin-api/types').OracleTasksSurface;
194
196
  /** Host user-preferences store for the current user, when provided. */
@@ -202,6 +204,12 @@ export interface AmbientServices {
202
204
  onTurnEnd?: (dispose: () => void | Promise<void>) => void;
203
205
  /** Host keep-alive for un-awaited work (`ctx.waitUntil` on Workers). */
204
206
  background?: (work: Promise<unknown>) => void;
207
+ /**
208
+ * Records that the tool call `toolCallId` of this turn left the object
209
+ * (`ctx.markDispatched`). Set per turn by a host that keeps write-ahead
210
+ * tool marks.
211
+ */
212
+ markDispatched?: (toolCallId: string) => void;
205
213
  }
206
214
 
207
215
  // ── Scoped emitter ──────────────────────────────────────────────────────────
@@ -317,6 +325,12 @@ export interface RuntimeSessionContext {
317
325
  client: 'portal' | 'matrix' | 'slack' | 'channel';
318
326
  wsId?: string;
319
327
  requestId: string;
328
+ runId?: string;
329
+ channel?: {
330
+ provider: string;
331
+ bindingId: string;
332
+ bindingRevision: number;
333
+ };
320
334
  roomId?: string;
321
335
  surface?: SessionSurface;
322
336
  }
@@ -434,6 +448,8 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
434
448
  };
435
449
 
436
450
  const delegation = user.ucanDelegation;
451
+ const toolCallId = runConfig.toolCall?.id;
452
+ const { markDispatched } = ambient;
437
453
 
438
454
  const ctx: RuntimeContext<TConfig> = {
439
455
  user,
@@ -520,6 +536,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
520
536
  logger: ambient.logger,
521
537
  abortSignal,
522
538
  ...(ambient.tasks ? { tasks: ambient.tasks } : {}),
539
+ ...(ambient.actionProposals
540
+ ? { actionProposals: ambient.actionProposals }
541
+ : {}),
542
+ ...(ambient.hubActions ? { hubActions: ambient.hubActions } : {}),
523
543
  ...(ambient.preferences ? { preferences: ambient.preferences } : {}),
524
544
  ...(ambient.kv ? { kv: ambient.kv } : {}),
525
545
  ...(ambient.frontend ? { frontend: ambient.frontend } : {}),
@@ -527,7 +547,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
527
547
  ...(ambient.onTurnEnd ? { onTurnEnd: ambient.onTurnEnd } : {}),
528
548
  ...(ambient.background ? { background: ambient.background } : {}),
529
549
  shared: EMPTY_SHARED,
530
- ...(runConfig.toolCall?.id ? { toolCallId: runConfig.toolCall.id } : {}),
550
+ ...(toolCallId ? { toolCallId } : {}),
551
+ ...(toolCallId && markDispatched
552
+ ? { markDispatched: () => markDispatched(toolCallId) }
553
+ : {}),
531
554
  };
532
555
  if (sharedFactory) {
533
556
  ctx.shared = sharedFactory(ctx);
@@ -730,5 +753,8 @@ export function createNoopAmbient(
730
753
  logger: overrides.logger ?? NOOP_LOGGER,
731
754
  ...(overrides.decisions ? { decisions: overrides.decisions } : {}),
732
755
  ...(overrides.kv ? { kv: overrides.kv } : {}),
756
+ ...(overrides.markDispatched
757
+ ? { markDispatched: overrides.markDispatched }
758
+ : {}),
733
759
  };
734
760
  }
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. */