@ixo/oracle-runtime-workers 0.38.0 → 0.39.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 (86) hide show
  1. package/package.json +2 -2
  2. package/src/channels/replies.ts +8 -4
  3. package/src/core/capability-expiry.ts +62 -0
  4. package/src/core/capability-router.ts +126 -95
  5. package/src/core/context-budget.ts +116 -4
  6. package/src/core/domain-context/document.ts +74 -2
  7. package/src/core/domain-context/index.ts +1 -0
  8. package/src/core/domain-context/resolver.ts +9 -6
  9. package/src/core/domain-context/turn-context.ts +62 -13
  10. package/src/core/domain-context/types.ts +17 -0
  11. package/src/core/env.ts +21 -2
  12. package/src/core/index.ts +4 -0
  13. package/src/core/llm.ts +29 -0
  14. package/src/core/main-agent-types.ts +45 -4
  15. package/src/core/main-agent.ts +210 -122
  16. package/src/core/manifest.ts +109 -24
  17. package/src/core/mcp-result.ts +56 -0
  18. package/src/core/meta-tools.ts +258 -110
  19. package/src/core/middlewares/capability-gate.ts +77 -28
  20. package/src/core/middlewares/context-guard.ts +154 -21
  21. package/src/core/middlewares/result-cap.ts +1 -1
  22. package/src/core/middlewares/summarization.ts +9 -0
  23. package/src/core/middlewares/tool-marks.ts +11 -2
  24. package/src/core/middlewares/turn-boundary.ts +22 -12
  25. package/src/core/operating-guides.ts +126 -0
  26. package/src/core/plugins/skills/skills-tools.ts +6 -31
  27. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  28. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  29. package/src/core/prompt-composer.ts +188 -111
  30. package/src/core/read-result-tool.ts +48 -14
  31. package/src/core/registries.ts +13 -5
  32. package/src/core/state.ts +4 -3
  33. package/src/core/sub-agent-fallback.ts +1 -0
  34. package/src/core/subagent-as-tool.ts +4 -1
  35. package/src/core/tool-access.ts +3 -32
  36. package/src/core/wrap-plugin-tool.ts +2 -1
  37. package/src/do/contracts.ts +13 -1
  38. package/src/do/transcript.ts +40 -11
  39. package/src/do/turn-metadata.ts +16 -4
  40. package/src/do/user-oracle-do.ts +132 -59
  41. package/src/index.ts +1 -0
  42. package/src/memory/session-history-indexer.ts +60 -14
  43. package/src/plugin-api/oracle-plugin.ts +4 -5
  44. package/src/plugin-api/types.ts +32 -3
  45. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  46. package/src/plugins/agui/agui.plugin.ts +1 -0
  47. package/src/plugins/compact-schema.ts +80 -0
  48. package/src/plugins/composio/composio.plugin.ts +1 -0
  49. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  50. package/src/plugins/editor/editor.plugin.ts +4 -0
  51. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  52. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  53. package/src/plugins/flows/flows.plugin.ts +37 -6
  54. package/src/plugins/flows/prompts.ts +1 -1
  55. package/src/plugins/flows/tools/authoring.ts +5 -7
  56. package/src/plugins/flows/tools/discovery.ts +1 -4
  57. package/src/plugins/flows/tools/forms.ts +2 -8
  58. package/src/plugins/flows/tools/linkage.ts +1 -4
  59. package/src/plugins/flows/tools/settings.ts +1 -4
  60. package/src/plugins/flows/translator.ts +16 -5
  61. package/src/plugins/flows/types.ts +27 -48
  62. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  63. package/src/plugins/index.ts +2 -2
  64. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  65. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  66. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  67. package/src/plugins/ixo-chain/queries.ts +10 -1
  68. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  69. package/src/plugins/matrix-group-chats/index.ts +1 -0
  70. package/src/plugins/memory/memory-tools.ts +4 -4
  71. package/src/plugins/memory/memory.plugin.ts +27 -1
  72. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  73. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  74. package/src/plugins/portal/portal.plugin.ts +1 -0
  75. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  76. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  77. package/src/plugins/tasks/manifest.ts +1 -0
  78. package/src/plugins/tasks/tasks-tools.ts +14 -19
  79. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  80. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  81. package/src/plugins/vfs/vfs-tools.ts +6 -6
  82. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  83. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  84. package/src/sqlite/serialization.ts +12 -0
  85. package/src/sqlite/sqlite-saver.ts +17 -0
  86. package/src/tasks/schedule-input.ts +5 -22
