@kaleidorg/mind 0.8.1 → 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.
Files changed (85) hide show
  1. package/README.md +7 -5
  2. package/dist/bitrefill/index.d.ts +4 -0
  3. package/dist/bitrefill/index.d.ts.map +1 -0
  4. package/dist/bitrefill/index.js +3 -0
  5. package/dist/bitrefill/index.js.map +1 -0
  6. package/dist/capabilities.d.ts +3 -3
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +4 -4
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/engine/answer.d.ts +37 -0
  11. package/dist/engine/answer.d.ts.map +1 -0
  12. package/dist/engine/answer.js +35 -0
  13. package/dist/engine/answer.js.map +1 -0
  14. package/dist/engine.d.ts +9 -3
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +159 -175
  17. package/dist/engine.js.map +1 -1
  18. package/dist/evidence.d.ts +1 -1
  19. package/dist/evidence.d.ts.map +1 -1
  20. package/dist/flashnet/index.d.ts +5 -0
  21. package/dist/flashnet/index.d.ts.map +1 -0
  22. package/dist/flashnet/index.js +4 -0
  23. package/dist/flashnet/index.js.map +1 -0
  24. package/dist/index.d.ts +6 -23
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +10 -27
  27. package/dist/index.js.map +1 -1
  28. package/dist/kaleidoswap/index.d.ts +8 -0
  29. package/dist/kaleidoswap/index.d.ts.map +1 -0
  30. package/dist/kaleidoswap/index.js +7 -0
  31. package/dist/kaleidoswap/index.js.map +1 -0
  32. package/dist/knowledge/index.d.ts +9 -0
  33. package/dist/knowledge/index.d.ts.map +1 -0
  34. package/dist/knowledge/index.js +6 -0
  35. package/dist/knowledge/index.js.map +1 -0
  36. package/dist/lsps1/index.d.ts +4 -0
  37. package/dist/lsps1/index.d.ts.map +1 -0
  38. package/dist/lsps1/index.js +3 -0
  39. package/dist/lsps1/index.js.map +1 -0
  40. package/dist/providers/types.d.ts +3 -3
  41. package/dist/providers/types.js +3 -3
  42. package/dist/qvac/index.d.ts +0 -1
  43. package/dist/qvac/index.d.ts.map +1 -1
  44. package/dist/qvac/index.js +0 -1
  45. package/dist/qvac/index.js.map +1 -1
  46. package/dist/qvac/provider.d.ts +9 -9
  47. package/dist/qvac/provider.d.ts.map +1 -1
  48. package/dist/qvac/provider.js +2 -15
  49. package/dist/qvac/provider.js.map +1 -1
  50. package/dist/qvac/stream.d.ts +4 -3
  51. package/dist/qvac/stream.d.ts.map +1 -1
  52. package/dist/qvac/stream.js.map +1 -1
  53. package/dist/qvac/voice.d.ts +1 -1
  54. package/dist/submarine/index.d.ts +5 -0
  55. package/dist/submarine/index.d.ts.map +1 -0
  56. package/dist/submarine/index.js +4 -0
  57. package/dist/submarine/index.js.map +1 -0
  58. package/dist/tools/in-process.d.ts +2 -2
  59. package/dist/tools/in-process.js +2 -2
  60. package/package.json +32 -2
  61. package/src/bitrefill/index.ts +13 -0
  62. package/src/capabilities.ts +7 -7
  63. package/src/context/context.test.ts +2 -2
  64. package/src/engine/answer.ts +66 -0
  65. package/src/engine.ts +185 -194
  66. package/src/evidence.ts +1 -1
  67. package/src/flashnet/index.ts +14 -0
  68. package/src/index.ts +10 -107
  69. package/src/kaleidoswap/index.ts +19 -0
  70. package/src/knowledge/index.ts +14 -0
  71. package/src/lsps1/index.ts +13 -0
  72. package/src/providers/types.ts +3 -3
  73. package/src/qvac/index.ts +0 -8
  74. package/src/qvac/provider.test.ts +0 -17
  75. package/src/qvac/provider.ts +11 -28
  76. package/src/qvac/stream.ts +4 -3
  77. package/src/qvac/voice.ts +1 -1
  78. package/src/submarine/index.ts +17 -0
  79. package/src/tools/in-process.ts +2 -2
  80. package/dist/qvac/delegate.d.ts +0 -50
  81. package/dist/qvac/delegate.d.ts.map +0 -1
  82. package/dist/qvac/delegate.js +0 -53
  83. package/dist/qvac/delegate.js.map +0 -1
  84. package/src/qvac/delegate.test.ts +0 -68
  85. package/src/qvac/delegate.ts +0 -73
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How an agentic run ends: the Engine's fixed replies, and the checks every
3
+ * answer the model wrote goes through before the user sees it.
4
+ *
5
+ * An answer is tagged with where it came from. Fixed replies are final; only
6
+ * model text is rewritten (amount fixes) or replaced (payment data no tool
7
+ * returned). Keeping that rule in one place is what stops a fixed reply that
8
+ * quotes a readback from being mistaken for an invented invoice.
9
+ */
10
+ import type { ToolCallError } from '../providers/types.js';
11
+ import { findUngroundedPaymentData, fixSatsBtcConversions, ungroundedReply } from '../guards.js';
12
+ import { fixRgbBalanceUnits } from '../context/rgb-units.js';
13
+
14
+ export interface Answer {
15
+ text: string;
16
+ /** `engine`: one of the fixed replies below, never rewritten. */
17
+ source: 'model' | 'engine';
18
+ }
19
+
20
+ export const model = (text: string): Answer => ({ text, source: 'model' });
21
+ export const engine = (text: string): Answer => ({ text, source: 'engine' });
22
+
23
+ export const STOPPED_REPLY = 'I had to stop after several steps — please try a more specific request.';
24
+
25
+ export const TOOL_CALL_FAILED_REPLY =
26
+ "I couldn't put together a valid request for that. Please rephrase it with the exact values (asset, amount, recipient).";
27
+
28
+ export const REPEATED_CALL_REPLY = 'I could not get a different result from the wallet — please try a more specific request.';
29
+
30
+ export function cancelledReply(declined: string[]): string {
31
+ return `Cancelled — you declined: ${declined.join('; ')}. Nothing was sent or changed.`;
32
+ }
33
+
34
+ /** Fed back to the model after a tool call it emitted could not be parsed. */
35
+ export function toolErrorFeedback(errors: ToolCallError[], cutOff: boolean): string {
36
+ if (cutOff) {
37
+ 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.';
38
+ }
39
+ const detail = errors.map((e) => e.message).join('; ');
40
+ 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.`;
41
+ }
42
+
43
+ export interface FinalizeOptions {
44
+ /** Recompute BTC figures and relabel RGB balances. */
45
+ fixAmounts: boolean;
46
+ /** Replace answers carrying payment data no tool returned and the user never typed. */
47
+ guardPaymentData: boolean;
48
+ /** What the answer may legitimately quote: the user's messages and the tool results. */
49
+ sources: unknown[];
50
+ /** Tool results, for relabelling RGB balances. */
51
+ toolResults: unknown[];
52
+ aborted: boolean;
53
+ }
54
+
55
+ /** The text the user sees. */
56
+ export function finalizeAnswer(answer: Answer, opts: FinalizeOptions): string {
57
+ if (!answer.text) return opts.aborted ? '' : STOPPED_REPLY;
58
+ if (answer.source === 'engine') return answer.text;
59
+ let text = answer.text;
60
+ if (opts.fixAmounts) text = fixRgbBalanceUnits(fixSatsBtcConversions(text), opts.toolResults);
61
+ if (opts.guardPaymentData) {
62
+ const ungrounded = findUngroundedPaymentData(text, opts.sources);
63
+ if (ungrounded.length) text = ungroundedReply(ungrounded);
64
+ }
65
+ return text;
66
+ }
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 is delegated to a remote provider.
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, fixRgbBalanceUnits } from './context/rgb-units.js';
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';
@@ -125,17 +124,16 @@ export interface AgenticResult {
125
124
  inference: InferenceMetrics[];
126
125
  }
127
126
 
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.`;
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;
139
137
  }
