@kaleidorg/mind 0.7.0 → 0.8.1

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 (136) hide show
  1. package/README.md +96 -8
  2. package/dist/context/compress.d.ts.map +1 -1
  3. package/dist/context/compress.js +1 -0
  4. package/dist/context/compress.js.map +1 -1
  5. package/dist/context/rgb-units.d.ts +18 -0
  6. package/dist/context/rgb-units.d.ts.map +1 -0
  7. package/dist/context/rgb-units.js +86 -0
  8. package/dist/context/rgb-units.js.map +1 -0
  9. package/dist/engine.d.ts +59 -1
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +196 -17
  12. package/dist/engine.js.map +1 -1
  13. package/dist/fastpath/fastpath.d.ts +6 -1
  14. package/dist/fastpath/fastpath.d.ts.map +1 -1
  15. package/dist/fastpath/fastpath.js +16 -2
  16. package/dist/fastpath/fastpath.js.map +1 -1
  17. package/dist/fastpath/render.d.ts +2 -0
  18. package/dist/fastpath/render.d.ts.map +1 -0
  19. package/dist/fastpath/render.js +58 -0
  20. package/dist/fastpath/render.js.map +1 -0
  21. package/dist/funnel.d.ts.map +1 -1
  22. package/dist/funnel.js +34 -18
  23. package/dist/funnel.js.map +1 -1
  24. package/dist/guards.d.ts +63 -0
  25. package/dist/guards.d.ts.map +1 -0
  26. package/dist/guards.js +284 -0
  27. package/dist/guards.js.map +1 -0
  28. package/dist/index.d.ts +9 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/kaleidoswap/contract.d.ts +3 -4
  33. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  34. package/dist/kaleidoswap/contract.js +3 -17
  35. package/dist/kaleidoswap/contract.js.map +1 -1
  36. package/dist/providers/openai.d.ts +64 -0
  37. package/dist/providers/openai.d.ts.map +1 -0
  38. package/dist/providers/openai.js +233 -0
  39. package/dist/providers/openai.js.map +1 -0
  40. package/dist/providers/types.d.ts +33 -0
  41. package/dist/providers/types.d.ts.map +1 -1
  42. package/dist/qvac/config.d.ts +1 -1
  43. package/dist/qvac/config.js +1 -1
  44. package/dist/qvac/index.d.ts +1 -0
  45. package/dist/qvac/index.d.ts.map +1 -1
  46. package/dist/qvac/index.js +1 -0
  47. package/dist/qvac/index.js.map +1 -1
  48. package/dist/qvac/models.d.ts +31 -0
  49. package/dist/qvac/models.d.ts.map +1 -0
  50. package/dist/qvac/models.js +80 -0
  51. package/dist/qvac/models.js.map +1 -0
  52. package/dist/qvac/parse.d.ts +11 -0
  53. package/dist/qvac/parse.d.ts.map +1 -1
  54. package/dist/qvac/parse.js +29 -7
  55. package/dist/qvac/parse.js.map +1 -1
  56. package/dist/qvac/provider.d.ts +14 -4
  57. package/dist/qvac/provider.d.ts.map +1 -1
  58. package/dist/qvac/provider.js +33 -8
  59. package/dist/qvac/provider.js.map +1 -1
  60. package/dist/qvac/stream.d.ts +3 -5
  61. package/dist/qvac/stream.d.ts.map +1 -1
  62. package/dist/qvac/stream.js.map +1 -1
  63. package/dist/qvac/tools.d.ts +5 -0
  64. package/dist/qvac/tools.d.ts.map +1 -1
  65. package/dist/qvac/tools.js +10 -0
  66. package/dist/qvac/tools.js.map +1 -1
  67. package/dist/recipe/issue-asset.d.ts.map +1 -1
  68. package/dist/recipe/issue-asset.js +42 -16
  69. package/dist/recipe/issue-asset.js.map +1 -1
  70. package/dist/recipe/runner.d.ts.map +1 -1
  71. package/dist/recipe/runner.js +1 -0
  72. package/dist/recipe/runner.js.map +1 -1
  73. package/dist/recipe/submarine-pay.d.ts +17 -0
  74. package/dist/recipe/submarine-pay.d.ts.map +1 -0
  75. package/dist/recipe/submarine-pay.js +79 -0
  76. package/dist/recipe/submarine-pay.js.map +1 -0
  77. package/dist/skills/registry.js +1 -1
  78. package/dist/skills/select.d.ts +20 -0
  79. package/dist/skills/select.d.ts.map +1 -0
  80. package/dist/skills/select.js +32 -0
  81. package/dist/skills/select.js.map +1 -0
  82. package/dist/submarine/contract.d.ts +42 -0
  83. package/dist/submarine/contract.d.ts.map +1 -0
  84. package/dist/submarine/contract.js +73 -0
  85. package/dist/submarine/contract.js.map +1 -0
  86. package/dist/testing/mock-wallet.d.ts +24 -0
  87. package/dist/testing/mock-wallet.d.ts.map +1 -1
  88. package/dist/testing/mock-wallet.js +75 -6
  89. package/dist/testing/mock-wallet.js.map +1 -1
  90. package/dist/wallet/confirm.d.ts.map +1 -1
  91. package/dist/wallet/confirm.js +27 -9
  92. package/dist/wallet/confirm.js.map +1 -1
  93. package/package.json +6 -1
  94. package/skills/README.md +1 -0
  95. package/skills/kaleido-trading/SKILL.md +22 -28
  96. package/skills/kaleido-trading/references/api.md +3 -3
  97. package/skills/rgb-lightning-node/SKILL.md +32 -12
  98. package/skills/spark-wallet/SKILL.md +1 -0
  99. package/skills/submarine-swaps/SKILL.md +48 -0
  100. package/src/context/compress.ts +1 -0
  101. package/src/context/rgb-units.test.ts +42 -0
  102. package/src/context/rgb-units.ts +89 -0
  103. package/src/engine.test.ts +133 -3
  104. package/src/engine.ts +254 -19
  105. package/src/fastpath/fastpath.ts +22 -2
  106. package/src/fastpath/render.test.ts +39 -0
  107. package/src/fastpath/render.ts +56 -0
  108. package/src/funnel.mind.test.ts +15 -17
  109. package/src/funnel.ts +34 -19
  110. package/src/guards.test.ts +399 -0
  111. package/src/guards.ts +299 -0
  112. package/src/index.ts +37 -1
  113. package/src/kaleidoswap/contract.test.ts +8 -16
  114. package/src/kaleidoswap/contract.ts +4 -32
  115. package/src/providers/openai.test.ts +127 -0
  116. package/src/providers/openai.ts +282 -0
  117. package/src/providers/types.ts +35 -0
  118. package/src/qvac/config.ts +1 -1
  119. package/src/qvac/index.ts +10 -0
  120. package/src/qvac/models.ts +98 -0
  121. package/src/qvac/parse.test.ts +7 -0
  122. package/src/qvac/parse.ts +33 -1
  123. package/src/qvac/provider.test.ts +54 -1
  124. package/src/qvac/provider.ts +49 -13
  125. package/src/qvac/stream.ts +3 -5
  126. package/src/qvac/tools.ts +10 -0
  127. package/src/recipe/issue-asset.test.ts +39 -0
  128. package/src/recipe/issue-asset.ts +41 -13
  129. package/src/recipe/runner.ts +1 -0
  130. package/src/recipe/submarine-pay.test.ts +87 -0
  131. package/src/recipe/submarine-pay.ts +78 -0
  132. package/src/skills/registry.ts +1 -1
  133. package/src/skills/select.ts +41 -0
  134. package/src/submarine/contract.ts +112 -0
  135. package/src/testing/mock-wallet.ts +73 -6
  136. package/src/wallet/confirm.ts +27 -9