@@ -27,8 +27,13 @@
27
27
  * transcript's last chance).
28
28
  * - Speakers get real identities (the user's preferred name / the oracle's
29
29
  * preferred or configured name) so Graphiti's extractor does not pin
30
- * facts to literal "user"/"assistant" nodes. Tool replies fold into the
31
- * assistant role (the engine has no tool role).
30
+ * facts to literal "user"/"assistant" nodes.
31
+ * - Only the conversation is sent: what the user said and the oracle's
32
+ * replies (`conversationReader`). The engine makes every message an episode
33
+ * of evidence, so a tool result, a status line before a tool call, or a
34
+ * reply that repeats what a memory search just returned would come back
35
+ * as a fact, and push real ones out of the capped user-context block. Those
36
+ * messages still count towards the watermark.
32
37
  * - Auth is the oracle's two-hop UCAN invocation minted from the user's
33
38
  * delegation (`ixo:memory` / `memory/*`) — no delegation, no indexing.
34
39
  * - Endpoint: `POST <MEMORY_ENGINE_URL>/messages` with `x-room-id`, as
@@ -45,13 +50,18 @@
45
50
  * Not ported: Node also asks Matrix for the user's display name when no
46
51
  * preferred name is set; here the fallback is the literal "Me" straight away.
47
52
  */
53
+ import { AIMessage, type BaseMessage } from '@langchain/core/messages';
54
+ import { visibleText, visibleToUser } from '../do/transcript';
48
55
  import type { Logger } from '../plugin-api/types';
49
56
  import { MEMORY_ENTITY_HEADER } from '../plugins/memory/memory-entity';
57
+ import { MEMORY_SEARCH_MCP_NAME } from '../plugins/memory/memory-tools';
50
58
 
51
59
  export interface HistoryMessage {
52
60
  /** LangChain message type: `human` | `ai` | `tool` | `system`. */
53
61
  type: string;
54
62
  content: string;
63
+ /** Counted by the watermark but not sent (`conversationReader`). */
64
+ notConversation?: boolean;
55
65
  }
56
66
 