140
138
 
141
139
  export class Engine {
@@ -190,226 +188,219 @@ export class Engine {
190
188
  }
191
189
 
192
190
  private async runAgenticSession(messages: Message[], opts: AgenticOptions): Promise<AgenticResult> {
193
- const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
194
- const hasSystem = messages.some((m) => m.role === 'system');
195
- const system = hasSystem ? undefined : this.defaultSystem;
196
-
197
191
  const startedAt = Date.now();
198
- const history: Message[] = [...messages];
192
+ const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
199
193
  const registryTools = await this.registry.listTools();
200
- // Progressive disclosure: expose only the active skill's tools when set.
201
- const allTools = opts.allowedTools
202
- ? registryTools.filter((t) => opts.allowedTools!.includes(t.name))
203
- : registryTools;
204
- const executed: ToolResult[] = [];
205
- let lastRequestId: string | undefined;
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;
210
- let turns = 0;
211
- const inference: InferenceMetrics[] = [];
212
- const seen = new Map<string, { result: unknown; count: number }>();
213
- 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
+ };
214
203
 
215
204
  const lastUser = [...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
216
205
  const action = this.guardMissingTools ? detectWalletAction(lastUser) : null;
217
- if (action && !hasCapableTool(action, allTools.map((t) => t.name))) {
206
+ if (action && !hasCapableTool(action, state.tools.map((t) => t.name))) {
218
207
  const text = noToolReply(action);
219
- history.push({ role: 'assistant', content: text });
220
- 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 };
221
210
  }
222
211
 
212
+ let answer: Answer = model('');
213
+ let turns = 0;
214
+ let toolErrorRetries = 0;
223
215
  // A retry after an unreadable tool call does not count against maxTurns.
224
216
  for (let turn = 1; turn <= maxTurns + toolErrorRetries; turn++) {
225
217
  turns = turn;
226
218
  if (opts.signal?.aborted) break;
227
219
 
228
- const out = await this.provider.runTurn({
229
- sessionKey: opts.sessionKey,
230
- messages: history,
231
- tools: allTools,
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
- : {}),
238
- onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
239
- 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 }) } : {}),
240
226
  });
