@ixo/oracle-runtime-workers 0.28.0 → 0.29.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.
@@ -1,11 +1,17 @@
1
1
  import {
2
+ CHAIN_TX_MAX_MESSAGES,
3
+ CHAIN_TX_MAX_TOKEN_LINES,
4
+ CHAIN_TX_MESSAGE_TYPES,
5
+ DENOM,
2
6
  operationJsonSchema,
3
7
  parseOperation,
4
8
  type ActionReferenceV1,
9
+ type ChainTxMessageType,
5
10
  } from '@ixo/action-protocol';
6
11
  import { z } from 'zod';
7
12
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
8
13
  import type {
14
+ ChainTxMessageInput,
9
15
  PluginManifest,
10
16
  PluginTool,
11
17
  RuntimeContext,
@@ -16,38 +22,303 @@ const configSchema = z.object({
16
22
  HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
17
23
  });
18
24
 
19
- const operationSchema = z.fromJSONSchema({
25
+ /**
26
+ * Kinds the generic tool never prepares: a chain transaction
27
+ * (`ixo.chain.tx.v1`, coin sends included) is built by the runtime from
28
+ * `request_chain_transaction`'s messages, and the older bank send
29
+ * (`ixo.bank.send.v1`) is no longer prepared by this runtime (Auth Hub
30
+ * still serves it for older runtimes). They are left out of the generic
31
+ * tool's schema (its handler refuses them anyway), which also keeps their
32
+ * branches (about 3.7 KB of the JSON Schema the model is sent) out of every
33
+ * model request.
34
+ */
35
+ const RUNTIME_BUILT_KINDS: readonly string[] = [
36
+ 'ixo.bank.send.v1',
37
+ 'ixo.chain.tx.v1',
38
+ ];
39
+ function branchKind(branch: Record<string, unknown>): unknown {
40
+ const properties = branch.properties;
41
+ if (!properties || typeof properties !== 'object') return undefined;
42
+ const kind = (properties as Record<string, unknown>).kind;
43
+ return kind && typeof kind === 'object'
44
+ ? (kind as Record<string, unknown>).const
45
+ : undefined;
46
+ }
47
+ export const requestOperationJsonSchema = {
20
48
  ...operationJsonSchema,
21
- $schema: 'https://json-schema.org/draft/2020-12/schema',
22
- });
49
+ $schema: 'https://json-schema.org/draft/2020-12/schema' as const,
50
+ oneOf: operationJsonSchema.oneOf.filter(
51
+ (branch) => !RUNTIME_BUILT_KINDS.includes(String(branchKind(branch))),
52
+ ),
53
+ };
54
+ const operationSchema = z.fromJSONSchema(requestOperationJsonSchema);
23
55
  const requestSchema = z.strictObject({ operation: operationSchema });
24
56
  const statusSchema = z.strictObject({
25
57
  actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
26
58
  });
27
59
  const discoverSchema = z.strictObject({});
28
- const transferSchema = z.strictObject({
29
- toAddress: z
30
- .string()
31
- .min(1)
32
- .max(100)
33
- .describe('The recipient address the user gave, exactly.'),
34
- denom: z
60
+
61
+ /**
62
+ * The messages of one chain transaction (`ixo.chain.tx.v1`), as the model
63
+ * writes them. The shapes are the protocol's minus the owner's identity
64
+ * (a claim's `agentDid`, an evaluation's `agentAddress`), which the runtime
65
+ * fills, as it fills the chain id, network and signer; a bank step names a
66
+ * display amount, which the owner object converts to base units from the
67
+ * owner's token list. The protocol parses the whole operation again before
68
+ * it reaches the Hub. The rules match Auth Hub's (ids, address length, DID
69
+ * escapes, memo characters), so a message the Hub would refuse is refused
70
+ * here, before any Hub call.
71
+ */
72
+ /** A 20-byte bech32 account address: `ixo1` and 38 characters (a 32-byte contract address is longer). */
73
+ const IXO_ADDRESS = /^ixo1[023456789acdefghjklmnpqrstuvwxyz]{38}$/;
74
+ const BASE_UNITS = /^[1-9][0-9]{0,77}$/;
75
+ /** A DID; `%` only as a `%xx` escape. */
76
+ const ANY_DID = /^did:[a-z0-9]+:(?:[A-Za-z0-9._:-]|%[0-9A-Fa-f]{2})+$/;
77
+ /** Claim, collection, token and proof ids: no spaces, quotes or markup. */
78
+ const ID_CHARS = /^[A-Za-z0-9:._/-]+$/;
79
+ const ID_RULE = 'Only letters, digits and : . _ / - (copied exactly).';
80
+ /**
81
+ * Characters that would let text render as something else: C0 and C1
82
+ * controls, zero-width and direction marks (U+200B–U+200F), line and
83
+ * paragraph separators (U+2028–U+2029), bidi embeddings, overrides and
84
+ * isolates (U+202A–U+202E, U+2066–U+2069), the Arabic letter mark (U+061C)
85
+ * and the byte order mark (U+FEFF). Auth Hub refuses the same set.
86
+ */
87
+ export function hasUnsafeText(value: string): boolean {
88
+ for (const char of value) {
89
+ const code = char.codePointAt(0) ?? 0;
90
+ if (
91
+ code <= 0x1f ||
92
+ (code >= 0x7f && code <= 0x9f) ||
93
+ code === 0x061c ||
94
+ (code >= 0x200b && code <= 0x200f) ||
95
+ (code >= 0x2028 && code <= 0x202e) ||
96
+ (code >= 0x2066 && code <= 0x2069) ||
97
+ code === 0xfeff
98
+ )
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ const MEMO_BYTES = 256;
104
+ const address = (what: string) =>
105
+ z
35
106
  .string()
36
- .min(1)
37
- .max(128)
38
- .describe(
39
- 'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
40
- ),
41
- amount: z
107
+ .regex(
108
+ IXO_ADDRESS,
109
+ 'A 20-byte ixo1… account address (42 characters); contract addresses are refused.',
110
+ )
111
+ .describe(`${what} (an ixo1… account address).`);
112
+ const id = (max: number) => z.string().min(1).max(max).regex(ID_CHARS, ID_RULE);
113
+ const baseUnits = (what: string) =>
114
+ z
42
115
  .string()
43
- .regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
44
- .max(80)
45
- .describe(
46
- "A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
116
+ .regex(BASE_UNITS, 'A whole number of base units above zero.')
117
+ .describe(what);
118
+ const claimId = id(256).describe('The claim id, exactly as stored.');
119
+ const collectionId = z
120
+ .string()
121
+ .regex(/^(0|[1-9][0-9]{0,19})$/, 'Digits only, no leading zeros.')
122
+ .describe('The claim collection id (digits).');
123
+
124
+ /** A display amount: a plain decimal in the token's units, as the user said it. */
125
+ const DISPLAY_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
126
+
127
+ /**
128
+ * One chain-transaction message type: the one-line `summary` the request
129
+ * tool's description lists, the full `rules` describe_chain_messages
130
+ * returns (with the fields of `schema`), and the strict `schema` the
131
+ * request handler checks each message against. Adding a message type is
132
+ * one entry here (once `@ixo/action-protocol` and Auth Hub support it).
133
+ */
134
+ interface ChainTxCatalogEntry {
135
+ summary: string;
136
+ rules: string;
137
+ schema: z.ZodObject;
138
+ }
139
+
140
+ export const CHAIN_TX_CATALOG = {
141
+ 'cosmos.bank.send': {
142
+ summary:
143
+ "send coins (IXO or another token the owner holds) from the owner's wallet.",
144
+ rules: [
145
+ "Sends coins from the owner's wallet to `toAddress`, alone (a single coin send) or as a step of a batch.",
146
+ '`amount` is the amount as the user said it, in the token’s display units ("1.5" for 1.5 IXO): a plain decimal, never base units. The runtime looks the token up in the owner’s token list (hub_action_options `transfer.tokens`), converts the amount with the listed `decimals`, and refuses a token not in the list, more digits after the point than the token has, zero, and more than the listed balance (the bank steps of one token together).',
147
+ '`denom` is optional and defaults to uixo (IXO, 6 decimals). For another token copy its `denom` exactly from `transfer.tokens` (never guess an `ibc/…` hash); name the full denom to the owner for anything but IXO, and say when the token is unverified (it lists 0 decimals: its amount is a whole number of base units).',
148
+ ].join(' '),
149
+ schema: z.strictObject({
150
+ type: z.literal('cosmos.bank.send'),
151
+ toAddress: address('The recipient'),
152
+ amount: z
153
+ .string()
154
+ .max(80)
155
+ .regex(
156
+ DISPLAY_AMOUNT,
157
+ 'A plain decimal amount in the token’s display units, such as 1.5 (never base units).',
158
+ )
159
+ .describe(
160
+ 'The amount as the user said it, in the token’s display units (1.5 = 1.5 IXO).',
161
+ ),
162
+ denom: z
163
+ .string()
164
+ .regex(DENOM, 'A denom exactly as hub_action_options lists it.')
165
+ .optional()
166
+ .describe(
167
+ 'The token’s denom, copied exactly from hub_action_options `transfer.tokens`; leave out for uixo (IXO).',
168
+ ),
169
+ }),
170
+ },
171
+ 'ixo.claims.submit': {
172
+ summary:
173
+ 'file a claim the owner has already created with a claim collection.',
174
+ rules:
175
+ 'Files a claim the owner has already created with a claim collection. `claimId` is the stored claim’s id, `collectionId` the collection, `adminAddress` the collection admin’s address. The runtime adds the owner’s DID as the filer. The collection admin must have authorized the owner to submit claims. Collections with member budgets cannot be filed into here (no member address is sent). Set `useIntent` only when the collection works with intents.',
176
+ schema: z.strictObject({
177
+ type: z.literal('ixo.claims.submit'),
178
+ collectionId,
179
+ claimId,
180
+ adminAddress: address("The collection admin's address"),
181
+ useIntent: z
182
+ .boolean()
183
+ .optional()
184
+ .describe('true only when the collection pays claims through intents.'),
185
+ }),
186
+ },
187
+ 'ixo.claims.evaluate': {
188
+ summary:
189
+ 'record the owner’s decision, as evaluator, on someone else’s claim (approve, reject, dispute, invalidate).',
190
+ rules:
191
+ 'Records the owner’s decision, as evaluator, on someone else’s claim (`claimId` in `collectionId`). `status` 1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate. It is recorded as the owner’s (the runtime adds their DID and address, never the claimant’s); `oracle` is the evaluator DID recorded on the evaluation (normally the owner’s DID, or the oracle entity DID the collection admin authorized); `adminAddress` is the collection admin’s address; `reason` (a number) and `verificationProof` are optional. The collection admin must have authorized the owner as an evaluator. An approval can pay the claimant.',
192
+ schema: z.strictObject({
193
+ type: z.literal('ixo.claims.evaluate'),
194
+ claimId,
195
+ collectionId,
196
+ oracle: z
197
+ .string()
198
+ .max(200)
199
+ .regex(ANY_DID, 'A DID (did:<method>:…; % only as %xx).')
200
+ .describe('The evaluator DID recorded on the evaluation (a did:…).'),
201
+ adminAddress: address("The collection admin's address"),
202
+ status: z
203
+ .literal([1, 2, 3, 4])
204
+ .describe(
205
+ '1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate.',
206
+ ),
207
+ reason: z
208
+ .number()
209
+ .int()
210
+ .min(0)
211
+ .max(4_294_967_295)
212
+ .optional()
213
+ .describe("The evaluator's reason code, when there is one."),
214
+ verificationProof: id(300)
215
+ .optional()
216
+ .describe(
217
+ 'The evaluation proof (a credential CID), when there is one.',
218
+ ),
219
+ }),
220
+ },
221
+ 'ixo.token.transfer': {
222
+ summary:
223
+ "move IXO impact tokens (1155-style token ids, whole amounts) from the owner's wallet.",
224
+ rules: `Moves IXO impact tokens (1155-style: each line a token \`id\` of 32 hex characters and a whole \`amount\`, 1 to ${CHAIN_TX_MAX_TOKEN_LINES} lines) from the owner’s wallet to \`recipient\`. Impact tokens have no decimals: an amount is a whole number of tokens. All tokens in one message must come from one token contract. These are not the IXO coin: coins go through \`cosmos.bank.send\`.`,
225
+ schema: z.strictObject({
226
+ type: z.literal('ixo.token.transfer'),
227
+ recipient: address('The recipient'),
228
+ tokens: z
229
+ .array(
230
+ z.strictObject({
231
+ id: id(128).describe(
232
+ 'The impact token id (32 hex characters), exactly as listed.',
233
+ ),
234
+ amount: baseUnits('A whole number of tokens above zero.'),
235
+ }),
236
+ )
237
+ .min(1)
238
+ .max(CHAIN_TX_MAX_TOKEN_LINES),
239
+ }),
240
+ },
241
+ } as const satisfies Record<ChainTxMessageType, ChainTxCatalogEntry>;
242
+
243
+ /** What every message type shares, returned once by describe_chain_messages. */
244
+ export const CHAIN_TX_GENERAL_RULES =
245
+ 'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused). Ids (`claimId`, `collectionId`, token `id`, `verificationProof`) use only letters, digits and `: . _ / -`; copy them exactly and never invent them. Give only the fields listed for a type: the runtime adds the chain, the network, the signer and the owner’s own DID and address where a message needs them, and Auth Hub sets the fee. Up to 8 messages per transaction, run in order; all succeed or none do.';
246
+
247
+ const memoSchema = z
248
+ .string()
249
+ .max(MEMO_BYTES)
250
+ .refine((memo) => new TextEncoder().encode(memo).length <= MEMO_BYTES, {
251
+ message: `At most ${MEMO_BYTES} bytes.`,
252
+ })
253
+ .refine((memo) => !hasUnsafeText(memo), {
254
+ message: 'No control, invisible or text-direction characters.',
255
+ })
256
+ .optional()
257
+ .describe('An optional note shown to the owner and kept on chain.');
258
+
259
+ /**
260
+ * The schema the model is given: constant in size whatever the catalogue
261
+ * holds (a type adds one enum value). Each message's own fields are
262
+ * checked by the handler against its catalogue schema.
263
+ */
264
+ export function chainTransactionSchemaFor<
265
+ const T extends readonly [string, ...string[]],
266
+ >(types: T) {
267
+ return z.strictObject({
268
+ messages: z
269
+ .array(
270
+ z
271
+ .object({
272
+ type: z
273
+ .enum(types)
274
+ .describe(
275
+ 'The message type; describe_chain_messages lists its fields.',
276
+ ),
277
+ })
278
+ .catchall(z.unknown()),
279
+ )
280
+ .min(1)
281
+ .max(CHAIN_TX_MAX_MESSAGES),
282
+ memo: memoSchema,
283
+ });
284
+ }
285
+ const chainTransactionSchema = chainTransactionSchemaFor(
286
+ CHAIN_TX_MESSAGE_TYPES,
287
+ );
288
+
289
+ /**
290
+ * The request tool's description: what it does, the discovery step, one
291
+ * line per catalogue type (its summary only) and the batch rules. A new
292
+ * type adds one line.
293
+ */
294
+ export function chainTransactionDescription(
295
+ catalog: Readonly<Record<string, { summary: string }>>,
296
+ ): string {
297
+ return [
298
+ 'Prepare one chain transaction for the owner (the person you are talking to) to review and sign in Auth Hub; it never signs or executes. A single coin send is one `cosmos.bank.send` message. Before the first use in a conversation, call describe_chain_messages for the types you need and give exactly the fields it lists. Message types:',
299
+ ...Object.entries(catalog).map(
300
+ ([type, entry]) => `- \`${type}\`: ${entry.summary}`,
47
301
  ),
48
- memo: z.string().max(256).optional(),
302
+ 'Up to 8 messages run in order as one transaction: all succeed or none do. Never invent ids or addresses (ask the owner or use other tools); coin amounts are display units ("1.5"), never base units. When the result is unavailable, tell the owner its reason in plain words; when it says retrying or changing the fields will not help, do not retry.',
303
+ ].join('\n');
304
+ }
305
+ export const CHAIN_TRANSACTION_TOOL_DESCRIPTION =
306
+ chainTransactionDescription(CHAIN_TX_CATALOG);
307
+
308
+ const describeChainMessagesSchema = z.strictObject({
309
+ types: z
310
+ .array(z.enum(CHAIN_TX_MESSAGE_TYPES))
311
+ .min(1)
312
+ .max(CHAIN_TX_MAX_MESSAGES)
313
+ .refine((types) => new Set(types).size === types.length, {
314
+ message: 'Name each type once.',
315
+ })
316
+ .describe('The message types to describe, each once.'),
49
317
  });
50
318
 
319
+ export const DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION =
320
+ 'Read the full rules of chain-transaction message types before using them in request_chain_transaction: every field with its meaning, format and limits, what the runtime fills, and chain caveats. Ask only for the types you need. Also returns `availableTypes`: the types this account may use.';
321
+
51
322
  export type HubActionRequestResult =
52
323
  | {
53
324
  status: 'prepared';
@@ -109,6 +380,32 @@ async function requestHubAction(
109
380
  input: unknown,
110
381
  ctx: RuntimeContext,
111
382
  ): Promise<HubActionRequestResult> {
383
+ // Named before the schema check: the schema has no branch for these kinds.
384
+ const kind =
385
+ input &&
386
+ typeof input === 'object' &&
387
+ 'operation' in input &&
388
+ input.operation &&
389
+ typeof input.operation === 'object' &&
390
+ 'kind' in input.operation
391
+ ? input.operation.kind
392
+ : undefined;
393
+ if (kind === 'ixo.bank.send.v1')
394
+ // A coin send is a `cosmos.bank.send` step of a chain transaction,
395
+ // built by the runtime from a listed token and a display amount: the
396
+ // model never supplies base units, a denom it chose or a fee.
397
+ return {
398
+ status: 'unavailable',
399
+ reason:
400
+ 'Use request_chain_transaction with one cosmos.bank.send message (a token from hub_action_options and the amount in its display units) for a coin send.',
401
+ };
402
+ if (kind === 'ixo.chain.tx.v1')
403
+ // The runtime adds the chain, network, signer and owner identity itself.
404
+ return {
405
+ status: 'unavailable',
406
+ reason:
407
+ 'Use request_chain_transaction with only the messages for a chain transaction.',
408
+ };
112
409
  const parsed = requestSchema.safeParse(input);
113
410
  if (!parsed.success)
114
411
  return { status: 'unavailable', reason: 'An operation is required.' };
@@ -131,15 +428,6 @@ async function requestHubAction(
131
428
  let operation;
132
429
  try {
133
430
  operation = parseOperation(parsed.data.operation);
134
- if (operation.kind === 'ixo.bank.send.v1')
135
- // A transfer is built by the runtime from a listed token and a
136
- // display amount (request_hub_transfer): the model never supplies
137
- // base units, a denom it chose or a fee.
138
- return {
139
- status: 'unavailable',
140
- reason:
141
- 'Use request_hub_transfer with a token from hub_action_options for a transfer.',
142
- };
143
431
  } catch (error) {
144
432
  // The protocol's own words ("Invalid fromAddress"): the model can fix
145
433
  // the field instead of guessing.
@@ -172,17 +460,14 @@ async function requestHubAction(
172
460
  return preparedResult(reference, ctx);
173
461
  }
174
462
 
175
- async function requestHubTransfer(
463
+ async function requestChainTransaction(
176
464
  input: unknown,
177
465
  ctx: RuntimeContext,
178
466
  ): Promise<HubActionRequestResult> {
179
- const parsed = transferSchema.safeParse(input);
180
- if (!parsed.success)
181
- return {
182
- status: 'unavailable',
183
- reason:
184
- 'A recipient, a listed token denom and a plain decimal amount are required.',
185
- };
467
+ const parsed = chainTransactionSchema.safeParse(input);
468
+ if (!parsed.success) return chainTxShapeRefusal(issuesOf(parsed.error));
469
+ const checked = strictChainTxMessages(parsed.data.messages);
470
+ if ('issues' in checked) return chainTxShapeRefusal(checked.issues);
186
471
  if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
187
472
  return {
188
473
  status: 'unavailable',
@@ -200,7 +485,7 @@ async function requestHubTransfer(
200
485
  reason: 'A verified channel turn is required.',
201
486
  };
202
487
  try {
203
- const reference = await ctx.hubActions.transfer({
488
+ const reference = await ctx.hubActions.chainTransaction({
204
489
  ownerDid: ctx.user.did,
205
490
  sessionId: ctx.session.id,
206
491
  runId: ctx.session.runId,
@@ -209,21 +494,148 @@ async function requestHubTransfer(
209
494
  bindingId: channel.bindingId,
210
495
  bindingRevision: channel.bindingRevision,
211
496
  requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
212
- toAddress: parsed.data.toAddress,
213
- denom: parsed.data.denom,
214
- amount: parsed.data.amount,
497
+ messages: checked.messages,
215
498
  memo: parsed.data.memo ?? '',
216
499
  });
217
500
  return preparedResult(reference, ctx);
218
501
  } catch (error) {
219
- // The owner object turns the protocol refusing the operation built
220
- // from the model's fields into a refusal; anything else is an error.
502
+ // The runtime refusing (turn origin, capabilities, a token or amount,
503
+ // the protocol on a field) or the Hub refusing with its reason: words
504
+ // the model relays.
221
505
  const refused = refusalOf(error);
222
506
  if (refused) return refused;
223
507
  throw error;
224
508
  }
225
509
  }
226
510
 
511
+ /**
512
+ * Each message against its type's catalogue schema (strict: a field the
513
+ * type does not have, the owner's identity included, is refused). The
514
+ * model-facing schema only checks the type, so this is where a message's
515
+ * fields are checked, on a model turn as on a direct call.
516
+ */
517
+ function strictChainTxMessages(
518
+ messages: readonly ({ type: ChainTxMessageType } & Record<string, unknown>)[],
519
+ ): { messages: ChainTxMessageInput[] } | { issues: string[] } {
520
+ const checked: ChainTxMessageInput[] = [];
521
+ const issues: string[] = [];
522
+ messages.forEach((message, index) => {
523
+ const result = CHAIN_TX_CATALOG[message.type].schema.safeParse(message);
524
+ if (result.success) checked.push(result.data);
525
+ else issues.push(...issuesOf(result.error, ['messages', index]));
526
+ });
527
+ return issues.length ? { issues } : { messages: checked };
528
+ }
529
+
530
+ function chainTxShapeRefusal(
531
+ issues: readonly string[],
532
+ ): HubActionRequestResult {
533
+ return {
534
+ status: 'unavailable',
535
+ reason: `The messages do not match a supported message shape (check ${[...new Set(issues)].slice(0, 5).join('; ')}).`,
536
+ };
537
+ }
538
+
539
+ /** `messages[1].toAddress`, from a zod path. */
540
+ function pathText(path: readonly PropertyKey[]): string {
541
+ return path.reduce<string>(
542
+ (text, key) =>
543
+ typeof key === 'number'
544
+ ? `${text}[${key}]`
545
+ : text
546
+ ? `${text}.${String(key)}`
547
+ : String(key),
548
+ '',
549
+ );
550
+ }
551
+
552
+ /**
553
+ * Each refused field as `path: what is wrong` (`messages[1].toAddress: A
554
+ * 20-byte ixo1… account address …`); a field the shape does not have is
555
+ * named on its own path.
556
+ */
557
+ function issuesOf(
558
+ error: z.ZodError,
559
+ prefix: readonly PropertyKey[] = [],
560
+ ): string[] {
561
+ return error.issues.flatMap((issue) =>
562
+ issue.code === 'unrecognized_keys'
563
+ ? issue.keys.map(
564
+ (key) =>
565
+ `${pathText([...prefix, ...issue.path, key])}: not a field here (describe_chain_messages lists each type's fields)`,
566
+ )
567
+ : [
568
+ `${pathText([...prefix, ...issue.path]) || 'arguments'}: ${issue.message.replace(/\.$/, '')}`,
569
+ ],
570
+ );
571
+ }
572
+
573
+ /**
574
+ * The catalogue types this account may use: the Hub capabilities'
575
+ * `chainTx.messageTypes` (none when chain transactions are off for the
576
+ * account), or the whole catalogue when the capabilities cannot be read
577
+ * (not a channel turn, or the Hub did not answer).
578
+ */
579
+ async function availableChainTxTypes(
580
+ ctx: RuntimeContext,
581
+ ): Promise<readonly ChainTxMessageType[]> {
582
+ if (
583
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
584
+ ctx.session.client !== 'channel' ||
585
+ !ctx.session.channel ||
586
+ !ctx.hubActions
587
+ )
588
+ return CHAIN_TX_MESSAGE_TYPES;
589
+ let chainTx;
590
+ try {
591
+ chainTx = (await ctx.hubActions.capabilities()).chainTx;
592
+ } catch (error) {
593
+ ctx.logger.warn(
594
+ `[hub-actions] capabilities unavailable for describe_chain_messages: ${error instanceof Error ? error.message : String(error)}`,
595
+ );
596
+ return CHAIN_TX_MESSAGE_TYPES;
597
+ }
598
+ return CHAIN_TX_MESSAGE_TYPES.filter((type) =>
599
+ chainTx?.messageTypes.includes(type),
600
+ );
601
+ }
602
+
603
+ /** Each type's fields as JSON Schema, built once. */
604
+ const catalogueFields = new Map<ChainTxMessageType, unknown>();
605
+ function fieldsOf(type: ChainTxMessageType): unknown {
606
+ let fields = catalogueFields.get(type);
607
+ if (fields === undefined) {
608
+ const { properties, required } = z.toJSONSchema(
609
+ CHAIN_TX_CATALOG[type].schema,
610
+ );
611
+ fields = { properties, required };
612
+ catalogueFields.set(type, fields);
613
+ }
614
+ return fields;
615
+ }
616
+
617
+ async function describeChainMessages(
618
+ input: unknown,
619
+ ctx: RuntimeContext,
620
+ ): Promise<unknown> {
621
+ const parsed = describeChainMessagesSchema.safeParse(input);
622
+ if (!parsed.success)
623
+ return {
624
+ status: 'unavailable',
625
+ reason: `Name 1 to ${CHAIN_TX_MAX_MESSAGES} different message types, each one of: ${CHAIN_TX_MESSAGE_TYPES.join(', ')}.`,
626
+ };
627
+ return {
628
+ status: 'known',
629
+ general: CHAIN_TX_GENERAL_RULES,
630
+ messages: parsed.data.types.map((type) => ({
631
+ type,
632
+ rules: CHAIN_TX_CATALOG[type].rules,
633
+ fields: fieldsOf(type),
634
+ })),
635
+ availableTypes: await availableChainTxTypes(ctx),
636
+ };
637
+ }
638
+
227
639
  /** A typed runtime refusal as a result the model can relay, else null. */
228
640
  function refusalOf(error: unknown): HubActionRequestResult | null {
229
641
  if (
@@ -301,7 +713,8 @@ const manifest: PluginManifest = {
301
713
  summary:
302
714
  'Prepare a registered operation for owner review and read its verified status.',
303
715
  whenToUse: [
304
- 'The user requests a registered form submission or a transfer.',
716
+ 'The user requests a registered form submission or a coin send.',
717
+ 'The user asks to file or evaluate a claim, or to send coins or impact tokens on chain.',
305
718
  'The user asks to review an exact private resource or to sign a statement.',
306
719
  'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
307
720
  'The user asks for document access or to save a private note.',
@@ -351,18 +764,24 @@ export class HubActionsPlugin extends OraclePlugin {
351
764
  {
352
765
  name: 'request_hub_action',
353
766
  description:
354
- '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.',
767
+ '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. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again.',
355
768
  schema: requestSchema,
356
769
  effect: 'write',
357
770
  handler: requestHubAction,
358
771
  },
359
772
  {
360
- name: 'request_hub_transfer',
361
- description:
362
- 'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
363
- schema: transferSchema,
773
+ name: 'describe_chain_messages',
774
+ description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
775
+ schema: describeChainMessagesSchema,
776
+ effect: 'read',
777
+ handler: describeChainMessages,
778
+ },
779
+ {
780
+ name: 'request_chain_transaction',
781
+ description: CHAIN_TRANSACTION_TOOL_DESCRIPTION,
782
+ schema: chainTransactionSchema,
364
783
  effect: 'write',
365
- handler: requestHubTransfer,
784
+ handler: requestChainTransaction,
366
785
  },
367
786
  {
368
787
  name: 'hub_action_status',