@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/engine.ts CHANGED
@@ -16,9 +16,25 @@
16
16
 
17
17
  import type { ConfirmDecision, Message, ToolResult } from './types.js';
18
18
  import type { LLMProvider } from './providers/types.js';
19
- import type { InferenceMetrics } from './providers/types.js';
19
+ import type { InferenceMetrics, ToolCallError, ToolChoice } from './providers/types.js';
20
20
  import type { ToolRegistry } from './tools/registry.js';
21
21
  import { compressToolResult, type ToolCrushOptions } from './context/compress.js';
22
+ import {
23
+ callKey,
24
+ declinedToolResult,
25
+ detectWalletAction,
26
+ hasCapableTool,
27
+ noToolReply,
28
+ findUngroundedPaymentData,
29
+ fixSatsBtcConversions,
30
+ ungroundedReply,
31
+ validateToolArgs,
32
+ } from './guards.js';
33
+ import { confirmReadback } from './wallet/confirm.js';
34
+ import { annotateRgbBalances, fixRgbBalanceUnits } from './context/rgb-units.js';
35
+ import type { SkillRegistry } from './skills/registry.js';
36
+ import type { Skill } from './skills/types.js';
37
+ import { selectAvailableSkill } from './skills/select.js';
22
38
 
23
39
  export interface EngineOptions {
24
40
  provider: LLMProvider;
@@ -36,6 +52,33 @@ export interface EngineOptions {
36
52
  * The `onToolResult` callback and `toolCalls` still carry the raw result.
37
53
  */
38
54
  compressToolOutput?: boolean | ToolCrushOptions;
55
+ /**
56
+ * Replace a final answer that contains an invoice/address/payment request no
57
+ * tool returned and the user never typed. Default true.
58
+ */
59
+ guardUngroundedPaymentData?: boolean;
60
+ /**
61
+ * Keep amounts honest: recompute BTC figures paired with a sats amount, add
62
+ * `balance_display` to RGB asset balances the model sees, and relabel an
63
+ * asset balance the answer calls sats. Default true.
64
+ */
65
+ fixAmountConversions?: boolean;
66
+ /** Let the model reason on a forced first tool call. Default false (thinking off). */
67
+ thinkOnForcedCalls?: boolean;
68
+ /**
69
+ * Answer a wallet action (create an invoice, get an address, pay, send) with
70
+ * a fixed "no tool" reply, without inference, when no exposed tool can do it.
71
+ * Default true.
72
+ */
73
+ guardMissingTools?: boolean;
74
+ /** End the run with a fixed "Cancelled" reply when the user declines every call in a turn. Default true. */
75
+ endTurnOnDecline?: boolean;
76
+ }
77
+
78
+ export interface ComposedSkill {
79
+ skill: Skill | null;
80
+ system: string;
81
+ allowedTools?: string[];
39
82
  }
40
83
 
41
84
  export interface AgenticOptions {
@@ -52,12 +95,20 @@ export interface AgenticOptions {
52
95
  */
53
96
  onToolResult?: (event: { name: string; arguments: Record<string, unknown>; result: unknown }, turn: number) => void;
54
97
  /** Human-in-the-loop gate for tools flagged requiresConfirmation. */
55
- onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
98
+ onConfirm?: (call: { name: string; arguments: Record<string, unknown>; summary?: string }) => Promise<ConfirmDecision>;
56
99
  /**
57
100
  * Restrict the tools exposed to the model this run (progressive disclosure).
58
101
  * Typically the active skill's tool list — see SkillRegistry.compose().
59
102
  */
60
103
  allowedTools?: string[];
104
+ /**
105
+ * Tool choice for the FIRST model call only — e.g. `'required'` when the
106
+ * request is a wallet action, so the model can't answer in prose with
107
+ * made-up data. Later rounds are left to the model.
108
+ */
109
+ firstTurnToolChoice?: ToolChoice;
110
+ /** Session key passed to every model call of this run. Default: a fresh key per run. */
111
+ sessionKey?: string;
61
112
  signal?: AbortSignal;
62
113
  }
63
114
 
@@ -74,18 +125,41 @@ export interface AgenticResult {
74
125
  inference: InferenceMetrics[];
75
126
  }
76
127
 
128
+ const STOPPED_MESSAGE = 'I had to stop after several steps — please try a more specific request.';
129
+
130
+ const TOOL_CALL_FAILED_MESSAGE =
131
+ "I couldn't put together a valid request for that. Please rephrase it with the exact values (asset, amount, recipient).";
132
+
133
+ function toolErrorMessage(errors: ToolCallError[], cutOff: boolean): string {
134
+ if (cutOff) {
135
+ return 'Your tool call was cut off because the output got too long. Make ONE tool call at a time with only the required arguments, or answer from the results you already have.';
136
+ }
137
+ const detail = errors.map((e) => e.message).join('; ');
138
+ return `Your tool call could not be read (${detail}). Call the tool again with valid JSON arguments that match its schema, or ask the user for the missing values.`;
139
+ }
140
+
77
141
  export class Engine {
78
142
  private readonly provider: LLMProvider;
79
143
  private readonly registry: ToolRegistry;
80
144
  private readonly defaultSystem?: string;
81
145
  private readonly defaultMaxTurns: number;
82
146
  private readonly compressOpts?: ToolCrushOptions;
147
+ private readonly guardPaymentData: boolean;
148
+ private readonly fixAmounts: boolean;
149
+ private readonly thinkOnForcedCalls: boolean;
150
+ private readonly guardMissingTools: boolean;
151
+ private readonly endTurnOnDecline: boolean;
83
152
 
84
153
  constructor(opts: EngineOptions) {
85
154
  this.provider = opts.provider;
86
155
  this.registry = opts.tools;
87
156
  this.defaultSystem = opts.defaultSystem;
88
157
  this.defaultMaxTurns = opts.defaultMaxTurns ?? 5;
158
+ this.guardPaymentData = opts.guardUngroundedPaymentData ?? true;
159
+ this.fixAmounts = opts.fixAmountConversions ?? true;
160
+ this.thinkOnForcedCalls = opts.thinkOnForcedCalls ?? false;
161
+ this.guardMissingTools = opts.guardMissingTools ?? true;
162
+ this.endTurnOnDecline = opts.endTurnOnDecline ?? true;
89
163
  this.compressOpts = opts.compressToolOutput
90
164
  ? opts.compressToolOutput === true
91
165
  ? {}
@@ -93,7 +167,29 @@ export class Engine {
93
167
  : undefined;
94
168
  }
95
169
 
170
+ /**
171
+ * Select the skill for `query` among those that can act with this engine's
172
+ * tools (skills whose `requires-tools` are missing are skipped), then
173
+ * compose its system prompt. Pass the result to runAgentic:
174
+ *
175
+ * const { system, allowedTools } = await engine.composeSkill(skills, question, base);
176
+ * await engine.runAgentic([{ role: 'system', content: system }, { role: 'user', content: question }], { allowedTools });
177
+ */
178
+ async composeSkill(skills: SkillRegistry, query: string, base: string): Promise<ComposedSkill> {
179
+ const skill = selectAvailableSkill(skills, query, await this.registry.listTools());
180
+ return { skill, ...skills.compose(base, skill) };
181
+ }
182
+
96
183
  async runAgentic(messages: Message[], opts: AgenticOptions = {}): Promise<AgenticResult> {
184
+ const sessionKey = opts.sessionKey ?? `mind-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
185
+ try {
186
+ return await this.runAgenticSession(messages, { ...opts, sessionKey });
187
+ } finally {
188
+ if (!opts.sessionKey) await this.provider.endSession?.(sessionKey).catch(() => {});
189
+ }
190
+ }
191
+
192
+ private async runAgenticSession(messages: Message[], opts: AgenticOptions): Promise<AgenticResult> {
97
193
  const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
98
194
  const hasSystem = messages.some((m) => m.role === 'system');
99
195
  const system = hasSystem ? undefined : this.defaultSystem;
@@ -108,17 +204,37 @@ export class Engine {
108
204
  const executed: ToolResult[] = [];
109
205
  let lastRequestId: string | undefined;
110
206
  let finalText = '';
207
+ // Set when finalText is one of the engine's own fixed replies, which the
208
+ // answer guards below must not rewrite.
209
+ let engineReply = false;
111
210
  let turns = 0;
112
211
  const inference: InferenceMetrics[] = [];
212
+ const seen = new Map<string, { result: unknown; count: number }>();
213
+ let toolErrorRetries = 0;
214
+
215
+ const lastUser = [...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
216
+ const action = this.guardMissingTools ? detectWalletAction(lastUser) : null;
217
+ if (action && !hasCapableTool(action, allTools.map((t) => t.name))) {
218
+ const text = noToolReply(action);
219
+ history.push({ role: 'assistant', content: text });
220
+ return { text, turns: 0, toolCalls: [], messages: history, latencyMs: Date.now() - startedAt, inference };
221
+ }
113
222
 
114
- for (let turn = 1; turn <= maxTurns; turn++) {
223
+ // A retry after an unreadable tool call does not count against maxTurns.
224
+ for (let turn = 1; turn <= maxTurns + toolErrorRetries; turn++) {
115
225
  turns = turn;
116
226
  if (opts.signal?.aborted) break;
117
227
 
118
228
  const out = await this.provider.runTurn({
229
+ sessionKey: opts.sessionKey,
119
230
  messages: history,
120
231
  tools: allTools,
121
232
  system,
233
+ // A forced first call only picks the tool and its arguments; reasoning
234
+ // there costs most of the turn's time on small models.
235
+ ...(turn === 1 && opts.firstTurnToolChoice && allTools.length
236
+ ? { toolChoice: opts.firstTurnToolChoice, ...(this.thinkOnForcedCalls ? {} : { thinking: 'off' as const }) }
237
+ : {}),
122
238
  onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
123
239
  signal: opts.signal,
124
240
  });
@@ -126,40 +242,126 @@ export class Engine {
126
242
  lastRequestId = out.requestId;
127
243
  if (out.inference) inference.push(out.inference);
128
244
  if (out.requestId) opts.onStart?.(out.requestId, turn);
129
- finalText = (out.text || '').trim();
245
+ finalText = out.incomplete ? '' : (out.text || '').trim();
246
+
247
+ // The model tried to call a tool but the call didn't parse: tell it what
248
+ // went wrong and let it try again (once) instead of showing the broken
249
+ // frame as the answer.
250
+ if ((!out.toolCalls || out.toolCalls.length === 0) && out.toolErrors?.length) {
251
+ if (toolErrorRetries < 1) {
252
+ toolErrorRetries += 1;
253
+ const cutOff = out.inference?.status === 'truncated';
254
+ history.push({ role: 'assistant', content: out.rawContent || finalText });
255
+ history.push({ role: 'tool', content: JSON.stringify({ error: toolErrorMessage(out.toolErrors, cutOff) }) });
256
+ continue;
257
+ }
258
+ finalText = TOOL_CALL_FAILED_MESSAGE;
259
+ engineReply = true;
260
+ break;
261
+ }
130
262
 
131
263
  // No tool calls ⇒ the model produced its final answer.
132
- if (!out.toolCalls || out.toolCalls.length === 0) break;
264
+ if (!out.toolCalls || out.toolCalls.length === 0) {
265
+ if (!finalText && executed.length) finalText = await this.recoverAnswer(history, system, executed, inference, opts, turn);
266
+ else if (!finalText && out.incomplete) finalText = (out.text || '').trim();
267
+ break;
268
+ }
133
269
 
134
270
  // Anchor the next turn with the raw assistant frame.
135
271
  history.push({ role: 'assistant', content: out.rawContent || finalText });
136
272
 
273
+ let repeatedAgain = false;
274
+ const declinedThisTurn: string[] = [];
137
275
  for (const call of out.toolCalls) {
138
276
  opts.onToolCall?.({ name: call.name, arguments: call.arguments }, turn);
139
277
  const def = await this.registry.getDef(call.name);
278
+ const key = callKey(call.name, call.arguments);
279
+ const previous = seen.get(key);
140
280
 
281
+ let args = call.arguments;
141
282
  let result: unknown;
142
- if (def?.requiresConfirmation) {
143
- const decision = opts.onConfirm
144
- ? await opts.onConfirm({ name: call.name, arguments: call.arguments })
145
- : { approved: false, reason: 'no confirmation handler available' };
146
- if (decision.approved) {
147
- result = await this.safeExecute(call.name, call.arguments);
283
+ if (previous) {
284
+ previous.count += 1;
285
+ if (previous.count > 2) repeatedAgain = true;
286
+ result = {
287
+ error:
288
+ `You already called ${call.name} with these arguments; the result was: ` +
289
+ `${this.toHistoryContent(previous.result)}. Do not call it again — answer the user now.`,
290
+ };
291
+ } else if (!def) {
292
+ result = { error: `Unknown tool "${call.name}".` };
293
+ } else {
294
+ const check = validateToolArgs(def, call.arguments);
295
+ if (!check.ok) {
296
+ result = {
297
+ error: `Invalid arguments for ${call.name}: ${check.errors.join('; ')}. Fix them or ask the user for the missing values.`,
298
+ };
299
+ } else if (def.requiresConfirmation) {
300
+ args = check.args;
301
+ const summary = confirmReadback({ name: call.name, arguments: args }) ?? undefined;
302
+ const decision = opts.onConfirm
303
+ ? await opts.onConfirm({ name: call.name, arguments: args, ...(summary ? { summary } : {}) })
304
+ : { approved: false, reason: 'no confirmation handler available' };
305
+ if (decision.approved) {
306
+ result = await this.safeExecute(call.name, args);
307
+ } else {
308
+ result = declinedToolResult(call.name, decision.reason);
309
+ declinedThisTurn.push(summary ? summary.replace(/\.?\s*Confirm\?$/, '') : call.name.replace(/_/g, ' '));
310
+ }
148
311
  } else {
149
- result = { declined: true, reason: decision.reason ?? 'user declined' };
312
+ args = check.args;
313
+ result = await this.safeExecute(call.name, args);
150
314
  }
151
- } else {
152
- result = await this.safeExecute(call.name, call.arguments);
153
315
  }
154
316
 
155
- executed.push({ name: call.name, arguments: call.arguments, result });
156
- opts.onToolResult?.({ name: call.name, arguments: call.arguments, result }, turn);
157
- history.push({ role: 'tool', content: this.toHistoryContent(result) });
317
+ if (!previous) {
318
+ // A mutating (confirm-gated) call can change what reads return.
319
+ if (def?.requiresConfirmation) seen.clear();
320
+ seen.set(key, { result, count: 1 });
321
+ }
322
+ executed.push({ name: call.name, arguments: args, result });
323
+ opts.onToolResult?.({ name: call.name, arguments: args, result }, turn);
324
+ history.push({ role: 'tool', content: this.toHistoryContent(this.fixAmounts ? annotateRgbBalances(result) : result) });
325
+ }
326
+
327
+ if (this.endTurnOnDecline && declinedThisTurn.length && declinedThisTurn.length === out.toolCalls.length) {
328
+ finalText = `Cancelled — you declined: ${declinedThisTurn.join('; ')}. Nothing was sent or changed.`;
329
+ engineReply = true;
330
+ break;
158
331
  }
159
332
 
160
- if (turn === maxTurns && !finalText) {
161
- finalText = 'I had to stop after several steps — please try a more specific request.';
333
+ if (repeatedAgain) {
334
+ const forced = await this.provider.runTurn({
335
+ sessionKey: opts.sessionKey,
336
+ messages: history,
337
+ tools: [],
338
+ system,
339
+ onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
340
+ signal: opts.signal,
341
+ });
342
+ if (forced.inference) inference.push(forced.inference);
343
+ finalText = (forced.text || '').trim() || 'I could not get a different result from the wallet — please try a more specific request.';
344
+ break;
162
345
  }
346
+
347
+ }
348
+
349
+ // Never return an empty answer (e.g. the last turn ran out of tokens).
350
+ if (!finalText && !opts.signal?.aborted) {
351
+ finalText = STOPPED_MESSAGE;
352
+ engineReply = true;
353
+ }
354
+
355
+ if (this.fixAmounts && finalText && !engineReply) {
356
+ finalText = fixRgbBalanceUnits(fixSatsBtcConversions(finalText), executed.map((e) => e.result));
357
+ }
358
+
359
+ if (this.guardPaymentData && finalText && !engineReply) {
360
+ const ungrounded = findUngroundedPaymentData(finalText, [
361
+ ...messages.map((m) => m.content),
362
+ ...executed.map((e) => e.result),
363
+ ]);
364
+ if (ungrounded.length) finalText = ungroundedReply(ungrounded);
163
365
  }
164
366
 
165
367
  // Append the final answer so the returned conversation is complete (the
@@ -177,6 +379,39 @@ export class Engine {
177
379
  };
178
380
  }
179
381
 
382
+ /**
383
+ * The model ran tools but produced no visible answer (e.g. reasoning used the
384
+ * whole output budget). Ask once more without tools; if that is empty too,
385
+ * show the last tool result instead of an empty reply.
386
+ */
387
+ private async recoverAnswer(
388
+ history: Message[],
389
+ system: string | undefined,
390
+ executed: ToolResult[],
391
+ inference: InferenceMetrics[],
392
+ opts: AgenticOptions,
393
+ turn: number,
394
+ ): Promise<string> {
395
+ if (opts.signal?.aborted) return '';
396
+ const retry = await this.provider.runTurn({
397
+ sessionKey: opts.sessionKey,
398
+ messages: [
399
+ ...history,
400
+ { role: 'user', content: 'Answer my question now from the tool results above, in a few short sentences.' },
401
+ ],
402
+ tools: [],
403
+ system,
404
+ onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
405
+ signal: opts.signal,
406
+ });
407
+ if (retry.inference) inference.push(retry.inference);
408
+ const text = retry.incomplete ? '' : (retry.text || '').trim();
409
+ if (text) return text;
410
+ const last = executed[executed.length - 1]!;
411
+ const body = compressToolResult(last.result, this.compressOpts ?? {}).content;
412
+ return `I couldn't phrase an answer in time. Here is what ${last.name.replace(/_/g, ' ')} returned:\n\n${body.length > 2000 ? `${body.slice(0, 2000)}…` : body}`;
413
+ }
414
+
180
415
  async cancel(requestId: string): Promise<void> {
181
416
  await this.provider.cancel?.(requestId);
182
417
  }
@@ -16,6 +16,8 @@ export interface FastIntent {
16
16
  name: string;
17
17
  /** Contract tool to call when this intent matches. */
18
18
  tool: string;
19
+ /** Equivalent tools to try, in order, when the host doesn't expose `tool`. */
20
+ fallbackTools?: string[];
19
21
  /** True only when this intent UNAMBIGUOUSLY matches the text. */
20
22
  match: (text: string) => boolean;
21
23
  /** Optional args derived from the text (default: none). */
@@ -24,7 +26,10 @@ export interface FastIntent {
24
26
 
25
27
  export interface FastHit {
26
28
  intent: FastIntent;
29
+ /** The intent's primary tool. */
27
30
  tool: string;
31
+ /** Primary tool first, then the fallbacks. */
32
+ tools: string[];
28
33
  args: Record<string, unknown>;
29
34
  }
30
35
 
@@ -42,24 +47,39 @@ export class FastPath {
42
47
  /** The first unambiguously-matching intent, or null. */
43
48
  select(text: string): FastHit | null {
44
49
  const intent = this.intents.find((i) => i.match(text));
45
- return intent ? { intent, tool: intent.tool, args: intent.args?.(text) ?? {} } : null;
50
+ return intent
51
+ ? { intent, tool: intent.tool, tools: [intent.tool, ...(intent.fallbackTools ?? [])], args: intent.args?.(text) ?? {} }
52
+ : null;
46
53
  }
47
54
  }
48
55
 
49
56
  // A "spend or compound" guard — never fast-path anything that moves money or
50
57
  // chains another action ("send", "pay", "and then", "swap").
51
58
  const ACTIONY = /\b(send|pay|transfer|swap|buy|sell|then|after that)\b/i;
59
+ // Asks that create something are not reads, even when they mention assets.
60
+ const CREATEY = /\b(issue|mint|create|make|new|generate|invoice|receive|request)\b/i;
52
61
 
53
- /** Default wallet read intents (balance / receive address / price). */
62
+ /** Default wallet read intents (balance / RGB assets / receive address / price). */
54
63
  export const WALLET_FAST_INTENTS: FastIntent[] = [
55
64
  {
56
65
  name: 'balance',
57
66
  tool: 'get_balances',
67
+ fallbackTools: ['rln_get_balances', 'wdk_get_balances'],
58
68
  match: (t) => !ACTIONY.test(t) && /\b(balance|funds|how much (do i|have i|i have)|how much.* (do i have|in my wallet))\b/i.test(t),
59
69
  },
70
+ {
71
+ name: 'assets',
72
+ tool: 'rln_list_assets',
73
+ fallbackTools: ['wdk_list_assets'],
74
+ match: (t) =>
75
+ !ACTIONY.test(t) &&
76
+ !CREATEY.test(t) &&
77
+ /\b(which|what|list|show|my)\b[^.?!]*\b(rgb assets?|assets?|tokens?)\b|\b(rgb assets?|tokens?) (do i|i) (have|hold|own)\b/i.test(t),
78
+ },
60
79
  {
61
80
  name: 'address',
62
81
  tool: 'spark_get_address',
82
+ fallbackTools: ['rln_get_address', 'wdk_get_address'],
63
83
  match: (t) => !ACTIONY.test(t) && /\b(receive address|deposit address|my address|an address|get .*address|where.* receive)\b/i.test(t),
64
84
  },
65
85
  {
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defaultRenderFast } from './render.js';
3
+ import { FastPath, WALLET_FAST_INTENTS } from './fastpath.js';
4
+
5
+ const fp = new FastPath(WALLET_FAST_INTENTS);
6
+
7
+ describe('fast path: RLN tools and RGB assets', () => {
8
+ it('lists fallback tools after the primary one', () => {
9
+ expect(fp.select('What is my on-chain BTC balance?')?.tools).toEqual(['get_balances', 'rln_get_balances', 'wdk_get_balances']);
10
+ });
11
+
12
+ it('routes asset questions, not asset actions', () => {
13
+ expect(fp.select('Which RGB assets do I hold, and what are the balances?')?.intent.name).toBe('assets');
14
+ expect(fp.select('list my tokens')?.intent.name).toBe('assets');
15
+ expect(fp.select('Issue a new RGB asset named FOO')).toBeNull();
16
+ expect(fp.select('Create an RGB invoice so I can receive 10 USDT')).toBeNull();
17
+ expect(fp.select('Send 1 USDT to rgb:abc')).toBeNull();
18
+ });
19
+
20
+ it('renders the kaleido-mcp, aggregate and MockWallet balance shapes', () => {
21
+ expect(
22
+ defaultRenderFast(
23
+ 'balance',
24
+ JSON.stringify({ btc_onchain: { vanilla_spendable_sats: 4277, colored_spendable_sats: 195000 }, lightning_balance_sat: 0 }),
25
+ ),
26
+ ).toBe('On-chain: 4,277 sats spendable.\nRGB UTXOs: 195,000 sats (holding RGB assets, not for spending).\nLightning: 0 sats.');
27
+ expect(defaultRenderFast('balance', { total_sats: 1000, layers: [{}, {}] })).toBe('You have 1,000 sats across 2 layers.');
28
+ expect(defaultRenderFast('balance', { btc_sats: 300000 })).toBe('You have 300,000 sats.');
29
+ });
30
+
31
+ it('renders RGB assets with their ticker and precision', () => {
32
+ const mcp = [
33
+ { ticker: 'Q35A', name: 'Bench Q35A', precision: 0, balance: { spendable: 1_000_000 } },
34
+ { ticker: 'USDT', name: 'Tether USD', precision: 6, balance: { spendable: 2_500_000 } },
35
+ ];
36
+ expect(defaultRenderFast('assets', mcp)).toBe('Your RGB assets:\n- Q35A (Bench Q35A): 1,000,000 Q35A\n- USDT (Tether USD): 2.5 USDT');
37
+ expect(defaultRenderFast('assets', { assets: [] })).toBe("You don't hold any RGB assets yet.");
38
+ });
39
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Text for a fast-path result. Tools that answer the same intent return
3
+ * different shapes (aggregate wallet, kaleido-mcp, MockWallet), so each shape
4
+ * is read here instead of asking a model to describe it.
5
+ */
6
+ import { formatRgbAmount } from '../context/rgb-units.js';
7
+
8
+ type Obj = Record<string, any>;
9
+
10
+ const sats = (n: unknown) => `${Number(n ?? 0).toLocaleString('en-US')} sats`;
11
+
12
+ function parse(r: unknown): unknown {
13
+ if (typeof r !== 'string') return r;
14
+ try {
15
+ return JSON.parse(r);
16
+ } catch {
17
+ return r;
18
+ }
19
+ }
20
+
21
+ function renderBalance(r: Obj): string {
22
+ if (r.total_sats !== undefined) {
23
+ const n = r.layers?.length ?? 0;
24
+ return `You have ${sats(r.total_sats)}${n > 1 ? ` across ${n} layers` : ''}.`;
25
+ }
26
+ if (r.btc_onchain) {
27
+ const b = r.btc_onchain;
28
+ const lines = [`On-chain: ${sats(b.vanilla_spendable_sats)} spendable.`];
29
+ if (Number(b.colored_spendable_sats)) lines.push(`RGB UTXOs: ${sats(b.colored_spendable_sats)} (holding RGB assets, not for spending).`);
30
+ if (r.lightning_balance_sat !== undefined) lines.push(`Lightning: ${sats(r.lightning_balance_sat)}.`);
31
+ return lines.join('\n');
32
+ }
33
+ if (r.btc_sats !== undefined) return `You have ${sats(r.btc_sats)}.`;
34
+ return `Balances:\n${JSON.stringify(r, null, 2)}`;
35
+ }
36
+
37
+ function renderAssets(r: unknown): string {
38
+ const list: Obj[] = Array.isArray(r) ? r : Array.isArray((r as Obj)?.assets) ? (r as Obj).assets : [];
39
+ if (!list.length) return "You don't hold any RGB assets yet.";
40
+ const rows = list.map((a) => {
41
+ const raw = a.balance?.spendable ?? a.balance?.settled;
42
+ const amount = typeof raw === 'number' ? `${formatRgbAmount(raw, Number(a.precision) || 0)} ${a.ticker}` : 'balance unknown';
43
+ return `- ${a.ticker}${a.name && a.name !== a.ticker ? ` (${a.name})` : ''}: ${amount}`;
44
+ });
45
+ return `Your RGB assets:\n${rows.join('\n')}`;
46
+ }
47
+
48
+ export function defaultRenderFast(intent: string, result: unknown): string {
49
+ const r = parse(result) as Obj;
50
+ if (intent === 'balance') return renderBalance(r ?? {});
51
+ if (intent === 'assets') return renderAssets(r);
52
+ if (intent === 'address') {
53
+ return r?.address ? `Here's your receive address:\n\n\`${r.address}\`` : 'No address available right now.';
54
+ }
55
+ return `Bitcoin is $${Number(r?.price_usd ?? 0).toLocaleString('en-US')}.`;
56
+ }
@@ -17,6 +17,7 @@
17
17
  * Fully deterministic (no node/model/maker), so it runs in CI. Live tool
18
18
  * execution against a real node is the separate mcp.live.test.ts.
19
19
  */
20
+ import type { FastIntent } from './fastpath/fastpath.js';
20
21
  import { describe, expect, it } from 'vitest';
21
22
  import { Funnel } from './funnel.js';
22
23
  import { ToolRegistry } from './tools/registry.js';
@@ -83,7 +84,7 @@ function searchMerchants(query: string): string {
83
84
  */
84
85
  function buildMind(
85
86
  provider: LLMProvider,
86
- opts: { skills?: Skill[]; log?: (m: string) => void } = {},
87
+ opts: { skills?: Skill[]; log?: (m: string) => void; fastIntents?: FastIntent[] } = {},
87
88
  ): { funnel: Funnel; calls: Array<{ name: string; args: any }> } {
88
89
  const calls: Array<{ name: string; args: any }> = [];
89
90
  const tool = (name: string, response: any, spend = false) => ({
@@ -140,27 +141,21 @@ function buildMind(
140
141
  ]);
141
142
 
142
143
  return {
143
- funnel: new Funnel({ provider, tools, recipes: DESKTOP_RECIPES, maxTurns: 8, skills: opts.skills, log: opts.log }),
144
+ funnel: new Funnel({ provider, tools, recipes: DESKTOP_RECIPES, maxTurns: 8, skills: opts.skills, log: opts.log, ...(opts.fastIntents ? { fastIntents: opts.fastIntents } : {}) }),
144
145
  calls,
145
146
  };
146
147
  }
147
148
 
148
149
  describe('desktop mind — balance', () => {
149
- it('routes "what\'s my balance?" to the agentic tier and calls rln_get_balances', async () => {
150
- const { funnel, calls } = buildMind(
151
- scripted([
152
- { text: '', toolCalls: [{ name: 'rln_get_balances', arguments: {} }] },
153
- { text: 'You have 1,949,753 sats in Lightning.' },
154
- ]),
155
- );
150
+ it('answers "what\'s my balance?" on the fast path with rln_get_balances, no model', async () => {
151
+ const { funnel, calls } = buildMind(scripted([]));
156
152
 
157
153
  const res = await funnel.runTurn("what's my balance?");
158
154
 
159
- expect(res.tier).toBe('agentic');
160
- expect(calls.map((c) => c.name)).toContain('rln_get_balances');
161
- const exec = res.toolCalls?.find((c) => c.name === 'rln_get_balances');
162
- expect((exec?.result as { lightning_balance_sat?: number })?.lightning_balance_sat).toBe(1_949_753);
163
- expect(res.text).toBeTruthy();
155
+ expect(res.tier).toBe('fast');
156
+ expect(calls.map((c) => c.name)).toEqual(['rln_get_balances']);
157
+ expect((res.data as { lightning_balance_sat?: number })?.lightning_balance_sat).toBe(1_949_753);
158
+ expect(res.text).toBe('On-chain: 100,000 sats spendable.\nLightning: 1,949,753 sats.');
164
159
  });
165
160
  });
166
161
 
@@ -335,11 +330,13 @@ describe('desktop mind — skill scoping (real skills)', () => {
335
330
  expect(node.tools?.every((tool) => tool.startsWith('rln_'))).toBe(true);
336
331
  });
337
332
 
338
- it('kaleido-trading drops the phantom kaleidoswap_get_nodeinfo / get_order_history names', () => {
333
+ it('kaleido-trading drops the phantom kaleidoswap_get_nodeinfo / removed order-flow names', () => {
339
334
  const trading = SKILLS.find((s) => s.name === 'kaleido-trading')!;
340
335
  expect(trading.tools).not.toContain('kaleidoswap_get_nodeinfo');
341
336
  expect(trading.tools).not.toContain('kaleidoswap_get_order_history');
342
- expect(trading.tools).toEqual(expect.arrayContaining(['kaleidoswap_get_quote', 'kaleidoswap_place_order']));
337
+ expect(trading.tools).not.toContain('kaleidoswap_place_order');
338
+ expect(trading.tools).not.toContain('kaleidoswap_get_order_status');
339
+ expect(trading.tools).toEqual(expect.arrayContaining(['kaleidoswap_get_quote', 'kaleidoswap_atomic_init']));
343
340
  expect(trading.tools).not.toEqual(
344
341
  expect.arrayContaining([
345
342
  'kaleidoswap_get_spreads',
@@ -357,7 +354,8 @@ describe('desktop mind — skill scoping (real skills)', () => {
357
354
  { text: '', toolCalls: [{ name: 'rln_get_balances', arguments: {} }] },
358
355
  { text: 'You have 1,949,753 sats.' },
359
356
  ]),
360
- { skills: SKILLS, log: (m) => logs.push(m) },
357
+ // Fast path off, so the balance ask exercises skill scoping.
358
+ { skills: SKILLS, log: (m) => logs.push(m), fastIntents: [] },
361
359
  );
362
360
 
363
361
  const res = await funnel.runTurn("what's my balance?");