From 9116289c24216541ce22f654bf22b07444a9dc82 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 17 Aug 2026 20:41:16 -0700 Subject: [PATCH 01/14] Add Parallel research subagent to Pi --- README.md | 2 +- packages/pi-extension/README.md | 73 +++- .../pi-extension/agents/parallel-research.md | 17 + packages/pi-extension/package.json | 12 +- .../src/__tests__/package.test.ts | 40 ++ .../src/__tests__/parallel-auth.test.ts | 13 +- .../src/__tests__/parallel-responses.test.ts | 328 +++++++++++++++ packages/pi-extension/src/parallel-auth.ts | 19 +- .../pi-extension/src/parallel-responses.ts | 385 ++++++++++++++++++ 9 files changed, 878 insertions(+), 11 deletions(-) create mode 100644 packages/pi-extension/agents/parallel-research.md create mode 100644 packages/pi-extension/src/__tests__/package.test.ts create mode 100644 packages/pi-extension/src/__tests__/parallel-responses.test.ts create mode 100644 packages/pi-extension/src/parallel-responses.ts diff --git a/README.md b/README.md index f95d625..094ba76 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 tools and a native Parallel Responses research subagent - `@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 1034ff1..e6d564b 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 a cited research model +backed by Parallel. Install it with: ``` @@ -11,6 +12,10 @@ pi install npm:@parallel-web/pi-extension - Registers `web_search` - Registers `web_fetch` +- Registers the `parallel/research` model, which makes one stateless Parallel + Responses API call +- Ships a `parallel-research` agent for + [pi-subagents](https://github.com/nicobailon/pi-subagents) - 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 @@ -26,6 +31,69 @@ Auth resolution order (owned by Pi, not the extension): Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer. +## Parallel Research Subagent + +Install both packages to add the native research agent: + +```bash +pi install npm:pi-subagents +pi install npm:@parallel-web/pi-extension +``` + +This integration requires pi-subagents 0.50.0 or newer. The rest of the Pi +extension still works without pi-subagents. + +Run one research child directly: + +```text +/run parallel-research Compare the current JavaScript runtimes in Node and Bun. Cite primary sources. +``` + +The agent is also an ordinary pi-subagents child in JavaScript code mode. Its +`output` is the cited research text, so a later branch can use it directly: + +```javascript +const research = await runs.run("research", { + agent: "parallel-research", + task: "Which JavaScript runtime currently has stronger Node API compatibility? Cite primary sources.", + thinking: "medium", + context: "fresh", + worktree: false +}); + +if (/Bun/i.test(research.output)) { + return { recommendation: "evaluate-bun", evidence: research.output }; +} +return { recommendation: "stay-on-node", evidence: research.output }; +``` + +The default research effort is `medium`. A run may select `low`, `medium`, or +`high` with its `thinking` option. Current +[prices](https://docs.parallel.ai/getting-started/pricing) per successful +response are: + +| Thinking | Price | Typical use | +| --- | ---: | --- | +| `low` | $0.01 | Focused lookup | +| `medium` | $0.05 | General research | +| `high` | $0.25 | Hard, high-value research | + +The provider makes one `POST /v1/responses` request and does not retry it. It +does not use `previous_response_id`, background jobs, or a remote status loop. +Stopping the child aborts the local HTTP request on a best-effort basis; +Parallel does not expose acknowledged server-side cancellation for Responses. + +The research request contains only the packaged agent instructions and the +latest textual child task. It does not send parent history, local files, cwd, +environment variables, Pi tools, session state, or git worktree data. The +agent cannot read or edit the local filesystem. A worktree therefore adds no +research capability and should normally remain disabled. + +Parallel Responses accepts at most 20,000 combined instruction and input +characters. The adapter fails before making a request when that boundary is +exceeded. It renders the returned URL citations as a deduplicated Markdown +source list. + ## Dogfooding Locally Build the extension first: @@ -50,6 +118,7 @@ If the extension loads successfully, Pi will have: - the `web_fetch` tool - `parallel` listed under `/login` - the `parallel-login` status command +- the `parallel/research` model - per-session Parallel `session_id` reuse inside that Pi session ### Option 2: Symlink It Into Pi Extensions @@ -149,4 +218,6 @@ 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")`. +- The research model is stateless and separate from the Search/Extract + `session_id` used by the web tools. - 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/agents/parallel-research.md b/packages/pi-extension/agents/parallel-research.md new file mode 100644 index 0000000..6527fb6 --- /dev/null +++ b/packages/pi-extension/agents/parallel-research.md @@ -0,0 +1,17 @@ +--- +name: parallel-research +description: One-shot cited web research through Parallel Responses +model: parallel/research +thinking: medium +systemPromptMode: replace +inheritProjectContext: false +inheritSkills: false +defaultContext: fresh +completionGuard: false +turnBudget: {"maxTurns":1,"graceTurns":0} +acceptance: {"level":"none","reason":"One-shot remote research provider"} +--- + +You are a read-only research agent backed by Parallel Responses. + +Research the user's task using current web sources. Return a direct, evidence-based answer with the citations supplied by the provider. Do not claim to inspect local files, run tools, change code, or access the parent session. diff --git a/packages/pi-extension/package.json b/packages/pi-extension/package.json index a900c66..9820e1a 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 tools and cited research to your pi agent", "author": "Parallel Web", "license": "MIT", "type": "module", @@ -9,10 +9,16 @@ "image": "https://assets.parallel.ai/white-parallel-avatar-540.png", "extensions": [ "./dist/index.js" - ] + ], + "subagents": { + "agents": [ + "./agents" + ] + } }, "files": [ "dist", + "agents", "package.json", "README.md" ], @@ -41,6 +47,8 @@ "pi agent", "extension", "parallel", + "research", + "subagents", "web", "search", "fetch", 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..f062a77 --- /dev/null +++ b/packages/pi-extension/src/__tests__/package.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const packageRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); + +describe('pi-subagents package contract', () => { + it('ships the Parallel research agent through the Pi manifest', () => { + const manifest = JSON.parse( + readFileSync(resolve(packageRoot, 'package.json'), 'utf8') + ); + + expect(manifest.name).toBe('@parallel-web/pi-extension'); + expect(manifest.version).toBe('1.2.0'); + expect(manifest.files).toContain('agents'); + expect(manifest.pi.subagents.agents).toEqual(['./agents']); + }); + + it('pins a one-turn fresh agent to the Parallel research model', () => { + const agent = readFileSync( + resolve(packageRoot, 'agents', 'parallel-research.md'), + 'utf8' + ); + + expect(agent).toContain('name: parallel-research'); + expect(agent).toContain('model: parallel/research'); + expect(agent).toContain('thinking: medium'); + expect(agent).toContain('systemPromptMode: replace'); + expect(agent).toContain('inheritProjectContext: false'); + expect(agent).toContain('inheritSkills: false'); + expect(agent).toContain('defaultContext: fresh'); + expect(agent).toContain('completionGuard: false'); + expect(agent).toContain('turnBudget: {"maxTurns":1,"graceTurns":0}'); + }); +}); diff --git a/packages/pi-extension/src/__tests__/parallel-auth.test.ts b/packages/pi-extension/src/__tests__/parallel-auth.test.ts index 6de427b..6a9a20c 100644 --- a/packages/pi-extension/src/__tests__/parallel-auth.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-auth.test.ts @@ -56,12 +56,21 @@ describe('parallel-auth', () => { } = await import('../parallel-auth.js')); }); - it('registers a Parallel provider that serves no models', () => { + it('registers a Parallel provider with one Responses research model', () => { const provider = registerProvider(); expect(provider.id).toBe('parallel'); expect(provider.name).toBe('Parallel'); - expect(provider.getModels()).toEqual([]); + expect(provider.getModels()).toEqual([ + expect.objectContaining({ + id: 'research', + name: 'Parallel Research', + api: 'parallel-responses', + provider: 'parallel', + reasoning: true, + input: ['text'], + }), + ]); expect(provider.auth.apiKey).toBeDefined(); expect(provider.auth.oauth).toBeUndefined(); }); 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..94751bd --- /dev/null +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + AssistantMessageEvent, + Context, + SimpleStreamOptions, +} from '@earendil-works/pi-ai'; +import { + PARALLEL_RESEARCH_MODEL, + PARALLEL_RESPONSES_MAX_INPUT_CHARS, + PARALLEL_RESPONSES_URL, + streamParallelResponses, +} from '../parallel-responses.js'; + +function completedResponse( + overrides: Record = {} +): Record { + return { + id: 'resp_test', + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'Parallel found the answer.', + annotations: [ + { + type: 'url_citation', + url: 'https://example.com/source', + title: 'Example [source]', + start_index: 0, + end_index: 14, + }, + { + type: 'url_citation', + url: 'https://example.com/source', + title: 'Duplicate title', + start_index: 15, + end_index: 21, + }, + ], + }, + ], + }, + ], + usage: { + input_tokens: 12, + output_tokens: 34, + total_tokens: 46, + }, + ...overrides, + }; +} + +function response(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'x-request-id': 'request-test' }, + }); +} + +function researchContext(): Context { + return { + systemPrompt: 'Research carefully and cite sources.', + messages: [ + { role: 'user', content: 'Do not send this parent-history question.' }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Do not send this prior answer.' }], + api: 'test', + provider: 'test', + model: 'test', + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: 'stop', + timestamp: 1, + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Research the current API contract.' }, + { type: 'image', data: 'not-forwarded', mimeType: 'image/png' }, + ], + timestamp: 2, + }, + ], + tools: [ + { + name: 'read', + description: 'Must not be forwarded', + parameters: { type: 'object' }, + }, + ], + } as Context; +} + +async function collect( + options: SimpleStreamOptions, + context = researchContext() +) { + const stream = streamParallelResponses( + PARALLEL_RESEARCH_MODEL, + context, + options + ); + const resultPromise = stream.result(); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) events.push(event); + return { events, result: await resultPromise }; +} + +describe('Parallel Responses model', () => { + it('maps one stateless request and returns cited text with usage and cost', async () => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const onPayload = vi.fn(); + const onResponse = vi.fn(); + + const { events, result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock, + onPayload, + onResponse, + reasoning: 'medium', + metadata: { + sessionId: 'not-forwarded', + cwd: '/not-forwarded', + worktree: true, + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + PARALLEL_RESPONSES_URL, + expect.objectContaining({ + method: 'POST', + redirect: 'error', + signal: expect.any(AbortSignal), + }) + ); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body))).toEqual({ + model: 'parallel', + input: 'Research the current API contract.', + instructions: 'Research carefully and cite sources.', + reasoning: { effort: 'medium' }, + stream: false, + }); + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer test-api-key'); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-tool-calling-package')).toBe( + 'npm:@parallel-web/pi-extension/v1.2.0' + ); + expect(onPayload).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledWith( + { + status: 200, + headers: expect.objectContaining({ 'x-request-id': 'request-test' }), + }, + PARALLEL_RESEARCH_MODEL + ); + + expect(events.map((event) => event.type)).toEqual([ + 'start', + 'text_start', + 'text_delta', + 'text_end', + 'done', + ]); + expect(result.content).toEqual([ + { + type: 'text', + text: [ + 'Parallel found the answer.', + '', + 'Sources:', + '1. [Example \\[source\\]]()', + ].join('\n'), + }, + ]); + expect(result.usage).toEqual( + expect.objectContaining({ + input: 12, + output: 34, + totalTokens: 46, + cost: expect.objectContaining({ total: 0.05 }), + }) + ); + expect(result.stopReason).toBe('stop'); + }); + + it.each([ + ['minimal', 'low', 0.01], + ['low', 'low', 0.01], + ['medium', 'medium', 0.05], + ['high', 'high', 0.25], + ['xhigh', 'high', 0.25], + ['max', 'high', 0.25], + ] as const)( + 'maps %s thinking to %s effort', + async (thinking, effort, cost) => { + const fetchMock = vi.fn(async () => response(completedResponse())); + + const { result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock, + reasoning: thinking, + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body)).reasoning).toEqual({ effort }); + expect(result.usage.cost.total).toBe(cost); + } + ); + + it('fails oversized input before making a request', async () => { + const fetchMock = vi.fn(); + const context = researchContext(); + context.systemPrompt = 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS); + + const { events, result } = await collect( + { apiKey: 'test-api-key', fetch: fetchMock }, + context + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(events.at(-1)).toEqual( + expect.objectContaining({ type: 'error', reason: 'error' }) + ); + expect(result.stopReason).toBe('error'); + expect(result.errorMessage).toContain('20,000-character limit'); + }); + + it('does not retry HTTP errors and redacts the credential', async () => { + const fetchMock = vi.fn(async () => + response({ error: { message: 'key test-api-key is unauthorized' } }, 401) + ); + + const { result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe('error'); + expect(result.errorMessage).toBe( + 'Parallel Responses request failed (401): key [REDACTED] is unauthorized' + ); + expect(result.errorMessage).not.toContain('test-api-key'); + }); + + it('does not retry malformed completed responses', async () => { + const fetchMock = vi.fn(async () => + response(completedResponse({ output: [] })) + ); + + const { result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe('error'); + expect(result.errorMessage).toBe( + 'Parallel returned an empty research response.' + ); + }); + + it('propagates caller cancellation to the request signal', async () => { + const controller = new AbortController(); + controller.abort(new Error('cancelled by caller')); + const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { + expect(init?.signal?.aborted).toBe(true); + throw new DOMException('The operation was aborted.', 'AbortError'); + }); + + const { events, result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock as typeof fetch, + signal: controller.signal, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(events.at(-1)).toEqual( + expect.objectContaining({ type: 'error', reason: 'aborted' }) + ); + expect(result.stopReason).toBe('aborted'); + }); + + it('terminates a hung request at the local timeout without retrying', async () => { + const fetchMock = vi.fn( + async (_url: unknown, init?: RequestInit): Promise => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Timed out', 'AbortError')), + { once: true } + ); + }) + ); + + const { result } = await collect({ + apiKey: 'test-api-key', + fetch: fetchMock as typeof fetch, + timeoutMs: 5, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe('error'); + expect(result.errorMessage).toBe('Parallel Research timed out.'); + }); +}); diff --git a/packages/pi-extension/src/parallel-auth.ts b/packages/pi-extension/src/parallel-auth.ts index d0abbd5..f764077 100644 --- a/packages/pi-extension/src/parallel-auth.ts +++ b/packages/pi-extension/src/parallel-auth.ts @@ -8,8 +8,13 @@ import type { AuthResult, Provider, ProviderAuthInteraction, + SimpleStreamOptions, } from '@earendil-works/pi-ai'; import { loginWithParallel as runParallelOAuth } from '@parallel-web/oauth'; +import { + PARALLEL_RESEARCH_MODEL, + streamParallelResponses, +} from './parallel-responses'; /** Provider id under which Pi stores the Parallel credential in its auth store. */ export const PARALLEL_PROVIDER = 'parallel'; @@ -87,12 +92,16 @@ function createParallelProvider(): Provider { resolve: resolveParallelAuth, }, }, - getModels: () => [], - stream() { - throw new Error('The Parallel provider does not serve models.'); + getModels: () => [PARALLEL_RESEARCH_MODEL], + stream(model, context, options) { + return streamParallelResponses( + model, + context, + options as SimpleStreamOptions + ); }, - streamSimple() { - throw new Error('The Parallel provider does not serve models.'); + streamSimple(model, context, options) { + return streamParallelResponses(model, context, options); }, }; } diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts new file mode 100644 index 0000000..29fc5d3 --- /dev/null +++ b/packages/pi-extension/src/parallel-responses.ts @@ -0,0 +1,385 @@ +declare const __PACKAGE_VERSION__: string; + +import { + createAssistantMessageEventStream, + type Api, + type AssistantMessage, + type AssistantMessageEventStream, + type Context, + type Model, + type SimpleStreamOptions, +} from '@earendil-works/pi-ai'; + +export const PARALLEL_RESPONSES_API = 'parallel-responses'; +export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses'; +export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000; +export const PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS = 120_000; + +export const PARALLEL_RESEARCH_MODEL: Model = { + id: 'research', + name: 'Parallel Research', + api: PARALLEL_RESPONSES_API, + provider: 'parallel', + baseUrl: 'https://api.parallel.ai', + reasoning: true, + thinkingLevelMap: { + off: null, + minimal: 'low', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'high', + max: 'high', + }, + input: ['text'], + // Parallel Responses is billed per successful call, not per token. The + // custom stream records the fixed call price in usage.cost.total. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: PARALLEL_RESPONSES_MAX_INPUT_CHARS, + maxTokens: 32_000, +}; + +type ResearchEffort = 'low' | 'medium' | 'high'; + +const COST_PER_SUCCESSFUL_CALL: Record = { + low: 0.01, + medium: 0.05, + high: 0.25, +}; + +interface UrlCitation { + url: string; + title: string; +} + +interface ParsedResponse { + text: string; + citations: UrlCitation[]; + usage: { + input: number; + output: number; + totalTokens: number; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function safeInteger(value: unknown): number { + return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : 0; +} + +function resolveEffort( + reasoning: SimpleStreamOptions['reasoning'] +): ResearchEffort { + if (reasoning === 'minimal' || reasoning === 'low') return 'low'; + if (reasoning === 'high' || reasoning === 'xhigh' || reasoning === 'max') { + return 'high'; + } + return 'medium'; +} + +function latestUserText(context: Context): string { + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== 'user') continue; + + const text = + typeof message.content === 'string' + ? message.content + : message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); + + if (text.trim()) return text; + } + + throw new Error('Parallel Research requires a non-empty textual user task.'); +} + +function parseCitation(value: unknown): UrlCitation | undefined { + if (!isRecord(value) || value.type !== 'url_citation') return undefined; + if (typeof value.url !== 'string' || typeof value.title !== 'string') { + return undefined; + } + + let url: URL; + try { + url = new URL(value.url); + } catch { + return undefined; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined; + + return { + url: url.toString(), + title: value.title, + }; +} + +function parseResponse(payload: unknown): ParsedResponse { + 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 citations: UrlCitation[] = []; + for (const item of payload.output) { + if ( + !isRecord(item) || + item.type !== 'message' || + !Array.isArray(item.content) + ) { + continue; + } + for (const content of item.content) { + if ( + !isRecord(content) || + content.type !== 'output_text' || + typeof content.text !== 'string' + ) { + continue; + } + texts.push(content.text); + if (Array.isArray(content.annotations)) { + for (const annotation of content.annotations) { + const citation = parseCitation(annotation); + if (citation) citations.push(citation); + } + } + } + } + + const text = texts.join('\n\n').trim(); + if (!text) throw new Error('Parallel returned an empty research response.'); + + const usage = isRecord(payload.usage) ? payload.usage : {}; + return { + text, + citations, + usage: { + input: safeInteger(usage.input_tokens), + output: safeInteger(usage.output_tokens), + totalTokens: safeInteger(usage.total_tokens), + }, + }; +} + +function escapeMarkdownLabel(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('[', '\\[') + .replaceAll(']', '\\]'); +} + +function markdownUrl(value: string): string { + return value.replaceAll('<', '%3C').replaceAll('>', '%3E'); +} + +function renderCitedResearch(parsed: ParsedResponse): string { + const sources = new Map(); + for (const citation of parsed.citations) { + if (!sources.has(citation.url)) { + sources.set(citation.url, citation.title.trim() || citation.url); + } + } + if (sources.size === 0) return parsed.text; + + const list = [...sources].map( + ([url, title], index) => + `${index + 1}. [${escapeMarkdownLabel(title)}](<${markdownUrl(url)}>)` + ); + return `${parsed.text}\n\nSources:\n${list.join('\n')}`; +} + +function responseHeaders(response: Response): Record { + return Object.fromEntries(response.headers.entries()); +} + +function safeErrorMessage(error: unknown, apiKey: string): string { + let message: string; + try { + message = error instanceof Error ? error.message : String(error); + } catch { + message = 'Unknown provider failure'; + } + + if (apiKey) message = message.replaceAll(apiKey, '[REDACTED]'); + const trimmed = message.trim(); + return (trimmed || 'Unknown provider failure').slice(0, 1_000); +} + +async function httpError(response: Response): Promise { + let message = response.statusText || 'request failed'; + try { + const payload: unknown = await response.json(); + if (isRecord(payload) && isRecord(payload.error)) { + if (typeof payload.error.message === 'string') { + message = payload.error.message; + } + } + } catch { + // Status and statusText remain the useful, bounded diagnostic. + } + return new Error( + `Parallel Responses request failed (${response.status}): ${message}` + ); +} + +function createOutput(model: Model): AssistantMessage { + return { + role: 'assistant', + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'pending', + timestamp: Date.now(), + }; +} + +export function streamParallelResponses( + model: Model, + context: Context, + options?: SimpleStreamOptions +): AssistantMessageEventStream { + const stream = createAssistantMessageEventStream(); + const output = createOutput(model); + + void (async () => { + const apiKey = options?.apiKey ?? ''; + const requestController = new AbortController(); + let cancelledByCaller = false; + let timedOut = false; + let timeout: ReturnType | undefined; + + const abortFromCaller = () => { + cancelledByCaller = true; + requestController.abort(options?.signal?.reason); + }; + + try { + stream.push({ type: 'start', partial: output }); + if (!apiKey) { + throw new Error( + 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.' + ); + } + + if (options?.signal?.aborted) abortFromCaller(); + options?.signal?.addEventListener('abort', abortFromCaller, { + once: true, + }); + + const timeoutMs = + options?.timeoutMs ?? PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS; + if (timeoutMs > 0) { + timeout = setTimeout(() => { + timedOut = true; + requestController.abort(new Error('Parallel Research timed out.')); + }, timeoutMs); + timeout.unref?.(); + } + + const input = latestUserText(context); + const instructions = context.systemPrompt?.trim() || undefined; + if ( + input.length + (instructions?.length ?? 0) > + PARALLEL_RESPONSES_MAX_INPUT_CHARS + ) { + throw new Error( + `Parallel Research input exceeds the ${PARALLEL_RESPONSES_MAX_INPUT_CHARS.toLocaleString('en-US')}-character limit.` + ); + } + + const effort = resolveEffort(options?.reasoning); + let payload: unknown = { + model: 'parallel', + input, + ...(instructions ? { instructions } : {}), + reasoning: { effort }, + stream: false, + }; + const replacement = await options?.onPayload?.(payload, model); + if (replacement !== undefined) payload = replacement; + + const headers = new Headers({ + 'Content-Type': 'application/json', + 'X-Tool-Calling-Package': `npm:@parallel-web/pi-extension/v${__PACKAGE_VERSION__ ?? '0.0.0'}`, + }); + for (const [name, value] of Object.entries(options?.headers ?? {})) { + if (value === null) headers.delete(name); + else headers.set(name, value); + } + headers.set('Authorization', `Bearer ${apiKey}`); + + const fetchImpl = options?.fetch ?? globalThis.fetch; + const response = await fetchImpl(PARALLEL_RESPONSES_URL, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: requestController.signal, + redirect: 'error', + }); + await options?.onResponse?.( + { status: response.status, headers: responseHeaders(response) }, + model + ); + if (!response.ok) throw await httpError(response); + + const parsed = parseResponse(await response.json()); + const text = renderCitedResearch(parsed); + output.content.push({ type: 'text', text }); + output.usage.input = parsed.usage.input; + output.usage.output = parsed.usage.output; + output.usage.totalTokens = parsed.usage.totalTokens; + output.usage.cost.total = COST_PER_SUCCESSFUL_CALL[effort]; + output.stopReason = 'stop'; + + stream.push({ type: 'text_start', contentIndex: 0, partial: output }); + stream.push({ + type: 'text_delta', + contentIndex: 0, + delta: text, + partial: output, + }); + stream.push({ + type: 'text_end', + contentIndex: 0, + content: text, + partial: output, + }); + stream.push({ type: 'done', reason: 'stop', message: output }); + } catch (error) { + const aborted = cancelledByCaller; + output.stopReason = aborted ? 'aborted' : 'error'; + output.errorMessage = timedOut + ? 'Parallel Research timed out.' + : safeErrorMessage(error, apiKey); + stream.push({ + type: 'error', + reason: aborted ? 'aborted' : 'error', + error: output, + }); + } finally { + if (timeout) clearTimeout(timeout); + options?.signal?.removeEventListener('abort', abortFromCaller); + stream.end(); + } + })(); + + return stream; +} From 52619e5ef9fbc052080a3dc5dac4627297fc8dc9 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 17 Aug 2026 21:00:52 -0700 Subject: [PATCH 02/14] Tighten Parallel research subagent boundaries --- packages/pi-extension/README.md | 5 +++ .../src/__tests__/parallel-auth.test.ts | 2 + .../src/__tests__/parallel-responses.test.ts | 42 +++++++++++++++++++ packages/pi-extension/src/parallel-auth.ts | 8 ++-- .../pi-extension/src/parallel-responses.ts | 24 +++++++++-- 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index e6d564b..ef63cfe 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -89,6 +89,11 @@ environment variables, Pi tools, session state, or git worktree data. The agent cannot read or edit the local filesystem. A worktree therefore adds no research capability and should normally remain disabled. +These data boundaries describe normal `parallel-research` runs. Pi's low-level +provider API also exposes `onPayload` and custom-header hooks to trusted caller +code. A caller that deliberately uses those hooks to replace or extend the +request owns the resulting data boundary. + Parallel Responses accepts at most 20,000 combined instruction and input characters. The adapter fails before making a request when that boundary is exceeded. It renders the returned URL citations as a deduplicated Markdown diff --git a/packages/pi-extension/src/__tests__/parallel-auth.test.ts b/packages/pi-extension/src/__tests__/parallel-auth.test.ts index 6a9a20c..ea56c4d 100644 --- a/packages/pi-extension/src/__tests__/parallel-auth.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-auth.test.ts @@ -69,6 +69,8 @@ describe('parallel-auth', () => { provider: 'parallel', reasoning: true, input: ['text'], + contextWindow: 37_000, + maxTokens: 32_000, }), ]); expect(provider.auth.apiKey).toBeDefined(); diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index 94751bd..fca04a2 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -229,6 +229,48 @@ describe('Parallel Responses model', () => { } ); + it('honors an explicit caller-owned payload replacement', async () => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const replacement = { + model: 'parallel', + input: 'Trusted caller replacement.', + reasoning: { effort: 'low' }, + stream: false, + }; + + await collect({ + apiKey: 'test-api-key', + fetch: fetchMock, + onPayload: () => replacement, + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body))).toEqual(replacement); + }); + + it('does not fall back to history for a non-textual latest task', async () => { + const fetchMock = vi.fn(); + const context = researchContext(); + context.messages.push({ + role: 'user', + content: [ + { type: 'image', data: 'latest-image-only', mimeType: 'image/png' }, + ], + timestamp: 3, + }); + + const { result } = await collect( + { apiKey: 'test-api-key', fetch: fetchMock }, + context + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.stopReason).toBe('error'); + expect(result.errorMessage).toBe( + 'Parallel Research requires a non-empty textual user task.' + ); + }); + it('fails oversized input before making a request', async () => { const fetchMock = vi.fn(); const context = researchContext(); diff --git a/packages/pi-extension/src/parallel-auth.ts b/packages/pi-extension/src/parallel-auth.ts index f764077..6336243 100644 --- a/packages/pi-extension/src/parallel-auth.ts +++ b/packages/pi-extension/src/parallel-auth.ts @@ -76,10 +76,10 @@ 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. + * Parallel's provider owns both the shared credential boundary and the static + * research model. Pi owns auth.json, `/login parallel`, `/logout parallel`, + * and the `PARALLEL_API_KEY` fallback; both web tools and the model reuse that + * resolved credential without adding another auth concept. */ function createParallelProvider(): Provider { return { diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index 29fc5d3..5bcba42 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -15,6 +15,9 @@ export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses'; export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000; export const PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS = 120_000; +const RESPONSE_USAGE_CHARS_PER_TOKEN = 4; +const RESEARCH_MAX_OUTPUT_TOKENS = 32_000; + export const PARALLEL_RESEARCH_MODEL: Model = { id: 'research', name: 'Parallel Research', @@ -33,10 +36,17 @@ export const PARALLEL_RESEARCH_MODEL: Model = { }, input: ['text'], // Parallel Responses is billed per successful call, not per token. The - // custom stream records the fixed call price in usage.cost.total. + // custom stream records the fixed call price in usage.cost.total. Pi's + // contextWindow includes input plus output tokens, while Responses limits + // input in characters and reports usage with a four-chars-per-token + // estimate. Keep that estimate in catalog metadata; the explicit character + // check below remains the authoritative request limit. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: PARALLEL_RESPONSES_MAX_INPUT_CHARS, - maxTokens: 32_000, + contextWindow: + Math.ceil( + PARALLEL_RESPONSES_MAX_INPUT_CHARS / RESPONSE_USAGE_CHARS_PER_TOKEN + ) + RESEARCH_MAX_OUTPUT_TOKENS, + maxTokens: RESEARCH_MAX_OUTPUT_TOKENS, }; type ResearchEffort = 'low' | 'medium' | 'high'; @@ -94,6 +104,11 @@ function latestUserText(context: Context): string { .join('\n'); if (text.trim()) return text; + + // The latest user turn is the task boundary. Never fall back to an older + // user message when the current task is empty or non-textual, because that + // would turn parent history into a new research request. + break; } throw new Error('Parallel Research requires a non-empty textual user task.'); @@ -313,6 +328,9 @@ export function streamParallelResponses( reasoning: { effort }, stream: false, }; + // Pi exposes onPayload as an explicit inspect-or-replace hook. Normal + // pi-subagents use does not replace this minimal body; a low-level caller + // that does return a replacement owns the resulting data boundary. const replacement = await options?.onPayload?.(payload, model); if (replacement !== undefined) payload = replacement; From 709a53bccdbbc84999529ebd760723628ac0c27b Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 18 Aug 2026 09:33:43 -0700 Subject: [PATCH 03/14] Fix Pi auth guidance --- packages/pi-extension/README.md | 7 ++++--- packages/pi-extension/src/__tests__/index.test.ts | 8 ++++++++ packages/pi-extension/src/index.ts | 5 ++++- packages/pi-extension/src/parallel-auth.ts | 6 +++--- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index ef63cfe..e128064 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -26,8 +26,9 @@ 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. @@ -169,7 +170,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 diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index e0f1912..7cb39ae 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -239,6 +239,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 +264,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 () => { diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index c836e4d..fd32c58 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -130,8 +130,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' ); }, diff --git a/packages/pi-extension/src/parallel-auth.ts b/packages/pi-extension/src/parallel-auth.ts index 6336243..d3f2f31 100644 --- a/packages/pi-extension/src/parallel-auth.ts +++ b/packages/pi-extension/src/parallel-auth.ts @@ -77,9 +77,9 @@ async function resolveParallelAuth(input: { /** * Parallel's provider owns both the shared credential boundary and the static - * research model. Pi owns auth.json, `/login parallel`, `/logout parallel`, - * and the `PARALLEL_API_KEY` fallback; both web tools and the model reuse that - * resolved credential without adding another auth concept. + * research model. Pi owns auth.json, `/login parallel`, the `/logout` provider + * picker, and the `PARALLEL_API_KEY` fallback; both web tools and the model + * reuse that resolved credential without adding another auth concept. */ function createParallelProvider(): Provider { return { From 33e472f441d61dbb1611811c1298158636749500 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Wed, 26 Aug 2026 17:11:08 -0700 Subject: [PATCH 04/14] Keep local context out of Parallel research requests --- .../pi-extension/agents/parallel-research.md | 1 + .../src/__tests__/package.test.ts | 2 +- .../src/__tests__/parallel-responses.test.ts | 79 ++++++++++++++++++- .../pi-extension/src/parallel-responses.ts | 33 +++++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/packages/pi-extension/agents/parallel-research.md b/packages/pi-extension/agents/parallel-research.md index 6527fb6..33da15d 100644 --- a/packages/pi-extension/agents/parallel-research.md +++ b/packages/pi-extension/agents/parallel-research.md @@ -3,6 +3,7 @@ name: parallel-research description: One-shot cited web research through Parallel Responses model: parallel/research thinking: medium +tools: systemPromptMode: replace inheritProjectContext: false inheritSkills: false diff --git a/packages/pi-extension/src/__tests__/package.test.ts b/packages/pi-extension/src/__tests__/package.test.ts index f062a77..7fd3709 100644 --- a/packages/pi-extension/src/__tests__/package.test.ts +++ b/packages/pi-extension/src/__tests__/package.test.ts @@ -16,7 +16,6 @@ describe('pi-subagents package contract', () => { ); expect(manifest.name).toBe('@parallel-web/pi-extension'); - expect(manifest.version).toBe('1.2.0'); expect(manifest.files).toContain('agents'); expect(manifest.pi.subagents.agents).toEqual(['./agents']); }); @@ -30,6 +29,7 @@ describe('pi-subagents package contract', () => { expect(agent).toContain('name: parallel-research'); expect(agent).toContain('model: parallel/research'); expect(agent).toContain('thinking: medium'); + expect(agent).toMatch(/^tools:\s*$/m); expect(agent).toContain('systemPromptMode: replace'); expect(agent).toContain('inheritProjectContext: false'); expect(agent).toContain('inheritSkills: false'); diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index fca04a2..db7c440 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -1,9 +1,11 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import type { AssistantMessageEvent, Context, SimpleStreamOptions, } from '@earendil-works/pi-ai'; +import { version } from '../../package.json'; import { PARALLEL_RESEARCH_MODEL, PARALLEL_RESPONSES_MAX_INPUT_CHARS, @@ -11,6 +13,13 @@ import { streamParallelResponses, } from '../parallel-responses.js'; +const researchInstructions = readFileSync( + new URL('../../agents/parallel-research.md', import.meta.url), + 'utf8' +) + .split('\n---\n')[1] + .trim(); + function completedResponse( overrides: Record = {} ): Record { @@ -64,7 +73,8 @@ function response(payload: unknown, status = 200): Response { function researchContext(): Context { return { - systemPrompt: 'Research carefully and cite sources.', + systemPrompt: + 'Do not send this Pi system prompt.\nCurrent working directory: /not-forwarded', messages: [ { role: 'user', content: 'Do not send this parent-history question.' }, { @@ -157,7 +167,7 @@ describe('Parallel Responses model', () => { expect(JSON.parse(String(init.body))).toEqual({ model: 'parallel', input: 'Research the current API contract.', - instructions: 'Research carefully and cite sources.', + instructions: researchInstructions, reasoning: { effort: 'medium' }, stream: false, }); @@ -165,7 +175,7 @@ describe('Parallel Responses model', () => { expect(headers.get('authorization')).toBe('Bearer test-api-key'); expect(headers.get('content-type')).toBe('application/json'); expect(headers.get('x-tool-calling-package')).toBe( - 'npm:@parallel-web/pi-extension/v1.2.0' + `npm:@parallel-web/pi-extension/v${version}` ); expect(onPayload).toHaveBeenCalledTimes(1); expect(onResponse).toHaveBeenCalledWith( @@ -248,6 +258,63 @@ describe('Parallel Responses model', () => { expect(JSON.parse(String(init.body))).toEqual(replacement); }); + it('keeps Pi artifact delivery instructions out of the research task', async () => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const context = researchContext(); + const delivery = [ + 'Return the complete artifact in your final response.', + 'The runtime will persist it to exactly this path: /private/artifact.md', + 'Do not call contact_supervisor merely because no write-capable tool is available.', + 'This path is authoritative for this run.', + 'Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.', + ].join('\n'); + context.systemPrompt += `\n\nRuntime output path override:\n${delivery}\n\n## Turn budget\nOne turn.`; + context.messages.push({ + role: 'user', + content: `Research the current API contract.\n\n---\n**Output:**\n${delivery}`, + timestamp: 3, + }); + + await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body))).toEqual({ + model: 'parallel', + input: 'Research the current API contract.', + instructions: researchInstructions, + reasoning: { effort: 'medium' }, + stream: false, + }); + }); + + it('preserves user output instructions without a matching Pi decoration', async () => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const context = researchContext(); + const task = 'Research this API.\n\n---\n**Output:**\nA concise summary.'; + context.systemPrompt += + '\n\nRuntime output path override:\nA concise summary.\nWith additional Pi instructions.\n\n'; + context.messages.push({ role: 'user', content: task, timestamp: 3 }); + + await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body)).input).toBe(task); + }); + + it('does not count unsent Pi context against the input limit', async () => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const context = researchContext(); + context.systemPrompt = 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS); + + const { result } = await collect( + { apiKey: 'test-api-key', fetch: fetchMock }, + context + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe('stop'); + }); + it('does not fall back to history for a non-textual latest task', async () => { const fetchMock = vi.fn(); const context = researchContext(); @@ -274,7 +341,11 @@ describe('Parallel Responses model', () => { it('fails oversized input before making a request', async () => { const fetchMock = vi.fn(); const context = researchContext(); - context.systemPrompt = 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS); + context.messages.push({ + role: 'user', + content: 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS), + timestamp: 3, + }); const { events, result } = await collect( { apiKey: 'test-api-key', fetch: fetchMock }, diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index 5bcba42..cc1f63a 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -1,5 +1,6 @@ declare const __PACKAGE_VERSION__: string; +import { readFileSync } from 'node:fs'; import { createAssistantMessageEventStream, type Api, @@ -18,6 +19,15 @@ export const PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS = 120_000; const RESPONSE_USAGE_CHARS_PER_TOKEN = 4; const RESEARCH_MAX_OUTPUT_TOKENS = 32_000; +// Pi's assembled system prompt includes local runtime metadata. The shipped +// agent body is the complete instruction boundary for this remote provider. +const RESEARCH_INSTRUCTIONS = readFileSync( + new URL('../agents/parallel-research.md', import.meta.url), + 'utf8' +) + .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '') + .trim(); + export const PARALLEL_RESEARCH_MODEL: Model = { id: 'research', name: 'Parallel Research', @@ -95,7 +105,7 @@ function latestUserText(context: Context): string { const message = context.messages[index]; if (message.role !== 'user') continue; - const text = + let text = typeof message.content === 'string' ? message.content : message.content @@ -103,6 +113,22 @@ function latestUserText(context: Context): string { .map((part) => part.text) .join('\n'); + // pi-subagents duplicates its local artifact delivery instructions in the + // task and system prompt. Pi persists the final answer; that matching + // suffix is not part of the research question. Keep unmatched user text. + const outputSeparator = '\n\n---\n**Output:**\n'; + const outputIndex = text.lastIndexOf(outputSeparator); + if (outputIndex !== -1) { + const delivery = text.slice(outputIndex + outputSeparator.length); + const promptDelivery = `Runtime output path override:\n${delivery}`; + if ( + context.systemPrompt?.endsWith(promptDelivery) || + context.systemPrompt?.includes(`${promptDelivery}\n\n`) + ) { + text = text.slice(0, outputIndex); + } + } + if (text.trim()) return text; // The latest user turn is the task boundary. Never fall back to an older @@ -310,9 +336,8 @@ export function streamParallelResponses( } const input = latestUserText(context); - const instructions = context.systemPrompt?.trim() || undefined; if ( - input.length + (instructions?.length ?? 0) > + input.length + RESEARCH_INSTRUCTIONS.length > PARALLEL_RESPONSES_MAX_INPUT_CHARS ) { throw new Error( @@ -324,7 +349,7 @@ export function streamParallelResponses( let payload: unknown = { model: 'parallel', input, - ...(instructions ? { instructions } : {}), + instructions: RESEARCH_INSTRUCTIONS, reasoning: { effort }, stream: false, }; From 715c44419aa3ae8fde59e681fc1bce6b90f3e36b Mon Sep 17 00:00:00 2001 From: George Pickett Date: Wed, 26 Aug 2026 17:27:15 -0700 Subject: [PATCH 05/14] Keep temporary task paths out of research requests --- .../src/__tests__/parallel-responses.test.ts | 75 +++++++++++++------ .../pi-extension/src/parallel-responses.ts | 16 ++++ 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index db7c440..c9dcd0f 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AssistantMessageEvent, Context, @@ -20,6 +20,11 @@ const researchInstructions = readFileSync( .split('\n---\n')[1] .trim(); +const originalArgv = process.argv; +afterEach(() => { + process.argv = originalArgv; +}); + function completedResponse( overrides: Record = {} ): Record { @@ -258,33 +263,59 @@ describe('Parallel Responses model', () => { expect(JSON.parse(String(init.body))).toEqual(replacement); }); - it('keeps Pi artifact delivery instructions out of the research task', async () => { + it.each(['argument', 'file'])( + 'keeps Pi artifact delivery instructions out of a task delivered by %s', + async (deliveryMode) => { + const fetchMock = vi.fn(async () => response(completedResponse())); + const context = researchContext(); + const delivery = [ + 'Return the complete artifact in your final response.', + 'The runtime will persist it to exactly this path: /private/artifact.md', + 'Do not call contact_supervisor merely because no write-capable tool is available.', + 'This path is authoritative for this run.', + 'Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.', + ].join('\n'); + context.systemPrompt += `\n\nRuntime output path override:\n${delivery}\n\n## Turn budget\nOne turn.`; + let task = `Research the current API contract.\n\n---\n**Output:**\n${delivery}`; + if (deliveryMode === 'file') { + const taskPath = '/private/tmp/pi-subagent-fixture/task.md'; + process.argv = [...originalArgv, `@${taskPath}`]; + task = `\n${task}\n\n`; + } + context.messages.push({ + role: 'user', + content: task, + timestamp: 3, + }); + + await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body))).toEqual({ + model: 'parallel', + input: 'Research the current API contract.', + instructions: researchInstructions, + reasoning: { effort: 'medium' }, + stream: false, + }); + } + ); + + it('preserves file-shaped user text that is not the runtime task input', async () => { const fetchMock = vi.fn(async () => response(completedResponse())); const context = researchContext(); - const delivery = [ - 'Return the complete artifact in your final response.', - 'The runtime will persist it to exactly this path: /private/artifact.md', - 'Do not call contact_supervisor merely because no write-capable tool is available.', - 'This path is authoritative for this run.', - 'Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.', - ].join('\n'); - context.systemPrompt += `\n\nRuntime output path override:\n${delivery}\n\n## Turn budget\nOne turn.`; - context.messages.push({ - role: 'user', - content: `Research the current API contract.\n\n---\n**Output:**\n${delivery}`, - timestamp: 3, - }); + process.argv = [ + ...originalArgv, + '@/private/tmp/pi-subagent-runtime/task.md', + ]; + const task = + '\nResearch this API.\n\n'; + context.messages.push({ role: 'user', content: task, timestamp: 3 }); await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body))).toEqual({ - model: 'parallel', - input: 'Research the current API contract.', - instructions: researchInstructions, - reasoning: { effort: 'medium' }, - stream: false, - }); + expect(JSON.parse(String(init.body)).input).toBe(task); }); it('preserves user output instructions without a matching Pi decoration', async () => { diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index cc1f63a..c4c2f24 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -113,6 +113,22 @@ function latestUserText(context: Context): string { .map((part) => part.text) .join('\n'); + // Long pi-subagents tasks arrive through a temporary @file. Pi adds its + // absolute path to the message; unwrap only that command-line input, not + // file-shaped text supplied by the user. No file is read here. + const taskFileArg = process.argv.find((arg) => + /^@.*[/\\]pi-subagent-[^/\\]+[/\\]task\.md$/.test(arg) + ); + const filePrefix = taskFileArg && `\n`; + const fileSuffix = '\n\n'; + if ( + filePrefix && + text.startsWith(filePrefix) && + text.endsWith(fileSuffix) + ) { + text = text.slice(filePrefix.length, -fileSuffix.length); + } + // pi-subagents duplicates its local artifact delivery instructions in the // task and system prompt. Pi persists the final answer; that matching // suffix is not part of the research question. Keep unmatched user text. From df64fd69ad6c980c4602ea56b79a91ad50154051 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 16:43:51 -0700 Subject: [PATCH 06/14] Expose Parallel research as a Pi tool --- README.md | 2 +- packages/pi-extension/README.md | 117 ++-- .../pi-extension/agents/parallel-research.md | 18 - packages/pi-extension/package.json | 11 +- .../pi-extension/src/__tests__/index.test.ts | 153 ++++- .../src/__tests__/package.test.ts | 40 +- .../src/__tests__/parallel-auth.test.ts | 15 +- .../src/__tests__/parallel-responses.test.ts | 640 +++++++----------- packages/pi-extension/src/index.ts | 100 ++- packages/pi-extension/src/parallel-auth.ts | 25 +- .../pi-extension/src/parallel-responses.ts | 509 ++++---------- 11 files changed, 681 insertions(+), 949 deletions(-) delete mode 100644 packages/pi-extension/agents/parallel-research.md diff --git a/README.md b/README.md index 094ba76..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 web tools and a native Parallel Responses research subagent +- [`@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 e128064..5c35fb6 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -1,7 +1,7 @@ # @parallel-web/pi-extension -Pi extension that adds `web_search`, `web_fetch`, and a cited research model -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: ``` @@ -12,10 +12,8 @@ pi install npm:@parallel-web/pi-extension - Registers `web_search` - Registers `web_fetch` -- Registers the `parallel/research` model, which makes one stateless Parallel - Responses API call -- Ships a `parallel-research` agent for - [pi-subagents](https://github.com/nicobailon/pi-subagents) +- 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 @@ -32,73 +30,56 @@ variables are not affected by Pi's logout flow. Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer. -## Parallel Research Subagent +## Web Research -Install both packages to add the native research agent: - -```bash -pi install npm:pi-subagents -pi install npm:@parallel-web/pi-extension -``` - -This integration requires pi-subagents 0.50.0 or newer. The rest of the Pi -extension still works without pi-subagents. - -Run one research child directly: +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 -/run parallel-research Compare the current JavaScript runtimes in Node and Bun. Cite primary sources. +Compare the current Node.js compatibility of Node and Bun for a production API server. Research the tradeoffs and cite primary sources. ``` -The agent is also an ordinary pi-subagents child in JavaScript code mode. Its -`output` is the cited research text, so a later branch can use it directly: +The agent can call the research tool directly: ```javascript -const research = await runs.run("research", { - agent: "parallel-research", - task: "Which JavaScript runtime currently has stronger Node API compatibility? Cite primary sources.", - thinking: "medium", - context: "fresh", - worktree: false +web_research({ + query: "Compare the current Node.js compatibility of Node and Bun for a production API server. Cite primary sources.", + effort: "medium" }); - -if (/Bun/i.test(research.output)) { - return { recommendation: "evaluate-bun", evidence: research.output }; -} -return { recommendation: "stay-on-node", evidence: research.output }; ``` -The default research effort is `medium`. A run may select `low`, `medium`, or -`high` with its `thinking` option. Current -[prices](https://docs.parallel.ai/getting-started/pricing) per successful -response are: - -| Thinking | Price | Typical use | -| --- | ---: | --- | -| `low` | $0.01 | Focused lookup | -| `medium` | $0.05 | General research | -| `high` | $0.25 | Hard, high-value research | - -The provider makes one `POST /v1/responses` request and does not retry it. It -does not use `previous_response_id`, background jobs, or a remote status loop. -Stopping the child aborts the local HTTP request on a best-effort basis; -Parallel does not expose acknowledged server-side cancellation for Responses. - -The research request contains only the packaged agent instructions and the -latest textual child task. It does not send parent history, local files, cwd, -environment variables, Pi tools, session state, or git worktree data. The -agent cannot read or edit the local filesystem. A worktree therefore adds no -research capability and should normally remain disabled. - -These data boundaries describe normal `parallel-research` runs. Pi's low-level -provider API also exposes `onPayload` and custom-header hooks to trusted caller -code. A caller that deliberately uses those hooks to replace or extend the -request owns the resulting data boundary. - -Parallel Responses accepts at most 20,000 combined instruction and input -characters. The adapter fails before making a request when that boundary is -exceeded. It renders the returned URL citations as a deduplicated Markdown -source list. +| 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 | + +`query` must be a complete, self-contained question. Research does not see the +conversation or local files, so include relevant constraints and only context +that is safe to send. Start with the full question in one call, then make +focused follow-ups for anything left unresolved. + +`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, background jobs, or remote continuation state. The local +deadline is 120 seconds, including reading the response. 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. 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 before sending them. It preserves the answer and renders returned +HTTP(S) citations as a deduplicated Markdown source list. 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. ## Dogfooding Locally @@ -124,7 +105,7 @@ If the extension loads successfully, Pi will have: - the `web_fetch` tool - `parallel` listed under `/login` - the `parallel-login` status command -- the `parallel/research` model +- the `web_research` tool - per-session Parallel `session_id` reuse inside that Pi session ### Option 2: Symlink It Into Pi Extensions @@ -216,14 +197,14 @@ pnpm --filter @parallel-web/pi-extension typecheck ## Notes -- The extension uses the `parallel-web` TypeScript SDK directly. -- Search requests use Parallel SDK `basic` mode. +- Search and Fetch use the `parallel-web` TypeScript SDK; Research calls the + Responses endpoint directly. +- Search requests use Parallel SDK `advanced` 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. - The login flow tries to open your browser automatically. - 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")`. -- The research model is stateless and separate from the Search/Extract - `session_id` used by the web tools. +- 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/agents/parallel-research.md b/packages/pi-extension/agents/parallel-research.md deleted file mode 100644 index 33da15d..0000000 --- a/packages/pi-extension/agents/parallel-research.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: parallel-research -description: One-shot cited web research through Parallel Responses -model: parallel/research -thinking: medium -tools: -systemPromptMode: replace -inheritProjectContext: false -inheritSkills: false -defaultContext: fresh -completionGuard: false -turnBudget: {"maxTurns":1,"graceTurns":0} -acceptance: {"level":"none","reason":"One-shot remote research provider"} ---- - -You are a read-only research agent backed by Parallel Responses. - -Research the user's task using current web sources. Return a direct, evidence-based answer with the citations supplied by the provider. Do not claim to inspect local files, run tools, change code, or access the parent session. diff --git a/packages/pi-extension/package.json b/packages/pi-extension/package.json index 9820e1a..d46cf0a 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 Parallel web tools and cited research to your pi agent", + "description": "Add Parallel web search, fetch, and research tools to your pi agent", "author": "Parallel Web", "license": "MIT", "type": "module", @@ -9,16 +9,10 @@ "image": "https://assets.parallel.ai/white-parallel-avatar-540.png", "extensions": [ "./dist/index.js" - ], - "subagents": { - "agents": [ - "./agents" - ] - } + ] }, "files": [ "dist", - "agents", "package.json", "README.md" ], @@ -48,7 +42,6 @@ "extension", "parallel", "research", - "subagents", "web", "search", "fetch", diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index 7cb39ae..e9cf8f3 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; import type { ExtensionAPI, ExtensionContext, @@ -10,6 +12,7 @@ const mocks = vi.hoisted(() => ({ registerParallelAuthProvider: vi.fn(), runParallelSearch: vi.fn(), runParallelExtract: vi.fn(), + runParallelResearch: vi.fn(), isParallelAuthenticationError: vi.fn(), })); @@ -25,6 +28,10 @@ vi.mock('../parallel-client.js', () => ({ isParallelAuthenticationError: mocks.isParallelAuthenticationError, })); +vi.mock('../parallel-responses.js', () => ({ + runParallelResearch: mocks.runParallelResearch, +})); + type MockPi = { on: ReturnType; registerCommand: ReturnType; @@ -82,6 +89,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 +116,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 +126,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 +167,39 @@ 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'); + 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 () => { @@ -470,4 +521,100 @@ describe('@parallel-web/pi-extension', () => { undefined ); }); + it('web_research uses shared auth and sends only explicit arguments with cancellation', async () => { + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + mocks.runParallelResearch.mockResolvedValue({ + text: 'Answer with [source](https://example.com)', + effort: 'low', + }); + const extension = (await import('../index.js')).default; + const pi = createMockPi(); + extension(pi as unknown as ExtensionAPI); + const tool = getRegisteredTool(pi, 'web_research'); + const signal = new AbortController().signal; + const onUpdate = vi.fn(); + const result = await tool.execute( + 'research-1', + { + query: 'A complete question', + effort: 'low', + history: 'private-history', + }, + signal, + onUpdate, + createToolContext({ + cwd: '/private-project', + model: { id: 'fixture-parent' }, + }) + ); + expect(mocks.runParallelResearch).toHaveBeenCalledWith( + 'stored-api-key', + { + query: 'A complete question', + effort: 'low', + }, + signal + ); + 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('web_research keeps the full answer and citations when its preview is truncated', async () => { + mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); + const fullText = + 'finding\n'.repeat(5_000) + + '\nSources:\n[Final source](https://example.com/end)'; + mocks.runParallelResearch.mockResolvedValue({ + text: fullText, + effort: 'medium', + }); + 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).toContain('Research output truncated'); + expect(result.content[0].text).toContain(outputFile); + expect(result.content[0].text.length).toBeLessThan(fullText.length); + expect(await readFile(outputFile, 'utf8')).toBe(fullText); + expect((await stat(outputFile)).mode & 0o077).toBe(0); + } finally { + if (outputFile) + await rm(dirname(outputFile), { recursive: true, force: true }); + } + }); + + 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 index 7fd3709..983076d 100644 --- a/packages/pi-extension/src/__tests__/package.test.ts +++ b/packages/pi-extension/src/__tests__/package.test.ts @@ -1,40 +1,16 @@ import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const packageRoot = resolve( - dirname(fileURLToPath(import.meta.url)), - '..', - '..' -); - -describe('pi-subagents package contract', () => { - it('ships the Parallel research agent through the Pi manifest', () => { +describe('Pi research package contract', () => { + it('ships research through the existing extension without a child package', () => { const manifest = JSON.parse( - readFileSync(resolve(packageRoot, 'package.json'), 'utf8') + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') ); - expect(manifest.name).toBe('@parallel-web/pi-extension'); - expect(manifest.files).toContain('agents'); - expect(manifest.pi.subagents.agents).toEqual(['./agents']); - }); - - it('pins a one-turn fresh agent to the Parallel research model', () => { - const agent = readFileSync( - resolve(packageRoot, 'agents', 'parallel-research.md'), - 'utf8' - ); - - expect(agent).toContain('name: parallel-research'); - expect(agent).toContain('model: parallel/research'); - expect(agent).toContain('thinking: medium'); - expect(agent).toMatch(/^tools:\s*$/m); - expect(agent).toContain('systemPromptMode: replace'); - expect(agent).toContain('inheritProjectContext: false'); - expect(agent).toContain('inheritSkills: false'); - expect(agent).toContain('defaultContext: fresh'); - expect(agent).toContain('completionGuard: false'); - expect(agent).toContain('turnBudget: {"maxTurns":1,"graceTurns":0}'); + 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 ea56c4d..18a1083 100644 --- a/packages/pi-extension/src/__tests__/parallel-auth.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-auth.test.ts @@ -56,23 +56,12 @@ describe('parallel-auth', () => { } = await import('../parallel-auth.js')); }); - it('registers a Parallel provider with one Responses research model', () => { + it('registers the shared credential provider without research models', () => { const provider = registerProvider(); expect(provider.id).toBe('parallel'); expect(provider.name).toBe('Parallel'); - expect(provider.getModels()).toEqual([ - expect.objectContaining({ - id: 'research', - name: 'Parallel Research', - api: 'parallel-responses', - provider: 'parallel', - reasoning: true, - input: ['text'], - contextWindow: 37_000, - maxTokens: 32_000, - }), - ]); + expect(provider.getModels()).toEqual([]); expect(provider.auth.apiKey).toBeDefined(); expect(provider.auth.oauth).toBeUndefined(); }); diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index c9dcd0f..7259e09 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -1,472 +1,324 @@ -import { readFileSync } from 'node:fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { - AssistantMessageEvent, - Context, - SimpleStreamOptions, -} from '@earendil-works/pi-ai'; import { version } from '../../package.json'; import { - PARALLEL_RESEARCH_MODEL, PARALLEL_RESPONSES_MAX_INPUT_CHARS, + PARALLEL_RESPONSES_TIMEOUT_MS, PARALLEL_RESPONSES_URL, - streamParallelResponses, + runParallelResearch, + type ResearchInput, } from '../parallel-responses.js'; -const researchInstructions = readFileSync( - new URL('../../agents/parallel-research.md', import.meta.url), - 'utf8' -) - .split('\n---\n')[1] - .trim(); +const apiKey = 'test-api-key'; +const query = 'Compare the current Node.js compatibility of Node and Bun.'; -const originalArgv = process.argv; -afterEach(() => { - process.argv = originalArgv; -}); - -function completedResponse( - overrides: Record = {} -): Record { +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 { - id: 'resp_test', status: 'completed', output: [ { type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text: 'Parallel found the answer.', - annotations: [ - { - type: 'url_citation', - url: 'https://example.com/source', - title: 'Example [source]', - start_index: 0, - end_index: 14, - }, - { - type: 'url_citation', - url: 'https://example.com/source', - title: 'Duplicate title', - start_index: 15, - end_index: 21, - }, - ], - }, - ], + content: [{ type: 'output_text', text, annotations }], }, ], - usage: { - input_tokens: 12, - output_tokens: 34, - total_tokens: 46, - }, - ...overrides, }; } -function response(payload: unknown, status = 200): Response { - return new Response(JSON.stringify(payload), { - status, - headers: { 'x-request-id': 'request-test' }, - }); -} - -function researchContext(): Context { - return { - systemPrompt: - 'Do not send this Pi system prompt.\nCurrent working directory: /not-forwarded', - messages: [ - { role: 'user', content: 'Do not send this parent-history question.' }, - { - role: 'assistant', - content: [{ type: 'text', text: 'Do not send this prior answer.' }], - api: 'test', - provider: 'test', - model: 'test', - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - stopReason: 'stop', - timestamp: 1, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Research the current API contract.' }, - { type: 'image', data: 'not-forwarded', mimeType: 'image/png' }, - ], - timestamp: 2, - }, - ], - tools: [ - { - name: 'read', - description: 'Must not be forwarded', - parameters: { type: 'object' }, - }, - ], - } as Context; -} - -async function collect( - options: SimpleStreamOptions, - context = researchContext() -) { - const stream = streamParallelResponses( - PARALLEL_RESEARCH_MODEL, - context, - options +function mockResponse(payload: unknown = completed(), status = 200) { + const fetchMock = vi.fn( + async (_url: unknown, _init?: RequestInit) => + new Response(JSON.stringify(payload), { status }) ); - const resultPromise = stream.result(); - const events: AssistantMessageEvent[] = []; - for await (const event of stream) events.push(event); - return { events, result: await resultPromise }; + vi.stubGlobal('fetch', fetchMock); + return fetchMock; } -describe('Parallel Responses model', () => { - it('maps one stateless request and returns cited text with usage and cost', async () => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const onPayload = vi.fn(); - const onResponse = vi.fn(); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); - const { events, result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock, - onPayload, - onResponse, - reasoning: 'medium', - metadata: { - sessionId: 'not-forwarded', - cwd: '/not-forwarded', - worktree: true, - }, - }); +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); - expect(fetchMock).toHaveBeenCalledWith( - PARALLEL_RESPONSES_URL, - expect.objectContaining({ - method: 'POST', - redirect: 'error', - signal: expect.any(AbortSignal), - }) - ); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body))).toEqual({ + 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: 'Research the current API contract.', - instructions: researchInstructions, + input: query, + instructions: expect.stringContaining('Research the user'), reasoning: { effort: 'medium' }, stream: false, }); - const headers = new Headers(init.headers); - expect(headers.get('authorization')).toBe('Bearer test-api-key'); - expect(headers.get('content-type')).toBe('application/json'); - expect(headers.get('x-tool-calling-package')).toBe( + 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(onPayload).toHaveBeenCalledTimes(1); - expect(onResponse).toHaveBeenCalledWith( - { - status: 200, - headers: expect.objectContaining({ 'x-request-id': 'request-test' }), - }, - PARALLEL_RESEARCH_MODEL - ); - - expect(events.map((event) => event.type)).toEqual([ - 'start', - 'text_start', - 'text_delta', - 'text_end', - 'done', - ]); - expect(result.content).toEqual([ - { - type: 'text', - text: [ - 'Parallel found the answer.', - '', - 'Sources:', - '1. [Example \\[source\\]]()', - ].join('\n'), - }, - ]); - expect(result.usage).toEqual( - expect.objectContaining({ - input: 12, - output: 34, - totalTokens: 46, - cost: expect.objectContaining({ total: 0.05 }), - }) - ); - expect(result.stopReason).toBe('stop'); + expect(result).toEqual({ + effort: 'medium', + text: 'The researched answer.\n\nSources:\n1. [Example \\[source\\]]()', + }); }); - it.each([ - ['minimal', 'low', 0.01], - ['low', 'low', 0.01], - ['medium', 'medium', 0.05], - ['high', 'high', 0.25], - ['xhigh', 'high', 0.25], - ['max', 'high', 0.25], - ] as const)( - 'maps %s thinking to %s effort', - async (thinking, effort, cost) => { - const fetchMock = vi.fn(async () => response(completedResponse())); - - const { result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock, - reasoning: thinking, - }); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body)).reasoning).toEqual({ effort }); - expect(result.usage.cost.total).toBe(cost); + 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('honors an explicit caller-owned payload replacement', async () => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const replacement = { - model: 'parallel', - input: 'Trusted caller replacement.', - reasoning: { effort: 'low' }, - stream: false, - }; - - await collect({ - apiKey: 'test-api-key', - fetch: fetchMock, - onPayload: () => replacement, - }); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body))).toEqual(replacement); - }); - - it.each(['argument', 'file'])( - 'keeps Pi artifact delivery instructions out of a task delivered by %s', - async (deliveryMode) => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const context = researchContext(); - const delivery = [ - 'Return the complete artifact in your final response.', - 'The runtime will persist it to exactly this path: /private/artifact.md', - 'Do not call contact_supervisor merely because no write-capable tool is available.', - 'This path is authoritative for this run.', - 'Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.', - ].join('\n'); - context.systemPrompt += `\n\nRuntime output path override:\n${delivery}\n\n## Turn budget\nOne turn.`; - let task = `Research the current API contract.\n\n---\n**Output:**\n${delivery}`; - if (deliveryMode === 'file') { - const taskPath = '/private/tmp/pi-subagent-fixture/task.md'; - process.argv = [...originalArgv, `@${taskPath}`]; - task = `\n${task}\n\n`; - } - context.messages.push({ - role: 'user', - content: task, - timestamp: 3, - }); - - await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body))).toEqual({ - model: 'parallel', - input: 'Research the current API contract.', - instructions: researchInstructions, - reasoning: { effort: 'medium' }, - stream: false, - }); + it.each([ + [{ query: '' }, 'non-empty question'], + [{ query: ' \n\t' }, 'non-empty question'], + [{ query: null }, 'non-empty question'], + [{ query, effort: 'extreme' }, '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('preserves file-shaped user text that is not the runtime task input', async () => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const context = researchContext(); - process.argv = [ - ...originalArgv, - '@/private/tmp/pi-subagent-runtime/task.md', - ]; - const task = - '\nResearch this API.\n\n'; - context.messages.push({ role: 'user', content: task, timestamp: 3 }); - - await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body)).input).toBe(task); - }); - - it('preserves user output instructions without a matching Pi decoration', async () => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const context = researchContext(); - const task = 'Research this API.\n\n---\n**Output:**\nA concise summary.'; - context.systemPrompt += - '\n\nRuntime output path override:\nA concise summary.\nWith additional Pi instructions.\n\n'; - context.messages.push({ role: 'user', content: task, timestamp: 3 }); - - await collect({ apiKey: 'test-api-key', fetch: fetchMock }, context); + 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; + fetchMock.mockClear(); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body)).input).toBe(task); + 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('does not count unsent Pi context against the input limit', async () => { - const fetchMock = vi.fn(async () => response(completedResponse())); - const context = researchContext(); - context.systemPrompt = 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS); - - const { result } = await collect( - { apiKey: 'test-api-key', fetch: fetchMock }, - context + it('requires authentication before sending a request', async () => { + const fetchMock = mockResponse(); + await expect(runParallelResearch('', { query })).rejects.toThrow( + '/login parallel' ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.stopReason).toBe('stop'); + expect(fetchMock).not.toHaveBeenCalled(); }); +}); - it('does not fall back to history for a non-textual latest task', async () => { - const fetchMock = vi.fn(); - const context = researchContext(); - context.messages.push({ - role: 'user', +describe('Parallel research evidence', () => { + 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: 'image', data: 'latest-image-only', mimeType: 'image/png' }, + { type: 'output_text', text: 'Second finding.', annotations: [] }, ], - timestamp: 3, }); - - const { result } = await collect( - { apiKey: 'test-api-key', fetch: fetchMock }, - context - ); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(result.stopReason).toBe('error'); - expect(result.errorMessage).toBe( - 'Parallel Research requires a non-empty textual user task.' + mockResponse(payload); + 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('fails oversized input before making a request', async () => { - const fetchMock = vi.fn(); - const context = researchContext(); - context.messages.push({ - role: 'user', - content: 'x'.repeat(PARALLEL_RESPONSES_MAX_INPUT_CHARS), - timestamp: 3, - }); - - const { events, result } = await collect( - { apiKey: 'test-api-key', fetch: fetchMock }, - context + 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.' ); + }); - expect(fetchMock).not.toHaveBeenCalled(); - expect(events.at(-1)).toEqual( - expect.objectContaining({ type: 'error', reason: 'error' }) + 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(result.stopReason).toBe('error'); - expect(result.errorMessage).toContain('20,000-character limit'); + expect(fetchMock).toHaveBeenCalledTimes(1); }); +}); - it('does not retry HTTP errors and redacts the credential', async () => { - const fetchMock = vi.fn(async () => - response({ error: { message: 'key test-api-key is unauthorized' } }, 401) - ); +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); + } + ); - const { result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock, + 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); - expect(result.stopReason).toBe('error'); - expect(result.errorMessage).toBe( - 'Parallel Responses request failed (401): key [REDACTED] is unauthorized' - ); - expect(result.errorMessage).not.toContain('test-api-key'); }); - it('does not retry malformed completed responses', async () => { - const fetchMock = vi.fn(async () => - response(completedResponse({ output: [] })) - ); + 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); + }); - const { result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock, + it('bounds network errors and redacts the key', async () => { + const fetchMock = vi.fn(async () => { + throw new Error(`${apiKey} ${'x'.repeat(5_000)}`); }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.stopReason).toBe('error'); - expect(result.errorMessage).toBe( - 'Parallel returned an empty research response.' + 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('propagates caller cancellation to the request signal', async () => { + it('sends no request when already cancelled', async () => { + const fetchMock = mockResponse(); const controller = new AbortController(); - controller.abort(new Error('cancelled by caller')); - const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { - expect(init?.signal?.aborted).toBe(true); - throw new DOMException('The operation was aborted.', 'AbortError'); - }); - - const { events, result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock as typeof fetch, - signal: controller.signal, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(events.at(-1)).toEqual( - expect.objectContaining({ type: 'error', reason: 'aborted' }) - ); - expect(result.stopReason).toBe('aborted'); + controller.abort(); + await expect( + runParallelResearch(apiKey, { query }, controller.signal) + ).rejects.toThrow('cancelled'); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('terminates a hung request at the local timeout without retrying', async () => { + it('cancels an in-flight request without retrying', async () => { + const controller = new AbortController(); const fetchMock = vi.fn( - async (_url: unknown, init?: RequestInit): Promise => - await new Promise((_resolve, reject) => { + async (_url: unknown, init?: RequestInit) => + await new Promise((_resolve, reject) => { init?.signal?.addEventListener( 'abort', - () => reject(new DOMException('Timed out', 'AbortError')), + () => reject(new Error('aborted')), { once: true } ); }) ); - - const { result } = await collect({ - apiKey: 'test-api-key', - fetch: fetchMock as typeof fetch, - timeoutMs: 5, - }); - + 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); - expect(result.stopReason).toBe('error'); - expect(result.errorMessage).toBe('Parallel Research timed out.'); }); + + 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 fd32c58..ffaa024 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,7 @@ import { truncateHead, } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; +import { runParallelResearch } from './parallel-responses'; import { getParallelApiKey, getParallelAuthStatus, @@ -62,16 +66,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. Pass the full self-contained question, including constraints, in one call; make focused follow-ups only for unresolved parts.', + 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(); @@ -143,21 +145,87 @@ 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\nPrefer sourced answers and preserve the returned citations.`, }; }); + 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 conversation or local files; include only context that is safe to send.', + '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 complete, self-contained research question, including all dates, constraints, and relevant context that is safe to send. Research has no access to the 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.', + } + ) + ), + }), + 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, + { query: params.query, effort: params.effort }, + signal + ) + ); + const preview = truncateHead(result.text, { + 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, result.text, { mode: 0o600 }); + text += `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`; + } + return { + content: [{ type: 'text', text }], + details: { + provider: 'parallel', + product: 'responses', + effort: result.effort, + ...(outputFile ? { outputFile } : {}), + }, + }; + }, + }); + pi.registerTool({ name: 'web_search', label: 'Web Search', @@ -166,7 +234,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 d3f2f31..4438623 100644 --- a/packages/pi-extension/src/parallel-auth.ts +++ b/packages/pi-extension/src/parallel-auth.ts @@ -8,13 +8,8 @@ import type { AuthResult, Provider, ProviderAuthInteraction, - SimpleStreamOptions, } from '@earendil-works/pi-ai'; import { loginWithParallel as runParallelOAuth } from '@parallel-web/oauth'; -import { - PARALLEL_RESEARCH_MODEL, - streamParallelResponses, -} from './parallel-responses'; /** Provider id under which Pi stores the Parallel credential in its auth store. */ export const PARALLEL_PROVIDER = 'parallel'; @@ -76,10 +71,8 @@ async function resolveParallelAuth(input: { } /** - * Parallel's provider owns both the shared credential boundary and the static - * research model. Pi owns auth.json, `/login parallel`, the `/logout` provider - * picker, and the `PARALLEL_API_KEY` fallback; both web tools and the model - * reuse that resolved credential without adding another auth concept. + * 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 { @@ -92,16 +85,12 @@ function createParallelProvider(): Provider { resolve: resolveParallelAuth, }, }, - getModels: () => [PARALLEL_RESEARCH_MODEL], - stream(model, context, options) { - return streamParallelResponses( - model, - context, - options as SimpleStreamOptions - ); + getModels: () => [], + stream() { + throw new Error('The Parallel provider does not serve models.'); }, - streamSimple(model, context, options) { - return streamParallelResponses(model, context, options); + streamSimple() { + throw new Error('The Parallel provider does not serve models.'); }, }; } diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index c4c2f24..ac232c4 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -1,182 +1,27 @@ declare const __PACKAGE_VERSION__: string; -import { readFileSync } from 'node:fs'; -import { - createAssistantMessageEventStream, - type Api, - type AssistantMessage, - type AssistantMessageEventStream, - type Context, - type Model, - type SimpleStreamOptions, -} from '@earendil-works/pi-ai'; - -export const PARALLEL_RESPONSES_API = 'parallel-responses'; export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses'; export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000; -export const PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS = 120_000; - -const RESPONSE_USAGE_CHARS_PER_TOKEN = 4; -const RESEARCH_MAX_OUTPUT_TOKENS = 32_000; - -// Pi's assembled system prompt includes local runtime metadata. The shipped -// agent body is the complete instruction boundary for this remote provider. -const RESEARCH_INSTRUCTIONS = readFileSync( - new URL('../agents/parallel-research.md', import.meta.url), - 'utf8' -) - .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '') - .trim(); +export const PARALLEL_RESPONSES_TIMEOUT_MS = 120_000; +export const DEFAULT_RESEARCH_EFFORT = 'medium'; -export const PARALLEL_RESEARCH_MODEL: Model = { - id: 'research', - name: 'Parallel Research', - api: PARALLEL_RESPONSES_API, - provider: 'parallel', - baseUrl: 'https://api.parallel.ai', - reasoning: true, - thinkingLevelMap: { - off: null, - minimal: 'low', - low: 'low', - medium: 'medium', - high: 'high', - xhigh: 'high', - max: 'high', - }, - input: ['text'], - // Parallel Responses is billed per successful call, not per token. The - // custom stream records the fixed call price in usage.cost.total. Pi's - // contextWindow includes input plus output tokens, while Responses limits - // input in characters and reports usage with a four-chars-per-token - // estimate. Keep that estimate in catalog metadata; the explicit character - // check below remains the authoritative request limit. - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: - Math.ceil( - PARALLEL_RESPONSES_MAX_INPUT_CHARS / RESPONSE_USAGE_CHARS_PER_TOKEN - ) + RESEARCH_MAX_OUTPUT_TOKENS, - maxTokens: RESEARCH_MAX_OUTPUT_TOKENS, -}; +export type ResearchEffort = 'low' | 'medium' | 'high'; -type ResearchEffort = 'low' | 'medium' | 'high'; - -const COST_PER_SUCCESSFUL_CALL: Record = { - low: 0.01, - medium: 0.05, - high: 0.25, -}; - -interface UrlCitation { - url: string; - title: string; +export interface ResearchInput { + query: string; + effort?: ResearchEffort; } -interface ParsedResponse { - text: string; - citations: UrlCitation[]; - usage: { - input: number; - output: number; - totalTokens: number; - }; -} +// Only these instructions and the explicit question 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); } -function safeInteger(value: unknown): number { - return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : 0; -} - -function resolveEffort( - reasoning: SimpleStreamOptions['reasoning'] -): ResearchEffort { - if (reasoning === 'minimal' || reasoning === 'low') return 'low'; - if (reasoning === 'high' || reasoning === 'xhigh' || reasoning === 'max') { - return 'high'; - } - return 'medium'; -} - -function latestUserText(context: Context): string { - for (let index = context.messages.length - 1; index >= 0; index -= 1) { - const message = context.messages[index]; - if (message.role !== 'user') continue; - - let text = - typeof message.content === 'string' - ? message.content - : message.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n'); - - // Long pi-subagents tasks arrive through a temporary @file. Pi adds its - // absolute path to the message; unwrap only that command-line input, not - // file-shaped text supplied by the user. No file is read here. - const taskFileArg = process.argv.find((arg) => - /^@.*[/\\]pi-subagent-[^/\\]+[/\\]task\.md$/.test(arg) - ); - const filePrefix = taskFileArg && `\n`; - const fileSuffix = '\n\n'; - if ( - filePrefix && - text.startsWith(filePrefix) && - text.endsWith(fileSuffix) - ) { - text = text.slice(filePrefix.length, -fileSuffix.length); - } - - // pi-subagents duplicates its local artifact delivery instructions in the - // task and system prompt. Pi persists the final answer; that matching - // suffix is not part of the research question. Keep unmatched user text. - const outputSeparator = '\n\n---\n**Output:**\n'; - const outputIndex = text.lastIndexOf(outputSeparator); - if (outputIndex !== -1) { - const delivery = text.slice(outputIndex + outputSeparator.length); - const promptDelivery = `Runtime output path override:\n${delivery}`; - if ( - context.systemPrompt?.endsWith(promptDelivery) || - context.systemPrompt?.includes(`${promptDelivery}\n\n`) - ) { - text = text.slice(0, outputIndex); - } - } - - if (text.trim()) return text; - - // The latest user turn is the task boundary. Never fall back to an older - // user message when the current task is empty or non-textual, because that - // would turn parent history into a new research request. - break; - } - - throw new Error('Parallel Research requires a non-empty textual user task.'); -} - -function parseCitation(value: unknown): UrlCitation | undefined { - if (!isRecord(value) || value.type !== 'url_citation') return undefined; - if (typeof value.url !== 'string' || typeof value.title !== 'string') { - return undefined; - } - - let url: URL; - try { - url = new URL(value.url); - } catch { - return undefined; - } - if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined; - - return { - url: url.toString(), - title: value.title, - }; -} - -function parseResponse(payload: unknown): ParsedResponse { +function renderResearch(payload: unknown): string { if (!isRecord(payload) || payload.status !== 'completed') { throw new Error('Parallel returned a response that was not completed.'); } @@ -185,7 +30,7 @@ function parseResponse(payload: unknown): ParsedResponse { } const texts: string[] = []; - const citations: UrlCitation[] = []; + const sources = new Map(); for (const item of payload.output) { if ( !isRecord(item) || @@ -203,242 +48,152 @@ function parseResponse(payload: unknown): ParsedResponse { continue; } texts.push(content.text); - if (Array.isArray(content.annotations)) { - for (const annotation of content.annotations) { - const citation = parseCitation(annotation); - if (citation) citations.push(citation); + if (!Array.isArray(content.annotations)) continue; + 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() : ''; + if (!sources.has(href)) sources.set(href, title || href); } } } const text = texts.join('\n\n').trim(); if (!text) throw new Error('Parallel returned an empty research response.'); + if (sources.size === 0) return text; - const usage = isRecord(payload.usage) ? payload.usage : {}; - return { - text, - citations, - usage: { - input: safeInteger(usage.input_tokens), - output: safeInteger(usage.output_tokens), - totalTokens: safeInteger(usage.total_tokens), - }, - }; -} - -function escapeMarkdownLabel(value: string): string { - return value - .replaceAll('\\', '\\\\') - .replaceAll('[', '\\[') - .replaceAll(']', '\\]'); -} - -function markdownUrl(value: string): string { - return value.replaceAll('<', '%3C').replaceAll('>', '%3E'); + const list = [...sources].map(([url, title], index) => { + const label = title + .replaceAll('\\', '\\\\') + .replaceAll('[', '\\[') + .replaceAll(']', '\\]') + .replace(/[\r\n]+/g, ' '); + const href = url.replaceAll('<', '%3C').replaceAll('>', '%3E'); + return `${index + 1}. [${label}](<${href}>)`; + }); + return `${text}\n\nSources:\n${list.join('\n')}`; } -function renderCitedResearch(parsed: ParsedResponse): string { - const sources = new Map(); - for (const citation of parsed.citations) { - if (!sources.has(citation.url)) { - sources.set(citation.url, citation.title.trim() || citation.url); - } - } - if (sources.size === 0) return parsed.text; - - const list = [...sources].map( - ([url, title], index) => - `${index + 1}. [${escapeMarkdownLabel(title)}](<${markdownUrl(url)}>)` - ); - return `${parsed.text}\n\nSources:\n${list.join('\n')}`; -} - -function responseHeaders(response: Response): Record { - return Object.fromEntries(response.headers.entries()); -} - -function safeErrorMessage(error: unknown, apiKey: string): string { +function safeError(error: unknown, apiKey: string): Error { let message: string; try { message = error instanceof Error ? error.message : String(error); } catch { - message = 'Unknown provider failure'; + message = 'Unknown research failure'; } - if (apiKey) message = message.replaceAll(apiKey, '[REDACTED]'); - const trimmed = message.trim(); - return (trimmed || 'Unknown provider failure').slice(0, 1_000); -} - -async function httpError(response: Response): Promise { - let message = response.statusText || 'request failed'; - try { - const payload: unknown = await response.json(); - if (isRecord(payload) && isRecord(payload.error)) { - if (typeof payload.error.message === 'string') { - message = payload.error.message; - } - } - } catch { - // Status and statusText remain the useful, bounded diagnostic. - } - return new Error( - `Parallel Responses request failed (${response.status}): ${message}` + 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; } -function createOutput(model: Model): AssistantMessage { - return { - role: 'assistant', - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: 'pending', - timestamp: Date.now(), - }; -} - -export function streamParallelResponses( - model: Model, - context: Context, - options?: SimpleStreamOptions -): AssistantMessageEventStream { - const stream = createAssistantMessageEventStream(); - const output = createOutput(model); - - void (async () => { - const apiKey = options?.apiKey ?? ''; - const requestController = new AbortController(); - let cancelledByCaller = false; - let timedOut = false; - let timeout: ReturnType | undefined; - - const abortFromCaller = () => { - cancelledByCaller = true; - requestController.abort(options?.signal?.reason); - }; - - try { - stream.push({ type: 'start', partial: output }); - if (!apiKey) { - throw new Error( - 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.' - ); - } - - if (options?.signal?.aborted) abortFromCaller(); - options?.signal?.addEventListener('abort', abortFromCaller, { - once: true, - }); - - const timeoutMs = - options?.timeoutMs ?? PARALLEL_RESPONSES_DEFAULT_TIMEOUT_MS; - if (timeoutMs > 0) { - timeout = setTimeout(() => { - timedOut = true; - requestController.abort(new Error('Parallel Research timed out.')); - }, timeoutMs); - timeout.unref?.(); - } - - const input = latestUserText(context); - if ( - input.length + RESEARCH_INSTRUCTIONS.length > - PARALLEL_RESPONSES_MAX_INPUT_CHARS - ) { - throw new Error( - `Parallel Research input exceeds the ${PARALLEL_RESPONSES_MAX_INPUT_CHARS.toLocaleString('en-US')}-character limit.` - ); - } +export async function runParallelResearch( + apiKey: string, + input: ResearchInput, + signal?: AbortSignal +): Promise<{ text: string; effort: ResearchEffort }> { + if (typeof input.query !== 'string' || !input.query.trim()) { + throw new Error('Parallel Research requires a non-empty question.'); + } + const effort = input.effort ?? DEFAULT_RESEARCH_EFFORT; + if (!['low', 'medium', 'high'].includes(effort)) { + throw new Error('Parallel Research effort must be low, medium, or high.'); + } + if ( + [...input.query].length + [...RESEARCH_INSTRUCTIONS].length > + PARALLEL_RESPONSES_MAX_INPUT_CHARS + ) { + throw new Error( + 'Parallel Research exceeds the 20,000-character input limit, including research instructions.' + ); + } + if (!apiKey) { + throw new Error( + 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.' + ); + } - const effort = resolveEffort(options?.reasoning); - let payload: unknown = { + 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, + input: input.query, instructions: RESEARCH_INSTRUCTIONS, reasoning: { effort }, stream: false, - }; - // Pi exposes onPayload as an explicit inspect-or-replace hook. Normal - // pi-subagents use does not replace this minimal body; a low-level caller - // that does return a replacement owns the resulting data boundary. - const replacement = await options?.onPayload?.(payload, model); - if (replacement !== undefined) payload = replacement; - - const headers = new Headers({ - 'Content-Type': 'application/json', - 'X-Tool-Calling-Package': `npm:@parallel-web/pi-extension/v${__PACKAGE_VERSION__ ?? '0.0.0'}`, - }); - for (const [name, value] of Object.entries(options?.headers ?? {})) { - if (value === null) headers.delete(name); - else headers.set(name, value); + }), + 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. } - headers.set('Authorization', `Bearer ${apiKey}`); - - const fetchImpl = options?.fetch ?? globalThis.fetch; - const response = await fetchImpl(PARALLEL_RESPONSES_URL, { - method: 'POST', - headers, - body: JSON.stringify(payload), - signal: requestController.signal, - redirect: 'error', - }); - await options?.onResponse?.( - { status: response.status, headers: responseHeaders(response) }, - model + requestSignal.throwIfAborted(); + throw Object.assign( + new Error( + `Parallel Responses request failed (${response.status}): ${message}` + ), + { status: response.status } ); - if (!response.ok) throw await httpError(response); - - const parsed = parseResponse(await response.json()); - const text = renderCitedResearch(parsed); - output.content.push({ type: 'text', text }); - output.usage.input = parsed.usage.input; - output.usage.output = parsed.usage.output; - output.usage.totalTokens = parsed.usage.totalTokens; - output.usage.cost.total = COST_PER_SUCCESSFUL_CALL[effort]; - output.stopReason = 'stop'; - - stream.push({ type: 'text_start', contentIndex: 0, partial: output }); - stream.push({ - type: 'text_delta', - contentIndex: 0, - delta: text, - partial: output, - }); - stream.push({ - type: 'text_end', - contentIndex: 0, - content: text, - partial: output, - }); - stream.push({ type: 'done', reason: 'stop', message: output }); - } catch (error) { - const aborted = cancelledByCaller; - output.stopReason = aborted ? 'aborted' : 'error'; - output.errorMessage = timedOut - ? 'Parallel Research timed out.' - : safeErrorMessage(error, apiKey); - stream.push({ - type: 'error', - reason: aborted ? 'aborted' : 'error', - error: output, - }); - } finally { - if (timeout) clearTimeout(timeout); - options?.signal?.removeEventListener('abort', abortFromCaller); - stream.end(); } - })(); - return stream; + const payload: unknown = await response.json(); + requestSignal.throwIfAborted(); + return { text: renderResearch(payload), effort }; + } 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.'); + throw safeError(error, apiKey); + } finally { + clearTimeout(timeout); + } } From 587774fa1ba0d74b8358a5c83273954269befacf Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 16:57:21 -0700 Subject: [PATCH 07/14] Keep research output bounded and preserve source links --- .../pi-extension/src/__tests__/index.test.ts | 82 +++++++++++-------- packages/pi-extension/src/index.ts | 9 +- 2 files changed, 57 insertions(+), 34 deletions(-) diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index e9cf8f3..bec3d94 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { readFile, rm, stat } from 'node:fs/promises'; import { dirname } from 'node:path'; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, +} from '@earendil-works/pi-coding-agent'; import type { ExtensionAPI, ExtensionContext, @@ -187,6 +191,9 @@ describe('@parallel-web/pi-extension', () => { ); 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'] }, @@ -565,39 +572,50 @@ describe('@parallel-web/pi-extension', () => { expect(result.details.outputFile).toBeUndefined(); }); - it('web_research keeps the full answer and citations when its preview is truncated', async () => { - mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); - const fullText = - 'finding\n'.repeat(5_000) + - '\nSources:\n[Final source](https://example.com/end)'; - mocks.runParallelResearch.mockResolvedValue({ - text: fullText, - effort: 'medium', - }); - 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).toContain('Research output truncated'); - expect(result.content[0].text).toContain(outputFile); - expect(result.content[0].text.length).toBeLessThan(fullText.length); - expect(await readFile(outputFile, 'utf8')).toBe(fullText); - expect((await stat(outputFile)).mode & 0o077).toBe(0); - } finally { - if (outputFile) - await rm(dirname(outputFile), { recursive: true, force: true }); + 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)'; + mocks.runParallelResearch.mockResolvedValue({ + text: fullText, + effort: 'medium', + }); + 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).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(fullText); + expect((await stat(outputFile)).mode & 0o077).toBe(0); + } finally { + if (outputFile) + await rm(dirname(outputFile), { recursive: true, force: true }); + } } - }); + ); it('web_research reuses the existing missing-credential guidance', async () => { mocks.getParallelApiKey.mockResolvedValue(undefined); diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index ffaa024..81f126d 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -156,7 +156,7 @@ export default function (pi: ExtensionAPI) { } return { - 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\nPrefer sourced answers and preserve the returned citations.`, + 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.`, }; }); @@ -212,7 +212,12 @@ export default function (pi: ExtensionAPI) { const directory = await mkdtemp(join(tmpdir(), 'parallel-research-')); outputFile = join(directory, 'research.md'); await writeFile(outputFile, result.text, { mode: 0o600 }); - text += `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`; + const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`; + text = + truncateHead(result.text, { + maxLines: DEFAULT_MAX_LINES - 2, + maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice), + }).content + notice; } return { content: [{ type: 'text', text }], From 28bbfb5951b9476e390287be1f40f61d136c3106 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 17:22:48 -0700 Subject: [PATCH 08/14] Validate research inputs and complete answers --- .../pi-extension/src/__tests__/index.test.ts | 39 ++++++++++++- .../src/__tests__/parallel-responses.test.ts | 43 ++++++++++++++- packages/pi-extension/src/index.ts | 4 +- .../pi-extension/src/parallel-responses.ts | 55 ++++++++++++------- 4 files changed, 117 insertions(+), 24 deletions(-) diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index bec3d94..5c7cda7 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -1,6 +1,7 @@ import { 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, @@ -32,7 +33,8 @@ vi.mock('../parallel-client.js', () => ({ isParallelAuthenticationError: mocks.isParallelAuthenticationError, })); -vi.mock('../parallel-responses.js', () => ({ +vi.mock('../parallel-responses.js', async (importOriginal) => ({ + ...(await importOriginal()), runParallelResearch: mocks.runParallelResearch, })); @@ -528,6 +530,41 @@ 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('web_research uses shared auth and sends only explicit arguments with cancellation', async () => { mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); mocks.runParallelResearch.mockResolvedValue({ diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index 7259e09..8e7c9c3 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -109,6 +109,7 @@ describe('Parallel research request', () => { [{ 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', @@ -169,7 +170,10 @@ describe('Parallel research evidence', () => { { type: 'output_text', text: 'Second finding.', annotations: [] }, ], }); - mockResponse(payload); + 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]()'); @@ -199,6 +203,43 @@ describe('Parallel research evidence', () => { ); 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', () => { diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index 81f126d..b0ffdb2 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -13,7 +13,7 @@ import { truncateHead, } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; -import { runParallelResearch } from './parallel-responses'; +import { parseResearchInput, runParallelResearch } from './parallel-responses'; import { getParallelApiKey, getParallelAuthStatus, @@ -188,6 +188,8 @@ export default function (pi: ExtensionAPI) { ) ), }), + // 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...' }], diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index ac232c4..c7e491b 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -32,20 +32,23 @@ function renderResearch(payload: unknown): string { const texts: string[] = []; const sources = new Map(); for (const item of payload.output) { - if ( - !isRecord(item) || - item.type !== 'message' || - !Array.isArray(item.content) - ) { - continue; + 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) || - content.type !== 'output_text' || - typeof content.text !== 'string' - ) { - continue; + 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; @@ -106,16 +109,17 @@ function safeError(error: unknown, apiKey: string): Error { return result; } -export async function runParallelResearch( - apiKey: string, - input: ResearchInput, - signal?: AbortSignal -): Promise<{ text: string; effort: ResearchEffort }> { - if (typeof input.query !== 'string' || !input.query.trim()) { +export function parseResearchInput(input: unknown): Required { + if ( + !isRecord(input) || + typeof input.query !== 'string' || + !input.query.trim() + ) { throw new Error('Parallel Research requires a non-empty question.'); } - const effort = input.effort ?? DEFAULT_RESEARCH_EFFORT; - if (!['low', 'medium', 'high'].includes(effort)) { + 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 ( @@ -126,6 +130,15 @@ export async function runParallelResearch( 'Parallel Research exceeds the 20,000-character input limit, including research instructions.' ); } + return { query: input.query, effort }; +} + +export async function runParallelResearch( + apiKey: string, + input: ResearchInput, + signal?: AbortSignal +): Promise<{ text: string; effort: ResearchEffort }> { + const { query, effort } = parseResearchInput(input); if (!apiKey) { throw new Error( 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.' @@ -153,7 +166,7 @@ export async function runParallelResearch( }, body: JSON.stringify({ model: 'parallel', - input: input.query, + input: query, instructions: RESEARCH_INSTRUCTIONS, reasoning: { effort }, stream: false, From a67ee43f432819326a9869120b6b52807ea5d553 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 17:40:28 -0700 Subject: [PATCH 09/14] Protect research errors and literal source URLs --- .../src/__tests__/parallel-responses.test.ts | 21 +++++++++++++++++++ .../pi-extension/src/parallel-responses.ts | 8 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index 8e7c9c3..5365d5a 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -191,6 +191,16 @@ describe('Parallel research evidence', () => { ); }); + 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'], @@ -278,6 +288,17 @@ describe('Parallel research failure lifecycle', () => { 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)}`); diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index c7e491b..b780005 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -85,7 +85,10 @@ function renderResearch(payload: unknown): string { .replaceAll('[', '\\[') .replaceAll(']', '\\]') .replace(/[\r\n]+/g, ' '); - const href = url.replaceAll('<', '%3C').replaceAll('>', '%3E'); + const href = url + .replaceAll('\\', '%5C') + .replaceAll('<', '%3C') + .replaceAll('>', '%3E'); return `${index + 1}. [${label}](<${href}>)`; }); return `${text}\n\nSources:\n${list.join('\n')}`; @@ -205,6 +208,9 @@ export async function runParallelResearch( 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); From b7f2a122f5cb570d0ab15b004e6a655d11c7c86d Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 18:39:47 -0700 Subject: [PATCH 10/14] Preserve research claim citations and match API input limits --- packages/pi-extension/README.md | 24 ++- .../src/__tests__/parallel-responses.test.ts | 148 +++++++++++++++++- .../pi-extension/src/parallel-responses.ts | 40 ++++- 3 files changed, 197 insertions(+), 15 deletions(-) diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index f3176d3..00c1ecc 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -56,8 +56,9 @@ web_research({ `query` must be a complete, self-contained question. Research does not see the conversation or local files, so include relevant constraints and only context -that is safe to send. Start with the full question in one call, then make -focused follow-ups for anything left unresolved. +that is safe to send. Start with the full question in one call. For focused +follow-ups, restate the relevant constraints and findings because earlier +research calls are not automatically included. `effort` is optional and defaults to `medium`, matching the Responses API default. Use `low` for focused lookups, `medium` for general research, and @@ -65,8 +66,10 @@ default. Use `low` for focused lookups, `medium` for general research, and the [current pricing](https://docs.parallel.ai/getting-started/pricing). Each invocation makes one non-streaming `POST /v1/responses` request, with no -automatic retries, background jobs, or remote continuation state. The local -deadline is 120 seconds, including reading the response. Cancelling the tool +automatic retries or background jobs. Calls are independent: the tool does +not pass `previous_response_id` to reuse earlier research. 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. @@ -76,10 +79,15 @@ 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 before sending them. It preserves the answer and renders returned -HTTP(S) citations as a deduplicated Markdown source list. 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. +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. ## Dogfooding Locally diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index 5365d5a..a3697e4 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -132,7 +132,7 @@ describe('Parallel research request', () => { String(fetchMock.mock.calls[0][1]?.body) ); const capacity = - PARALLEL_RESPONSES_MAX_INPUT_CHARS - [...instructions].length; + PARALLEL_RESPONSES_MAX_INPUT_CHARS - [...instructions].length - 1; fetchMock.mockClear(); await runParallelResearch(apiKey, { query: '🔎'.repeat(capacity) }); @@ -153,6 +153,152 @@ describe('Parallel research request', () => { }); describe('Parallel research evidence', () => { + 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').trim() + ); + expect(text).toContain('part 1, characters 4:'); + 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' }, diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index b780005..5926b45 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -30,7 +30,7 @@ function renderResearch(payload: unknown): string { } const texts: string[] = []; - const sources = new Map(); + 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.'); @@ -52,6 +52,8 @@ function renderResearch(payload: unknown): string { } 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) || @@ -70,7 +72,27 @@ function renderResearch(payload: unknown): string { const href = url.toString(); const title = typeof citation.title === 'string' ? citation.title.trim() : ''; - if (!sources.has(href)) sources.set(href, title || href); + 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)}` + ); + } } } } @@ -79,7 +101,7 @@ function renderResearch(payload: unknown): string { if (!text) throw new Error('Parallel returned an empty research response.'); if (sources.size === 0) return text; - const list = [...sources].map(([url, title], index) => { + const list = [...sources].map(([url, { title, passages }], index) => { const label = title .replaceAll('\\', '\\\\') .replaceAll('[', '\\[') @@ -89,9 +111,15 @@ function renderResearch(payload: unknown): string { .replaceAll('\\', '%5C') .replaceAll('<', '%3C') .replaceAll('>', '%3E'); - return `${index + 1}. [${label}](<${href}>)`; + const link = `${index + 1}. [${label}](<${href}>)`; + return [link, ...[...passages].map((passage) => ` ${passage}`)].join( + '\n' + ); }); - return `${text}\n\nSources:\n${list.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 { @@ -126,7 +154,7 @@ export function parseResearchInput(input: unknown): Required { throw new Error('Parallel Research effort must be low, medium, or high.'); } if ( - [...input.query].length + [...RESEARCH_INSTRUCTIONS].length > + [...`${RESEARCH_INSTRUCTIONS}\n${input.query}`].length > PARALLEL_RESPONSES_MAX_INPUT_CHARS ) { throw new Error( From aadd2b060b769c045897ea98f6cd1e1ead08a47e Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 18:52:47 -0700 Subject: [PATCH 11/14] Preserve research answer whitespace --- .../src/__tests__/parallel-responses.test.ts | 20 +++++++++++++------ .../pi-extension/src/parallel-responses.ts | 5 +++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index a3697e4..316e020 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -153,6 +153,16 @@ describe('Parallel research request', () => { }); 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.'; @@ -227,10 +237,10 @@ describe('Parallel research evidence', () => { }); it('resolves each passage against its original text part without editing Markdown', async () => { - const first = ' 🔎 A [link](https://example.com/path).\n'; + 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 firstStart = [...' 🔎 '].length; const secondStart = [...'Code:\n\n~~~js\n'].length; const payload = completed(first, [ { @@ -254,10 +264,8 @@ describe('Parallel research evidence', () => { }); mockResponse(payload); const { text } = await runParallelResearch(apiKey, { query }); - expect(text.split('\n\nSources:\n')[0]).toBe( - [first, second].join('\n\n').trim() - ); - expect(text).toContain('part 1, characters 4:'); + 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( diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index 5926b45..64a25b1 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -97,8 +97,9 @@ function renderResearch(payload: unknown): string { } } - const text = texts.join('\n\n').trim(); - if (!text) throw new Error('Parallel returned an empty research response.'); + 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) => { From 7f926559ac035dba97f801ef13ad7d426e6eff2f Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 19:37:20 -0700 Subject: [PATCH 12/14] Support explicit Pi research follow-ups --- packages/pi-extension/README.md | 39 ++++- .../pi-extension/src/__tests__/index.test.ts | 85 ++++++++++- .../src/__tests__/parallel-responses.test.ts | 141 ++++++++++++++++++ packages/pi-extension/src/index.ts | 41 ++++- .../pi-extension/src/parallel-responses.ts | 47 +++++- 5 files changed, 330 insertions(+), 23 deletions(-) diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index 00c1ecc..c9ffb50 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -54,11 +54,33 @@ web_research({ | `web_search` | Discovering sources and raw excerpts to investigate yourself | | `web_fetch` | Reading known URLs or checking original sources | -`query` must be a complete, self-contained question. Research does not see the +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. Start with the full question in one call. For focused -follow-ups, restate the relevant constraints and findings because earlier -research calls are not automatically included. +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 @@ -66,15 +88,15 @@ default. Use `low` for focused lookups, `medium` for general research, and 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. Calls are independent: the tool does -not pass `previous_response_id` to reuse earlier research. The local deadline +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. It does not automatically forward parent history, +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. @@ -87,7 +109,8 @@ 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. +temporary file. The response ID stays visible in the preview and is included +in the complete report. ## Dogfooding Locally diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index 5c7cda7..babd443 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -565,6 +565,83 @@ describe('@parallel-web/pi-extension', () => { } ); + 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 () => { mocks.getParallelApiKey.mockResolvedValue('stored-api-key'); mocks.runParallelResearch.mockResolvedValue({ @@ -618,9 +695,11 @@ describe('@parallel-web/pi-extension', () => { ? '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(); @@ -636,6 +715,10 @@ describe('@parallel-web/pi-extension', () => { ); 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); @@ -645,7 +728,7 @@ describe('@parallel-web/pi-extension', () => { expect(result.content[0].text.split('\n').length).toBeLessThanOrEqual( DEFAULT_MAX_LINES ); - expect(await readFile(outputFile, 'utf8')).toBe(fullText); + expect(await readFile(outputFile, 'utf8')).toBe(fullReport); expect((await stat(outputFile)).mode & 0o077).toBe(0); } finally { if (outputFile) diff --git a/packages/pi-extension/src/__tests__/parallel-responses.test.ts b/packages/pi-extension/src/__tests__/parallel-responses.test.ts index 316e020..a0f1837 100644 --- a/packages/pi-extension/src/__tests__/parallel-responses.test.ts +++ b/packages/pi-extension/src/__tests__/parallel-responses.test.ts @@ -152,6 +152,147 @@ describe('Parallel research request', () => { }); }); +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', diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index b0ffdb2..1704c5b 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -13,7 +13,11 @@ import { truncateHead, } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; -import { parseResearchInput, runParallelResearch } from './parallel-responses'; +import { + MAX_RESPONSE_ID_LENGTH, + parseResearchInput, + runParallelResearch, +} from './parallel-responses'; import { getParallelApiKey, getParallelAuthStatus, @@ -68,7 +72,7 @@ function suppressSkillsFromPrompt(systemPrompt: string) { const WEB_TOOL_GUIDANCE = { web_research: - 'Use web_research for a complete answer that requires current web research and synthesis. Pass the full self-contained question, including constraints, in one call; make focused follow-ups only for unresolved parts.', + '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: @@ -169,14 +173,15 @@ export default function (pi: ExtensionAPI) { '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 conversation or local files; include only context that is safe to send.', + '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 complete, self-contained research question, including all dates, constraints, and relevant context that is safe to send. Research has no access to the conversation or local files.', + '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( @@ -187,6 +192,14 @@ export default function (pi: ExtensionAPI) { } ) ), + 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, @@ -198,11 +211,22 @@ export default function (pi: ExtensionAPI) { const result = await runWithAuth(ctx, (apiKey) => runParallelResearch( apiKey, - { query: params.query, effort: params.effort }, + { + query: params.query, + effort: params.effort, + ...(params.previous_response_id !== undefined + ? { previous_response_id: params.previous_response_id } + : {}), + }, signal ) ); - const preview = truncateHead(result.text, { + // 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, }); @@ -213,10 +237,10 @@ export default function (pi: ExtensionAPI) { // 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, result.text, { mode: 0o600 }); + await writeFile(outputFile, report, { mode: 0o600 }); const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`; text = - truncateHead(result.text, { + truncateHead(report, { maxLines: DEFAULT_MAX_LINES - 2, maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice), }).content + notice; @@ -227,6 +251,7 @@ export default function (pi: ExtensionAPI) { provider: 'parallel', product: 'responses', effort: result.effort, + ...(result.responseId ? { responseId: result.responseId } : {}), ...(outputFile ? { outputFile } : {}), }, }; diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index 64a25b1..f02330f 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -4,15 +4,18 @@ 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 and the explicit question cross the research boundary. +// 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."; @@ -21,6 +24,17 @@ 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.'); @@ -141,7 +155,9 @@ function safeError(error: unknown, apiKey: string): Error { return result; } -export function parseResearchInput(input: unknown): Required { +export function parseResearchInput( + input: unknown +): ResearchInput & { effort: ResearchEffort } { if ( !isRecord(input) || typeof input.query !== 'string' || @@ -162,15 +178,27 @@ export function parseResearchInput(input: unknown): Required { 'Parallel Research exceeds the 20,000-character input limit, including research instructions.' ); } - return { query: input.query, effort }; + 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 }> { - const { query, effort } = parseResearchInput(input); +): 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.' @@ -202,6 +230,7 @@ export async function runParallelResearch( instructions: RESEARCH_INSTRUCTIONS, reasoning: { effort }, stream: false, + ...(previous_response_id !== undefined ? { previous_response_id } : {}), }), signal: requestSignal, redirect: 'error', @@ -232,7 +261,13 @@ export async function runParallelResearch( const payload: unknown = await response.json(); requestSignal.throwIfAborted(); - return { text: renderResearch(payload), effort }; + 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) From 13d82c01b8340f082c3cd574d8e51b717c78381e Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 20:05:23 -0700 Subject: [PATCH 13/14] Preserve research errors that mention authentication --- .../pi-extension/src/__tests__/index.test.ts | 70 +++++++++++++++++++ packages/pi-extension/src/index.ts | 35 ++++++---- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index babd443..6db74a5 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -737,6 +737,76 @@ describe('@parallel-web/pi-extension', () => { } ); + 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; diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index 1704c5b..0998057 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -97,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; } @@ -208,18 +209,24 @@ export default function (pi: ExtensionAPI) { content: [{ type: 'text', text: 'Researching the web...' }], details: { provider: 'parallel', product: 'responses' }, }); - const result = await runWithAuth(ctx, (apiKey) => - runParallelResearch( - apiKey, - { - query: params.query, - effort: params.effort, - ...(params.previous_response_id !== undefined - ? { previous_response_id: params.previous_response_id } - : {}), - }, - signal - ) + const result = await runWithAuth( + ctx, + (apiKey) => + runParallelResearch( + apiKey, + { + query: params.query, + effort: params.effort, + ...(params.previous_response_id !== undefined + ? { previous_response_id: params.previous_response_id } + : {}), + }, + 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. From 4da5957156c2f5486d175d38338d2191f1c76d5f Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 27 Aug 2026 21:25:58 -0700 Subject: [PATCH 14/14] Remove redundant research argument copying --- .../pi-extension/src/__tests__/index.test.ts | 51 ++++++++++++++----- packages/pi-extension/src/index.ts | 13 +---- .../pi-extension/src/parallel-responses.ts | 2 +- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/pi-extension/src/__tests__/index.test.ts b/packages/pi-extension/src/__tests__/index.test.ts index 6db74a5..dd50101 100644 --- a/packages/pi-extension/src/__tests__/index.test.ts +++ b/packages/pi-extension/src/__tests__/index.test.ts @@ -1,4 +1,4 @@ -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'; @@ -83,6 +83,8 @@ function createToolContext(overrides: Record = {}) { } describe('@parallel-web/pi-extension', () => { + afterEach(() => vi.unstubAllGlobals()); + beforeEach(() => { vi.clearAllMocks(); delete process.env.PARALLEL_API_KEY; @@ -643,16 +645,30 @@ describe('@parallel-web/pi-extension', () => { }); 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.mockResolvedValue({ - text: 'Answer with [source](https://example.com)', - effort: 'low', - }); + 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 signal = new AbortController().signal; + const controller = new AbortController(); + const signal = controller.signal; const onUpdate = vi.fn(); const result = await tool.execute( 'research-1', @@ -660,6 +676,7 @@ describe('@parallel-web/pi-extension', () => { query: 'A complete question', effort: 'low', history: 'private-history', + instructions: 'private-instructions', }, signal, onUpdate, @@ -668,14 +685,22 @@ describe('@parallel-web/pi-extension', () => { model: { id: 'fixture-parent' }, }) ); - expect(mocks.runParallelResearch).toHaveBeenCalledWith( - 'stored-api-key', - { - query: 'A complete question', - effort: 'low', - }, - signal + 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)' }, diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index 0998057..593287f 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -211,18 +211,7 @@ export default function (pi: ExtensionAPI) { }); const result = await runWithAuth( ctx, - (apiKey) => - runParallelResearch( - apiKey, - { - query: params.query, - effort: params.effort, - ...(params.previous_response_id !== undefined - ? { previous_response_id: params.previous_response_id } - : {}), - }, - signal - ), + (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) => diff --git a/packages/pi-extension/src/parallel-responses.ts b/packages/pi-extension/src/parallel-responses.ts index f02330f..785bc4f 100644 --- a/packages/pi-extension/src/parallel-responses.ts +++ b/packages/pi-extension/src/parallel-responses.ts @@ -230,7 +230,7 @@ export async function runParallelResearch( instructions: RESEARCH_INSTRUCTIONS, reasoning: { effort }, stream: false, - ...(previous_response_id !== undefined ? { previous_response_id } : {}), + previous_response_id, }), signal: requestSignal, redirect: 'error',