@kaleidorg/mind 0.6.4 → 0.8.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 (189) hide show
  1. package/README.md +345 -0
  2. package/dist/autonomy/risk.js.map +1 -1
  3. package/dist/autonomy/run-state.d.ts.map +1 -1
  4. package/dist/autonomy/run-state.js.map +1 -1
  5. package/dist/autonomy/task-store.d.ts.map +1 -1
  6. package/dist/autonomy/task-store.js.map +1 -1
  7. package/dist/bitrefill/contract.js.map +1 -1
  8. package/dist/context/budget.js.map +1 -1
  9. package/dist/context/builder.d.ts.map +1 -1
  10. package/dist/context/builder.js.map +1 -1
  11. package/dist/context/compress.d.ts.map +1 -1
  12. package/dist/context/compress.js +1 -0
  13. package/dist/context/compress.js.map +1 -1
  14. package/dist/context/rgb-units.d.ts +18 -0
  15. package/dist/context/rgb-units.d.ts.map +1 -0
  16. package/dist/context/rgb-units.js +86 -0
  17. package/dist/context/rgb-units.js.map +1 -0
  18. package/dist/engine.d.ts +53 -1
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +179 -17
  21. package/dist/engine.js.map +1 -1
  22. package/dist/evidence.d.ts +1 -1
  23. package/dist/evidence.d.ts.map +1 -1
  24. package/dist/evidence.js.map +1 -1
  25. package/dist/fastpath/fastpath.d.ts.map +1 -1
  26. package/dist/fastpath/fastpath.js.map +1 -1
  27. package/dist/flashnet/contract.js.map +1 -1
  28. package/dist/funnel.d.ts.map +1 -1
  29. package/dist/funnel.js +23 -4
  30. package/dist/funnel.js.map +1 -1
  31. package/dist/guards.d.ts +63 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +284 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/index.d.ts +10 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/kaleidoswap/contract.d.ts +3 -4
  40. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  41. package/dist/kaleidoswap/contract.js +3 -17
  42. package/dist/kaleidoswap/contract.js.map +1 -1
  43. package/dist/knowledge/btc-map.js.map +1 -1
  44. package/dist/logger.d.ts.map +1 -1
  45. package/dist/logger.js.map +1 -1
  46. package/dist/lsps1/contract.js.map +1 -1
  47. package/dist/memory/store.d.ts.map +1 -1
  48. package/dist/memory/store.js.map +1 -1
  49. package/dist/providers/openai.d.ts +64 -0
  50. package/dist/providers/openai.d.ts.map +1 -0
  51. package/dist/providers/openai.js +233 -0
  52. package/dist/providers/openai.js.map +1 -0
  53. package/dist/providers/types.d.ts +20 -0
  54. package/dist/providers/types.d.ts.map +1 -1
  55. package/dist/qvac/assistant.js.map +1 -1
  56. package/dist/qvac/config.d.ts +7 -7
  57. package/dist/qvac/config.d.ts.map +1 -1
  58. package/dist/qvac/config.js +1 -1
  59. package/dist/qvac/delegate.d.ts +2 -0
  60. package/dist/qvac/delegate.d.ts.map +1 -1
  61. package/dist/qvac/delegate.js +2 -0
  62. package/dist/qvac/delegate.js.map +1 -1
  63. package/dist/qvac/index.d.ts +2 -0
  64. package/dist/qvac/index.d.ts.map +1 -1
  65. package/dist/qvac/index.js +2 -0
  66. package/dist/qvac/index.js.map +1 -1
  67. package/dist/qvac/models.d.ts +31 -0
  68. package/dist/qvac/models.d.ts.map +1 -0
  69. package/dist/qvac/models.js +80 -0
  70. package/dist/qvac/models.js.map +1 -0
  71. package/dist/qvac/parse.d.ts +16 -2
  72. package/dist/qvac/parse.d.ts.map +1 -1
  73. package/dist/qvac/parse.js +29 -7
  74. package/dist/qvac/parse.js.map +1 -1
  75. package/dist/qvac/provider.d.ts +5 -4
  76. package/dist/qvac/provider.d.ts.map +1 -1
  77. package/dist/qvac/provider.js +30 -21
  78. package/dist/qvac/provider.js.map +1 -1
  79. package/dist/qvac/stream.d.ts +3 -5
  80. package/dist/qvac/stream.d.ts.map +1 -1
  81. package/dist/qvac/stream.js +29 -1
  82. package/dist/qvac/stream.js.map +1 -1
  83. package/dist/qvac/tools.d.ts +37 -0
  84. package/dist/qvac/tools.d.ts.map +1 -0
  85. package/dist/qvac/tools.js +94 -0
  86. package/dist/qvac/tools.js.map +1 -0
  87. package/dist/qvac/voice.js.map +1 -1
  88. package/dist/rag/retriever.d.ts.map +1 -1
  89. package/dist/rag/tool.js.map +1 -1
  90. package/dist/rag/vector-store.d.ts.map +1 -1
  91. package/dist/rag/vector-store.js.map +1 -1
  92. package/dist/recipe/issue-asset.d.ts +16 -0
  93. package/dist/recipe/issue-asset.d.ts.map +1 -0
  94. package/dist/recipe/issue-asset.js +120 -0
  95. package/dist/recipe/issue-asset.js.map +1 -0
  96. package/dist/recipe/runner.d.ts.map +1 -1
  97. package/dist/recipe/runner.js +1 -0
  98. package/dist/recipe/runner.js.map +1 -1
  99. package/dist/recipe/submarine-pay.d.ts +17 -0
  100. package/dist/recipe/submarine-pay.d.ts.map +1 -0
  101. package/dist/recipe/submarine-pay.js +79 -0
  102. package/dist/recipe/submarine-pay.js.map +1 -0
  103. package/dist/skills/loader.js.map +1 -1
  104. package/dist/skills/registry.d.ts.map +1 -1
  105. package/dist/skills/registry.js +1 -1
  106. package/dist/skills/registry.js.map +1 -1
  107. package/dist/skills/select.d.ts +20 -0
  108. package/dist/skills/select.d.ts.map +1 -0
  109. package/dist/skills/select.js +32 -0
  110. package/dist/skills/select.js.map +1 -0
  111. package/dist/submarine/contract.d.ts +42 -0
  112. package/dist/submarine/contract.d.ts.map +1 -0
  113. package/dist/submarine/contract.js +73 -0
  114. package/dist/submarine/contract.js.map +1 -0
  115. package/dist/testing/index.d.ts +14 -0
  116. package/dist/testing/index.d.ts.map +1 -0
  117. package/dist/testing/index.js +12 -0
  118. package/dist/testing/index.js.map +1 -0
  119. package/dist/testing/mock-wallet.d.ts +103 -0
  120. package/dist/testing/mock-wallet.d.ts.map +1 -0
  121. package/dist/testing/mock-wallet.js +249 -0
  122. package/dist/testing/mock-wallet.js.map +1 -0
  123. package/dist/testing/scripted-provider.d.ts +17 -0
  124. package/dist/testing/scripted-provider.d.ts.map +1 -0
  125. package/dist/testing/scripted-provider.js +26 -0
  126. package/dist/testing/scripted-provider.js.map +1 -0
  127. package/dist/tools/in-process.d.ts.map +1 -1
  128. package/dist/tools/mcp.d.ts.map +1 -1
  129. package/dist/tools/registry.d.ts.map +1 -1
  130. package/dist/tools/registry.js.map +1 -1
  131. package/dist/wallet/confirm.d.ts.map +1 -1
  132. package/dist/wallet/confirm.js +43 -5
  133. package/dist/wallet/confirm.js.map +1 -1
  134. package/dist/wallet/contract.d.ts.map +1 -1
  135. package/dist/wallet/contract.js +36 -0
  136. package/dist/wallet/contract.js.map +1 -1
  137. package/package.json +15 -5
  138. package/skills/README.md +2 -1
  139. package/skills/kaleido-node/SKILL.md +63 -0
  140. package/skills/kaleido-trading/SKILL.md +34 -27
  141. package/skills/kaleido-trading/references/api.md +61 -0
  142. package/skills/kaleido-trading/references/assets.md +57 -0
  143. package/skills/kaleido-trading/references/atomic.md +93 -0
  144. package/skills/paid-data/SKILL.md +59 -6
  145. package/skills/rgb-lightning-node/SKILL.md +142 -17
  146. package/skills/spark-wallet/SKILL.md +1 -0
  147. package/skills/submarine-swaps/SKILL.md +48 -0
  148. package/src/context/compress.ts +1 -0
  149. package/src/context/rgb-units.test.ts +42 -0
  150. package/src/context/rgb-units.ts +89 -0
  151. package/src/engine.test.ts +94 -3
  152. package/src/engine.ts +234 -19
  153. package/src/funnel.mind.test.ts +4 -2
  154. package/src/funnel.ts +23 -4
  155. package/src/guards.test.ts +399 -0
  156. package/src/guards.ts +299 -0
  157. package/src/index.ts +40 -1
  158. package/src/kaleidoswap/contract.test.ts +8 -16
  159. package/src/kaleidoswap/contract.ts +4 -32
  160. package/src/providers/openai.test.ts +127 -0
  161. package/src/providers/openai.ts +282 -0
  162. package/src/providers/types.ts +22 -0
  163. package/src/qvac/config.ts +1 -1
  164. package/src/qvac/delegate.ts +2 -0
  165. package/src/qvac/index.ts +12 -0
  166. package/src/qvac/models.ts +98 -0
  167. package/src/qvac/parse.test.ts +7 -0
  168. package/src/qvac/parse.ts +38 -3
  169. package/src/qvac/provider.test.ts +72 -1
  170. package/src/qvac/provider.ts +40 -27
  171. package/src/qvac/stream.test.ts +28 -0
  172. package/src/qvac/stream.ts +33 -6
  173. package/src/qvac/tools.test.ts +86 -0
  174. package/src/qvac/tools.ts +116 -0
  175. package/src/recipe/issue-asset.test.ts +131 -0
  176. package/src/recipe/issue-asset.ts +123 -0
  177. package/src/recipe/runner.ts +1 -0
  178. package/src/recipe/submarine-pay.test.ts +87 -0
  179. package/src/recipe/submarine-pay.ts +78 -0
  180. package/src/skills/registry.ts +1 -1
  181. package/src/skills/select.ts +41 -0
  182. package/src/submarine/contract.ts +112 -0
  183. package/src/testing/index.ts +14 -0
  184. package/src/testing/mock-wallet.ts +278 -0
  185. package/src/testing/scripted-provider.ts +37 -0
  186. package/src/wallet/confirm.test.ts +16 -0
  187. package/src/wallet/confirm.ts +43 -5
  188. package/src/wallet/contract.test.ts +38 -0
  189. package/src/wallet/contract.ts +36 -0