package/src/funnel.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  import { Engine } from './engine.js';
23
23
  import type { ToolCrushOptions } from './context/compress.js';
24
24
  import type { ToolRegistry } from './tools/registry.js';
25
+ import { defaultRenderFast } from './fastpath/render.js';
25
26
  import { FastPath, WALLET_FAST_INTENTS } from './fastpath/fastpath.js';
26
27
  import type { FastIntent } from './fastpath/fastpath.js';
27
28
  import { RecipeRegistry, runRecipe } from './recipe/runner.js';
@@ -30,6 +31,8 @@ import { receiveRecipe } from './recipe/receive.js';
30
31
  import { assetSendRecipe } from './recipe/asset-send.js';
31
32
  import type { Recipe } from './recipe/types.js';
32
33
  import { SkillRegistry } from './skills/registry.js';
34
+ import { selectAvailableSkill } from './skills/select.js';
35
+ import { detectWalletAction, hasCapableTool, noToolReply, wantsToolCall } from './guards.js';
33
36
  import type { Skill } from './skills/types.js';
34
37
  import type { LLMProvider } from './providers/types.js';
35
38
  import type { InferenceMetrics } from './providers/types.js';
@@ -174,18 +177,6 @@ export interface FunnelOptions {
174
177
  topKRag?: number;
175
178
  }
176
179
 
