diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 37b811ee6..f470437bb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,14 +1,20 @@ # Changelog ## [Unreleased] - ### Added +- Added `omp q` CLI subcommand for testing web search providers with query, provider, recency, and limit options +- Added web search provider information API with authentication requirements and provider metadata +- Added support for `hour` recency filter option in Perplexity web search - Support for image file mentions—images are now automatically detected, resized, and attached when referenced with @filepath syntax - Image dimension information displayed in file mention UI to show image properties alongside text files ### Changed +- Refactored web search provider definitions into centralized provider-info module for better maintainability +- Updated web search result rendering to support long-form answers with text wrapping in CLI mode +- Removed related questions section from web search result rendering +- Updated Perplexity API types to support extended message content formats including images, files, and PDFs - Updated Perplexity search to use 'pro' search type for improved search quality and relevance - File mention messages now support both text content and image attachments, with optional line count for text files - Updated file mention processing to respect image auto-resize settings diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index cc909b53e..58e201027 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -196,6 +196,7 @@ ${chalk.bold("Subcommands:")} jupyter Manage the shared Jupyter gateway shell Interactive shell console (brush-core test) grep Test grep tool + q Test web search providers ${chalk.bold("Options:")} --model Model to use (fuzzy match: "opus", "gpt-5.2", or "p-openai/gpt-5.2") diff --git a/packages/coding-agent/src/cli/web-search-cli.ts b/packages/coding-agent/src/cli/web-search-cli.ts new file mode 100644 index 000000000..7df07d265 --- /dev/null +++ b/packages/coding-agent/src/cli/web-search-cli.ts @@ -0,0 +1,148 @@ +/** + * Web search CLI command handlers. + * + * Handles `omp q`/`omp web-search` subcommands for testing web search providers. + */ +import chalk from "chalk"; +import { APP_NAME } from "../config"; +import { initTheme, theme } from "../modes/theme/theme"; +import { renderWebSearchResult } from "../web/search/render"; +import { runWebSearchQuery, type WebSearchParams } from "../web/search/index"; +import type { WebSearchProvider } from "../web/search/types"; + +export interface WebSearchCommandArgs { + query: string; + provider?: WebSearchProvider | "auto"; + recency?: "day" | "week" | "month" | "year"; + limit?: number; + expanded: boolean; +} + +const PROVIDERS: Array = [ + "auto", + "anthropic", + "perplexity", + "exa", + "jina", + "gemini", + "codex", +]; + +const RECENCY_OPTIONS: WebSearchCommandArgs["recency"][] = ["day", "week", "month", "year"]; + +/** + * Parse web search subcommand arguments. + * Returns undefined if not a web search command. + */ +export function parseWebSearchArgs(args: string[]): WebSearchCommandArgs | undefined { + if (args.length === 0 || (args[0] !== "q" && args[0] !== "web-search")) { + return undefined; + } + + const result: WebSearchCommandArgs = { + query: "", + expanded: true, + }; + + const positional: string[] = []; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg === "--provider") { + result.provider = args[++i] as WebSearchCommandArgs["provider"]; + } else if (arg === "--recency") { + result.recency = args[++i] as WebSearchCommandArgs["recency"]; + } else if (arg === "--limit" || arg === "-l") { + result.limit = Number.parseInt(args[++i], 10); + } else if (arg === "--compact") { + result.expanded = false; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } + } + + if (positional.length > 0) { + result.query = positional.join(" "); + } + + return result; +} + +export async function runWebSearchCommand(cmd: WebSearchCommandArgs): Promise { + if (!cmd.query) { + writeStderr(chalk.red("Error: Query is required")); + process.exit(1); + } + + if (cmd.provider && !PROVIDERS.includes(cmd.provider)) { + writeStderr(chalk.red(`Error: Unknown provider "${cmd.provider}"`)); + writeStderr(chalk.dim(`Valid providers: ${PROVIDERS.join(", ")}`)); + process.exit(1); + } + + if (cmd.recency && !RECENCY_OPTIONS.includes(cmd.recency)) { + writeStderr(chalk.red(`Error: Invalid recency "${cmd.recency}"`)); + writeStderr(chalk.dim(`Valid recency values: ${RECENCY_OPTIONS.join(", ")}`)); + process.exit(1); + } + + if (cmd.limit !== undefined && Number.isNaN(cmd.limit)) { + writeStderr(chalk.red("Error: --limit must be a number")); + process.exit(1); + } + + await initTheme(); + + const params: WebSearchParams = { + query: cmd.query, + provider: cmd.provider, + recency: cmd.recency, + limit: cmd.limit, + }; + + const result = await runWebSearchQuery(params); + const component = renderWebSearchResult(result, { expanded: cmd.expanded, isPartial: false }, theme, { + query: cmd.query, + provider: cmd.provider, + allowLongAnswer: true, + maxAnswerLines: cmd.expanded ? undefined : 6, + }); + + const width = Math.max(60, process.stdout.columns ?? 100); + writeStdout(component.render(width).join("\n")); + + if (result.details?.error) { + process.exitCode = 1; + } +} + +export function printWebSearchHelp(): void { + writeStdout(`${chalk.bold(`${APP_NAME} q`)} - Test web search providers + +${chalk.bold("Usage:")} + ${APP_NAME} q [options] + ${APP_NAME} web-search [options] + +${chalk.bold("Arguments:")} + query Search query text + +${chalk.bold("Options:")} + --provider Provider: ${PROVIDERS.join(", ")} + --recency Recency filter (Perplexity only): ${RECENCY_OPTIONS.join(", ")} + -l, --limit Max results to return + --compact Render condensed output + -h, --help Show this help + +${chalk.bold("Examples:")} + ${APP_NAME} q --provider=exa "what's the color of the sky" + ${APP_NAME} q --provider=perplexity --recency=week "latest TypeScript 5.7 changes" +`); +} + +function writeStdout(message: string): void { + process.stdout.write(`${message}\n`); +} + +function writeStderr(message: string): void { + process.stderr.write(`${message}\n`); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 308e6ec61..89c51f5dd 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -563,6 +563,17 @@ export async function main(args: string[]) { return; } + // Handle web search subcommand (for testing web search providers) + const webSearchCmd = parseWebSearchArgs(args); + if (webSearchCmd) { + if (args.includes("--help") || args.includes("-h")) { + printWebSearchHelp(); + return; + } + await runWebSearchCommand(webSearchCmd); + return; + } + // Handle shell subcommand (for testing brush-core shell) const shellCmd = parseShellArgs(args); if (shellCmd) { diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 6f01c7e6b..4783a48bb 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -24,13 +24,12 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" }; import type { ToolSession } from "../../tools"; import { formatAge } from "../../tools/render-utils"; -import { findAnthropicAuth } from "./auth"; -import { searchAnthropic } from "./providers/anthropic"; -import { searchCodex, hasCodexWebSearch } from "./providers/codex"; -import { searchExa } from "./providers/exa"; -import { searchGemini, hasGeminiWebSearch } from "./providers/gemini"; -import { findApiKey as findJinaKey, searchJina } from "./providers/jina"; -import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity"; +import { + formatWebSearchProviderLabel, + getWebSearchProviderInfo, + WEB_SEARCH_PROVIDER_ORDER, + WEB_SEARCH_PROVIDERS, +} from "./provider-info"; import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render"; import type { WebSearchProvider, WebSearchResponse } from "./types"; import { WebSearchProviderError } from "./types"; @@ -58,90 +57,6 @@ export type WebSearchParams = { limit?: number; }; -interface WebSearchProviderDefinition { - id: WebSearchProvider; - label: string; - isAvailable: () => Promise; - search: (params: { - query: string; - limit?: number; - recency?: "day" | "week" | "month" | "year"; - systemPrompt: string; - signal?: AbortSignal; - }) => Promise; -} - -const webSearchProviders: WebSearchProviderDefinition[] = [ - { - id: "exa", - label: "Exa", - isAvailable: async () => Boolean(findExaKey()), - search: async ({ query, limit }) => - searchExa({ - query, - num_results: limit, - }), - }, - { - id: "jina", - label: "Jina", - isAvailable: async () => Boolean(findJinaKey()), - search: async ({ query, limit }) => - searchJina({ - query, - num_results: limit, - }), - }, - { - id: "perplexity", - label: "Perplexity", - isAvailable: async () => Boolean(findPerplexityKey()), - search: async ({ query, limit, recency, systemPrompt }) => - searchPerplexity({ - query, - system_prompt: systemPrompt, - search_recency_filter: recency, - num_results: limit, - }), - }, - { - id: "anthropic", - label: "Anthropic", - isAvailable: async () => Boolean(await findAnthropicAuth()), - search: async ({ query, limit, systemPrompt }) => - searchAnthropic({ - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, - { - id: "gemini", - label: "Gemini", - isAvailable: hasGeminiWebSearch, - search: async ({ query, limit, systemPrompt }) => - searchGemini({ - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, - { - id: "codex", - label: "Codex", - isAvailable: hasCodexWebSearch, - search: async ({ query, limit, systemPrompt, signal }) => - searchCodex({ - signal, - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, -]; - -const webSearchProviderMap = new Map(webSearchProviders.map(provider => [provider.id, provider])); - /** Preferred provider set via settings (default: auto) */ let preferredProvider: WebSearchProvider | "auto" = "auto"; @@ -154,21 +69,18 @@ export function setPreferredWebSearchProvider(provider: WebSearchProvider | "aut async function getAvailableProviders(): Promise { const providers: WebSearchProvider[] = []; - for (const provider of webSearchProviders) { - if (await provider.isAvailable()) { - providers.push(provider.id); + for (const provider of WEB_SEARCH_PROVIDER_ORDER) { + const definition = getWebSearchProviderInfo(provider); + if (await definition.isAvailable()) { + providers.push(provider); } } return providers; } -function formatProviderLabel(provider: WebSearchProvider): string { - return webSearchProviderMap.get(provider)?.label ?? provider; -} - function formatProviderList(providers: WebSearchProvider[]): string { - return providers.map(provider => formatProviderLabel(provider)).join(", "); + return providers.map(provider => formatWebSearchProviderLabel(provider)).join(", "); } function formatProviderError(error: unknown, provider: WebSearchProvider): string { @@ -177,12 +89,12 @@ function formatProviderError(error: unknown, provider: WebSearchProvider): strin return "Anthropic web search returned 404 (model or endpoint not found)."; } if (error.status === 401 || error.status === 403) { - return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`; + return `${formatWebSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`; } return error.message; } if (error instanceof Error) return error.message; - return `Unknown error from ${formatProviderLabel(provider)}`; + return `Unknown error from ${formatWebSearchProviderLabel(provider)}`; } async function resolveProviderChain( @@ -305,7 +217,7 @@ async function executeWebSearch( for (const provider of providers) { lastProvider = provider; - const providerDefinition = webSearchProviderMap.get(provider); + const providerDefinition = WEB_SEARCH_PROVIDERS[provider]; if (!providerDefinition) { lastError = new Error(`Unknown web search provider: ${provider}`); if (!allowFallback) break; @@ -313,7 +225,7 @@ async function executeWebSearch( } try { const response = await providerDefinition.search({ - query: params.query, + query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL limit: params.limit, recency: params.recency, systemPrompt: webSearchSystemPrompt, @@ -343,6 +255,15 @@ async function executeWebSearch( }; } +/** + * Execute a web search query for CLI/testing workflows. + */ +export async function runWebSearchQuery( + params: WebSearchParams, +): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { + return executeWebSearch("cli-web-search", params); +} + /** * Web search tool implementation. * @@ -693,4 +614,10 @@ export async function hasExaWebSearch(): Promise { return exaKey !== null; } +export { + formatWebSearchProviderLabel, + getWebSearchProviderInfo, + WEB_SEARCH_PROVIDER_ORDER, + WEB_SEARCH_PROVIDERS, +} from "./provider-info"; export type { WebSearchProvider, WebSearchResponse } from "./types"; diff --git a/packages/coding-agent/src/web/search/provider-info.ts b/packages/coding-agent/src/web/search/provider-info.ts new file mode 100644 index 000000000..3e81abbad --- /dev/null +++ b/packages/coding-agent/src/web/search/provider-info.ts @@ -0,0 +1,124 @@ +import { findAnthropicAuth } from "./auth"; +import { searchAnthropic } from "./providers/anthropic"; +import { searchCodex, hasCodexWebSearch } from "./providers/codex"; +import { searchExa } from "./providers/exa"; +import { searchGemini, hasGeminiWebSearch } from "./providers/gemini"; +import { findApiKey as findJinaKey, searchJina } from "./providers/jina"; +import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity"; +import type { WebSearchProvider, WebSearchResponse } from "./types"; +import { findApiKey as findExaKey } from "../../exa/mcp-client"; + +export interface WebSearchProviderInfo { + id: WebSearchProvider; + label: string; + /** Authentication requirement summary. */ + auth: string; + /** Query hint for UI or docs. */ + query: string; + isAvailable: () => Promise; + search: (params: { + query: string; + limit?: number; + recency?: "day" | "week" | "month" | "year"; + systemPrompt: string; + signal?: AbortSignal; + }) => Promise; +} + +export const WEB_SEARCH_PROVIDER_ORDER: WebSearchProvider[] = [ + "exa", + "jina", + "perplexity", + "anthropic", + "gemini", + "codex", +]; + +export const WEB_SEARCH_PROVIDERS: Record = { + exa: { + id: "exa", + label: "Exa", + auth: "EXA_API_KEY", + query: "Search query", + isAvailable: async () => Boolean(findExaKey()), + search: async ({ query, limit }) => + searchExa({ + query, + num_results: limit, + }), + }, + jina: { + id: "jina", + label: "Jina", + auth: "JINA_API_KEY", + query: "Search query", + isAvailable: async () => Boolean(findJinaKey()), + search: async ({ query, limit }) => + searchJina({ + query, + num_results: limit, + }), + }, + perplexity: { + id: "perplexity", + label: "Perplexity", + auth: "PERPLEXITY_API_KEY / PPLX_API_KEY", + query: "Search query", + isAvailable: async () => Boolean(findPerplexityKey()), + search: async ({ query, limit, recency, systemPrompt }) => + searchPerplexity({ + query, + system_prompt: systemPrompt, + search_recency_filter: recency, + num_results: limit, + }), + }, + anthropic: { + id: "anthropic", + label: "Anthropic", + auth: "ANTHROPIC_SEARCH_API_KEY / OAuth", + query: "Search query", + isAvailable: async () => Boolean(await findAnthropicAuth()), + search: async ({ query, limit, systemPrompt }) => + searchAnthropic({ + query, + system_prompt: systemPrompt, + num_results: limit, + }), + }, + gemini: { + id: "gemini", + label: "Gemini", + auth: "OAuth (agent.db: google-antigravity/google-gemini-cli)", + query: "Search query", + isAvailable: hasGeminiWebSearch, + search: async ({ query, limit, systemPrompt }) => + searchGemini({ + query, + system_prompt: systemPrompt, + num_results: limit, + }), + }, + codex: { + id: "codex", + label: "Codex", + auth: "OAuth (agent.db: openai-codex)", + query: "Search query", + isAvailable: hasCodexWebSearch, + search: async ({ query, limit, systemPrompt, signal }) => + searchCodex({ + signal, + query, + system_prompt: systemPrompt, + num_results: limit, + }), + }, +}; + +export function formatWebSearchProviderLabel(provider: WebSearchProvider): string { + return WEB_SEARCH_PROVIDERS[provider]?.label ?? "Unknown"; +} + +export function getWebSearchProviderInfo(provider: WebSearchProvider): WebSearchProviderInfo { + return WEB_SEARCH_PROVIDERS[provider]; +} diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 4331bebef..0316a4167 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -7,6 +7,7 @@ import { getEnvApiKey } from "@oh-my-pi/pi-ai"; import type { + PerplexityMessageOutput, PerplexityRequest, PerplexityResponse, WebSearchCitation, @@ -20,7 +21,7 @@ const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; export interface PerplexitySearchParams { query: string; system_prompt?: string; - search_recency_filter?: "day" | "week" | "month" | "year"; + search_recency_filter?: "hour" | "day" | "week" | "month" | "year"; num_results?: number; } @@ -53,7 +54,7 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi } /** Calculate age in seconds from ISO date string */ -function dateToAgeSeconds(dateStr: string | undefined): number | undefined { +function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined { if (!dateStr) return undefined; try { const date = new Date(dateStr); @@ -64,9 +65,16 @@ function dateToAgeSeconds(dateStr: string | undefined): number | undefined { } } +function messageContentToText(content: PerplexityMessageOutput["content"]): string { + if (!content) return ""; + if (typeof content === "string") return content; + return content.map(chunk => (chunk.type === "text" ? chunk.text : "")).join(""); +} + /** Parse API response into unified WebSearchResponse */ function parseResponse(response: PerplexityResponse): WebSearchResponse { - const answer = response.choices[0]?.message?.content ?? ""; + const messageContent = response.choices[0]?.message?.content ?? null; + const answer = messageContentToText(messageContent); // Build sources by matching citations to search_results const sources: WebSearchSource[] = []; @@ -82,7 +90,7 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse { title: searchResult?.title ?? url, url, snippet: searchResult?.snippet, - publishedDate: searchResult?.date, + publishedDate: searchResult?.date ?? undefined, ageSeconds: dateToAgeSeconds(searchResult?.date), }); citations.push({ @@ -96,7 +104,7 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse { title: searchResult.title ?? searchResult.url, url: searchResult.url, snippet: searchResult.snippet, - publishedDate: searchResult.date, + publishedDate: searchResult.date ?? undefined, ageSeconds: dateToAgeSeconds(searchResult.date), }); } @@ -107,12 +115,13 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse { answer: answer || undefined, sources, citations: citations.length > 0 ? citations : undefined, - relatedQuestions: response.related_questions, - usage: { - inputTokens: response.usage.prompt_tokens, - outputTokens: response.usage.completion_tokens, - totalTokens: response.usage.total_tokens, - }, + usage: response.usage + ? { + inputTokens: response.usage.prompt_tokens, + outputTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + } + : undefined, model: response.model, requestId: response.id, }; @@ -134,7 +143,6 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise< const request: PerplexityRequest = { model: "sonar-pro", messages, - return_related_questions: false, web_search_options: { search_type: "pro", search_context_size: "high", diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index ec0ccf323..83ed8c74d 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -4,7 +4,7 @@ * Tree-based rendering with collapsed/expanded states for web search results. */ import type { Component } from "@oh-my-pi/pi-tui"; -import { Text } from "@oh-my-pi/pi-tui"; +import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; import { @@ -20,6 +20,7 @@ import { truncateToWidth, } from "../../tools/render-utils"; import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui"; +import { formatWebSearchProviderLabel } from "./provider-info"; import type { WebSearchResponse } from "./types"; const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; @@ -70,7 +71,12 @@ export function renderWebSearchResult( result: { content: Array<{ type: string; text?: string }>; details?: WebSearchRenderDetails }, options: RenderResultOptions, theme: Theme, - args?: { query?: string; provider?: string }, + args?: { + query?: string; + provider?: string; + allowLongAnswer?: boolean; + maxAnswerLines?: number; + }, ): Component { const { expanded } = options; const details = result.details; @@ -90,10 +96,6 @@ export function renderWebSearchResult( const sourceCount = sources.length; const citations = Array.isArray(response.citations) ? response.citations : []; const citationCount = citations.length; - const related = Array.isArray(response.relatedQuestions) - ? response.relatedQuestions.filter(item => typeof item === "string") - : []; - const relatedCount = related.length; const searchQueries = Array.isArray(response.searchQueries) ? response.searchQueries.filter(item => typeof item === "string") : []; @@ -102,20 +104,21 @@ export function renderWebSearchResult( // Get answer text const answerText = typeof response.answer === "string" ? response.answer.trim() : ""; const contentText = answerText || rawText; - const totalAnswerLines = contentText ? contentText.split("\n").filter(l => l.trim()).length : 0; + const answerLines = contentText + ? contentText + .split("\n") + .filter(l => l.trim()) + .map(l => l.trim()) + : []; + const totalAnswerLines = answerLines.length; const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES; - const answerPreview = contentText ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) : []; + const answerPreview = contentText + ? args?.allowLongAnswer + ? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length) + : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) + : []; - const providerLabel = - provider === "anthropic" - ? "Anthropic" - : provider === "perplexity" - ? "Perplexity" - : provider === "exa" - ? "Exa" - : provider === "jina" - ? "Jina" - : "Unknown"; + const providerLabel = formatWebSearchProviderLabel(provider); const queryPreview = args?.query ? truncateToWidth(args.query, 80) : searchQueries[0] @@ -132,20 +135,6 @@ export function renderWebSearchResult( ); const remainingAnswer = totalAnswerLines - answerPreview.length; - const answerLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; - const answerTree = renderTreeList( - { - items: answerLines, - expanded: true, - maxCollapsed: answerLines.length, - itemType: "line", - renderItem: line => (line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line)), - }, - theme, - ); - if (remainingAnswer > 0) { - answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line"))); - } const sourceTree = renderTreeList( { @@ -185,28 +174,12 @@ export function renderWebSearchResult( theme, ); - const relatedLines = related.length > 0 ? related : ["No related questions"]; - const relatedTree = renderTreeList( - { - items: relatedLines, - expanded, - maxCollapsed: MAX_COLLAPSED_ITEMS, - itemType: "question", - renderItem: line => theme.fg("muted", line === "No related questions" ? line : `${theme.format.dash} ${line}`), - }, - theme, - ); - if (!expanded && relatedCount > MAX_COLLAPSED_ITEMS) { - relatedTree.push(theme.fg("muted", formatMoreItems(relatedCount - MAX_COLLAPSED_ITEMS, "question"))); - } - const metaLines: string[] = []; metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`); if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`); metaLines.push(`${theme.fg("muted", "Sources:")} ${theme.fg("text", String(sourceCount))}`); if (citationCount > 0) metaLines.push(`${theme.fg("muted", "Citations:")} ${theme.fg("text", String(citationCount))}`); - if (relatedCount > 0) metaLines.push(`${theme.fg("muted", "Related:")} ${theme.fg("text", String(relatedCount))}`); if (response.usage) { const usageParts: string[] = []; if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`); @@ -228,30 +201,66 @@ export function renderWebSearchResult( metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`); } - const sections = [ - ...(queryPreview - ? [ - { - lines: [`${theme.fg("muted", "Query:")} ${theme.fg("text", queryPreview)}`], - }, - ] - : []), - { label: theme.fg("toolTitle", "Answer"), lines: answerTree }, - { - label: theme.fg("toolTitle", "Sources"), - lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")], - }, - { label: theme.fg("toolTitle", "Related"), lines: relatedTree }, - { label: theme.fg("toolTitle", "Metadata"), lines: metaLines }, - ]; - return { render: (width: number) => renderOutputBlock( { header, state: sourceCount > 0 ? "success" : "warning", - sections, + sections: [ + ...(queryPreview + ? [ + { + lines: [`${theme.fg("muted", "Query:")} ${theme.fg("text", queryPreview)}`], + }, + ] + : []), + { + label: theme.fg("toolTitle", "Answer"), + lines: (() => { + const state = sourceCount > 0 ? "success" : "warning"; + const borderColor: "warning" | "dim" = state === "warning" ? "warning" : "dim"; + const border = (text: string) => theme.fg(borderColor, text); + const contentPrefix = border(`${theme.boxSharp.vertical} `); + const contentSuffix = border(theme.boxSharp.vertical); + const contentWidth = Math.max( + 0, + width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix), + ); + const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; + const answerTree = renderTreeList( + { + items: answerTreeLines, + expanded: true, + maxCollapsed: answerTreeLines.length, + itemType: "line", + renderItem: (line, context) => { + const coloredLine = + line === "No answer text returned" + ? theme.fg("muted", line) + : theme.fg("dim", line); + if (!args?.allowLongAnswer) { + return coloredLine; + } + const prefixWidth = visibleWidth(context.continuePrefix); + const wrapWidth = Math.max(10, contentWidth - prefixWidth); + return wrapTextWithAnsi(coloredLine, wrapWidth); + }, + }, + theme, + ); + if (remainingAnswer > 0) { + answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line"))); + } + return answerTree; + })(), + }, + { + label: theme.fg("toolTitle", "Sources"), + lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")], + }, + { label: theme.fg("toolTitle", "Metadata"), lines: metaLines }, + ], width, }, theme, diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index f39e93a35..07714b51d 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -45,8 +45,6 @@ export interface WebSearchResponse { sources: WebSearchSource[]; /** Text citations with context */ citations?: WebSearchCitation[]; - /** Follow-up questions (perplexity) */ - relatedQuestions?: string[]; /** Intermediate search queries (anthropic) */ searchQueries?: string[]; /** Token usage metrics */ @@ -148,52 +146,283 @@ export interface AnthropicApiResponse { } /** Perplexity API types */ -export interface PerplexityMessage { - role: "system" | "user" | "assistant"; - content: string; +export type PerplexityChatMessageRole = "system" | "user" | "assistant" | "tool"; + +export interface PerplexityUrl { + url: string; +} + +export interface PerplexityVideoUrl { + url: string; + frame_interval?: string | number; +} + +export interface PerplexityContentTextChunk { + type: "text"; + text: string; +} + +export interface PerplexityContentImageChunk { + type: "image_url"; + image_url: PerplexityUrl | string; +} + +export interface PerplexityContentFileChunk { + type: "file_url"; + file_url: PerplexityUrl | string; + file_name?: string | null; +} + +export interface PerplexityContentPdfChunk { + type: "pdf_url"; + pdf_url: PerplexityUrl | string; +} + +export interface PerplexityContentVideoChunk { + type: "video_url"; + video_url: PerplexityVideoUrl | string; +} + +export type PerplexityContentChunk = + | PerplexityContentTextChunk + | PerplexityContentImageChunk + | PerplexityContentFileChunk + | PerplexityContentPdfChunk + | PerplexityContentVideoChunk; + +export interface PerplexityWebSearchStepDetails { + search_results: PerplexitySearchResult[]; + search_keywords: string[]; +} + +export interface PerplexityFetchUrlContentStepDetails { + contents: PerplexitySearchResult[]; +} + +export interface PerplexityExecutePythonStepDetails { + code: string; + result: string; +} + +export interface PerplexityReasoningStepInput { + thought: string; + type?: string | null; + web_search?: PerplexityWebSearchStepDetails | null; + fetch_url_content?: PerplexityFetchUrlContentStepDetails | null; + execute_python?: PerplexityExecutePythonStepDetails | null; +} + +export interface PerplexityReasoningStepOutput { + thought: string; + type?: string | null; + web_search?: PerplexityWebSearchStepDetails | null; + fetch_url_content?: PerplexityFetchUrlContentStepDetails | null; + execute_python?: PerplexityExecutePythonStepDetails | null; +} + +export interface PerplexityToolCallFunction { + name?: string | null; + arguments?: string | null; +} + +export interface PerplexityToolCall { + id?: string | null; + type?: "function" | null; + function?: PerplexityToolCallFunction | null; +} + +export interface PerplexityMessageInput { + role: PerplexityChatMessageRole; + content: string | PerplexityContentChunk[] | null; + reasoning_steps?: PerplexityReasoningStepInput[] | null; + tool_calls?: PerplexityToolCall[] | null; + tool_call_id?: string | null; +} + +export interface PerplexityMessageOutput { + role: PerplexityChatMessageRole; + content: string | PerplexityContentChunk[] | null; + reasoning_steps?: PerplexityReasoningStepOutput[] | null; + tool_calls?: PerplexityToolCall[] | null; + tool_call_id?: string | null; +} + +export type PerplexityMessage = PerplexityMessageInput; + +export interface PerplexityResponseFormatText { + type: "text"; +} + +export interface PerplexityJSONSchema { + schema: Record; + name?: string | null; + description?: string | null; + strict?: boolean | null; +} + +export interface PerplexityResponseFormatJSONSchema { + type: "json_schema"; + json_schema: PerplexityJSONSchema; +} + +export interface PerplexityRegexSchema { + regex: string; + name?: string | null; + description?: string | null; + strict?: boolean | null; +} + +export interface PerplexityResponseFormatRegex { + type: "regex"; + regex: PerplexityRegexSchema; +} + +export type PerplexityResponseFormat = + | PerplexityResponseFormatText + | PerplexityResponseFormatJSONSchema + | PerplexityResponseFormatRegex; + +export interface PerplexityParameterSpec { + type: string; + properties: Record; + required?: string[] | null; + additional_properties?: boolean | null; +} + +export interface PerplexityFunctionSpec { + name: string; + description: string; + parameters: PerplexityParameterSpec; + strict?: boolean | null; +} + +export interface PerplexityToolSpec { + type: "function"; + function: PerplexityFunctionSpec; +} + +export interface PerplexityUserLocation { + latitude?: number | null; + longitude?: number | null; + country?: string | null; + city?: string | null; + region?: string | null; +} + +export interface PerplexityWebSearchOptions { + search_context_size?: "low" | "medium" | "high"; + search_type?: "fast" | "pro" | "auto" | null; + user_location?: PerplexityUserLocation | null; + image_results_enhanced_relevance?: boolean; } export interface PerplexityRequest { + max_tokens?: number | null; + n?: number | null; model: string; - messages: PerplexityMessage[]; - temperature?: number; - max_tokens?: number; - search_domain_filter?: string[]; - search_recency_filter?: "day" | "week" | "month" | "year"; - return_images?: boolean; - return_related_questions?: boolean; - web_search_options?: { - search_type?: "fast" | "pro"; - search_context_size?: "low" | "medium" | "high"; - }; + stream?: boolean | null; + stop?: string | string[] | null; + cum_logprobs?: boolean | null; + logprobs?: boolean | null; + top_logprobs?: number | null; + best_of?: number | null; + response_metadata?: Record | null; + response_format?: PerplexityResponseFormat | null; + diverse_first_token?: boolean | null; + _inputs?: number[] | null; + _prompt_token_length?: number | null; + messages: PerplexityMessageInput[]; + tools?: PerplexityToolSpec[] | null; + tool_choice?: "none" | "auto" | "required" | null; + parallel_tool_calls?: boolean | null; + web_search_options?: PerplexityWebSearchOptions; + search_mode?: "web" | "academic" | "sec" | null; + return_images?: boolean | null; + return_related_questions?: boolean | null; + num_search_results?: number; + num_images?: number; + enable_search_classifier?: boolean | null; + disable_search?: boolean | null; + search_domain_filter?: string[] | null; + search_language_filter?: string[] | null; + search_tenant?: string | null; + ranking_model?: string | null; + latitude?: number | null; + longitude?: number | null; + country?: string | null; + search_recency_filter?: "hour" | "day" | "week" | "month" | "year" | null; + search_after_date_filter?: string | null; + search_before_date_filter?: string | null; + last_updated_before_filter?: string | null; + last_updated_after_filter?: string | null; + image_format_filter?: string[] | null; + image_domain_filter?: string[] | null; + safe_search?: boolean | null; + file_workspace_id?: string | null; + updated_before_timestamp?: number | null; + updated_after_timestamp?: number | null; + search_internal_properties?: Record | null; + use_threads?: boolean | null; + thread_id?: string | null; + stream_mode?: "full" | "concise"; + _debug_pro_search?: boolean; + has_image_url?: boolean; + reasoning_effort?: "minimal" | "low" | "medium" | "high" | null; + language_preference?: string | null; + user_original_query?: string | null; + _force_new_agent?: boolean | null; } export interface PerplexitySearchResult { title: string; url: string; - date?: string; + date?: string | null; + last_updated?: string | null; snippet?: string; + source?: "web" | "attachment"; +} + +export interface PerplexityCost { + input_tokens_cost: number; + output_tokens_cost: number; + reasoning_tokens_cost?: number | null; + request_cost?: number | null; + citation_tokens_cost?: number | null; + search_queries_cost?: number | null; + total_cost: number; +} + +export interface PerplexityUsageInfo { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + search_context_size?: string | null; + citation_tokens?: number | null; + num_search_queries?: number | null; + reasoning_tokens?: number | null; + cost: PerplexityCost; +} + +export type PerplexityCompletionResponseType = "message" | "info" | "end_of_stream"; + +export type PerplexityCompletionResponseStatus = "PENDING" | "COMPLETED"; + +export interface PerplexityChoice { + index: number; + finish_reason?: "stop" | "length" | null; + message: PerplexityMessageOutput; + delta: PerplexityMessageOutput; } export interface PerplexityResponse { id: string; model: string; created: number; - usage: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - search_context_size?: string; - }; - citations?: string[]; - search_results?: PerplexitySearchResult[]; - related_questions?: string[]; - choices: Array<{ - index: number; - finish_reason: string; - message: { - role: string; - content: string; - }; - }>; + usage?: PerplexityUsageInfo | null; + object?: string; + choices: PerplexityChoice[]; + citations?: string[] | null; + search_results?: PerplexitySearchResult[] | null; + type?: PerplexityCompletionResponseType | null; + status?: PerplexityCompletionResponseStatus | null; }