@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
@@ -0,0 +1,67 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+ import {
3
+ stripTurnTimeNote,
4
+ TURN_TIME_NOTE_KWARG,
5
+ } from '../../core/turn-time-note';
6
+ import { contentToText } from '../../do/transcript';
7
+
8
+ /** How many of the user's own messages count as evidence, and how much of each. */
9
+ export const EVIDENCE_MESSAGES = 8;
10
+ export const EVIDENCE_MESSAGE_CHARS = 4000;
11
+
12
+ /**
13
+ * Words that make the latest message a question, a refusal or a thought
14
+ * experiment rather than a request.
15
+ */
16
+ export const NEGATION =
17
+ /\b(don't|do not|never|not yet|what if|suppose|hypothetically)\b/i;
18
+
19
+ /**
20
+ * The user's own words from the last few turns. The runtime's time note
21
+ * (`Current time: … 14:05 …`) rides on every message the user sent; it is
22
+ * stripped here so its digits can never stand in for an amount.
23
+ */
24
+ export function userEvidence(messages: readonly BaseMessage[]): string[] {
25
+ return messages
26
+ .filter((m) => m.type === 'human')
27
+ .slice(-EVIDENCE_MESSAGES)
28
+ .map((m) =>
29
+ contentToText(
30
+ stripTurnTimeNote(
31
+ m.content,
32
+ m.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
33
+ ),
34
+ ).slice(0, EVIDENCE_MESSAGE_CHARS),
35
+ );
36
+ }
37
+
38
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
39
+
40
+ /**
41
+ * `value` written as a whole token: not inside a longer identifier, number
42
+ * or id (`CARBON-1` is not evidence for `CARBON-10`, nor the reverse).
43
+ */
44
+ export function mentionsToken(text: string, value: string): boolean {
45
+ return new RegExp(`(?<![\\w.-])${escape(value)}(?![\\w.-])`).test(text);
46
+ }
47
+
48
+ /**
49
+ * The amount with one of its units right beside it (`1.5 IXO`, `1.5IXO`,
50
+ * `1500000 uixo`, `10 credits`, `10 CARBON-1`). A bare number is not
51
+ * evidence: `14` in a clock time, `10` inside `CARBON-10` or `10.5`, `5 uixo`
52
+ * for a request of 5 IXO, and the tail of a grouped or divided number
53
+ * (`1 500 IXO`, `1'500 IXO`, `1/5 IXO`, `1:5 IXO`) all fail here.
54
+ */
55
+ export function mentionsAmount(
56
+ text: string,
57
+ amount: string,
58
+ units: readonly string[],
59
+ ): boolean {
60
+ const unit = units.map(escape).join('|');
61
+ // Not glued to a word character, and not preceded by a digit or a number
62
+ // separator with any run of whitespace in between (`1 500`, `1 ,500`).
63
+ return new RegExp(
64
+ `(?<!\\w)(?<![\\d.,'’/:-]\\s*)${escape(amount)}\\s*(?:${unit})(?![\\w-])`,
65
+ 'i',
66
+ ).test(text);
67
+ }
@@ -98,11 +98,8 @@ export function buildActionTool(action: AgAction): PluginTool | null {
98
98
  `AG-UI action ${action.name} needs the realtime channel, which this host does not provide.`,
99
99
  );
100
100
  }
101
- if (!frontend.hasClient(sessionId)) {
102
- throw new Error(
103
- `No browser is connected to session ${sessionId}, so AG-UI action ${action.name} cannot run — the client must open the realtime (socket.io) channel for this session first.`,
104
- );
105
- }
101
+ // No browser of the session connected yet: the bridge parks the call
102
+ // until one joins (or its park deadline / the turn's abort ends it).
106
103
  // The bridge makes every invocation unique (`ag_<requestId>:<uuid>`).
107
104
  let invocationId: string | undefined;
