@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/web",
3
- "version": "2.10.3",
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.10.3",
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": "^0.87.0",
26
- "@earendil-works/pi-tui": "^0.87.0",
27
- "@types/node": "^22.0.0",
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.1.0",
34
- "@earendil-works/pi-tui": ">=0.1.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
- const config = loadConfig();
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 { loadConfig as loadSettings } from "@pi-archimedes/core/settings-io";
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
- export function loadConfig(): WebConfig {
14
- const fileConfig = loadSettings("archimedes.web", {}) as Record<string, any>;
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 || fileConfig.braveApiKey,
18
- tavilyApiKey: process.env.TAVILY_API_KEY || fileConfig.tavilyApiKey,
19
- openaiApiKey: process.env.OPENAI_API_KEY || fileConfig.openaiApiKey,
20
- perplexityApiKey: process.env.PERPLEXITY_API_KEY || fileConfig.perplexityApiKey,
21
- searxngUrl: process.env.SEARXNG_URL || fileConfig.searxngUrl,
22
- proxy: process.env.HTTP_PROXY || process.env.HTTPS_PROXY || fileConfig.proxy,
23
- defaultProvider: fileConfig.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
+ }
@@ -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
- const config = loadConfig();
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
- const { provider, results } = await executeSearch(queries, {
35
- ...(params.numResults !== undefined && { numResults: params.numResults }),
36
- ...(params.recencyFilter !== undefined && { recencyFilter: params.recencyFilter }),
37
- ...(params.domainFilter !== undefined && { domainFilter: params.domainFilter }),
38
- ...(proxy !== undefined && { proxy }),
39
- signal
40
- }, config);
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
- const doc = await extractContent(params.url, params.mode ?? "readable", {
72
- ...(params.prompt !== undefined && { prompt: params.prompt }),
73
- ...(proxy !== undefined && { proxy }),
74
- signal: signal ?? undefined
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: doc.markdown.slice(0, 500) }
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) throw new Error("Response not found");
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 { content: [{ type: "text", text: JSON.stringify(matches) }], details: { matches } };
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) return { content: [{ type: "text", text: "" }], details: { content: "" } };
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 { content: [{ type: "text", text: content }], details: { content } };
264
+ return {
265
+ content: [{ type: "text", text: content }],
266
+ details: { content },
267
+ structuredContent: { responseId: params.responseId, content },
268
+ };
114
269
  },
115
270
  });
116
271
  }