@ixo/oracle-runtime-workers 0.30.0 → 0.32.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 (53) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/middlewares/tool-repetition-guard.ts +6 -3
  15. package/src/core/middlewares/turn-boundary.ts +6 -1
  16. package/src/core/runtime-context.ts +3 -0
  17. package/src/core/tool-not-run.ts +30 -0
  18. package/src/core/wrap-plugin-tool.ts +9 -1
  19. package/src/delivery/plan.ts +3 -1
  20. package/src/do/contracts.ts +3 -0
  21. package/src/do/turn-attachments.ts +103 -0
  22. package/src/do/user-oracle-do.ts +220 -57
  23. package/src/hub-actions/chain-tx-refusals.ts +217 -0
  24. package/src/hub-actions/client.ts +170 -26
  25. package/src/hub-actions/hub-text.ts +82 -0
  26. package/src/hub-actions/request.ts +39 -29
  27. package/src/hub-actions/store.ts +5 -1
  28. package/src/hub-actions/transfer.ts +99 -32
  29. package/src/index.ts +1 -0
  30. package/src/plugin-api/index.ts +1 -0
  31. package/src/plugin-api/types.ts +102 -20
  32. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  33. package/src/plugins/hub-actions/chain-tx-catalog.ts +1239 -0
  34. package/src/plugins/hub-actions/hub-actions.plugin.ts +490 -232
  35. package/src/plugins/ixo-chain/blocksync.ts +6 -2
  36. package/src/plugins/ixo-chain/http.ts +16 -8
  37. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +1 -1
  38. package/src/plugins/ixo-chain/kinds/chain/index.ts +2 -0
  39. package/src/plugins/ixo-chain/kinds/chain/validators.ts +151 -0
  40. package/src/plugins/ixo-chain/rest.ts +7 -1
  41. package/src/plugins/yoma/catalog.ts +780 -0
  42. package/src/plugins/yoma/clef.ts +98 -0
  43. package/src/plugins/yoma/drafts.ts +339 -0
  44. package/src/plugins/yoma/engine.ts +978 -0
  45. package/src/plugins/yoma/index.ts +13 -0
  46. package/src/plugins/yoma/judge.ts +358 -0
  47. package/src/plugins/yoma/permission.ts +62 -0
  48. package/src/plugins/yoma/reads.ts +195 -0
  49. package/src/plugins/yoma/submission.ts +531 -0
  50. package/src/plugins/yoma/text.ts +16 -0
  51. package/src/plugins/yoma/work.ts +190 -0
  52. package/src/plugins/yoma/yoma.plugin.ts +2171 -0
  53. package/src/sqlite/serialization.ts +2 -0
@@ -162,6 +162,14 @@ const CHANNEL_TOPIC_LOOKBACK = 5;
162
162
  /** Ids per statement: a Durable Object binds at most 100 parameters. */
163
163
  const CHANNEL_IDS_PER_STATEMENT = 50;
164
164
 