241
-
242
- lastRequestId = out.requestId;
243
- if (out.inference) inference.push(out.inference);
244
227
  if (out.requestId) opts.onStart?.(out.requestId, turn);
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;
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;
257
246
  }
258
- finalText = TOOL_CALL_FAILED_MESSAGE;
259
- engineReply = true;
260
- break;
261
- }
262
-
263
- // No tool calls ⇒ the model produced its final answer.
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();
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());
267
250
  break;
268
251
  }
269
252
 
270
253
  // Anchor the next turn with the raw assistant frame.
271
- history.push({ role: 'assistant', content: out.rawContent || finalText });
254
+ state.history.push({ role: 'assistant', content: out.rawContent || answer.text });
272
255
 
273
256
  let repeatedAgain = false;
274
- const declinedThisTurn: string[] = [];
257
+ const declined: string[] = [];
275
258
  for (const call of out.toolCalls) {
276
- opts.onToolCall?.({ name: call.name, arguments: call.arguments }, turn);
277
- const def = await this.registry.getDef(call.name);
278
- const key = callKey(call.name, call.arguments);
279
- const previous = seen.get(key);
280
-
281
- let args = call.arguments;
282
- let result: unknown;
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
- }
311
- } else {
312
- args = check.args;
313
- result = await this.safeExecute(call.name, args);
314
- }
315
- }
316
-
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) });
259
+ const step = await this.executeCall(state, call, opts, turn);
260
+ repeatedAgain ||= step.repeatedAgain;
261
+ if (step.declined) declined.push(step.declined);
325
262
  }
326
263
 
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;
264
+ if (this.endTurnOnDecline && declined.length && declined.length === out.toolCalls.length) {
265
+ answer = engine(cancelledReply(declined));
330
266
  break;
331
267
  }
332
268
 
333
269
  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.';
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);
344
273
  break;
345
274
  }
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);
365
275
  }
366
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
+ });
367
284
  // Append the final answer so the returned conversation is complete (the
368
285
  // loop breaks before pushing the no-tool-call turn).
369
- if (finalText) history.push({ role: 'assistant', content: finalText });
286
+ if (text) state.history.push({ role: 'assistant', content: text });
370
287
 
371
288
  return {
372
- text: finalText,
289
+ text,
373
290
  turns,
374
- toolCalls: executed,
375
- requestId: lastRequestId,
376
- messages: history,
291
+ toolCalls: state.executed,
292
+ requestId: state.lastRequestId,
293
+ messages: state.history,
377
294
  latencyMs: Date.now() - startedAt,
378
- inference,
295
+ inference: state.inference,
379
296
  };
380
297
  }
381
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
+
382
383
  /**
383
384
  * The model ran tools but produced no visible answer (e.g. reasoning used the
384
385
  * whole output budget). Ask once more without tools; if that is empty too,
385
386
  * show the last tool result instead of an empty reply.
386
387
  */
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,
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: [],
398
392
  messages: [
399
- ...history,
393
+ ...state.history,
400
394
  { role: 'user', content: 'Answer my question now from the tool results above, in a few short sentences.' },
401
395
  ],
402
- tools: [],
403
- system,
404
- onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
405
- signal: opts.signal,
406
396
  });
407
- if (retry.inference) inference.push(retry.inference);
408
397
  const text = retry.incomplete ? '' : (retry.text || '').trim();
409
- if (text) return text;
410
- const last = executed[executed.length - 1]!;
398
+ if (text) return model(text);
399
+ const last = state.executed[state.executed.length - 1]!;
411
400
  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}`;
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
+ );
413
404
  }
414
405
 
415
406
  async cancel(requestId: string): Promise<void> {
package/src/evidence.ts CHANGED
@@ -27,7 +27,7 @@ export interface EvidenceEvent {
27
27
  model?: {
28
28
  name: string;
29
29
  version?: string;
30
- source?: 'local' | 'delegated';
30
+ source?: 'local' | 'remote';
31
31
  };
32
32
  hardware?: {
33
33
  device: string;
@@ -0,0 +1,14 @@
1
+ /** Flashnet (Spark-native AMM): tool contract and swap recipe. */
2
+ export {
3
+ FLASHNET_TOOLS,
4
+ FLASHNET_SPEND_TOOLS,
5
+ isFlashnetSpendTool,
6
+ getFlashnetTool,
7
+ bindFlashnetTools,
8
+ } from './contract.js';
9
+ export type {
10
+ FlashnetToolDef,
11
+ FlashnetHandler,
12
+ BindFlashnetOptions,
13
+ } from './contract.js';
14
+ export { flashnetSwapRecipe } from '../recipe/flashnet-swap.js';