@@ -111,8 +111,8 @@ describe('Engine agentic loop', () => {
111
111
  const res = await engine.runAgentic([{ role: 'user', content: 'pay lnbc1' }], { onConfirm });
112
112
 
113
113
  expect(payTool.handler).not.toHaveBeenCalled();
114
- expect(res.toolCalls[0].result).toMatchObject({ declined: true, reason: 'cancelled' });
115
- expect(res.text).toBe('Okay, cancelled.');
114
+ expect(res.toolCalls[0].result).toMatchObject({ status: 'cancelled_by_user', declined_by: 'user', host_reason: 'cancelled' });
115
+ expect(res.text).toBe('Cancelled — you declined: pay invoice. Nothing was sent or changed.');
116
116
  });
117
117
 
118
118
  it('chains multiple tool calls across turns', async () => {
@@ -138,7 +138,9 @@ describe('Engine agentic loop', () => {
138
138
  it('stops at maxTurns if the model never stops calling tools', async () => {
139
139
  const engine = new Engine({
140
140
  provider: scriptedProvider([
141
- { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: {} }] }, // always calls a tool
141
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 1 } }] },
142
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 2 } }] },
143
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 3 } }] },
142
144
  ]),
143
145
  tools: freshTools(),
144
146
  defaultMaxTurns: 3,
