@kaleidorg/mind 0.8.0 → 0.9.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 +7 -5
- package/dist/bitrefill/index.d.ts +4 -0
- package/dist/bitrefill/index.d.ts.map +1 -0
- package/dist/bitrefill/index.js +3 -0
- package/dist/bitrefill/index.js.map +1 -0
- package/dist/capabilities.d.ts +3 -3
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +4 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/engine/answer.d.ts +37 -0
- package/dist/engine/answer.d.ts.map +1 -0
- package/dist/engine/answer.js +35 -0
- package/dist/engine/answer.js.map +1 -0
- package/dist/engine.d.ts +15 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +171 -170
- package/dist/engine.js.map +1 -1
- package/dist/evidence.d.ts +1 -1
- package/dist/evidence.d.ts.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/flashnet/index.d.ts +5 -0
- package/dist/flashnet/index.d.ts.map +1 -0
- package/dist/flashnet/index.js +4 -0
- package/dist/flashnet/index.js.map +1 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +11 -14
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +6 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -27
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/index.d.ts +8 -0
- package/dist/kaleidoswap/index.d.ts.map +1 -0
- package/dist/kaleidoswap/index.js +7 -0
- package/dist/kaleidoswap/index.js.map +1 -0
- package/dist/knowledge/index.d.ts +9 -0
- package/dist/knowledge/index.d.ts.map +1 -0
- package/dist/knowledge/index.js +6 -0
- package/dist/knowledge/index.js.map +1 -0
- package/dist/lsps1/index.d.ts +4 -0
- package/dist/lsps1/index.d.ts.map +1 -0
- package/dist/lsps1/index.js +3 -0
- package/dist/lsps1/index.js.map +1 -0
- package/dist/providers/types.d.ts +16 -3
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +3 -3
- package/dist/qvac/index.d.ts +0 -1
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js +0 -1
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/provider.d.ts +15 -6
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +14 -16
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +4 -3
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/qvac/voice.d.ts +1 -1
- package/dist/submarine/index.d.ts +5 -0
- package/dist/submarine/index.d.ts.map +1 -0
- package/dist/submarine/index.js +4 -0
- package/dist/submarine/index.js.map +1 -0
- package/dist/tools/in-process.d.ts +2 -2
- package/dist/tools/in-process.js +2 -2
- package/package.json +32 -2
- package/src/bitrefill/index.ts +13 -0
- package/src/capabilities.ts +7 -7
- package/src/context/context.test.ts +2 -2
- package/src/engine/answer.ts +66 -0
- package/src/engine.test.ts +39 -0
- package/src/engine.ts +199 -188
- package/src/evidence.ts +1 -1
- package/src/fastpath/fastpath.ts +22 -2
- package/src/fastpath/render.test.ts +39 -0
- package/src/fastpath/render.ts +56 -0
- package/src/flashnet/index.ts +14 -0
- package/src/funnel.mind.test.ts +11 -15
- package/src/funnel.ts +11 -15
- package/src/index.ts +10 -107
- package/src/kaleidoswap/index.ts +19 -0
- package/src/knowledge/index.ts +14 -0
- package/src/lsps1/index.ts +13 -0
- package/src/providers/types.ts +16 -3
- package/src/qvac/index.ts +0 -8
- package/src/qvac/provider.test.ts +20 -17
- package/src/qvac/provider.ts +28 -26
- package/src/qvac/stream.ts +4 -3
- package/src/qvac/voice.ts +1 -1
- package/src/submarine/index.ts +17 -0
- package/src/tools/in-process.ts +2 -2
- package/dist/qvac/delegate.d.ts +0 -50
- package/dist/qvac/delegate.d.ts.map +0 -1
- package/dist/qvac/delegate.js +0 -53
- package/dist/qvac/delegate.js.map +0 -1
- package/src/qvac/delegate.test.ts +0 -68
- package/src/qvac/delegate.ts +0 -73
package/src/engine.ts
CHANGED
|
@@ -11,27 +11,26 @@
|
|
|
11
11
|
* `{role:'tool'}` results into history each round, loop until the model stops
|
|
12
12
|
* calling tools. Money tools pause for an `onConfirm` gate; their handlers run
|
|
13
13
|
* wherever the ToolSource lives (on the phone for the wallet), even when
|
|
14
|
-
* inference
|
|
14
|
+
* inference runs on a remote server.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { ConfirmDecision, Message, ToolResult } from './types.js';
|
|
18
|
-
import type { LLMProvider } from './providers/types.js';
|
|
19
|
-
import type { InferenceMetrics, ToolCallError, ToolChoice } from './providers/types.js';
|
|
17
|
+
import type { ConfirmDecision, Message, ToolCall, ToolDef, ToolResult } from './types.js';
|
|
18
|
+
import type { InferenceMetrics, LLMProvider, ToolChoice } from './providers/types.js';
|
|
20
19
|
import type { ToolRegistry } from './tools/registry.js';
|
|
21
20
|
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';
|
|
21
|
+
import { callKey, declinedToolResult, detectWalletAction, hasCapableTool, noToolReply, validateToolArgs } from './guards.js';
|
|
33
22
|
import { confirmReadback } from './wallet/confirm.js';
|
|
34
|
-
import { annotateRgbBalances
|
|
23
|
+
import { annotateRgbBalances } from './context/rgb-units.js';
|
|
24
|
+
import {
|
|
25
|
+
REPEATED_CALL_REPLY,
|
|
26
|
+
TOOL_CALL_FAILED_REPLY,
|
|
27
|
+
cancelledReply,
|
|
28
|
+
engine,
|
|
29
|
+
finalizeAnswer,
|
|
30
|
+
model,
|
|
31
|
+
toolErrorFeedback,
|
|
32
|
+
type Answer,
|
|
33
|
+
} from './engine/answer.js';
|
|
35
34
|
import type { SkillRegistry } from './skills/registry.js';
|
|
36
35
|
import type { Skill } from './skills/types.js';
|
|
37
36
|
import { selectAvailableSkill } from './skills/select.js';
|
|
@@ -63,6 +62,8 @@ export interface EngineOptions {
|
|
|
63
62
|
* asset balance the answer calls sats. Default true.
|
|
64
63
|
*/
|
|
65
64
|
fixAmountConversions?: boolean;
|
|
65
|
+
/** Let the model reason on a forced first tool call. Default false (thinking off). */
|
|
66
|
+
thinkOnForcedCalls?: boolean;
|
|
66
67
|
/**
|
|
67
68
|
* Answer a wallet action (create an invoice, get an address, pay, send) with
|
|
68
69
|
* a fixed "no tool" reply, without inference, when no exposed tool can do it.
|
|
@@ -105,6 +106,8 @@ export interface AgenticOptions {
|
|
|
105
106
|
* made-up data. Later rounds are left to the model.
|
|
106
107
|
*/
|
|
107
108
|
firstTurnToolChoice?: ToolChoice;
|
|
109
|
+
/** Session key passed to every model call of this run. Default: a fresh key per run. */
|
|
110
|
+
sessionKey?: string;
|
|
108
111
|
signal?: AbortSignal;
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -121,17 +124,16 @@ export interface AgenticResult {
|
|
|
121
124
|
inference: InferenceMetrics[];
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
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.`;
|
|
127
|
+
/** State of one agentic run. */
|
|
128
|
+
interface RunState {
|
|
129
|
+
history: Message[];
|
|
130
|
+
system?: string;
|
|
131
|
+
tools: ToolDef[];
|
|
132
|
+
executed: ToolResult[];
|
|
133
|
+
inference: InferenceMetrics[];
|
|
134
|
+
/** Calls made this run, by name + arguments, with their first result. */
|
|
135
|
+
seen: Map<string, { result: unknown; count: number }>;
|
|
136
|
+
lastRequestId?: string;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
export class Engine {
|
|
@@ -142,6 +144,7 @@ export class Engine {
|
|
|
142
144
|
private readonly compressOpts?: ToolCrushOptions;
|
|
143
145
|
private readonly guardPaymentData: boolean;
|
|
144
146
|
private readonly fixAmounts: boolean;
|
|
147
|
+
private readonly thinkOnForcedCalls: boolean;
|
|
145
148
|
private readonly guardMissingTools: boolean;
|
|
146
149
|
private readonly endTurnOnDecline: boolean;
|
|
147
150
|
|
|
@@ -152,6 +155,7 @@ export class Engine {
|
|
|
152
155
|
this.defaultMaxTurns = opts.defaultMaxTurns ?? 5;
|
|
153
156
|
this.guardPaymentData = opts.guardUngroundedPaymentData ?? true;
|
|
154
157
|
this.fixAmounts = opts.fixAmountConversions ?? true;
|
|
158
|
+
this.thinkOnForcedCalls = opts.thinkOnForcedCalls ?? false;
|
|
155
159
|
this.guardMissingTools = opts.guardMissingTools ?? true;
|
|
156
160
|
this.endTurnOnDecline = opts.endTurnOnDecline ?? true;
|
|
157
161
|
this.compressOpts = opts.compressToolOutput
|
|
@@ -175,221 +179,228 @@ export class Engine {
|
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
async runAgentic(messages: Message[], opts: AgenticOptions = {}): Promise<AgenticResult> {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
const sessionKey = opts.sessionKey ?? `mind-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
183
|
+
try {
|
|
184
|
+
return await this.runAgenticSession(messages, { ...opts, sessionKey });
|
|
185
|
+
} finally {
|
|
186
|
+
if (!opts.sessionKey) await this.provider.endSession?.(sessionKey).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
181
189
|
|
|
190
|
+
private async runAgenticSession(messages: Message[], opts: AgenticOptions): Promise<AgenticResult> {
|
|
182
191
|
const startedAt = Date.now();
|
|
183
|
-
const
|
|
192
|
+
const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
|
|
184
193
|
const registryTools = await this.registry.listTools();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let engineReply = false;
|
|
195
|
-
let turns = 0;
|
|
196
|
-
const inference: InferenceMetrics[] = [];
|
|
197
|
-
const seen = new Map<string, { result: unknown; count: number }>();
|
|
198
|
-
let toolErrorRetries = 0;
|
|
194
|
+
const state: RunState = {
|
|
195
|
+
history: [...messages],
|
|
196
|
+
system: messages.some((m) => m.role === 'system') ? undefined : this.defaultSystem,
|
|
197
|
+
// Progressive disclosure: expose only the active skill's tools when set.
|
|
198
|
+
tools: opts.allowedTools ? registryTools.filter((t) => opts.allowedTools!.includes(t.name)) : registryTools,
|
|
199
|
+
executed: [],
|
|
200
|
+
inference: [],
|
|
201
|
+
seen: new Map(),
|
|
202
|
+
};
|
|
199
203
|
|
|
200
204
|
const lastUser = [...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
|
|
201
205
|
const action = this.guardMissingTools ? detectWalletAction(lastUser) : null;
|
|
202
|
-
if (action && !hasCapableTool(action,
|
|
206
|
+
if (action && !hasCapableTool(action, state.tools.map((t) => t.name))) {
|
|
203
207
|
const text = noToolReply(action);
|
|
204
|
-
history.push({ role: 'assistant', content: text });
|
|
205
|
-
return { text, turns: 0, toolCalls: [], messages: history, latencyMs: Date.now() - startedAt, inference };
|
|
208
|
+
state.history.push({ role: 'assistant', content: text });
|
|
209
|
+
return { text, turns: 0, toolCalls: [], messages: state.history, latencyMs: Date.now() - startedAt, inference: state.inference };
|
|
206
210
|
}
|
|
207
211
|
|
|
212
|
+
let answer: Answer = model('');
|
|
213
|
+
let turns = 0;
|
|
214
|
+
let toolErrorRetries = 0;
|
|
208
215
|
// A retry after an unreadable tool call does not count against maxTurns.
|
|
209
216
|
for (let turn = 1; turn <= maxTurns + toolErrorRetries; turn++) {
|
|
210
217
|
turns = turn;
|
|
211
218
|
if (opts.signal?.aborted) break;
|
|
212
219
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
: {}),
|
|
220
|
-
onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
|
|
221
|
-
signal: opts.signal,
|
|
220
|
+
// A forced first call only picks the tool and its arguments; reasoning
|
|
221
|
+
// there costs most of the turn's time on small models.
|
|
222
|
+
const forced = turn === 1 && opts.firstTurnToolChoice && state.tools.length;
|
|
223
|
+
const out = await this.callModel(state, opts, turn, {
|
|
224
|
+
tools: state.tools,
|
|
225
|
+
...(forced ? { toolChoice: opts.firstTurnToolChoice, ...(this.thinkOnForcedCalls ? {} : { thinking: 'off' as const }) } : {}),
|
|
222
226
|
});
|
|
223
|
-
|
|
224
|
-
lastRequestId = out.requestId;
|
|
225
|
-
if (out.inference) inference.push(out.inference);
|
|
226
227
|
if (out.requestId) opts.onStart?.(out.requestId, turn);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
toolErrorRetries
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
228
|
+
answer = model(out.incomplete ? '' : (out.text || '').trim());
|
|
229
|
+
|
|
230
|
+
if (!out.toolCalls?.length) {
|
|
231
|
+
// The model tried to call a tool but the call didn't parse: tell it
|
|
232
|
+
// what went wrong and let it try once more instead of showing the
|
|
233
|
+
// broken frame as the answer.
|
|
234
|
+
if (out.toolErrors?.length) {
|
|
235
|
+
if (toolErrorRetries < 1) {
|
|
236
|
+
toolErrorRetries += 1;
|
|
237
|
+
state.history.push({ role: 'assistant', content: out.rawContent || answer.text });
|
|
238
|
+
state.history.push({
|
|
239
|
+
role: 'tool',
|
|
240
|
+
content: JSON.stringify({ error: toolErrorFeedback(out.toolErrors, out.inference?.status === 'truncated') }),
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
answer = engine(TOOL_CALL_FAILED_REPLY);
|
|
245
|
+
break;
|
|
239
246
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// No tool calls ⇒ the model produced its final answer.
|
|
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();
|
|
247
|
+
// No tool calls ⇒ the model produced its final answer.
|
|
248
|
+
if (!answer.text && state.executed.length) answer = await this.recoverAnswer(state, opts, turn);
|
|
249
|
+
else if (!answer.text && out.incomplete) answer = model((out.text || '').trim());
|
|
249
250
|
break;
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
// Anchor the next turn with the raw assistant frame.
|
|
253
|
-
history.push({ role: 'assistant', content: out.rawContent ||
|
|
254
|
+
state.history.push({ role: 'assistant', content: out.rawContent || answer.text });
|
|
254
255
|
|
|
255
256
|
let repeatedAgain = false;
|
|
256
|
-
const
|
|
257
|
+
const declined: string[] = [];
|
|
257
258
|
for (const call of out.toolCalls) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const previous = seen.get(key);
|
|
262
|
-
|
|
263
|
-
let args = call.arguments;
|
|
264
|
-
let result: unknown;
|
|
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
|
-
}
|
|
293
|
-
} else {
|
|
294
|
-
args = check.args;
|
|
295
|
-
result = await this.safeExecute(call.name, args);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
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) });
|
|
259
|
+
const step = await this.executeCall(state, call, opts, turn);
|
|
260
|
+
repeatedAgain ||= step.repeatedAgain;
|
|
261
|
+
if (step.declined) declined.push(step.declined);
|
|
307
262
|
}
|
|
308
263
|
|
|
309
|
-
if (this.endTurnOnDecline &&
|
|
310
|
-
|
|
311
|
-
engineReply = true;
|
|
264
|
+
if (this.endTurnOnDecline && declined.length && declined.length === out.toolCalls.length) {
|
|
265
|
+
answer = engine(cancelledReply(declined));
|
|
312
266
|
break;
|
|
313
267
|
}
|
|
314
268
|
|
|
315
269
|
if (repeatedAgain) {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
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.';
|
|
270
|
+
const last = await this.callModel(state, opts, turn, { tools: [] });
|
|
271
|
+
const text = (last.text || '').trim();
|
|
272
|
+
answer = text ? model(text) : engine(REPEATED_CALL_REPLY);
|
|
325
273
|
break;
|
|
326
274
|
}
|
|
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);
|
|
346
275
|
}
|
|
347
276
|
|
|
277
|
+
const text = finalizeAnswer(answer, {
|
|
278
|
+
fixAmounts: this.fixAmounts,
|
|
279
|
+
guardPaymentData: this.guardPaymentData,
|
|
280
|
+
sources: [...messages.map((m) => m.content), ...state.executed.map((e) => e.result)],
|
|
281
|
+
toolResults: state.executed.map((e) => e.result),
|
|
282
|
+
aborted: !!opts.signal?.aborted,
|
|
283
|
+
});
|
|
348
284
|
// Append the final answer so the returned conversation is complete (the
|
|
349
285
|
// loop breaks before pushing the no-tool-call turn).
|
|
350
|
-
if (
|
|
286
|
+
if (text) state.history.push({ role: 'assistant', content: text });
|
|
351
287
|
|
|
352
288
|
return {
|
|
353
|
-
text
|
|
289
|
+
text,
|
|
354
290
|
turns,
|
|
355
|
-
toolCalls: executed,
|
|
356
|
-
requestId: lastRequestId,
|
|
357
|
-
messages: history,
|
|
291
|
+
toolCalls: state.executed,
|
|
292
|
+
requestId: state.lastRequestId,
|
|
293
|
+
messages: state.history,
|
|
358
294
|
latencyMs: Date.now() - startedAt,
|
|
359
|
-
inference,
|
|
295
|
+
inference: state.inference,
|
|
360
296
|
};
|
|
361
297
|
}
|
|
362
298
|
|
|
299
|
+
/** One model call within the run's session; records its receipt. */
|
|
300
|
+
private async callModel(
|
|
301
|
+
state: RunState,
|
|
302
|
+
opts: AgenticOptions,
|
|
303
|
+
turn: number,
|
|
304
|
+
extra: { tools: ToolDef[]; messages?: Message[]; toolChoice?: ToolChoice; thinking?: 'off' },
|
|
305
|
+
) {
|
|
306
|
+
const out = await this.provider.runTurn({
|
|
307
|
+
messages: extra.messages ?? state.history,
|
|
308
|
+
system: state.system,
|
|
309
|
+
sessionKey: opts.sessionKey,
|
|
310
|
+
...extra,
|
|
311
|
+
onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
|
|
312
|
+
signal: opts.signal,
|
|
313
|
+
});
|
|
314
|
+
if (out.inference) state.inference.push(out.inference);
|
|
315
|
+
if (out.requestId) state.lastRequestId = out.requestId;
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Validate, confirm (for spends) and execute one tool call, and add its
|
|
321
|
+
* result to the history. Returns the readback when the user declined it.
|
|
322
|
+
*/
|
|
323
|
+
private async executeCall(
|
|
324
|
+
state: RunState,
|
|
325
|
+
call: ToolCall,
|
|
326
|
+
opts: AgenticOptions,
|
|
327
|
+
turn: number,
|
|
328
|
+
): Promise<{ repeatedAgain: boolean; declined?: string }> {
|
|
329
|
+
opts.onToolCall?.({ name: call.name, arguments: call.arguments }, turn);
|
|
330
|
+
const def = await this.registry.getDef(call.name);
|
|
331
|
+
const key = callKey(call.name, call.arguments);
|
|
332
|
+
const previous = state.seen.get(key);
|
|
333
|
+
let repeatedAgain = false;
|
|
334
|
+
let declined: string | undefined;
|
|
335
|
+
|
|
336
|
+
let args = call.arguments;
|
|
337
|
+
let result: unknown;
|
|
338
|
+
if (previous) {
|
|
339
|
+
previous.count += 1;
|
|
340
|
+
if (previous.count > 2) repeatedAgain = true;
|
|
341
|
+
result = {
|
|
342
|
+
error:
|
|
343
|
+
`You already called ${call.name} with these arguments; the result was: ` +
|
|
344
|
+
`${this.toHistoryContent(previous.result)}. Do not call it again — answer the user now.`,
|
|
345
|
+
};
|
|
346
|
+
} else if (!def) {
|
|
347
|
+
result = { error: `Unknown tool "${call.name}".` };
|
|
348
|
+
} else {
|
|
349
|
+
const check = validateToolArgs(def, call.arguments);
|
|
350
|
+
if (!check.ok) {
|
|
351
|
+
result = {
|
|
352
|
+
error: `Invalid arguments for ${call.name}: ${check.errors.join('; ')}. Fix them or ask the user for the missing values.`,
|
|
353
|
+
};
|
|
354
|
+
} else if (def.requiresConfirmation) {
|
|
355
|
+
args = check.args;
|
|
356
|
+
const summary = confirmReadback({ name: call.name, arguments: args }) ?? undefined;
|
|
357
|
+
const decision = opts.onConfirm
|
|
358
|
+
? await opts.onConfirm({ name: call.name, arguments: args, ...(summary ? { summary } : {}) })
|
|
359
|
+
: { approved: false, reason: 'no confirmation handler available' };
|
|
360
|
+
if (decision.approved) {
|
|
361
|
+
result = await this.safeExecute(call.name, args);
|
|
362
|
+
} else {
|
|
363
|
+
result = declinedToolResult(call.name, decision.reason);
|
|
364
|
+
declined = summary ? summary.replace(/\.?\s*Confirm\?$/, '') : call.name.replace(/_/g, ' ');
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
args = check.args;
|
|
368
|
+
result = await this.safeExecute(call.name, args);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!previous) {
|
|
373
|
+
// A mutating (confirm-gated) call can change what reads return.
|
|
374
|
+
if (def?.requiresConfirmation) state.seen.clear();
|
|
375
|
+
state.seen.set(key, { result, count: 1 });
|
|
376
|
+
}
|
|
377
|
+
state.executed.push({ name: call.name, arguments: args, result });
|
|
378
|
+
opts.onToolResult?.({ name: call.name, arguments: args, result }, turn);
|
|
379
|
+
state.history.push({ role: 'tool', content: this.toHistoryContent(this.fixAmounts ? annotateRgbBalances(result) : result) });
|
|
380
|
+
return { repeatedAgain, ...(declined ? { declined } : {}) };
|
|
381
|
+
}
|
|
382
|
+
|
|
363
383
|
/**
|
|
364
384
|
* The model ran tools but produced no visible answer (e.g. reasoning used the
|
|
365
385
|
* whole output budget). Ask once more without tools; if that is empty too,
|
|
366
386
|
* show the last tool result instead of an empty reply.
|
|
367
387
|
*/
|
|
368
|
-
private async recoverAnswer(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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({
|
|
388
|
+
private async recoverAnswer(state: RunState, opts: AgenticOptions, turn: number): Promise<Answer> {
|
|
389
|
+
if (opts.signal?.aborted) return model('');
|
|
390
|
+
const retry = await this.callModel(state, opts, turn, {
|
|
391
|
+
tools: [],
|
|
378
392
|
messages: [
|
|
379
|
-
...history,
|
|
393
|
+
...state.history,
|
|
380
394
|
{ role: 'user', content: 'Answer my question now from the tool results above, in a few short sentences.' },
|
|
381
395
|
],
|
|
382
|
-
tools: [],
|
|
383
|
-
system,
|
|
384
|
-
onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
|
|
385
|
-
signal: opts.signal,
|
|
386
396
|
});
|
|
387
|
-
if (retry.inference) inference.push(retry.inference);
|
|
388
397
|
const text = retry.incomplete ? '' : (retry.text || '').trim();
|
|
389
|
-
if (text) return text;
|
|
390
|
-
const last = executed[executed.length - 1]!;
|
|
398
|
+
if (text) return model(text);
|
|
399
|
+
const last = state.executed[state.executed.length - 1]!;
|
|
391
400
|
const body = compressToolResult(last.result, this.compressOpts ?? {}).content;
|
|
392
|
-
return
|
|
401
|
+
return engine(
|
|
402
|
+
`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}`,
|
|
403
|
+
);
|
|
393
404
|
}
|
|
394
405
|
|
|
395
406
|
async cancel(requestId: string): Promise<void> {
|
package/src/evidence.ts
CHANGED
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
|
+
}
|