@pi-archimedes/web 2.10.3 → 2.11.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/package.json +7 -7
- package/src/command.ts +4 -2
- package/src/config.test.ts +152 -0
- package/src/config.ts +28 -12
- package/src/renderer.test.ts +47 -1
- package/src/renderer.ts +11 -0
- package/src/tool-declarations.test.ts +52 -0
- package/src/tools.output-schema.test.ts +305 -0
- package/src/tools.ts +176 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/web",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@mozilla/readability": "^0.6.0",
|
|
17
|
-
"@pi-archimedes/core": "2.
|
|
17
|
+
"@pi-archimedes/core": "2.11.0",
|
|
18
18
|
"linkedom": "^0.18.9",
|
|
19
19
|
"p-limit": "^6.2.0",
|
|
20
20
|
"turndown": "^7.2.0",
|
|
@@ -22,16 +22,16 @@
|
|
|
22
22
|
"undici": "^7.4.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": "
|
|
26
|
-
"@earendil-works/pi-tui": "
|
|
27
|
-
"@types/node": "
|
|
25
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
26
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
27
|
+
"@types/node": "22.19.19",
|
|
28
28
|
"@types/turndown": "^5.0.5",
|
|
29
29
|
"typebox": "^1.1.38",
|
|
30
30
|
"typescript": "^6.0.3"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
34
|
-
"@earendil-works/pi-tui": ">=0.
|
|
33
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
34
|
+
"@earendil-works/pi-tui": ">=0.99.0",
|
|
35
35
|
"typebox": ">=1.1.0"
|
|
36
36
|
},
|
|
37
37
|
"pi": {
|
package/src/command.ts
CHANGED
|
@@ -16,7 +16,9 @@ export function registerCommand(pi: ExtensionAPI) {
|
|
|
16
16
|
break;
|
|
17
17
|
}
|
|
18
18
|
case "status": {
|
|
19
|
-
|
|
19
|
+
// `pi` from the enclosing registerCommand — `/web status` reports the
|
|
20
|
+
// provider the *effective* settings pick, project scope included.
|
|
21
|
+
const config = loadConfig(pi);
|
|
20
22
|
const provider = resolveProvider(undefined, config);
|
|
21
23
|
const stats = getCacheStats();
|
|
22
24
|
ctx.ui.notify(`Active provider: ${provider.name}\nCache: ${stats.count} items, ${stats.estimatedBytes} bytes`);
|
|
@@ -28,7 +30,7 @@ export function registerCommand(pi: ExtensionAPI) {
|
|
|
28
30
|
ctx.ui.notify("Usage: /web search <query>");
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
|
-
const config = loadConfig();
|
|
33
|
+
const config = loadConfig(pi);
|
|
32
34
|
const results = await executeSearch([query], {}, config);
|
|
33
35
|
const summary = `Found ${results.results.length} results using ${results.provider}:\n${results.results.map(r => `- ${r.title}: ${r.url}`).join("\n")}`;
|
|
34
36
|
ctx.ui.notify(summary, "info");
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Where web's config comes from ───────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `loadConfig()` used to read `~/.pi/agent/settings.json` by hand, which cannot
|
|
6
|
+
// see project-scoped settings (`<cwd>/.pi/settings.json`) or in-process
|
|
7
|
+
// overrides — so a key configured only in the project file was invisible to the
|
|
8
|
+
// search tools while `pi.getSettings()` could see it. The handle is threaded in
|
|
9
|
+
// from the tool/command call sites, which have it; `getSettings()` lives on
|
|
10
|
+
// ExtensionAPI, not on ExtensionContext, so it has to be `pi` and not `ctx`.
|
|
11
|
+
|
|
12
|
+
const { tempDir, fs, join, randomUUID } = vi.hoisted(() => {
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const { join } = require("node:path");
|
|
15
|
+
const { tmpdir } = require("node:os");
|
|
16
|
+
const { randomUUID } = require("node:crypto");
|
|
17
|
+
const dir = join(tmpdir(), `web-config-test-${randomUUID()}`);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
return { tempDir: dir, fs, join, tmpdir, randomUUID };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
23
|
+
getAgentDir: () => tempDir,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const { loadConfig } = await import("./config.js");
|
|
27
|
+
|
|
28
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
29
|
+
|
|
30
|
+
/** The env vars loadConfig consults first — cleared so tests are deterministic. */
|
|
31
|
+
const ENV_KEYS = [
|
|
32
|
+
"BRAVE_API_KEY",
|
|
33
|
+
"TAVILY_API_KEY",
|
|
34
|
+
"OPENAI_API_KEY",
|
|
35
|
+
"PERPLEXITY_API_KEY",
|
|
36
|
+
"SEARXNG_URL",
|
|
37
|
+
"HTTP_PROXY",
|
|
38
|
+
"HTTPS_PROXY",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
let savedEnv: Record<string, string | undefined> = {};
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
savedEnv = {};
|
|
45
|
+
for (const key of ENV_KEYS) {
|
|
46
|
+
savedEnv[key] = process.env[key];
|
|
47
|
+
delete process.env[key];
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(settingsPath());
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
for (const key of ENV_KEYS) {
|
|
58
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
59
|
+
else process.env[key] = savedEnv[key];
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(settingsPath());
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("loadConfig with an ExtensionAPI handle", () => {
|
|
69
|
+
it("sees a key that exists only in the effective settings (project scope)", () => {
|
|
70
|
+
// The global file says nothing about web at all; the value lives in the
|
|
71
|
+
// project's `.pi/settings.json`, which only pi's merged view contains.
|
|
72
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ theme: "dark" }), "utf-8");
|
|
73
|
+
const pi = {
|
|
74
|
+
getSettings: () => ({ "archimedes.web": { braveApiKey: "project-only-key" } }),
|
|
75
|
+
};
|
|
76
|
+
expect(loadConfig(pi as never).braveApiKey).toBe("project-only-key");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("prefers the effective settings over the file when both carry a value", () => {
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
settingsPath(),
|
|
82
|
+
JSON.stringify({ "archimedes.web": { braveApiKey: "global-key", defaultProvider: "brave" } }),
|
|
83
|
+
"utf-8",
|
|
84
|
+
);
|
|
85
|
+
const pi = {
|
|
86
|
+
getSettings: () => ({ "archimedes.web": { braveApiKey: "effective-key" } }),
|
|
87
|
+
};
|
|
88
|
+
const config = loadConfig(pi as never);
|
|
89
|
+
expect(config.braveApiKey).toBe("effective-key");
|
|
90
|
+
// Whatever the effective object does not carry is simply absent: it is the
|
|
91
|
+
// effective view, not a merge with our private file read.
|
|
92
|
+
expect(config.defaultProvider).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("carries the rest of the namespace through (provider selection sees project config)", () => {
|
|
96
|
+
const pi = {
|
|
97
|
+
getSettings: () => ({
|
|
98
|
+
"archimedes.web": { defaultProvider: "tavily", tavilyApiKey: "tvly-project" },
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
const config = loadConfig(pi as never);
|
|
102
|
+
expect(config.defaultProvider).toBe("tavily");
|
|
103
|
+
expect(config.tavilyApiKey).toBe("tvly-project");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("an env var still outranks both sources (unchanged precedence)", () => {
|
|
107
|
+
const pi = {
|
|
108
|
+
getSettings: () => ({ "archimedes.web": { braveApiKey: "effective-key" } }),
|
|
109
|
+
};
|
|
110
|
+
process.env.BRAVE_API_KEY = "env-key";
|
|
111
|
+
expect(loadConfig(pi as never).braveApiKey).toBe("env-key");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("loadConfig without a usable handle (the fallback that keeps core's floor)", () => {
|
|
116
|
+
it("reads settings.json when no handle is passed", () => {
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
settingsPath(),
|
|
119
|
+
JSON.stringify({ "archimedes.web": { braveApiKey: "file-key" } }),
|
|
120
|
+
"utf-8",
|
|
121
|
+
);
|
|
122
|
+
expect(loadConfig().braveApiKey).toBe("file-key");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reads settings.json when the host predates getSettings()", () => {
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
settingsPath(),
|
|
128
|
+
JSON.stringify({ "archimedes.web": { searxngUrl: "http://localhost:8888" } }),
|
|
129
|
+
"utf-8",
|
|
130
|
+
);
|
|
131
|
+
expect(loadConfig({} as never).searxngUrl).toBe("http://localhost:8888");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reads settings.json when getSettings() throws", () => {
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
settingsPath(),
|
|
137
|
+
JSON.stringify({ "archimedes.web": { braveApiKey: "file-key" } }),
|
|
138
|
+
"utf-8",
|
|
139
|
+
);
|
|
140
|
+
const pi = {
|
|
141
|
+
getSettings: () => {
|
|
142
|
+
throw new Error("Extension is not active");
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
expect(loadConfig(pi as never).braveApiKey).toBe("file-key");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("yields an empty config when there is nothing anywhere, and never writes", () => {
|
|
149
|
+
expect(loadConfig()).toEqual({});
|
|
150
|
+
expect(fs.existsSync(settingsPath())).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readEffectiveSettings } from "@pi-archimedes/core/settings-io";
|
|
2
3
|
|
|
3
4
|
export interface WebConfig {
|
|
4
5
|
braveApiKey?: string;
|
|
@@ -10,16 +11,31 @@ export interface WebConfig {
|
|
|
10
11
|
defaultProvider?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The web package's view of settings, from the effective settings the running pi
|
|
16
|
+
* applies.
|
|
17
|
+
*
|
|
18
|
+
* `pi` is the `ExtensionAPI` handle, threaded in by the tool `execute` and the
|
|
19
|
+
* `/web` command handler — the two places that have it. It is optional because
|
|
20
|
+
* it is not always available (and older pi has no `getSettings`), and
|
|
21
|
+
* `readEffectiveSettings` falls back to reading `~/.pi/agent/settings.json` by
|
|
22
|
+
* hand in that case. Passing the handle is what makes a key configured only in
|
|
23
|
+
* the project's `.pi/settings.json`, or set by an in-process override, visible
|
|
24
|
+
* here: the hand-rolled file read sees the global file and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Note the handle is used and then dropped — it is never stored.
|
|
27
|
+
*/
|
|
28
|
+
export function loadConfig(pi?: ExtensionAPI): WebConfig {
|
|
29
|
+
const effective = readEffectiveSettings(pi);
|
|
30
|
+
const config = (effective["archimedes.web"] ?? {}) as Record<string, any>;
|
|
31
|
+
|
|
16
32
|
return {
|
|
17
|
-
braveApiKey: process.env.BRAVE_API_KEY ||
|
|
18
|
-
tavilyApiKey: process.env.TAVILY_API_KEY ||
|
|
19
|
-
openaiApiKey: process.env.OPENAI_API_KEY ||
|
|
20
|
-
perplexityApiKey: process.env.PERPLEXITY_API_KEY ||
|
|
21
|
-
searxngUrl: process.env.SEARXNG_URL ||
|
|
22
|
-
proxy: process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
|
|
23
|
-
defaultProvider:
|
|
33
|
+
braveApiKey: process.env.BRAVE_API_KEY || config.braveApiKey,
|
|
34
|
+
tavilyApiKey: process.env.TAVILY_API_KEY || config.tavilyApiKey,
|
|
35
|
+
openaiApiKey: process.env.OPENAI_API_KEY || config.openaiApiKey,
|
|
36
|
+
perplexityApiKey: process.env.PERPLEXITY_API_KEY || config.perplexityApiKey,
|
|
37
|
+
searxngUrl: process.env.SEARXNG_URL || config.searxngUrl,
|
|
38
|
+
proxy: process.env.HTTP_PROXY || process.env.HTTPS_PROXY || config.proxy,
|
|
39
|
+
defaultProvider: config.defaultProvider,
|
|
24
40
|
};
|
|
25
|
-
}
|
|
41
|
+
}
|
package/src/renderer.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { Text } from '@earendil-works/pi-tui';
|
|
3
|
-
import { reuseText, sanitizeStatus, renderWebSearchCall } from './renderer.js';
|
|
3
|
+
import { reuseText, sanitizeStatus, renderWebSearchCall, renderGetSearchContentResult } from './renderer.js';
|
|
4
4
|
|
|
5
5
|
describe('renderer', () => {
|
|
6
6
|
it('reuseText recycles existing Text component', () => {
|
|
@@ -29,3 +29,49 @@ describe('renderer', () => {
|
|
|
29
29
|
}).not.toThrow();
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
describe('renderGetSearchContentResult', () => {
|
|
34
|
+
// Records the theme token each fragment is coloured with, so a test can tell
|
|
35
|
+
// an error row ("error" token, ✗) from a success row ("success", ✓).
|
|
36
|
+
const theme = { fg: (token: string, s: string) => `${token}:${s}`, bold: (s: string) => s } as any;
|
|
37
|
+
// One context per call: reuseText() recycles `lastComponent`, and the
|
|
38
|
+
// collapsed and expanded rows under test must not share a component.
|
|
39
|
+
const fresh = () => ({ lastComponent: new Text('', 0, 0) });
|
|
40
|
+
const row = (result: unknown, expanded: boolean) =>
|
|
41
|
+
renderGetSearchContentResult(result, { expanded, isPartial: false }, theme, fresh()).render(200).join('\n');
|
|
42
|
+
|
|
43
|
+
// The cache-miss result exactly as `get_search_content` returns it: reported
|
|
44
|
+
// with isError, so the row must not read as a successful retrieval.
|
|
45
|
+
const miss = {
|
|
46
|
+
content: [{ type: 'text', text: 'Response not found' }],
|
|
47
|
+
details: { responseId: 'web_missing', error: 'Response not found' },
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
it('collapses to an error row naming the failure, not "Content retrieved"', () => {
|
|
52
|
+
const rendered = row(miss, false);
|
|
53
|
+
expect(rendered).toContain('✗');
|
|
54
|
+
expect(rendered).toContain('error:');
|
|
55
|
+
expect(rendered).toContain('Response not found');
|
|
56
|
+
expect(rendered).not.toContain('Content retrieved');
|
|
57
|
+
expect(rendered).not.toContain('✓');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('expands to the failure text, not the "No content found" placeholder', () => {
|
|
61
|
+
const rendered = row(miss, true);
|
|
62
|
+
expect(rendered).toContain('Response not found');
|
|
63
|
+
expect(rendered).not.toContain('No content found');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a successful retrieval still renders its success row (the fix is not a blanket error)', () => {
|
|
67
|
+
const ok = { content: [{ type: 'text', text: 'cached body' }], details: { content: 'cached body' } };
|
|
68
|
+
const rendered = row(ok, false);
|
|
69
|
+
expect(rendered).toContain('✓');
|
|
70
|
+
expect(rendered).toContain('11 characters');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a passages row still renders its passage count', () => {
|
|
74
|
+
const ok = { content: [{ type: 'text', text: '[]' }], details: { matches: [{ passage: 'p', offset: 0, match: 'm' }] } };
|
|
75
|
+
expect(row(ok, false)).toContain('Found 1 passages');
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/renderer.ts
CHANGED
|
@@ -135,6 +135,17 @@ export function renderGetSearchContentResult(result: unknown, options: { expande
|
|
|
135
135
|
text.setText(renderStatusLabel("error", sanitizeStatus("No details"), theme));
|
|
136
136
|
return text;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// A failed lookup (`Response not found`) is reported with `isError`, and pi
|
|
140
|
+
// hands a renderer only `{ content, details }` — the flag itself is not in
|
|
141
|
+
// what this function is given (`modes/interactive/components/
|
|
142
|
+
// tool-execution.js:243`) — so `details.error` is what a row can read. Without
|
|
143
|
+
// this branch the row said “✓ Content retrieved” for a call that retrieved
|
|
144
|
+
// nothing, and expanding said “No content found”.
|
|
145
|
+
if (details.error) {
|
|
146
|
+
text.setText(renderStatusLabel("error", sanitizeStatus(details.error), theme));
|
|
147
|
+
return text;
|
|
148
|
+
}
|
|
138
149
|
|
|
139
150
|
if (options?.expanded) {
|
|
140
151
|
text.setText(sanitizeExpanded(details.matches ? details.matches.map((m: any) => m.passage).join("\n\n") : (details.content ?? "No content found")));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 3 — how the web tools are declared to the model and to scripts.
|
|
3
|
+
*
|
|
4
|
+
* `openWorldHint` is the MCP hint for "this reaches outside the closed domain",
|
|
5
|
+
* which is exactly what a search provider and an HTTP fetch do. pi documents
|
|
6
|
+
* `annotations` as author-declared and unverified — a permission gate's input,
|
|
7
|
+
* not a guarantee (`pi: dist/core/extensions/types.d.ts`, `ToolAnnotations`).
|
|
8
|
+
*
|
|
9
|
+
* The exposure assertion is the negative half of the plan: these tools stay
|
|
10
|
+
* `direct`. `codemode`/`deferred` would take their declarations away from the
|
|
11
|
+
* model, and the suite does not register virtual models or MCP servers
|
|
12
|
+
* (ADR 0029), so nothing would put them back.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi } from "vitest";
|
|
16
|
+
import { registerTools } from "./tools.js";
|
|
17
|
+
|
|
18
|
+
vi.mock("./config.js", () => ({ loadConfig: () => ({}) }));
|
|
19
|
+
|
|
20
|
+
type RegisteredTool = {
|
|
21
|
+
name: string;
|
|
22
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
|
23
|
+
exposure?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const tools: Record<string, RegisteredTool> = {};
|
|
27
|
+
registerTools({ registerTool: (t: unknown) => { const tool = t as RegisteredTool; tools[tool.name] = tool; } } as never);
|
|
28
|
+
|
|
29
|
+
const names = ["web_search", "fetch_content", "get_search_content"];
|
|
30
|
+
|
|
31
|
+
describe("web tool annotations", () => {
|
|
32
|
+
it.each(names)("%s declares openWorldHint", (name) => {
|
|
33
|
+
expect(tools[name]!.annotations?.openWorldHint).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(names)("%s declares nothing it has not earned", (name) => {
|
|
37
|
+
const annotations = tools[name]!.annotations ?? {};
|
|
38
|
+
// Only `openWorldHint` is declared. The others are claims this suite has
|
|
39
|
+
// not verified and a permission gate would have to take on trust: a fetch
|
|
40
|
+
// writes to the in-process response cache, so "read-only" is not plainly
|
|
41
|
+
// true of these handlers either.
|
|
42
|
+
expect(annotations.readOnlyHint).toBeUndefined();
|
|
43
|
+
expect(annotations.destructiveHint).toBeUndefined();
|
|
44
|
+
expect(annotations.idempotentHint).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("web tool exposure", () => {
|
|
49
|
+
it.each(names)("%s stays direct: declared to the model and callable from scripts", (name) => {
|
|
50
|
+
expect(tools[name]!.exposure ?? "direct").toBe("direct");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 2 — `web_search`, `fetch_content` and `get_search_content` as
|
|
3
|
+
* programmatic tools.
|
|
4
|
+
*
|
|
5
|
+
* Same referee as `todo`'s: the payloads come from the real handlers, and are
|
|
6
|
+
* validated against the `outputSchema` the registered tool declares
|
|
7
|
+
* (`pi` hands a codemode script `structuredContent` only when one is declared,
|
|
8
|
+
* and validates nothing). The providers and the cache are mocked so the shapes
|
|
9
|
+
* under test are the ones the real modules produce — `SearchResultItem`,
|
|
10
|
+
* the extractor's document fields, and `PassageResult` — not invented ones.
|
|
11
|
+
*
|
|
12
|
+
* The third group is the throw-to-`isError` change. All three handlers used to
|
|
13
|
+
* let a provider failure (or a cache miss) escape; `pi` turned that into an
|
|
14
|
+
* error result whose text was the message (`pi-agent-core: agent-loop.js`,
|
|
15
|
+
* `createErrorToolResult()`, which also attaches `details: {}`). Returning
|
|
16
|
+
* `isError: true` keeps that text byte-identical and moves the failure report
|
|
17
|
+
* out of the harness and into the handler, where the tool can say what it
|
|
18
|
+
* knows. See `AgentToolResult.isError`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
22
|
+
import { Value } from "typebox/value";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
|
|
25
|
+
const { executeSearch, extractContent, getResponse, findPassages } = vi.hoisted(() => ({
|
|
26
|
+
executeSearch: vi.fn(),
|
|
27
|
+
extractContent: vi.fn(),
|
|
28
|
+
getResponse: vi.fn(),
|
|
29
|
+
findPassages: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("./config.js", () => ({ loadConfig: () => ({}) }));
|
|
33
|
+
vi.mock("./providers/registry.js", () => ({ executeSearch }));
|
|
34
|
+
vi.mock("./extractors/pipeline.js", () => ({ extractContent }));
|
|
35
|
+
vi.mock("./storage/cache.js", () => ({
|
|
36
|
+
storeResponse: () => "web_test_1",
|
|
37
|
+
getResponse,
|
|
38
|
+
}));
|
|
39
|
+
vi.mock("./storage/find.js", () => ({ findPassages }));
|
|
40
|
+
|
|
41
|
+
type RegisteredTool = {
|
|
42
|
+
name: string;
|
|
43
|
+
outputSchema?: unknown;
|
|
44
|
+
execute: (
|
|
45
|
+
id: string,
|
|
46
|
+
params: Record<string, unknown>,
|
|
47
|
+
signal: AbortSignal | undefined,
|
|
48
|
+
onUpdate: unknown,
|
|
49
|
+
ctx: unknown,
|
|
50
|
+
) => Promise<{
|
|
51
|
+
content: Array<{ type: "text"; text: string }>;
|
|
52
|
+
details: unknown;
|
|
53
|
+
structuredContent?: unknown;
|
|
54
|
+
isError?: boolean;
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function register(): Record<string, RegisteredTool> {
|
|
59
|
+
const tools: Record<string, RegisteredTool> = {};
|
|
60
|
+
registerTools({
|
|
61
|
+
registerTool: (t: unknown) => {
|
|
62
|
+
const tool = t as RegisteredTool;
|
|
63
|
+
tools[tool.name] = tool;
|
|
64
|
+
},
|
|
65
|
+
} as never);
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tools = register();
|
|
70
|
+
|
|
71
|
+
function check(tool: RegisteredTool, value: unknown): boolean {
|
|
72
|
+
expect(tool.outputSchema, `${tool.name} declares no outputSchema`).toBeDefined();
|
|
73
|
+
return Value.Check(tool.outputSchema as never, value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.clearAllMocks();
|
|
78
|
+
executeSearch.mockResolvedValue({
|
|
79
|
+
provider: "test-provider",
|
|
80
|
+
results: [
|
|
81
|
+
{ title: "First", url: "https://example.com/1", snippet: "snap one" },
|
|
82
|
+
{
|
|
83
|
+
title: "Second",
|
|
84
|
+
url: "https://example.com/2",
|
|
85
|
+
snippet: "snap two",
|
|
86
|
+
publishedDate: "2026-01-02",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
extractContent.mockResolvedValue({
|
|
91
|
+
url: "https://example.com/page",
|
|
92
|
+
title: "A page",
|
|
93
|
+
markdown: "# A page\nbody text",
|
|
94
|
+
wordCount: 42,
|
|
95
|
+
status: 200,
|
|
96
|
+
extractor: "readability",
|
|
97
|
+
});
|
|
98
|
+
getResponse.mockReturnValue({
|
|
99
|
+
id: "web_test_1",
|
|
100
|
+
type: "search",
|
|
101
|
+
content: "cached body",
|
|
102
|
+
timestamp: 1,
|
|
103
|
+
});
|
|
104
|
+
findPassages.mockReturnValue([{ passage: "…cached body…", offset: 2, match: "body" }]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("outputSchema declarations", () => {
|
|
108
|
+
it("all three web tools declare one", () => {
|
|
109
|
+
for (const name of ["web_search", "fetch_content", "get_search_content"]) {
|
|
110
|
+
expect(tools[name]!.outputSchema, `${name} declares no outputSchema`).toBeDefined();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("web_search", () => {
|
|
117
|
+
it("success → structuredContent mirrors the details the renderer already uses", async () => {
|
|
118
|
+
const tool = tools.web_search!;
|
|
119
|
+
const result = await tool.execute("c1", { query: "pi" }, undefined, undefined, {});
|
|
120
|
+
expect(result.content[0]!.text).toBe(
|
|
121
|
+
"[First](https://example.com/1)\n[Second](https://example.com/2)",
|
|
122
|
+
);
|
|
123
|
+
expect(result.structuredContent).toEqual({
|
|
124
|
+
responseId: "web_test_1",
|
|
125
|
+
provider: "test-provider",
|
|
126
|
+
resultCount: 2,
|
|
127
|
+
results: [
|
|
128
|
+
{ title: "First", url: "https://example.com/1", snippet: "snap one" },
|
|
129
|
+
{
|
|
130
|
+
title: "Second",
|
|
131
|
+
url: "https://example.com/2",
|
|
132
|
+
snippet: "snap two",
|
|
133
|
+
publishedDate: "2026-01-02",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("a provider that rejects comes back as isError, not a thrown error", async () => {
|
|
141
|
+
executeSearch.mockRejectedValueOnce(new Error("provider exploded"));
|
|
142
|
+
const tool = tools.web_search!;
|
|
143
|
+
const result = await tool.execute("c1", { query: "pi" }, undefined, undefined, {});
|
|
144
|
+
expect(result.isError).toBe(true);
|
|
145
|
+
// Same text pi's own catch produced from the throw.
|
|
146
|
+
expect(result.content[0]!.text).toBe("provider exploded");
|
|
147
|
+
expect("structuredContent" in result).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("a hit with no publishedDate omits the optional, and still validates", async () => {
|
|
151
|
+
executeSearch.mockResolvedValueOnce({
|
|
152
|
+
provider: "p",
|
|
153
|
+
results: [{ title: "No date", url: "https://example.com/nd", snippet: "s" }],
|
|
154
|
+
});
|
|
155
|
+
const tool = tools.web_search!;
|
|
156
|
+
const result = await tool.execute("c1", { query: "pi" }, undefined, undefined, {});
|
|
157
|
+
expect((result.structuredContent as { results: Array<Record<string, unknown>> }).results[0]).toEqual({
|
|
158
|
+
title: "No date",
|
|
159
|
+
url: "https://example.com/nd",
|
|
160
|
+
snippet: "s",
|
|
161
|
+
});
|
|
162
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("zero hits → results: [], still valid", async () => {
|
|
166
|
+
executeSearch.mockResolvedValueOnce({ provider: "p", results: [] });
|
|
167
|
+
const tool = tools.web_search!;
|
|
168
|
+
const result = await tool.execute("c1", { query: "nothing matches this" }, undefined, undefined, {});
|
|
169
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", provider: "p", resultCount: 0, results: [] });
|
|
170
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("fetch_content", () => {
|
|
175
|
+
it("success → structuredContent mirrors the details", async () => {
|
|
176
|
+
const tool = tools.fetch_content!;
|
|
177
|
+
const result = await tool.execute("c2", { url: "https://example.com/page" }, undefined, undefined, {});
|
|
178
|
+
expect(result.content[0]!.text).toBe("# A page\nbody text");
|
|
179
|
+
expect(result.structuredContent).toEqual({
|
|
180
|
+
responseId: "web_test_1",
|
|
181
|
+
url: "https://example.com/page",
|
|
182
|
+
title: "A page",
|
|
183
|
+
wordCount: 42,
|
|
184
|
+
status: 200,
|
|
185
|
+
extractor: "readability",
|
|
186
|
+
content: "# A page\nbody text",
|
|
187
|
+
snippet: "# A page\nbody text",
|
|
188
|
+
});
|
|
189
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("an extractor that rejects comes back as isError, not a thrown error", async () => {
|
|
193
|
+
extractContent.mockRejectedValueOnce(new Error("HTTP 404"));
|
|
194
|
+
const tool = tools.fetch_content!;
|
|
195
|
+
const result = await tool.execute("c2", { url: "https://example.com/x" }, undefined, undefined, {});
|
|
196
|
+
expect(result.isError).toBe(true);
|
|
197
|
+
expect(result.content[0]!.text).toBe("HTTP 404");
|
|
198
|
+
expect("structuredContent" in result).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("a document longer than the snippet keeps its FULL text in structuredContent", async () => {
|
|
202
|
+
// Declaring `outputSchema` moves a script from the text (the whole document)
|
|
203
|
+
// to the structured content (pi: `toScriptValue()`), so the structured
|
|
204
|
+
// payload has to carry what the text used to — a 500-character window is not
|
|
205
|
+
// the document. pi's own `bash` does the same: `structuredContent.output`
|
|
206
|
+
// holds the output, not a preview (`core/tools/bash.js`).
|
|
207
|
+
const long = "# Heading\n" + "word ".repeat(400);
|
|
208
|
+
extractContent.mockResolvedValueOnce({
|
|
209
|
+
url: "https://example.com/long",
|
|
210
|
+
title: "Long page",
|
|
211
|
+
markdown: long,
|
|
212
|
+
wordCount: 401,
|
|
213
|
+
status: 200,
|
|
214
|
+
extractor: "readability",
|
|
215
|
+
});
|
|
216
|
+
const tool = tools.fetch_content!;
|
|
217
|
+
const result = await tool.execute("c2", { url: "https://example.com/long" }, undefined, undefined, {});
|
|
218
|
+
const structured = result.structuredContent as Record<string, unknown>;
|
|
219
|
+
// The model still reads the whole document from `content`…
|
|
220
|
+
expect(result.content[0]!.text).toBe(long);
|
|
221
|
+
// …and so does a script, which never sees `content`.
|
|
222
|
+
expect(structured["content"]).toBe(long);
|
|
223
|
+
// The glanceable window stays, unchanged.
|
|
224
|
+
expect(structured["snippet"]).toBe(long.slice(0, 500));
|
|
225
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("get_search_content", () => {
|
|
230
|
+
it("content branch → { responseId, content }", async () => {
|
|
231
|
+
const tool = tools.get_search_content!;
|
|
232
|
+
const result = await tool.execute("c3", { responseId: "web_test_1" }, undefined, undefined, {});
|
|
233
|
+
expect(result.content[0]!.text).toBe("cached body");
|
|
234
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", content: "cached body" });
|
|
235
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("offset/limit branch → the slice actually handed back", async () => {
|
|
239
|
+
const tool = tools.get_search_content!;
|
|
240
|
+
const result = await tool.execute(
|
|
241
|
+
"c3",
|
|
242
|
+
{ responseId: "web_test_1", offset: 1, limit: 3 },
|
|
243
|
+
undefined,
|
|
244
|
+
undefined,
|
|
245
|
+
{},
|
|
246
|
+
);
|
|
247
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", content: "ach" });
|
|
248
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("limit 0 → empty content, still valid against the schema", async () => {
|
|
252
|
+
const tool = tools.get_search_content!;
|
|
253
|
+
const result = await tool.execute("c3", { responseId: "web_test_1", limit: 0 }, undefined, undefined, {});
|
|
254
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", content: "" });
|
|
255
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("findText branch → { responseId, matches }, passages with offset and match", async () => {
|
|
259
|
+
const tool = tools.get_search_content!;
|
|
260
|
+
const result = await tool.execute(
|
|
261
|
+
"c3",
|
|
262
|
+
{ responseId: "web_test_1", findText: "body" },
|
|
263
|
+
undefined,
|
|
264
|
+
undefined,
|
|
265
|
+
{},
|
|
266
|
+
);
|
|
267
|
+
expect(result.content[0]!.text).toBe(JSON.stringify([{ passage: "…cached body…", offset: 2, match: "body" }]));
|
|
268
|
+
expect(result.structuredContent).toEqual({
|
|
269
|
+
responseId: "web_test_1",
|
|
270
|
+
matches: [{ passage: "…cached body…", offset: 2, match: "body" }],
|
|
271
|
+
});
|
|
272
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("an unknown responseId is an isError result, not a thrown Error", async () => {
|
|
276
|
+
getResponse.mockReturnValue(undefined);
|
|
277
|
+
const tool = tools.get_search_content!;
|
|
278
|
+
const result = await tool.execute("c3", { responseId: "web_missing" }, undefined, undefined, {});
|
|
279
|
+
expect(result.isError).toBe(true);
|
|
280
|
+
// Byte-identical to the text pi built from `throw new Error("Response not found")`.
|
|
281
|
+
expect(result.content[0]!.text).toBe("Response not found");
|
|
282
|
+
expect("structuredContent" in result).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("findText with no matches → matches: [] — the union's second branch, empty", async () => {
|
|
286
|
+
findPassages.mockReturnValueOnce([]);
|
|
287
|
+
const tool = tools.get_search_content!;
|
|
288
|
+
const result = await tool.execute(
|
|
289
|
+
"c3",
|
|
290
|
+
{ responseId: "web_test_1", findText: "not in the cache" },
|
|
291
|
+
undefined,
|
|
292
|
+
undefined,
|
|
293
|
+
{},
|
|
294
|
+
);
|
|
295
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", matches: [] });
|
|
296
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("an offset past the end → empty content, first branch still valid", async () => {
|
|
300
|
+
const tool = tools.get_search_content!;
|
|
301
|
+
const result = await tool.execute("c3", { responseId: "web_test_1", offset: 999 }, undefined, undefined, {});
|
|
302
|
+
expect(result.structuredContent).toEqual({ responseId: "web_test_1", content: "" });
|
|
303
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
4
|
import { executeSearch } from "./providers/registry.js";
|
|
5
|
+
import type { SearchResultItem } from "./providers/types.js";
|
|
5
6
|
import { extractContent } from "./extractors/pipeline.js";
|
|
7
|
+
import type { ExtractedDoc } from "./extractors/types.js";
|
|
6
8
|
import { storeResponse, getResponse } from "./storage/cache.js";
|
|
7
9
|
import { findPassages } from "./storage/find.js";
|
|
8
10
|
import {
|
|
@@ -11,6 +13,81 @@ import {
|
|
|
11
13
|
renderGetSearchContentCall, renderGetSearchContentResult
|
|
12
14
|
} from "./renderer.js";
|
|
13
15
|
|
|
16
|
+
// ── Output schemas ───────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// Each schema describes the `structuredContent` the handler really returns, so
|
|
19
|
+
// a codemode script resolves to structure where it used to resolve to the
|
|
20
|
+
// text (`pi: dist/extensions/codemode/execute.js`, `toScriptValue()`). pi does
|
|
21
|
+
// not validate one against the other, so `tools.output-schema.test.ts` runs the
|
|
22
|
+
// handlers and validates the payloads against these declarations.
|
|
23
|
+
//
|
|
24
|
+
// Failure results carry no structured content: a provider that failed, a page
|
|
25
|
+
// that could not be fetched and a cache miss are all calls that produced no
|
|
26
|
+
// observation, so a script keeps rejecting with the same text it rejected with
|
|
27
|
+
// before these schemas existed. (pi's own `bash` does the opposite for a
|
|
28
|
+
// non-zero exit, where the failing command *is* the observation.)
|
|
29
|
+
//
|
|
30
|
+
// Exposure is left at `direct` for all three on purpose: `codemode`/`deferred`
|
|
31
|
+
// would take the declarations away from the model, and with no virtual model and
|
|
32
|
+
// no MCP server registered (ADR 0029) nothing would hand them back.
|
|
33
|
+
|
|
34
|
+
/** One hit, as the providers produce them (`SearchResultItem`). */
|
|
35
|
+
const SearchResultItemSchema = Type.Object({
|
|
36
|
+
title: Type.String(),
|
|
37
|
+
url: Type.String(),
|
|
38
|
+
snippet: Type.String(),
|
|
39
|
+
publishedDate: Type.Optional(Type.String()),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const WebSearchOutputSchema = Type.Object({
|
|
43
|
+
/** Id to hand `get_search_content` to page through this response. */
|
|
44
|
+
responseId: Type.String(),
|
|
45
|
+
provider: Type.String(),
|
|
46
|
+
resultCount: Type.Number(),
|
|
47
|
+
results: Type.Array(SearchResultItemSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const FetchContentOutputSchema = Type.Object({
|
|
51
|
+
responseId: Type.String(),
|
|
52
|
+
url: Type.String({ description: "The URL the document was read from, after redirects" }),
|
|
53
|
+
title: Type.String(),
|
|
54
|
+
wordCount: Type.Number(),
|
|
55
|
+
status: Type.Number({ description: "HTTP status of the fetch" }),
|
|
56
|
+
extractor: Type.String(),
|
|
57
|
+
/**
|
|
58
|
+
* The whole document, the same text the `content` block carries. A script
|
|
59
|
+
* cannot read that block — declaring `outputSchema` makes pi hand it this
|
|
60
|
+
* object instead (`toScriptValue()`) — so the document itself belongs here,
|
|
61
|
+
* as it does in pi's own `bash`, whose `structuredContent.output` is the
|
|
62
|
+
* output rather than a preview (`core/tools/bash.js`).
|
|
63
|
+
*/
|
|
64
|
+
content: Type.String({ description: "The full extracted document, exactly as the content block reports it" }),
|
|
65
|
+
/** First 500 characters of `content`, for a glanceable confirmation. */
|
|
66
|
+
snippet: Type.String(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const PassageOutputSchema = Type.Object({
|
|
70
|
+
passage: Type.String(),
|
|
71
|
+
offset: Type.Number(),
|
|
72
|
+
match: Type.String(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The two shapes this tool can answer with: passages for `findText`, text for
|
|
76
|
+
// everything else.
|
|
77
|
+
const GetSearchContentOutputSchema = Type.Union([
|
|
78
|
+
Type.Object({ responseId: Type.String(), content: Type.String() }),
|
|
79
|
+
Type.Object({ responseId: Type.String(), matches: Type.Array(PassageOutputSchema) }),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The text pi builds from a thrown error (`pi-agent-core: agent-loop.js`,
|
|
84
|
+
* `createErrorToolResult()`), so reporting a failure as `isError` instead of
|
|
85
|
+
* throwing leaves the model-facing text byte-identical.
|
|
86
|
+
*/
|
|
87
|
+
function failureText(error: unknown): string {
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
14
91
|
export function registerTools(pi: ExtensionAPI) {
|
|
15
92
|
pi.registerTool({
|
|
16
93
|
name: "web_search",
|
|
@@ -25,20 +102,38 @@ export function registerTools(pi: ExtensionAPI) {
|
|
|
25
102
|
domainFilter: Type.Optional(Type.Array(Type.String())),
|
|
26
103
|
proxy: Type.Optional(Type.String()),
|
|
27
104
|
}),
|
|
105
|
+
outputSchema: WebSearchOutputSchema,
|
|
106
|
+
// Results come from outside this session's domain, which is what the MCP
|
|
107
|
+
// hint means. Author-declared and unverified: pi documents `annotations` as
|
|
108
|
+
// the input for a permission gate, not as a guarantee.
|
|
109
|
+
annotations: { openWorldHint: true },
|
|
28
110
|
renderCall: renderWebSearchCall,
|
|
29
111
|
renderResult: renderWebSearchResult,
|
|
30
112
|
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
|
|
31
|
-
|
|
113
|
+
// `pi` comes from the enclosing registerTools: `getSettings()` is an
|
|
114
|
+
// ExtensionAPI method, so a tool's own ctx cannot answer it.
|
|
115
|
+
const config = loadConfig(pi);
|
|
32
116
|
const queries = params.queries ?? (params.query ? [params.query] : []);
|
|
33
117
|
const proxy = params.proxy ?? config.proxy;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
118
|
+
let search: { provider: string; results: SearchResultItem[] };
|
|
119
|
+
try {
|
|
120
|
+
search = await executeSearch(queries, {
|
|
121
|
+
...(params.numResults !== undefined && { numResults: params.numResults }),
|
|
122
|
+
...(params.recencyFilter !== undefined && { recencyFilter: params.recencyFilter }),
|
|
123
|
+
...(params.domainFilter !== undefined && { domainFilter: params.domainFilter }),
|
|
124
|
+
...(proxy !== undefined && { proxy }),
|
|
125
|
+
signal
|
|
126
|
+
}, config);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Reported, not thrown: same text pi would have built from the throw.
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: failureText(error) }],
|
|
131
|
+
details: { error: failureText(error) },
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const { provider, results } = search;
|
|
136
|
+
|
|
42
137
|
const summary = results.map(r => `[${r.title}](${r.url})`).join('\n');
|
|
43
138
|
const responseId = storeResponse({
|
|
44
139
|
type: "search",
|
|
@@ -48,7 +143,18 @@ export function registerTools(pi: ExtensionAPI) {
|
|
|
48
143
|
|
|
49
144
|
return {
|
|
50
145
|
content: [{ type: "text", text: summary }],
|
|
51
|
-
details: { responseId, provider, resultCount: results.length, results }
|
|
146
|
+
details: { responseId, provider, resultCount: results.length, results },
|
|
147
|
+
structuredContent: {
|
|
148
|
+
responseId,
|
|
149
|
+
provider,
|
|
150
|
+
resultCount: results.length,
|
|
151
|
+
results: results.map((r) => ({
|
|
152
|
+
title: r.title,
|
|
153
|
+
url: r.url,
|
|
154
|
+
snippet: r.snippet,
|
|
155
|
+
...(r.publishedDate !== undefined && { publishedDate: r.publishedDate }),
|
|
156
|
+
})),
|
|
157
|
+
},
|
|
52
158
|
};
|
|
53
159
|
},
|
|
54
160
|
});
|
|
@@ -63,25 +169,47 @@ export function registerTools(pi: ExtensionAPI) {
|
|
|
63
169
|
prompt: Type.Optional(Type.String()),
|
|
64
170
|
proxy: Type.Optional(Type.String()),
|
|
65
171
|
}),
|
|
172
|
+
outputSchema: FetchContentOutputSchema,
|
|
173
|
+
annotations: { openWorldHint: true },
|
|
66
174
|
renderCall: renderFetchContentCall,
|
|
67
175
|
renderResult: renderFetchContentResult,
|
|
68
176
|
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
|
|
69
|
-
const config = loadConfig();
|
|
177
|
+
const config = loadConfig(pi);
|
|
70
178
|
const proxy = params.proxy ?? config.proxy;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
179
|
+
let doc: ExtractedDoc;
|
|
180
|
+
try {
|
|
181
|
+
doc = await extractContent(params.url, params.mode ?? "readable", {
|
|
182
|
+
...(params.prompt !== undefined && { prompt: params.prompt }),
|
|
183
|
+
...(proxy !== undefined && { proxy }),
|
|
184
|
+
signal: signal ?? undefined
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: failureText(error) }],
|
|
189
|
+
details: { error: failureText(error) },
|
|
190
|
+
isError: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
76
193
|
const responseId = storeResponse({
|
|
77
194
|
type: "fetch",
|
|
78
195
|
content: doc.markdown,
|
|
79
196
|
metadata: { url: doc.url, title: doc.title, wordCount: doc.wordCount, status: doc.status, extractor: doc.extractor },
|
|
80
197
|
});
|
|
198
|
+
const snippet = doc.markdown.slice(0, 500);
|
|
81
199
|
|
|
82
200
|
return {
|
|
83
201
|
content: [{ type: "text", text: doc.markdown }],
|
|
84
|
-
details: { responseId, url: doc.url, title: doc.title, wordCount: doc.wordCount, status: doc.status, extractor: doc.extractor, snippet
|
|
202
|
+
details: { responseId, url: doc.url, title: doc.title, wordCount: doc.wordCount, status: doc.status, extractor: doc.extractor, snippet } ,
|
|
203
|
+
structuredContent: {
|
|
204
|
+
responseId,
|
|
205
|
+
url: doc.url,
|
|
206
|
+
title: doc.title,
|
|
207
|
+
wordCount: doc.wordCount,
|
|
208
|
+
status: doc.status,
|
|
209
|
+
extractor: doc.extractor,
|
|
210
|
+
content: doc.markdown,
|
|
211
|
+
snippet,
|
|
212
|
+
},
|
|
85
213
|
};
|
|
86
214
|
},
|
|
87
215
|
});
|
|
@@ -96,21 +224,48 @@ export function registerTools(pi: ExtensionAPI) {
|
|
|
96
224
|
offset: Type.Optional(Type.Number()),
|
|
97
225
|
limit: Type.Optional(Type.Number()),
|
|
98
226
|
}),
|
|
227
|
+
outputSchema: GetSearchContentOutputSchema,
|
|
228
|
+
annotations: { openWorldHint: true },
|
|
99
229
|
renderCall: renderGetSearchContentCall,
|
|
100
230
|
renderResult: renderGetSearchContentResult,
|
|
101
231
|
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
102
232
|
const item = getResponse(params.responseId);
|
|
103
|
-
if (!item)
|
|
233
|
+
if (!item) {
|
|
234
|
+
// Reported, not thrown: pi turned this throw into an error result whose
|
|
235
|
+
// text was the message, and that text is what the model has been reading.
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: "Response not found" }],
|
|
238
|
+
details: { responseId: params.responseId, error: "Response not found" },
|
|
239
|
+
isError: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
104
242
|
|
|
105
243
|
if (params.findText) {
|
|
106
244
|
const matches = findPassages(item.content, [params.findText]);
|
|
107
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: JSON.stringify(matches) }],
|
|
247
|
+
details: { matches },
|
|
248
|
+
structuredContent: {
|
|
249
|
+
responseId: params.responseId,
|
|
250
|
+
matches: matches.map((m) => ({ passage: m.passage, offset: m.offset, match: m.match })),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
108
253
|
}
|
|
109
254
|
|
|
110
|
-
if (params.limit === 0)
|
|
255
|
+
if (params.limit === 0) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{ type: "text", text: "" }],
|
|
258
|
+
details: { content: "" },
|
|
259
|
+
structuredContent: { responseId: params.responseId, content: "" },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
111
262
|
|
|
112
263
|
const content = item.content.slice(params.offset ?? 0, params.limit ? (params.offset ?? 0) + params.limit : undefined);
|
|
113
|
-
return {
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: "text", text: content }],
|
|
266
|
+
details: { content },
|
|
267
|
+
structuredContent: { responseId: params.responseId, content },
|
|
268
|
+
};
|
|
114
269
|
},
|
|
115
270
|
});
|
|
116
271
|
}
|