@@ -205,6 +207,95 @@ describe('Engine agentic loop', () => {
205
207
  expect(res.toolCalls[0].result).toMatchObject({ error: 'kaboom' });
206
208
  expect(res.text).toBe('handled the error');
207
209
  });
210
+
211
+ it('sends firstTurnToolChoice on the first call only', async () => {
212
+ const seen: Array<string | undefined> = [];
213
+ const provider: LLMProvider = {
214
+ name: 'rec',
215
+ async runTurn(input) {
216
+ seen.push(input.toolChoice);
217
+ return seen.length === 1
218
+ ? { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] }
219
+ : { text: 'done', rawContent: 'done', toolCalls: [] };
220
+ },
221
+ };
222
+ const engine = new Engine({ provider, tools: freshTools() });
223
+ await engine.runAgentic([{ role: 'user', content: 'send 1000 sats' }], { firstTurnToolChoice: 'required' });
224
+ expect(seen).toEqual(['required', undefined]);
225
+ });
226
+
227
+ it('feeds an unparseable tool call back to the model once, then gives up cleanly', async () => {
228
+ const histories: number[] = [];
229
+ const provider: LLMProvider = {
230
+ name: 'broken',
231
+ async runTurn(input) {
232
+ histories.push(input.messages.length);
233
+ return {
234
+ text: '<tool_call>{"ticker":"HCK',
235
+ rawContent: '<tool_call>{"ticker":"HCK',
236
+ toolCalls: [],
237
+ toolErrors: [{ code: 'PARSE_ERROR', message: 'unterminated string' }],
238
+ };
239
+ },
240
+ };
241
+ const engine = new Engine({ provider, tools: freshTools() });
242
+ const res = await engine.runAgentic([{ role: 'user', content: 'issue HCK' }]);
243
+ expect(histories).toEqual([1, 3]);
244
+ expect(res.text).not.toContain('tool_call');
245
+ expect(res.text).toMatch(/couldn't put together a valid request/i);
246
+ });
247
+
248
+ it('does not count the retry against maxTurns and explains a cut-off call', async () => {
249
+ const seen: string[] = [];
250
+ let n = 0;
251
+ const provider: LLMProvider = {
252
+ name: 'cutoff',
253
+ async runTurn(input) {
254
+ n += 1;
255
+ seen.push(input.messages[input.messages.length - 1]!.content);
256
+ if (n === 1) return { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] };
257
+ if (n === 2) {
258
+ return {
259
+ text: '', rawContent: '<tool_call>{"name":"get_balance"', toolCalls: [],
260
+ toolErrors: [{ code: 'PARSE_ERROR', message: 'eof' }],
261
+ inference: { durationMs: 1, status: 'truncated' },
262
+ };
263
+ }
264
+ return { text: 'You have 50,000 sats.', rawContent: '', toolCalls: [] };
265
+ },
266
+ };
267
+ const engine = new Engine({ provider, tools: freshTools() });
268
+ const res = await engine.runAgentic([{ role: 'user', content: 'balance?' }], { maxTurns: 2 });
269
+ expect(res.text).toBe('You have 50,000 sats.');
270
+ expect(seen[2]).toMatch(/cut off/);
271
+ });
272
+
273
+ it('never returns an empty answer', async () => {
274
+ const engine = new Engine({ provider: scriptedProvider([{ text: '' }]), tools: freshTools() });
275
+ const res = await engine.runAgentic([{ role: 'user', content: 'hi' }]);
276
+ expect(res.text).toMatch(/had to stop/);
277
+ });
278
+
279
+ it('recovers when the retried call parses', async () => {
280
+ const engine = new Engine({
281
+ provider: (() => {
282
+ let n = 0;
283
+ return {
284
+ name: 'retry',
285
+ async runTurn(): Promise<TurnOutput> {
286
+ n += 1;
287
+ if (n === 1) return { text: 'x', rawContent: 'x', toolCalls: [], toolErrors: [{ code: 'PARSE_ERROR', message: 'bad' }] };
288
+ if (n === 2) return { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] };
289
+ return { text: 'You have 50,000 sats.', rawContent: '', toolCalls: [] };
290
+ },
291
+ };
292
+ })(),
293
+ tools: freshTools(),
294
+ });
295
+ const res = await engine.runAgentic([{ role: 'user', content: 'balance?' }]);
296
+ expect(balanceTool.handler).toHaveBeenCalledTimes(1);
297
+ expect(res.text).toBe('You have 50,000 sats.');
298
+ });
208
299
  });
