@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.
- package/README.md +96 -8
- package/dist/context/compress.d.ts.map +1 -1
- package/dist/context/compress.js +1 -0
- package/dist/context/compress.js.map +1 -1
- package/dist/context/rgb-units.d.ts +18 -0
- package/dist/context/rgb-units.d.ts.map +1 -0
- package/dist/context/rgb-units.js +86 -0
- package/dist/context/rgb-units.js.map +1 -0
- package/dist/engine.d.ts +59 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +196 -17
- package/dist/engine.js.map +1 -1
- package/dist/fastpath/fastpath.d.ts +6 -1
- package/dist/fastpath/fastpath.d.ts.map +1 -1
- package/dist/fastpath/fastpath.js +16 -2
- package/dist/fastpath/fastpath.js.map +1 -1
- package/dist/fastpath/render.d.ts +2 -0
- package/dist/fastpath/render.d.ts.map +1 -0
- package/dist/fastpath/render.js +58 -0
- package/dist/fastpath/render.js.map +1 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +34 -18
- package/dist/funnel.js.map +1 -1
- package/dist/guards.d.ts +63 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +284 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -4
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +3 -17
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/providers/openai.d.ts +64 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +233 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/qvac/config.d.ts +1 -1
- package/dist/qvac/config.js +1 -1
- package/dist/qvac/index.d.ts +1 -0
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js +1 -0
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/models.d.ts +31 -0
- package/dist/qvac/models.d.ts.map +1 -0
- package/dist/qvac/models.js +80 -0
- package/dist/qvac/models.js.map +1 -0
- package/dist/qvac/parse.d.ts +11 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +29 -7
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/provider.d.ts +14 -4
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +33 -8
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +3 -5
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/qvac/tools.d.ts +5 -0
- package/dist/qvac/tools.d.ts.map +1 -1
- package/dist/qvac/tools.js +10 -0
- package/dist/qvac/tools.js.map +1 -1
- package/dist/recipe/issue-asset.d.ts.map +1 -1
- package/dist/recipe/issue-asset.js +42 -16
- package/dist/recipe/issue-asset.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +1 -0
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/submarine-pay.d.ts +17 -0
- package/dist/recipe/submarine-pay.d.ts.map +1 -0
- package/dist/recipe/submarine-pay.js +79 -0
- package/dist/recipe/submarine-pay.js.map +1 -0
- package/dist/skills/registry.js +1 -1
- package/dist/skills/select.d.ts +20 -0
- package/dist/skills/select.d.ts.map +1 -0
- package/dist/skills/select.js +32 -0
- package/dist/skills/select.js.map +1 -0
- package/dist/submarine/contract.d.ts +42 -0
- package/dist/submarine/contract.d.ts.map +1 -0
- package/dist/submarine/contract.js +73 -0
- package/dist/submarine/contract.js.map +1 -0
- package/dist/testing/mock-wallet.d.ts +24 -0
- package/dist/testing/mock-wallet.d.ts.map +1 -1
- package/dist/testing/mock-wallet.js +75 -6
- package/dist/testing/mock-wallet.js.map +1 -1
- package/dist/wallet/confirm.d.ts.map +1 -1
- package/dist/wallet/confirm.js +27 -9
- package/dist/wallet/confirm.js.map +1 -1
- package/package.json +6 -1
- package/skills/README.md +1 -0
- package/skills/kaleido-trading/SKILL.md +22 -28
- package/skills/kaleido-trading/references/api.md +3 -3
- package/skills/rgb-lightning-node/SKILL.md +32 -12
- package/skills/spark-wallet/SKILL.md +1 -0
- package/skills/submarine-swaps/SKILL.md +48 -0
- package/src/context/compress.ts +1 -0
- package/src/context/rgb-units.test.ts +42 -0
- package/src/context/rgb-units.ts +89 -0
- package/src/engine.test.ts +133 -3
- package/src/engine.ts +254 -19
- package/src/fastpath/fastpath.ts +22 -2
- package/src/fastpath/render.test.ts +39 -0
- package/src/fastpath/render.ts +56 -0
- package/src/funnel.mind.test.ts +15 -17
- package/src/funnel.ts +34 -19
- package/src/guards.test.ts +399 -0
- package/src/guards.ts +299 -0
- package/src/index.ts +37 -1
- package/src/kaleidoswap/contract.test.ts +8 -16
- package/src/kaleidoswap/contract.ts +4 -32
- package/src/providers/openai.test.ts +127 -0
- package/src/providers/openai.ts +282 -0
- package/src/providers/types.ts +35 -0
- package/src/qvac/config.ts +1 -1
- package/src/qvac/index.ts +10 -0
- package/src/qvac/models.ts +98 -0
- package/src/qvac/parse.test.ts +7 -0
- package/src/qvac/parse.ts +33 -1
- package/src/qvac/provider.test.ts +54 -1
- package/src/qvac/provider.ts +49 -13
- package/src/qvac/stream.ts +3 -5
- package/src/qvac/tools.ts +10 -0
- package/src/recipe/issue-asset.test.ts +39 -0
- package/src/recipe/issue-asset.ts +41 -13
- package/src/recipe/runner.ts +1 -0
- package/src/recipe/submarine-pay.test.ts +87 -0
- package/src/recipe/submarine-pay.ts +78 -0
- package/src/skills/registry.ts +1 -1
- package/src/skills/select.ts +41 -0
- package/src/submarine/contract.ts +112 -0
- package/src/testing/mock-wallet.ts +73 -6
- 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
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
161
|
-
|
|
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
|
}
|
package/src/fastpath/fastpath.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/funnel.mind.test.ts
CHANGED
|
@@ -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('
|
|
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('
|
|
160
|
-
expect(calls.map((c) => c.name)).
|
|
161
|
-
|
|
162
|
-
expect((
|
|
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 /
|
|
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).
|
|
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
|
-
|
|
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?");
|