@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/guards.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent guards — deterministic checks around the model's tool use, so a small
|
|
3
|
+
* local model can't turn a bad tool call into a bad user experience:
|
|
4
|
+
*
|
|
5
|
+
* - validateToolArgs: check a call against the tool's JSON Schema (or Zod
|
|
6
|
+
* schema) before it reaches the confirmation gate.
|
|
7
|
+
* - findUngroundedPaymentData: payment requests/addresses in a reply that no
|
|
8
|
+
* tool returned and the user never typed — i.e. made up by the model.
|
|
9
|
+
* - detectWalletAction / hasCapableTool: refuse a wallet action up front when
|
|
10
|
+
* no tool in scope can perform it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ToolDef } from './types.js';
|
|
14
|
+
|
|
15
|
+
type Obj = Record<string, any>;
|
|
16
|
+
|
|
17
|
+
export interface ArgValidation {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
/** Arguments with numeric/boolean strings coerced to the schema's type. */
|
|
20
|
+
args: Record<string, unknown>;
|
|
21
|
+
errors: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isZodLike(p: unknown): p is { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: any } } {
|
|
25
|
+
return !!p && typeof p === 'object' && typeof (p as Obj).safeParse === 'function';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function schemaType(s: Obj): string | undefined {
|
|
29
|
+
if (Array.isArray(s.type)) return s.type.find((t: unknown) => t !== 'null');
|
|
30
|
+
if (typeof s.type === 'string') return s.type;
|
|
31
|
+
for (const alt of [...(s.anyOf ?? []), ...(s.oneOf ?? [])]) {
|
|
32
|
+
if (alt && typeof alt === 'object' && alt.type !== 'null') return schemaType(alt);
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkValue(key: string, value: unknown, s: Obj): { value: unknown; error?: string } {
|
|
38
|
+
const type = schemaType(s);
|
|
39
|
+
let v = value;
|
|
40
|
+
if (type === 'number' || type === 'integer') {
|
|
41
|
+
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) v = Number(v);
|
|
42
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return { value, error: `"${key}" must be a finite number` };
|
|
43
|
+
if (type === 'integer' && !Number.isInteger(v)) return { value, error: `"${key}" must be an integer` };
|
|
44
|
+
if (typeof s.minimum === 'number' && v < s.minimum) return { value, error: `"${key}" must be ≥ ${s.minimum}` };
|
|
45
|
+
if (typeof s.maximum === 'number' && v > s.maximum) return { value, error: `"${key}" must be ≤ ${s.maximum}` };
|
|
46
|
+
} else if (type === 'boolean') {
|
|
47
|
+
if (typeof v === 'string' && /^(true|false)$/i.test(v.trim())) v = v.trim().toLowerCase() === 'true';
|
|
48
|
+
if (typeof v !== 'boolean') return { value, error: `"${key}" must be true or false` };
|
|
49
|
+
} else if (type === 'string') {
|
|
50
|
+
if (typeof v !== 'string') return { value, error: `"${key}" must be a string` };
|
|
51
|
+
if (typeof s.pattern === 'string' && !new RegExp(s.pattern).test(v)) {
|
|
52
|
+
return { value, error: `"${key}" does not match the expected format` };
|
|
53
|
+
}
|
|
54
|
+
} else if (type === 'array') {
|
|
55
|
+
if (!Array.isArray(v)) return { value, error: `"${key}" must be an array` };
|
|
56
|
+
} else if (type === 'object') {
|
|
57
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return { value, error: `"${key}" must be an object` };
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(s.enum) && !s.enum.includes(v)) {
|
|
60
|
+
return { value, error: `"${key}" must be one of ${s.enum.map((e: unknown) => JSON.stringify(e)).join(', ')}` };
|
|
61
|
+
}
|
|
62
|
+
return { value: v };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Tool-specific rules the published schemas can't express (conditional requirements). */
|
|
66
|
+
const SEMANTIC_RULES: Record<string, (a: Record<string, unknown>) => string[]> = {
|
|
67
|
+
rln_issue_asset: (a) => {
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
const schema = String(a.schema ?? 'NIA').toUpperCase();
|
|
70
|
+
if (schema !== 'UDA' && a.amount == null) errors.push('"amount" (total supply) is required for NIA and CFA assets');
|
|
71
|
+
if (schema !== 'CFA') {
|
|
72
|
+
if (a.ticker == null) errors.push('"ticker" is required for NIA and UDA assets');
|
|
73
|
+
else if (!/^[A-Z0-9]{1,8}$/.test(String(a.ticker))) errors.push('"ticker" must be 1–8 uppercase letters or digits');
|
|
74
|
+
}
|
|
75
|
+
if (typeof a.amount === 'number' && a.amount <= 0) errors.push('"amount" must be greater than 0');
|
|
76
|
+
return errors;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
SEMANTIC_RULES.wdk_issue_asset = SEMANTIC_RULES.rln_issue_asset!;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate (and lightly coerce) a tool call's arguments against its schema.
|
|
83
|
+
* Unknown schema shapes pass through unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export function validateToolArgs(def: Pick<ToolDef, 'name' | 'parameters'>, rawArgs: unknown): ArgValidation {
|
|
86
|
+
const args: Record<string, unknown> =
|
|
87
|
+
rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) ? { ...(rawArgs as Obj) } : {};
|
|
88
|
+
if (rawArgs != null && (typeof rawArgs !== 'object' || Array.isArray(rawArgs))) {
|
|
89
|
+
return { ok: false, args, errors: ['arguments must be a JSON object'] };
|
|
90
|
+
}
|
|
91
|
+
const p = def.parameters as Obj | undefined;
|
|
92
|
+
const errors: string[] = [];
|
|
93
|
+
|
|
94
|
+
if (isZodLike(p)) {
|
|
95
|
+
const r = p.safeParse(args);
|
|
96
|
+
if (!r.success) {
|
|
97
|
+
const issues: Obj[] = r.error?.issues ?? [];
|
|
98
|
+
errors.push(...(issues.length
|
|
99
|
+
? issues.map((i) => `${(i.path ?? []).join('.') || 'arguments'}: ${i.message}`)
|
|
100
|
+
: ['arguments do not match the tool schema']));
|
|
101
|
+
}
|
|
102
|
+
} else if (p && typeof p === 'object' && (p.properties || p.required)) {
|
|
103
|
+
const props: Obj = p.properties ?? {};
|
|
104
|
+
for (const key of Array.isArray(p.required) ? p.required : []) {
|
|
105
|
+
if (args[key] == null || args[key] === '') errors.push(`"${key}" is required`);
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(args)) {
|
|
108
|
+
const s = props[key];
|
|
109
|
+
if (value == null) continue;
|
|
110
|
+
if (!s) {
|
|
111
|
+
if (p.additionalProperties === false) errors.push(`unknown argument "${key}"`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const r = checkValue(key, value, s);
|
|
115
|
+
if (r.error) errors.push(r.error);
|
|
116
|
+
else args[key] = r.value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [key, value] of Object.entries(args)) {
|
|
121
|
+
if (typeof value === 'number' && !Number.isFinite(value)) errors.push(`"${key}" must be a finite number`);
|
|
122
|
+
}
|
|
123
|
+
const rule = SEMANTIC_RULES[def.name];
|
|
124
|
+
if (rule && errors.length === 0) errors.push(...rule(args));
|
|
125
|
+
return { ok: errors.length === 0, args, errors: [...new Set(errors)] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Payment requests and addresses a model must never make up. */
|
|
129
|
+
const PAYMENT_DATA: Array<{ kind: string; re: RegExp }> = [
|
|
130
|
+
{ kind: 'Lightning invoice', re: /\bln(?:bc|tb|bcrt|tbs|sb)[0-9a-z]{20,}\b/gi },
|
|
131
|
+
{ kind: 'Lightning offer', re: /\blno1[0-9a-z]{20,}\b/gi },
|
|
132
|
+
{ kind: 'LNURL', re: /\blnurl1[0-9a-z]{20,}\b/gi },
|
|
133
|
+
// An RGB invoice carries a "/" path or a utxob:/wvout: beneficiary; plain
|
|
134
|
+
// `rgb:` asset ids (contract ids) are not payment data.
|
|
135
|
+
{ kind: 'RGB invoice', re: /\brgb:[^\s`'"<>)|]*(?:\/|utxob:|wvout:)[^\s`'"<>)|]*/gi },
|
|
136
|
+
{ kind: 'blinded UTXO', re: /\butxob:[^\s`'"<>)|]{10,}/gi },
|
|
137
|
+
{ kind: 'Spark address', re: /\bspark(?:rt|t|s)?1[0-9a-z]{20,}\b/gi },
|
|
138
|
+
{ kind: 'Ark address', re: /\b(?:t?ark)1[0-9a-z]{20,}\b/gi },
|
|
139
|
+
{ kind: 'Liquid address', re: /\b(?:lq1|ex1|tlq1|tex1|el1|ert1)[0-9a-z]{20,}\b/gi },
|
|
140
|
+
{ kind: 'Bitcoin address', re: /\b(?:bc1|tb1|bcrt1)[0-9a-z]{20,}\b/gi },
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
export interface UngroundedItem {
|
|
144
|
+
kind: string;
|
|
145
|
+
value: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Payment strings in `text` that do not occur in any of the `sources` (tool
|
|
150
|
+
* results, user and history messages). Comparison is case-insensitive.
|
|
151
|
+
*/
|
|
152
|
+
export function findUngroundedPaymentData(text: string, sources: unknown[]): UngroundedItem[] {
|
|
153
|
+
if (!text) return [];
|
|
154
|
+
const haystack = sources
|
|
155
|
+
.map((s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s); } catch { return ''; } })()))
|
|
156
|
+
.join('\n')
|
|
157
|
+
.toLowerCase();
|
|
158
|
+
const out: UngroundedItem[] = [];
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
for (const { kind, re } of PAYMENT_DATA) {
|
|
161
|
+
for (const m of text.matchAll(re)) {
|
|
162
|
+
const value = m[0].replace(/[.,;:!?*]+$/, '');
|
|
163
|
+
const key = value.toLowerCase();
|
|
164
|
+
if (seen.has(key)) continue;
|
|
165
|
+
seen.add(key);
|
|
166
|
+
if (!haystack.includes(key)) out.push({ kind, value });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ungroundedReply(items: UngroundedItem[]): string {
|
|
173
|
+
const kinds = [...new Set(items.map((i) => i.kind))].join(' / ');
|
|
174
|
+
return `I can't give you that ${kinds}: no tool produced it in this conversation, and I won't make one up. ` +
|
|
175
|
+
'Make sure the wallet that should create it is connected, then ask again.';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const NUM = String.raw`(\d[\d,]*(?:\.\d+)?)`;
|
|
179
|
+
const SATS = String.raw`(?:sats?|satoshis)`;
|
|
180
|
+
// `[*_]*` lets markdown emphasis sit between the parts: **300,000 sats** (3 BTC).
|
|
181
|
+
const SATS_THEN_BTC = new RegExp(String.raw`${NUM}\s*${SATS}([*_]*\s*\(\s*~?\s*)${NUM}(\s*BTC\b)`, 'gi');
|
|
182
|
+
const BTC_THEN_SATS = new RegExp(String.raw`${NUM}(\s*BTC[*_]*\s*\(\s*~?\s*)${NUM}(\s*${SATS}\b)`, 'gi');
|
|
183
|
+
|
|
184
|
+
const toNumber = (s: string): number => Number(s.replace(/,/g, ''));
|
|
185
|
+
|
|
186
|
+
/** Sats as a BTC decimal string, without trailing zeros. */
|
|
187
|
+
export function formatSatsAsBtc(sats: number): string {
|
|
188
|
+
return (sats / 1e8).toFixed(8).replace(/\.?0+$/, '');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sameBtc = (btc: number, sats: number) => Math.abs(btc * 1e8 - sats) < 0.5;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Small models get sats↔BTC conversions wrong ("4,277 sats (42.77 BTC)").
|
|
195
|
+
* Where the answer pairs a sats amount with a BTC amount, recompute the BTC
|
|
196
|
+
* figure from the sats figure, which is the one copied from the tool result.
|
|
197
|
+
*/
|
|
198
|
+
export function fixSatsBtcConversions(text: string): string {
|
|
199
|
+
return text
|
|
200
|
+
.replace(SATS_THEN_BTC, (m, sats: string, mid: string, btc: string, unit: string) =>
|
|
201
|
+
sameBtc(toNumber(btc), toNumber(sats)) ? m : m.replace(`${mid}${btc}${unit}`, `${mid}${formatSatsAsBtc(toNumber(sats))}${unit}`))
|
|
202
|
+
.replace(BTC_THEN_SATS, (m, btc: string, _mid: string, sats: string) =>
|
|
203
|
+
sameBtc(toNumber(btc), toNumber(sats)) ? m : `${formatSatsAsBtc(toNumber(sats))}${m.slice(btc.length)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A wallet action the user asked for, and how to recognise a tool that can do it. */
|
|
207
|
+
export interface WalletAction {
|
|
208
|
+
id: 'receive-invoice' | 'receive-address' | 'pay' | 'send' | 'issue-asset';
|
|
209
|
+
label: string;
|
|
210
|
+
tool: RegExp;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const WALLET_ACTIONS: Array<WalletAction & { request: RegExp }> = [
|
|
214
|
+
{
|
|
215
|
+
id: 'receive-invoice',
|
|
216
|
+
label: 'creating an invoice',
|
|
217
|
+
request: /\b(create|make|generate|new|give me|get me|send me|i need|request)\b[^.?!]*\binvoice\b|\binvoice\b[^.?!]*\bfor\s+\d/i,
|
|
218
|
+
tool: /(^|_)create_(ln_|lightning_|rgb_)?invoice$|(^|_)receive(_|$)|_invoice_create$/i,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'receive-address',
|
|
222
|
+
label: 'getting a receive address',
|
|
223
|
+
request: /\b(my|new|receive|receiving|deposit|funding|give me|get me|show me)\b[^.?!]*\baddress\b/i,
|
|
224
|
+
tool: /address/i,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'pay',
|
|
228
|
+
label: 'paying an invoice',
|
|
229
|
+
request: /\bpay\b[^.?!]*\b(invoice|lnbc|lntb|lnbcrt|lno1|bolt11|bolt12|offer)/i,
|
|
230
|
+
tool: /pay/i,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'send',
|
|
234
|
+
label: 'sending funds',
|
|
235
|
+
request: /\bsend\b[^.?!]*\b(\d|sats?|btc|usdt|xaut|asset|to)\b/i,
|
|
236
|
+
tool: /send|pay|transfer/i,
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'issue-asset',
|
|
240
|
+
label: 'issuing an asset',
|
|
241
|
+
request: /\b(issue|mint|create)\b[^.?!]*\b(asset|token|coin|nia|cfa|uda)\b/i,
|
|
242
|
+
tool: /issue_asset/i,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const HOW_TO_QUESTION = /^\s*(how|what|why|when|where|which|who)\b/i;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* True when the request is a wallet action the model should act on with a
|
|
250
|
+
* tool right away — not a question about how such an action works.
|
|
251
|
+
*/
|
|
252
|
+
export function wantsToolCall(text: string): boolean {
|
|
253
|
+
return detectWalletAction(text) !== null && !HOW_TO_QUESTION.test(text);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The wallet action a request asks for, or null. Payment tools from commerce sources don't count. */
|
|
257
|
+
export function detectWalletAction(text: string): WalletAction | null {
|
|
258
|
+
const hit = WALLET_ACTIONS.find((a) => a.request.test(text));
|
|
259
|
+
return hit ? { id: hit.id, label: hit.label, tool: hit.tool } : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const NON_WALLET_PREFIXES = /^(bitrefill_|l402_|mpp_|search_|read_skill_reference$|remember$|recall$)/;
|
|
263
|
+
|
|
264
|
+
export function hasCapableTool(action: WalletAction, toolNames: string[]): boolean {
|
|
265
|
+
return toolNames.some((n) => !NON_WALLET_PREFIXES.test(n) && action.tool.test(n));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function noToolReply(action: WalletAction): string {
|
|
269
|
+
return `I can't do that here: no tool for ${action.label} is available, so I won't make anything up. ` +
|
|
270
|
+
'Connect a wallet that supports it and ask again.';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The text fed back to the model when the user declines at the confirmation gate. */
|
|
274
|
+
export const DECLINED_TOOL_MESSAGE =
|
|
275
|
+
'Cancelled by the user: the user said no at the confirmation prompt, so this action was not run. ' +
|
|
276
|
+
'Nothing was sent or changed. Tell the user it was cancelled at their request. ' +
|
|
277
|
+
'Do not say that the node, the wallet, the recipient or anyone else declined it, and do not retry it.';
|
|
278
|
+
|
|
279
|
+
/** Tool result for a call the user declined — the same shape for every confirm-gated tool. */
|
|
280
|
+
export function declinedToolResult(tool: string, reason?: string): Record<string, unknown> {
|
|
281
|
+
return {
|
|
282
|
+
status: 'cancelled_by_user',
|
|
283
|
+
declined_by: 'user',
|
|
284
|
+
tool,
|
|
285
|
+
message: DECLINED_TOOL_MESSAGE,
|
|
286
|
+
...(reason ? { host_reason: reason } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Stable key for a tool call: name + canonical JSON of its arguments. */
|
|
291
|
+
export function callKey(name: string, args: Record<string, unknown>): string {
|
|
292
|
+
const canon = (v: unknown): unknown =>
|
|
293
|
+
Array.isArray(v)
|
|
294
|
+
? v.map(canon)
|
|
295
|
+
: v && typeof v === 'object'
|
|
296
|
+
? Object.fromEntries(Object.keys(v as Obj).sort().map((k) => [k, canon((v as Obj)[k])]))
|
|
297
|
+
: v;
|
|
298
|
+
return `${name}:${JSON.stringify(canon(args ?? {}))}`;
|
|
299
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type {
|
|
|
20
20
|
export type {
|
|
21
21
|
InferenceMetrics,
|
|
22
22
|
LLMProvider,
|
|
23
|
+
ToolCallError,
|
|
24
|
+
ToolChoice,
|
|
23
25
|
TurnInput,
|
|
24
26
|
TurnOutput,
|
|
25
27
|
} from './providers/types.js';
|
|
@@ -55,6 +57,19 @@ export type {
|
|
|
55
57
|
BindWalletOptions,
|
|
56
58
|
} from './wallet/contract.js';
|
|
57
59
|
export { confirmReadback } from './wallet/confirm.js';
|
|
60
|
+
export {
|
|
61
|
+
validateToolArgs,
|
|
62
|
+
findUngroundedPaymentData,
|
|
63
|
+
detectWalletAction,
|
|
64
|
+
hasCapableTool,
|
|
65
|
+
wantsToolCall,
|
|
66
|
+
fixSatsBtcConversions,
|
|
67
|
+
formatSatsAsBtc,
|
|
68
|
+
DECLINED_TOOL_MESSAGE,
|
|
69
|
+
declinedToolResult,
|
|
70
|
+
} from './guards.js';
|
|
71
|
+
export { annotateRgbBalances, fixRgbBalanceUnits, formatRgbAmount } from './context/rgb-units.js';
|
|
72
|
+
export type { ArgValidation, UngroundedItem, WalletAction } from './guards.js';
|
|
58
73
|
|
|
59
74
|
// ── KaleidoSwap maker tool contract (single source of truth) ────────────────
|
|
60
75
|
export {
|
|
@@ -86,6 +101,23 @@ export type {
|
|
|
86
101
|
BindLsps1Options,
|
|
87
102
|
} from './lsps1/contract.js';
|
|
88
103
|
|
|
104
|
+
// ── KaleidoSwap /v2 submarine swaps (pay Lightning from Liquid) ─────────────
|
|
105
|
+
export {
|
|
106
|
+
SUBMARINE_TOOLS,
|
|
107
|
+
SUBMARINE_SPEND_TOOLS,
|
|
108
|
+
SUBMARINE_FROM_ASSETS,
|
|
109
|
+
isSubmarineSpendTool,
|
|
110
|
+
getSubmarineTool,
|
|
111
|
+
formatSubmarineAmount,
|
|
112
|
+
bindSubmarineTools,
|
|
113
|
+
} from './submarine/contract.js';
|
|
114
|
+
export type {
|
|
115
|
+
SubmarineToolDef,
|
|
116
|
+
SubmarineFromAsset,
|
|
117
|
+
SubmarineHandler,
|
|
118
|
+
BindSubmarineOptions,
|
|
119
|
+
} from './submarine/contract.js';
|
|
120
|
+
|
|
89
121
|
// ── Bitrefill (gift cards / mobile top-ups / eSIMs) ─────────────────────────
|
|
90
122
|
export {
|
|
91
123
|
BITREFILL_TOOLS,
|
|
@@ -129,6 +161,12 @@ export {
|
|
|
129
161
|
// ── Buy-an-asset-channel recipe (opt-in — register via Funnel.recipes) ─────
|
|
130
162
|
export { buyAssetChannelRecipe, extractBuyAsset } from './recipe/buy-asset-channel.js';
|
|
131
163
|
|
|
164
|
+
// ── Issue-an-RGB-asset recipe (opt-in — register via Funnel.recipes) ───────
|
|
165
|
+
export { issueAssetRecipe, extractIssueAsset } from './recipe/issue-asset.js';
|
|
166
|
+
|
|
167
|
+
// ── Submarine-pay recipe (opt-in — register via Funnel.recipes, before payments) ──
|
|
168
|
+
export { submarinePayRecipe, extractSubmarinePay } from './recipe/submarine-pay.js';
|
|
169
|
+
|
|
132
170
|
// ── Recipes (mobile multi-step: "recipes, not planning") ───────────────────
|
|
133
171
|
export { runRecipe, extractSlots, RecipeRegistry } from './recipe/runner.js';
|
|
134
172
|
export type { RunRecipeOptions } from './recipe/runner.js';
|
|
@@ -202,10 +240,11 @@ export type {
|
|
|
202
240
|
} from './knowledge/btc-map.js';
|
|
203
241
|
|
|
204
242
|
export { Engine } from './engine.js';
|
|
205
|
-
export type { EngineOptions, AgenticOptions, AgenticResult } from './engine.js';
|
|
243
|
+
export type { EngineOptions, AgenticOptions, AgenticResult, ComposedSkill } from './engine.js';
|
|
206
244
|
|
|
207
245
|
// ── Funnel (T0 fast-path → T2 recipe → T1 agentic — the tiered agent) ───────
|
|
208
246
|
export { Funnel, DEFAULT_WALLET_SYSTEM } from './funnel.js';
|
|
247
|
+
export { skillAvailable, selectAvailableSkill } from './skills/select.js';
|
|
209
248
|
export type { FunnelOptions, FunnelSettings, FunnelCallbacks, FunnelResult } from './funnel.js';
|
|
210
249
|
|
|
211
250
|
export {
|
|
@@ -17,9 +17,6 @@ describe('KALEIDOSWAP_TOOLS — shape invariants', () => {
|
|
|
17
17
|
'kaleidoswap_get_pairs',
|
|
18
18
|
'kaleidoswap_get_quote',
|
|
19
19
|
'kaleidoswap_get_nodeinfo',
|
|
20
|
-
'kaleidoswap_place_order',
|
|
21
|
-
'kaleidoswap_get_order_status',
|
|
22
|
-
'kaleidoswap_get_order_history',
|
|
23
20
|
'kaleidoswap_atomic_init',
|
|
24
21
|
'kaleidoswap_atomic_execute',
|
|
25
22
|
'kaleidoswap_atomic_status',
|
|
@@ -30,7 +27,7 @@ describe('KALEIDOSWAP_TOOLS — shape invariants', () => {
|
|
|
30
27
|
|
|
31
28
|
it('every tool has a group and a parameters object', () => {
|
|
32
29
|
for (const t of KALEIDOSWAP_TOOLS) {
|
|
33
|
-
expect(['market', '
|
|
30
|
+
expect(['market', 'atomic', 'liquidity']).toContain(t.group);
|
|
34
31
|
expect(t.parameters).toBeDefined();
|
|
35
32
|
expect((t.parameters as any).type).toBe('object');
|
|
36
33
|
}
|
|
@@ -45,17 +42,16 @@ describe('KALEIDOSWAP_TOOLS — shape invariants', () => {
|
|
|
45
42
|
it('lists every spend tool exactly once in KALEIDOSWAP_SPEND_TOOLS', () => {
|
|
46
43
|
const expected = KALEIDOSWAP_TOOLS.filter((t) => t.spend).map((t) => t.name).sort();
|
|
47
44
|
expect([...KALEIDOSWAP_SPEND_TOOLS].sort()).toEqual(expected);
|
|
48
|
-
// Sanity:
|
|
45
|
+
// Sanity: atomic_init/execute, create_asset_channel are spend; the rest aren't.
|
|
49
46
|
expect(expected).toEqual([
|
|
50
47
|
'kaleidoswap_atomic_execute',
|
|
51
48
|
'kaleidoswap_atomic_init',
|
|
52
49
|
'kaleidoswap_lsp_create_asset_channel',
|
|
53
|
-
'kaleidoswap_place_order',
|
|
54
50
|
]);
|
|
55
51
|
});
|
|
56
52
|
|
|
57
53
|
it('isKaleidoswapSpendTool agrees with the set', () => {
|
|
58
|
-
expect(isKaleidoswapSpendTool('
|
|
54
|
+
expect(isKaleidoswapSpendTool('kaleidoswap_atomic_init')).toBe(true);
|
|
59
55
|
expect(isKaleidoswapSpendTool('kaleidoswap_get_pairs')).toBe(false);
|
|
60
56
|
expect(isKaleidoswapSpendTool('not_a_tool')).toBe(false);
|
|
61
57
|
});
|
|
@@ -75,13 +71,13 @@ describe('kaleidoswapTools(groups)', () => {
|
|
|
75
71
|
const market = kaleidoswapTools({ groups: ['market'] });
|
|
76
72
|
expect(market.every((t) => t.group === 'market')).toBe(true);
|
|
77
73
|
expect(market.map((t) => t.name)).toContain('kaleidoswap_get_quote');
|
|
78
|
-
expect(market.map((t) => t.name)).not.toContain('
|
|
74
|
+
expect(market.map((t) => t.name)).not.toContain('kaleidoswap_atomic_init');
|
|
79
75
|
});
|
|
80
76
|
|
|
81
77
|
it('combines multiple groups', () => {
|
|
82
|
-
const
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
78
|
+
const readPlusLiquidity = kaleidoswapTools({ groups: ['market', 'liquidity'] });
|
|
79
|
+
expect(readPlusLiquidity.some((t) => t.name === 'kaleidoswap_atomic_init')).toBe(false);
|
|
80
|
+
expect(readPlusLiquidity.some((t) => t.name === 'kaleidoswap_lsp_create_asset_channel')).toBe(true);
|
|
85
81
|
});
|
|
86
82
|
});
|
|
87
83
|
|
|
@@ -92,9 +88,6 @@ describe('bindKaleidoswapTools', () => {
|
|
|
92
88
|
kaleidoswap_get_pairs: async () => ({ ok: true, tool: 'get_pairs' }),
|
|
93
89
|
kaleidoswap_get_quote: async (a) => ({ ok: true, tool: 'get_quote', args: a }),
|
|
94
90
|
kaleidoswap_get_nodeinfo: async () => ({ ok: true, tool: 'get_nodeinfo' }),
|
|
95
|
-
kaleidoswap_place_order: async (a) => ({ ok: true, tool: 'place_order', args: a }),
|
|
96
|
-
kaleidoswap_get_order_status: async (a) => ({ ok: true, tool: 'get_order_status', args: a }),
|
|
97
|
-
kaleidoswap_get_order_history: async (a) => ({ ok: true, tool: 'get_order_history', args: a }),
|
|
98
91
|
kaleidoswap_atomic_init: async (a) => ({ ok: true, tool: 'atomic_init', args: a }),
|
|
99
92
|
kaleidoswap_atomic_execute: async (a) => ({ ok: true, tool: 'atomic_execute', args: a }),
|
|
100
93
|
kaleidoswap_atomic_status: async (a) => ({ ok: true, tool: 'atomic_status', args: a }),
|
|
@@ -110,7 +103,7 @@ describe('bindKaleidoswapTools', () => {
|
|
|
110
103
|
|
|
111
104
|
it('preserves descriptions and the spend gate (requiresConfirmation)', () => {
|
|
112
105
|
const src = bindKaleidoswapTools(echoHandlers());
|
|
113
|
-
const place = src.listTools().find((t) => t.name === '
|
|
106
|
+
const place = src.listTools().find((t) => t.name === 'kaleidoswap_atomic_init');
|
|
114
107
|
const pairs = src.listTools().find((t) => t.name === 'kaleidoswap_get_pairs');
|
|
115
108
|
expect(place?.requiresConfirmation).toBe(true);
|
|
116
109
|
expect(pairs?.requiresConfirmation).toBeFalsy();
|
|
@@ -141,7 +134,6 @@ describe('bindKaleidoswapTools', () => {
|
|
|
141
134
|
const src = bindKaleidoswapTools(echoHandlers(), { groups: ['market'] });
|
|
142
135
|
const names = src.listTools().map((t) => t.name);
|
|
143
136
|
expect(names).toContain('kaleidoswap_get_quote');
|
|
144
|
-
expect(names).not.toContain('kaleidoswap_place_order');
|
|
145
137
|
expect(names).not.toContain('kaleidoswap_atomic_init');
|
|
146
138
|
});
|
|
147
139
|
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* - eval → stub handlers, also via `bindKaleidoswapTools`
|
|
10
10
|
*
|
|
11
11
|
* Because the schemas are identical everywhere, skills are portable and the
|
|
12
|
-
* model comparison is honest. Tools are grouped (`market`, `
|
|
12
|
+
* model comparison is honest. Tools are grouped (`market`, `atomic`,
|
|
13
13
|
* `liquidity`) so a host can expose a read-only subset for sandbox/eval modes.
|
|
14
14
|
*
|
|
15
|
-
* Spend tools (
|
|
15
|
+
* Spend tools (init/execute an atomic swap, buy an asset channel) carry
|
|
16
16
|
* `spend: true` → `requiresConfirmation: true`, so the Engine always pauses
|
|
17
17
|
* for the host's confirm gate before executing.
|
|
18
18
|
*
|
|
@@ -24,7 +24,7 @@ import { InProcessToolSource } from '../tools/in-process.js';
|
|
|
24
24
|
import type { InProcessTool } from '../tools/in-process.js';
|
|
25
25
|
|
|
26
26
|
/** Functional grouping for selective binding (e.g. read-only sandbox). */
|
|
27
|
-
export type KaleidoswapGroup = 'market' | '
|
|
27
|
+
export type KaleidoswapGroup = 'market' | 'atomic' | 'liquidity';
|
|
28
28
|
|
|
29
29
|
export interface KaleidoswapToolDef extends ToolDef {
|
|
30
30
|
/** Functional group — lets a host expose a subset. */
|
|
@@ -70,7 +70,7 @@ export const KALEIDOSWAP_TOOLS: KaleidoswapToolDef[] = [
|
|
|
70
70
|
|
|
71
71
|
t('market',
|
|
72
72
|
'kaleidoswap_get_quote',
|
|
73
|
-
'Get an executable quote for swapping a specific amount on one pair. Returns a quote id (use it with
|
|
73
|
+
'Get an executable quote for swapping a specific amount on one pair. Returns a quote id (use it with atomic_init), the expected receive amount, fees, slippage, and how long the quote is valid for. Re-quote rather than reusing a stale id.',
|
|
74
74
|
{
|
|
75
75
|
from_asset: { type: 'string', description: 'Asset to spend, e.g. "BTC" or "USDT".' },
|
|
76
76
|
to_asset: { type: 'string', description: 'Asset to receive, e.g. "USDT" or "BTC".' },
|
|
@@ -83,33 +83,6 @@ export const KALEIDOSWAP_TOOLS: KaleidoswapToolDef[] = [
|
|
|
83
83
|
'kaleidoswap_get_nodeinfo',
|
|
84
84
|
"Get info about the maker's Lightning node — pubkey, host, port, connect URI. Useful before opening a channel or when the user wants to see the counterparty. No args."),
|
|
85
85
|
|
|
86
|
-
// ─── orders (orderbook / market-order flow) ─────────────────────────────
|
|
87
|
-
t('orders',
|
|
88
|
-
'kaleidoswap_place_order',
|
|
89
|
-
'Place an order using an executable quote. Returns order_id + access_token (save the token — required for get_order_status). SPEND: the host pauses for user confirmation before the maker is called. Use only after kaleidoswap_get_quote and only when the user has explicitly approved the amount + destination.',
|
|
90
|
-
{
|
|
91
|
-
quote_id: { type: 'string', description: 'The quote id returned by kaleidoswap_get_quote (must still be valid).' },
|
|
92
|
-
},
|
|
93
|
-
['quote_id'],
|
|
94
|
-
/* spend */ true),
|
|
95
|
-
|
|
96
|
-
t('orders',
|
|
97
|
-
'kaleidoswap_get_order_status',
|
|
98
|
-
'Check the status of an order by id — pending / settling / completed / failed. Poll this after place_order until the order settles. Requires the access_token returned by place_order for authenticated orders.',
|
|
99
|
-
{
|
|
100
|
-
order_id: { type: 'string', description: 'The order id returned by kaleidoswap_place_order.' },
|
|
101
|
-
access_token: { type: 'string', description: 'The per-order access token returned by kaleidoswap_place_order. Required for status checks on the order.' },
|
|
102
|
-
},
|
|
103
|
-
['order_id', 'access_token']),
|
|
104
|
-
|
|
105
|
-
t('orders',
|
|
106
|
-
'kaleidoswap_get_order_history',
|
|
107
|
-
"Get the user's recent KaleidoSwap orders for context (last N, paginated). Read-only.",
|
|
108
|
-
{
|
|
109
|
-
limit: { type: 'number', description: 'Max rows (default 20, max 100).' },
|
|
110
|
-
cursor: { type: 'string', description: 'Pagination cursor from a previous call.' },
|
|
111
|
-
}),
|
|
112
|
-
|
|
113
86
|
// ─── atomic (the trust-minimised swap chain — used by the recipe) ───────
|
|
114
87
|
t('atomic',
|
|
115
88
|
'kaleidoswap_atomic_init',
|
|
@@ -205,7 +178,6 @@ export interface BindKaleidoswapOptions {
|
|
|
205
178
|
*
|
|
206
179
|
* const source = bindKaleidoswapTools({
|
|
207
180
|
* kaleidoswap_get_quote: async (args) => makerSdk.quote(args),
|
|
208
|
-
* kaleidoswap_place_order: async ({ quote_id }) => makerSdk.placeOrder({ quoteId: quote_id }),
|
|
209
181
|
* // …
|
|
210
182
|
* });
|
|
211
183
|
* tools.register(source);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createOpenAICompatibleProvider, encodeToolCalls, toWireMessages } from './openai.js';
|
|
3
|
+
|
|
4
|
+
function sse(chunks: unknown[]): Response {
|
|
5
|
+
const text = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n';
|
|
6
|
+
const bytes = new TextEncoder().encode(text);
|
|
7
|
+
// Split mid-line to exercise buffering.
|
|
8
|
+
const body = new ReadableStream<Uint8Array>({
|
|
9
|
+
start(ctl) {
|
|
10
|
+
ctl.enqueue(bytes.slice(0, 17));
|
|
11
|
+
ctl.enqueue(bytes.slice(17));
|
|
12
|
+
ctl.close();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
return new Response(body, { status: 200 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fakeFetch(responses: Response[]) {
|
|
19
|
+
const bodies: any[] = [];
|
|
20
|
+
const fn = (async (_url: string, init: RequestInit) => {
|
|
21
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
22
|
+
return responses.shift()!;
|
|
23
|
+
}) as unknown as typeof fetch;
|
|
24
|
+
return { fn, bodies };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const balanceTool = {
|
|
28
|
+
name: 'get_balance',
|
|
29
|
+
description: 'Wallet balance',
|
|
30
|
+
parameters: { type: 'object', properties: { account: { type: 'string', enum: ['rln', 'spark'] } }, required: ['account'] },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('createOpenAICompatibleProvider', () => {
|
|
34
|
+
it('streams text and reports usage', async () => {
|
|
35
|
+
const { fn, bodies } = fakeFetch([
|
|
36
|
+
sse([
|
|
37
|
+
{ choices: [{ delta: { content: 'Hel' } }] },
|
|
38
|
+
{ choices: [{ delta: { content: 'lo' }, finish_reason: 'stop' }] },
|
|
39
|
+
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } },
|
|
40
|
+
]),
|
|
41
|
+
]);
|
|
42
|
+
const p = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1/', model: 'm', fetch: fn, defaultTemperature: 0.1 });
|
|
43
|
+
const tokens: string[] = [];
|
|
44
|
+
const out = await p.runTurn({ system: 'S', messages: [{ role: 'user', content: 'hi' }], tools: [], onToken: (t) => tokens.push(t) });
|
|
45
|
+
expect(out.text).toBe('Hello');
|
|
46
|
+
expect(tokens).toEqual(['Hel', 'lo']);
|
|
47
|
+
expect(out.inference).toMatchObject({ status: 'completed', promptTokens: 12, completionTokens: 2, stopReason: 'stop' });
|
|
48
|
+
expect(bodies[0]).toMatchObject({ model: 'm', stream: true, temperature: 0.1, messages: [{ role: 'system', content: 'S' }, { role: 'user', content: 'hi' }] });
|
|
49
|
+
expect(bodies[0].tools).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('assembles streamed tool-call fragments and sends tools + tool_choice', async () => {
|
|
53
|
+
const { fn, bodies } = fakeFetch([
|
|
54
|
+
sse([
|
|
55
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'get_balance', arguments: '{"acc' } }] } }] },
|
|
56
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: 'ount":"rln"}' } }] }, finish_reason: 'tool_calls' }] },
|
|
57
|
+
]),
|
|
58
|
+
]);
|
|
59
|
+
const p = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1', model: 'm', fetch: fn });
|
|
60
|
+
const out = await p.runTurn({ messages: [{ role: 'user', content: 'balance' }], tools: [balanceTool], toolChoice: 'required' });
|
|
61
|
+
expect(out.toolCalls).toEqual([{ id: 'c1', name: 'get_balance', arguments: { account: 'rln' } }]);
|
|
62
|
+
expect(out.rawContent).toBe('<tool_call>{"name":"get_balance","arguments":{"account":"rln"}}</tool_call>');
|
|
63
|
+
expect(bodies[0].tool_choice).toBe('required');
|
|
64
|
+
expect(bodies[0].tools[0]).toEqual({ type: 'function', function: { name: 'get_balance', description: 'Wallet balance', parameters: balanceTool.parameters } });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('maps a named tool choice and reports unparseable arguments as toolErrors', async () => {
|
|
68
|
+
const { fn, bodies } = fakeFetch([
|
|
69
|
+
sse([{ choices: [{ delta: { tool_calls: [{ index: 0, function: { name: 'get_balance', arguments: '{"account":"rl' } }] }, finish_reason: 'length' }] }]),
|
|
70
|
+
]);
|
|
71
|
+
const p = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1', model: 'm', fetch: fn });
|
|
72
|
+
const out = await p.runTurn({ messages: [{ role: 'user', content: 'b' }], tools: [balanceTool], toolChoice: 'get_balance' });
|
|
73
|
+
expect(bodies[0].tool_choice).toEqual({ type: 'function', function: { name: 'get_balance' } });
|
|
74
|
+
expect(out.toolCalls).toEqual([]);
|
|
75
|
+
expect(out.toolErrors?.[0]).toMatchObject({ code: 'PARSE_ERROR' });
|
|
76
|
+
expect(out.inference?.status).toBe('truncated');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('works without streaming', async () => {
|
|
80
|
+
const res = new Response(
|
|
81
|
+
JSON.stringify({ choices: [{ message: { content: null, tool_calls: [{ id: 'z', type: 'function', function: { name: 'get_balance', arguments: '{"account":"spark"}' } }] }, finish_reason: 'tool_calls' }] }),
|
|
82
|
+
);
|
|
83
|
+
const { fn, bodies } = fakeFetch([res]);
|
|
84
|
+
const p = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1', model: 'm', fetch: fn, stream: false, apiKey: 'k' });
|
|
85
|
+
const out = await p.runTurn({ messages: [{ role: 'user', content: 'b' }], tools: [balanceTool] });
|
|
86
|
+
expect(out.toolCalls).toEqual([{ id: 'z', name: 'get_balance', arguments: { account: 'spark' } }]);
|
|
87
|
+
expect(bodies[0].stream).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws on an HTTP error and returns a cancelled turn on abort', async () => {
|
|
91
|
+
const p1 = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1', model: 'm', fetch: fakeFetch([new Response('nope', { status: 500 })]).fn });
|
|
92
|
+
await expect(p1.runTurn({ messages: [{ role: 'user', content: 'b' }], tools: [] })).rejects.toThrow(/500/);
|
|
93
|
+
|
|
94
|
+
const abortingFetch = ((_u: string, init: RequestInit) =>
|
|
95
|
+
new Promise((_, reject) => init.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))) as unknown as typeof fetch;
|
|
96
|
+
const p2 = createOpenAICompatibleProvider({ baseUrl: 'http://x/v1', model: 'm', fetch: abortingFetch });
|
|
97
|
+
const ctl = new AbortController();
|
|
98
|
+
const pending = p2.runTurn({ messages: [{ role: 'user', content: 'b' }], tools: [], signal: ctl.signal });
|
|
99
|
+
ctl.abort();
|
|
100
|
+
expect((await pending).inference?.status).toBe('cancelled');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('toWireMessages', () => {
|
|
105
|
+
it('rebuilds tool_calls / tool_call_id pairs from engine history', () => {
|
|
106
|
+
const raw = encodeToolCalls('Checking.', [
|
|
107
|
+
{ name: 'get_balance', arguments: { account: 'rln' } },
|
|
108
|
+
{ name: 'get_balance', arguments: { account: 'spark' } },
|
|
109
|
+
]);
|
|
110
|
+
const wire = toWireMessages(
|
|
111
|
+
[
|
|
112
|
+
{ role: 'user', content: 'balances?' },
|
|
113
|
+
{ role: 'assistant', content: raw },
|
|
114
|
+
{ role: 'tool', content: '{"sats":1}' },
|
|
115
|
+
{ role: 'tool', content: '{"sats":2}' },
|
|
116
|
+
{ role: 'tool', content: '{"error":"unreadable call"}' },
|
|
117
|
+
],
|
|
118
|
+
'SYS',
|
|
119
|
+
);
|
|
120
|
+
expect(wire[0]).toEqual({ role: 'system', content: 'SYS' });
|
|
121
|
+
expect(wire[2]).toMatchObject({ role: 'assistant', content: 'Checking.', tool_calls: [{ id: 'call_1_0' }, { id: 'call_1_1' }] });
|
|
122
|
+
expect((wire[2] as any).tool_calls[1].function).toEqual({ name: 'get_balance', arguments: '{"account":"spark"}' });
|
|
123
|
+
expect(wire[3]).toEqual({ role: 'tool', content: '{"sats":1}', tool_call_id: 'call_1_0' });
|
|
124
|
+
expect(wire[4]).toEqual({ role: 'tool', content: '{"sats":2}', tool_call_id: 'call_1_1' });
|
|
125
|
+
expect(wire[5]).toEqual({ role: 'user', content: 'Tool result: {"error":"unreadable call"}' });
|
|
126
|
+
});
|
|
127
|
+
});
|