@normful/parallel-web-pi-extension 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Parallel Web
4
+ Copyright (c) 2026 Norman Sue
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+
package/README.md CHANGED
@@ -1,3 +1,232 @@
1
- # Temporary Holding Version
1
+ # @normful/parallel-web-pi-extension
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi extension that adds `web_search`, `web_fetch`, and `web_research` backed by
4
+ Parallel. Keep your usual coding agent and give it web tools with one install.
5
+
6
+ This is a fork: a manually maintained copy of `@parallel-web/pi-extension` 1.3.0
7
+ from `parallel-web/parallel-npm-packages`, renamed and released here. The
8
+ upstream `@parallel-web/oauth` helper (private on npm) is vendored into
9
+ `src/oauth/` so the package builds and tests standalone.
10
+
11
+ Install it with:
12
+ ```
13
+ pi install npm:@normful/parallel-web-pi-extension
14
+ ```
15
+
16
+ ## What It Does
17
+
18
+ - Registers `web_search`
19
+ - Registers `web_fetch`
20
+ - Registers `web_research` for synthesized answers with sources through the
21
+ Responses API
22
+ - Registers a `parallel` auth provider, so Pi's own `/login parallel` runs the
23
+ Parallel browser OAuth flow and stores the API key in Pi's auth store
24
+ (`auth.json`) alongside every other provider credential
25
+ - Adds a `parallel-login` command that reports current auth status
26
+
27
+ Auth resolution order (owned by Pi, not the extension):
28
+
29
+ 1. The credential Pi stored for provider `parallel`
30
+ 2. `PARALLEL_API_KEY`
31
+
32
+ Run `/parallel-login` inside Pi to check whether Parallel is configured. To
33
+ remove a stored credential, run `/logout` and select Parallel. Environment
34
+ variables are not affected by Pi's logout flow.
35
+
36
+ Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer.
37
+
38
+ ## Web Research
39
+
40
+ After installing this extension, run `/login parallel` and ask your usual Pi
41
+ agent a research question. No additional package or model selection is needed:
42
+
43
+ ```text
44
+ Compare the current Node.js compatibility of Node and Bun for a production API server. Research the tradeoffs and cite primary sources.
45
+ ```
46
+
47
+ The agent can call the research tool directly:
48
+
49
+ ```javascript
50
+ web_research({
51
+ query: "Compare the current Node.js compatibility of Node and Bun for a production API server. Cite primary sources.",
52
+ effort: "medium"
53
+ });
54
+ ```
55
+
56
+ | Tool | Use it for |
57
+ | --- | --- |
58
+ | `web_research` | A complete answer that needs web research and synthesis |
59
+ | `web_search` | Discovering sources and raw excerpts to investigate yourself |
60
+ | `web_fetch` | Reading known URLs or checking original sources |
61
+
62
+ Start with a complete, self-contained `query`. Research does not see the Pi
63
+ conversation or local files, so include relevant constraints and only context
64
+ that is safe to send.
65
+
66
+ When available, the result starts with a `Response ID`. For a focused follow-up
67
+ on the same investigation, pass that ID as `previous_response_id`:
68
+
69
+ ```javascript
70
+ web_research({
71
+ query: "Which of those compatibility gaps would matter for an API server that uses native Node.js addons?",
72
+ previous_response_id: "resp_..." // Copy the actual ID from the previous result.
73
+ });
74
+ ```
75
+
76
+ Use the new ID returned by each follow-up to continue from its answer. Omit the
77
+ ID for a new or unrelated question. Calls never chain automatically, and the
78
+ extension keeps no local research-session state. Continuation reuses saved
79
+ research context, including earlier answer summaries, not every internal step
80
+ of the research process. IDs are opaque; the tool accepts non-empty IDs up to
81
+ 512 characters without whitespace or control characters.
82
+
83
+ Saved context is not guaranteed to remain available, and continuation is not
84
+ supported for zero data retention (ZDR) accounts. An unavailable ID or a ZDR
85
+ restriction returns an error without retrying as fresh research. A valid
86
+ answer without a usable new ID is still returned, but cannot be continued
87
+ through that result. If Pi loses an ID during conversation compaction, do not
88
+ invent a replacement.
89
+
90
+ `effort` is optional and defaults to `medium`, matching the Responses API
91
+ default. Use `low` for focused lookups, `medium` for general research, and
92
+ `high` for extensive research. Responses is billed per successful call; see
93
+ the [current pricing](https://docs.parallel.ai/getting-started/pricing).
94
+
95
+ Each invocation makes one non-streaming `POST /v1/responses` request, with no
96
+ automatic retries or background jobs. The local deadline
97
+ is 120 seconds, including reading the response; slower valid research can
98
+ exceed this client limit. Cancelling the tool
99
+ aborts the local request on a best-effort basis; it does not confirm that work
100
+ stopped on the server. A manual retry is a new request and may incur a new charge.
101
+
102
+ The request contains only the fixed research instructions and explicit query,
103
+ with the selected effort and `previous_response_id` only when explicitly
104
+ supplied. It does not automatically forward parent history,
105
+ local files, cwd, environment variables, Pi tools, or session metadata.
106
+ Anything the calling agent includes in `query` is sent to Parallel.
107
+
108
+ The tool rejects requests over 20,000 combined instruction and input
109
+ characters, including the separator the API counts, before sending them.
110
+ It preserves the answer and renders returned HTTP(S) citations as a
111
+ deduplicated Markdown source list. When a citation identifies a passage,
112
+ the source includes that exact quoted answer text and its location in the
113
+ original text part. These are passages from the answer, not excerpts from
114
+ the source page. Unresolved citation ranges keep the source link without
115
+ inventing a passage. Results that exceed Pi's output limits are shown as a
116
+ marked preview with a path to the complete answer and sources in a private
117
+ temporary file. The response ID stays visible in the preview and is included
118
+ in the complete report.
119
+
120
+ ## Dogfooding Locally
121
+
122
+ Pi transpiles TypeScript extensions with jiti, so it loads `src/index.ts`
123
+ directly. There is no build step: the package ships source.
124
+
125
+ ### Option 1: Load It Directly With `pi -e`
126
+
127
+ This is the fastest way to test a local checkout.
128
+
129
+ From the repo root:
130
+
131
+ ```bash
132
+ pi --no-extensions --no-skills -e ./src/index.ts
133
+ ```
134
+
135
+ If the extension loads successfully, Pi will have:
136
+
137
+ - the `web_search` tool
138
+ - the `web_fetch` tool
139
+ - `parallel` listed under `/login`
140
+ - the `parallel-login` status command
141
+ - the `web_research` tool
142
+ - per-session Parallel `session_id` reuse inside that Pi session
143
+
144
+ ### Option 2: Symlink It Into Pi Extensions
145
+
146
+ This is better if you want Pi to auto-discover it and support `/reload`.
147
+
148
+ Then symlink the package directory into Pi's global extensions folder:
149
+
150
+ ```bash
151
+ mkdir -p ~/.pi/agent/extensions
152
+ ln -s \
153
+ "$PWD" \
154
+ ~/.pi/agent/extensions/normful-parallel-pi-extension
155
+ ```
156
+
157
+ Then start Pi normally:
158
+
159
+ ```bash
160
+ pi
161
+ ```
162
+
163
+ After code changes, run `/reload` inside Pi.
164
+
165
+ ## Local Auth Testing
166
+
167
+ ### Use Stored Pi Auth
168
+
169
+ Inside Pi, run:
170
+
171
+ ```text
172
+ /login parallel
173
+ ```
174
+
175
+ That opens the browser for Parallel OAuth. On success, Pi stores the API key in
176
+ its auth store under `parallel`. Run `/parallel-login` to see the current status,
177
+ and run `/logout` and select Parallel to remove the stored credential.
178
+
179
+ ### Use Environment Variable Instead
180
+
181
+ ```bash
182
+ export PARALLEL_API_KEY=your_key_here
183
+ pi --no-extensions --no-skills -e ./src/index.ts
184
+ ```
185
+
186
+ Note: stored Pi auth is preferred over `PARALLEL_API_KEY` if both exist.
187
+
188
+ ### Override The OAuth Platform URL
189
+
190
+ For local or staging OAuth testing, set `PARALLEL_PLATFORM_URL` before starting Pi:
191
+
192
+ ```bash
193
+ export PARALLEL_PLATFORM_URL=https://your-platform-host
194
+ pi --no-extensions --no-skills -e ./src/index.ts
195
+ ```
196
+
197
+ This changes the browser login endpoints used by `/login parallel`.
198
+
199
+ ## Quick Smoke Test
200
+
201
+ Once Pi is running with the extension loaded, ask something that should force web usage, for example:
202
+
203
+ ```text
204
+ Search the web for the latest Parallel docs on OAuth and summarize them.
205
+ ```
206
+
207
+ Or ask Pi to call the tool explicitly.
208
+
209
+ `web_search` requires `search_queries`.
210
+
211
+ `web_fetch` accepts multiple URLs in a single call, so the agent can batch extraction work instead of parallelizing many single-URL requests.
212
+
213
+ ## Dev Loop
214
+
215
+ ```bash
216
+ npm test
217
+ npm run typecheck
218
+ ```
219
+
220
+ ## Notes
221
+
222
+ - Search and Fetch use the `parallel-web` TypeScript SDK; Research calls the
223
+ Responses endpoint directly.
224
+ - Search requests use Parallel SDK `fast` mode.
225
+ - Search requests include `client_model` when Pi has an active model selected.
226
+ - Search and extract requests reuse a generated `session_id` for the life of the current Pi session.
227
+ - The login flow tries to open your browser automatically.
228
+ - If automatic callback capture does not complete, the login dialog asks you to paste the callback URL.
229
+ - Credential storage is entirely Pi's; the extension only reads the resolved key
230
+ through `ctx.modelRegistry.getApiKeyForProvider("parallel")`.
231
+ - Research requests do not reuse the Search/Extract `session_id`.
232
+ - Skill suppression inside the extension is prompt-level only. If you want a clean dogfooding session without your usual skills list, start Pi with `--no-skills`.
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@normful/parallel-web-pi-extension",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Add Parallel web search, fetch, and research tools to your pi agent",
5
+ "author": "Norman Sue",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "pi": {
9
+ "image": "https://assets.parallel.ai/white-parallel-avatar-540.png",
10
+ "extensions": [
11
+ "./src/index.ts"
12
+ ]
13
+ },
14
+ "files": [
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "package.json",
18
+ "README.md"
19
+ ],
20
+ "dependencies": {
21
+ "parallel-web": "1.3.0",
22
+ "typebox": "^1.1.37"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": ">=0.83.0"
26
+ },
27
+ "devDependencies": {
28
+ "@earendil-works/pi-ai": ">=0.83.0",
29
+ "@types/node": "^24.0.0",
30
+ "@typescript-eslint/eslint-plugin": "^6.13.0",
31
+ "@typescript-eslint/parser": "^6.13.0",
32
+ "@vitest/coverage-v8": "^5.0.4",
33
+ "eslint": "^8.54.0",
34
+ "prettier": "^3.1.0",
35
+ "typescript": "^5.3.2",
36
+ "vitest": "^5.0.4"
37
+ },
38
+ "scripts": {
39
+ "test": "vitest run",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "eslint .",
42
+ "lint:fix": "eslint . --fix",
43
+ "format": "prettier --write .",
44
+ "format:check": "prettier --check ."
45
+ },
46
+ "keywords": [
47
+ "pi-package",
48
+ "pi.dev",
49
+ "pi agent",
50
+ "extension",
51
+ "parallel",
52
+ "research",
53
+ "web",
54
+ "search",
55
+ "fetch",
56
+ "ai"
57
+ ],
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/normful/parallel-web-pi-extension-fork.git"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,362 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdtemp, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ } from '@earendil-works/pi-coding-agent';
9
+ import {
10
+ DEFAULT_MAX_BYTES,
11
+ DEFAULT_MAX_LINES,
12
+ formatSize,
13
+ truncateHead,
14
+ } from '@earendil-works/pi-coding-agent';
15
+ import { Type } from 'typebox';
16
+ import {
17
+ MAX_RESPONSE_ID_LENGTH,
18
+ parseResearchInput,
19
+ runParallelResearch,
20
+ } from './parallel-responses';
21
+ import {
22
+ getParallelApiKey,
23
+ getParallelAuthStatus,
24
+ registerParallelAuthProvider,
25
+ } from './parallel-auth';
26
+ import {
27
+ isParallelAuthenticationError,
28
+ runParallelExtract,
29
+ runParallelSearch,
30
+ } from './parallel-client';
31
+
32
+ function truncateJson(value: unknown) {
33
+ const pretty = JSON.stringify(value, null, 2);
34
+ const truncation = truncateHead(pretty, {
35
+ maxLines: DEFAULT_MAX_LINES,
36
+ maxBytes: DEFAULT_MAX_BYTES,
37
+ });
38
+
39
+ let text = truncation.content;
40
+ if (truncation.truncated) {
41
+ text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
42
+ }
43
+ return text;
44
+ }
45
+
46
+ const SUPPRESSED_SKILL_NAMES = new Set([
47
+ 'parallel-cli-setup',
48
+ 'parallel-web-extract',
49
+ 'parallel-web-search',
50
+ ]);
51
+
52
+ function escapeRegex(value: string) {
53
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+ }
55
+
56
+ function suppressSkillsFromPrompt(systemPrompt: string) {
57
+ let nextPrompt = systemPrompt;
58
+
59
+ for (const skillName of SUPPRESSED_SKILL_NAMES) {
60
+ const pattern = new RegExp(
61
+ String.raw`\n?\s*<skill>\s*<name>${escapeRegex(skillName)}</name>[\s\S]*?<\/skill>`,
62
+ 'g'
63
+ );
64
+ nextPrompt = nextPrompt.replace(pattern, '');
65
+ }
66
+
67
+ return nextPrompt.replace(
68
+ /\n?<available_skills>\s*<\/available_skills>/g,
69
+ ''
70
+ );
71
+ }
72
+
73
+ const WEB_TOOL_GUIDANCE = {
74
+ web_research:
75
+ 'Use web_research for a complete answer that requires current web research and synthesis. Start with the full self-contained question, including constraints. For a focused follow-up, pass the latest returned Response ID as previous_response_id; omit it for unrelated research.',
76
+ web_search:
77
+ 'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
78
+ web_fetch:
79
+ 'Use web_fetch to read a known URL or inspect the original source behind a claim.',
80
+ };
81
+
82
+ export default function (pi: ExtensionAPI) {
83
+ const parallelSessionId = randomUUID();
84
+
85
+ registerParallelAuthProvider(pi);
86
+
87
+ async function resolveApiKey(ctx: ExtensionContext) {
88
+ const apiKey = await getParallelApiKey(ctx);
89
+ if (apiKey) {
90
+ return apiKey;
91
+ }
92
+
93
+ throw new Error(
94
+ 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
95
+ );
96
+ }
97
+
98
+ async function runWithAuth<T>(
99
+ ctx: ExtensionContext,
100
+ request: (apiKey: string) => Promise<T>,
101
+ isAuthenticationError = isParallelAuthenticationError
102
+ ) {
103
+ const apiKey = await resolveApiKey(ctx);
104
+
105
+ try {
106
+ return await request(apiKey);
107
+ } catch (error) {
108
+ if (!isAuthenticationError(error)) {
109
+ throw error;
110
+ }
111
+
112
+ if (process.env.PARALLEL_API_KEY) {
113
+ throw new Error(
114
+ 'Parallel rejected PARALLEL_API_KEY. Update or unset it, then try again.'
115
+ );
116
+ }
117
+
118
+ throw new Error(
119
+ 'Parallel rejected the stored credential. Run `/login parallel` in Pi to sign in again.'
120
+ );
121
+ }
122
+ }
123
+
124
+ pi.registerCommand('parallel-login', {
125
+ description: 'Show Parallel authentication status and how to sign in',
126
+ handler: async (_args, ctx) => {
127
+ // getProviderAuthStatus is a synchronous snapshot that only knows about
128
+ // stored credentials, so ask for the resolved key to catch the env var too.
129
+ const apiKey = await getParallelApiKey(ctx);
130
+ if (!apiKey) {
131
+ ctx.ui.notify(
132
+ 'Parallel is not authenticated. Run `/login parallel` to sign in, or set PARALLEL_API_KEY.',
133
+ 'info'
134
+ );
135
+ return;
136
+ }
137
+
138
+ const status = getParallelAuthStatus(ctx);
139
+ const source = status.label ?? status.source ?? 'PARALLEL_API_KEY';
140
+ const guidance = status.configured
141
+ ? 'Run `/login parallel` to replace the stored credential, or `/logout` and select Parallel to remove it.'
142
+ : 'Run `/login parallel` to store a credential, or unset PARALLEL_API_KEY to remove the current one.';
143
+ ctx.ui.notify(
144
+ `Parallel is authenticated (${source}). ${guidance}`,
145
+ 'info'
146
+ );
147
+ },
148
+ });
149
+
150
+ pi.on('before_agent_start', async (event) => {
151
+ const filteredPrompt = suppressSkillsFromPrompt(event.systemPrompt);
152
+ const selectedTools = event.systemPromptOptions.selectedTools ?? [];
153
+ const guidance = Object.entries(WEB_TOOL_GUIDANCE)
154
+ .filter(([tool]) => selectedTools.includes(tool))
155
+ .map(([, text]) => `- ${text}`);
156
+
157
+ if (guidance.length === 0) {
158
+ return filteredPrompt === event.systemPrompt
159
+ ? undefined
160
+ : { systemPrompt: filteredPrompt };
161
+ }
162
+
163
+ return {
164
+ systemPrompt: `${filteredPrompt}\n\n## Grounding and web usage\n\nUse available web tools when current information or source evidence would improve the answer.\n\n${guidance.join('\n')}\n\nWhen citing web evidence, include the returned source URLs as clickable Markdown links. Do not replace source links with source names alone.`,
165
+ };
166
+ });
167
+
168
+ pi.registerTool({
169
+ name: 'web_research',
170
+ label: 'Web Research',
171
+ description:
172
+ "Answer a complete question using Parallel's Responses API. Delegates multi-step web research and returns a synthesized answer with sources, not raw search results. One call can handle the full research question.",
173
+ promptSnippet:
174
+ 'Get a cited answer to a complete web research question using Parallel',
175
+ promptGuidelines: [
176
+ 'Use web_research for questions that need web research and synthesis; pass the complete question in one call before making focused follow-ups.',
177
+ 'Include dates, constraints, and relevant context in query. Research cannot see this Pi conversation or local files; include only context that is safe to send.',
178
+ 'For a follow-up on the same investigation, pass its latest returned Response ID as previous_response_id to reuse prior research context. Omit it for unrelated questions. If no ID was returned, continuation is unavailable.',
179
+ 'Use low effort for focused lookups, medium for general research, and high only for extensive research. The default is medium.',
180
+ ],
181
+ parameters: Type.Object({
182
+ query: Type.String({
183
+ minLength: 1,
184
+ description:
185
+ 'The research question and constraints that are safe to send. Make a new question self-contained; a follow-up with previous_response_id may refer to prior research. Research cannot see the Pi conversation or local files.',
186
+ }),
187
+ effort: Type.Optional(
188
+ Type.Union(
189
+ [Type.Literal('low'), Type.Literal('medium'), Type.Literal('high')],
190
+ {
191
+ description:
192
+ 'Research depth: low for focused lookups, medium for general research (default), high for extensive research.',
193
+ }
194
+ )
195
+ ),
196
+ previous_response_id: Type.Optional(
197
+ Type.String({
198
+ minLength: 1,
199
+ maxLength: MAX_RESPONSE_ID_LENGTH,
200
+ description:
201
+ 'The latest Response ID returned by web_research for this investigation. Reuses prior research context for a follow-up. Omit for a new or unrelated question; never invent an ID.',
202
+ })
203
+ ),
204
+ }),
205
+ // Reject invalid raw values before Pi coerces them to schema types.
206
+ prepareArguments: parseResearchInput,
207
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
208
+ onUpdate?.({
209
+ content: [{ type: 'text', text: 'Researching the web...' }],
210
+ details: { provider: 'parallel', product: 'responses' },
211
+ });
212
+ const result = await runWithAuth(
213
+ ctx,
214
+ (apiKey) => runParallelResearch(apiKey, params, signal),
215
+ // Responses errors carry HTTP status. Their text can echo opaque IDs,
216
+ // so words such as "unauthorized" do not identify rejected credentials.
217
+ (error) =>
218
+ error instanceof Error && 'status' in error && error.status === 401
219
+ );
220
+ // Pi sends content to the parent, not details. Keep the ID ahead of the
221
+ // answer so a truncated preview still supports an explicit follow-up.
222
+ const report = result.responseId
223
+ ? `Response ID: ${result.responseId}\n\n${result.text}`
224
+ : result.text;
225
+ const preview = truncateHead(report, {
226
+ maxLines: DEFAULT_MAX_LINES,
227
+ maxBytes: DEFAULT_MAX_BYTES,
228
+ });
229
+ let text = preview.content;
230
+ let outputFile: string | undefined;
231
+ if (preview.truncated) {
232
+ // Keep the complete answer and citations available when Pi's context
233
+ // limit requires a preview. Normal research results create no file.
234
+ const directory = await mkdtemp(join(tmpdir(), 'parallel-research-'));
235
+ outputFile = join(directory, 'research.md');
236
+ await writeFile(outputFile, report, { mode: 0o600 });
237
+ const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`;
238
+ text =
239
+ truncateHead(report, {
240
+ maxLines: DEFAULT_MAX_LINES - 2,
241
+ maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice),
242
+ }).content + notice;
243
+ }
244
+ return {
245
+ content: [{ type: 'text', text }],
246
+ details: {
247
+ provider: 'parallel',
248
+ product: 'responses',
249
+ effort: result.effort,
250
+ ...(result.responseId ? { responseId: result.responseId } : {}),
251
+ ...(outputFile ? { outputFile } : {}),
252
+ },
253
+ };
254
+ },
255
+ });
256
+
257
+ pi.registerTool({
258
+ name: 'web_search',
259
+ label: 'Web Search',
260
+ description:
261
+ "Search the web using Parallel's Search API. Prefer this over generic browser-like search tools for current web results.",
262
+ promptSnippet:
263
+ "Search the web using Parallel's Search API for current information",
264
+ promptGuidelines: [
265
+ 'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
266
+ 'Provide 2-3 concise keyword search queries when possible; search_queries is required.',
267
+ ],
268
+ parameters: Type.Object({
269
+ objective: Type.String({
270
+ description:
271
+ 'Natural-language description of the underlying question or goal driving the search. Used together with search_queries to focus results on the most relevant content. Should be self-contained with enough context to understand the intent of the search.',
272
+ }),
273
+ search_queries: Type.Array(
274
+ Type.String({
275
+ description:
276
+ 'Concise keyword search queries, 3-6 words each. At least one query is required, provide 2-3 for best results. Used together with objective to focus results on the most relevant content.',
277
+ })
278
+ ),
279
+ }),
280
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
281
+ const result = await runWithAuth(ctx, (apiKey) =>
282
+ runParallelSearch(
283
+ apiKey,
284
+ {
285
+ objective: params.objective,
286
+ search_queries: params.search_queries,
287
+ client_model: ctx.model?.id,
288
+ session_id: parallelSessionId,
289
+ },
290
+ signal
291
+ )
292
+ );
293
+
294
+ return {
295
+ content: [{ type: 'text', text: truncateJson(result) }],
296
+ details: {
297
+ provider: 'parallel',
298
+ product: 'search',
299
+ },
300
+ };
301
+ },
302
+ });
303
+
304
+ pi.registerTool({
305
+ name: 'web_fetch',
306
+ label: 'Web Fetch',
307
+ description:
308
+ "Fetch and extract content from a URL using Parallel's Extract API. Prefer this over raw HTML fetch tools for readable content extraction.",
309
+ promptSnippet:
310
+ "Fetch and extract readable webpage content from one or more URLs using Parallel's Extract API",
311
+ promptGuidelines: [
312
+ 'Use web_fetch when the user provides one or more URLs and wants the page content or a clean extraction.',
313
+ 'Batch multiple URLs into one web_fetch call instead of parallelizing many single-URL calls.',
314
+ ],
315
+ parameters: Type.Object({
316
+ urls: Type.Array(
317
+ Type.String({
318
+ description:
319
+ 'List of URLs to extract content from. Must be valid HTTP/HTTPS URLs. Up to 20 URLs.',
320
+ })
321
+ ),
322
+ objective: Type.Optional(
323
+ Type.String({
324
+ description:
325
+ 'As in SearchRequest, a natural-language description of the underlying question or goal driving the request. Used together with search_queries to focus excerpts on the most relevant content.',
326
+ })
327
+ ),
328
+ search_queries: Type.Optional(
329
+ Type.Array(
330
+ Type.String({
331
+ description:
332
+ 'Optional keyword search queries, as in SearchRequest. Used together with objective to focus excerpts on the most relevant content.',
333
+ })
334
+ )
335
+ ),
336
+ }),
337
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
338
+ const result = await runWithAuth(ctx, (apiKey) =>
339
+ runParallelExtract(
340
+ apiKey,
341
+ {
342
+ urls: params.urls,
343
+ objective: params.objective,
344
+ search_queries: params.search_queries,
345
+ client_model: ctx.model?.id,
346
+ session_id: parallelSessionId,
347
+ },
348
+ signal
349
+ )
350
+ );
351
+
352
+ return {
353
+ content: [{ type: 'text', text: truncateJson(result) }],
354
+ details: {
355
+ provider: 'parallel',
356
+ product: 'extract',
357
+ urls: params.urls,
358
+ },
359
+ };
360
+ },
361
+ });
362
+ }