diff --git a/README.md b/README.md index f95d625..60a1da2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Monorepo for @parallel-web npm packages. - [`@parallel-web/ai-sdk-tools`](./packages/ai-sdk-tools) - AI SDK tools for Parallel Web - [`@parallel-web/dsh-web-search`](./packages/dsh-web-search) - Parallel Search provider for DeepSeek Harness - [`@parallel-web/opencode-plugin`](./packages/opencode-plugin) - Opencode plugin for Parallel Web -- [`@parallel-web/pi-extension`](./packages/pi-extension) - pi agent extension for Parallel Web +- [`@parallel-web/pi-extension`](./packages/pi-extension) - Pi web search, fetch, and cited research tools - `@parallel-web/oauth` - Internal, unpublished shared PKCE OAuth helper. Bundled into the opencode plugin and pi extension at build time (`noExternal`), so it is never installed by consumers and is intentionally marked `private`. ## Development diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index 95743e3..c9ffb50 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -1,6 +1,7 @@ # @parallel-web/pi-extension -Pi extension that adds `web_search` and `web_fetch` backed by Parallel. +Pi extension that adds `web_search`, `web_fetch`, and `web_research` backed by +Parallel. Keep your usual coding agent and give it web tools with one install. Install it with: ``` @@ -11,6 +12,8 @@ pi install npm:@parallel-web/pi-extension - Registers `web_search` - Registers `web_fetch` +- Registers `web_research` for synthesized answers with sources through the + Responses API - Registers a `parallel` auth provider, so Pi's own `/login parallel` runs the Parallel browser OAuth flow and stores the API key in Pi's auth store (`auth.json`) alongside every other provider credential @@ -21,11 +24,94 @@ Auth resolution order (owned by Pi, not the extension): 1. The credential Pi stored for provider `parallel` 2. `PARALLEL_API_KEY` -`/logout parallel` removes the stored credential, and -`pi auth check --provider parallel` reports whether it is configured. +Run `/parallel-login` inside Pi to check whether Parallel is configured. To +remove a stored credential, run `/logout` and select Parallel. Environment +variables are not affected by Pi's logout flow. Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer. +## Web Research + +After installing this extension, run `/login parallel` and ask your usual Pi +agent a research question. No additional package or model selection is needed: + +```text +Compare the current Node.js compatibility of Node and Bun for a production API server. Research the tradeoffs and cite primary sources. +``` + +The agent can call the research tool directly: + +```javascript +web_research({ + query: "Compare the current Node.js compatibility of Node and Bun for a production API server. Cite primary sources.", + effort: "medium" +}); +``` + +| Tool | Use it for | +| --- | --- | +| `web_research` | A complete answer that needs web research and synthesis | +| `web_search` | Discovering sources and raw excerpts to investigate yourself | +| `web_fetch` | Reading known URLs or checking original sources | + +Start with a complete, self-contained `query`. Research does not see the Pi +conversation or local files, so include relevant constraints and only context +that is safe to send. + +When available, the result starts with a `Response ID`. For a focused follow-up +on the same investigation, pass that ID as `previous_response_id`: + +```javascript +web_research({ + query: "Which of those compatibility gaps would matter for an API server that uses native Node.js addons?", + previous_response_id: "resp_..." // Copy the actual ID from the previous result. +}); +``` + +Use the new ID returned by each follow-up to continue from its answer. Omit the +ID for a new or unrelated question. Calls never chain automatically, and the +extension keeps no local research-session state. Continuation reuses saved +research context, including earlier answer summaries, not every internal step +of the research process. IDs are opaque; the tool accepts non-empty IDs up to +512 characters without whitespace or control characters. + +Saved context is not guaranteed to remain available, and continuation is not +supported for zero data retention (ZDR) accounts. An unavailable ID or a ZDR +restriction returns an error without retrying as fresh research. A valid +answer without a usable new ID is still returned, but cannot be continued +through that result. If Pi loses an ID during conversation compaction, do not +invent a replacement. + +`effort` is optional and defaults to `medium`, matching the Responses API +default. Use `low` for focused lookups, `medium` for general research, and +`high` for extensive research. Responses is billed per successful call; see +the [current pricing](https://docs.parallel.ai/getting-started/pricing). + +Each invocation makes one non-streaming `POST /v1/responses` request, with no +automatic retries or background jobs. The local deadline +is 120 seconds, including reading the response; slower valid research can +exceed this client limit. Cancelling the tool +aborts the local request on a best-effort basis; it does not confirm that work +stopped on the server. A manual retry is a new request and may incur a new charge. + +The request contains only the fixed research instructions and explicit query, +with the selected effort and `previous_response_id` only when explicitly +supplied. It does not automatically forward parent history, +local files, cwd, environment variables, Pi tools, or session metadata. +Anything the calling agent includes in `query` is sent to Parallel. + +The tool rejects requests over 20,000 combined instruction and input +characters, including the separator the API counts, before sending them. +It preserves the answer and renders returned HTTP(S) citations as a +deduplicated Markdown source list. When a citation identifies a passage, +the source includes that exact quoted answer text and its location in the +original text part. These are passages from the answer, not excerpts from +the source page. Unresolved citation ranges keep the source link without +inventing a passage. Results that exceed Pi's output limits are shown as a +marked preview with a path to the complete answer and sources in a private +temporary file. The response ID stays visible in the preview and is included +in the complete report. + ## Dogfooding Locally Build the extension first: @@ -50,6 +136,7 @@ If the extension loads successfully, Pi will have: - the `web_fetch` tool - `parallel` listed under `/login` - the `parallel-login` status command +- the `web_research` tool - per-session Parallel `session_id` reuse inside that Pi session ### Option 2: Symlink It Into Pi Extensions @@ -95,7 +182,7 @@ Inside Pi, run: That opens the browser for Parallel OAuth. On success, Pi stores the API key in its auth store under `parallel`. Run `/parallel-login` to see the current status, -and `/logout parallel` to remove the credential. +and run `/logout` and select Parallel to remove the stored credential. ### Use Environment Variable Instead @@ -141,7 +228,8 @@ pnpm --filter @parallel-web/pi-extension typecheck ## Notes -- The extension uses the `parallel-web` TypeScript SDK directly. +- Search and Fetch use the `parallel-web` TypeScript SDK; Research calls the + Responses endpoint directly. - Search requests use Parallel SDK `fast` mode. - Search requests include `client_model` when Pi has an active model selected. - Search and extract requests reuse a generated `session_id` for the life of the current Pi session. @@ -149,4 +237,5 @@ pnpm --filter @parallel-web/pi-extension typecheck - If automatic callback capture does not complete, the login dialog asks you to paste the callback URL. - Credential storage is entirely Pi's; the extension only reads the resolved key through `ctx.modelRegistry.getApiKeyForProvider("parallel")`. +- Research requests do not reuse the Search/Extract `session_id`. - 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`. diff --git a/packages/pi-extension/package.json b/packages/pi-extension/package.json index d694a67..80da69f 100644 --- a/packages/pi-extension/package.json +++ b/packages/pi-extension/package.json @@ -1,7 +1,7 @@ { "name": "@parallel-web/pi-extension", "version": "1.2.0", - "description": "Add web search and web fetch to your pi agent", + "description": "Add Parallel web search, fetch, and research tools to your pi agent", "author": "Parallel Web", "license": "MIT", "type": "module", @@ -41,6 +41,7 @@ "pi agent", "extension", "parallel", + "research", "web", "search", "fetch", diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index e0f1912..dd50101 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -1,4 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { validateToolArguments } from '@earendil-works/pi-ai'; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, +} from '@earendil-works/pi-coding-agent'; import type { ExtensionAPI, ExtensionContext, @@ -10,6 +17,7 @@ const mocks = vi.hoisted(() => ({ registerParallelAuthProvider: vi.fn(), runParallelSearch: vi.fn(), runParallelExtract: vi.fn(), + runParallelResearch: vi.fn(), isParallelAuthenticationError: vi.fn(), })); @@ -25,6 +33,11 @@ vi.mock('../parallel-client.js', () => ({ isParallelAuthenticationError: mocks.isParallelAuthenticationError, })); +vi.mock('../parallel-responses.js', async (importOriginal) => ({ + ...(await importOriginal()), + runParallelResearch: mocks.runParallelResearch, +})); + type MockPi = { on: ReturnType; registerCommand: ReturnType; @@ -70,6 +83,8 @@ function createToolContext(overrides: Record = {}) { } describe('@parallel-web/pi-extension', () => { + afterEach(() => vi.unstubAllGlobals()); + beforeEach(() => { vi.clearAllMocks(); delete process.env.PARALLEL_API_KEY; @@ -82,6 +97,18 @@ describe('@parallel-web/pi-extension', () => { expect(typeof module.default).toBe('function'); }); + it('registers research as a normal tool without another package', async () => { + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + expect(getRegisteredTool(pi, 'web_research')).toEqual( + expect.objectContaining({ + name: 'web_research', + execute: expect.any(Function), + }) + ); + }); + it('should register the login command and web tools', async () => { const extension = (await import('../index.js')).default; const pi = createMockPi(); @@ -97,7 +124,7 @@ describe('@parallel-web/pi-extension', () => { }) ); - expect(pi.registerTool).toHaveBeenCalledTimes(2); + expect(pi.registerTool).toHaveBeenCalledTimes(3); const searchTool = getRegisteredTool(pi, 'web_search'); expect(searchTool).toEqual( @@ -107,7 +134,7 @@ describe('@parallel-web/pi-extension', () => { description: expect.stringContaining("Parallel's Search API"), promptSnippet: expect.stringContaining("Parallel's Search API"), promptGuidelines: [ - 'Use web_search when the user asks for current web information, discovery, or source finding.', + 'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.', 'Provide 2-3 concise keyword search queries when possible; search_queries is required.', ], execute: expect.any(Function), @@ -148,7 +175,42 @@ describe('@parallel-web/pi-extension', () => { }); expect(result.systemPrompt).toContain('Grounding and web usage'); expect(result.systemPrompt).toContain('Use web_search'); - expect(result.systemPrompt).toContain('Use web_fetch'); + expect(result.systemPrompt).not.toContain('Use web_fetch'); + expect(result.systemPrompt).not.toContain('Use web_research'); + }); + + it('exposes routing guidance only for active web tools', async () => { + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const handler = getEventHandler(pi, 'before_agent_start'); + const all = await handler({ + systemPrompt: 'Base prompt', + systemPromptOptions: { + selectedTools: ['web_research', 'web_search', 'web_fetch'], + }, + }); + expect(all.systemPrompt).toContain( + 'Use web_research for a complete answer' + ); + expect(all.systemPrompt).toContain('Use web_search for source discovery'); + expect(all.systemPrompt).toContain('Use web_fetch to read a known URL'); + expect(all.systemPrompt).toContain( + 'source URLs as clickable Markdown links' + ); + const researchOnly = await handler({ + systemPrompt: 'Base prompt', + systemPromptOptions: { selectedTools: ['web_research'] }, + }); + expect(researchOnly.systemPrompt).toContain('Use web_research'); + expect(researchOnly.systemPrompt).not.toContain('Use web_search'); + expect(researchOnly.systemPrompt).not.toContain('Use web_fetch'); + expect( + await handler({ + systemPrompt: 'Base prompt', + systemPromptOptions: { selectedTools: ['read'] }, + }) + ).toBeUndefined(); }); it('should suppress overlapping Parallel skills from the system prompt', async () => { @@ -239,6 +301,10 @@ describe('@parallel-web/pi-extension', () => { expect.stringContaining('authenticated (stored)'), 'info' ); + expect(ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining('`/logout` and select Parallel'), + 'info' + ); }); it('parallel-login should recognize a key that only PARALLEL_API_KEY provides', async () => { @@ -260,6 +326,10 @@ describe('@parallel-web/pi-extension', () => { expect.stringContaining('authenticated (PARALLEL_API_KEY)'), 'info' ); + expect(ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining('unset PARALLEL_API_KEY'), + 'info' + ); }); it('web_search should use the stored api key when available', async () => { @@ -462,4 +532,322 @@ describe('@parallel-web/pi-extension', () => { undefined ); }); + it.each([null, 0, false])( + 'web_research rejects a %j query before Pi can coerce it to a string', + async (query) => { + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + mocks.runParallelResearch.mockResolvedValue({ + text: 'This request should not have been sent.', + effort: 'medium', + }); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const tool = getRegisteredTool(pi, 'web_research'); + + const execute = async () => { + const args = tool.prepareArguments?.({ query }) ?? { query }; + const params = validateToolArguments(tool, { + type: 'toolCall', + id: 'research-invalid', + name: 'web_research', + arguments: args, + }); + return await tool.execute( + 'research-invalid', + params, + undefined, + undefined, + createToolContext() + ); + }; + + await expect(execute()).rejects.toThrow('non-empty question'); + expect(mocks.runParallelResearch).not.toHaveBeenCalled(); + } + ); + + it.each([null, 42, false, {}, [], '', ' ', 'resp_\n', 'x'.repeat(513)])( + 'web_research rejects raw invalid continuation IDs before Pi coercion: %j', + async (previous_response_id) => { + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const tool = getRegisteredTool(pi, 'web_research'); + expect(() => { + const args = tool.prepareArguments({ + query: 'A follow-up', + previous_response_id, + }); + validateToolArguments(tool, { + type: 'toolCall', + id: 'invalid-followup', + name: 'web_research', + arguments: args, + }); + }).toThrow('previous_response_id'); + expect(mocks.getParallelApiKey).not.toHaveBeenCalled(); + expect(mocks.runParallelResearch).not.toHaveBeenCalled(); + } + ); + + it('web_research exposes continuation IDs to the parent and forwards only the explicit ID', async () => { + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + const answer = ' const answer = 42;\n\n[source](https://example.com)\n'; + mocks.runParallelResearch.mockResolvedValue({ + text: answer, + effort: 'medium', + responseId: 'resp_next', + }); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const tool = getRegisteredTool(pi, 'web_research'); + expect(tool.parameters.required).not.toContain('previous_response_id'); + expect(tool.parameters.properties.previous_response_id).toMatchObject({ + type: 'string', + maxLength: 512, + }); + expect(tool.promptGuidelines.join(' ')).toContain('previous_response_id'); + const args = tool.prepareArguments({ + query: 'A focused follow-up', + previous_response_id: 'resp_prior', + history: 'private-history', + }); + const params = validateToolArguments(tool, { + type: 'toolCall', + id: 'followup', + name: 'web_research', + arguments: args, + }); + const signal = new AbortController().signal; + const result = await tool.execute( + 'followup', + params, + signal, + undefined, + createToolContext() + ); + expect(mocks.runParallelResearch).toHaveBeenCalledWith( + 'stored-api-key', + { + query: 'A focused follow-up', + effort: 'medium', + previous_response_id: 'resp_prior', + }, + signal + ); + expect(result.content).toEqual([ + { type: 'text', text: `Response ID: resp_next\n\n${answer}` }, + ]); + expect(result.details.responseId).toBe('resp_next'); + expect(result.details.outputFile).toBeUndefined(); + }); + + it('web_research uses shared auth and sends only explicit arguments with cancellation', async () => { + const { runParallelResearch } = await vi.importActual< + typeof import('../parallel-responses.js') + >('../parallel-responses.js'); + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + mocks.runParallelResearch.mockImplementationOnce(runParallelResearch); + const answer = 'Answer with [source](https://example.com)'; + const fetchMock = vi.fn(async (_url: unknown, _init?: RequestInit) => + Response.json({ + status: 'completed', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: answer }], + }, + ], + }) + ); + vi.stubGlobal('fetch', fetchMock); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const tool = getRegisteredTool(pi, 'web_research'); + const controller = new AbortController(); + const signal = controller.signal; + const onUpdate = vi.fn(); + const result = await tool.execute( + 'research-1', + { + query: 'A complete question', + effort: 'low', + history: 'private-history', + instructions: 'private-instructions', + }, + signal, + onUpdate, + createToolContext({ + cwd: '/private-project', + model: { id: 'fixture-parent' }, + }) + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, request] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.parallel.ai/v1/responses'); + expect(new Headers(request?.headers).get('Authorization')).toBe( + 'Bearer stored-api-key' + ); + expect(JSON.parse(String(request?.body))).toEqual({ + model: 'parallel', + input: 'A complete question', + instructions: expect.stringContaining('Research the user'), + reasoning: { effort: 'low' }, + stream: false, + }); + expect(request?.signal?.aborted).toBe(false); + controller.abort(); + expect(request?.signal?.aborted).toBe(true); + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Answer with [source](https://example.com)' }, + ], + details: { provider: 'parallel', product: 'responses', effort: 'low' }, + }); + expect(onUpdate).toHaveBeenCalled(); + expect(result.details.outputFile).toBeUndefined(); + }); + + it.each(['line', 'byte'])( + 'web_research keeps the full answer and citations at the %s limit', + async (limit) => { + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + const fullText = + (limit === 'line' + ? 'finding\n'.repeat(5_000) + : '界'.repeat(DEFAULT_MAX_BYTES)) + + '\nSources:\n[Final source](https://example.com/end)'; + const fullReport = `Response ID: resp_long\n\n${fullText}`; + mocks.runParallelResearch.mockResolvedValue({ + text: fullText, + effort: 'medium', + responseId: 'resp_long', + }); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const result = await getRegisteredTool(pi, 'web_research').execute( + 'research-long', + { + query: 'A complete question', + }, + undefined, + undefined, + createToolContext() + ); + const outputFile = result.details.outputFile; + try { + expect( + result.content[0].text.startsWith('Response ID: resp_long\n\n') + ).toBe(true); + expect(result.details.responseId).toBe('resp_long'); + expect(result.content[0].text).toContain('Research output truncated'); + expect(result.content[0].text).toContain(outputFile); + expect(result.content[0].text.length).toBeLessThan(fullText.length); + expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual( + DEFAULT_MAX_BYTES + ); + expect(result.content[0].text.split('\n').length).toBeLessThanOrEqual( + DEFAULT_MAX_LINES + ); + expect(await readFile(outputFile, 'utf8')).toBe(fullReport); + expect((await stat(outputFile)).mode & 0o077).toBe(0); + } finally { + if (outputFile) + await rm(dirname(outputFile), { recursive: true, force: true }); + } + } + ); + + it.each([ + [404, 'Interaction context not found: resp_unauthorized'], + [404, 'Invalid interaction id: authentication'], + [400, 'Continuation unavailable: authentication policy restriction'], + [429, 'Rate limit exceeded for this API key'], + [500, 'Authentication service unavailable'], + [undefined, 'Authentication service connection failed'], + ])( + 'web_research preserves non-authentication errors with status %s', + async (status, message) => { + const { isParallelAuthenticationError } = await vi.importActual< + typeof import('../parallel-client.js') + >('../parallel-client.js'); + mocks.isParallelAuthenticationError.mockImplementation( + isParallelAuthenticationError + ); + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + const error = Object.assign(new Error(String(message)), { status }); + mocks.runParallelResearch.mockRejectedValue(error); + const pi = createMockPi(); + (await import('../index.js')).default(pi as unknown as ExtensionAPI); + + await expect( + getRegisteredTool(pi, 'web_research').execute( + 'research-error', + { query: 'A follow-up', previous_response_id: 'resp_unauthorized' }, + undefined, + undefined, + createToolContext() + ) + ).rejects.toBe(error); + expect(mocks.runParallelResearch).toHaveBeenCalledTimes(1); + } + ); + + it.each(['stored', 'environment'])( + 'web_research keeps HTTP 401 guidance for %s credentials', + async (source) => { + const { isParallelAuthenticationError } = await vi.importActual< + typeof import('../parallel-client.js') + >('../parallel-client.js'); + mocks.isParallelAuthenticationError.mockImplementation( + isParallelAuthenticationError + ); + if (source === 'environment') + process.env.PARALLEL_API_KEY = 'rejected-key'; + mocks.getParallelApiKey.mockResolvedValue('rejected-key'); + mocks.runParallelResearch.mockRejectedValue( + Object.assign(new Error('Rejected'), { status: 401 }) + ); + const pi = createMockPi(); + (await import('../index.js')).default(pi as unknown as ExtensionAPI); + + await expect( + getRegisteredTool(pi, 'web_research').execute( + 'research-auth', + { query: 'Research question' }, + undefined, + undefined, + createToolContext() + ) + ).rejects.toThrow( + source === 'environment' + ? 'rejected PARALLEL_API_KEY' + : 'rejected the stored credential' + ); + expect(mocks.runParallelResearch).toHaveBeenCalledTimes(1); + } + ); + + it('web_research reuses the existing missing-credential guidance', async () => { + mocks.getParallelApiKey.mockResolvedValue(undefined); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + await expect( + getRegisteredTool(pi, 'web_research').execute( + 'research-auth', + { + query: 'A complete question', + }, + undefined, + undefined, + createToolContext() + ) + ).rejects.toThrow('/login parallel'); + expect(mocks.runParallelResearch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/pi-extension/src/__tests__/package.test.ts b/packages/pi-extension/src/__tests__/package.test.ts new file mode 100644 index 0000000..983076d --- /dev/null +++ b/packages/pi-extension/src/__tests__/package.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('Pi research package contract', () => { + it('ships research through the existing extension without a child package', () => { + const manifest = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') + ); + expect(manifest.name).toBe('@parallel-web/pi-extension'); + expect(manifest.pi.extensions).toEqual(['./dist/index.js']); + expect(manifest.pi.subagents).toBeUndefined(); + expect(manifest.files).not.toContain('agents'); + expect(manifest.dependencies['pi-subagents']).toBeUndefined(); + expect(manifest.peerDependencies['pi-subagents']).toBeUndefined(); + }); +}); diff --git a/packages/pi-extension/src/__tests__/parallel-auth.test.ts b/packages/pi-extension/src/__tests__/parallel-auth.test.ts index 6de427b..18a1083 100644 --- a/packages/pi-extension/src/__tests__/parallel-auth.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-auth.test.ts @@ -56,7 +56,7 @@ describe('parallel-auth', () => { } = await import('../parallel-auth.js')); }); - it('registers a Parallel provider that serves no models', () => { + it('registers the shared credential provider without research models', () => { const provider = registerProvider(); expect(provider.id).toBe('parallel'); diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts new file mode 100644 index 0000000..a0f1837 --- /dev/null +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -0,0 +1,681 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { version } from '../../package.json'; +import { + PARALLEL_RESPONSES_MAX_INPUT_CHARS, + PARALLEL_RESPONSES_TIMEOUT_MS, + PARALLEL_RESPONSES_URL, + runParallelResearch, + type ResearchInput, +} from '../parallel-responses.js'; + +const apiKey = 'test-api-key'; +const query = 'Compare the current Node.js compatibility of Node and Bun.'; + +function completed( + text = 'The researched answer.', + annotations: unknown[] = [ + { + type: 'url_citation', + url: 'https://example.com/source', + title: 'Example [source]', + }, + { + type: 'url_citation', + url: 'https://example.com/source', + title: 'Duplicate', + }, + ] +) { + return { + status: 'completed', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text, annotations }], + }, + ], + }; +} + +function mockResponse(payload: unknown = completed(), status = 200) { + const fetchMock = vi.fn( + async (_url: unknown, _init?: RequestInit) => + new Response(JSON.stringify(payload), { status }) + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('Parallel research request', () => { + it('sends only the explicit question and fixed instructions, returning cited text', async () => { + const fetchMock = mockResponse(); + const result = await runParallelResearch(apiKey, { + query, + history: 'private-history', + systemPrompt: 'private-system-prompt', + cwd: '/private-project', + sessionId: 'private-session', + tools: ['read'], + } as ResearchInput); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(PARALLEL_RESPONSES_URL); + expect(init).toMatchObject({ + method: 'POST', + redirect: 'error', + signal: expect.any(AbortSignal), + }); + expect(JSON.parse(String(init?.body))).toEqual({ + model: 'parallel', + input: query, + instructions: expect.stringContaining('Research the user'), + reasoning: { effort: 'medium' }, + stream: false, + }); + expect(String(init?.body)).not.toContain('private-'); + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe(`Bearer ${apiKey}`); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Tool-Calling-Package')).toBe( + `npm:@parallel-web/pi-extension/v${version}` + ); + expect(result).toEqual({ + effort: 'medium', + text: 'The researched answer.\n\nSources:\n1. [Example \\[source\\]]()', + }); + }); + + it.each(['low', 'medium', 'high'] as const)( + 'sends explicit %s effort', + async (effort) => { + const fetchMock = mockResponse(); + expect( + (await runParallelResearch(apiKey, { query, effort })).effort + ).toBe(effort); + expect( + JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).reasoning + ).toEqual({ effort }); + } + ); + + it.each([ + [{ query: '' }, 'non-empty question'], + [{ query: ' \n\t' }, 'non-empty question'], + [{ query: null }, 'non-empty question'], + [{ query, effort: 'extreme' }, 'effort must be'], + [{ query, effort: null }, 'effort must be'], + [ + { query: 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS) }, + '20,000-character', + ], + ])( + 'rejects invalid input before making a request: %j', + async (input, error) => { + const fetchMock = mockResponse(); + await expect( + runParallelResearch(apiKey, input as ResearchInput) + ).rejects.toThrow(String(error)); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('counts both instructions and Unicode characters at the exact input boundary', async () => { + const fetchMock = mockResponse(); + await runParallelResearch(apiKey, { query }); + const { instructions } = JSON.parse( + String(fetchMock.mock.calls[0][1]?.body) + ); + const capacity = + PARALLEL_RESPONSES_MAX_INPUT_CHARS - [...instructions].length - 1; + fetchMock.mockClear(); + + await runParallelResearch(apiKey, { query: '🔎'.repeat(capacity) }); + expect(fetchMock).toHaveBeenCalledTimes(1); + await expect( + runParallelResearch(apiKey, { query: '🔎'.repeat(capacity + 1) }) + ).rejects.toThrow('20,000-character'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('requires authentication before sending a request', async () => { + const fetchMock = mockResponse(); + await expect(runParallelResearch('', { query })).rejects.toThrow( + '/login parallel' + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('Parallel research continuation', () => { + it('forwards an explicit opaque ID and returns the new response ID', async () => { + const fetchMock = mockResponse({ ...completed(), id: 'resp_new' }); + const result = await runParallelResearch(apiKey, { + query: 'Which of those findings applies to our constraints?', + previous_response_id: 'opaque.v2:branch-A', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ + model: 'parallel', + input: 'Which of those findings applies to our constraints?', + instructions: expect.stringContaining('Research the user'), + reasoning: { effort: 'medium' }, + stream: false, + previous_response_id: 'opaque.v2:branch-A', + }); + expect(result).toMatchObject({ responseId: 'resp_new', effort: 'medium' }); + expect(result.text).toContain('https://example.com/source'); + }); + + it('chains only the IDs explicitly supplied and leaves unrelated calls independent', async () => { + let count = 0; + const fetchMock = vi.fn( + async (_url: unknown, _init?: RequestInit) => + new Response(JSON.stringify({ ...completed(), id: `resp_${++count}` })) + ); + vi.stubGlobal('fetch', fetchMock); + const first = await runParallelResearch(apiKey, { query }); + const second = await runParallelResearch(apiKey, { + query: 'Compare the findings.', + previous_response_id: first.responseId, + }); + await runParallelResearch(apiKey, { + query: 'Check the second answer.', + previous_response_id: second.responseId, + }); + await runParallelResearch(apiKey, { + query: 'An unrelated research question.', + }); + const bodies = fetchMock.mock.calls.map(([, init]) => + JSON.parse(String(init?.body)) + ); + expect(bodies.map((body) => body.previous_response_id)).toEqual([ + undefined, + 'resp_1', + 'resp_2', + undefined, + ]); + expect(bodies[0]).not.toHaveProperty('previous_response_id'); + expect(bodies[3]).not.toHaveProperty('previous_response_id'); + }); + + it.each([ + null, + 42, + false, + {}, + [], + '', + ' ', + 'resp one', + 'resp_\n', + 'resp_\u0000', + 'x'.repeat(513), + ])( + 'rejects unusable explicit continuation IDs before dispatch: %j', + async (previous_response_id) => { + const fetchMock = mockResponse(); + await expect( + runParallelResearch(apiKey, { + query, + previous_response_id, + } as ResearchInput) + ).rejects.toThrow('previous_response_id'); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('accepts the tool ID length boundary without trimming or rewriting it', async () => { + const id = 'x'.repeat(512); + const fetchMock = mockResponse({ ...completed(), id }); + expect( + (await runParallelResearch(apiKey, { query, previous_response_id: id })) + .responseId + ).toBe(id); + expect( + JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).previous_response_id + ).toBe(id); + }); + + it.each([ + undefined, + null, + 42, + '', + 'resp one', + 'resp_\n', + 'resp_\u0000', + 'x'.repeat(513), + ])( + 'keeps a valid answer without advertising unusable returned IDs: %j', + async (id) => { + mockResponse({ ...completed(), id, previous_response_id: 'resp_old' }); + const result = await runParallelResearch(apiKey, { + query, + previous_response_id: 'resp_old', + }); + expect(result.text).toContain('The researched answer.'); + expect(result.text).toContain('https://example.com/source'); + expect(result).not.toHaveProperty('responseId'); + } + ); + + it.each([ + [404, 'Interaction context not found: resp_missing'], + [ + 400, + 'previous_interaction_id is not supported for zero data retention (ZDR) customers. Interaction context cannot be persisted under ZDR.', + ], + ])( + 'preserves continuation HTTP %s without retrying as fresh research', + async (status, message) => { + const fetchMock = mockResponse({ error: { message } }, Number(status)); + await expect( + runParallelResearch(apiKey, { + query, + previous_response_id: 'resp_missing', + }) + ).rejects.toMatchObject({ + status, + message: expect.stringContaining(String(message)), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) + .previous_response_id + ).toBe('resp_missing'); + } + ); +}); + +describe('Parallel research evidence', () => { + it.each([ + ' const answer = 42;\n console.log(answer);\n', + '\tconst answer = "🔎";\r\n', + '\n\nA researched answer.\n\n', + 'A researched answer. ', + ])('preserves answer whitespace without citations: %j', async (answer) => { + mockResponse(completed(answer, [])); + expect((await runParallelResearch(apiKey, { query })).text).toBe(answer); + }); + + it('preserves the cited passages for each source using Unicode character ranges', async () => { + const first = 'Alpha shipped in 2024.'; + const second = 'Beta shipped in 2025.'; + const answer = '🔎 ' + first + ' ' + second; + const firstCitation = { + type: 'url_citation', + url: 'https://example.com/alpha', + title: 'Alpha source', + start_index: 2, + end_index: 2 + [...first].length, + }; + mockResponse( + completed(answer, [ + firstCitation, + firstCitation, + { + ...firstCitation, + url: 'https://example.com/both', + title: 'Combined source', + }, + { + ...firstCitation, + url: 'https://example.com/both', + title: 'Combined source', + start_index: 3 + [...first].length, + end_index: [...answer].length, + }, + ]) + ); + const { text } = await runParallelResearch(apiKey, { query }); + expect(text.startsWith(answer + '\n\nSources:\n')).toBe(true); + const sources = text.split('\nSources:\n')[1]; + const alpha = sources.slice(sources.indexOf('1. '), sources.indexOf('2. ')); + const both = sources.slice(sources.indexOf('2. ')); + expect(alpha.match(/Cited answer passage/g)).toHaveLength(1); + expect(alpha).toContain(JSON.stringify(first)); + expect(alpha).not.toContain(JSON.stringify(second)); + expect(both).toContain(JSON.stringify(first)); + expect(both).toContain(JSON.stringify(second)); + expect(alpha).toContain('part 1, characters 2:24'); + }); + + it('retains separate locations and overlapping spans for the same source', async () => { + const claim = 'Revenue increased 10%.'; + const answer = 'Company A\n' + claim + '\n\nCompany B\n' + claim; + const startA = [...'Company A\n'].length; + const startB = [...('Company A\n' + claim + '\n\nCompany B\n')].length; + const citation = { + type: 'url_citation', + url: 'https://example.com/revenue', + start_index: startA, + end_index: startA + [...claim].length, + }; + mockResponse( + completed(answer, [ + citation, + { + ...citation, + start_index: startB, + end_index: startB + [...claim].length, + }, + { ...citation, end_index: startA + [...'Revenue increased'].length }, + ]) + ); + const { text } = await runParallelResearch(apiKey, { query }); + const sources = text.split('\nSources:\n')[1]; + expect(sources.match(/Cited answer passage/g)).toHaveLength(3); + expect(sources.match(/"Revenue increased 10%."/g)).toHaveLength(2); + expect(sources).toContain('characters 10:32'); + expect(sources).toContain('characters 44:66'); + expect(sources).toContain('"Revenue increased"'); + }); + + it('resolves each passage against its original text part without editing Markdown', async () => { + const first = ' 🔎 A [link](https://example.com/path).\n'; + const second = 'Code:\n\n~~~js\nconst value = "🔎";\n~~~\n'; + const passage = 'const value = "🔎";'; + const firstStart = [...' 🔎 '].length; + const secondStart = [...'Code:\n\n~~~js\n'].length; + const payload = completed(first, [ + { + type: 'url_citation', + url: 'https://example.com/first', + start_index: firstStart, + end_index: [...first].length - 1, + }, + ]); + payload.output[0].content.push({ + type: 'output_text', + text: second, + annotations: [ + { + type: 'url_citation', + url: 'https://example.com/code', + start_index: secondStart, + end_index: secondStart + [...passage].length, + }, + ], + }); + mockResponse(payload); + const { text } = await runParallelResearch(apiKey, { query }); + expect(text.split('\n\nSources:\n')[0]).toBe([first, second].join('\n\n')); + expect(text).toContain('part 1, characters 6:'); + expect(text).toContain('part 2, characters 13:'); + expect(text).toContain(JSON.stringify(passage)); + expect(text).toContain( + JSON.stringify('A [link](https://example.com/path).') + ); + }); + + it.each([ + [0, 0], + [-1, 5], + [3, 3], + [5, 1], + [0, 200], + [0.5, 3], + [0, 3.5], + ['0', 4], + [0, '4'], + [null, 4], + [0, null], + [undefined, 4], + [0, undefined], + ])( + 'keeps the source without inventing a passage for indices %j:%j', + async (start, end) => { + mockResponse( + completed('A finding.', [ + { + type: 'url_citation', + url: 'https://example.com/source', + start_index: start, + end_index: end, + }, + ]) + ); + const { text } = await runParallelResearch(apiKey, { query }); + expect(text).toContain(']()'); + expect(text).not.toContain('Cited answer passage'); + expect(text).not.toContain('character offsets'); + } + ); + + it('keeps multiple text parts and safe citations without fabricating sources', async () => { + const payload = completed('First finding.', [ + { type: 'url_citation', url: 'javascript:alert(1)', title: 'Unsafe' }, + { type: 'url_citation', url: 'not-a-url' }, + { + type: 'url_citation', + url: 'https://example.com/a(b)', + title: 'A [label]\nnext', + }, + { type: 'url_citation', url: 'https://example.com/fallback' }, + ]); + payload.output.push({ + type: 'message', + content: [ + { type: 'output_text', text: 'Second finding.', annotations: [] }, + ], + }); + mockResponse({ + ...payload, + output: [{ type: 'reasoning', summary: [] }, ...payload.output], + }); + const { text } = await runParallelResearch(apiKey, { query }); + expect(text).toContain('First finding.\n\nSecond finding.'); + expect(text).toContain('[A \\[label\\] next]()'); + expect(text).toContain( + '[https://example.com/fallback]()' + ); + expect(text).not.toContain('javascript:'); + expect(text).not.toContain('not-a-url'); + }); + + it('does not invent citations when no sources were returned', async () => { + mockResponse(completed('No reliable evidence was found.', [])); + expect((await runParallelResearch(apiKey, { query })).text).toBe( + 'No reliable evidence was found.' + ); + }); + + it.each([ + ['https://example.com/?q=\\[tag]', 'https://example.com/?q=%5C[tag]'], + ['https://example.com/#trail\\', 'https://example.com/#trail%5C'], + ])('preserves literal backslashes in source URLs: %s', async (url, href) => { + mockResponse(completed('Answer.', [{ type: 'url_citation', url }])); + expect((await runParallelResearch(apiKey, { query })).text).toContain( + `](<${href}>)` + ); + }); + + it.each([ + [{ status: 'in_progress', output: [] }, 'not completed'], + [{ status: 'completed' }, 'without output messages'], + [{ status: 'completed', output: [] }, 'empty research response'], + [completed(' \n'), 'empty research response'], + ])('rejects incomplete or empty responses: %j', async (payload, error) => { + const fetchMock = mockResponse(payload); + await expect(runParallelResearch(apiKey, { query })).rejects.toThrow( + String(error) + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each(['in_progress', 'incomplete'])( + 'rejects an answer message marked %s even when the response is completed', + async (status) => { + const payload = completed('Partial answer.'); + Object.assign(payload.output[0], { status }); + const fetchMock = mockResponse(payload); + await expect(runParallelResearch(apiKey, { query })).rejects.toThrow( + 'not completed' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + null, + {}, + { type: 'message', content: null }, + { type: 'message', content: [null] }, + { + type: 'message', + content: [{ type: 'output_text', text: 42 }], + }, + ])( + 'rejects malformed answer parts instead of returning partial text: %j', + async (item) => { + const payload = completed('Only the first part of the answer.'); + const fetchMock = mockResponse({ + ...payload, + output: [...payload.output, item], + }); + await expect(runParallelResearch(apiKey, { query })).rejects.toThrow( + 'malformed research output' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); +}); + +describe('Parallel research failure lifecycle', () => { + it.each([401, 429, 500])( + 'does not retry HTTP %s and preserves safe status', + async (status) => { + const fetchMock = mockResponse( + { error: { message: `Rejected key ${apiKey}` } }, + status + ); + await expect( + runParallelResearch(apiKey, { query }) + ).rejects.toMatchObject({ + status, + message: `Parallel Responses request failed (${status}): Rejected key [REDACTED]`, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it('reports non-JSON HTTP errors with their status', async () => { + const fetchMock = vi.fn( + async () => new Response('Unavailable', { status: 503 }) + ); + vi.stubGlobal('fetch', fetchMock); + await expect(runParallelResearch(apiKey, { query })).rejects.toMatchObject({ + status: 503, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not retry malformed JSON', async () => { + const fetchMock = vi.fn(async () => new Response('not JSON')); + vi.stubGlobal('fetch', fetchMock); + await expect(runParallelResearch(apiKey, { query })).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not expose response excerpts through JSON parse errors', async () => { + const secret = 'fixture-secret-key-0123456789abcdefghijklmnopqrstuvwxyz'; + const fetchMock = vi.fn(async () => new Response(secret)); + vi.stubGlobal('fetch', fetchMock); + + await expect(runParallelResearch(secret, { query })).rejects.toThrow( + 'Parallel returned malformed research JSON.' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('bounds network errors and redacts the key', async () => { + const fetchMock = vi.fn(async () => { + throw new Error(`${apiKey} ${'x'.repeat(5_000)}`); + }); + vi.stubGlobal('fetch', fetchMock); + const error = await runParallelResearch(apiKey, { query }).catch( + (value: Error) => value + ); + expect(error).toBeInstanceOf(Error); + const { message } = error as Error; + expect(message).toContain('[REDACTED]'); + expect(message).not.toContain(apiKey); + expect(message.length).toBeLessThanOrEqual(1_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sends no request when already cancelled', async () => { + const fetchMock = mockResponse(); + const controller = new AbortController(); + controller.abort(); + await expect( + runParallelResearch(apiKey, { query }, controller.signal) + ).rejects.toThrow('cancelled'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels an in-flight request without retrying', async () => { + const controller = new AbortController(); + const fetchMock = vi.fn( + async (_url: unknown, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new Error('aborted')), + { once: true } + ); + }) + ); + vi.stubGlobal('fetch', fetchMock); + const result = runParallelResearch(apiKey, { query }, controller.signal); + const assertion = expect(result).rejects.toThrow('cancelled'); + controller.abort(); + await assertion; + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each(['headers', 'body'])( + 'keeps the timeout active while waiting for %s', + async (phase) => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { + if (phase === 'headers') { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new Error('aborted')), + { once: true } + ); + }); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"status":')); + init?.signal?.addEventListener( + 'abort', + () => controller.error(new Error('aborted')), + { once: true } + ); + }, + }) + ); + }); + vi.stubGlobal('fetch', fetchMock); + const result = runParallelResearch(apiKey, { query }); + const assertion = expect(result).rejects.toThrow( + 'timed out after 120 seconds' + ); + await vi.advanceTimersByTimeAsync(PARALLEL_RESPONSES_TIMEOUT_MS); + await assertion; + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } + ); +}); diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index c836e4d..593287f 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { ExtensionAPI, ExtensionContext, @@ -10,6 +13,11 @@ import { truncateHead, } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; +import { + MAX_RESPONSE_ID_LENGTH, + parseResearchInput, + runParallelResearch, +} from './parallel-responses'; import { getParallelApiKey, getParallelAuthStatus, @@ -62,16 +70,14 @@ function suppressSkillsFromPrompt(systemPrompt: string) { ); } -const WEB_GROUNDING_GUIDANCE = ` -## Grounding and web usage - -You should proactively use available web tools to ground your answers when doing so would improve correctness, freshness, or source quality. - -- Use web_search when the task involves current information, external facts, source discovery, recent changes, or any claim you are not highly confident about. -- Use web_fetch when the user provides a URL, when a search result should be verified against the source, or when primary-source content would improve the answer. -- Prefer grounded, sourced answers over unsupported recall when freshness or factual precision matters. -- If a grounded answer would likely be better than answering from memory, use the web tools first. -`; +const WEB_TOOL_GUIDANCE = { + web_research: + '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.', + web_search: + 'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.', + web_fetch: + 'Use web_fetch to read a known URL or inspect the original source behind a claim.', +}; export default function (pi: ExtensionAPI) { const parallelSessionId = randomUUID(); @@ -91,14 +97,15 @@ export default function (pi: ExtensionAPI) { async function runWithAuth( ctx: ExtensionContext, - request: (apiKey: string) => Promise + request: (apiKey: string) => Promise, + isAuthenticationError = isParallelAuthenticationError ) { const apiKey = await resolveApiKey(ctx); try { return await request(apiKey); } catch (error) { - if (!isParallelAuthenticationError(error)) { + if (!isAuthenticationError(error)) { throw error; } @@ -130,8 +137,11 @@ export default function (pi: ExtensionAPI) { const status = getParallelAuthStatus(ctx); const source = status.label ?? status.source ?? 'PARALLEL_API_KEY'; + const guidance = status.configured + ? 'Run `/login parallel` to replace the stored credential, or `/logout` and select Parallel to remove it.' + : 'Run `/login parallel` to store a credential, or unset PARALLEL_API_KEY to remove the current one.'; ctx.ui.notify( - `Parallel is authenticated (${source}). Run \`/login parallel\` to replace the credential, or \`/logout parallel\` to remove it.`, + `Parallel is authenticated (${source}). ${guidance}`, 'info' ); }, @@ -140,21 +150,110 @@ export default function (pi: ExtensionAPI) { pi.on('before_agent_start', async (event) => { const filteredPrompt = suppressSkillsFromPrompt(event.systemPrompt); const selectedTools = event.systemPromptOptions.selectedTools ?? []; - const hasWebTools = - selectedTools.includes('web_search') || - selectedTools.includes('web_fetch'); + const guidance = Object.entries(WEB_TOOL_GUIDANCE) + .filter(([tool]) => selectedTools.includes(tool)) + .map(([, text]) => `- ${text}`); - if (!hasWebTools) { + if (guidance.length === 0) { return filteredPrompt === event.systemPrompt ? undefined : { systemPrompt: filteredPrompt }; } return { - systemPrompt: `${filteredPrompt}\n${WEB_GROUNDING_GUIDANCE}`, + 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.`, }; }); + pi.registerTool({ + name: 'web_research', + label: 'Web Research', + description: + "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.", + promptSnippet: + 'Get a cited answer to a complete web research question using Parallel', + promptGuidelines: [ + 'Use web_research for questions that need web research and synthesis; pass the complete question in one call before making focused follow-ups.', + '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.', + '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.', + 'Use low effort for focused lookups, medium for general research, and high only for extensive research. The default is medium.', + ], + parameters: Type.Object({ + query: Type.String({ + minLength: 1, + description: + '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.', + }), + effort: Type.Optional( + Type.Union( + [Type.Literal('low'), Type.Literal('medium'), Type.Literal('high')], + { + description: + 'Research depth: low for focused lookups, medium for general research (default), high for extensive research.', + } + ) + ), + previous_response_id: Type.Optional( + Type.String({ + minLength: 1, + maxLength: MAX_RESPONSE_ID_LENGTH, + description: + '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.', + }) + ), + }), + // Reject invalid raw values before Pi coerces them to schema types. + prepareArguments: parseResearchInput, + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: 'text', text: 'Researching the web...' }], + details: { provider: 'parallel', product: 'responses' }, + }); + const result = await runWithAuth( + ctx, + (apiKey) => runParallelResearch(apiKey, params, signal), + // Responses errors carry HTTP status. Their text can echo opaque IDs, + // so words such as "unauthorized" do not identify rejected credentials. + (error) => + error instanceof Error && 'status' in error && error.status === 401 + ); + // Pi sends content to the parent, not details. Keep the ID ahead of the + // answer so a truncated preview still supports an explicit follow-up. + const report = result.responseId + ? `Response ID: ${result.responseId}\n\n${result.text}` + : result.text; + const preview = truncateHead(report, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + let text = preview.content; + let outputFile: string | undefined; + if (preview.truncated) { + // Keep the complete answer and citations available when Pi's context + // limit requires a preview. Normal research results create no file. + const directory = await mkdtemp(join(tmpdir(), 'parallel-research-')); + outputFile = join(directory, 'research.md'); + await writeFile(outputFile, report, { mode: 0o600 }); + const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`; + text = + truncateHead(report, { + maxLines: DEFAULT_MAX_LINES - 2, + maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice), + }).content + notice; + } + return { + content: [{ type: 'text', text }], + details: { + provider: 'parallel', + product: 'responses', + effort: result.effort, + ...(result.responseId ? { responseId: result.responseId } : {}), + ...(outputFile ? { outputFile } : {}), + }, + }; + }, + }); + pi.registerTool({ name: 'web_search', label: 'Web Search', @@ -163,7 +262,7 @@ export default function (pi: ExtensionAPI) { promptSnippet: "Search the web using Parallel's Search API for current information", promptGuidelines: [ - 'Use web_search when the user asks for current web information, discovery, or source finding.', + 'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.', 'Provide 2-3 concise keyword search queries when possible; search_queries is required.', ], parameters: Type.Object({ diff --git a/packages/pi-extension/src/parallel-auth.ts b/packages/pi-extension/src/parallel-auth.ts index d0abbd5..4438623 100644 --- a/packages/pi-extension/src/parallel-auth.ts +++ b/packages/pi-extension/src/parallel-auth.ts @@ -71,10 +71,8 @@ async function resolveParallelAuth(input: { } /** - * A provider that exists purely to carry Parallel's credential. Pi owns the - * storage (auth.json), the `/login parallel` and `/logout parallel` flows, and - * the `PARALLEL_API_KEY` fallback; the extension only reads the resolved key. - * It serves no models, so the stream entry points are never reached. + * This provider only carries credentials. Pi owns auth.json, the login/logout + * flows and environment fallback; every web tool reuses the resolved key. */ function createParallelProvider(): Provider { return { diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts new file mode 100644 index 0000000..785bc4f --- /dev/null +++ b/packages/pi-extension/src/parallel-responses.ts @@ -0,0 +1,282 @@ +declare const __PACKAGE_VERSION__: string; + +export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses'; +export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000; +export const PARALLEL_RESPONSES_TIMEOUT_MS = 120_000; +export const DEFAULT_RESEARCH_EFFORT = 'medium'; +export const MAX_RESPONSE_ID_LENGTH = 512; + +export type ResearchEffort = 'low' | 'medium' | 'high'; + +export interface ResearchInput { + query: string; + effort?: ResearchEffort; + previous_response_id?: string; +} + +// Only these instructions, the explicit question and optional continuation ID +// cross the research boundary. +// Do not assemble this prompt from Pi's session, system prompt, or local files. +const RESEARCH_INSTRUCTIONS = + "Research the user's question using current web sources. Return a direct, evidence-based answer with citations. State uncertainty when the sources do not support a conclusion."; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// IDs are opaque. Bound their size and keep the displayed continuation header +// on one line without coupling the tool to the server's current ID format. +function isResponseId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_RESPONSE_ID_LENGTH && + !/[\s\p{Cc}]/u.test(value) + ); +} + +function renderResearch(payload: unknown): string { + if (!isRecord(payload) || payload.status !== 'completed') { + throw new Error('Parallel returned a response that was not completed.'); + } + if (!Array.isArray(payload.output)) { + throw new Error('Parallel returned a response without output messages.'); + } + + const texts: string[] = []; + const sources = new Map }>(); + for (const item of payload.output) { + if (!isRecord(item) || typeof item.type !== 'string') { + throw new Error('Parallel returned malformed research output.'); + } + if (item.type !== 'message') continue; + if (item.status !== undefined && item.status !== 'completed') { + throw new Error('Parallel returned an answer that was not completed.'); + } + if (!Array.isArray(item.content)) { + throw new Error('Parallel returned malformed research output.'); + } + for (const content of item.content) { + if (!isRecord(content) || typeof content.type !== 'string') { + throw new Error('Parallel returned malformed research output.'); + } + if (content.type !== 'output_text') continue; + if (typeof content.text !== 'string') { + throw new Error('Parallel returned malformed research output.'); + } + texts.push(content.text); + if (!Array.isArray(content.annotations)) continue; + // API citation offsets count Unicode code points, not UTF-16 units. + const characters = [...content.text]; + for (const citation of content.annotations) { + if ( + !isRecord(citation) || + citation.type !== 'url_citation' || + typeof citation.url !== 'string' + ) { + continue; + } + let url: URL; + try { + url = new URL(citation.url); + } catch { + continue; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') continue; + const href = url.toString(); + const title = + typeof citation.title === 'string' ? citation.title.trim() : ''; + let source = sources.get(href); + if (!source) { + source = { title: title || href, passages: new Set() }; + sources.set(href, source); + } + const start = citation.start_index; + const end = citation.end_index; + if ( + typeof start === 'number' && + typeof end === 'number' && + Number.isSafeInteger(start) && + Number.isSafeInteger(end) && + start >= 0 && + end > start && + end <= characters.length + ) { + const passage = characters.slice(start, end).join(''); + source.passages.add( + `Cited answer passage (part ${texts.length}, characters ${start}:${end}): ${JSON.stringify(passage)}` + ); + } + } + } + } + + const text = texts.join('\n\n'); + if (!text.trim()) + throw new Error('Parallel returned an empty research response.'); + if (sources.size === 0) return text; + + const list = [...sources].map(([url, { title, passages }], index) => { + const label = title + .replaceAll('\\', '\\\\') + .replaceAll('[', '\\[') + .replaceAll(']', '\\]') + .replace(/[\r\n]+/g, ' '); + const href = url + .replaceAll('\\', '%5C') + .replaceAll('<', '%3C') + .replaceAll('>', '%3E'); + const link = `${index + 1}. [${label}](<${href}>)`; + return [link, ...[...passages].map((passage) => ` ${passage}`)].join( + '\n' + ); + }); + const rangeNote = [...sources.values()].some((source) => source.passages.size) + ? 'Passage locations use zero-based Unicode character offsets within each original text part; the end is exclusive.\n' + : ''; + return `${text}\n\nSources:\n${rangeNote}${list.join('\n')}`; +} + +function safeError(error: unknown, apiKey: string): Error { + let message: string; + try { + message = error instanceof Error ? error.message : String(error); + } catch { + message = 'Unknown research failure'; + } + if (apiKey) message = message.replaceAll(apiKey, '[REDACTED]'); + const result = new Error( + (message.trim() || 'Unknown research failure').slice(0, 1_000) + ); + // Preserve HTTP status for the extension's existing authentication guidance. + if (isRecord(error) && typeof error.status === 'number') { + Object.assign(result, { status: error.status }); + } + return result; +} + +export function parseResearchInput( + input: unknown +): ResearchInput & { effort: ResearchEffort } { + if ( + !isRecord(input) || + typeof input.query !== 'string' || + !input.query.trim() + ) { + throw new Error('Parallel Research requires a non-empty question.'); + } + const effort = + input.effort === undefined ? DEFAULT_RESEARCH_EFFORT : input.effort; + if (effort !== 'low' && effort !== 'medium' && effort !== 'high') { + throw new Error('Parallel Research effort must be low, medium, or high.'); + } + if ( + [...`${RESEARCH_INSTRUCTIONS}\n${input.query}`].length > + PARALLEL_RESPONSES_MAX_INPUT_CHARS + ) { + throw new Error( + 'Parallel Research exceeds the 20,000-character input limit, including research instructions.' + ); + } + const previousResponseId = input.previous_response_id; + if (previousResponseId !== undefined && !isResponseId(previousResponseId)) { + throw new Error( + `Parallel Research previous_response_id must be a non-empty string of at most ${MAX_RESPONSE_ID_LENGTH} characters without whitespace or control characters.` + ); + } + return { + query: input.query, + effort, + ...(previousResponseId !== undefined + ? { previous_response_id: previousResponseId } + : {}), + }; +} + +export async function runParallelResearch( + apiKey: string, + input: ResearchInput, + signal?: AbortSignal +): Promise<{ text: string; effort: ResearchEffort; responseId?: string }> { + const { query, effort, previous_response_id } = parseResearchInput(input); + if (!apiKey) { + throw new Error( + 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.' + ); + } + + const deadline = new AbortController(); + const requestSignal = signal + ? AbortSignal.any([signal, deadline.signal]) + : deadline.signal; + // Keep the deadline active through body consumption, not just response headers. + const timeout = setTimeout( + () => deadline.abort(), + PARALLEL_RESPONSES_TIMEOUT_MS + ); + timeout.unref?.(); + try { + requestSignal.throwIfAborted(); + const response = await fetch(PARALLEL_RESPONSES_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-Tool-Calling-Package': `npm:@parallel-web/pi-extension/v${__PACKAGE_VERSION__ ?? '0.0.0'}`, + }, + body: JSON.stringify({ + model: 'parallel', + input: query, + instructions: RESEARCH_INSTRUCTIONS, + reasoning: { effort }, + stream: false, + previous_response_id, + }), + signal: requestSignal, + redirect: 'error', + }); + + if (!response.ok) { + let message = response.statusText || 'request failed'; + try { + const payload: unknown = await response.json(); + if ( + isRecord(payload) && + isRecord(payload.error) && + typeof payload.error.message === 'string' + ) { + message = payload.error.message; + } + } catch { + // A non-JSON error still has a useful HTTP status. + } + requestSignal.throwIfAborted(); + throw Object.assign( + new Error( + `Parallel Responses request failed (${response.status}): ${message}` + ), + { status: response.status } + ); + } + + const payload: unknown = await response.json(); + requestSignal.throwIfAborted(); + return { + text: renderResearch(payload), + effort, + ...(isRecord(payload) && isResponseId(payload.id) + ? { responseId: payload.id } + : {}), + }; + } catch (error) { + if (signal?.aborted) throw new Error('Parallel Research cancelled.'); + if (deadline.signal.aborted) + throw new Error('Parallel Research timed out after 120 seconds.'); + // JSON parser errors can include body excerpts, even partial credentials. + if (error instanceof SyntaxError) + throw new Error('Parallel returned malformed research JSON.'); + throw safeError(error, apiKey); + } finally { + clearTimeout(timeout); + } +}