diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aba3bf..7479b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,10 +105,10 @@ jobs: cache: 'pnpm' - name: Install dependencies - run: pnpm install --filter @parallel-web/dsh-web-search... --frozen-lockfile + run: pnpm install --filter @parallel-web/dsh-web-search... --filter @parallel-web/dsh-responses-subagent... --frozen-lockfile - name: Run deterministic plugin checks - run: pnpm --filter @parallel-web/dsh-web-search check + run: pnpm --filter @parallel-web/dsh-web-search --filter @parallel-web/dsh-responses-subagent run check - name: Verify packed DSH profile lifecycle shell: bash @@ -133,6 +133,47 @@ jobs: node packages/dsh-web-search/scripts/verify-packed-profile.mjs \ --dsh-home "$dsh_home" \ --expected-version "$version" + + pnpm --dir packages/dsh-responses-subagent pack --pack-destination "$pack_dir" + responses_artifact="$(find "$pack_dir" -name '*dsh-responses-subagent*.tgz' -print -quit)" + tar -xOzf "$responses_artifact" package/package.json | node -e ' + const manifest = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + if (manifest.private || manifest.publishConfig?.access !== "public" || + manifest.dsh?.bundle?.patch !== "./cordis.patch.yml") { + throw new Error("invalid packed Responses plugin manifest"); + } + ' + DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ + plugin --profile web add "$responses_artifact" + DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ + --profile web --dump-config > "$dsh_home/combined.yml" + grep -Fq 'id: subagent-parallel-responses' "$dsh_home/combined.yml" + grep -Fq 'toolName: parallel_research' "$dsh_home/combined.yml" + grep -Fq 'id: web-search-parallel' "$dsh_home/combined.yml" + + node --input-type=module -e ' + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + import { join } from "node:path"; + + const require = createRequire(join(process.argv[1], "profiles/web/package.json")); + const plugin = await import(require.resolve("@parallel-web/dsh-responses-subagent")); + const { Context } = await import(require.resolve("@deepseek-ai/cordis")); + const subagents = await import(require.resolve("@deepseek-ai/dsh-subagent")); + const context = new Context(); + context.provide("systemPrompt", { section() {} }); + await context.plugin(subagents.default); + await context.plugin(plugin, { apiKey: "parallel_test_packed_profile" }); + assert.equal(plugin.Config.dict.apiKey.meta.role, "secret"); + assert.equal(context.subagents.getProvider("parallel-responses")?.inheritsParentContext, false); + ' "$dsh_home" + + DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ + plugin --profile web remove @parallel-web/dsh-responses-subagent + DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ + --profile web --dump-config > "$dsh_home/responses-removed.yml" + cmp "$dsh_home/after.yml" "$dsh_home/responses-removed.yml" + DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ plugin --profile web remove @parallel-web/dsh-web-search DSH_HOME="$dsh_home" pnpm --dir packages/dsh-web-search exec dsh \ diff --git a/.gitignore b/.gitignore index 05c7c25..6ab33bb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ # Build output dist/ packages/dsh-web-search/lib/ +packages/dsh-responses-subagent/lib/ *.tsbuildinfo # Testing diff --git a/PUBLISHING.md b/PUBLISHING.md index 4e6e67d..80223d4 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,12 +1,17 @@ # Publishing Guide -This monorepo publishes four npm packages, each versioned, tagged, and released **independently**: +This monorepo tracks five publishable npm packages, each versioned, tagged, and released +**independently**: - `@parallel-web/ai-sdk-tools` — `packages/ai-sdk-tools` +- `@parallel-web/dsh-responses-subagent` — `packages/dsh-responses-subagent` - `@parallel-web/dsh-web-search` — `packages/dsh-web-search` - `@parallel-web/opencode-plugin` — `packages/opencode-plugin` - `@parallel-web/pi-extension` — `packages/pi-extension` +`@parallel-web/dsh-responses-subagent` has not yet been published. It can be installed only +from a local tarball until an npm organization owner completes its reviewed first release. + (`@parallel-web/oauth` in `packages/parallel-oauth` is `private` — it is bundled into the OpenCode plugin and Pi extension at build time and is never published.) @@ -57,12 +62,12 @@ the reviewed bootstrap release manually from a clean, updated `main` checkout: ```bash pnpm install --frozen-lockfile -pnpm --filter @parallel-web/dsh-web-search check +pnpm --filter @parallel-web/dsh-responses-subagent check BOOTSTRAP_DIR="$(mktemp -d)" -pnpm --dir packages/dsh-web-search pack --pack-destination "$BOOTSTRAP_DIR" +pnpm --dir packages/dsh-responses-subagent pack --pack-destination "$BOOTSTRAP_DIR" BOOTSTRAP_TARBALL="$(find "$BOOTSTRAP_DIR" -name '*.tgz' -print -quit)" npm publish "$BOOTSTRAP_TARBALL" --access public --tag rc -npm view @parallel-web/dsh-web-search dist-tags --json +npm view @parallel-web/dsh-responses-subagent dist-tags --json ``` The npm owner should inspect the tarball listing before the publish and complete npm's 2FA prompt. diff --git a/README.md b/README.md index f95d625..31442b2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Monorepo for @parallel-web npm packages. ## Packages - [`@parallel-web/ai-sdk-tools`](./packages/ai-sdk-tools) - AI SDK tools for Parallel Web +- [`@parallel-web/dsh-responses-subagent`](./packages/dsh-responses-subagent) - Parallel Responses research subagent for DeepSeek Harness - [`@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 diff --git a/packages/dsh-responses-subagent/README.md b/packages/dsh-responses-subagent/README.md new file mode 100644 index 0000000..554bed1 --- /dev/null +++ b/packages/dsh-responses-subagent/README.md @@ -0,0 +1,124 @@ +# Parallel Responses for DeepSeek Harness + +This DeepSeek Harness plugin adds `parallel_research`, an opt-in +[Parallel Responses](https://docs.parallel.ai/responses-api/examples/research-subagent) +subagent that returns complete, cited web research. It works alongside +`web_search` without changing the existing Parallel Search plugin. + +Only the explicitly delegated question is forwarded as research input. Parent +history, files, workspace state, tools, and environment details are never +gathered automatically. + +## Availability + +This plugin is an unreleased preview. It is not published to npm, and there is +no release tarball to download yet. The setup below requires a locally built +preview tarball. If you want to build one yourself, see [Development](#development). + +## Set up a preview + +You will need: + +- Node.js 22.19 or later in the 22.x series, or Node.js 24 or newer; +- pnpm 10 or newer, available in your terminal; +- a [Parallel API key](https://platform.parallel.ai/); and +- a preview `.tgz` file for this plugin. + +### 1. Install the plugin + +Install into Harness's `web` profile. Replace `/absolute/path/to/plugin.tgz` +with the path to your preview tarball: + +```sh +npx --yes @deepseek-ai/dsh@0.1.1-rc.2 \ + plugin --profile web add /absolute/path/to/plugin.tgz +``` + +### 2. Start Harness + +Set your Parallel API key in the same terminal that starts Harness: + +```sh +export PARALLEL_API_KEY="your-key" +npx --yes @deepseek-ai/dsh@0.1.1-rc.2 web +``` + +Open [http://127.0.0.1:3080](http://127.0.0.1:3080), configure your parent +model and its credentials in **Settings > Models**, choose a workspace, and +start a new session. Restart Harness if it was already running when you +installed the plugin or set the key. + +### 3. Try a research question + +Ask Harness to use the plugin, for example: + +> Use parallel_research to compare Node.js 22 and 24 support schedules. Include +> links to the official sources. + +The agent calls `parallel_research` and receives a researched answer with +source URLs. It can still use `web_search` separately. + +To check that the plugin is installed in the profile: + +```sh +npx --yes @deepseek-ai/dsh@0.1.1-rc.2 --profile web --dump-config +``` + +Look for `subagent-parallel-responses` and `toolName: parallel_research`. +If the plugin reports that `PARALLEL_API_KEY` is required, set the key in the +terminal that starts Harness and restart it. + +## Research depth and parallelism + +Ask the parent agent to delegate a complete, standalone research question. It +receives guidance to preserve constraints, prioritize primary sources, and +cite the returned URLs. + +To choose the research depth, edit the `subagent-parallel-responses` entry in +`~/.dsh/profiles/web/cordis.patch.yml`: + +```yaml +- id: subagent-parallel-responses + config: + effort: low +``` + +Choose `low` for focused questions, `medium` (the default) for ordinary +research, or `high` for deeper synthesis. At low effort, the parent is +encouraged to dispatch independent questions together. Harness schedules +parallel tool calls itself, allowing 10 by default. To allow more, increase +**Settings > Plugins > Agent loop > Parallel tool calls**. + +Keep `PARALLEL_API_KEY` in the launch environment, not in this profile. + +## Remove + +```sh +npx --yes @deepseek-ai/dsh@0.1.1-rc.2 \ + plugin --profile web remove @parallel-web/dsh-responses-subagent +``` + +Restart Harness after removing the plugin. + +## Development + +These steps are for building a preview from source. Use a checkout containing +this package, available in [PR #42](https://github.com/parallel-web/parallel-npm-packages/pull/42). +From the repository root, enable Corepack to use the pinned pnpm version, +install dependencies, and run the package checks: + +```sh +corepack enable +pnpm install --frozen-lockfile +pnpm --filter @parallel-web/dsh-responses-subagent check +``` + +The checks include the build. Pack it into a local tarball: + +```sh +PREVIEW_PACK_DIR="$(mktemp -d)" +pnpm --dir packages/dsh-responses-subagent pack --pack-destination "$PREVIEW_PACK_DIR" +``` + +Use the tarball path printed by `pack` in the [preview setup](#1-install-the-plugin). +This does not publish the package. diff --git a/packages/dsh-responses-subagent/cordis.patch.yml b/packages/dsh-responses-subagent/cordis.patch.yml new file mode 100644 index 0000000..42205c7 --- /dev/null +++ b/packages/dsh-responses-subagent/cordis.patch.yml @@ -0,0 +1,11 @@ +- insert: + - id: subagent-parallel-responses + name: '@parallel-web/dsh-responses-subagent' + + - id: tool-subagent-parallel-responses + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: parallel-responses + toolName: parallel_research + enableRunInBackground: false + maxDepth: provider-managed diff --git a/packages/dsh-responses-subagent/package.json b/packages/dsh-responses-subagent/package.json new file mode 100644 index 0000000..e60b02f --- /dev/null +++ b/packages/dsh-responses-subagent/package.json @@ -0,0 +1,77 @@ +{ + "name": "@parallel-web/dsh-responses-subagent", + "description": "Parallel Responses research subagent for DeepSeek Harness", + "version": "0.1.0-rc.0", + "author": "Parallel Web", + "license": "MIT", + "type": "module", + "sideEffects": false, + "engines": { + "node": "^22.19.0 || >=24.0.0" + }, + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/index.d.ts", + "cordis.patch.yml", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/parallel-web/parallel-npm-packages.git", + "directory": "packages/dsh-responses-subagent" + }, + "keywords": [ + "deepseek-harness", + "dsh-plugin", + "parallel-responses", + "research-agent" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "scripts": { + "build": "tsdown", + "check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build", + "clean": "rm -rf lib", + "lint": "oxlint src tests tsdown.config.ts", + "prepare": "pnpm run build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6 || ^0.1.1-rc.2", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.2", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6 || ^0.1.1-rc.2", + "@deepseek-ai/dsh-tool-subagent": "^0.1.0-rc.6 || ^0.1.1-rc.2" + }, + "dependencies": { + "@deepseek-ai/schemastery": "3.18.1" + }, + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6", + "@types/node": "26.2.0", + "oxlint": "1.76.0", + "tsdown": "0.22.2", + "typescript": "6.0.3", + "vitest": "4.1.8" + } +} diff --git a/packages/dsh-responses-subagent/src/index.ts b/packages/dsh-responses-subagent/src/index.ts new file mode 100644 index 0000000..d2a73c8 --- /dev/null +++ b/packages/dsh-responses-subagent/src/index.ts @@ -0,0 +1,83 @@ +/** Parallel Responses research-subagent provider for DeepSeek Harness. */ + +import type { Context } from '@deepseek-ai/cordis'; +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'; +import z from '@deepseek-ai/schemastery'; +import type {} from '@deepseek-ai/dsh-subagent'; +import { + PARALLEL_RESPONSES_EFFORTS, + ParallelResponsesProvider, + type ParallelResponsesEffort, +} from './provider.ts'; + +export { PARALLEL_RESPONSES_PROVIDER_ID } from './provider.ts'; +export type { ParallelResponsesEffort } from './provider.ts'; + +export const name = 'subagent-parallel-responses'; +export const inject = ['subagents', 'systemPrompt']; + +const RESEARCH_TOOL_GUIDANCE = + 'parallel_research delegates to an autonomous web-research specialist and ' + + 'returns a synthesized, citation-backed answer. Pass a complete, ' + + 'self-contained research question with all requested constraints. Do not ' + + 'invent JSON schemas or machine-output requirements. '; + +function researchToolGuidance(effort: ParallelResponsesEffort): string { + const strategy = + effort === 'low' + ? 'Break multi-part research into focused, independent questions and ' + + 'start those calls together when possible. Reconcile their answers ' + + 'and make focused follow-up calls only for missing evidence.' + : 'For a connected question, prefer one complete handoff and use ' + + 'focused follow-up calls only for material unresolved facts.'; + return `${RESEARCH_TOOL_GUIDANCE}${strategy} Preserve and cite the returned source URLs.`; +} + +/** Deployment configuration for the fixed Parallel Responses provider. */ +export interface Config { + /** Explicit Parallel API key; omission uses the Harness launch snapshot. */ + apiKey?: string; + /** Research tier; higher effort trades cost for deeper investigation. */ + effort?: ParallelResponsesEffort; +} + +export const Config: z = z.object({ + apiKey: z.string().role('secret'), + effort: z.union(PARALLEL_RESPONSES_EFFORTS), +}); + +/** + * Register the fixed `parallel-responses` provider. + * @param ctx - context carrying the shared subagent service. + * @param config - optional explicit Parallel credential. + */ +export function apply(ctx: Context, config: Config): void { + const apiKey = + config.apiKey ?? + launchEnvironmentOf(ctx).get('PARALLEL_API_KEY')?.value ?? + ''; + if (apiKey.trim().length === 0) { + throw new Error( + 'dsh-responses-subagent: apiKey or PARALLEL_API_KEY is required' + ); + } + ctx.subagents.registerProvider( + new ParallelResponsesProvider({ + apiKey, + ...(config.effort === undefined ? {} : { effort: config.effort }), + onError: (error) => { + ctx.logger.warn( + `subagent-parallel-responses: remote run failed: ${error.message}` + ); + }, + }) + ); + ctx.systemPrompt.section({ + name: 'tool:parallel_research', + order: 110.5, + text: (context) => + ctx.get('tools')?.get('parallel_research', context.scope) === undefined + ? '' + : researchToolGuidance(config.effort ?? 'medium'), + }); +} diff --git a/packages/dsh-responses-subagent/src/provider.ts b/packages/dsh-responses-subagent/src/provider.ts new file mode 100644 index 0000000..9043a0f --- /dev/null +++ b/packages/dsh-responses-subagent/src/provider.ts @@ -0,0 +1,263 @@ +/** + * One-shot Parallel Responses transport for the DeepSeek Harness subagent seam. + * + * @module @parallel-web/dsh-responses-subagent/provider + */ + +import { randomUUID } from 'node:crypto'; +import { SessionId } from '@deepseek-ai/dsh-session'; +import { + NO_START_CAPABILITIES, + settleRunResult, + subprocessRunHandle, + type ResolvedSubagentStartRequest, + type SubagentProvider, + type SubagentResult, + type SubagentRun, +} from '@deepseek-ai/dsh-subagent'; + +export const PARALLEL_RESPONSES_PROVIDER_ID = 'parallel-responses'; +export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses'; +export const PARALLEL_RESPONSES_TIMEOUT_MS = 10 * 60_000; +export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000; +export const PARALLEL_RESPONSES_EFFORTS = ['low', 'medium', 'high'] as const; +export const PARALLEL_RESEARCH_INSTRUCTIONS = + 'You are an autonomous live-web research specialist. Answer the actual ' + + 'research question with current evidence, prioritizing official ' + + 'documentation, original announcements, direct repository evidence, and ' + + 'other primary sources. Cover every requested entity and constraint. Verify ' + + 'dates, versions, prices, and units, distinguish confirmed facts from ' + + 'uncertainty, and cite direct supporting source URLs. Return a complete ' + + 'synthesized answer for the parent agent. Do not reject a valid research ' + + 'question merely because its wording mentions JSON or an output schema.'; + +export type ParallelResponsesEffort = + (typeof PARALLEL_RESPONSES_EFFORTS)[number]; + +type Fetch = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +function diagnosticError(error: unknown): Error { + if ( + error instanceof Error && + /^dsh-responses-subagent: Parallel Responses (?:returned HTTP \d{3}|reported response\.failed|request timed out)$/u.test( + error.message + ) + ) { + return new Error(error.message); + } + return new Error( + 'dsh-responses-subagent: Parallel Responses transport failed' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Preserve the exact text sequence of a non-empty, text-only delegated task. + * @param request - resolved Harness request carrying the standalone prompt. + * @returns the exact concatenated text supplied to Parallel Responses. + */ +export function researchPrompt(request: ResolvedSubagentStartRequest): string { + if ( + request.prompt.length === 0 || + !request.prompt.every((block) => block.type === 'text') + ) { + throw new Error( + 'dsh-responses-subagent: the research prompt must contain only text blocks' + ); + } + const prompt = request.prompt.map((block) => block.text).join(''); + if (prompt.trim().length === 0) { + throw new Error( + 'dsh-responses-subagent: the research prompt must not be empty' + ); + } + if ( + prompt.length + PARALLEL_RESEARCH_INSTRUCTIONS.length + 1 > + PARALLEL_RESPONSES_MAX_INPUT_CHARS + ) { + throw new Error( + `dsh-responses-subagent: the research prompt and instructions exceed ${PARALLEL_RESPONSES_MAX_INPUT_CHARS.toLocaleString('en-US')} characters` + ); + } + return prompt; +} + +function completedResult(payload: unknown): SubagentResult { + if (!isRecord(payload) || payload.status !== 'completed') { + if (isRecord(payload) && payload.status === 'failed') { + throw new Error( + 'dsh-responses-subagent: Parallel Responses reported response.failed' + ); + } + throw new TypeError( + 'dsh-responses-subagent: Parallel Responses response was not completed' + ); + } + if (!Array.isArray(payload.output)) { + throw new TypeError( + 'dsh-responses-subagent: completed response has no output array' + ); + } + + const answerParts: string[] = []; + const citations = new Map(); + 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; + } + answerParts.push(content.text); + if (!Array.isArray(content.annotations)) continue; + for (const annotation of content.annotations) { + if ( + !isRecord(annotation) || + annotation.type !== 'url_citation' || + typeof annotation.url !== 'string' || + annotation.url.length === 0 + ) { + continue; + } + if (!citations.get(annotation.url)?.trim()) { + citations.set( + annotation.url, + typeof annotation.title === 'string' ? annotation.title : undefined + ); + } + } + } + } + + const answer = answerParts.join(''); + if (answer.trim().length === 0) { + throw new TypeError( + 'dsh-responses-subagent: completed response contains no answer text' + ); + } + const sourceLines = [...citations].map(([url, title]) => { + const compactTitle = title?.replace(/\s+/gu, ' ').trim(); + return compactTitle ? `- ${compactTitle} — ${url}` : `- ${url}`; + }); + const text = + sourceLines.length === 0 + ? answer + : `${answer}\n\nSources:\n${sourceLines.join('\n')}`; + return { output: [{ type: 'text', text }], stopReason: 'completed' }; +} + +/** Construction inputs kept private from Cordis configuration. */ +export interface ParallelResponsesProviderOptions { + readonly apiKey: string; + readonly effort?: ParallelResponsesEffort; + readonly fetch?: Fetch; + /** Safe diagnostic sink for failures flattened into an error result. */ + readonly onError?: (error: Error) => void; +} + +/** Fixed, one-shot remote research provider. */ +export class ParallelResponsesProvider implements SubagentProvider { + readonly name = PARALLEL_RESPONSES_PROVIDER_ID; + readonly capabilities = NO_START_CAPABILITIES; + readonly inheritsParentContext = false; + + private readonly apiKey: string; + private readonly effort: ParallelResponsesEffort; + private readonly fetch: Fetch; + private readonly onError: ((error: Error) => void) | undefined; + + constructor(options: ParallelResponsesProviderOptions) { + this.apiKey = options.apiKey; + this.effort = options.effort ?? 'medium'; + this.fetch = options.fetch ?? globalThis.fetch; + this.onError = options.onError; + } + + async start(request: ResolvedSubagentStartRequest): Promise { + const prompt = researchPrompt(request); + if (request.signal.aborted) { + throw new Error('dsh-responses-subagent: request was aborted'); + } + + const controller = new AbortController(); + let cancelled = false; + const requestCancel = (): void => { + cancelled = true; + controller.abort(); + }; + request.signal.addEventListener('abort', requestCancel, { once: true }); + + let timedOut = false; + const timeoutError = new Error( + 'dsh-responses-subagent: Parallel Responses request timed out' + ); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(timeoutError); + }, PARALLEL_RESPONSES_TIMEOUT_MS); + + const result = settleRunResult({ + attempt: async () => { + try { + const response = await this.fetch(PARALLEL_RESPONSES_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'parallel', + input: prompt, + instructions: PARALLEL_RESEARCH_INSTRUCTIONS, + reasoning: { effort: this.effort }, + stream: false, + }), + redirect: 'error', + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `dsh-responses-subagent: Parallel Responses returned HTTP ${response.status}` + ); + } + const terminal = completedResult(await response.json()); + if (timedOut) throw timeoutError; + return terminal; + } finally { + clearTimeout(timer); + } + }, + collectOutput: () => [], + cancelled: () => cancelled, + signal: request.signal, + onAbort: requestCancel, + onError: (error) => { + this.onError?.(diagnosticError(timedOut ? timeoutError : error)); + }, + }); + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort: requestCancel, + requestCancel, + teardown: () => result.then(() => undefined), + }); + } +} diff --git a/packages/dsh-responses-subagent/tests/provider.spec.ts b/packages/dsh-responses-subagent/tests/provider.spec.ts new file mode 100644 index 0000000..e57b205 --- /dev/null +++ b/packages/dsh-responses-subagent/tests/provider.spec.ts @@ -0,0 +1,430 @@ +import { Context } from '@deepseek-ai/cordis'; +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'; +import SubagentRuntime, { + type ResolvedSubagentStartRequest, +} from '@deepseek-ai/dsh-subagent'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as plugin from '../src/index.ts'; +import { + PARALLEL_RESPONSES_MAX_INPUT_CHARS, + PARALLEL_RESPONSES_TIMEOUT_MS, + PARALLEL_RESPONSES_URL, + PARALLEL_RESEARCH_INSTRUCTIONS, + ParallelResponsesProvider, + researchPrompt, +} from '../src/provider.ts'; + +type ContentBlock = ResolvedSubagentStartRequest['prompt'][number]; + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'research this exactly' }], + signal = new AbortController().signal +): ResolvedSubagentStartRequest { + return { + prompt, + signal, + parent: {} as ResolvedSubagentStartRequest['parent'], + descriptor: {} as ResolvedSubagentStartRequest['descriptor'], + }; +} + +function completed( + text = 'The researched answer.', + annotations: unknown[] = [] +): Response { + return Response.json({ + status: 'completed', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text, annotations }], + }, + ], + }); +} + +function abortableFetch() { + return vi.fn( + async (_input: string | URL | Request, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new Error('transport aborted')), + { once: true } + ); + }) + ); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Parallel Responses request and output', () => { + it('sends one fixed request and tolerates imperfect, duplicate citations', async () => { + const response = completed('Evidence-backed answer.', [ + { + type: 'url_citation', + title: 'Source A', + url: 'https://a.test/report', + }, + { + type: 'url_citation', + title: 'Duplicate title', + url: 'https://a.test/report', + }, + { type: 'url_citation', url: 'https://b.test/data' }, + { type: 'url_citation', url: '' }, + { type: 'url_citation', url: 42 }, + { + type: 'url_citation', + title: 42, + url: 'https://c.test/report', + }, + { + type: 'url_citation', + title: 'Recovered title', + url: 'https://c.test/report', + }, + ]); + const fetch = vi.fn().mockResolvedValue(response); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + }); + const run = await provider.start( + request([{ type: 'text', text: ' unchanged prompt ' }]) + ); + + await expect(run.result).resolves.toEqual({ + output: [ + { + type: 'text', + text: + 'Evidence-backed answer.\n\nSources:\n' + + '- Source A — https://a.test/report\n' + + '- https://b.test/data\n' + + '- Recovered title — https://c.test/report', + }, + ], + stopReason: 'completed', + }); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe(PARALLEL_RESPONSES_URL); + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }); + expect(JSON.parse(String(init?.body))).toEqual({ + model: 'parallel', + input: ' unchanged prompt ', + instructions: PARALLEL_RESEARCH_INSTRUCTIONS, + reasoning: { effort: 'medium' }, + stream: false, + }); + const headers = new Headers(init?.headers); + expect(headers.get('authorization')).toBe('Bearer parallel_test_provider'); + expect(headers.get('content-type')).toBe('application/json'); + expect(run.localAgent).toBeUndefined(); + const firstDispose = run.dispose(); + expect(run.dispose()).toBe(firstDispose); + await firstDispose; + }); + + it.each(['low', 'medium', 'high'] as const)( + 'sends the configured %s research tier', + async (effort) => { + const fetch = vi.fn().mockResolvedValue(completed()); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + effort, + fetch, + }); + const run = await provider.start(request()); + + await expect(run.result).resolves.toMatchObject({ + stopReason: 'completed', + }); + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toMatchObject({ + instructions: PARALLEL_RESEARCH_INSTRUCTIONS, + reasoning: { effort }, + }); + await run.dispose(); + } + ); + + it('preserves text block order and rejects blank or non-text prompts', () => { + expect( + researchPrompt( + request([ + { type: 'text', text: 'one' }, + { type: 'text', text: ' two' }, + ]) + ) + ).toBe('one two'); + expect(() => researchPrompt(request([]))).toThrow('only text blocks'); + expect(() => + researchPrompt(request([{ type: 'text', text: ' \n ' }])) + ).toThrow('must not be empty'); + expect(() => + researchPrompt( + request([{ type: 'reasoning', text: 'private' } as ContentBlock]) + ) + ).toThrow('only text blocks'); + }); + + it('rejects prompts that exceed the API limit with research instructions', () => { + const available = + PARALLEL_RESPONSES_MAX_INPUT_CHARS - + PARALLEL_RESEARCH_INSTRUCTIONS.length - + 1; + + expect( + researchPrompt(request([{ type: 'text', text: 'x'.repeat(available) }])) + ).toHaveLength(available); + expect(() => + researchPrompt( + request([{ type: 'text', text: 'x'.repeat(available + 1) }]) + ) + ).toThrow('exceed 20,000 characters'); + }); + + it.each([ + new Response('denied', { status: 403 }), + Response.json({ status: 'failed' }), + new Response('not-json'), + Response.json({ status: 'completed', output: [] }), + ])( + 'settles HTTP, API, malformed JSON, and empty-answer failures', + async (response) => { + const fetch = vi.fn().mockResolvedValue(response); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + }); + const run = await provider.start(request()); + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }); + expect(fetch).toHaveBeenCalledOnce(); + await run.dispose(); + } + ); + + it('reports safe diagnostics without leaking transport details', async () => { + const onError = vi.fn(); + const fetch = vi + .fn() + .mockRejectedValue( + new Error( + 'dsh-responses-subagent: Bearer parallel_secret_value failed with private body' + ) + ); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_secret_value', + fetch, + onError, + }); + const run = await provider.start(request()); + + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + message: 'dsh-responses-subagent: Parallel Responses transport failed', + }); + expect(String(onError.mock.calls[0]?.[0])).not.toContain( + 'parallel_secret_value' + ); + await run.dispose(); + }); + + it('keeps diagnostic sink failures inside the non-rejecting run seam', async () => { + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch: vi.fn().mockResolvedValue(new Response('', { status: 429 })), + onError: () => { + throw new Error('diagnostic sink failed'); + }, + }); + const run = await provider.start(request()); + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }); + await run.dispose(); + }); +}); + +describe('Parallel Responses lifecycle', () => { + it('rejects cancellation before publication without making a request', async () => { + const fetch = vi.fn(); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + }); + const controller = new AbortController(); + controller.abort(); + await expect( + provider.start(request(undefined, controller.signal)) + ).rejects.toThrow('aborted'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts an active request, settles, and disposes idempotently', async () => { + const fetch = abortableFetch(); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + }); + const controller = new AbortController(); + const run = await provider.start(request(undefined, controller.signal)); + controller.abort(); + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }); + const disposal = run.dispose(); + expect(run.dispose()).toBe(disposal); + await disposal; + }); + + it('does not throttle simultaneous research calls', async () => { + const fetch = abortableFetch(); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + }); + const runs = await Promise.all( + Array.from({ length: 20 }, async () => await provider.start(request())) + ); + + expect(fetch).toHaveBeenCalledTimes(20); + await Promise.all(runs.map(async (run) => await run.dispose())); + }); + + it('times out once with no retry', async () => { + vi.useFakeTimers(); + const fetch = abortableFetch(); + const onError = vi.fn(); + const provider = new ParallelResponsesProvider({ + apiKey: 'parallel_test_provider', + fetch, + onError, + }); + const run = await provider.start(request()); + await vi.advanceTimersByTimeAsync(PARALLEL_RESPONSES_TIMEOUT_MS); + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }); + expect(fetch).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'dsh-responses-subagent: Parallel Responses request timed out', + }) + ); + await run.dispose(); + }); +}); + +describe('Parallel Responses plugin registration', () => { + it('marks the optional API key as secret configuration', () => { + const schema = plugin.Config as typeof plugin.Config & { + dict: Record; + }; + expect(schema.dict.apiKey?.meta.role).toBe('secret'); + }); + + it('uses the launch environment and unregisters with its Cordis fiber', async () => { + const ctx = new Context(); + const section = vi.fn(); + const get = vi.fn((): unknown => ({})); + ctx.provide('systemPrompt', { + section, + } as unknown as Context['systemPrompt']); + ctx.provide('tools', { get } as unknown as Context['tools']); + ctx.provide( + 'launchEnvironment', + createLaunchEnvironmentSnapshot([ + { + source: 'process', + values: { PARALLEL_API_KEY: 'parallel_test_environment' }, + }, + ]) + ); + await ctx.plugin(SubagentRuntime); + const fiber = await ctx.plugin(plugin, {}); + expect( + ctx.subagents.getProvider(plugin.PARALLEL_RESPONSES_PROVIDER_ID) + ).toMatchObject({ + name: 'parallel-responses', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }); + expect(section).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tool:parallel_research', + text: expect.any(Function), + }) + ); + const registration = section.mock.calls[0]?.[0] as { + text: (context: { scope?: unknown }) => string; + }; + expect(registration.text({})).toContain('complete, self-contained'); + get.mockReturnValue(undefined); + expect(registration.text({})).toBe(''); + await fiber.dispose(); + expect( + ctx.subagents.getProvider(plugin.PARALLEL_RESPONSES_PROVIDER_ID) + ).toBeUndefined(); + }); + + it('teaches low-effort parents to fan out independent research', async () => { + const ctx = new Context(); + const section = vi.fn(); + ctx.provide('systemPrompt', { + section, + } as unknown as Context['systemPrompt']); + ctx.provide('tools', { + get: vi.fn(() => ({})), + } as unknown as Context['tools']); + ctx.provide('launchEnvironment', createLaunchEnvironmentSnapshot([])); + await ctx.plugin(SubagentRuntime); + const fiber = await ctx.plugin(plugin, { + apiKey: 'parallel_test_explicit', + effort: 'low', + }); + + const registration = section.mock.calls[0]?.[0] as { + text: (context: { scope?: unknown }) => string; + }; + expect(registration.text({})).toContain('focused, independent questions'); + await fiber.dispose(); + }); + + it('fails at load without an explicit or launch-time key', async () => { + const ctx = new Context(); + ctx.provide('systemPrompt', { + section: vi.fn(), + } as unknown as Context['systemPrompt']); + ctx.provide('launchEnvironment', createLaunchEnvironmentSnapshot([])); + await ctx.plugin(SubagentRuntime); + await expect(ctx.plugin(plugin, {})).rejects.toThrow( + 'apiKey or PARALLEL_API_KEY is required' + ); + }); + + it('has a narrow namespace export surface', () => { + expect('default' in plugin).toBe(false); + expect('ParallelResponsesProvider' in plugin).toBe(false); + expect(plugin.PARALLEL_RESPONSES_PROVIDER_ID).toBe('parallel-responses'); + }); +}); diff --git a/packages/dsh-responses-subagent/tsconfig.json b/packages/dsh-responses-subagent/tsconfig.json new file mode 100644 index 0000000..3a6d651 --- /dev/null +++ b/packages/dsh-responses-subagent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node", "vitest/globals"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "tsdown.config.ts"] +} diff --git a/packages/dsh-responses-subagent/tsdown.config.ts b/packages/dsh-responses-subagent/tsdown.config.ts new file mode 100644 index 0000000..9433656 --- /dev/null +++ b/packages/dsh-responses-subagent/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + dts: true, + sourcemap: false, + clean: true, + fixedExtension: false, + deps: { + neverBundle: [ + '@deepseek-ai/cordis', + '@deepseek-ai/dsh-launch-environment', + '@deepseek-ai/dsh-session', + '@deepseek-ai/dsh-subagent', + '@deepseek-ai/schemastery', + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8838fa..d58c59c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,43 @@ importers: specifier: ^6.0.0 version: 6.0.192(zod@4.3.6) + packages/dsh-responses-subagent: + dependencies: + '@deepseek-ai/schemastery': + specifier: 3.18.1 + version: 3.18.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: 4.0.1 + version: 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/dsh-launch-environment': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-session': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6) + '@deepseek-ai/dsh-subagent': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(4aacb0222c4facc173b0ce0d58a42120) + '@deepseek-ai/dsh-tool-subagent': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(927fe785b6705493f00b6337d6e44987) + '@types/node': + specifier: 26.2.0 + version: 26.2.0 + oxlint: + specifier: 1.76.0 + version: 1.76.0 + tsdown: + specifier: 0.22.2 + version: 0.22.2(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + packages/dsh-web-search: dependencies: '@deepseek-ai/schemastery':