@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.
- package/README.md +345 -0
- package/dist/autonomy/risk.js.map +1 -1
- package/dist/autonomy/run-state.d.ts.map +1 -1
- package/dist/autonomy/run-state.js.map +1 -1
- package/dist/autonomy/task-store.d.ts.map +1 -1
- package/dist/autonomy/task-store.js.map +1 -1
- package/dist/bitrefill/contract.js.map +1 -1
- package/dist/context/budget.js.map +1 -1
- package/dist/context/builder.d.ts.map +1 -1
- package/dist/context/builder.js.map +1 -1
- 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 +53 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +179 -17
- package/dist/engine.js.map +1 -1
- package/dist/evidence.d.ts +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js.map +1 -1
- package/dist/fastpath/fastpath.d.ts.map +1 -1
- package/dist/fastpath/fastpath.js.map +1 -1
- package/dist/flashnet/contract.js.map +1 -1
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +23 -4
- 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 +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -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/knowledge/btc-map.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.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 +20 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/qvac/assistant.js.map +1 -1
- package/dist/qvac/config.d.ts +7 -7
- package/dist/qvac/config.d.ts.map +1 -1
- package/dist/qvac/config.js +1 -1
- package/dist/qvac/delegate.d.ts +2 -0
- package/dist/qvac/delegate.d.ts.map +1 -1
- package/dist/qvac/delegate.js +2 -0
- package/dist/qvac/delegate.js.map +1 -1
- package/dist/qvac/index.d.ts +2 -0
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js +2 -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 +16 -2
- 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 +5 -4
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +30 -21
- 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 +29 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/qvac/tools.d.ts +37 -0
- package/dist/qvac/tools.d.ts.map +1 -0
- package/dist/qvac/tools.js +94 -0
- package/dist/qvac/tools.js.map +1 -0
- package/dist/qvac/voice.js.map +1 -1
- package/dist/rag/retriever.d.ts.map +1 -1
- package/dist/rag/tool.js.map +1 -1
- package/dist/rag/vector-store.d.ts.map +1 -1
- package/dist/rag/vector-store.js.map +1 -1
- package/dist/recipe/issue-asset.d.ts +16 -0
- package/dist/recipe/issue-asset.d.ts.map +1 -0
- package/dist/recipe/issue-asset.js +120 -0
- package/dist/recipe/issue-asset.js.map +1 -0
- 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/loader.js.map +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +1 -1
- package/dist/skills/registry.js.map +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/index.d.ts +14 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-wallet.d.ts +103 -0
- package/dist/testing/mock-wallet.d.ts.map +1 -0
- package/dist/testing/mock-wallet.js +249 -0
- package/dist/testing/mock-wallet.js.map +1 -0
- package/dist/testing/scripted-provider.d.ts +17 -0
- package/dist/testing/scripted-provider.d.ts.map +1 -0
- package/dist/testing/scripted-provider.js +26 -0
- package/dist/testing/scripted-provider.js.map +1 -0
- package/dist/tools/in-process.d.ts.map +1 -1
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/wallet/confirm.d.ts.map +1 -1
- package/dist/wallet/confirm.js +43 -5
- package/dist/wallet/confirm.js.map +1 -1
- package/dist/wallet/contract.d.ts.map +1 -1
- package/dist/wallet/contract.js +36 -0
- package/dist/wallet/contract.js.map +1 -1
- package/package.json +15 -5
- package/skills/README.md +2 -1
- package/skills/kaleido-node/SKILL.md +63 -0
- package/skills/kaleido-trading/SKILL.md +34 -27
- package/skills/kaleido-trading/references/api.md +61 -0
- package/skills/kaleido-trading/references/assets.md +57 -0
- package/skills/kaleido-trading/references/atomic.md +93 -0
- package/skills/paid-data/SKILL.md +59 -6
- package/skills/rgb-lightning-node/SKILL.md +142 -17
- 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 +94 -3
- package/src/engine.ts +234 -19
- package/src/funnel.mind.test.ts +4 -2
- package/src/funnel.ts +23 -4
- package/src/guards.test.ts +399 -0
- package/src/guards.ts +299 -0
- package/src/index.ts +40 -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 +22 -0
- package/src/qvac/config.ts +1 -1
- package/src/qvac/delegate.ts +2 -0
- package/src/qvac/index.ts +12 -0
- package/src/qvac/models.ts +98 -0
- package/src/qvac/parse.test.ts +7 -0
- package/src/qvac/parse.ts +38 -3
- package/src/qvac/provider.test.ts +72 -1
- package/src/qvac/provider.ts +40 -27
- package/src/qvac/stream.test.ts +28 -0
- package/src/qvac/stream.ts +33 -6
- package/src/qvac/tools.test.ts +86 -0
- package/src/qvac/tools.ts +116 -0
- package/src/recipe/issue-asset.test.ts +131 -0
- package/src/recipe/issue-asset.ts +123 -0
- 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/index.ts +14 -0
- package/src/testing/mock-wallet.ts +278 -0
- package/src/testing/scripted-provider.ts +37 -0
- package/src/wallet/confirm.test.ts +16 -0
- package/src/wallet/confirm.ts +43 -5
- package/src/wallet/contract.test.ts +38 -0
- package/src/wallet/contract.ts +36 -0
package/src/engine.test.ts
CHANGED
|
@@ -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({
|
|
115
|
-
expect(res.text).toBe('
|
|
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: {} }] },
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
161
|
-
finalText =
|
|
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
|
}
|
package/src/funnel.mind.test.ts
CHANGED
|
@@ -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 /
|
|
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).
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
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 {
|