57
67
  export interface MemoryEngineMessage {
@@ -230,6 +240,47 @@ interface QuietWait {
230
240
  resolve: () => void;
231
241
  }
232
242
 
243
+ /** Text that parses as a JSON object or list: a tool's output, not a reply. */
244
+ function isJsonDocument(text: string): boolean {
245
+ const start = text.trimStart()[0];
246
+ if (start !== '{' && start !== '[') return false;
247
+ try {
248
+ JSON.parse(text);
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Reads a transcript in order, one reader per read, and turns each message
257
+ * into what the indexer takes. A turn's memory search is seen before its reply,
258
+ * so that reply is known to repeat what memory returned, which must not go
259
+ * back in as new evidence. A read that starts in the middle of a turn has not
260
+ * seen the search; a pass normally starts at a turn's end.
261
+ */
262
+ export function conversationReader(): (message: BaseMessage) => HistoryMessage {
263
+ let readMemory = false;
264
+ return (message) => {
265
+ const content = visibleText(message);
266
+ const calls = AIMessage.isInstance(message)
267
+ ? (message.tool_calls ?? [])
268
+ : [];
269
+ if (message.type === 'human') readMemory = false;
270
+ if (calls.some((call) => call.name === MEMORY_SEARCH_MCP_NAME))
271
+ readMemory = true;
272
+ const conversation =
273
+ visibleToUser(message) &&
274
+ (message.type === 'human' ||
275
+ (calls.length === 0 && !readMemory && !isJsonDocument(content)));
276
+ return {
277
+ type: message.type,
278
+ content,
279
+ ...(conversation ? {} : { notConversation: true }),
280
+ };
281
+ };
282
+ }
283
+
233
284
  /** Node's `transformMessagesToMemoryEngineFormat`, verbatim in mapping. */
234
285
  export function transformMessagesToMemoryEngineFormat(
235
286
  messages: readonly HistoryMessage[],
@@ -237,7 +288,12 @@ export function transformMessagesToMemoryEngineFormat(
237
288
  userSpeakerLabel: string,
238
289
  oracleSpeakerLabel: string,
239
290
  ): MemoryEngineMessage[] {
240
- return messages.map((message) => {
291
+ // The engine rejects empty episodes, and has no tool role.
292
+ const conversation = messages.filter(
293
+ (m) =>
294
+ m.type !== 'tool' && !m.notConversation && m.content.trim().length > 0,
295
+ );
296
+ return conversation.map((message) => {
241
297
  let role_type: MemoryEngineMessage['role_type'];
242
298
  let label: string;
243
299
  switch (message.type) {
@@ -253,12 +309,6 @@ export function transformMessagesToMemoryEngineFormat(
253
309
  role_type = 'system';
254
310
  label = 'System';
255
311
  break;
256
- case 'tool':
257
- // The engine has no tool role — tool replies are the oracle
258
- // reporting back, so they share the oracle speaker label.
259
- role_type = 'assistant';
260
- label = oracleSpeakerLabel;
261
- break;
262
312
  default:
263
313
  role_type = 'user';
264
314
  label = userSpeakerLabel;
@@ -564,11 +614,7 @@ export class SessionHistoryIndexer {
564
614
  try {
565
615
  while (chunk.length > 0) {
566
616
  const transformed = transformMessagesToMemoryEngineFormat(
567
- // Tool-call-only AI turns carry no text; the engine has nothing to
568
- // extract from them and rejects empty episodes.
569
- chunk
570
- .map((entry) => entry.message)
571
- .filter((m) => m.content.trim().length > 0),
617
+ chunk.map((entry) => entry.message),
572
618
  session.title ?? '',
573
619
  labels.user,
574
620
  labels.oracle,
@@ -63,11 +63,10 @@ export abstract class OraclePlugin {
63
63
  * plugin's working rules, as plain text (markdown, starting at a `###`
64
64
  * heading). Never part of the manifest, the Tier-1 capability block or
65
65
  * `list_capabilities`, so it costs nothing on turns that do not use the
66
- * plugin. `load_capability` returns it (once per turn) when it loads the
67
- * plugin or the capability router preloaded it for the turn, and the
68
- * system prompt carries it on every turn that starts with the plugin
69
- * loaded on the thread (or `always` visible) and usable by the user's
70
- * delegation.
66
+ * plugin. The model has it when it first uses the plugin's tools: in the
67
+ * load result or on the turn's message when the plugin loads on a warm
68
+ * turn, in the system prompt from the next cold turn on, and always there
69
+ * for an `always` plugin the user's delegation can use.
71
70
  */
72
71
  readonly operatingGuide?: string;
73
72
 
@@ -473,15 +473,39 @@ export interface PluginManifest {
473
473
  /**
474
474
  * Discovery and loading mode:
475
475
  * - `always` — tools bound to agent at boot; listed in Tier-1 prompt.
476
- * - `on-demand` — tools NOT bound; manifest discoverable via
477
- * `list_capabilities`; agent calls `load_capability(name)`
478
- * to load.
476
+ * - `on-demand` — tools NOT bound; named in the prompt's on-demand
477
+ * catalogue; agent calls `load_capability(name)` to load.
479
478
  * - `silent` — invisible to agent; runs as middleware-only.
480
479
  *
481
480
  * Default: `on-demand`.
482
481
  */
483
482
  visibility?: 'always' | 'on-demand' | 'silent';
484
483
 
484
+ /**
485
+ * A few words (at most 60 characters) on what `load_capability` adds: the
486
+ * line the system prompt's on-demand catalogue shows for this plugin, so
487
+ * the model can pick it without a `list_capabilities` round trip. Falls
488
+ * back to a clipped `summary`.
489
+ */
490
+ brief?: string;
491
+
492
+ /**
493
+ * Tools this plugin keeps out of the model's tool list until
494
+ * `load_capability` names the plugin, while its other tools (and, for an
495
+ * `always` plugin, its Tier-1 entry) stay in place. Names are matched at
496
+ * request time, so MCP-sourced tools can be listed. A tool's own
497
+ * `visibility` wins over this list. A host keeps every tool bound with
498
+ * `manifestOverrides: { name: { onDemandTools: [] } }`.
499
+ */
500
+ onDemandTools?: readonly string[];
501
+ /**
502
+ * `false` keeps the plugin away from the capability router: it stays in the
503
+ * catalogue and `load_capability` loads it, but no message preloads it. For
504
+ * a plugin whose name attracts messages its gated tools do not serve, or
505
+ * one the host loads itself when it applies.
506
+ */
507
+ routable?: boolean;
508
+
485
509
  /** Stability hint surfaced to the agent (`experimental` → warning footnote). */
486
510
  stability?: 'stable' | 'beta' | 'experimental';
487
511
 
@@ -1297,6 +1321,11 @@ export interface PluginSubAgent {
1297
1321
  forwardTools?: boolean | string[];
1298
1322
  /** Called after sub-agent completes; can emit follow-up events. */
1299
1323
  onComplete?: (result: string, ctx: RuntimeContext) => Promise<void>;
1324
+ /**
1325
+ * Added to the error the model reads when the sub-agent could not run, so a
1326
+ * capability that is not loaded can still be the way forward.
1327
+ */
1328
+ failureHint?: string;
1300
1329
  }
1301
1330
 
1302
1331
  /**
@@ -10,6 +10,7 @@ import {
10
10
  type ActionReference,
11
11
  } from '@ixo/ixo-transaction/action-proposals';
12
12
  import { z } from 'zod';
13
+ import { modelFacingSchema } from '../compact-schema';
13
14
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
14
15
  import { tool } from '../../plugin-api/tool-helper';
15
16
  import type {
@@ -131,7 +132,9 @@ export async function prepareAction(
131
132
  const parsed = PrepareActionSchema.safeParse(input);
132
133
  if (!parsed.success)
133
134
  return clarify(
134
- 'Provide the exact supported action, network, recipient and amount.',
135
+ `Provide the exact supported action, network, recipient and amount (${parsed.error.issues
136
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
137
+ .join('; ')}).`,
135
138
  );
136
139
  if (ctx.config.ACTION_PROPOSALS_ENABLED !== 'true' || !ctx.actionProposals)
137
140
  return clarify('Action proposals are not enabled for this companion.');
@@ -459,6 +462,7 @@ const manifest: PluginManifest = {
459
462
  tags: ['actions', 'wallet', 'channels'],
460
463
  category: 'integration',
461
464
  visibility: 'on-demand',
465
+ brief: 'transfers, discussions and grants to sign in Portal',
462
466
  stability: 'experimental',
463
467
  };
464
468
  export class ActionProposalsPlugin extends OraclePlugin {
@@ -478,7 +482,8 @@ export class ActionProposalsPlugin extends OraclePlugin {
478
482
  name: 'prepare_action',
479
483
  description:
480
484
  'Prepare a validated transfer, draft discussion or topic claim-access grant for explicit owner review. Never executes. Use exact supported inputs from current-session user evidence and authenticated current-room/owner bindings. Other topic kinds and unverified identities require clarification.',
481
- schema: PrepareActionSchema,
485
+ // The handler parses PrepareActionSchema and names what it refuses.
486
+ schema: modelFacingSchema(PrepareActionSchema),
482
487
  effect: 'write',
483
488
  }),
484
489
  ];
@@ -50,6 +50,7 @@ const manifest: PluginManifest = {
50
50
  tags: ['agui', 'ui', 'portal', 'copilot'],
51
51
  category: 'ui',
52
52
  visibility: 'on-demand',
53
+ brief: 'show interactive tables, charts and forms',
53
54
  stability: 'stable',
54
55
  };
55
56
 
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+
3
+ type JsonSchema = z.core.JSONSchema._JSONSchema;
4
+ type ObjectSchema = z.core.JSONSchema.JSONSchema;
5
+
6
+ /**
7
+ * Keywords a model does not need to write a valid call and that repeat all
8
+ * over a large schema. They stay enforced by the handler's strict parse.
9
+ */
10
+ const CHECK_ONLY = [
11
+ 'minLength',
12
+ 'maxLength',
13
+ 'pattern',
14
+ 'minItems',
15
+ 'maxItems',
16
+ 'minimum',
17
+ 'maximum',
18
+ 'exclusiveMinimum',
19
+ 'exclusiveMaximum',
20
+ 'propertyNames',
21
+ '$schema',
22
+ ] as const;
23
+
24
+ type Options = { descriptions?: boolean };
25
+
26
+ function compactInner(schema: JsonSchema, options: Options): JsonSchema {
27
+ return typeof schema === 'boolean'
28
+ ? schema
29
+ : compactJsonSchema(schema, options);
30
+ }
31
+
32
+ /**
33
+ * `schema` with the same shape (properties, required keys, types, enums,
34
+ * constants, nesting) and without {@link CHECK_ONLY}, and without its
35
+ * descriptions when `descriptions` is false.
36
+ */
37
+ export function compactJsonSchema(
38
+ schema: ObjectSchema,
39
+ options: Options = {},
40
+ ): ObjectSchema {
41
+ const compact = { ...schema };
42
+ for (const keyword of CHECK_ONLY) delete compact[keyword];
43
+ if (options.descriptions === false) delete compact.description;
44
+ const { properties, items, additionalProperties, anyOf, oneOf } = schema;
45
+ if (properties)
46
+ compact.properties = Object.fromEntries(
47
+ Object.entries(properties).map(([name, property]) => [
48
+ name,
49
+ compactInner(property, options),
50
+ ]),
51
+ );
52
+ if (items !== undefined)
53
+ compact.items = Array.isArray(items)
54
+ ? items.map((item) => compactInner(item, options))
55
+ : compactInner(items, options);
56
+ if (additionalProperties !== undefined)
57
+ compact.additionalProperties = compactInner(additionalProperties, options);
58
+ if (anyOf) compact.anyOf = anyOf.map((s) => compactJsonSchema(s, options));
59
+ if (oneOf) compact.oneOf = oneOf.map((s) => compactJsonSchema(s, options));
60
+ return compact;
61
+ }
62
+
63
+ /**
64
+ * The schema a tool binds when its handler parses its input strictly itself:
65
+ * the shape of `schema` as its input, reduced by {@link compactJsonSchema}.
66
+ * Tools repeating a large schema bind this, so the model reads each field's
67
+ * limits and words once at most; a call the bound schema lets through and
68
+ * the handler refuses must name the problem in its answer.
69
+ */
70
+ export function modelFacingSchema(
71
+ schema: z.ZodType,
72
+ options: Options = {},
73
+ ): z.ZodType {
74
+ return z.fromJSONSchema(
75
+ compactJsonSchema(
76
+ z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }),
77
+ options,
78
+ ),
79
+ );
80
+ }
@@ -100,6 +100,7 @@ const manifest: PluginManifest = {
100
100
  ],
101
101
  category: 'integration',
102
102
  visibility: 'on-demand',
103
+ brief: 'Gmail, GitHub, Slack, Calendar and hundreds of apps',
103
104
  stability: 'stable',
104
105
  requires: [SANDBOX_CAPABILITY],
105
106
  };
@@ -45,14 +45,14 @@ function resolveDomainIndexerUrl(config: MergedConfig): string {
45
45
  const manifest: PluginManifest = {
46
46
  title: 'Domain Indexer',
47
47
  summary:
48
- 'Domain analysis and entity lookup across the IXO ecosystem — organizations, projects, DAOs, DIDs.',
48
+ 'Search the IXO ecosystem by name or topic: organizations, projects, DAOs and their domain cards.',
49
49
  whenToUse: [
50
- 'User asks "what is X?" or "tell me about X" for an organization, project, DAO, or DID.',
50
+ 'User asks "what is X?" or "tell me about X" for an organization, project or DAO by name.',
51
51
  'User needs the summary, overview, or FAQ of an IXO entity.',
52
- 'Looking up a domain card by its DID.',
53
52
  'Discovering entities by topic, category, or keyword.',
54
53
  ],
55
54
  whenNotToUse: [
55
+ 'One exact DID: read the chain first (`chain_lookup` kind `entity`).',
56
56
  'General web search unrelated to IXO entities (use Firecrawl).',
57
57
  'Personal memory or past-conversation recall (use Memory).',
58
58
  'Page editing or workspace pages (use Editor) — pages are NOT entities.',
@@ -75,6 +75,7 @@ const manifest: PluginManifest = {
75
75
  // via `load_capability` when the conversation actually needs it, keeping
76
76
  // the sub-agent schema out of every unrelated chat turn.
77
77
  visibility: 'on-demand',
78
+ brief: 'search organisations, projects and DAOs by name or topic',
78
79
  stability: 'stable',
79
80
  };
80
81
 
@@ -72,6 +72,10 @@ const manifest: PluginManifest = {
72
72
  category: 'data',
73
73
  // On-demand so ordinary chats carry no editor Tier-1 entry or gated tools.
74
74
  visibility: 'on-demand',
75
+ brief: "read and edit the user's open editor document",
76
+ // An open document loads it (the editor context); without one there is
77
+ // nothing for its tools to read.
78
+ routable: false,
75
79
  stability: 'stable',
76
80
  };
77
81
 
@@ -89,5 +89,7 @@ export function createFirecrawlSubAgent(tools: PluginTool[]): PluginSubAgent {
89
89
  tools,
90
90
  model: 'subagent',
91
91
  middlewares: [],
92
+ failureHint:
93
+ 'Web search is unavailable, but this is not the end of the request: a page or feed can still be fetched with code. Load the `sandbox` capability if it is not in your tool list and fetch the URL with `sandbox_run`, or use another capability that can answer. Tell the user only if none can.',
92
94
  };
93
95
  }
@@ -48,6 +48,7 @@ const manifest: PluginManifest = {
48
48
  tags: ['web', 'search', 'scrape', 'firecrawl'],
49
49
  category: 'data',
50
50
  visibility: 'on-demand',
51
+ brief: 'search the web; scrape and read web pages',
51
52
  stability: 'stable',
52
53
  };
53
54
 
@@ -19,12 +19,14 @@
19
19
  * context where timers are allowed.
20
20
  */
21
21
  import type { MatrixClient } from 'matrix-js-sdk';
22
+ import type { z } from 'zod';
22
23
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
23
24
  import type {
24
25
  PluginManifest,
25
26
  PluginTool,
26
27
  RuntimeContext,
27
28
  } from '../../plugin-api/types';
29
+ import { modelFacingSchema } from '../compact-schema';
28
30
  import { FLOWS_OPERATING_GUIDE, FLOWS_PLUGIN_NAME } from './prompts';
29
31
 
30
32
  export interface FlowsPluginOptions {
@@ -37,11 +39,9 @@ export interface FlowsPluginOptions {
37
39
  * `summary` is the one-line teaser; `whenToUse`/`whenNotToUse`/`examples` teach
38
40
  * the agent when and how to load it. The full operating contract,
39
41
  * `FLOWS_OPERATING_GUIDE` (prompts.ts), is the plugin's `operatingGuide`, not
40
- * part of the manifest: `load_capability` returns it when it loads flows (also
41
- * when the capability router had only preloaded it for the turn), and the
42
- * system prompt carries it on every later turn that starts with flows loaded
43
- * on the thread. A one-turn router preload leaves the system prompt alone, and
44
- * turns that never use flows pay no tokens for it.
42
+ * part of the manifest: it reaches the model with the load, in the system
43
+ * prompt on a cold turn and at the tail of the messages otherwise, and turns
44
+ * that never use flows pay no tokens for it (core/operating-guides.ts).
45
45
  */
46
46
  const manifest: PluginManifest = {
47
47
  title: 'Flow Builder',
@@ -80,9 +80,39 @@ const manifest: PluginManifest = {
80
80
  tags: ['flows', 'templates', 'automation', 'workflow', 'forms'],
81
81
  category: 'automation',
82
82
  visibility: 'on-demand',
83
+ brief: 'build and run automation flows from action blocks',
83
84
  stability: 'beta',
84
85
  };
85
86
 
87
+ /**
88
+ * The tools that repeat `create_template`'s step in their schema. All 27 are
89
+ * bound together, so the step's words appear once, on `create_template`.
90
+ */
91
+ const REPEATS_STEP: ReadonlySet<string> = new Set([
92
+ 'validate_flow',
93
+ 'add_step',
94
+ 'update_step',
95
+ ]);
96
+
97
+ const boundSchemas = new Map<string, z.ZodType>();
98
+
99
+ /**
100
+ * What a flow tool binds: its schema's shape without limits and patterns.
101
+ * Every handler parses its own strict schema first and answers
102
+ * `validation_failed` with its messages.
103
+ * Built once per isolate; a tool's schema does not depend on the turn.
104
+ */
105
+ function boundSchema(tool: PluginTool): z.ZodType {
106
+ let schema = boundSchemas.get(tool.name);
107
+ if (!schema) {
108
+ schema = modelFacingSchema(tool.schema, {
109
+ descriptions: !REPEATS_STEP.has(tool.name),
110
+ });
111
+ boundSchemas.set(tool.name, schema);
112
+ }
113
+ return schema;
114
+ }
115
+
86
116
  export class FlowsPlugin extends OraclePlugin {
87
117
  readonly name = FLOWS_PLUGIN_NAME;
88
118
  readonly version = '0.1.0';
@@ -112,7 +142,7 @@ export class FlowsPlugin extends OraclePlugin {
112
142
  import('./tools/settings'),
113
143
  ]);
114
144
 
115
- return [
145
+ const tools = [
116
146
  // Discovery
117
147
  discovery.buildListActionsTool(),
118
148
  discovery.buildDescribeActionTool(),
@@ -131,5 +161,6 @@ export class FlowsPlugin extends OraclePlugin {
131
161
  // Forms
132
162
  ...forms.buildFormTools(this.matrixClient),
133
163
  ];
164
+ return tools.map((tool) => ({ ...tool, schema: boundSchema(tool) }));
134
165
  }
135
166
  }
@@ -56,7 +56,7 @@ You have loaded the **Flow Builder** capability. Use it whenever the user wants
56
56
  - \`set_step_trigger\` sets \`manual\` vs \`flow-start\`. \`onEvent\` is only valid when the upstream action actually emits events (\`validate_flow\` enforces this); otherwise fall back to order + data references and the user runs the step manually.
57
57
  - Every newly authored condition declares \`source: "configured_input"\` or \`source: "runtime_output"\`. Use configured inputs for authored values and runtime output for a previous step's successful result. Untagged stored conditions retain the runner's runtime-output interpretation.
58
58
 
59
- **Asking vs guessing:** STOP and ask for any \`requires\` you can't fill from context (collection ids, DIDs, recipients, connected accounts, template names, endpoints, amounts). Never fabricate them. But don't ask for things the portal collects at run time, or that you can reasonably infer.
59
+ **Asking vs guessing:** STOP and ask for any \`requires\` you can't fill from context (collection ids, DIDs, recipients, connected accounts, template names, endpoints, amounts). Never fabricate them. But don't ask for things the portal collects at run time, or that you can reasonably infer. When the user tells you not to ask or to make your own choices, skip the confirmation and build: put a visible placeholder such as \`<claim collection id>\` in place of what you can't fill, never an invented value, and list the placeholders when you hand off.
60
60
 
61
61
  **Secrets:** never ask for a PIN, mnemonic, password, access token, recovery phrase or API key in chat, and never write one into a flow — every room member can read the flow. PINs are collected by the portal when the user runs the step. A secret input (\`pin\`, \`mnemonic\`, \`matrixPassword\`, \`matrixAccessToken\`, \`matrixRecoveryPhrase\`, a plaintext API key) may only hold a \`{{step-id.output.field}}\` reference to a step of this flow (e.g. a wallet step's mnemonic output); the tools refuse anything else. If the user offers a PIN in chat, tell them not to share it and that the portal will ask for it.
62
62
 
@@ -65,10 +65,7 @@ const flowSpecTitleSchema = z.string().min(1).max(MAX_NAME_CHARS);
65
65
  const flowSpecGoalSchema = z.string().max(MAX_DESCRIPTION_CHARS);
66
66
 
67
67
  const base = {
68
- flowRef: z
69
- .string()
70
- .optional()
71
- .describe('Which flow. Omit to use the flow that is currently open.'),
68
+ flowRef: z.string().optional().describe('Default: the open flow.'),
72
69
  };
73
70
  const ok = { ok: true } as const;
74
71
 
@@ -141,7 +138,8 @@ function buildValidateFlowTool(): PluginTool {
141
138
  name: 'validate_flow',
142
139
  description:
143
140
  'Check whether a flow is valid without saving it. Returns the exact problems (unknown action, duplicate step id, ' +
144
- 'a non-event-capable auto-trigger, a trigger cycle, etc.) so they can be fixed before creating the flow.',
141
+ 'a non-event-capable auto-trigger, a trigger cycle, etc.) so they can be fixed before creating the flow. ' +
142
+ '`flow` as in create_template.',
145
143
  schema: validateSchema,
146
144
  },
147
145
  );
@@ -415,7 +413,7 @@ function buildAddStepTool(matrixClient: MatrixClient | undefined): PluginTool {
415
413
  name: 'add_step',
416
414
  description:
417
415
  'Add one step to an existing flow without disturbing the others: the title, owner and step order stay as they are. ' +
418
- 'Optionally place it at a position. A step id that already exists is refused.',
416
+ 'A step id that already exists is refused. `step` as in create_template. `position`: a 0-based index, {after: id} or {before: id}; default: append.',
419
417
  schema: addStepSchema,
420
418
  },
421
419
  );
@@ -611,7 +609,7 @@ export function buildAuthoringTools(
611
609
  name: 'update_step',
612
610
  description:
613
611
  "Update any subset of a step's settings in one call (phase, execution boundary, required skills, inputs, conditions, schedule, assignee, confirmation, " +
614
- 'trigger, onEvent). onEvent takes precedence over trigger when both are given; set trigger to "manual" to ' +
612
+ 'trigger, onEvent). `patch` fields as in create_template\'s step. onEvent takes precedence over trigger when both are given; set trigger to "manual" to ' +
615
613
  'clear an onEvent auto-trigger. Set phase or semanticGate to null to clear it.',
616
614
  schema: updateStepSchema,
617
615
  },
@@ -27,10 +27,7 @@ const describeActionSchema = z.object({
27
27
  });
28
28
 
29
29
  const referenceableSchema = z.object({
30
- flowRef: z
31
- .string()
32
- .optional()
33
- .describe('Which flow. Omit to use the flow that is currently open.'),
30
+ flowRef: z.string().optional().describe('Default: the open flow.'),
34
31
  stepId: z
35
32
  .string()
36
33
  .min(1)
@@ -18,10 +18,7 @@ import {
18
18
  } from '../input-policy';
19
19
 
20
20
  const describeSchema = z.object({
21
- flowRef: z
22
- .string()
23
- .optional()
24
- .describe('Which flow. Omit to use the flow that is currently open.'),
21
+ flowRef: z.string().optional().describe('Default: the open flow.'),
25
22
  stepId: stepIdSchema.describe('The form step to describe.'),
26
23
  });
27
24
 
@@ -59,10 +56,7 @@ const questionSchema = z.object({
59
56
  });
60
57
 
61
58
  const setFormSchemaSchema = z.object({
62
- flowRef: z
63
- .string()
64
- .optional()
65
- .describe('Which flow. Omit to use the flow that is currently open.'),
59
+ flowRef: z.string().optional().describe('Default: the open flow.'),
66
60
  stepId: z
67
61
  .string()
68
62
  .min(1)
@@ -10,10 +10,7 @@ import { toToolError } from '../errors';
10
10
  import { withFlowDoc } from '../flow-doc';
11
11
  import { checkLink, compatibleActions, requirements } from '../linkage';
12
12
 
13
- const flowRef = z
14
- .string()
15
- .optional()
16
- .describe('Which flow. Omit to use the flow that is currently open.');
13
+ const flowRef = z.string().optional().describe('Default: the open flow.');
17
14
 
18
15
  const checkLinkSchema = z.object({
19
16
  flowRef,
@@ -27,10 +27,7 @@ import {
27
27
  } from '../types';
28
28
 
29
29
  const base = {
30
- flowRef: z
31
- .string()
32
- .optional()
33
- .describe('Which flow. Omit to use the flow that is currently open.'),
30
+ flowRef: z.string().optional().describe('Default: the open flow.'),
34
31
  };
35
32
 
36
33
  const inputsSchema = z.object({
@@ -19,7 +19,12 @@ import type {
19
19
  FlowCapability,
20
20
  TriggerSpec,
21
21
  } from '@ixo/editor/core';
22
- import { canToType, getActionByCan, typeToCan } from '@ixo/editor/core';
22
+ import {
23
+ canToType,
24
+ getAction,
25
+ getActionByCan,
26
+ typeToCan,
27
+ } from '@ixo/editor/core';
23
28
  import { FlowError } from './errors';
24
29
  import { rewriteRefTokens, wholeRef } from './ref-syntax';
25
30
  import type { Condition, FlowSpecInput, FlowStep } from './types';
@@ -93,18 +98,24 @@ function rewriteEmbeddedRefs(
93
98
  return rewriteRefTokens(text, mapRef);
94
99
  }
95
100
 
96
- /** Resolve a friendly action name (registry `type`) to its `can` ability string. */
101
+ /**
102
+ * Resolve a friendly action name (registry `type`) to its `can` ability
103
+ * string. The editor's type-to-can table does not cover every action its
104
+ * registry lists (`qi/form.submit` among them), so the action's own
105
+ * definition answers when the table does not: an action `list_actions`
106
+ * offers always resolves.
107
+ */
97
108
  export function actionToCan(action: string): string {
98
- const can = typeToCan(action);
109
+ const can = typeToCan(action) ?? getAction(action)?.can;
99
110
  if (can) return can;
100
111
  // Tolerate an action that is already a `can` string.
101
112
  if (getActionByCan(action)) return action;
102
113
  throw new FlowError('unknown_action', `Unknown action "${action}".`);
103
114
  }
104
115
 
105
- /** Resolve a `can` ability string back to the friendly action name (registry `type`). */
116
+ /** Resolve a `can` ability string back to the friendly action name (registry `type`), by the table, then the registry. */
106
117
  export function canToAction(can: string): string {
107
- return canToType(can) ?? can;
118
+ return canToType(can) ?? getActionByCan(can)?.type ?? can;
108
119
  }
109
120
 
110
121
  /** A `{ $ref: string }` runtime reference — the only shape the engine resolves. */