165
+ /**
166
+ * The text the gateway sends for a file with no caption: `[photo]`,
167
+ * `[video]`, `[audio]` or `[document: <name>]`.
168
+ */
169
+ export function isAttachmentPlaceholder(message: string): boolean {
170
+ return /^\[(?:photo|video|audio)\]$|^\[document: [^\n]*\]$/.test(message);
171
+ }
172
+
165
173
  function clipCodePoints(text: string, limit: number): string {
166
174
  const points = Array.from(text);
167
175
  return points.length > limit ? points.slice(0, limit).join('') : text;
@@ -1463,7 +1471,14 @@ export class ChannelTurns {
1463
1471
  assignment.destination = { kind: 'session', sessionId: session };
1464
1472
  } else if (mode === 'on')
1465
1473
  assignment.destination = { kind: 'clarify', text: ambiguousThreadText };
1466
- } else {
1474
+ } else if (
1475
+ input.attachments?.length &&
1476
+ isAttachmentPlaceholder(input.message)
1477
+ )
1478
+ // A file with no caption, only the gateway's placeholder: nothing to
1479
+ // classify, so it stays in the current conversation.
1480
+ assignment.reason = 'attachment-placeholder';
1481
+ else {
1467
1482
  const candidates = await this.routingCandidates(input, baseline);
1468
1483
  if (candidates.length) {
1469
1484
  const evidence = {
@@ -1516,6 +1531,10 @@ export class ChannelTurns {
1516
1531
  } else assignment.reason = 'shadow';
1517
1532
  }
1518
1533
  }
1534
+ // A clarifying question is answered instead of the message, so the files
1535
+ // would be dropped: a turn that carries files stays where it is.
1536
+ if (input.attachments?.length && assignment.destination.kind === 'clarify')
1537
+ assignment.destination = { kind: 'session', sessionId: baseline };
1519
1538
  await this.serialize(async () => {
1520
1539
  await this.assertNotCancelled(input);
1521
1540
  await this.db.run(
@@ -1861,6 +1880,9 @@ export class ChannelTurns {
1861
1880
  bindingId: input.bindingId,
1862
1881
  bindingRevision: input.bindingRevision,
1863
1882
  remoteMessageRef: input.remoteMessageRef,
1883
+ ...(input.attachments?.length
1884
+ ? { attachments: input.attachments }
1885
+ : {}),
1864
1886
  ...(replyContext ? { replyContext } : {}),
1865
1887
  ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1866
1888
  ...(routing?.destination.kind === 'clarify'
@@ -9,6 +9,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
9
9
  import type { BaseCheckpointSaver } from '@langchain/langgraph';
10
10
  import type { ReactAgent } from 'langchain';
11
11
  import { z } from 'zod';
12
+ import { ChannelAttachment } from '../channels/contract';
12
13
  import type {
13
14
  ChatOpenAIFields,
14
15
  MergedConfig,
@@ -48,6 +49,7 @@ export const mainAgentRequestContextSchema = z.object({
48
49
  provider: z.string(),
49
50
  bindingId: z.string(),
50
51
  bindingRevision: z.number().int().positive(),
52
+ attachments: z.array(ChannelAttachment).optional(),
51
53
  })
52
54
  .optional(),
53
55
  wsId: z.string().optional(),
@@ -7,6 +7,7 @@ import { type AgentMiddleware, createMiddleware } from 'langchain';
7
7
  import type { Logger } from '../../plugin-api/types';
8
8
  import { NOOP_LOGGER } from '../utils';
9
9
  import { isCapabilityGateRefusal } from './capability-gate';
10
+ import { isToolNotRun } from '../tool-not-run';
10
11
  import { turnCarryOf, turnStart } from './turn-boundary';
11
12
 
12
13
  export { turnStart };
@@ -145,9 +146,11 @@ export const createToolRepetitionGuardMiddleware = (
145
146
  const msg = messages[i];
146
147
  if (!(msg instanceof ToolMessage)) continue;
147
148
  if (msg.status !== 'error') continue;
148
- // The capability gate refused it without running it; after a
149
- // `load_capability` the same call is the expected next step.
150
- if (isCapabilityGateRefusal(msg)) continue;
149
+ // The capability gate refused it without running it, or the tool
150
+ // said it did not run until a step comes first: after that step
151
+ // (`load_capability`, reading the rules) the same call is the
152
+ // expected next one.
153
+ if (isCapabilityGateRefusal(msg) || isToolNotRun(msg)) continue;
151
154
  if (msg.name !== toolName) continue;
152
155
 
153
156
  const priorArgs = argsById.get(msg.tool_call_id);
@@ -22,6 +22,7 @@ import {
22
22
  } from '@langchain/core/messages';
23
23
  import { isAttachmentViewMessage } from '../../attachments/retention';
24
24
  import { isCapabilityGateRefusal } from './capability-gate';
25
+ import { isToolNotRun } from '../tool-not-run';
25
26
 
26
27
  export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
27
28
 
@@ -140,7 +141,11 @@ export function carriedCallsOfTurn(
140
141
  if (call.id) calls.set(call.id, { name: call.name, args: call.args });
141
142
  continue;
142
143
  }
143
- if (!ToolMessage.isInstance(message) || isCapabilityGateRefusal(message))
144
+ if (
145
+ !ToolMessage.isInstance(message) ||
146
+ isCapabilityGateRefusal(message) ||
147
+ isToolNotRun(message)
148
+ )
144
149
  continue;
145
150
  const call = calls.get(message.tool_call_id);
146
151
  if (!call) continue;
@@ -1,3 +1,4 @@
1
+ import type { ChannelAttachment } from '../channels/contract';
1
2
  import {
2
3
  UNAVAILABLE_DECISION_EVALUATOR,
3
4
  type DecisionEvaluateOptions,
@@ -330,6 +331,8 @@ export interface RuntimeSessionContext {
330
331
  provider: string;
331
332
  bindingId: string;
332
333
  bindingRevision: number;
334
+ /** Files sent with this message (see `RuntimeContext.session.channel`). */
335
+ attachments?: readonly ChannelAttachment[];
333
336
  };
334
337
  roomId?: string;
335
338
  surface?: SessionSurface;
@@ -0,0 +1,30 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+
3
+ /** `response_metadata` key of a result whose tool did not run (never sent to a provider). */
4
+ const NOT_RUN = 'toolNotRun';
5
+
6
+ /**
7
+ * A tool result saying the tool did not run because another step must come
8
+ * first (for example reading a message type's rules before preparing it).
9
+ * It is an error result, so it never counts as a write that happened, and
10
+ * like a capability-gate refusal the repetition guard lets the identical
11
+ * call through once the step is done (`isToolNotRun`).
12
+ */
13
+ export function toolNotRunMessage(input: {
14
+ toolCallId: string;
15
+ name: string;
16
+ content: string;
17
+ }): ToolMessage {
18
+ return new ToolMessage({
19
+ tool_call_id: input.toolCallId,
20
+ name: input.name,
21
+ content: input.content,
22
+ status: 'error',
23
+ response_metadata: { [NOT_RUN]: true },
24
+ });
25
+ }
26
+
27
+ /** A result `toolNotRunMessage` made: the tool did not run. */
28
+ export function isToolNotRun(message: ToolMessage): boolean {
29
+ return message.response_metadata?.[NOT_RUN] === true;
30
+ }
@@ -1,3 +1,4 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
1
2
  import { Command } from '@langchain/langgraph';
2
3
  import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
3
4
  import { tool } from '@langchain/core/tools';
@@ -125,7 +126,14 @@ export function wrapPluginTool(
125
126
  if (pluginName !== undefined)
126
127
  requireToolPlane(ctx, pluginName, pluginTool);
127
128
  const output = await pluginTool.handler(args, ctx);
128
- if (!resultCap || output instanceof Command || output == null)
129
+ // A tool's own message (a result that says the tool did not run) is
130
+ // returned whole, never capped into text.
131
+ if (
132
+ !resultCap ||
133
+ output instanceof Command ||
134
+ ToolMessage.isInstance(output) ||
135
+ output == null
136
+ )
129
137
  return output;
130
138
  const text = typeof output === 'string' ? output : JSON.stringify(output);
131
139
  if (typeof text !== 'string' || text.length <= resultCap.capChars)
@@ -165,7 +165,9 @@ export function draftReplyPlan(input: {
165
165
  for (const call of step.toolCalls) {
166
166
  if (
167
167
  call.name === 'request_hub_action' ||
168
- call.name === 'request_chain_transaction'
168
+ call.name === 'request_chain_transaction' ||
169
+ // The Yoma plugin's application and claim cards.
170
+ call.name === 'yoma_form'
169
171
  ) {
170
172
  try {
171
173
  const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChannelAttachment,
2
3
  ChannelControlInput,
3
4
  ChannelControlOutcome,
4
5
  ChannelReplyContext,
@@ -400,6 +401,8 @@ export interface TurnRequest {
400
401
  replyTo?: ChannelReplyReference;
401
402
  /** Host-generated direct reply: no agent or tools run for an ambiguous destination. */
402
403
  routingClarification?: string;
404
+ /** Files the person sent with this message, as the gateway serves them. */
405
+ attachments?: ChannelAttachment[];
403
406
  };
404
407
  executionProfile?: TaskExecutionProfile;
405
408
  identity: TurnIdentity;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The attachments of one turn attempt: which files the run reads, how a
3
+ * channel file's `channel:` reference becomes its link (server-side, never
4
+ * through the model), and when nothing is downloaded at all.
5
+ */
6
+ import {
7
+ attachmentMetaFor,
8
+ channelAttachmentRef,
9
+ channelMediaOrigins,
10
+ createChannelMediaSource,
11
+ parseAttachmentInputs,
12
+ type AttachmentInput,
13
+ type ChannelAttachmentLinkStore,
14
+ type ChannelMediaLink,
15
+ type ChannelMediaSource,
16
+ type PreparedAttachments,
17
+ } from '../attachments';
18
+ import { parseChannelServiceDids } from '../channels/auth';
19
+ import {
20
+ channelAttachmentInputs,
21
+ type ChannelAttachment,
22
+ } from '../channels/contract';
23
+ import type { AttachmentMeta } from '../sqlite/serialization';
24
+
25
+ /** The part of a turn request the attachment inputs come from. */
26
+ interface TurnFiles {
27
+ client: string;
28
+ attachments?: unknown;
29
+ channel?: { bindingId: string; attachments?: readonly ChannelAttachment[] };
30
+ }
31
+
32
+ /**
33
+ * What the run reads: a channel turn's files by their `channel:` reference
34
+ * (from the gateway's list on the request), otherwise the request body's
35
+ * attachments as the Portal and Matrix send them. A body can never name a
36
+ * `channel:` reference itself.
37
+ */
38
+ export function turnAttachmentInputs(
39
+ req: TurnFiles,
40
+ bodyAttachments: unknown,
41
+ ): AttachmentInput[] {
42
+ const files = req.client === 'channel' ? req.channel?.attachments : undefined;
43
+ if (files?.length) return channelAttachmentInputs(files);
44
+ return parseAttachmentInputs(bodyAttachments ?? req.attachments);
45
+ }
46
+
47
+ /** A channel turn's files as the links the owner object resolves references to. */
48
+ export function channelTurnLinks(
49
+ req: TurnFiles,
50
+ ): Array<ChannelMediaLink & { ref: string }> {
51
+ if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
52
+ const bindingId = req.channel.bindingId;
53
+ return req.channel.attachments.map((file) => ({
54
+ ref: channelAttachmentRef(file.sha256),
55
+ url: file.url,
56
+ sha256: file.sha256,
57
+ bindingId,
58
+ }));
59
+ }
60
+
61
+ /**
62
+ * The channel source of a turn in `sessionId`: a reference resolves to the
63
+ * turn's own files first, then to the links kept for the session; only the
64
+ * gateways of `CHANNEL_SERVICE_DID` are fetched from.
65
+ */
66
+ export function turnChannelMediaSource(input: {
67
+ sessionId: string;
68
+ turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
69
+ store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
70
+ channelServiceDid: string | undefined;
71
+ }): ChannelMediaSource {
72
+ return createChannelMediaSource({
73
+ origins: channelMediaOrigins(
74
+ parseChannelServiceDids(input.channelServiceDid),
75
+ ),
76
+ resolve: async (ref) => {
77
+ const own = input.turnLinks.find((link) => link.ref === ref);
78
+ if (own) return own;
79
+ return input.store?.get(input.sessionId, ref);
80
+ },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * The turn's prepared attachments, or none: a resumed attempt hands the
86
+ * graph no new input (the person's message, with its files, is already in
87
+ * the checkpoint), so nothing is downloaded again; its metadata is still
88
+ * known for `view_attachment`.
89
+ */
90
+ export async function prepareTurnAttachments(input: {
91
+ inputs: readonly AttachmentInput[];
92
+ resumed: boolean;
93
+ prepare: () => Promise<PreparedAttachments>;
94
+ }): Promise<{ prepared: PreparedAttachments | null; metas: AttachmentMeta[] }> {
95
+ if (input.inputs.length === 0) return { prepared: null, metas: [] };
96
+ if (input.resumed)
97
+ return {
98
+ prepared: null,
99
+ metas: input.inputs.map((attachment) => attachmentMetaFor(attachment)),
100
+ };
101
+ const prepared = await input.prepare();
102
+ return { prepared, metas: prepared.metas };
103
+ }