177
- function defaultRenderFast(intent: string, r: any): string {
178
- if (intent === 'balance') {
179
- const sats = Number(r?.total_sats ?? 0);
180
- const n = r?.layers?.length ?? 0;
181
- return `You have ${sats.toLocaleString()} sats${n > 1 ? ` across ${n} layers` : ''}.`;
182
- }
183
- if (intent === 'address') {
184
- return r?.address ? `Here's your receive address:\n\n\`${r.address}\`` : 'No address available right now.';
185
- }
186
- return `Bitcoin is $${Number(r?.price_usd ?? 0).toLocaleString()}.`;
187
- }
188
-
189
180
  export class Funnel {
190
181
  private readonly provider: LLMProvider;
191
182
  private readonly registry: ToolRegistry;
@@ -249,9 +240,16 @@ export class Funnel {
249
240
  // tool — a partial tool surface (e.g. desktop without the core aggregate
250
241
  // helpers) falls through to the agentic tier instead of erroring.
251
242
  const fast = this.fastPath.select(text);
252
- if (fast && (await this.registry.getDef(fast.tool))) {
253
- this.log(`tier=fast-path → ${fast.tool}`);
254
- const r = await this.registry.execute(fast.tool, fast.args);
243
+ let fastTool: string | undefined;
244
+ for (const name of fast?.tools ?? []) {
245
+ if (await this.registry.getDef(name)) {
246
+ fastTool = name;
247
+ break;
248
+ }
249
+ }
250
+ if (fast && fastTool) {
251
+ this.log(`tier=fast-path → ${fastTool}`);
252
+ const r = await this.registry.execute(fastTool, fast.args);
255
253
  return { text: this.renderFast(fast.intent.name, r), tier: 'fast', route: fast.intent.name, intent: fast.intent.name, data: r };
256
254
  }
257
255
 
@@ -320,7 +318,9 @@ export class Funnel {
320
318
 
321
319
  // ── T1: skill-scoped agentic loop ──
322
320
  const skills = this.skillsFor(settings.disabledSkills);
323
- const skill = skills.select(text);
321
+ const liveTools = (await this.registry.listTools()).map((t) => t.name);
322
+ const present = new Set(liveTools);
323
+ const skill = selectAvailableSkill(skills, text, present);
324
324
  let base = settings.persona ? `${this.system}\n\n## Your persona\n${settings.persona}` : this.system;
325
325
 
326
326
  // Auto-inject relevant knowledge chunks (best-effort — corpus is grounding
@@ -357,7 +357,6 @@ export class Funnel {
357
357
  // leaves the model TOOL-LESS — it then narrates "the tool isn't available"
358
358
  // instead of acting. If NONE of the scoped tools resolve against the live
359
359
  // registry, widen to the full surface so the agent can still work.
360
- const present = new Set((await this.registry.listTools()).map((t) => t.name));
361
360
  if (!scoped.some((n) => present.has(n))) {
362
361
  this.log(
363
362
  `tier=agentic: skill '${skill?.name ?? '?'}' tools resolved to 0 live tools — using full tool surface`,
@@ -367,8 +366,23 @@ export class Funnel {
367
366
  } else if (disabledAmbient.length) {
368
367
  // No skill matched but a toggle is off: expose everything except the
369
368
  // disabled ambient tools (the sources stay mounted — no rebuild).
370
- const all = (await this.registry.listTools()).map((t) => t.name);
371
- scoped = all.filter((n) => !disabledAmbient.includes(n));
369
+ scoped = liveTools.filter((n) => !disabledAmbient.includes(n));
370
+ }
371
+
372
+ // A wallet action with no tool able to perform it: answer deterministically
373
+ // instead of letting the model improvise an invoice/address/payment.
374
+ const action = detectWalletAction(text);
375
+ if (action) {
376
+ const inScope = (scoped ?? liveTools).filter((n) => present.has(n));
377
+ if (!hasCapableTool(action, inScope)) {
378
+ if (scoped && hasCapableTool(action, liveTools)) {
379
+ this.log(`tier=agentic: skill '${skill?.name ?? '?'}' has no tool for ${action.id} — using full tool surface`);
380
+ scoped = liveTools.filter((n) => !disabledAmbient.includes(n));
381
+ } else {
382
+ this.log(`tier=agentic: no tool for ${action.id} — refusing`);
383
+ return { text: noToolReply(action), tier: 'agentic', route: 'no-tool', toolCalls: [], turns: 0, inference: [] };
384
+ }
385
+ }
372
386
  }
373
387
 
374
388
  // Trim history so the prompt (system + skill + tools + history) stays
@@ -396,6 +410,7 @@ export class Funnel {
396
410
  },
397
411
  onToolResult: cbs.onToolResult,
398
412
  onConfirm: cbs.onConfirm,
413
+ ...(wantsToolCall(text) ? { firstTurnToolChoice: 'required' as const } : {}),
399
414
  signal: cbs.signal,
400
415
  });
401
416
  return {
@@ -0,0 +1,399 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Engine } from './engine.js';
3
+ import { Funnel } from './funnel.js';
4
+ import { ToolRegistry } from './tools/registry.js';
5
+ import { InProcessToolSource } from './tools/in-process.js';
6
+ import { parseSkill, SkillRegistry } from './skills/registry.js';
7
+ import { scriptedProvider } from './testing/scripted-provider.js';
8
+ import { confirmReadback } from './wallet/confirm.js';
9
+ import {
10
+ DECLINED_TOOL_MESSAGE,
11
+ detectWalletAction,
12
+ wantsToolCall,
13
+ fixSatsBtcConversions,
14
+ formatSatsAsBtc,
15
+ findUngroundedPaymentData,
16
+ hasCapableTool,
17
+ validateToolArgs,
18
+ } from './guards.js';
19
+
20
+ const ISSUE_SCHEMA = {
21
+ type: 'object',
22
+ properties: {
23
+ name: { type: 'string' },
24
+ ticker: { type: 'string' },
25
+ amount: { type: 'number' },
26
+ precision: { type: 'number', minimum: 0, maximum: 18 },
27
+ schema: { type: 'string', enum: ['NIA', 'CFA', 'UDA'] },
28
+ },
29
+ required: ['name'],
30
+ };
31
+
32
+ const INVOICE = 'lnbcrt50u1pn9xyzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq';
33
+
34
+ describe('validateToolArgs', () => {
35
+ it('accepts valid args and coerces numeric strings', () => {
36
+ const r = validateToolArgs({ name: 'rln_issue_asset', parameters: ISSUE_SCHEMA }, { name: 'Hack', ticker: 'HCK', amount: '1000' });
37
+ expect(r.ok).toBe(true);
38
+ expect(r.args.amount).toBe(1000);
39
+ });
40
+
41
+ it('rejects the garbled issue call from the live test', () => {
42
+ const r = validateToolArgs({ name: 'rln_issue_asset', parameters: ISSUE_SCHEMA }, { name: 'Hack', ticker: "HCK', " });
43
+ expect(r.ok).toBe(false);
44
+ expect(r.errors.join(' ')).toMatch(/amount/);
45
+ expect(r.errors.join(' ')).toMatch(/ticker/);
46
+ });
47
+
48
+ it('flags missing required fields, wrong types, enums, ranges and non-finite numbers', () => {
49
+ const def = { name: 'x', parameters: ISSUE_SCHEMA };
50
+ expect(validateToolArgs(def, {}).errors).toContain('"name" is required');
51
+ expect(validateToolArgs(def, { name: 1 }).ok).toBe(false);
52
+ expect(validateToolArgs(def, { name: 'a', schema: 'ERC20' }).ok).toBe(false);
53
+ expect(validateToolArgs(def, { name: 'a', precision: 19 }).ok).toBe(false);
54
+ expect(validateToolArgs(def, { name: 'a', amount: Number.NaN }).ok).toBe(false);
55
+ expect(validateToolArgs(def, { name: 'a', amount: 'lots' }).ok).toBe(false);
56
+ });
57
+
58
+ it('passes through tools without a JSON schema', () => {
59
+ expect(validateToolArgs({ name: 'x', parameters: {} }, { anything: 1 }).ok).toBe(true);
60
+ });
61
+
62
+ it('uses safeParse for Zod-like schemas', () => {
63
+ const zodLike = { safeParse: (v: any) => (v.n ? { success: true } : { success: false, error: { issues: [{ path: ['n'], message: 'Required' }] } }) };
64
+ expect(validateToolArgs({ name: 'x', parameters: zodLike }, {}).errors).toEqual(['n: Required']);
65
+ expect(validateToolArgs({ name: 'x', parameters: zodLike }, { n: 1 }).ok).toBe(true);
66
+ });
67
+ });
68
+
69
+ describe('confirmReadback never shows NaN', () => {
70
+ it('describes a missing amount instead of NaN and strips stray quotes', () => {
71
+ const line = confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Hack', ticker: "HCK', " } });
72
+ expect(line).not.toMatch(/NaN/);
73
+ expect(line).toBe('Issue an unspecified amount of HCK (Hack), a new RGB asset. Confirm?');
74
+ expect(confirmReadback({ name: 'spark_send', arguments: { to: 'bob' } })).not.toMatch(/NaN/);
75
+ });
76
+ });
77
+
78
+ describe('findUngroundedPaymentData / detectWalletAction', () => {
79
+ it('finds invoices and addresses no source contains', () => {
80
+ expect(findUngroundedPaymentData(`Here: ${INVOICE}`, [])).toEqual([{ kind: 'Lightning invoice', value: INVOICE }]);
81
+ expect(findUngroundedPaymentData(`Here: ${INVOICE}`, [{ invoice: INVOICE }])).toEqual([]);
82
+ expect(findUngroundedPaymentData('Send to tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', [])).toHaveLength(1);
83
+ expect(findUngroundedPaymentData('You have 5,000 sats.', [])).toEqual([]);
84
+ });
85
+
86
+ it('detects wallet actions and capable tools', () => {
87
+ const a = detectWalletAction('Create a Lightning invoice for 5000 sats')!;
88
+ expect(a.id).toBe('receive-invoice');
89
+ expect(hasCapableTool(a, ['bitrefill_create_invoice', 'get_price'])).toBe(false);
90
+ expect(hasCapableTool(a, ['rln_create_ln_invoice'])).toBe(true);
91
+ expect(detectWalletAction('what is the BTC price?')).toBeNull();
92
+ });
93
+
94
+ it('detects asset issuance', () => {
95
+ const a = detectWalletAction('issue a new asset HCK with 1000 units')!;
96
+ expect(a.id).toBe('issue-asset');
97
+ expect(hasCapableTool(a, ['rln_list_assets'])).toBe(false);
98
+ expect(hasCapableTool(a, ['rln_issue_asset'])).toBe(true);
99
+ });
100
+
101
+ it('wantsToolCall: actions yes, how-to questions no', () => {
102
+ expect(wantsToolCall('send 1000 sats to bob')).toBe(true);
103
+ expect(wantsToolCall('Can you create an invoice for 5000 sats?')).toBe(true);
104
+ expect(wantsToolCall('how do I send sats to someone?')).toBe(false);
105
+ expect(wantsToolCall('what is the BTC price?')).toBe(false);
106
+ });
107
+ });
108
+
109
+ function engineWith(
110
+ tools: ConstructorParameters<typeof InProcessToolSource>[1],
111
+ turns: Parameters<typeof scriptedProvider>[0],
112
+ opts: Partial<ConstructorParameters<typeof Engine>[0]> = {},
113
+ ) {
114
+ return new Engine({ provider: scriptedProvider(turns), tools: new ToolRegistry([new InProcessToolSource('t', tools)]), ...opts });
115
+ }
116
+
117
+ const LIST_ASSETS_RESULT = [
118
+ {
119
+ asset_id: 'rgb:rXXZqZR5-g9x7NDo-fXkijg3-BhJAbGd-Y24H0f2-Rgw1PkE', ticker: 'Q35A', name: 'Bench Q35A', details: null, precision: 0,
120
+ issued_supply: 1_000_000, balance: { settled: 1_000_000, future: 1_000_000, spendable: 1_000_000, offchain_outbound: 0, offchain_inbound: 0 },
121
+ },
122
+ {
123
+ asset_id: 'rgb:WBq3wXtG-xG8XBXq-ALV6AP3-wL6qamB-_X4StEv-Jaxl~ME', ticker: 'QNINE', name: 'Bench QNINE', details: null, precision: 0,
124
+ issued_supply: 1_000_000, balance: { settled: 1_000_000, future: 1_000_000, spendable: 1_000_000, offchain_outbound: 0, offchain_inbound: 0 },
125
+ },
126
+ ];
127
+
128
+ describe('Engine guards', () => {
129
+ it('F2: answers from the cached result instead of re-running an identical call, then forces an answer', async () => {
130
+ const list = vi.fn(async () => ({ assets: [] }));
131
+ const engine = engineWith(
132
+ [{ name: 'rln_list_assets', description: '', parameters: {}, handler: list }],
133
+ [
134
+ { tool: 'rln_list_assets' },
135
+ { tool: 'rln_list_assets' },
136
+ { tool: 'rln_list_assets' },
137
+ { text: 'You have no RGB assets yet.' },
138
+ ],
139
+ );
140
+ const res = await engine.runAgentic([{ role: 'user', content: 'list my assets' }], { maxTurns: 5 });
141
+ expect(list).toHaveBeenCalledTimes(1);
142
+ expect(res.toolCalls[1]!.result).toMatchObject({ error: expect.stringMatching(/You already called rln_list_assets/) });
143
+ expect(res.text).toBe('You have no RGB assets yet.');
144
+ expect(res.turns).toBe(3);
145
+ });
146
+
147
+ it('F2: a confirm-gated call in between allows the same read again', async () => {
148
+ const list = vi.fn(async () => ({ assets: [] }));
149
+ const engine = engineWith(
150
+ [
151
+ { name: 'rln_list_assets', description: '', parameters: {}, handler: list },
152
+ { name: 'rln_issue_asset', description: '', parameters: ISSUE_SCHEMA, requiresConfirmation: true, handler: async () => ({ asset_id: 'rgb:x' }) },
153
+ ],
154
+ [
155
+ { tool: 'rln_list_assets' },
156
+ { tool: 'rln_issue_asset', args: { name: 'Hack', ticker: 'HCK', amount: 10 } },
157
+ { tool: 'rln_list_assets' },
158
+ { text: 'done' },
159
+ ],
160
+ );
161
+ await engine.runAgentic([{ role: 'user', content: 'issue' }], { onConfirm: async () => ({ approved: true }) });
162
+ expect(list).toHaveBeenCalledTimes(2);
163
+ });
164
+
165
+ it('F3: invalid args go back to the model as a tool error and never reach onConfirm', async () => {
166
+ const issue = vi.fn(async () => ({ asset_id: 'rgb:x' }));
167
+ const onConfirm = vi.fn(async () => ({ approved: true }));
168
+ const engine = engineWith(
169
+ [{ name: 'rln_issue_asset', description: '', parameters: ISSUE_SCHEMA, requiresConfirmation: true, handler: issue }],
170
+ [{ tool: 'rln_issue_asset', args: { name: 'Hack', ticker: "HCK', " } }, { text: 'How many tokens should I issue?' }],
171
+ );
172
+ const res = await engine.runAgentic([{ role: 'user', content: 'issue HCK' }], { onConfirm });
173
+ expect(onConfirm).not.toHaveBeenCalled();
174
+ expect(issue).not.toHaveBeenCalled();
175
+ expect(res.toolCalls[0]!.result).toMatchObject({ error: expect.stringMatching(/Invalid arguments.*amount/) });
176
+ expect(res.text).toBe('How many tokens should I issue?');
177
+ });
178
+
179
+ it('F3: valid args reach onConfirm coerced, with a readback summary', async () => {
180
+ const onConfirm = vi.fn(async (_c: { name: string; arguments: Record<string, unknown>; summary?: string }) => ({ approved: true }));
181
+ const engine = engineWith(
182
+ [{ name: 'rln_issue_asset', description: '', parameters: ISSUE_SCHEMA, requiresConfirmation: true, handler: async () => ({ asset_id: 'rgb:x' }) }],
183
+ [{ tool: 'rln_issue_asset', args: { name: 'Hack', ticker: 'HCK', amount: '1000' } }, { text: 'Issued.' }],
184
+ );
185
+ await engine.runAgentic([{ role: 'user', content: 'issue 1000 HCK' }], { onConfirm });
186
+ expect(onConfirm.mock.calls[0]![0]).toMatchObject({
187
+ arguments: { amount: 1000 },
188
+ summary: 'Issue 1,000 HCK (Hack), a new RGB asset. Confirm?',
189
+ });
190
+ });
191
+
192
+ it('F4: a decline feeds the model an unambiguous message', async () => {
193
+ const engine = engineWith(
194
+ [{ name: 'rln_send_btc', description: '', parameters: {}, requiresConfirmation: true, handler: async () => ({ txid: 'x' }) }],
195
+ [{ tool: 'rln_send_btc', args: { address: 'tb1q', amount_sat: 1000 } }, { text: 'Cancelled.' }],
196
+ );
197
+ const res = await engine.runAgentic([{ role: 'user', content: 'send' }], { onConfirm: async () => ({ approved: false }) });
198
+ expect(res.toolCalls[0]!.result).toEqual({ status: 'cancelled_by_user', declined_by: 'user', tool: 'rln_send_btc', message: DECLINED_TOOL_MESSAGE });
199
+ expect(res.messages.find((m) => m.role === 'tool')!.content).toContain('Cancelled by the user');
200
+ expect(res.text).toBe('Cancelled — you declined: Send 1,000 sats on-chain to tb1q over RLN. Nothing was sent or changed.');
201
+ expect(res.turns).toBe(1);
202
+ });
203
+
204
+ it('F4: declining rln_create_utxos ends the turn without letting the model blame the node', async () => {
205
+ const utxos = vi.fn(async () => ({ created: 5 }));
206
+ const engine = engineWith(
207
+ [{ name: 'rln_create_utxos', description: '', parameters: {}, requiresConfirmation: true, handler: utxos }],
208
+ [{ tool: 'rln_create_utxos', args: {} }, { text: 'The node declined the UTXO creation.' }],
209
+ );
210
+ const res = await engine.runAgentic([{ role: 'user', content: 'create utxos' }], { onConfirm: async () => ({ approved: false }) });
211
+ expect(utxos).not.toHaveBeenCalled();
212
+ expect(res.text).toBe('Cancelled — you declined: Create 5 RGB UTXOs on-chain over RLN. Nothing was sent or changed.');
213
+ });
214
+
215
+ it('keeps a realistic asset-list answer with rgb: asset ids intact', async () => {
216
+ const answer =
217
+ 'You hold 2 RGB assets: | Asset ID | Ticker | Balance | |---|---|---| ' +
218
+ '| rgb:rXXZqZR5-g9x7NDo-fXkijg3-BhJAbGd-Y24H0f2-Rgw1PkE | Q35A | 1,000,000 | ' +
219
+ '| rgb:WBq3wXtG-xG8XBXq-ALV6AP3-wL6qamB-_X4StEv-Jaxl~ME | QNINE | 1,000,000 |. Truncated: rgb:WBq3wXtG-xG8X';
220
+ const engine = engineWith(
221
+ [{ name: 'rln_list_assets', description: '', parameters: { type: 'object', properties: { schemas: { type: 'array' } } }, handler: async () => LIST_ASSETS_RESULT }],
222
+ [{ tool: 'rln_list_assets', args: { schemas: [] } }, { text: answer }],
223
+ { compressToolOutput: true },
224
+ );
225
+ const res = await engine.runAgentic([{ role: 'user', content: 'Which RGB assets do I hold, and what are the balances?' }]);
226
+ expect(res.text).toBe(answer);
227
+ });
228
+
229
+ it('recovers when the answer turn comes back empty (reasoning ate the output budget)', async () => {
230
+ const turns = [
231
+ { text: '', toolCalls: [{ name: 'rln_list_assets', arguments: {} }] },
232
+ { text: '', toolCalls: [], incomplete: true },
233
+ { text: 'You hold Q35A and QNINE, 1,000,000 each.', toolCalls: [] },
234
+ ];
235
+ let i = 0;
236
+ const provider = { name: 'p', runTurn: vi.fn(async () => ({ rawContent: '', ...turns[i++]! })) };
237
+ const engine = new Engine({ provider, tools: new ToolRegistry([new InProcessToolSource('t', [{ name: 'rln_list_assets', description: '', parameters: {}, handler: async () => LIST_ASSETS_RESULT }])]) });
238
+ const res = await engine.runAgentic([{ role: 'user', content: 'Which RGB assets do I hold?' }]);
239
+ expect(res.text).toBe('You hold Q35A and QNINE, 1,000,000 each.');
240
+ expect(provider.runTurn.mock.calls[2]![0].tools).toEqual([]);
241
+ });
242
+
243
+ it('falls back to the tool result when the recovery turn is empty too', async () => {
244
+ const engine = engineWith(
245
+ [{ name: 'rln_list_assets', description: '', parameters: {}, handler: async () => LIST_ASSETS_RESULT }],
246
+ [{ tool: 'rln_list_assets' }, { text: '' }, { text: '' }],
247
+ );
248
+ const res = await engine.runAgentic([{ role: 'user', content: 'Which RGB assets do I hold?' }]);
249
+ expect(res.text).toMatch(/^I couldn't phrase an answer in time\. Here is what rln list assets returned:/);
250
+ expect(res.text).toContain('Q35A');
251
+ });
252
+
253
+ it('F1: refuses a wallet action with no capable tool before any inference', async () => {
254
+ const provider = scriptedProvider([{ text: `Here is your invoice: ${INVOICE}` }]);
255
+ const runTurn = vi.spyOn(provider, 'runTurn');
256
+ const engine = new Engine({
257
+ provider,
258
+ tools: new ToolRegistry([new InProcessToolSource('t', [{ name: 'bitrefill_create_invoice', description: '', parameters: {}, handler: async () => ({}) }])]),
259
+ });
260
+ const res = await engine.runAgentic([{ role: 'user', content: 'Create a Lightning invoice for 5000 sats' }]);
261
+ expect(runTurn).not.toHaveBeenCalled();
262
+ expect(res.turns).toBe(0);
263
+ expect(res.text).toMatch(/no tool for creating an invoice/);
264
+ });
265
+
266
+ it('F1: replaces an invoice the model made up', async () => {
267
+ const engine = engineWith([], [{ text: `Here is your invoice: ${INVOICE}` }], { guardMissingTools: false });
268
+ const res = await engine.runAgentic([{ role: 'user', content: 'Create a Lightning invoice for 5000 sats' }]);
269
+ expect(res.text).not.toContain(INVOICE);
270
+ expect(res.text).toMatch(/won't make one up/);
271
+ });
272
+
273
+ it('F1: keeps an invoice a tool returned', async () => {
274
+ const engine = engineWith(
275
+ [{ name: 'rln_create_ln_invoice', description: '', parameters: {}, handler: async () => ({ invoice: INVOICE }) }],
276
+ [{ tool: 'rln_create_ln_invoice', args: { amount_sats: 5000 } }, { text: `Here is your invoice: ${INVOICE}` }],
277
+ );
278
+ const res = await engine.runAgentic([{ role: 'user', content: 'Create a Lightning invoice for 5000 sats' }]);
279
+ expect(res.text).toContain(INVOICE);
280
+ });
281
+ });
282
+
283
+ const SPARK_SKILL = parseSkill([
284
+ '---',
285
+ 'name: spark-wallet',
286
+ 'description: Spark wallet — create a Spark Lightning invoice, pay invoices.',
287
+ 'tools: spark_create_invoice, spark_get_balance, bitrefill_search',
288
+ 'triggers: spark, lightning invoice, ln invoice',
289
+ 'requires-tools: spark_get_balance',
290
+ '---',
291
+ 'Use spark tools.',
292
+ ].join('\n'));
293
+
294
+ const RLN_SKILL = parseSkill([
295
+ '---',
296
+ 'name: rgb-lightning-node',
297
+ 'description: Drive the RGB Lightning Node.',
298
+ 'tools: rln_create_ln_invoice, rln_list_assets',
299
+ 'triggers: invoice, node',
300
+ '---',
301
+ 'Use rln tools.',
302
+ ].join('\n'));
303
+
304
+ describe('Engine.composeSkill', () => {
305
+ it('skips a skill whose requires-tools are not in the registry (no app wiring needed)', async () => {
306
+ const engine = new Engine({
307
+ provider: scriptedProvider([]),
308
+ tools: new ToolRegistry([
309
+ new InProcessToolSource('t', [
310
+ { name: 'bitrefill_search', description: '', parameters: {}, handler: async () => ({}) },
311
+ { name: 'rln_create_ln_invoice', description: '', parameters: {}, handler: async () => ({}) },
312
+ ]),
313
+ ]),
314
+ });
315
+ const skills = new SkillRegistry([SPARK_SKILL, RLN_SKILL]);
316
+ expect(skills.select('Create a Lightning invoice for 5000 sats')?.name).toBe('spark-wallet');
317
+ const composed = await engine.composeSkill(skills, 'Create a Lightning invoice for 5000 sats', 'base');
318
+ expect(composed.skill?.name).toBe('rgb-lightning-node');
319
+ expect(composed.system).toContain('Active skill: rgb-lightning-node');
320
+ expect(composed.allowedTools).toContain('rln_create_ln_invoice');
321
+ });
322
+ });
323
+
324
+ describe('Funnel F1 guards', () => {
325
+ it('skips a skill whose required tools are missing and picks one that can act', async () => {
326
+ const tools = new ToolRegistry([
327
+ new InProcessToolSource('t', [
328
+ { name: 'bitrefill_search', description: '', parameters: {}, handler: async () => ({}) },
329
+ { name: 'rln_create_ln_invoice', description: '', parameters: {}, handler: async () => ({ invoice: INVOICE }) },
330
+ ]),
331
+ ]);
332
+ const funnel = new Funnel({
333
+ provider: scriptedProvider([{ tool: 'rln_create_ln_invoice', args: { amount_sats: 5000 } }, { text: `Invoice: ${INVOICE}` }]),
334
+ tools,
335
+ skills: [SPARK_SKILL, RLN_SKILL],
336
+ recipes: [],
337
+ fastIntents: [],
338
+ });
339
+ const res = await funnel.runTurn('Create a Lightning invoice for 5000 sats');
340
+ expect(res.route).toBe('rgb-lightning-node');
341
+ expect(res.text).toContain(INVOICE);
342
+ });
343
+
344
+ it('refuses without inference when no tool can create an invoice', async () => {
345
+ const provider = { ...scriptedProvider([{ text: `Invoice: ${INVOICE}` }]) };
346
+ const runTurn = vi.spyOn(provider, 'runTurn');
347
+ const funnel = new Funnel({
348
+ provider,
349
+ tools: new ToolRegistry([
350
+ new InProcessToolSource('t', [
351
+ { name: 'bitrefill_create_invoice', description: '', parameters: {}, handler: async () => ({}) },
352
+ { name: 'spark_get_balance', description: '', parameters: {}, handler: async () => ({}) },
353
+ ]),
354
+ ]),
355
+ skills: [SPARK_SKILL],
356
+ recipes: [],
357
+ fastIntents: [],
358
+ });
359
+ const res = await funnel.runTurn('Create a Lightning invoice for 5000 sats');
360
+ expect(runTurn).not.toHaveBeenCalled();
361
+ expect(res.route).toBe('no-tool');
362
+ expect(res.text).toMatch(/no tool for creating an invoice/);
363
+ });
364
+ });
365
+
366
+ describe('fixSatsBtcConversions', () => {
367
+ it('recomputes a wrong BTC figure from the sats figure', () => {
368
+ expect(fixSatsBtcConversions('Vanilla UTXOs: 4,277 sats (42.77 BTC)')).toBe('Vanilla UTXOs: 4,277 sats (0.00004277 BTC)');
369
+ expect(fixSatsBtcConversions('0.5 BTC (5,000 sats)')).toBe('0.00005 BTC (5,000 sats)');
370
+ expect(fixSatsBtcConversions('You have **300,000 satoshis** (3 BTC) on-chain.')).toBe('You have **300,000 satoshis** (0.003 BTC) on-chain.');
371
+ expect(fixSatsBtcConversions('**0.5 BTC** (5,000 sats)')).toBe('**0.00005 BTC** (5,000 sats)');
372
+ });
373
+
374
+ it('leaves correct pairs and lone amounts alone', () => {
375
+ const ok = 'You have 150,000,000 sats (1.5 BTC) and 0.001 BTC (100,000 sats). Fee: 300 sats.';
376
+ expect(fixSatsBtcConversions(ok)).toBe(ok);
377
+ });
378
+
379
+ it('formats sats as BTC without trailing zeros', () => {
380
+ expect(formatSatsAsBtc(100_000_000)).toBe('1');
381
+ expect(formatSatsAsBtc(4_277)).toBe('0.00004277');
382
+ expect(formatSatsAsBtc(0)).toBe('0');
383
+ });
384
+ });
385
+
386
+ describe('engine replies skip the answer guards', () => {
387
+ it('a declined send keeps its cancel message even though the readback shortens the invoice', async () => {
388
+ const to = 'rgb:~/~/~/sig/any/1/utxob:test-recipient';
389
+ const engine = engineWith(
390
+ [{ name: 'rln_send_asset', description: '', parameters: {}, requiresConfirmation: true, handler: async () => ({ ok: true }) }],
391
+ [{ tool: 'rln_send_asset', args: { asset: 'USDT', amount: 1, to } }, { text: 'unused' }],
392
+ );
393
+ const res = await engine.runAgentic([{ role: 'user', content: `Send 1 USDT to this RGB invoice: ${to}` }], {
394
+ onConfirm: async () => ({ approved: false, reason: 'no' }),
395
+ });
396
+ expect(res.text).toMatch(/^Cancelled — you declined/);
397
+ expect(res.text).not.toMatch(/won't make one up/);
398
+ });
399
+ });