209
300
 
210
301
  describe('ToolRegistry', () => {
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,31 @@ 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
+ /**
67
+ * Answer a wallet action (create an invoice, get an address, pay, send) with
68
+ * a fixed "no tool" reply, without inference, when no exposed tool can do it.
69
+ * Default true.
70
+ */
71
+ guardMissingTools?: boolean;
72
+ /** End the run with a fixed "Cancelled" reply when the user declines every call in a turn. Default true. */
73
+ endTurnOnDecline?: boolean;
74
+ }
75
+
76
+ export interface ComposedSkill {
77
+ skill: Skill | null;
78
+ system: string;
79
+ allowedTools?: string[];
39
80
  }
40
81
 
41
82
  export interface AgenticOptions {
@@ -52,12 +93,18 @@ export interface AgenticOptions {
52
93
  */
53
94
  onToolResult?: (event: { name: string; arguments: Record<string, unknown>; result: unknown }, turn: number) => void;
54
95
  /** Human-in-the-loop gate for tools flagged requiresConfirmation. */
55
- onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
96
+ onConfirm?: (call: { name: string; arguments: Record<string, unknown>; summary?: string }) => Promise<ConfirmDecision>;
56
97
  /**
57
98
  * Restrict the tools exposed to the model this run (progressive disclosure).
58
99
  * Typically the active skill's tool list — see SkillRegistry.compose().
59
100
  */
60
101
  allowedTools?: string[];
102
+ /**
103
+ * Tool choice for the FIRST model call only — e.g. `'required'` when the
104
+ * request is a wallet action, so the model can't answer in prose with
105
+ * made-up data. Later rounds are left to the model.
106
+ */
107
+ firstTurnToolChoice?: ToolChoice;
61
108
  signal?: AbortSignal;
62
109
  }
63
110
 
@@ -74,18 +121,39 @@ export interface AgenticResult {
74
121
  inference: InferenceMetrics[];
75
122
  }
76
123
 
124
+ const STOPPED_MESSAGE = 'I had to stop after several steps — please try a more specific request.';
125
+
126
+ const TOOL_CALL_FAILED_MESSAGE =
127
+ "I couldn't put together a valid request for that. Please rephrase it with the exact values (asset, amount, recipient).";
128
+
129
+ function toolErrorMessage(errors: ToolCallError[], cutOff: boolean): string {
130
+ if (cutOff) {
131
+ 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.';
132
+ }
133
+ const detail = errors.map((e) => e.message).join('; ');
134
+ 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.`;
135
+ }
136
+
77
137
  export class Engine {
78
138
  private readonly provider: LLMProvider;
79
139
  private readonly registry: ToolRegistry;
80
140
  private readonly defaultSystem?: string;
81
141
  private readonly defaultMaxTurns: number;
82
142
  private readonly compressOpts?: ToolCrushOptions;
143
+ private readonly guardPaymentData: boolean;
144
+ private readonly fixAmounts: boolean;
145
+ private readonly guardMissingTools: boolean;
146
+ private readonly endTurnOnDecline: boolean;
83
147
 
84
148
  constructor(opts: EngineOptions) {
85
149
  this.provider = opts.provider;
86
150
  this.registry = opts.tools;
87
151
  this.defaultSystem = opts.defaultSystem;
88
152
  this.defaultMaxTurns = opts.defaultMaxTurns ?? 5;
153
+ this.guardPaymentData = opts.guardUngroundedPaymentData ?? true;
154
+ this.fixAmounts = opts.fixAmountConversions ?? true;
155
+ this.guardMissingTools = opts.guardMissingTools ?? true;
156
+ this.endTurnOnDecline = opts.endTurnOnDecline ?? true;
89
157
  this.compressOpts = opts.compressToolOutput
90
158
  ? opts.compressToolOutput === true
91
159
  ? {}
@@ -93,6 +161,19 @@ export class Engine {
93
161
  : undefined;
94
162
  }
95
163
 
164
+ /**
165
+ * Select the skill for `query` among those that can act with this engine's
166
+ * tools (skills whose `requires-tools` are missing are skipped), then
167
+ * compose its system prompt. Pass the result to runAgentic:
168
+ *
169
+ * const { system, allowedTools } = await engine.composeSkill(skills, question, base);
170
+ * await engine.runAgentic([{ role: 'system', content: system }, { role: 'user', content: question }], { allowedTools });
171
+ */
172
+ async composeSkill(skills: SkillRegistry, query: string, base: string): Promise<ComposedSkill> {
173
+ const skill = selectAvailableSkill(skills, query, await this.registry.listTools());
174
+ return { skill, ...skills.compose(base, skill) };
175
+ }
176
+
96
177
  async runAgentic(messages: Message[], opts: AgenticOptions = {}): Promise<AgenticResult> {
97
178
  const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
98
179
  const hasSystem = messages.some((m) => m.role === 'system');
@@ -108,10 +189,24 @@ export class Engine {
108
189
  const executed: ToolResult[] = [];
109
190
  let lastRequestId: string | undefined;
110
191
  let finalText = '';
192
+ // Set when finalText is one of the engine's own fixed replies, which the
193
+ // answer guards below must not rewrite.
194
+ let engineReply = false;
111
195
  let turns = 0;
112
196
  const inference: InferenceMetrics[] = [];
197
+ const seen = new Map<string, { result: unknown; count: number }>();
198
+ let toolErrorRetries = 0;
199
+
200
+ const lastUser = [...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
201
+ const action = this.guardMissingTools ? detectWalletAction(lastUser) : null;
202
+ if (action && !hasCapableTool(action, allTools.map((t) => t.name))) {
203
+ const text = noToolReply(action);
204
+ history.push({ role: 'assistant', content: text });
205
+ return { text, turns: 0, toolCalls: [], messages: history, latencyMs: Date.now() - startedAt, inference };
206
+ }
113
207
 
114
- for (let turn = 1; turn <= maxTurns; turn++) {
208
+ // A retry after an unreadable tool call does not count against maxTurns.
209
+ for (let turn = 1; turn <= maxTurns + toolErrorRetries; turn++) {
115
210
  turns = turn;
116
211
  if (opts.signal?.aborted) break;
117
212
 
@@ -119,6 +214,9 @@ export class Engine {
119
214
  messages: history,
120
215
  tools: allTools,
121
216
  system,
217
+ ...(turn === 1 && opts.firstTurnToolChoice && allTools.length
218
+ ? { toolChoice: opts.firstTurnToolChoice }
219
+ : {}),
122
220
  onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
123
221
  signal: opts.signal,
124
222
  });
@@ -126,40 +224,125 @@ export class Engine {
126
224
  lastRequestId = out.requestId;
127
225
  if (out.inference) inference.push(out.inference);
128
226
  if (out.requestId) opts.onStart?.(out.requestId, turn);
129
- finalText = (out.text || '').trim();
227
+ finalText = out.incomplete ? '' : (out.text || '').trim();
228
+
229
+ // The model tried to call a tool but the call didn't parse: tell it what
230
+ // went wrong and let it try again (once) instead of showing the broken
231
+ // frame as the answer.
232
+ if ((!out.toolCalls || out.toolCalls.length === 0) && out.toolErrors?.length) {
233
+ if (toolErrorRetries < 1) {
234
+ toolErrorRetries += 1;
235
+ const cutOff = out.inference?.status === 'truncated';
236
+ history.push({ role: 'assistant', content: out.rawContent || finalText });
237
+ history.push({ role: 'tool', content: JSON.stringify({ error: toolErrorMessage(out.toolErrors, cutOff) }) });
238
+ continue;
239
+ }
240
+ finalText = TOOL_CALL_FAILED_MESSAGE;
241
+ engineReply = true;
242
+ break;
243
+ }
130
244
 
131
245
  // No tool calls ⇒ the model produced its final answer.
132
- if (!out.toolCalls || out.toolCalls.length === 0) break;
246
+ if (!out.toolCalls || out.toolCalls.length === 0) {
247
+ if (!finalText && executed.length) finalText = await this.recoverAnswer(history, system, executed, inference, opts, turn);
248
+ else if (!finalText && out.incomplete) finalText = (out.text || '').trim();
249
+ break;
250
+ }
133
251
 
134
252
  // Anchor the next turn with the raw assistant frame.
135
253
  history.push({ role: 'assistant', content: out.rawContent || finalText });
136
254
 
255
+ let repeatedAgain = false;
256
+ const declinedThisTurn: string[] = [];
137
257
  for (const call of out.toolCalls) {
138
258
  opts.onToolCall?.({ name: call.name, arguments: call.arguments }, turn);
139
259
  const def = await this.registry.getDef(call.name);
260
+ const key = callKey(call.name, call.arguments);
261
+ const previous = seen.get(key);
140
262
 
263
+ let args = call.arguments;
141
264
  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);
265
+ if (previous) {
266
+ previous.count += 1;
267
+ if (previous.count > 2) repeatedAgain = true;
268
+ result = {
269
+ error:
270
+ `You already called ${call.name} with these arguments; the result was: ` +
271
+ `${this.toHistoryContent(previous.result)}. Do not call it again — answer the user now.`,
272
+ };
273
+ } else if (!def) {
274
+ result = { error: `Unknown tool "${call.name}".` };
275
+ } else {
276
+ const check = validateToolArgs(def, call.arguments);
277
+ if (!check.ok) {
278
+ result = {
279
+ error: `Invalid arguments for ${call.name}: ${check.errors.join('; ')}. Fix them or ask the user for the missing values.`,
280
+ };
281
+ } else if (def.requiresConfirmation) {
282
+ args = check.args;
283
+ const summary = confirmReadback({ name: call.name, arguments: args }) ?? undefined;
284
+ const decision = opts.onConfirm
285
+ ? await opts.onConfirm({ name: call.name, arguments: args, ...(summary ? { summary } : {}) })
286
+ : { approved: false, reason: 'no confirmation handler available' };
287
+ if (decision.approved) {
288
+ result = await this.safeExecute(call.name, args);
289
+ } else {
290
+ result = declinedToolResult(call.name, decision.reason);
291
+ declinedThisTurn.push(summary ? summary.replace(/\.?\s*Confirm\?$/, '') : call.name.replace(/_/g, ' '));
292
+ }
148
293
  } else {
149
- result = { declined: true, reason: decision.reason ?? 'user declined' };
294
+ args = check.args;
295
+ result = await this.safeExecute(call.name, args);
150
296
  }
151
- } else {
152
- result = await this.safeExecute(call.name, call.arguments);
153
297
  }
154
298
 
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) });
299
+ if (!previous) {
300
+ // A mutating (confirm-gated) call can change what reads return.
301
+ if (def?.requiresConfirmation) seen.clear();
302
+ seen.set(key, { result, count: 1 });
303
+ }
304
+ executed.push({ name: call.name, arguments: args, result });
305
+ opts.onToolResult?.({ name: call.name, arguments: args, result }, turn);
306
+ history.push({ role: 'tool', content: this.toHistoryContent(this.fixAmounts ? annotateRgbBalances(result) : result) });
158
307
  }
159
308
 
160
- if (turn === maxTurns && !finalText) {
161
- finalText = 'I had to stop after several steps — please try a more specific request.';
309
+ if (this.endTurnOnDecline && declinedThisTurn.length && declinedThisTurn.length === out.toolCalls.length) {
310
+ finalText = `Cancelled — you declined: ${declinedThisTurn.join('; ')}. Nothing was sent or changed.`;
311
+ engineReply = true;
312
+ break;
162
313
  }
314
+
315
+ if (repeatedAgain) {
316
+ const forced = await this.provider.runTurn({
317
+ messages: history,
318
+ tools: [],
319
+ system,
320
+ onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
321
+ signal: opts.signal,
322
+ });
323
+ if (forced.inference) inference.push(forced.inference);
324
+ finalText = (forced.text || '').trim() || 'I could not get a different result from the wallet — please try a more specific request.';
325
+ break;
326
+ }
327
+
328
+ }
329
+
330
+ // Never return an empty answer (e.g. the last turn ran out of tokens).
331
+ if (!finalText && !opts.signal?.aborted) {
332
+ finalText = STOPPED_MESSAGE;
333
+ engineReply = true;
334
+ }
335
+
336
+ if (this.fixAmounts && finalText && !engineReply) {
337
+ finalText = fixRgbBalanceUnits(fixSatsBtcConversions(finalText), executed.map((e) => e.result));
338
+ }
339
+
340
+ if (this.guardPaymentData && finalText && !engineReply) {
341
+ const ungrounded = findUngroundedPaymentData(finalText, [
342
+ ...messages.map((m) => m.content),
343
+ ...executed.map((e) => e.result),
344
+ ]);
345
+ if (ungrounded.length) finalText = ungroundedReply(ungrounded);
163
346
  }
164
347
 
165
348
  // Append the final answer so the returned conversation is complete (the
@@ -177,6 +360,38 @@ export class Engine {
177
360
  };
178
361
  }
179
362
 
363
+ /**
364
+ * The model ran tools but produced no visible answer (e.g. reasoning used the
365
+ * whole output budget). Ask once more without tools; if that is empty too,
366
+ * show the last tool result instead of an empty reply.
367
+ */
368
+ private async recoverAnswer(
369
+ history: Message[],
370
+ system: string | undefined,
371
+ executed: ToolResult[],
372
+ inference: InferenceMetrics[],
373
+ opts: AgenticOptions,
374
+ turn: number,
375
+ ): Promise<string> {
376
+ if (opts.signal?.aborted) return '';
377
+ const retry = await this.provider.runTurn({
378
+ messages: [
379
+ ...history,
380
+ { role: 'user', content: 'Answer my question now from the tool results above, in a few short sentences.' },
381
+ ],
382
+ tools: [],
383
+ system,
384
+ onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
385
+ signal: opts.signal,
386
+ });
387
+ if (retry.inference) inference.push(retry.inference);
388
+ const text = retry.incomplete ? '' : (retry.text || '').trim();
389
+ if (text) return text;
390
+ const last = executed[executed.length - 1]!;
391
+ const body = compressToolResult(last.result, this.compressOpts ?? {}).content;
392
+ 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}`;
393
+ }
394
+
180
395
  async cancel(requestId: string): Promise<void> {
181
396
  await this.provider.cancel?.(requestId);
182
397
  }
@@ -335,11 +335,13 @@ describe('desktop mind — skill scoping (real skills)', () => {
335
335
  expect(node.tools?.every((tool) => tool.startsWith('rln_'))).toBe(true);
336
336
  });
337
337
 
338
- it('kaleido-trading drops the phantom kaleidoswap_get_nodeinfo / get_order_history names', () => {
338
+ it('kaleido-trading drops the phantom kaleidoswap_get_nodeinfo / removed order-flow names', () => {
339
339
  const trading = SKILLS.find((s) => s.name === 'kaleido-trading')!;
340
340
  expect(trading.tools).not.toContain('kaleidoswap_get_nodeinfo');
341
341
  expect(trading.tools).not.toContain('kaleidoswap_get_order_history');
342
- expect(trading.tools).toEqual(expect.arrayContaining(['kaleidoswap_get_quote', 'kaleidoswap_place_order']));
342
+ expect(trading.tools).not.toContain('kaleidoswap_place_order');
343
+ expect(trading.tools).not.toContain('kaleidoswap_get_order_status');
344
+ expect(trading.tools).toEqual(expect.arrayContaining(['kaleidoswap_get_quote', 'kaleidoswap_atomic_init']));
343
345
  expect(trading.tools).not.toEqual(
344
346
  expect.arrayContaining([
345
347
  'kaleidoswap_get_spreads',
package/src/funnel.ts CHANGED
@@ -30,6 +30,8 @@ import { receiveRecipe } from './recipe/receive.js';
30
30
  import { assetSendRecipe } from './recipe/asset-send.js';
31
31
  import type { Recipe } from './recipe/types.js';
32
32
  import { SkillRegistry } from './skills/registry.js';
33
+ import { selectAvailableSkill } from './skills/select.js';
34
+ import { detectWalletAction, hasCapableTool, noToolReply, wantsToolCall } from './guards.js';
33
35
  import type { Skill } from './skills/types.js';
34
36
  import type { LLMProvider } from './providers/types.js';
35
37
  import type { InferenceMetrics } from './providers/types.js';
@@ -320,7 +322,9 @@ export class Funnel {
320
322
 
321
323
  // ── T1: skill-scoped agentic loop ──
322
324
  const skills = this.skillsFor(settings.disabledSkills);
323
- const skill = skills.select(text);
325
+ const liveTools = (await this.registry.listTools()).map((t) => t.name);
326
+ const present = new Set(liveTools);
327
+ const skill = selectAvailableSkill(skills, text, present);
324
328
  let base = settings.persona ? `${this.system}\n\n## Your persona\n${settings.persona}` : this.system;
325
329
 
326
330
  // Auto-inject relevant knowledge chunks (best-effort — corpus is grounding
@@ -357,7 +361,6 @@ export class Funnel {
357
361
  // leaves the model TOOL-LESS — it then narrates "the tool isn't available"
358
362
  // instead of acting. If NONE of the scoped tools resolve against the live
359
363
  // 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
364
  if (!scoped.some((n) => present.has(n))) {
362
365
  this.log(
363
366
  `tier=agentic: skill '${skill?.name ?? '?'}' tools resolved to 0 live tools — using full tool surface`,
@@ -367,8 +370,23 @@ export class Funnel {
367
370
  } else if (disabledAmbient.length) {
368
371
  // No skill matched but a toggle is off: expose everything except the
369
372
  // 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));
373
+ scoped = liveTools.filter((n) => !disabledAmbient.includes(n));
374
+ }
375
+
376
+ // A wallet action with no tool able to perform it: answer deterministically
377
+ // instead of letting the model improvise an invoice/address/payment.
378
+ const action = detectWalletAction(text);
379
+ if (action) {
380
+ const inScope = (scoped ?? liveTools).filter((n) => present.has(n));
381
+ if (!hasCapableTool(action, inScope)) {
382
+ if (scoped && hasCapableTool(action, liveTools)) {
383
+ this.log(`tier=agentic: skill '${skill?.name ?? '?'}' has no tool for ${action.id} — using full tool surface`);
384
+ scoped = liveTools.filter((n) => !disabledAmbient.includes(n));
385
+ } else {
386
+ this.log(`tier=agentic: no tool for ${action.id} — refusing`);
387
+ return { text: noToolReply(action), tier: 'agentic', route: 'no-tool', toolCalls: [], turns: 0, inference: [] };
388
+ }
389
+ }
372
390
  }
373
391
 
374
392
  // Trim history so the prompt (system + skill + tools + history) stays
@@ -396,6 +414,7 @@ export class Funnel {
396
414
  },
397
415
  onToolResult: cbs.onToolResult,
398
416
  onConfirm: cbs.onConfirm,
417
+ ...(wantsToolCall(text) ? { firstTurnToolChoice: 'required' as const } : {}),
399
418
  signal: cbs.signal,
400
419
  });
401
420
  return {