@chatpanel/events 0.66.0 → 0.67.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/find-tool.js +59 -0
- package/index.js +9 -0
- package/package.json +10 -2
- package/tool-dispatch.js +231 -0
- package/toolset.js +89 -0
- package/web-search-tool.js +107 -0
package/find-tool.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `find` — the user's own data and the web, behind ONE registered tool.
|
|
2
|
+
//
|
|
3
|
+
// The history and web-search schemas cost ~1,760 tokens on EVERY turn (1,081 of schema plus
|
|
4
|
+
// a 678-token system block explaining them), paid whether or not the turn touched the user's
|
|
5
|
+
// data. It was noticed on "hi": the model, handed 678 tokens of instructions about history
|
|
6
|
+
// tools, opened the conversation by reciting them. It was doing what we asked.
|
|
7
|
+
//
|
|
8
|
+
// Relevance-narrowing could not help: local tools are exempt from culling on purpose, and
|
|
9
|
+
// culling them by relevance would trade a constant cost for a guessing game in which a turn
|
|
10
|
+
// that needed history silently lost it. A dispatcher has no such trade: everything stays
|
|
11
|
+
// reachable, nothing is guessed, and the saving is identical on every turn.
|
|
12
|
+
//
|
|
13
|
+
// The NAME and the WORDING are the contract every client shares. The extension's `find`
|
|
14
|
+
// and the desktop's `find` must be the same tool — a model that learned to call one on the
|
|
15
|
+
// panel should find the identical tool in the app, and a recipe recorded on one must run on
|
|
16
|
+
// the other. What goes BEHIND it (which search engine, which history store) is the host's.
|
|
17
|
+
|
|
18
|
+
import { makeDispatchProvider } from './tool-dispatch.js';
|
|
19
|
+
|
|
20
|
+
export const FIND_TOOL_NAME = 'find';
|
|
21
|
+
|
|
22
|
+
export const FIND_DESCRIPTION =
|
|
23
|
+
'Search and read the user\'s own saved data (past chats, notes, meetings) and the web. '
|
|
24
|
+
+ 'Pass an `action` and put that action\'s own arguments inside `args`, e.g. '
|
|
25
|
+
+ '{"action":"history_search","args":{"query":"pricing"}}. Unsure of an action\'s '
|
|
26
|
+
+ 'arguments? {"action":"describe","args":{"tool":"<action>"}} returns its full schema. '
|
|
27
|
+
+ 'Use this when the answer plausibly depends on something the user already has; do not '
|
|
28
|
+
+ 'call it for greetings or general knowledge.';
|
|
29
|
+
|
|
30
|
+
// One line resident, not 678. The rest travels with `describe`.
|
|
31
|
+
//
|
|
32
|
+
// SAY THAT IT HAS THE DATA, not just that a tool exists. Asked "check my meetings with
|
|
33
|
+
// <name>", a model answered "I do not have access to your personal calendar, emails, or
|
|
34
|
+
// meeting history" — while `find` was sitting in its toolset. The old line named the tool
|
|
35
|
+
// and left the capability to be inferred, and inference is what small models are worst at.
|
|
36
|
+
export const FIND_RESIDENT =
|
|
37
|
+
"You HAVE access to the user's own ChatPanel data — their past chats, notes, and "
|
|
38
|
+
+ 'meeting transcripts and summaries — through the `find` tool, plus the web. When the '
|
|
39
|
+
+ 'question is about past meetings, notes, people, decisions, or anything the user '
|
|
40
|
+
+ 'discussed or wrote, call `find` FIRST and answer from what it returns. Never tell '
|
|
41
|
+
+ 'the user you cannot access their meetings, notes or history: you can.';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Wrap the real search/read tools (history, web search, weather…) as the one `find` tool.
|
|
45
|
+
*
|
|
46
|
+
* `remote` is false: history is on-device and web search is proxied by the host under its
|
|
47
|
+
* own settings, so the harness hands these tools real values under "redact remote".
|
|
48
|
+
*/
|
|
49
|
+
export function findDispatchProvider(inner, { all = null, rank = undefined } = {}) {
|
|
50
|
+
return makeDispatchProvider({
|
|
51
|
+
name: FIND_TOOL_NAME,
|
|
52
|
+
description: FIND_DESCRIPTION,
|
|
53
|
+
resident: FIND_RESIDENT,
|
|
54
|
+
inner,
|
|
55
|
+
remote: false,
|
|
56
|
+
all,
|
|
57
|
+
rank,
|
|
58
|
+
});
|
|
59
|
+
}
|
package/index.js
CHANGED
|
@@ -177,6 +177,15 @@ export {
|
|
|
177
177
|
resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
|
|
178
178
|
} from './tool-result.js';
|
|
179
179
|
export { findTools, findToolsResult, findActionArgs, oneLiner, overlapRank, FIND_ACTION } from './tool-discovery.js';
|
|
180
|
+
// One registered tool per group, the registry that merges providers, and the two tools every
|
|
181
|
+
// client with a loop offers — the SAME `find` and `web_search` on the panel and in the app.
|
|
182
|
+
export { buildToolset } from './toolset.js';
|
|
183
|
+
export {
|
|
184
|
+
DESCRIBE_ACTION, actionMenu, buildGroupDispatchSpec, validateAction, makeGroupDispatchExecutor,
|
|
185
|
+
withGuidance, makeDispatchProvider, estimateTokens,
|
|
186
|
+
} from './tool-dispatch.js';
|
|
187
|
+
export { FIND_TOOL_NAME, FIND_DESCRIPTION, FIND_RESIDENT, findDispatchProvider } from './find-tool.js';
|
|
188
|
+
export { WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_SYSTEM, WEB_SEARCH_SPEC, searchResultsToText, webSearchToolProvider } from './web-search-tool.js';
|
|
180
189
|
export { compressToolSpec, compressToolSpecs, compressionStats, trimDescription, COMPRESSION_MODES, DEFAULT_COMPRESSION } from './tool-schema.js';
|
|
181
190
|
export { validateRecipe, expandRecipe, recipeParams, mapInput, dryRunRecipe, runPlan, runRecipe, RecipeError, RECIPE_MODES } from './recipe.js';
|
|
182
191
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.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",
|
|
@@ -94,7 +94,11 @@
|
|
|
94
94
|
"./voice-speaker.js": "./voice-speaker.js",
|
|
95
95
|
"./weather.js": "./weather.js",
|
|
96
96
|
"./web-search.js": "./web-search.js",
|
|
97
|
-
"./widget.js": "./widget.js"
|
|
97
|
+
"./widget.js": "./widget.js",
|
|
98
|
+
"./toolset.js": "./toolset.js",
|
|
99
|
+
"./tool-dispatch.js": "./tool-dispatch.js",
|
|
100
|
+
"./find-tool.js": "./find-tool.js",
|
|
101
|
+
"./web-search-tool.js": "./web-search-tool.js"
|
|
98
102
|
},
|
|
99
103
|
"files": [
|
|
100
104
|
"LICENSE",
|
|
@@ -111,6 +115,7 @@
|
|
|
111
115
|
"entity.js",
|
|
112
116
|
"event.js",
|
|
113
117
|
"extraction.js",
|
|
118
|
+
"find-tool.js",
|
|
114
119
|
"flowchart.js",
|
|
115
120
|
"harness.js",
|
|
116
121
|
"index.js",
|
|
@@ -171,18 +176,21 @@
|
|
|
171
176
|
"theme.js",
|
|
172
177
|
"titles.js",
|
|
173
178
|
"tool-discovery.js",
|
|
179
|
+
"tool-dispatch.js",
|
|
174
180
|
"tool-groups.js",
|
|
175
181
|
"tool-need.js",
|
|
176
182
|
"tool-result.js",
|
|
177
183
|
"tool-round.js",
|
|
178
184
|
"tool-schema.js",
|
|
179
185
|
"tool-traits.js",
|
|
186
|
+
"toolset.js",
|
|
180
187
|
"trajectory.js",
|
|
181
188
|
"upcast.js",
|
|
182
189
|
"vault.js",
|
|
183
190
|
"view.js",
|
|
184
191
|
"voice-intents.js",
|
|
185
192
|
"weather.js",
|
|
193
|
+
"web-search-tool.js",
|
|
186
194
|
"web-search.js",
|
|
187
195
|
"widget.js"
|
|
188
196
|
],
|
package/tool-dispatch.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// PROGRESSIVE TOOL DISCLOSURE — a group of tools registered as ONE tool.
|
|
2
|
+
//
|
|
3
|
+
// Twenty page-action schemas cost ~3,300 tokens on EVERY turn; six data tools cost ~1,760.
|
|
4
|
+
// Paid whether or not the turn touched any of them, and on a small local model that can eat
|
|
5
|
+
// half the context before the user has typed anything. So a group is registered as one
|
|
6
|
+
// compact tool carrying an action enum and a one-line gist each; the full schema for any
|
|
7
|
+
// action is REACHABLE via `{action:'describe', tool:'<name>'}`, and arguments are validated
|
|
8
|
+
// at execution with a structured error the model can act on.
|
|
9
|
+
//
|
|
10
|
+
// WHY A DISPATCHER RATHER THAN AN INDEX. Over MCP a model may only call tools that are
|
|
11
|
+
// REGISTERED; returning a schema from an index tool would not make the described tool
|
|
12
|
+
// callable. A dispatcher is one registered tool that can reach all of them, so the same
|
|
13
|
+
// mechanism works for a relayed CLI agent and for an in-client loop.
|
|
14
|
+
//
|
|
15
|
+
// The page dispatcher proved the shape and earned three bugs doing it (the stripped `args`
|
|
16
|
+
// envelope, the blinded loop guard, the unreadable activity rows). Every later group — the
|
|
17
|
+
// user's own data, MCP servers, and now the desktop's turn — goes through this instead of
|
|
18
|
+
// re-earning them. What a group supplies is only what is genuinely its own: a name, a
|
|
19
|
+
// sentence about when to reach for it, and whether its tools are remote.
|
|
20
|
+
|
|
21
|
+
import { FIND_ACTION, findToolsResult, findActionArgs } from './tool-discovery.js';
|
|
22
|
+
import { traitsIndex } from './tool-traits.js';
|
|
23
|
+
|
|
24
|
+
export const DESCRIBE_ACTION = 'describe';
|
|
25
|
+
|
|
26
|
+
/** First sentence of a description — enough to choose an action, not to call it blind. */
|
|
27
|
+
function gistOf(spec) {
|
|
28
|
+
const text = String(spec.description || '').replace(/\s+/g, ' ').trim();
|
|
29
|
+
const stop = text.search(/(?<=[.!?])\s/);
|
|
30
|
+
const first = stop > 0 ? text.slice(0, stop) : text;
|
|
31
|
+
return first.length > 90 ? `${first.slice(0, 87).trimEnd()}...` : first;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requiredOf(spec) {
|
|
35
|
+
const req = spec?.parameters?.required;
|
|
36
|
+
return Array.isArray(req) ? req : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The action menu — one line per action, enough to choose but not to call blind. */
|
|
40
|
+
export function actionMenu(specs) {
|
|
41
|
+
return specs.map((s) => {
|
|
42
|
+
const req = requiredOf(s);
|
|
43
|
+
return `- ${s.name}${req.length ? `(${req.join(', ')})` : '()'}: ${gistOf(s)}`;
|
|
44
|
+
}).join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a dispatcher spec for ANY group of tools.
|
|
49
|
+
*
|
|
50
|
+
* @param hidden how many more actions the group can reach than the menu lists (a relevance
|
|
51
|
+
* cap trimmed it). When > 0 the spec says so and names the way back: `find`
|
|
52
|
+
* searches every tool the group owns, listed or not. Without that line a tool
|
|
53
|
+
* the cap dropped was, for that turn, gone.
|
|
54
|
+
*/
|
|
55
|
+
export function buildGroupDispatchSpec({ name, description, specs, hidden = 0 }) {
|
|
56
|
+
const more = hidden > 0
|
|
57
|
+
? `\n${hidden} more action${hidden === 1 ? '' : 's'} not listed — {"action":"${FIND_ACTION}","args":{"query":"<task words>"}} finds them by name.`
|
|
58
|
+
: '';
|
|
59
|
+
return {
|
|
60
|
+
name,
|
|
61
|
+
description: `${description}\n\nActions:\n${actionMenu(specs)}${more}`,
|
|
62
|
+
parameters: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
action: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
enum: [DESCRIBE_ACTION, ...(hidden > 0 ? [FIND_ACTION] : []), ...specs.map((s) => s.name)],
|
|
68
|
+
description: 'Which action to run.',
|
|
69
|
+
},
|
|
70
|
+
// A DECLARED envelope, not `additionalProperties`. Providers and MCP validators
|
|
71
|
+
// routinely strip properties that are not in `properties`, so undeclared top-level
|
|
72
|
+
// arguments silently vanish before they reach the executor — which is exactly how
|
|
73
|
+
// `structured_insert` lost its `elements` array. Anything declared survives.
|
|
74
|
+
args: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
description: 'The chosen action\'s own arguments, verbatim. Use {} when it takes none.',
|
|
77
|
+
additionalProperties: true,
|
|
78
|
+
},
|
|
79
|
+
tool: { type: 'string', description: `With action="${DESCRIBE_ACTION}": the action to describe.` },
|
|
80
|
+
...(hidden > 0 ? findActionArgs() : {}),
|
|
81
|
+
},
|
|
82
|
+
required: ['action'],
|
|
83
|
+
additionalProperties: true, // tolerated, but never relied upon — see `args`
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate arguments against the REAL spec. Returns null when fine, else a structured error
|
|
90
|
+
* naming exactly what is missing — a bounded repair path instead of a dead turn.
|
|
91
|
+
*/
|
|
92
|
+
export function validateAction(spec, args) {
|
|
93
|
+
const missing = requiredOf(spec).filter((k) => args[k] === undefined || args[k] === null);
|
|
94
|
+
if (!missing.length) return null;
|
|
95
|
+
return {
|
|
96
|
+
error: `Missing required argument(s) for "${spec.name}": ${missing.join(', ')}.`,
|
|
97
|
+
required: requiredOf(spec),
|
|
98
|
+
hint: `Put them inside \`args\`: {"action":"${spec.name}","args":{...}}. `
|
|
99
|
+
+ `Call {"action":"${DESCRIBE_ACTION}","args":{"tool":"${spec.name}"}} for the full schema.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Route one dispatch call to the real per-action executor.
|
|
105
|
+
*
|
|
106
|
+
* `runAction(name, args, meta)` is the EXISTING guarded executor, so every confirmation gate,
|
|
107
|
+
* budget and site grant keeps firing on the real action name — the dispatcher must never
|
|
108
|
+
* become a way around them.
|
|
109
|
+
*
|
|
110
|
+
* @param specs the MENU — what the dispatcher lists
|
|
111
|
+
* @param all everything the group can reach; defaults to the menu. When larger, `find`
|
|
112
|
+
* searches it and any action in it runs, listed or not.
|
|
113
|
+
* @param rank `(specs, query) => specs` for `find`; the shared IDF ranker when given
|
|
114
|
+
*/
|
|
115
|
+
export function makeGroupDispatchExecutor({ name: dispatchName, specs, all = specs, runAction, rank }) {
|
|
116
|
+
const byName = new Map(all.map((s) => [s.name, s]));
|
|
117
|
+
for (const s of specs) byName.set(s.name, s); // the menu's copy wins a duplicate name
|
|
118
|
+
const menuNames = specs.map((s) => s.name);
|
|
119
|
+
return async (name, input, meta) => {
|
|
120
|
+
if (name !== dispatchName) return runAction(name, input, meta); // direct calls still work
|
|
121
|
+
// Accept BOTH shapes. `args` is the declared envelope and the one the description
|
|
122
|
+
// teaches; top-level arguments are merged too, so a model that ignores the envelope — or
|
|
123
|
+
// a provider that happens to pass extras through — still works rather than failing in a
|
|
124
|
+
// way that looks like the tool is broken.
|
|
125
|
+
const raw = input || {};
|
|
126
|
+
const { action: rawAction, args: envelope, tool: rawTool, ...rest } = raw;
|
|
127
|
+
const args = { ...rest, ...(envelope && typeof envelope === 'object' ? envelope : {}) };
|
|
128
|
+
const action = String(rawAction || '');
|
|
129
|
+
|
|
130
|
+
if (action === FIND_ACTION) {
|
|
131
|
+
return findToolsResult(all, String(args.query ?? rawTool ?? ''), { rank, describeAction: DESCRIBE_ACTION, menu: menuNames });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (action === DESCRIBE_ACTION) {
|
|
135
|
+
const spec = byName.get(String(args.tool || rawTool || ''));
|
|
136
|
+
return JSON.stringify(
|
|
137
|
+
spec
|
|
138
|
+
? {
|
|
139
|
+
name: spec.name,
|
|
140
|
+
// The full contract when the menu carried a compressed one (tool-schema.js).
|
|
141
|
+
description: spec.full?.description || spec.description,
|
|
142
|
+
parameters: spec.full?.parameters || spec.parameters,
|
|
143
|
+
...(spec.annotations ? { annotations: spec.annotations } : {}),
|
|
144
|
+
callAs: { action: spec.name, args: '<the properties above, verbatim>' },
|
|
145
|
+
}
|
|
146
|
+
: { error: `Unknown action "${args.tool || rawTool}".`, actions: [...byName.keys()] },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const spec = byName.get(action);
|
|
151
|
+
if (!spec) {
|
|
152
|
+
return JSON.stringify({
|
|
153
|
+
error: `Unknown action "${action}".`,
|
|
154
|
+
actions: menuNames,
|
|
155
|
+
...(all.length > specs.length ? { hint: `${all.length - specs.length} more are reachable: {"action":"${FIND_ACTION}","args":{"query":"…"}} finds them.` } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const bad = validateAction(spec, args);
|
|
159
|
+
if (bad) return JSON.stringify(bad);
|
|
160
|
+
return runAction(action, args, meta);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Attach a group's detailed guidance to `describe` instead of the prompt. The model reads it
|
|
166
|
+
* at the moment it is about to act on it — which is when it is most likely to follow it —
|
|
167
|
+
* and a turn that never reaches for the group never pays for it.
|
|
168
|
+
*/
|
|
169
|
+
export function withGuidance(execute, guidance) {
|
|
170
|
+
if (!guidance) return execute;
|
|
171
|
+
return async (name, input, meta) => {
|
|
172
|
+
const out = await execute(name, input, meta);
|
|
173
|
+
if (String(input?.action || '') !== DESCRIBE_ACTION) return out;
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(out);
|
|
176
|
+
if (!parsed || !parsed.name) return out;
|
|
177
|
+
return JSON.stringify({ ...parsed, guidance });
|
|
178
|
+
} catch {
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Turn a toolset into ONE provider — the reusable half of progressive disclosure.
|
|
186
|
+
*
|
|
187
|
+
* @param inner a toolset ({ specs, execute, system }) — the real tools, kept whole.
|
|
188
|
+
* @param resident the ONE line that stays in the prompt. Everything else the group wants to
|
|
189
|
+
* say travels with `describe`.
|
|
190
|
+
* @param remote true when these tools call a third party. This is load-bearing for
|
|
191
|
+
* PRIVACY, not bookkeeping: the harness uses it to keep PII off remote tools
|
|
192
|
+
* under "redact remote". A dispatcher that lost the flag would quietly turn
|
|
193
|
+
* redacted tools into unredacted ones.
|
|
194
|
+
* @param all every spec the group can reach when the menu (`inner.specs`) is a
|
|
195
|
+
* relevance-capped subset. `find` searches it; any action in it runs.
|
|
196
|
+
* @param rank the ranker `find` uses — the shared IDF one, so discovery agrees with the
|
|
197
|
+
* narrowing that hid the tool in the first place.
|
|
198
|
+
*/
|
|
199
|
+
export function makeDispatchProvider({ name, description, resident, inner, remote = false, all = null, rank = undefined }) {
|
|
200
|
+
if (!inner || !inner.specs?.length) return null;
|
|
201
|
+
const specs = inner.specs;
|
|
202
|
+
const reach = all && all.length > specs.length ? all : specs;
|
|
203
|
+
return {
|
|
204
|
+
specs: [buildGroupDispatchSpec({ name, specs, description, hidden: reach.length - specs.length })],
|
|
205
|
+
system: resident,
|
|
206
|
+
remote,
|
|
207
|
+
// What each REAL tool does to the world (annotations, else its name) — read by the round
|
|
208
|
+
// runner through the dispatcher, which otherwise hides every inner spec.
|
|
209
|
+
traits: traitsIndex(reach),
|
|
210
|
+
// …and WHICH tools are behind this name, so a recipe step can name the real tool and be
|
|
211
|
+
// routed through the dispatcher (buildToolset builds `hiddenVia` from it).
|
|
212
|
+
reach,
|
|
213
|
+
execute: withGuidance(
|
|
214
|
+
makeGroupDispatchExecutor({
|
|
215
|
+
name,
|
|
216
|
+
specs,
|
|
217
|
+
all: reach,
|
|
218
|
+
rank,
|
|
219
|
+
// Routes on the REAL tool name so every guard, budget and gate downstream keeps
|
|
220
|
+
// firing on the name it was written against.
|
|
221
|
+
runAction: (toolName, args, meta) => inner.execute(toolName, args, meta),
|
|
222
|
+
}),
|
|
223
|
+
inner.system,
|
|
224
|
+
),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Rough token estimate — used by budget tests, not at runtime. */
|
|
229
|
+
export function estimateTokens(value) {
|
|
230
|
+
return Math.round(JSON.stringify(value).length / 4);
|
|
231
|
+
}
|
package/toolset.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A generic tool registry — ANY number of tool providers merged into the one shape a model
|
|
2
|
+
// loop consumes: `{ specs, execute, system }`.
|
|
3
|
+
//
|
|
4
|
+
// A provider is `{ specs: ToolSpec[], execute(name, input, meta) => string | {text, note,
|
|
5
|
+
// image}, system?: string, remote?: boolean, serial?: boolean, traits?: Map, reach?: [] }`.
|
|
6
|
+
// ToolSpec is `{ name, description, parameters (JSON schema), annotations? }`.
|
|
7
|
+
//
|
|
8
|
+
// This lived in the extension for as long as the extension was the only client with a tool
|
|
9
|
+
// loop. The desktop grew one, and the second copy of "first provider to claim a name wins"
|
|
10
|
+
// would have been the second place that rule could quietly differ. Everything here is
|
|
11
|
+
// input → output; the one platform-flavoured thing — the shared MCP guidance a client
|
|
12
|
+
// prepends when any `mcp_*` tool is present — is INJECTED, so this file carries no prompt
|
|
13
|
+
// text of its own and stays off the extension's first-paint budget by exactly the bytes
|
|
14
|
+
// the old copy cost.
|
|
15
|
+
|
|
16
|
+
const REMOTE_NAME_RE = /^mcp[_-]/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param providers the tool providers, in the order the model should read them
|
|
20
|
+
* @param mcpSystem the shared MCP rules — a string, or a function returning one, consulted
|
|
21
|
+
* only when an `mcp_*` tool is present so a turn without MCP pays nothing
|
|
22
|
+
* @returns the merged toolset, or `undefined` when no provider brought a tool
|
|
23
|
+
*/
|
|
24
|
+
export function buildToolset(providers, { mcpSystem = '' } = {}) {
|
|
25
|
+
const list = (providers || []).filter((p) => p && p.specs?.length);
|
|
26
|
+
if (!list.length) return undefined;
|
|
27
|
+
|
|
28
|
+
const specs = [];
|
|
29
|
+
const route = new Map(); // tool name -> the provider.execute that owns it
|
|
30
|
+
// Tools that call a REMOTE server — from a provider flagged remote, or (fallback) whose
|
|
31
|
+
// name matches the mcp_ convention. The PII harness uses this exact set to keep private
|
|
32
|
+
// data off remote tools under "redact remote".
|
|
33
|
+
const remoteTools = new Set();
|
|
34
|
+
// What each HIDDEN tool does — a dispatcher's own index of the tools behind it
|
|
35
|
+
// (tool-traits.js). Top-level specs carry `annotations` and are classified at run time.
|
|
36
|
+
const traits = new Map();
|
|
37
|
+
// Tools that must run one at a time even when read-only: page tools share ONE tab.
|
|
38
|
+
const serialTools = new Set();
|
|
39
|
+
// The tools a dispatcher hides, and which dispatcher: a recipe step names the real tool.
|
|
40
|
+
const reach = [];
|
|
41
|
+
const hiddenVia = new Map();
|
|
42
|
+
for (const p of list) {
|
|
43
|
+
const providerRemote = p.remote === true;
|
|
44
|
+
if (p.traits instanceof Map) for (const [k, v] of p.traits) if (!traits.has(k)) traits.set(k, v);
|
|
45
|
+
if (Array.isArray(p.reach) && p.specs.length === 1) {
|
|
46
|
+
for (const h of p.reach) if (h?.name && !hiddenVia.has(h.name)) { hiddenVia.set(h.name, p.specs[0].name); reach.push(h); }
|
|
47
|
+
}
|
|
48
|
+
for (const s of p.specs) {
|
|
49
|
+
if (route.has(s.name)) continue; // first provider to claim a name wins
|
|
50
|
+
specs.push(s);
|
|
51
|
+
route.set(s.name, p.execute);
|
|
52
|
+
if (providerRemote || REMOTE_NAME_RE.test(String(s.name || ''))) remoteTools.add(s.name);
|
|
53
|
+
if (p.serial === true) serialTools.add(s.name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!specs.length) return undefined;
|
|
57
|
+
|
|
58
|
+
// Generic MCP rules ONCE (not repeated per server), then each provider's own inventory.
|
|
59
|
+
const hasMcp = specs.some((s) => REMOTE_NAME_RE.test(String(s?.name || '')));
|
|
60
|
+
const shared = hasMcp ? String((typeof mcpSystem === 'function' ? mcpSystem() : mcpSystem) || '') : '';
|
|
61
|
+
const parts = [shared, ...list.map((p) => p.system)];
|
|
62
|
+
const system = parts.map((x) => String(x || '').trim()).filter(Boolean).join('\n\n') || undefined;
|
|
63
|
+
// WHICH blurb costs what — one total for the whole preamble is visible but unattributable,
|
|
64
|
+
// and a number nobody can attribute is a number nobody can reduce.
|
|
65
|
+
const systemParts = {};
|
|
66
|
+
if (shared.trim()) systemParts.mcp = Math.round(shared.length / 4);
|
|
67
|
+
for (const p of list) {
|
|
68
|
+
const t = Math.round(String(p.system || '').trim().length / 4);
|
|
69
|
+
// Named by the dispatcher tool it owns — 'page', 'find', 'mcp' — which is what the
|
|
70
|
+
// reader sees in the tools list and can act on.
|
|
71
|
+
if (t) systemParts[p.id || p.specs[0]?.name || 'group'] = t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
specs,
|
|
76
|
+
system,
|
|
77
|
+
systemParts,
|
|
78
|
+
remoteTools,
|
|
79
|
+
traits,
|
|
80
|
+
serialTools,
|
|
81
|
+
reach,
|
|
82
|
+
hiddenVia,
|
|
83
|
+
async execute(name, input, meta = {}) {
|
|
84
|
+
const fn = route.get(name);
|
|
85
|
+
if (!fn) return JSON.stringify({ error: `Unknown tool: ${name}` });
|
|
86
|
+
return fn(name, input, meta);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `web_search` as a TOOL — the spec, the guidance, and how results are put in front of a
|
|
2
|
+
// model, without the search itself.
|
|
3
|
+
//
|
|
4
|
+
// Running a search is platform work: the extension fetches SERPs from a service worker
|
|
5
|
+
// with DOMParser, the desktop's main process reads anchors with a tokenizer, a gateway that
|
|
6
|
+
// grew one would use its own fetch. What is identical everywhere is what the model is told
|
|
7
|
+
// the tool does, what it is told about citing, and how a result list becomes text it can
|
|
8
|
+
// cite from — so that is what lives here, and `search` is injected.
|
|
9
|
+
//
|
|
10
|
+
// The citation rules are in the RESULT, not only in the system prompt, on purpose: the
|
|
11
|
+
// model reads them at the moment it has sources in hand, which is when it is most likely to
|
|
12
|
+
// follow them, and a turn that never searches never pays for them.
|
|
13
|
+
|
|
14
|
+
export const WEB_SEARCH_TOOL_NAME = 'web_search';
|
|
15
|
+
|
|
16
|
+
export const WEB_SEARCH_TOOL_SYSTEM =
|
|
17
|
+
'You can call web_search to look up current information from the web — prices, news, recent '
|
|
18
|
+
+ 'events, documentation, or anything time-sensitive or that may have changed since your training. '
|
|
19
|
+
+ 'Call it whenever the user asks about such things instead of guessing or saying you are unsure. '
|
|
20
|
+
+ 'Cite results inline as markdown links — e.g. ([1](https://…)) — never HTML, <sup>, or bare '
|
|
21
|
+
+ 'numbers, and finish with a "Sources" list of the links you used.';
|
|
22
|
+
|
|
23
|
+
export const WEB_SEARCH_SPEC = Object.freeze({
|
|
24
|
+
name: WEB_SEARCH_TOOL_NAME,
|
|
25
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
26
|
+
description:
|
|
27
|
+
'Search the web and return ranked result snippets with their source URLs. Use this for '
|
|
28
|
+
+ 'current events, live prices/quotes, news, product/library docs, or any fact you are unsure '
|
|
29
|
+
+ 'about or that may have changed since training — prefer it over guessing.',
|
|
30
|
+
parameters: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
query: { type: 'string', description: 'The search query — a few keywords work best.' },
|
|
34
|
+
},
|
|
35
|
+
required: ['query'],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Flatten a search result into one readable blob the model can cite from.
|
|
42
|
+
*
|
|
43
|
+
* `res` is `{ query, engines: [ids], results: [{ rank, title, url, text }] }`. A citation
|
|
44
|
+
* index of markdown links sits at the TOP so it survives truncation.
|
|
45
|
+
*/
|
|
46
|
+
export function searchResultsToText(res) {
|
|
47
|
+
if (!res?.results?.length) {
|
|
48
|
+
// Name the engines HERE especially. The success path already lists them; the failure
|
|
49
|
+
// path did not, so "no web results" looked like "the web has nothing" when it usually
|
|
50
|
+
// means "the one engine we were allowed to ask returned nothing" — a search engine
|
|
51
|
+
// blocking us and a query with no answer are completely different problems, and the
|
|
52
|
+
// model cannot tell them apart without this.
|
|
53
|
+
const tried = (res?.engines || []).join(', ');
|
|
54
|
+
return `No web results for "${res?.query || ''}"`
|
|
55
|
+
+ (tried ? ` (searched: ${tried}).` : '.')
|
|
56
|
+
+ ' This may mean the engine blocked the request rather than that nothing exists —'
|
|
57
|
+
+ ' do NOT conclude the information is unavailable. Try a shorter, more general query'
|
|
58
|
+
+ ' (drop dates and qualifiers), and tell the user they can enable another search'
|
|
59
|
+
+ ' engine in ChatPanel settings if it keeps failing.';
|
|
60
|
+
}
|
|
61
|
+
const engines = Array.isArray(res.engines) ? res.engines : [];
|
|
62
|
+
const sources = res.results.map((r) => `[${r.rank}] [${r.title}](${r.url})`).join('\n');
|
|
63
|
+
const example = res.results[0].url;
|
|
64
|
+
const head =
|
|
65
|
+
`Web search results for "${res.query}" (engines: ${engines.join(', ')}).\n\n`
|
|
66
|
+
+ 'Citation rules: when a claim draws on a result below, cite it inline as a markdown '
|
|
67
|
+
+ `link to that result's URL — e.g. ([1](${example})). Cite multiple sources as separate `
|
|
68
|
+
+ 'links: ([1](url)) ([3](url)). Do NOT output HTML, <sup>, or bare bracket numbers like '
|
|
69
|
+
+ '[1] — every citation must be a clickable markdown link. Finish with a "Sources" section '
|
|
70
|
+
+ 'that repeats, as markdown links, each source you cited.\n\n'
|
|
71
|
+
+ `Sources:\n${sources}`;
|
|
72
|
+
const body = res.results
|
|
73
|
+
.map((r) => `### [${r.rank}] ${r.title}\n<${r.url}>\n\n${r.text}`)
|
|
74
|
+
.join('\n\n---\n\n');
|
|
75
|
+
return `${head}\n\n---\nResult details:\n\n${body}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The tool provider — `{ specs, system, execute }` for `buildToolset`.
|
|
80
|
+
*
|
|
81
|
+
* @param search `(query) => Promise<{ query, engines, results }>` — the host's search. It
|
|
82
|
+
* may throw; the model gets the message rather than a dead turn.
|
|
83
|
+
*/
|
|
84
|
+
export function webSearchToolProvider({ search } = {}) {
|
|
85
|
+
if (typeof search !== 'function') throw new Error('webSearchToolProvider: search required');
|
|
86
|
+
return {
|
|
87
|
+
specs: [WEB_SEARCH_SPEC],
|
|
88
|
+
system: WEB_SEARCH_TOOL_SYSTEM,
|
|
89
|
+
async execute(name, input) {
|
|
90
|
+
if (name !== WEB_SEARCH_TOOL_NAME) return JSON.stringify({ error: `Unknown tool: ${name}` });
|
|
91
|
+
const q = String(input?.query || '').trim();
|
|
92
|
+
if (!q) return 'No query provided to web_search.';
|
|
93
|
+
try {
|
|
94
|
+
const res = await search(q);
|
|
95
|
+
// Return an OBJECT so the step can name the ENGINE that actually served the results.
|
|
96
|
+
// "web_search" alone doesn't tell the user whether Startpage or DuckDuckGo answered —
|
|
97
|
+
// which matters, because engines differ in coverage, and because a CLI agent may have
|
|
98
|
+
// run its OWN search instead of this one. `note` becomes the step's badge; `text` is
|
|
99
|
+
// what the model reads, unchanged.
|
|
100
|
+
const engines = (res?.engines || []).join(', ');
|
|
101
|
+
return { text: searchResultsToText(res), note: engines ? `ChatPanel · ${engines}` : 'ChatPanel' };
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return `web_search failed: ${e?.message || e}`;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|