108
105
  const result = await frontend.callAgAction({
@@ -112,6 +109,7 @@ export function buildActionTool(action: AgAction): PluginTool | null {
112
109
  args: parseArgs(input),
113
110
  timeoutMs: AG_ACTION_TIMEOUT_MS,
114
111
  signal: ctx.abortSignal,
112
+ ...(ctx.markDispatched ? { onDispatched: ctx.markDispatched } : {}),
115
113
  onInvocation: (id) => {
116
114
  invocationId = id;
117
115
  },
@@ -125,6 +123,10 @@ export function buildActionTool(action: AgAction): PluginTool | null {
125
123
  schema,
126
124
  // A UI action on the user's screen: running it again is a new action.
127
125
  repeatable: true,
126
+ // It runs on this session's AG-UI surface in the browser: scheduled in
127
+ // the session's frontend lane, so a parked call holds no slot another
128
+ // chat needs.
129
+ surface: 'frontend',
128
130
  },
129
131
  );
130
132
  }
@@ -0,0 +1,244 @@
1
+ import {
2
+ operationJsonSchema,
3
+ parseOperation,
4
+ type ActionReferenceV1,
5
+ } from '@ixo/action-protocol';
6
+ import { z } from 'zod';
7
+ import { OraclePlugin } from '../../plugin-api/oracle-plugin';
8
+ import type {
9
+ PluginManifest,
10
+ PluginTool,
11
+ RuntimeContext,
12
+ } from '../../plugin-api/types';
13
+
14
+ const configSchema = z.object({
15
+ HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
16
+ });
17
+
18
+ const operationSchema = z.fromJSONSchema({
19
+ ...operationJsonSchema,
20
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
21
+ });
22
+ const requestSchema = z.strictObject({ operation: operationSchema });
23
+ const statusSchema = z.strictObject({
24
+ actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
25
+ });
26
+ const discoverSchema = z.strictObject({});
27
+
28
+ export type HubActionRequestResult =
29
+ | { status: 'prepared'; actionReference: ActionReferenceV1; summary: string }
30
+ | {
31
+ status: 'unavailable' | 'expired';
32
+ reason: string;
33
+ /** The earlier action that blocks this one (an unconfirmed email). */
34
+ priorActionId?: string;
35
+ };
36
+
37
+ /**
38
+ * The idempotency key the Hub sees: a fixed-length digest of the run and
39
+ * tool call (the Hub allows 120 characters; a run id alone is 72).
40
+ */
41
+ async function hubRequestKey(runId: string, toolCallId: string) {
42
+ const bytes = new Uint8Array(
43
+ await crypto.subtle.digest(
44
+ 'SHA-256',
45
+ new TextEncoder().encode(`${runId}:${toolCallId}`),
46
+ ),
47
+ );
48
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
49
+ }
50
+
51
+ async function requestHubAction(
52
+ input: unknown,
53
+ ctx: RuntimeContext,
54
+ ): Promise<HubActionRequestResult> {
55
+ const parsed = requestSchema.safeParse(input);
56
+ if (!parsed.success)
57
+ return { status: 'unavailable', reason: 'An operation is required.' };
58
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
59
+ return {
60
+ status: 'unavailable',
61
+ reason: 'Hub actions are not enabled for this oracle.',
62
+ };
63
+ const channel = ctx.session.channel;
64
+ if (
65
+ ctx.session.client !== 'channel' ||
66
+ !channel ||
67
+ !ctx.session.runId ||
68
+ !ctx.toolCallId
69
+ )
70
+ return {
71
+ status: 'unavailable',
72
+ reason: 'A verified channel turn is required.',
73
+ };
74
+ let operation;
75
+ try {
76
+ operation = parseOperation(parsed.data.operation);
77
+ } catch (error) {
78
+ // The protocol's own words ("Invalid fromAddress"): the model can fix
79
+ // the field instead of guessing.
80
+ return {
81
+ status: 'unavailable',
82
+ reason: `The requested operation is unsupported or incomplete${error instanceof Error && error.message ? ` (${error.message})` : ''}.`,
83
+ };
84
+ }
85
+ let reference: ActionReferenceV1;
86
+ try {
87
+ reference = await ctx.hubActions.request({
88
+ ownerDid: ctx.user.did,
89
+ sessionId: ctx.session.id,
90
+ runId: ctx.session.runId,
91
+ requestId: ctx.session.requestId,
92
+ provider: channel.provider,
93
+ bindingId: channel.bindingId,
94
+ bindingRevision: channel.bindingRevision,
95
+ requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
96
+ operation,
97
+ });
98
+ } catch (error) {
99
+ // A cap reached, a request that expired, a run no user message
100
+ // started: the runtime's own words, so the model can tell the user.
101
+ // Anything else stays an error.
102
+ if (
103
+ error instanceof Error &&
104
+ error.name === 'HubActionRefused' &&
105
+ 'kind' in error
106
+ )
107
+ return {
108
+ status: error.kind === 'expired' ? 'expired' : 'unavailable',
109
+ reason: error.message,
110
+ ...('priorActionId' in error && typeof error.priorActionId === 'string'
111
+ ? { priorActionId: error.priorActionId }
112
+ : {}),
113
+ };
114
+ throw error;
115
+ }
116
+ return {
117
+ status: 'prepared',
118
+ actionReference: reference,
119
+ summary: reference.title,
120
+ };
121
+ }
122
+
123
+ async function hubActionStatus(
124
+ input: unknown,
125
+ ctx: RuntimeContext,
126
+ ): Promise<unknown> {
127
+ const parsed = statusSchema.safeParse(input);
128
+ if (
129
+ !parsed.success ||
130
+ !ctx.hubActions ||
131
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true'
132
+ )
133
+ return { status: 'unavailable' };
134
+ return {
135
+ status: 'known',
136
+ outcome: await ctx.hubActions.status(ctx.user.did, parsed.data.actionId),
137
+ };
138
+ }
139
+
140
+ async function hubActionCapabilities(
141
+ _input: unknown,
142
+ ctx: RuntimeContext,
143
+ ): Promise<unknown> {
144
+ if (
145
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
146
+ ctx.session.client !== 'channel' ||
147
+ !ctx.session.channel ||
148
+ !ctx.hubActions
149
+ )
150
+ return { status: 'unavailable' };
151
+ return ctx.hubActions.capabilities();
152
+ }
153
+
154
+ async function hubActionOptions(
155
+ _input: unknown,
156
+ ctx: RuntimeContext,
157
+ ): Promise<unknown> {
158
+ const channel = ctx.session.channel;
159
+ if (
160
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
161
+ ctx.session.client !== 'channel' ||
162
+ !channel ||
163
+ !ctx.hubActions
164
+ )
165
+ return { status: 'unavailable' };
166
+ return ctx.hubActions.options({
167
+ ownerDid: ctx.user.did,
168
+ provider: channel.provider,
169
+ bindingId: channel.bindingId,
170
+ bindingRevision: channel.bindingRevision,
171
+ });
172
+ }
173
+
174
+ const manifest: PluginManifest = {
175
+ title: 'Auth Hub action review',
176
+ summary:
177
+ 'Prepare a registered operation for owner review and read its verified status.',
178
+ whenToUse: [
179
+ 'The user requests a registered form submission or a transfer.',
180
+ 'The user asks to review an exact private resource or to sign a statement.',
181
+ 'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
182
+ 'The user asks for document access or to save a private note.',
183
+ ],
184
+ whenNotToUse: [
185
+ 'Signing, executing, or treating a resource open as acceptance.',
186
+ ],
187
+ tags: ['actions', 'channels'],
188
+ category: 'integration',
189
+ visibility: 'on-demand',
190
+ stability: 'experimental',
191
+ };
192
+
193
+ export class HubActionsPlugin extends OraclePlugin {
194
+ readonly name = 'hub-actions';
195
+ readonly version = '0.1.0';
196
+ readonly manifest = manifest;
197
+ override readonly configSchema = configSchema;
198
+ /** The flag alone is not enough: the owner object needs the Hub's URL and this oracle's key. */
199
+ override autoDetect(env: Record<string, unknown>): boolean {
200
+ return (
201
+ env.HUB_ACTIONS_ENABLED === 'true' &&
202
+ typeof env.AUTH_HUB_URL === 'string' &&
203
+ env.AUTH_HUB_URL.length > 0 &&
204
+ typeof env.AUTH_HUB_CHANNEL_SERVICE_KEY === 'string' &&
205
+ env.AUTH_HUB_CHANNEL_SERVICE_KEY.length > 0
206
+ );
207
+ }
208
+ override getTools(): PluginTool[] {
209
+ return [
210
+ {
211
+ name: 'hub_action_capabilities',
212
+ description:
213
+ "Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
214
+ schema: discoverSchema,
215
+ effect: 'read',
216
+ handler: hubActionCapabilities,
217
+ },
218
+ {
219
+ name: 'hub_action_options',
220
+ description:
221
+ 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and action resources. Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending.',
222
+ schema: discoverSchema,
223
+ effect: 'read',
224
+ handler: hubActionOptions,
225
+ },
226
+ {
227
+ name: 'request_hub_action',
228
+ description:
229
+ 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again.',
230
+ schema: requestSchema,
231
+ effect: 'write',
232
+ handler: requestHubAction,
233
+ },
234
+ {
235
+ name: 'hub_action_status',
236
+ description:
237
+ 'Read the verified status of a Hub action already prepared for this owner. Never executes.',
238
+ schema: statusSchema,
239
+ effect: 'read',
240
+ handler: hubActionStatus,
241
+ },
242
+ ];
243
+ }
244
+ }
@@ -1,3 +1,7 @@
1
+ import { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
2
+ import { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
3
+ export { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
4
+ export { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
1
5
  /**
2
6
  * Bundled Workers-runtime plugins — ports of the Node runtime's MCP / fetch
3
7
  * plugins. Each is gated the same way as on Node: `autoDetect` on its env
@@ -66,6 +70,8 @@ export const tasksPlugin = TasksPlugin;
66
70
  * instances instead of using these singletons.
67
71
  */
68
72
  export const BUNDLED_WORKERS_PLUGINS = [
73
+ new ActionProposalsPlugin(),
74
+ new HubActionsPlugin(),
69
75
  memoryPlugin,
70
76
  sandboxPlugin,
71
77
  firecrawlPlugin,
@@ -65,7 +65,10 @@ type WalletChannel =
65
65
 
66
66
  /**
67
67
  * The turn's way to the wallet: a session with a realtime channel and a
68
- * Portal browser connected to it.
68
+ * Portal tab that can run the action right now (`canDispatch`: a foreground
69
+ * socket; a background executor never runs an AG-UI action). A signing
70
+ * request is never parked: without such a tab it is refused at once, so it
71
+ * never waits minutes in the user-wide write lane for a tab to open.
69
72
  */
70
73
  function walletChannel(ctx: RuntimeContext): WalletChannel {
71
74
  const sessionId = ctx.session.id;
@@ -77,7 +80,7 @@ function walletChannel(ctx: RuntimeContext): WalletChannel {
77
80
  'Wallet signing needs a Portal session with a realtime connection, which this conversation does not have.',
78
81
  };
79
82
  }
80
- if (!frontend.hasClient(sessionId)) {
83
+ if (!frontend.canDispatch(sessionId, 'agui', SIGN_TRANSACTION_ACTION_NAME)) {
81
84
  return {
82
85
  ok: false,
83
86
  reason:
@@ -25,6 +25,18 @@ export interface DeclaredTool {
25
25
  schema: Record<string, unknown>;
26
26
  }
27
27
 
28
+ /**
29
+ * The effect a client declares for one of its tools (`effect` on a browser
30
+ * tool declaration): exactly `'read'` or `'write'`. Any other value is
31
+ * treated as no declaration — the entry itself is kept, and the runtime's
32
+ * own classification applies.
33
+ */
34
+ export function declaredToolEffect(
35
+ value: unknown,
36
+ ): 'read' | 'write' | undefined {
37
+ return value === 'read' || value === 'write' ? value : undefined;
38
+ }
39
+
28
40
  export interface DroppedDeclaration {
29
41
  name: string;
30
42
  reason: string;
@@ -24,7 +24,11 @@ import type {
24
24
  RuntimeContext,
25
25
  } from '../../plugin-api/types';
26
26
  import { logFrontendAction } from './action-log';
27
- import { describeDropped, sanitizeDeclaredTools } from './declared-tools';
27
+ import {
28
+ declaredToolEffect,
29
+ describeDropped,
30
+ sanitizeDeclaredTools,
31
+ } from './declared-tools';
28
32
 
29
33
  const manifest: PluginManifest = {
30
34
  title: 'Portal',
@@ -79,6 +83,7 @@ const BROWSER_TOOL_SHAPE = z.object({
79
83
  name: z.string(),
80
84
  description: z.string(),
81
85
  schema: z.record(z.string(), z.unknown()),
86
+ effect: z.unknown().optional(),
82
87
  });
83
88
 
84
89
  const ARGS_RECORD_SHAPE = z.record(z.string(), z.unknown());
@@ -92,7 +97,9 @@ export function parseBrowserTools(value: unknown): BrowserToolCall[] {
92
97
  const parsed = BROWSER_TOOL_SHAPE.safeParse(entry);
93
98
  if (!parsed.success || names.has(parsed.data.name)) continue;
94
99
  names.add(parsed.data.name);
95
- out.push(parsed.data);
100
+ const { effect: rawEffect, ...declaration } = parsed.data;
101
+ const effect = declaredToolEffect(rawEffect);
102
+ out.push(effect ? { ...declaration, effect } : declaration);
96
103
  }
97
104
  return out;
98
105
  }
@@ -170,11 +177,8 @@ export function buildBrowserTool(
170
177
  `Browser tool ${descriptor.name} needs the realtime channel, which this host does not provide.`,
171
178
  );
172
179
  }
173
- if (!frontend.hasClient(sessionId)) {
174
- throw new Error(
175
- `No browser is connected to session ${sessionId}, so browser tool ${descriptor.name} cannot run — the client must open the realtime (socket.io) channel for this session first.`,
176
- );
177
- }
180
+ // No browser of the session connected yet: the bridge parks the call
181
+ // until one joins (or its park deadline / the turn's abort ends it).
178
182
  const args = withRuntimeSuppliedArgs(
179
183
  parseArgs(input),
180
184
  descriptor,
@@ -189,6 +193,7 @@ export function buildBrowserTool(
189
193
  args,
190
194
  timeoutMs: browserToolTimeoutMs(descriptor.name),
191
195
  signal: ctx.abortSignal,
196
+ ...(ctx.markDispatched ? { onDispatched: ctx.markDispatched } : {}),
192
197
  onInvocation: (id) => {
193
198
  invocationId = id;
194
199
  },
@@ -202,9 +207,18 @@ export function buildBrowserTool(
202
207
  schema,
203
208
  // A browser step (scroll, page, click) repeated is a new step.
204
209
  repeatable: !HUMAN_DECISION_TOOLS.has(descriptor.name),
210
+ // It runs in this session's browser tab: scheduled in the session's
211
+ // frontend lane, so a parked call (or a question to the user) holds
212
+ // no slot another chat needs.
213
+ surface: 'frontend',
214
+ // A question to the user is a write whatever the client declares:
215
+ // it is never asked twice after a restart once it was sent. Any
216
+ // other tool keeps the effect its declaration names, if any.
205
217
  ...(HUMAN_DECISION_TOOLS.has(descriptor.name)
206
218
  ? { effect: 'write' as const }
207
- : {}),
219
+ : descriptor.effect
220
+ ? { effect: descriptor.effect }
221
+ : {}),
208
222
  },
209
223
  );
210
224
  }