/** * Deep research workflow. * Built-in workflow for comprehensive research across multiple sources. */ import { createReadOnlyTools, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { createWebTools } from "./web-tools.js"; export interface DeepResearchConfig { /** Number of distinct search angles/queries to explore. */ angles: number; /** Minimum distinct sources required for a claim to survive cross-checking. */ minSupport: number; } /** * Modest, documented bounds for `/deep-research` (issue #122). They keep * unusually large model output from overflowing / clipping the agent-result * channel while staying generous enough for ordinary research. */ export const DEEP_RESEARCH_BOUNDS = Object.freeze({ /** Max research-question length in characters. */ maxQuestionChars: 2000, /** Min/max number of search angles (fan-out width). */ minAngles: 1, maxAngles: 8, /** Cap on gathered sources fed into cross-checking (fan-in). */ maxSources: 32, /** Per-source claim cap. */ maxClaimsPerSource: 12, /** Char cap on a single source URL. */ maxUrlChars: 400, /** Char cap on a single extracted claim. */ maxClaimChars: 800, /** Char cap on the JSON payload handed to the verify/report agents. */ maxSourcesJsonChars: 60_000, /** Char cap on the final report text. */ maxReportChars: 8_000, } as const); /** * Tool pool for `/deep-research` subagents: read-only repository tools plus the * real `web_search` / `web_fetch` tools. Research agents cannot mutate the * workspace because `createReadOnlyTools` returns only `read`, `grep`, `find`, * `ls` (no `bash`, `edit`, `write`). The handler additionally sets the run-level * `readOnly` fence (see `deepResearchSafetyOptions`) so an `agentType` * allowlist / harness config can never re-grant a write tool. */ export function createDeepResearchTools(cwd: string): ToolDefinition[] { return [...createReadOnlyTools(cwd), ...createWebTools()] as unknown as ToolDefinition[]; } /** * The safety-relevant subset of `/deep-research`'s `runWorkflow` options — * extracted as a pure, testable seam. Spreading this into the handler's run * options guarantees the run is read-only and web-enabled without relying on * prompt text alone. */ export function deepResearchSafetyOptions(cwd: string): { tools: ToolDefinition[]; readOnly: true; } { return { tools: createDeepResearchTools(cwd), readOnly: true }; } /** * Generate a deep-research workflow that uses the real web_search/web_fetch tools. * * The script is static and reads its inputs from `args` (question/angles/minSupport), * so the question is never string-interpolated into source — no escaping hazards. * Inject the web tools at run time via the agent's `tools` option. * * Bounds (issue #122): question/angles/minSupport are validated up front and * fail with a clear `Error` message; gathered sources and the final report are * capped with explicit truncation notes so unusually large output never silently * overflows the agent-result channel. */ export function generateDeepResearchWorkflow(): string { const b = DEEP_RESEARCH_BOUNDS; return `export const meta = { name: 'deep_research', description: 'Deep research with real web search and cross-checked claims', phases: [ { title: 'Queries' }, { title: 'Gather' }, { title: 'Verify' }, { title: 'Report' }, ], } const RAW_QUESTION = (args && args.question) || '' const rawAngles = Number((args && args.angles) || 4) const rawMinSupport = Number((args && args.minSupport) || 2) // ── Input bounds: fail with a clear message instead of silently overflowing ── if (!RAW_QUESTION || !String(RAW_QUESTION).trim()) { throw new Error('deep-research: a non-empty research question is required (got an empty question).') } if (String(RAW_QUESTION).length > ${b.maxQuestionChars}) { throw new Error('deep-research: question is too long (' + String(RAW_QUESTION).length + ' chars; limit ${b.maxQuestionChars}). Please shorten the question.') } if (!Number.isFinite(rawAngles) || rawAngles < ${b.minAngles} || rawAngles > ${b.maxAngles}) { throw new Error('deep-research: angles must be an integer between ${b.minAngles} and ${b.maxAngles} (got ' + rawAngles + ').') } if (!Number.isFinite(rawMinSupport) || rawMinSupport < 1 || rawMinSupport > rawAngles) { throw new Error('deep-research: minSupport must be an integer between 1 and the angle count (' + rawAngles + ') (got ' + rawMinSupport + ').') } const question = String(RAW_QUESTION) const angles = Math.floor(rawAngles) const minSupport = Math.floor(rawMinSupport) phase('Queries') const plan = await agent( 'You are planning web research for this question:\\n' + question + '\\n\\nProduce ' + angles + ' diverse, specific search queries that together cover the question from different angles.', { label: 'plan queries', schema: { type: 'object', properties: { queries: { type: 'array', items: { type: 'string' } } }, required: ['queries'] } } ) const queries = (plan.queries || []).slice(0, angles) phase('Gather') const gathered = await parallel(queries.map((q, i) => () => agent( 'Research this query using the web_search and web_fetch tools.\\nQuery: ' + q + '\\n\\nSteps: (1) call web_search with the query; (2) web_fetch the 2 most relevant result URLs; ' + '(3) extract concrete, verifiable factual claims, each tagged with the exact source URL it came from. ' + 'Do NOT invent sources or claims — report only what the fetched pages actually say.', { label: 'research ' + (i + 1), schema: { type: 'object', properties: { sources: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' }, claims: { type: 'array', items: { type: 'string' } } }, required: ['url', 'claims'] } } }, required: ['sources'] } } ) )) // ── Fan-in bounds: cap and normalize gathered sources ── const MAX_SOURCES = ${b.maxSources} const MAX_CLAIMS_PER_SOURCE = ${b.maxClaimsPerSource} const MAX_URL_CHARS = ${b.maxUrlChars} const MAX_CLAIM_CHARS = ${b.maxClaimChars} let allSources = gathered.filter(Boolean).flatMap((g) => (g && g.sources) || []) const sourcesTruncated = allSources.length > MAX_SOURCES if (sourcesTruncated) allSources = allSources.slice(0, MAX_SOURCES) const boundedSources = allSources.map((s) => ({ url: String((s && s.url) || '').slice(0, MAX_URL_CHARS), claims: (Array.isArray(s && s.claims) ? s.claims : []).slice(0, MAX_CLAIMS_PER_SOURCE).map((c) => String(c).slice(0, MAX_CLAIM_CHARS)), })) phase('Verify') let sourcesJson = JSON.stringify(boundedSources) const MAX_SOURCES_JSON = ${b.maxSourcesJsonChars} let sourcesJsonTruncated = false if (sourcesJson.length > MAX_SOURCES_JSON) { sourcesJsonTruncated = true sourcesJson = sourcesJson.slice(0, MAX_SOURCES_JSON) + '\\n[truncated: too many large claims to fit; narrow the question]' } const verdict = await agent( 'Cross-check these research sources. Group claims that assert the same fact across different source URLs. ' + 'Keep a claim only if it is supported by at least ' + minSupport + ' distinct source URLs OR by one clearly authoritative source. ' + 'Discard claims found in a single weak source or that conflict with others.\\n\\nSOURCES JSON:\\n' + sourcesJson, { label: 'cross-check', schema: { type: 'object', properties: { supported: { type: 'array', items: { type: 'object', properties: { claim: { type: 'string' }, sources: { type: 'array', items: { type: 'string' } } }, required: ['claim', 'sources'] } }, discarded: { type: 'array', items: { type: 'string' } } }, required: ['supported'] } } ) phase('Report') const reportRaw = await agent( 'Write a concise, well-structured research report that answers the question using ONLY the supported claims below. ' + 'Cite source URLs inline next to each claim. If the evidence is thin, say so explicitly.\\n\\n' + 'QUESTION: ' + question + '\\n\\nSUPPORTED CLAIMS JSON:\\n' + JSON.stringify((verdict && verdict.supported) || []), { label: 'write report' } ) // ── Final-response bound: cap the report with a clear truncation note ── const MAX_REPORT = ${b.maxReportChars} let report = typeof reportRaw === 'string' ? reportRaw : (reportRaw && typeof reportRaw === 'object' && typeof reportRaw.report === 'string') ? reportRaw.report : JSON.stringify(reportRaw ?? '') if (report.length > MAX_REPORT) { report = report.slice(0, MAX_REPORT) + '\\n\\n[report truncated to keep within the result channel limit]' } // Surface fan-in truncation to the user so the bound is not silent (issue #122). if (sourcesTruncated || sourcesJsonTruncated) { report += '\\n\\n[note: gathered sources were capped to ' + MAX_SOURCES + ' to fit the research bounds]' } return { question, queries, supported: (verdict && verdict.supported) || [], report, bounds: { angles, minSupport, sourcesUsed: boundedSources.length, sourcesTruncated, sourcesJsonTruncated, reportTruncated: report.length >= MAX_REPORT, }, }`; } /** * Generate a codebase audit workflow. */ export function generateCodebaseAuditWorkflow(scope: string, checks: string[]): string { const escapedScope = scope.replace(/'/g, "\\'").slice(0, 60); const checkAgents = checks .map((check) => { const label = check .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .slice(0, 20); return ` () => agent('Audit ${check} across: ' + scope, { label: '${label}' }),`; }) .join("\n"); return `export const meta = { name: 'codebase_audit', description: 'Codebase audit: ${escapedScope}', phases: [ { title: 'Individual Checks' }, { title: 'Cross-Validation' }, { title: 'Report' }, ], }; phase('Individual Checks'); const scope = '${escapedScope}'; const findings = await parallel([ ${checkAgents} ]); phase('Cross-Validation'); const validated = await agent( 'Cross-validate these audit findings. Remove false positives and confirm real issues:\\n' + JSON.stringify(findings), { label: 'validator' } ); phase('Report'); const report = await agent( 'Generate a prioritized audit report with actionable recommendations:\\n' + validated, { label: 'report-writer' } ); return { findings, validated, report };`; }