@chatpanel/events 0.63.0 → 0.65.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/index.js +13 -1
- package/mcp-errors.js +19 -0
- package/meeting-shape.js +262 -0
- package/meeting-text.js +5 -46
- package/package.json +20 -6
- package/recipe.js +264 -0
- package/tool-discovery.js +87 -0
- package/tool-result.js +438 -0
- package/tool-round.js +0 -0
- package/tool-schema.js +155 -0
- package/tool-traits.js +101 -0
package/recipe.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Recipes — a multi-step tool workflow written down once and run by name, with no model
|
|
2
|
+
// in the loop.
|
|
3
|
+
//
|
|
4
|
+
// Skills are prompts: they tell a model what to do and the model decides which tools to
|
|
5
|
+
// call. A recipe is the other thing — the DECISION already made, as data: "open_bug" is
|
|
6
|
+
// `create_issue` with `labels: ["bug"]` baked in and `title`/`body` filled from the
|
|
7
|
+
// caller; "triage_pair" fetches two issues at once; "search_then_read" searches, then
|
|
8
|
+
// reads the first hit. It runs identically every time, from any client, with no tokens
|
|
9
|
+
// spent deciding, and it is the shape the harness spine calls class R: declarative,
|
|
10
|
+
// permissioned, never agent-written code. Amendment A2 asked for automations to be
|
|
11
|
+
// authored conversationally and approved before they run; this is what gets approved.
|
|
12
|
+
//
|
|
13
|
+
// Four modes, matching the primitives a round already has: `call` (one tool),
|
|
14
|
+
// `parallel` (independent calls, run through tool-round.js so reads overlap), `batch` (one
|
|
15
|
+
// tool, many argument sets) and `pipeline` (each step may map values out of the previous
|
|
16
|
+
// step's result with `inputMapping`: "$text" for its text, "$json" for it parsed,
|
|
17
|
+
// "$json.items.0.key" for a path inside).
|
|
18
|
+
//
|
|
19
|
+
// Two properties are load-bearing:
|
|
20
|
+
// • DRY RUN before side effects — `dryRunRecipe` resolves every parameter and names
|
|
21
|
+
// every unknown tool, missing required field and destructive call without executing
|
|
22
|
+
// anything. It is the propose step of propose → approve → activate.
|
|
23
|
+
// • PARTIAL FAILURE IS RECOVERABLE — a parallel/batch run returns every sibling's result
|
|
24
|
+
// plus `failedIndexes`; a pipeline stops at the failing step and returns the outputs
|
|
25
|
+
// of the steps before it, plus which mappings resolved and which did not. Nothing is
|
|
26
|
+
// retried blindly, and nothing that succeeded is lost.
|
|
27
|
+
//
|
|
28
|
+
// Pure. Execution, tool specs and traits are injected; nothing here knows a model, a
|
|
29
|
+
// window, or where recipes are stored.
|
|
30
|
+
|
|
31
|
+
import { runToolRound } from './tool-round.js';
|
|
32
|
+
import { toolTraits, needsConfirmation } from './tool-traits.js';
|
|
33
|
+
|
|
34
|
+
export const RECIPE_MODES = Object.freeze(['call', 'parallel', 'batch', 'pipeline']);
|
|
35
|
+
|
|
36
|
+
export class RecipeError extends Error {
|
|
37
|
+
constructor(code, message) { super(message); this.name = 'RecipeError'; this.code = code; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
const isParamRef = (v) => isRecord(v) && typeof v.$param === 'string' && Object.keys(v).every((k) => k === '$param' || k === 'default');
|
|
42
|
+
|
|
43
|
+
/** Every `{ "$param": name }` a recipe reads, in first-seen order. */
|
|
44
|
+
export function recipeParams(recipe) {
|
|
45
|
+
const names = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const walk = (v) => {
|
|
48
|
+
if (isParamRef(v)) { if (!seen.has(v.$param)) { seen.add(v.$param); names.push({ name: v.$param, required: !('default' in v), default: v.default }); } return; }
|
|
49
|
+
if (Array.isArray(v)) v.forEach(walk);
|
|
50
|
+
else if (isRecord(v)) Object.values(v).forEach(walk);
|
|
51
|
+
};
|
|
52
|
+
walk(recipe);
|
|
53
|
+
return names;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Structural validity — the errors a person authoring one needs to see. */
|
|
57
|
+
export function validateRecipe(recipe) {
|
|
58
|
+
const errors = [];
|
|
59
|
+
if (!isRecord(recipe)) return { ok: false, errors: ['recipe must be an object'] };
|
|
60
|
+
if (!recipe.name || typeof recipe.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/i.test(recipe.name)) errors.push('name: a short identifier (letters, digits, _ -) is required');
|
|
61
|
+
if (!RECIPE_MODES.includes(recipe.mode)) errors.push(`mode: one of ${RECIPE_MODES.join(', ')}`);
|
|
62
|
+
const call = (c, where) => {
|
|
63
|
+
if (!isRecord(c)) { errors.push(`${where}: must be an object`); return; }
|
|
64
|
+
if (!c.tool || typeof c.tool !== 'string') errors.push(`${where}.tool: required`);
|
|
65
|
+
if (c.arguments !== undefined && !isRecord(c.arguments)) errors.push(`${where}.arguments: must be an object`);
|
|
66
|
+
if (c.inputMapping !== undefined && (!isRecord(c.inputMapping) || Object.values(c.inputMapping).some((m) => typeof m !== 'string' || !/^\$(text|json)(\.[^.\s]+)*$/.test(m)))) {
|
|
67
|
+
errors.push(`${where}.inputMapping: values must be "$text", "$json" or "$json.<path>"`);
|
|
68
|
+
}
|
|
69
|
+
if (c.onMappingMissing !== undefined && !['continue', 'fail'].includes(c.onMappingMissing)) errors.push(`${where}.onMappingMissing: "continue" or "fail"`);
|
|
70
|
+
};
|
|
71
|
+
switch (recipe.mode) {
|
|
72
|
+
case 'call': call(recipe, 'recipe'); break;
|
|
73
|
+
case 'parallel':
|
|
74
|
+
if (!Array.isArray(recipe.calls) || !recipe.calls.length) errors.push('calls: a non-empty array');
|
|
75
|
+
else recipe.calls.forEach((c, i) => call(c, `calls[${i}]`));
|
|
76
|
+
break;
|
|
77
|
+
case 'batch':
|
|
78
|
+
if (!recipe.tool || typeof recipe.tool !== 'string') errors.push('tool: required');
|
|
79
|
+
if (!Array.isArray(recipe.items) || !recipe.items.length) errors.push('items: a non-empty array');
|
|
80
|
+
else recipe.items.forEach((it, i) => { if (!isRecord(it) || !isRecord(it.arguments)) errors.push(`items[${i}].arguments: must be an object`); });
|
|
81
|
+
break;
|
|
82
|
+
case 'pipeline':
|
|
83
|
+
if (!Array.isArray(recipe.steps) || !recipe.steps.length) errors.push('steps: a non-empty array');
|
|
84
|
+
else {
|
|
85
|
+
recipe.steps.forEach((s, i) => call(s, `steps[${i}]`));
|
|
86
|
+
if (isRecord(recipe.steps[0]) && recipe.steps[0].inputMapping) errors.push('steps[0].inputMapping: the first step has no previous result');
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
default: break;
|
|
90
|
+
}
|
|
91
|
+
return { ok: errors.length === 0, errors };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Substitute parameters. Returns the plan — a list of concrete calls in the recipe's mode
|
|
96
|
+
* — and the names of any parameters missing without a default. A missing parameter is
|
|
97
|
+
* reported, not thrown: the caller decides whether to ask for it or fail.
|
|
98
|
+
*/
|
|
99
|
+
export function expandRecipe(recipe, params = {}) {
|
|
100
|
+
const v = validateRecipe(recipe);
|
|
101
|
+
if (!v.ok) return { ok: false, errors: v.errors, missing: [], plan: null };
|
|
102
|
+
const missing = new Set();
|
|
103
|
+
const fill = (x) => {
|
|
104
|
+
if (isParamRef(x)) {
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(params, x.$param) && params[x.$param] !== undefined) return params[x.$param];
|
|
106
|
+
if ('default' in x) return x.default;
|
|
107
|
+
missing.add(x.$param);
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(x)) return x.map(fill);
|
|
111
|
+
if (isRecord(x)) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, val] of Object.entries(x)) { const f = fill(val); if (f !== undefined) out[k] = f; }
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
return x;
|
|
117
|
+
};
|
|
118
|
+
const one = (c) => ({ tool: c.tool, arguments: fill(c.arguments || {}), ...(c.inputMapping ? { inputMapping: { ...c.inputMapping } } : {}), ...(c.onMappingMissing ? { onMappingMissing: c.onMappingMissing } : {}) });
|
|
119
|
+
let calls;
|
|
120
|
+
switch (recipe.mode) {
|
|
121
|
+
case 'call': calls = [one(recipe)]; break;
|
|
122
|
+
case 'parallel': calls = recipe.calls.map(one); break;
|
|
123
|
+
case 'batch': calls = recipe.items.map((it) => ({ tool: recipe.tool, arguments: fill(it.arguments) })); break;
|
|
124
|
+
case 'pipeline': calls = recipe.steps.map(one); break;
|
|
125
|
+
default: calls = [];
|
|
126
|
+
}
|
|
127
|
+
return { ok: missing.size === 0, errors: [], missing: [...missing], plan: { name: recipe.name, mode: recipe.mode, calls } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── mapping ──────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const textOf = (r) => (typeof r === 'string' ? r : r && typeof r === 'object' && typeof r.text === 'string' ? r.text : r == null ? '' : JSON.stringify(r));
|
|
133
|
+
|
|
134
|
+
function parseJson(text) {
|
|
135
|
+
try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false }; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve one step's `inputMapping` against the previous result.
|
|
140
|
+
* @returns `{ arguments, mapped: { [arg]: value }, skipped: [{ arg, expr, reason }] }`
|
|
141
|
+
*/
|
|
142
|
+
export function mapInput(previous, mapping = {}, base = {}) {
|
|
143
|
+
const args = { ...base };
|
|
144
|
+
const mapped = {};
|
|
145
|
+
const skipped = [];
|
|
146
|
+
const text = textOf(previous);
|
|
147
|
+
let json;
|
|
148
|
+
for (const [arg, expr] of Object.entries(mapping || {})) {
|
|
149
|
+
if (expr === '$text') { args[arg] = text; mapped[arg] = text; continue; }
|
|
150
|
+
if (!expr.startsWith('$json')) { skipped.push({ arg, expr, reason: 'unknown expression' }); continue; }
|
|
151
|
+
if (json === undefined) json = parseJson(text);
|
|
152
|
+
if (!json.ok) { skipped.push({ arg, expr, reason: 'previous result is not JSON' }); continue; }
|
|
153
|
+
let cur = json.value;
|
|
154
|
+
const path = expr.slice(5).split('.').filter(Boolean);
|
|
155
|
+
let found = true;
|
|
156
|
+
for (const seg of path) {
|
|
157
|
+
if (Array.isArray(cur) && /^\d+$/.test(seg)) cur = cur[Number(seg)];
|
|
158
|
+
else if (isRecord(cur) && Object.prototype.hasOwnProperty.call(cur, seg)) cur = cur[seg];
|
|
159
|
+
else { found = false; break; }
|
|
160
|
+
}
|
|
161
|
+
if (!found || cur === undefined) { skipped.push({ arg, expr, reason: 'path not found in previous result' }); continue; }
|
|
162
|
+
args[arg] = cur;
|
|
163
|
+
mapped[arg] = cur;
|
|
164
|
+
}
|
|
165
|
+
return { arguments: args, mapped, skipped };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── dry run ──────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Everything a person should know before approving: unknown tools, missing required
|
|
172
|
+
* fields, destructive calls, and which arguments a pipeline will only learn at run time.
|
|
173
|
+
*
|
|
174
|
+
* @param specs the tool specs the runtime will execute against (for existence and
|
|
175
|
+
* required fields); omit to skip those checks
|
|
176
|
+
* @param traitsOf `(tool) => traits`; defaults to toolTraits on the spec or name
|
|
177
|
+
*/
|
|
178
|
+
export function dryRunRecipe(recipe, params = {}, { specs = null, traitsOf = null } = {}) {
|
|
179
|
+
const ex = expandRecipe(recipe, params);
|
|
180
|
+
if (!ex.plan) return { ok: false, errors: ex.errors, missing: ex.missing, calls: [], warnings: [], destructive: [] };
|
|
181
|
+
const byName = specs ? new Map(specs.filter((s) => s?.name).map((s) => [s.name, s])) : null;
|
|
182
|
+
const traits = traitsOf || ((tool) => toolTraits(byName?.get(tool) || tool));
|
|
183
|
+
const warnings = [];
|
|
184
|
+
const destructive = [];
|
|
185
|
+
const calls = ex.plan.calls.map((c, i) => {
|
|
186
|
+
const spec = byName?.get(c.tool);
|
|
187
|
+
const known = byName ? !!spec : null;
|
|
188
|
+
const t = traits(c.tool);
|
|
189
|
+
const mappedLater = new Set(Object.keys(c.inputMapping || {}));
|
|
190
|
+
const required = Array.isArray(spec?.parameters?.required) ? spec.parameters.required : Array.isArray(spec?.inputSchema?.required) ? spec.inputSchema.required : [];
|
|
191
|
+
const missingRequired = required.filter((k) => c.arguments[k] === undefined && !mappedLater.has(k));
|
|
192
|
+
const row = { index: i, tool: c.tool, arguments: c.arguments, known, traits: t, missingRequired, mappedLater: [...mappedLater] };
|
|
193
|
+
if (known === false) warnings.push({ index: i, code: 'unknown_tool', message: `No tool named "${c.tool}" is available.` });
|
|
194
|
+
if (missingRequired.length) warnings.push({ index: i, code: 'missing_required', message: `"${c.tool}" needs ${missingRequired.join(', ')}.` });
|
|
195
|
+
if (needsConfirmation(t)) { destructive.push(i); warnings.push({ index: i, code: 'destructive', message: `"${c.tool}" is destructive.` }); }
|
|
196
|
+
if (ex.plan.mode === 'pipeline' && i > 0 && !mappedLater.size) warnings.push({ index: i, code: 'no_mapping', message: `steps[${i}] uses nothing from the previous step; was that intended?` });
|
|
197
|
+
return row;
|
|
198
|
+
});
|
|
199
|
+
const blocking = ex.missing.length > 0 || warnings.some((w) => w.code === 'unknown_tool' || w.code === 'missing_required');
|
|
200
|
+
return { ok: !blocking, errors: [], missing: ex.missing, plan: ex.plan, calls, warnings, destructive };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── run ──────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const defaultIsError = (r) => {
|
|
206
|
+
if (r == null) return false;
|
|
207
|
+
if (typeof r === 'string') return /^error:/i.test(r) || /^\{\s*"error"/.test(r);
|
|
208
|
+
return typeof r === 'object' && (r.error != null || r.isError === true);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Run an expanded plan. `execute(tool, arguments, meta)` is the host's toolset executor —
|
|
213
|
+
* every guard, redaction and confirmation it wraps applies unchanged, because a recipe is
|
|
214
|
+
* a way of composing calls, never a way around them.
|
|
215
|
+
*/
|
|
216
|
+
export async function runPlan(plan, { execute, traitsOf, concurrent, isError = defaultIsError, onStart, onDone } = {}) {
|
|
217
|
+
if (typeof execute !== 'function') throw new RecipeError('BAD_RUN', 'execute required');
|
|
218
|
+
if (!plan || !Array.isArray(plan.calls)) throw new RecipeError('BAD_PLAN', 'plan.calls required');
|
|
219
|
+
const meta = { recipe: plan.name, mode: plan.mode };
|
|
220
|
+
|
|
221
|
+
if (plan.mode === 'pipeline') {
|
|
222
|
+
const steps = [];
|
|
223
|
+
let previous;
|
|
224
|
+
for (let i = 0; i < plan.calls.length; i += 1) {
|
|
225
|
+
const c = plan.calls[i];
|
|
226
|
+
let args = c.arguments;
|
|
227
|
+
let mapped = {};
|
|
228
|
+
let skipped = [];
|
|
229
|
+
if (i > 0 && c.inputMapping) {
|
|
230
|
+
const m = mapInput(previous, c.inputMapping, c.arguments);
|
|
231
|
+
args = m.arguments; mapped = m.mapped; skipped = m.skipped;
|
|
232
|
+
if (skipped.length && c.onMappingMissing === 'fail') {
|
|
233
|
+
steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result: null, status: 'skipped' });
|
|
234
|
+
return { status: 'failed', failedStep: i, reason: 'mapping_missing', steps, finalResult: null };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
onStart?.({ name: c.tool, input: args }, i);
|
|
238
|
+
let result;
|
|
239
|
+
try { result = await execute(c.tool, args, { ...meta, step: i }); } catch (e) { result = { error: `Tool ${c.tool} failed: ${e?.message || e}` }; }
|
|
240
|
+
onDone?.({ name: c.tool, input: args }, i, result);
|
|
241
|
+
const failed = isError(result);
|
|
242
|
+
steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result, status: failed ? 'failed' : 'ok' });
|
|
243
|
+
if (failed) return { status: 'failed', failedStep: i, reason: 'tool_error', steps, finalResult: null };
|
|
244
|
+
previous = result;
|
|
245
|
+
}
|
|
246
|
+
return { status: 'completed', steps, finalResult: previous };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const calls = plan.calls.map((c) => ({ name: c.tool, input: c.arguments }));
|
|
250
|
+
const round = await runToolRound(calls, {
|
|
251
|
+
execute: (call, i) => execute(call.name, call.input, { ...meta, index: i }),
|
|
252
|
+
traitsOf: traitsOf ? (c) => traitsOf(c.name) : undefined,
|
|
253
|
+
concurrent, isError, onStart, onDone,
|
|
254
|
+
});
|
|
255
|
+
return { status: round.status, succeeded: round.succeeded, failed: round.failed, failedIndexes: round.failedIndexes, results: round.results, coalesced: round.coalesced };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Expand, then run. Missing parameters fail before anything executes. */
|
|
259
|
+
export async function runRecipe(recipe, params, options) {
|
|
260
|
+
const ex = expandRecipe(recipe, params);
|
|
261
|
+
if (!ex.plan) throw new RecipeError('INVALID', ex.errors.join('; '));
|
|
262
|
+
if (!ex.ok) throw new RecipeError('MISSING_PARAMS', `missing: ${ex.missing.join(', ')}`);
|
|
263
|
+
return runPlan(ex.plan, options);
|
|
264
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `find` — the way back to a tool the menu left out.
|
|
2
|
+
//
|
|
3
|
+
// Narrowing keeps a turn's tool list short by ranking the connected tools against the
|
|
4
|
+
// message and showing the top few. It keeps the EXECUTE map whole on purpose — a tool the
|
|
5
|
+
// model knows the name of still runs — but nothing told the model the names it was not
|
|
6
|
+
// shown. So a tool that ranked low was, for that turn, gone: the model could not ask for
|
|
7
|
+
// it because it did not know to.
|
|
8
|
+
//
|
|
9
|
+
// This is the missing half. A `find` action searches the FULL set — every tool the group
|
|
10
|
+
// owns, narrowed or not — and answers with names and one-liners, cheap enough to sit on
|
|
11
|
+
// every dispatcher and cheap enough to call on a hunch. The model discovers, then calls;
|
|
12
|
+
// the menu stays short; the capability decision the cap used to make silently is now a
|
|
13
|
+
// call away. `describe` already gives the full schema for one action, so the two together
|
|
14
|
+
// are the pay-as-you-go ladder: names → one line → full schema, each on demand.
|
|
15
|
+
//
|
|
16
|
+
// Ranking is injected. The lexical, IDF-weighted ranker the extension and gateway share
|
|
17
|
+
// lives in @chatpanel/pii; this package must stay dependency-free, so it takes `rank` and
|
|
18
|
+
// falls back to a plain token overlap when a host has none.
|
|
19
|
+
|
|
20
|
+
export const FIND_ACTION = 'find';
|
|
21
|
+
|
|
22
|
+
const STOP = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'use', 'can', 'you', 'your', 'from', 'what', 'how', 'are', 'get', 'find', 'tool', 'tools']);
|
|
23
|
+
|
|
24
|
+
const tokens = (s) => String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w));
|
|
25
|
+
|
|
26
|
+
/** Token overlap — a fallback, not a ranker anyone should prefer. */
|
|
27
|
+
export function overlapRank(specs, query) {
|
|
28
|
+
const q = new Set(tokens(query));
|
|
29
|
+
if (!q.size) return [...specs];
|
|
30
|
+
return specs
|
|
31
|
+
.map((s, i) => {
|
|
32
|
+
const hay = `${s.name} ${s.description || ''}`.toLowerCase();
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (const w of q) if (hay.includes(w)) n += 1;
|
|
35
|
+
return { s, i, n };
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => (b.n - a.n) || (a.i - b.i))
|
|
38
|
+
.map((x) => x.s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** First sentence, whitespace collapsed, capped — the "one line" of the ladder. */
|
|
42
|
+
export function oneLiner(description, max = 110) {
|
|
43
|
+
const s = String(description || '').replace(/\s+/g, ' ').trim();
|
|
44
|
+
const cut = s.search(/[.!?]\s|\n/);
|
|
45
|
+
const first = cut > 20 ? s.slice(0, cut + 1) : s;
|
|
46
|
+
return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const requiredOf = (spec) => {
|
|
50
|
+
const p = spec?.parameters || spec?.inputSchema;
|
|
51
|
+
return Array.isArray(p?.required) ? p.required.map(String) : [];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param specs the FULL set — not the narrowed menu
|
|
56
|
+
* @param rank `(specs, query) => specs` most-relevant first
|
|
57
|
+
* @returns `[{ name, summary, required }]`
|
|
58
|
+
*/
|
|
59
|
+
export function findTools(specs, query, { limit = 8, rank = overlapRank } = {}) {
|
|
60
|
+
const list = (specs || []).filter((s) => s && s.name);
|
|
61
|
+
const q = String(query || '').trim();
|
|
62
|
+
const ranked = q ? rank(list, q) : list;
|
|
63
|
+
const cap = Math.max(1, Math.min(50, Number(limit) || 8));
|
|
64
|
+
return ranked.slice(0, cap).map((s) => ({ name: s.name, summary: oneLiner(s.description), required: requiredOf(s) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The JSON text a `find` action returns, with the next step spelled out. */
|
|
68
|
+
export function findToolsResult(specs, query, { limit, rank, describeAction = 'describe', menu = [] } = {}) {
|
|
69
|
+
const found = findTools(specs, query, { limit, rank });
|
|
70
|
+
const total = (specs || []).length;
|
|
71
|
+
const hidden = new Set(menu.map((m) => (typeof m === 'string' ? m : m?.name)));
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
query: String(query || ''),
|
|
74
|
+
matches: found.map((f) => ({ ...f, listed: hidden.size ? hidden.has(f.name) : undefined })),
|
|
75
|
+
total,
|
|
76
|
+
hint: found.length
|
|
77
|
+
? `Call one as {"action":"<name>","args":{…}}. Unsure of its arguments? {"action":"${describeAction}","args":{"tool":"<name>"}}.`
|
|
78
|
+
: `No tool matched "${query}" among ${total}. Try other words, or describe the task differently.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The parameter schema fragment a dispatcher advertises for `find`. */
|
|
83
|
+
export function findActionArgs() {
|
|
84
|
+
return {
|
|
85
|
+
query: { type: 'string', description: `With action="${FIND_ACTION}": words describing the task; returns matching tool names.` },
|
|
86
|
+
};
|
|
87
|
+
}
|