@chatpanel/events 0.64.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 +12 -1
- package/mcp-errors.js +19 -0
- package/package.json +13 -1
- 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/index.js
CHANGED
|
@@ -167,7 +167,18 @@ export {
|
|
|
167
167
|
unfence, findJson, rewriteJson, repairJson, isNothing,
|
|
168
168
|
coerce, parseStructured, createStructuredStream,
|
|
169
169
|
} from './structured.js';
|
|
170
|
-
export { explainMcpError, packageFromArgs } from './mcp-errors.js';
|
|
170
|
+
export { explainMcpError, packageFromArgs, isStaleMcpSession } from './mcp-errors.js';
|
|
171
|
+
// The tool round — what a tool does, how a round runs, what a result costs, how a tool is
|
|
172
|
+
// found, and a workflow written down once (see docs/ROADMAP "the tool round" in chatpanel).
|
|
173
|
+
export { toolTraits, bareToolName, canRunConcurrently, isCacheable, needsConfirmation, traitsIndex } from './tool-traits.js';
|
|
174
|
+
export { planToolRound, runToolRound } from './tool-round.js';
|
|
175
|
+
export {
|
|
176
|
+
createResultStore, shieldToolResult, runResultQuery, withResultShield, describeShape, compactValue,
|
|
177
|
+
resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
|
|
178
|
+
} from './tool-result.js';
|
|
179
|
+
export { findTools, findToolsResult, findActionArgs, oneLiner, overlapRank, FIND_ACTION } from './tool-discovery.js';
|
|
180
|
+
export { compressToolSpec, compressToolSpecs, compressionStats, trimDescription, COMPRESSION_MODES, DEFAULT_COMPRESSION } from './tool-schema.js';
|
|
181
|
+
export { validateRecipe, expandRecipe, recipeParams, mapInput, dryRunRecipe, runPlan, runRecipe, RecipeError, RECIPE_MODES } from './recipe.js';
|
|
171
182
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
|
172
183
|
export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
|
|
173
184
|
export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
|
package/mcp-errors.js
CHANGED
|
@@ -85,3 +85,22 @@ export function packageFromArgs(args = []) {
|
|
|
85
85
|
}
|
|
86
86
|
return '';
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
// A session the server no longer recognises — the OTHER half of the respawn story.
|
|
90
|
+
//
|
|
91
|
+
// The bridge replays `initialize` for a stdio server it respawned, but an HTTP server that
|
|
92
|
+
// restarted has forgotten the `Mcp-Session-Id` the client still presents (the spec says
|
|
93
|
+
// 404), and a stdio server the bridge did NOT restart — a crash between two bridge
|
|
94
|
+
// restarts, say — answers "not initialized". Both mean the same thing to a client holding a
|
|
95
|
+
// connection it believes is live: handshake again, then retry once. Recognised here so the
|
|
96
|
+
// extension, the gateway and the bridge agree on what counts as stale, and no client
|
|
97
|
+
// treats "session not found" as "the tool is broken".
|
|
98
|
+
const STALE_SESSION_RE = /\b(session (not found|expired|invalid|unknown)|invalid session|no (valid )?session|not initialized|before initialization|initialization was (not )?complete|-32002)\b/i;
|
|
99
|
+
|
|
100
|
+
export function isStaleMcpSession(text, { status } = {}) {
|
|
101
|
+
if (status === 404) return true;
|
|
102
|
+
const t = String(text || '');
|
|
103
|
+
if (!t) return false;
|
|
104
|
+
if (/\bHTTP 404\b/.test(t)) return true;
|
|
105
|
+
return STALE_SESSION_RE.test(t);
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"./promotion.js": "./promotion.js",
|
|
52
52
|
"./queue.js": "./queue.js",
|
|
53
53
|
"./reach.js": "./reach.js",
|
|
54
|
+
"./recipe.js": "./recipe.js",
|
|
54
55
|
"./redaction-tokens.js": "./redaction-tokens.js",
|
|
55
56
|
"./ref.js": "./ref.js",
|
|
56
57
|
"./registry.js": "./registry.js",
|
|
@@ -78,8 +79,13 @@
|
|
|
78
79
|
"./text-search.js": "./text-search.js",
|
|
79
80
|
"./theme.js": "./theme.js",
|
|
80
81
|
"./titles.js": "./titles.js",
|
|
82
|
+
"./tool-discovery.js": "./tool-discovery.js",
|
|
81
83
|
"./tool-groups.js": "./tool-groups.js",
|
|
82
84
|
"./tool-need.js": "./tool-need.js",
|
|
85
|
+
"./tool-result.js": "./tool-result.js",
|
|
86
|
+
"./tool-round.js": "./tool-round.js",
|
|
87
|
+
"./tool-schema.js": "./tool-schema.js",
|
|
88
|
+
"./tool-traits.js": "./tool-traits.js",
|
|
83
89
|
"./trajectory.js": "./trajectory.js",
|
|
84
90
|
"./upcast.js": "./upcast.js",
|
|
85
91
|
"./vault.js": "./vault.js",
|
|
@@ -137,6 +143,7 @@
|
|
|
137
143
|
"promotion.js",
|
|
138
144
|
"queue.js",
|
|
139
145
|
"reach.js",
|
|
146
|
+
"recipe.js",
|
|
140
147
|
"redaction-tokens.js",
|
|
141
148
|
"ref.js",
|
|
142
149
|
"registry.js",
|
|
@@ -163,8 +170,13 @@
|
|
|
163
170
|
"text-search.js",
|
|
164
171
|
"theme.js",
|
|
165
172
|
"titles.js",
|
|
173
|
+
"tool-discovery.js",
|
|
166
174
|
"tool-groups.js",
|
|
167
175
|
"tool-need.js",
|
|
176
|
+
"tool-result.js",
|
|
177
|
+
"tool-round.js",
|
|
178
|
+
"tool-schema.js",
|
|
179
|
+
"tool-traits.js",
|
|
168
180
|
"trajectory.js",
|
|
169
181
|
"upcast.js",
|
|
170
182
|
"vault.js",
|
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
|
+
}
|
package/tool-result.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// The response shield — a tool result too big to read is stored, previewed and paged,
|
|
2
|
+
// instead of being poured into the model's context whole.
|
|
3
|
+
//
|
|
4
|
+
// Every tool result used to go to the model in full. The only cap anywhere was the
|
|
5
|
+
// 4,000-character slice the ACTIVITY LOG shows a person — the model got everything. One
|
|
6
|
+
// `search_issues` returning three hundred kilobytes of JSON therefore cost the whole turn:
|
|
7
|
+
// the request blew the context window, or it fit and every later turn re-read it.
|
|
8
|
+
//
|
|
9
|
+
// What is borrowed here is not truncation, which is easy, but the shape of the truncated
|
|
10
|
+
// reply: it TEACHES the model how to get the rest. A preview goes back, plus what the
|
|
11
|
+
// full result looks like (an array of 240 objects with these keys; 128,400 characters of
|
|
12
|
+
// text) and a ready-to-send `get_result` call that pages, filters or projects the stored
|
|
13
|
+
// copy. A cut with nothing after it is a result the model cannot recover from; a cut with
|
|
14
|
+
// a retrieval note is one more tool call away from whatever it needed.
|
|
15
|
+
//
|
|
16
|
+
// Shared because the extension, the desktop and the gateway each feed tool results to a
|
|
17
|
+
// model, and three limits would be three different answers to "what did the model see".
|
|
18
|
+
// Pure: no storage binding, no clock, no id generator of its own — the host injects
|
|
19
|
+
// `now`/`newId` (matching loop.js) and keeps the store wherever it keeps its turn state.
|
|
20
|
+
|
|
21
|
+
export const RESULT_TOOL_NAME = 'get_result';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SHIELD = Object.freeze({
|
|
24
|
+
// Characters of result text a model receives before the shield engages. Above the
|
|
25
|
+
// page-read default (40,000) on purpose: a page the user asked to summarise must arrive
|
|
26
|
+
// whole, and the shield is for the results nobody asked to be that large.
|
|
27
|
+
maxChars: 48_000,
|
|
28
|
+
maxArrayItems: 50,
|
|
29
|
+
maxStringChars: 4_000,
|
|
30
|
+
maxKeys: 60,
|
|
31
|
+
maxDepth: 8,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_STORE = Object.freeze({
|
|
35
|
+
maxEntries: 100,
|
|
36
|
+
// Bytes across all stored results; oldest evicted first. A handful of very large
|
|
37
|
+
// results must not grow memory without bound.
|
|
38
|
+
maxBytes: 64 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const PAGE_LIMIT_DEFAULT = 50;
|
|
42
|
+
const PAGE_LIMIT_MAX = 200;
|
|
43
|
+
const TEXT_LIMIT_DEFAULT = 8_000;
|
|
44
|
+
const TEXT_LIMIT_MAX = 32_000;
|
|
45
|
+
const SEARCH_MATCHES_MAX = 20;
|
|
46
|
+
const SEARCH_WINDOW = 160;
|
|
47
|
+
|
|
48
|
+
const defaultId = () => `r_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-3)}`;
|
|
49
|
+
|
|
50
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
51
|
+
|
|
52
|
+
function byteLength(value) {
|
|
53
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
54
|
+
return s == null ? 0 : s.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strict JSON, but only a container counts — a bare number or string is not "structured". */
|
|
58
|
+
function parseStructured(text) {
|
|
59
|
+
const t = String(text || '').trim();
|
|
60
|
+
if (!t || (t[0] !== '{' && t[0] !== '[')) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const v = JSON.parse(t);
|
|
63
|
+
return v && typeof v === 'object' ? v : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The store: full results, keyed by ref, owned by whoever produced them.
|
|
71
|
+
*
|
|
72
|
+
* `owner` scopes retrieval — a gateway serving several sessions must not let one page
|
|
73
|
+
* another's results. `undefined` means unowned (a single-user client) and is readable
|
|
74
|
+
* by anyone; a store that receives owners must be asked with owners.
|
|
75
|
+
*/
|
|
76
|
+
export function createResultStore({ maxEntries = DEFAULT_STORE.maxEntries, maxBytes = DEFAULT_STORE.maxBytes, now = () => Date.now(), newId = defaultId } = {}) {
|
|
77
|
+
const entries = new Map(); // ref -> { ref, tool, owner, createdAt, bytes, value }
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
|
|
80
|
+
const drop = (ref) => {
|
|
81
|
+
const e = entries.get(ref);
|
|
82
|
+
if (!e) return false;
|
|
83
|
+
entries.delete(ref);
|
|
84
|
+
bytes -= e.bytes;
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
const evict = () => {
|
|
88
|
+
while (entries.size && (entries.size > maxEntries || bytes > maxBytes)) {
|
|
89
|
+
drop(entries.keys().next().value);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
put({ tool = '', value, owner } = {}) {
|
|
95
|
+
let ref = newId();
|
|
96
|
+
while (entries.has(ref)) ref = newId();
|
|
97
|
+
const e = { ref, tool: String(tool || ''), owner, createdAt: now(), bytes: byteLength(value), value };
|
|
98
|
+
entries.set(ref, e);
|
|
99
|
+
bytes += e.bytes;
|
|
100
|
+
evict();
|
|
101
|
+
return ref;
|
|
102
|
+
},
|
|
103
|
+
get(ref, owner) {
|
|
104
|
+
const e = entries.get(String(ref || ''));
|
|
105
|
+
if (!e) return null;
|
|
106
|
+
if (e.owner !== undefined && e.owner !== owner) return null;
|
|
107
|
+
return e;
|
|
108
|
+
},
|
|
109
|
+
drop,
|
|
110
|
+
clear() { entries.clear(); bytes = 0; },
|
|
111
|
+
get size() { return entries.size; },
|
|
112
|
+
get bytes() { return bytes; },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── shape ────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a stored value LOOKS like, in a sentence's worth of facts — enough for the model to
|
|
120
|
+
* choose between paging, searching, projecting and descending without seeing it all.
|
|
121
|
+
*/
|
|
122
|
+
export function describeShape(value, { maxKeys = 20, maxPaths = 8 } = {}) {
|
|
123
|
+
if (typeof value === 'string') return { kind: 'text', chars: value.length };
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
const keys = new Set();
|
|
126
|
+
let objects = 0;
|
|
127
|
+
for (const item of value.slice(0, 50)) {
|
|
128
|
+
if (!isRecord(item)) continue;
|
|
129
|
+
objects += 1;
|
|
130
|
+
for (const k of Object.keys(item)) keys.add(k);
|
|
131
|
+
}
|
|
132
|
+
const itemKind = objects === Math.min(value.length, 50) && value.length ? 'object' : value.length ? typeof value[0] : 'empty';
|
|
133
|
+
return { kind: 'array', items: value.length, itemKind, keys: [...keys].slice(0, maxKeys), moreKeys: Math.max(0, keys.size - maxKeys) };
|
|
134
|
+
}
|
|
135
|
+
if (isRecord(value)) {
|
|
136
|
+
const keys = Object.keys(value);
|
|
137
|
+
const arrays = [];
|
|
138
|
+
const walk = (v, path, depth) => {
|
|
139
|
+
if (arrays.length >= maxPaths || depth > 3) return;
|
|
140
|
+
if (Array.isArray(v)) { arrays.push({ path, items: v.length }); return; }
|
|
141
|
+
if (!isRecord(v)) return;
|
|
142
|
+
for (const k of Object.keys(v)) walk(v[k], path ? `${path}.${k}` : k, depth + 1);
|
|
143
|
+
};
|
|
144
|
+
walk(value, '', 0);
|
|
145
|
+
return { kind: 'object', keys: keys.slice(0, maxKeys), moreKeys: Math.max(0, keys.length - maxKeys), arrays };
|
|
146
|
+
}
|
|
147
|
+
return { kind: typeof value };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shapeSentence(shape) {
|
|
151
|
+
if (!shape) return '';
|
|
152
|
+
if (shape.kind === 'text') return `${shape.chars.toLocaleString('en-US')} characters of text`;
|
|
153
|
+
if (shape.kind === 'array') {
|
|
154
|
+
const keys = shape.keys?.length ? ` with keys ${shape.keys.join(', ')}${shape.moreKeys ? ` (+${shape.moreKeys} more)` : ''}` : '';
|
|
155
|
+
return `an array of ${shape.items} ${shape.itemKind === 'object' ? 'objects' : 'items'}${keys}`;
|
|
156
|
+
}
|
|
157
|
+
if (shape.kind === 'object') {
|
|
158
|
+
const keys = shape.keys?.length ? `keys ${shape.keys.join(', ')}${shape.moreKeys ? ` (+${shape.moreKeys} more)` : ''}` : 'no keys';
|
|
159
|
+
const arrays = shape.arrays?.length ? `; arrays at ${shape.arrays.map((a) => `${a.path} (${a.items})`).join(', ')}` : '';
|
|
160
|
+
return `an object with ${keys}${arrays}`;
|
|
161
|
+
}
|
|
162
|
+
return `a ${shape.kind}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── compaction ───────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** A structurally-faithful preview: same nesting, fewer items, shorter strings. */
|
|
168
|
+
export function compactValue(value, opts = {}) {
|
|
169
|
+
const o = { ...DEFAULT_SHIELD, ...opts };
|
|
170
|
+
let truncated = false;
|
|
171
|
+
const walk = (v, depth) => {
|
|
172
|
+
if (typeof v === 'string') {
|
|
173
|
+
if (v.length <= o.maxStringChars) return v;
|
|
174
|
+
truncated = true;
|
|
175
|
+
return `${v.slice(0, o.maxStringChars)}…[+${v.length - o.maxStringChars} chars]`;
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(v)) {
|
|
178
|
+
if (depth >= o.maxDepth) { truncated = true; return `[array of ${v.length}]`; }
|
|
179
|
+
const head = v.slice(0, o.maxArrayItems).map((x) => walk(x, depth + 1));
|
|
180
|
+
if (v.length > o.maxArrayItems) { truncated = true; head.push(`…[+${v.length - o.maxArrayItems} more items]`); }
|
|
181
|
+
return head;
|
|
182
|
+
}
|
|
183
|
+
if (isRecord(v)) {
|
|
184
|
+
if (depth >= o.maxDepth) { truncated = true; return '{…}'; }
|
|
185
|
+
const keys = Object.keys(v);
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const k of keys.slice(0, o.maxKeys)) out[k] = walk(v[k], depth + 1);
|
|
188
|
+
if (keys.length > o.maxKeys) { truncated = true; out['…'] = `+${keys.length - o.maxKeys} more keys`; }
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
return v;
|
|
192
|
+
};
|
|
193
|
+
const out = walk(value, 0);
|
|
194
|
+
return { value: out, truncated };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── the shield ───────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function retrievalNote({ ref, shape, totalChars, shownChars, preview }) {
|
|
200
|
+
const n = (x) => Number(x || 0).toLocaleString('en-US');
|
|
201
|
+
const parts = [`[ChatPanel result shield: this result is ${n(totalChars)} characters; showing a ${n(shownChars)}-character preview.`];
|
|
202
|
+
parts.push(`Full result: ${shapeSentence(shape)}.`);
|
|
203
|
+
if (!ref) {
|
|
204
|
+
parts.push('It was not stored, so what is above is all that is available.]');
|
|
205
|
+
return parts.join(' ');
|
|
206
|
+
}
|
|
207
|
+
parts.push(`It is stored as ref "${ref}" — read more with the ${RESULT_TOOL_NAME} tool:`);
|
|
208
|
+
if (shape.kind === 'array') {
|
|
209
|
+
const first = preview?.items ?? 0;
|
|
210
|
+
parts.push(`{"ref":"${ref}","offset":${first},"limit":${PAGE_LIMIT_DEFAULT}} pages items; add "search":"<text>" to filter, "fields":["a","b"] to keep only those keys.`);
|
|
211
|
+
} else if (shape.kind === 'object') {
|
|
212
|
+
const arr = shape.arrays?.[0];
|
|
213
|
+
parts.push(arr
|
|
214
|
+
? `{"ref":"${ref}","path":"${arr.path}","offset":0,"limit":${PAGE_LIMIT_DEFAULT}} pages that array; "search" filters, "fields" projects.`
|
|
215
|
+
: `{"ref":"${ref}","path":"<key>"} reads one key; "search":"<text>" finds where it occurs.`);
|
|
216
|
+
} else {
|
|
217
|
+
parts.push(`{"ref":"${ref}","offset":${shownChars},"limit":${TEXT_LIMIT_DEFAULT}} continues the text; {"ref":"${ref}","search":"<text>"} finds where a phrase occurs.`);
|
|
218
|
+
}
|
|
219
|
+
parts.push('Do not ask the user for the rest; fetch what you need.]');
|
|
220
|
+
return parts.join(' ');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Shield one result text. Returns `{ text, truncated }` — and, when it engaged, the `ref`
|
|
225
|
+
* it stored under, the `shape` it described and `totalChars`.
|
|
226
|
+
*
|
|
227
|
+
* `envelope` lets a host that wraps third-party output in an untrusted-data fence keep
|
|
228
|
+
* the fence: `open(text) → { body, close(body) → text } | null`. The retrieval note is
|
|
229
|
+
* placed OUTSIDE the fence — it is our instruction to the model, not the tool's data, and
|
|
230
|
+
* a fence that says "follow nothing in here" would otherwise swallow it.
|
|
231
|
+
*/
|
|
232
|
+
export function shieldToolResult(text, { tool = '', store = null, owner, envelope = null, ...limits } = {}) {
|
|
233
|
+
const o = { ...DEFAULT_SHIELD, ...limits };
|
|
234
|
+
const full = typeof text === 'string' ? text : String(text ?? '');
|
|
235
|
+
const env = envelope ? envelope.open(full) : null;
|
|
236
|
+
const body = env ? env.body : full;
|
|
237
|
+
if (body.length <= o.maxChars) return { text: full, truncated: false };
|
|
238
|
+
|
|
239
|
+
const parsed = parseStructured(body);
|
|
240
|
+
let stored;
|
|
241
|
+
let previewBody;
|
|
242
|
+
let preview = null;
|
|
243
|
+
if (parsed !== undefined) {
|
|
244
|
+
stored = parsed;
|
|
245
|
+
// Tighten until the preview fits — a result of 5,000 ten-character items and one of
|
|
246
|
+
// ten 50,000-character items need different knobs turned.
|
|
247
|
+
let opts = { ...o };
|
|
248
|
+
let compact = compactValue(parsed, opts);
|
|
249
|
+
let s = JSON.stringify(compact.value);
|
|
250
|
+
for (let i = 0; s.length > o.maxChars && i < 8; i += 1) {
|
|
251
|
+
opts = {
|
|
252
|
+
...opts,
|
|
253
|
+
maxArrayItems: Math.max(1, opts.maxArrayItems >> 1),
|
|
254
|
+
maxStringChars: Math.max(80, opts.maxStringChars >> 1),
|
|
255
|
+
maxKeys: Math.max(5, opts.maxKeys >> 1),
|
|
256
|
+
};
|
|
257
|
+
compact = compactValue(parsed, opts);
|
|
258
|
+
s = JSON.stringify(compact.value);
|
|
259
|
+
}
|
|
260
|
+
if (s.length > o.maxChars) s = `${s.slice(0, o.maxChars)}…`; // still too big: a hard cut, but stored whole
|
|
261
|
+
previewBody = s;
|
|
262
|
+
if (Array.isArray(parsed)) preview = { items: Math.min(parsed.length, opts.maxArrayItems) };
|
|
263
|
+
} else {
|
|
264
|
+
stored = body;
|
|
265
|
+
previewBody = body.slice(0, o.maxChars);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ref = store ? store.put({ tool, value: stored, owner }) : null;
|
|
269
|
+
const shape = describeShape(stored);
|
|
270
|
+
const wrapped = env ? env.close(previewBody) : previewBody;
|
|
271
|
+
const note = retrievalNote({ ref, shape, totalChars: body.length, shownChars: previewBody.length, preview });
|
|
272
|
+
return { text: `${wrapped}\n${note}`, truncated: true, ref, shape, totalChars: body.length };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── get_result ───────────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
export function resultToolSpec() {
|
|
278
|
+
return {
|
|
279
|
+
name: RESULT_TOOL_NAME,
|
|
280
|
+
description:
|
|
281
|
+
'Read more of a tool result that was shielded (truncated) — the result named this tool and '
|
|
282
|
+
+ 'gave a ref. Pages arrays by item and text by character; `search` filters, `fields` keeps '
|
|
283
|
+
+ 'only those keys, `path` descends into a nested value (dot path, e.g. "data.items").',
|
|
284
|
+
parameters: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {
|
|
287
|
+
ref: { type: 'string', description: 'The ref from the shielded result.' },
|
|
288
|
+
path: { type: 'string', description: 'Dot path inside the stored result.' },
|
|
289
|
+
offset: { type: 'integer', minimum: 0, description: 'First item (arrays) or character (text). Default 0.' },
|
|
290
|
+
limit: { type: 'integer', minimum: 1, description: `Items (default ${PAGE_LIMIT_DEFAULT}, max ${PAGE_LIMIT_MAX}) or characters (default ${TEXT_LIMIT_DEFAULT}, max ${TEXT_LIMIT_MAX}).` },
|
|
291
|
+
fields: { type: 'array', items: { type: 'string' }, description: 'Keep only these keys of each object.' },
|
|
292
|
+
search: { type: 'string', description: 'Case-insensitive text to filter items by, or to locate in text.' },
|
|
293
|
+
},
|
|
294
|
+
required: ['ref'],
|
|
295
|
+
},
|
|
296
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function descend(value, path) {
|
|
301
|
+
const segs = String(path || '').split('.').map((s) => s.trim()).filter(Boolean);
|
|
302
|
+
let cur = value;
|
|
303
|
+
const walked = [];
|
|
304
|
+
for (const seg of segs) {
|
|
305
|
+
if (Array.isArray(cur) && /^\d+$/.test(seg)) cur = cur[Number(seg)];
|
|
306
|
+
else if (isRecord(cur) && Object.prototype.hasOwnProperty.call(cur, seg)) cur = cur[seg];
|
|
307
|
+
else return { error: `No "${seg}" at "${walked.join('.') || '(root)'}"`, available: isRecord(cur) ? Object.keys(cur).slice(0, 40) : Array.isArray(cur) ? `array of ${cur.length}` : typeof cur };
|
|
308
|
+
walked.push(seg);
|
|
309
|
+
}
|
|
310
|
+
return { value: cur };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const clampInt = (v, def, min, max) => {
|
|
314
|
+
const n = Number.parseInt(v, 10);
|
|
315
|
+
if (!Number.isFinite(n)) return def;
|
|
316
|
+
return Math.min(max, Math.max(min, n));
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Answer one `get_result` call. Returns the JSON text the model receives — including
|
|
321
|
+
* errors, which name what exists so the model can correct itself without another call.
|
|
322
|
+
*/
|
|
323
|
+
export function runResultQuery(store, args = {}, { owner, maxChars = DEFAULT_SHIELD.maxChars } = {}) {
|
|
324
|
+
const ref = String(args?.ref || '');
|
|
325
|
+
const entry = store ? store.get(ref, owner) : null;
|
|
326
|
+
if (!entry) {
|
|
327
|
+
return JSON.stringify({ error: `Unknown or expired ref "${ref}".`, hint: 'Refs come from a shielded result in this conversation and expire when newer results replace them; re-run the original tool if needed.' });
|
|
328
|
+
}
|
|
329
|
+
const located = args.path ? descend(entry.value, args.path) : { value: entry.value };
|
|
330
|
+
if (located.error) return JSON.stringify({ error: located.error, available: located.available, ref });
|
|
331
|
+
const target = located.value;
|
|
332
|
+
const search = args.search != null && String(args.search).trim() ? String(args.search).toLowerCase() : '';
|
|
333
|
+
const base = { ref, ...(args.path ? { path: args.path } : {}) };
|
|
334
|
+
|
|
335
|
+
if (Array.isArray(target)) {
|
|
336
|
+
let items = target;
|
|
337
|
+
if (search) items = items.filter((it) => JSON.stringify(it).toLowerCase().includes(search));
|
|
338
|
+
const fields = Array.isArray(args.fields) ? args.fields.map(String).filter(Boolean) : [];
|
|
339
|
+
const project = (it) => {
|
|
340
|
+
if (!fields.length || !isRecord(it)) return it;
|
|
341
|
+
const out = {};
|
|
342
|
+
for (const f of fields) if (Object.prototype.hasOwnProperty.call(it, f)) out[f] = it[f];
|
|
343
|
+
return out;
|
|
344
|
+
};
|
|
345
|
+
const offset = clampInt(args.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
346
|
+
let limit = clampInt(args.limit, PAGE_LIMIT_DEFAULT, 1, PAGE_LIMIT_MAX);
|
|
347
|
+
let page;
|
|
348
|
+
let text;
|
|
349
|
+
// A page that itself exceeds the budget shrinks until it fits — the shield's guarantee
|
|
350
|
+
// holds for its own pages too.
|
|
351
|
+
for (let i = 0; i < 8; i += 1) {
|
|
352
|
+
page = items.slice(offset, offset + limit).map((it) => compactValue(project(it), { maxArrayItems: 20, maxStringChars: 2000 }).value);
|
|
353
|
+
const next = offset + limit < items.length ? { offset: offset + limit, limit } : null;
|
|
354
|
+
text = JSON.stringify({ ...base, total: target.length, ...(search ? { matched: items.length, search: args.search } : {}), offset, limit, count: page.length, items: page, next });
|
|
355
|
+
if (text.length <= maxChars || limit === 1) break;
|
|
356
|
+
limit = Math.max(1, limit >> 1);
|
|
357
|
+
}
|
|
358
|
+
return text;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (typeof target === 'string') {
|
|
362
|
+
if (search) {
|
|
363
|
+
const hay = target.toLowerCase();
|
|
364
|
+
const matches = [];
|
|
365
|
+
let from = 0;
|
|
366
|
+
while (matches.length < SEARCH_MATCHES_MAX) {
|
|
367
|
+
const at = hay.indexOf(search, from);
|
|
368
|
+
if (at < 0) break;
|
|
369
|
+
const start = Math.max(0, at - SEARCH_WINDOW);
|
|
370
|
+
const end = Math.min(target.length, at + search.length + SEARCH_WINDOW);
|
|
371
|
+
matches.push({ offset: at, excerpt: target.slice(start, end) });
|
|
372
|
+
from = at + search.length;
|
|
373
|
+
}
|
|
374
|
+
return JSON.stringify({ ...base, chars: target.length, search: args.search, matches, more: matches.length >= SEARCH_MATCHES_MAX });
|
|
375
|
+
}
|
|
376
|
+
const offset = clampInt(args.offset, 0, 0, target.length);
|
|
377
|
+
const limit = Math.min(clampInt(args.limit, TEXT_LIMIT_DEFAULT, 1, TEXT_LIMIT_MAX), Math.max(1, maxChars - 200));
|
|
378
|
+
const slice = target.slice(offset, offset + limit);
|
|
379
|
+
const next = offset + limit < target.length ? { offset: offset + limit, limit } : null;
|
|
380
|
+
return JSON.stringify({ ...base, chars: target.length, offset, limit, text: slice, next });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (isRecord(target)) {
|
|
384
|
+
const shape = describeShape(target);
|
|
385
|
+
const compact = compactValue(target, { maxArrayItems: 10, maxStringChars: 1000, maxKeys: 40 });
|
|
386
|
+
let text = JSON.stringify({ ...base, shape, value: compact.value, hint: shape.arrays?.length ? `Use "path" to page an array, e.g. "${shape.arrays[0].path}".` : 'Use "path" to read one key.' });
|
|
387
|
+
if (text.length > maxChars) text = JSON.stringify({ ...base, shape, hint: 'Too large to show whole; use "path" to descend.' });
|
|
388
|
+
return text;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return JSON.stringify({ ...base, value: target });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── the wrapper ──────────────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Wrap a toolset `{ specs, execute, … }` so every result passes the shield and `get_result`
|
|
398
|
+
* exists to page what it stored. Everything else on the toolset is kept as-is.
|
|
399
|
+
*
|
|
400
|
+
* @param exempt `(name) => boolean` — tools whose results are never shielded (a host
|
|
401
|
+
* that already sizes a result itself).
|
|
402
|
+
* @param textOf how to read a result's text; `setText` how to write it back. Defaults fit
|
|
403
|
+
* the `string | { text, … }` executor contract.
|
|
404
|
+
*/
|
|
405
|
+
export function withResultShield(toolset, { store, owner, envelope = null, exempt = () => false, limits = {}, textOf = defaultTextOf, setText = defaultSetText } = {}) {
|
|
406
|
+
if (!toolset || typeof toolset.execute !== 'function') return toolset;
|
|
407
|
+
const spec = resultToolSpec();
|
|
408
|
+
const specs = [...(toolset.specs || []).filter((s) => s?.name !== RESULT_TOOL_NAME), spec];
|
|
409
|
+
const base = toolset.execute.bind(toolset);
|
|
410
|
+
const shielded = new Map(); // ref -> tool, for the host's activity log
|
|
411
|
+
return {
|
|
412
|
+
...toolset,
|
|
413
|
+
specs,
|
|
414
|
+
shieldStore: store,
|
|
415
|
+
async execute(name, input, meta) {
|
|
416
|
+
if (name === RESULT_TOOL_NAME) return runResultQuery(store, input || {}, { owner, maxChars: limits.maxChars });
|
|
417
|
+
const raw = await base(name, input, meta);
|
|
418
|
+
if (exempt(name, input)) return raw;
|
|
419
|
+
const text = textOf(raw);
|
|
420
|
+
if (typeof text !== 'string') return raw;
|
|
421
|
+
const out = shieldToolResult(text, { tool: name, store, owner, envelope, ...limits });
|
|
422
|
+
if (!out.truncated) return raw;
|
|
423
|
+
if (out.ref) shielded.set(out.ref, name);
|
|
424
|
+
return setText(raw, out.text, out);
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function defaultTextOf(result) {
|
|
430
|
+
if (typeof result === 'string') return result;
|
|
431
|
+
if (result && typeof result === 'object' && typeof result.text === 'string') return result.text;
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function defaultSetText(result, text, shield) {
|
|
436
|
+
if (typeof result === 'string') return text;
|
|
437
|
+
return { ...result, text, shielded: { ref: shield.ref, totalChars: shield.totalChars } };
|
|
438
|
+
}
|
package/tool-round.js
ADDED
|
Binary file
|
package/tool-schema.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Schema compression — a tool's contract, with the prose it does not need for the model to
|
|
2
|
+
// call it correctly taken out.
|
|
3
|
+
//
|
|
4
|
+
// A connected server's tools arrive as full JSON schemas: a paragraph of description per
|
|
5
|
+
// tool, a sentence per parameter, `title`s, `examples`, `$schema` and `$comment`. All of
|
|
6
|
+
// it is re-sent on every turn the tool is armed, whether it is called or not. What the
|
|
7
|
+
// model actually needs to make a correct call is small and structural — the names, the
|
|
8
|
+
// types, which fields are required, the enum values, the bounds and defaults — plus enough
|
|
9
|
+
// description to choose the tool and to disambiguate a parameter whose name does not say
|
|
10
|
+
// what it holds.
|
|
11
|
+
//
|
|
12
|
+
// So `balanced` (the default) keeps everything structural, caps the tool description at a
|
|
13
|
+
// sentence or two, and keeps a parameter's description only when it adds something the
|
|
14
|
+
// name and type do not already say — `cursor: "opaque token from the previous page"` stays;
|
|
15
|
+
// `issue_number: "The number of the issue"` goes. `aggressive` keeps parameter descriptions
|
|
16
|
+
// only for the handful of names that are ambiguous everywhere (`ref`, `mode`, `sort`).
|
|
17
|
+
// `off` is the schema as the server sent it.
|
|
18
|
+
//
|
|
19
|
+
// Pure and shared: the extension applies it as tools arrive from a server, the gateway
|
|
20
|
+
// as tools pass through its relay, and the stats function makes the saving a number a
|
|
21
|
+
// person can see rather than a claim.
|
|
22
|
+
|
|
23
|
+
export const COMPRESSION_MODES = Object.freeze(['off', 'balanced', 'aggressive']);
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_COMPRESSION = Object.freeze({
|
|
26
|
+
mode: 'balanced',
|
|
27
|
+
maxDescriptionChars: 200,
|
|
28
|
+
maxParamDescriptionChars: 100,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Names that mean something different on every server, so their description always earns
|
|
32
|
+
// its place — a `ref` is a git ref here and a result ref there.
|
|
33
|
+
const AMBIGUOUS = new Set([
|
|
34
|
+
'ref', 'mode', 'sort', 'order', 'direction', 'format', 'type', 'kind', 'state', 'status',
|
|
35
|
+
'cursor', 'after', 'before', 'anchor', 'sha', 'q', 'query', 'filter', 'scope', 'target',
|
|
36
|
+
'id', 'key', 'path', 'name', 'value', 'data', 'body', 'input', 'output', 'options',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// Dropped outright: nothing a model needs to form a call.
|
|
40
|
+
const DROP_KEYS = new Set(['$schema', '$comment', 'title', 'examples', 'example', 'deprecated', 'readOnly', 'writeOnly', '$id', 'contentMediaType', 'contentEncoding']);
|
|
41
|
+
|
|
42
|
+
const collapse = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
43
|
+
|
|
44
|
+
/** Cut at a sentence end when one falls comfortably inside the budget; else at a word. */
|
|
45
|
+
export function trimDescription(text, max) {
|
|
46
|
+
const s = collapse(text);
|
|
47
|
+
if (!max || s.length <= max) return s;
|
|
48
|
+
const head = s.slice(0, max);
|
|
49
|
+
const sentence = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '));
|
|
50
|
+
if (sentence >= max * 0.4) return head.slice(0, sentence + 1);
|
|
51
|
+
const word = head.lastIndexOf(' ');
|
|
52
|
+
return `${(word > max * 0.6 ? head.slice(0, word) : head).trimEnd()}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const words = (s) => new Set(collapse(s).toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
56
|
+
|
|
57
|
+
/** Does a parameter description say anything its name and type do not? */
|
|
58
|
+
const FILLER = new Set([
|
|
59
|
+
'the', 'this', 'that', 'for', 'and', 'with', 'optional', 'required', 'string', 'number', 'integer',
|
|
60
|
+
'boolean', 'array', 'object', 'value', 'values', 'field', 'parameter', 'param', 'name', 'identifier',
|
|
61
|
+
'text', 'list', 'given', 'specified', 'target', 'item', 'items', 'new', 'existing', 'which', 'whose',
|
|
62
|
+
'will', 'should', 'must', 'can', 'may', 'used', 'use', 'set', 'get', 'input', 'output',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function informative(name, desc, node) {
|
|
66
|
+
const d = words(desc);
|
|
67
|
+
const own = [...words(name.replace(/([a-z])([A-Z])/g, '$1 $2'))];
|
|
68
|
+
for (const w of [...d]) {
|
|
69
|
+
if (FILLER.has(w)) { d.delete(w); continue; }
|
|
70
|
+
// `repo` covers "repository", `issue_number` covers "issues": a shared stem is the name.
|
|
71
|
+
if (own.some((n) => n.length >= 3 && (w.startsWith(n) || n.startsWith(w)))) d.delete(w);
|
|
72
|
+
}
|
|
73
|
+
if (node && typeof node.type === 'string') d.delete(node.type);
|
|
74
|
+
return d.size >= 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function compressNode(node, o, { name = '', depth = 0 } = {}) {
|
|
78
|
+
if (!node || typeof node !== 'object') return node;
|
|
79
|
+
if (Array.isArray(node)) return node.map((n) => compressNode(n, o, { depth: depth + 1 }));
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [k, v] of Object.entries(node)) {
|
|
82
|
+
if (DROP_KEYS.has(k)) continue;
|
|
83
|
+
if (k === 'description') {
|
|
84
|
+
if (depth === 0) { out[k] = trimDescription(v, o.maxDescriptionChars); continue; }
|
|
85
|
+
const keep = o.mode === 'aggressive'
|
|
86
|
+
? AMBIGUOUS.has(name.toLowerCase())
|
|
87
|
+
: (AMBIGUOUS.has(name.toLowerCase()) || informative(name, v, node));
|
|
88
|
+
if (keep) {
|
|
89
|
+
const t = trimDescription(v, o.maxParamDescriptionChars);
|
|
90
|
+
if (t) out[k] = t;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (k === 'properties' && v && typeof v === 'object') {
|
|
95
|
+
const props = {};
|
|
96
|
+
for (const [pn, pv] of Object.entries(v)) props[pn] = compressNode(pv, o, { name: pn, depth: depth + 1 });
|
|
97
|
+
out[k] = props;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if ((k === 'items' || k === 'additionalProperties' || k === 'not') && v && typeof v === 'object') {
|
|
101
|
+
out[k] = compressNode(v, o, { name, depth: depth + 1 });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if ((k === 'anyOf' || k === 'oneOf' || k === 'allOf') && Array.isArray(v)) {
|
|
105
|
+
out[k] = v.map((n) => compressNode(n, o, { name, depth: depth + 1 }));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (k === '$defs' || k === 'definitions') {
|
|
109
|
+
const defs = {};
|
|
110
|
+
for (const [dn, dv] of Object.entries(v || {})) defs[dn] = compressNode(dv, o, { name: dn, depth: depth + 1 });
|
|
111
|
+
out[k] = defs;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
out[k] = v;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compress one spec. Works on the extension shape (`parameters`) and the MCP shape
|
|
121
|
+
* (`inputSchema`); everything else on the spec (name, annotations, …) is kept as-is.
|
|
122
|
+
*/
|
|
123
|
+
export function compressToolSpec(spec, opts = {}) {
|
|
124
|
+
const o = { ...DEFAULT_COMPRESSION, ...opts };
|
|
125
|
+
if (!spec || o.mode === 'off' || !COMPRESSION_MODES.includes(o.mode)) return spec;
|
|
126
|
+
const out = { ...spec };
|
|
127
|
+
if (typeof spec.description === 'string') {
|
|
128
|
+
const d = trimDescription(spec.description, o.maxDescriptionChars);
|
|
129
|
+
if (d) out.description = d; else delete out.description;
|
|
130
|
+
}
|
|
131
|
+
for (const k of ['parameters', 'inputSchema']) {
|
|
132
|
+
if (spec[k] && typeof spec[k] === 'object') out[k] = compressNode(spec[k], o, { depth: 1 });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function compressToolSpecs(specs, opts) {
|
|
138
|
+
return (specs || []).map((s) => compressToolSpec(s, opts));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The saving, as a number: bytes of JSON before and after, per tool and in total. */
|
|
142
|
+
export function compressionStats(specs, opts = {}) {
|
|
143
|
+
const list = specs || [];
|
|
144
|
+
const size = (v) => JSON.stringify(v ?? null).length;
|
|
145
|
+
let before = 0;
|
|
146
|
+
let after = 0;
|
|
147
|
+
const tools = [];
|
|
148
|
+
for (const s of list) {
|
|
149
|
+
const b = size(s);
|
|
150
|
+
const a = size(compressToolSpec(s, opts));
|
|
151
|
+
before += b; after += a;
|
|
152
|
+
tools.push({ name: s?.name, before: b, after: a });
|
|
153
|
+
}
|
|
154
|
+
return { mode: opts.mode || DEFAULT_COMPRESSION.mode, tools: list.length, before, after, saved: before - after, savedPercent: before ? Math.round(((before - after) / before) * 100) : 0, perTool: tools };
|
|
155
|
+
}
|
package/tool-traits.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// What a tool DOES to the world — read from its annotations, guessed from its name, and
|
|
2
|
+
// turned into the three decisions every host was making by hand.
|
|
3
|
+
//
|
|
4
|
+
// MCP tools carry `annotations` (readOnlyHint, destructiveHint, idempotentHint,
|
|
5
|
+
// openWorldHint) and nothing in any ChatPanel client read them. Meanwhile each client
|
|
6
|
+
// decided separately whether a call could run alongside another (never — every round ran
|
|
7
|
+
// one call at a time), whether a repeat could be answered from the last result (a
|
|
8
|
+
// hand-kept list of seven tool names), and whether an action deserved a confirmation
|
|
9
|
+
// (per-action, per-site rules with no idea what the tool itself declared). Three
|
|
10
|
+
// decisions, one fact underneath: is this a read, and could it destroy something.
|
|
11
|
+
//
|
|
12
|
+
// Annotations win when present. When absent, the NAME is read — `get_`, `list_`,
|
|
13
|
+
// `search_` on one side, `delete_`, `purge_`, `revoke_` on the other — with the
|
|
14
|
+
// conservative default in the middle: a tool nobody can classify is a write, not a read,
|
|
15
|
+
// and "unknown" is never promoted to "safe".
|
|
16
|
+
//
|
|
17
|
+
// Class R: strings in, booleans out. No I/O, so the extension, desktop, gateway and bridge
|
|
18
|
+
// give the same answer for the same spec.
|
|
19
|
+
|
|
20
|
+
const READ_VERBS = new Set([
|
|
21
|
+
'get', 'list', 'search', 'read', 'find', 'fetch', 'query', 'describe', 'lookup', 'look', 'show',
|
|
22
|
+
'view', 'count', 'check', 'status', 'recall', 'inspect', 'screenshot', 'preview', 'browse', 'grep',
|
|
23
|
+
'glob', 'head', 'tail', 'cat', 'ls', 'stat', 'resolve', 'detect', 'validate', 'explain', 'summarize',
|
|
24
|
+
'summarise', 'summary', 'history', 'recent', 'latest', 'whoami', 'info', 'render', 'compare', 'diff',
|
|
25
|
+
'ping', 'health', 'select', 'retrieve', 'load', 'peek', 'watch', 'tools', 'schema', 'suggest', 'smart',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Anything that changes the world without necessarily destroying part of it. A name that
|
|
29
|
+
// carries one of these is never a read, whatever else it carries: `search_and_replace`
|
|
30
|
+
// searches, and replaces.
|
|
31
|
+
const WRITE_VERBS = new Set([
|
|
32
|
+
'create', 'update', 'set', 'post', 'put', 'send', 'write', 'add', 'edit', 'move', 'rename',
|
|
33
|
+
'upload', 'run', 'execute', 'exec', 'click', 'type', 'submit', 'save', 'insert', 'patch', 'apply',
|
|
34
|
+
'merge', 'push', 'commit', 'start', 'stop', 'enable', 'disable', 'assign', 'close', 'reopen',
|
|
35
|
+
'login', 'logout', 'install', 'replace', 'transfer', 'pay', 'order', 'book', 'schedule',
|
|
36
|
+
'publish', 'deploy', 'restart', 'import', 'sync', 'remember', 'label', 'tag', 'mark', 'fill',
|
|
37
|
+
'press', 'scroll', 'navigate', 'open', 'goto', 'drag', 'invoke', 'call', 'trigger', 'act',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const DESTRUCTIVE_VERBS = new Set([
|
|
41
|
+
'delete', 'remove', 'drop', 'purge', 'destroy', 'reset', 'clear', 'kill', 'wipe', 'revoke',
|
|
42
|
+
'truncate', 'erase', 'overwrite', 'rm', 'rmdir', 'uninstall', 'terminate', 'ban', 'force',
|
|
43
|
+
'unlink', 'discard', 'forget',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// `mcp_<server>__<tool>` → `<tool>`; `jira:delete-issue` → `delete-issue`.
|
|
47
|
+
export function bareToolName(name) {
|
|
48
|
+
const s = String(name || '');
|
|
49
|
+
const i = s.lastIndexOf('__');
|
|
50
|
+
return i >= 0 ? s.slice(i + 2) : s.replace(/^mcp[_-]/i, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tokensOf = (name) => bareToolName(name).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
54
|
+
|
|
55
|
+
function hasHint(a) {
|
|
56
|
+
return !!a && typeof a === 'object' && ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].some((k) => typeof a[k] === 'boolean');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @returns {{ readOnly: boolean, destructive: boolean, idempotent: boolean, openWorld: boolean, source: 'annotations'|'heuristic' }}
|
|
61
|
+
*/
|
|
62
|
+
export function toolTraits(spec) {
|
|
63
|
+
const name = typeof spec === 'string' ? spec : spec?.name;
|
|
64
|
+
const a = typeof spec === 'object' && spec ? spec.annotations : null;
|
|
65
|
+
const toks = tokensOf(name);
|
|
66
|
+
const namedDestructive = toks.some((t) => DESTRUCTIVE_VERBS.has(t));
|
|
67
|
+
const namedWrite = namedDestructive || toks.some((t) => WRITE_VERBS.has(t));
|
|
68
|
+
// A read verb anywhere (`web_search`, `issue_get`) — as long as nothing in the name writes.
|
|
69
|
+
const namedRead = !namedWrite && toks.some((t) => READ_VERBS.has(t));
|
|
70
|
+
|
|
71
|
+
if (hasHint(a)) {
|
|
72
|
+
const readOnly = a.readOnlyHint === true;
|
|
73
|
+
// The spec's default for an absent destructiveHint is TRUE, which would put every
|
|
74
|
+
// `create_issue` behind a confirmation. Explicit wins; absent falls back to the name.
|
|
75
|
+
const destructive = readOnly ? false : (typeof a.destructiveHint === 'boolean' ? a.destructiveHint : namedDestructive);
|
|
76
|
+
return {
|
|
77
|
+
readOnly,
|
|
78
|
+
destructive,
|
|
79
|
+
idempotent: readOnly || a.idempotentHint === true,
|
|
80
|
+
openWorld: typeof a.openWorldHint === 'boolean' ? a.openWorldHint : true,
|
|
81
|
+
source: 'annotations',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { readOnly: namedRead, destructive: namedDestructive, idempotent: namedRead, openWorld: true, source: 'heuristic' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Two reads never race each other for the world; a write is a barrier. */
|
|
88
|
+
export const canRunConcurrently = (t) => !!t?.readOnly;
|
|
89
|
+
|
|
90
|
+
/** A read asked twice has one answer, so the second can be served from the first. */
|
|
91
|
+
export const isCacheable = (t) => !!t?.readOnly;
|
|
92
|
+
|
|
93
|
+
/** Destroying is the one thing a person should be asked about, whatever the site rules say. */
|
|
94
|
+
export const needsConfirmation = (t) => t?.destructive === true;
|
|
95
|
+
|
|
96
|
+
/** Name → traits for a whole toolset, including the tools a dispatcher hides. */
|
|
97
|
+
export function traitsIndex(specs = []) {
|
|
98
|
+
const index = new Map();
|
|
99
|
+
for (const s of specs) if (s?.name) index.set(s.name, toolTraits(s));
|
|
100
|
+
return index;
|
|
101
|
+
}
|