diff --git a/AGENTS.md b/AGENTS.md index ff4dd8fe9..6826cd9af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Development Rules +## Default Context + +This repo contains multiple packages, but **`packages/coding-agent/`** is the primary focus. Unless otherwise specified, assume work refers to this package. + +**Terminology**: When the user says "agent" or asks "why is agent doing X", they mean the **coding-agent package implementation**, not you (the assistant). The coding-agent is a CLI tool that uses Claude—questions about its behavior refer to the code in `packages/coding-agent/`, not your current session. + ## Code Quality - No `any` types unless absolutely necessary diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f2c93e2a6..ff4258980 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,15 @@ ## [Unreleased] ### Added +- Added `webSearchProvider` setting to override auto-detection priority (Exa > Perplexity > Anthropic) +- Added `imageProvider` setting to override auto-detection priority (OpenRouter > Gemini) +- Added `git.enabled` setting to enable/disable the structured git tool +- Added `offset` and `limit` parameters to Output tool for paginated reading of large outputs +- Added provider fallback chain for web search that tries all configured providers before failing +- Added `WebSearchProviderError` class with HTTP status for actionable provider error messages +- Added bash interceptor rule to block git commands when structured git tool is enabled +- Added validation requiring `message` parameter for git commit operations (prevents interactive editor) +- Added output ID hints in multi-agent Task results pointing to Output tool for full logs - Added fuzzy matching support for `all: true` mode in edit tool, enabling replacement of similar text blocks with whitespace differences - Added `all` parameter to edit tool for replacing all occurrences instead of requiring unique matches - Added OpenRouter support for image generation when `OPENROUTER_API_KEY` is set @@ -10,10 +19,17 @@ - Added slash commands to the extensions inspector panel for visibility and management - Added support for file-based slash commands from `commands/` directories - Added `$ARGUMENTS` placeholder for slash command argument substitution, aligning with Claude and Codex conventions -- Added OpenRouter image generation support for `generate_image` when `OPENROUTER_API_KEY` is set ### Changed +- Changed web search to try all configured providers in sequence with fallback before reporting errors +- Changed default Anthropic web search model from `claude-sonnet-4-5-20250514` to `claude-haiku-4-5` +- Changed read tool to show first 50KB of oversized lines instead of directing users to bash sed +- Changed web_fetch to use `Bun.which()` instead of spawning `which`/`where` for command detection +- Changed web_fetch to check Content-Length header before downloading to reject oversized files early +- Changed generate_image tool to save images to temp files and report paths instead of inline base64 +- Changed system prompt with tool usage guidance (ground answers with tools, minimize context, iterate on results) +- Changed Task tool prompt with plan-then-execute guidance and output tool hints - Changed edit tool success message to report count when replacing multiple occurrences with `all: true` - Changed default image generation model to `gemini-3-pro-image-preview` - Changed error message for multiple occurrences to suggest using `all: true` option @@ -23,6 +39,9 @@ ### Fixed +- Fixed read tool markitdown truncation message using broken template string (missing `${` around format call) +- Fixed web_fetch URL normalization order to run before special handlers +- Fixed TUI image display for generate_image tool by sourcing images from details.images in addition to content blocks - Fixed context file preview in inspector panel to display content correctly instead of attempting async file reads - Fixed Linux ARM64 installs failing on fresh Debian when the `sharp` module is unavailable during session image compression diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index aacc8d673..a66e1f6f7 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -101,6 +101,8 @@ import { lsTool, readOnlyTools, readTool, + setPreferredImageProvider, + setPreferredWebSearchProvider, type Tool, type ToolName, warmupLspServers, @@ -528,6 +530,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} initializeWithSettings(settingsManager); time("initializeWithSettings"); + // Initialize provider preferences from settings + setPreferredWebSearchProvider(settingsManager.getWebSearchProvider()); + setPreferredImageProvider(settingsManager.getImageProvider()); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd); time("sessionManager"); @@ -636,9 +642,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }); time("createAllTools"); - const initialActiveToolNames: ToolName[] = options.tools + // Determine which tools to include based on settings + let baseToolNames = options.tools ? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap) - : baseCodingToolNames; + : [...baseCodingToolNames]; + + // Filter out git tool if disabled in settings + if (!settingsManager.getGitToolEnabled()) { + baseToolNames = baseToolNames.filter((name) => name !== "git"); + } + + const initialActiveToolNames: ToolName[] = baseToolNames; const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]); // Discover MCP tools from .mcp.json files diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2ede59bc7..11cdc639f 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -62,10 +62,22 @@ export interface ExaSettings { enableWebsets?: boolean; // default: false } +export type WebSearchProviderOption = "auto" | "exa" | "perplexity" | "anthropic"; +export type ImageProviderOption = "auto" | "gemini" | "openrouter"; + +export interface ProviderSettings { + webSearch?: WebSearchProviderOption; // default: "auto" (exa > perplexity > anthropic) + image?: ImageProviderOption; // default: "auto" (openrouter > gemini) +} + export interface BashInterceptorSettings { enabled?: boolean; // default: false (blocks shell commands that have dedicated tools) } +export interface GitSettings { + enabled?: boolean; // default: false (structured git tool; use bash for git commands when disabled) +} + export interface MCPSettings { enableProjectConfig?: boolean; // default: true (load .mcp.json from project root) } @@ -167,11 +179,13 @@ export interface Settings { enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) exa?: ExaSettings; bashInterceptor?: BashInterceptorSettings; + git?: GitSettings; mcp?: MCPSettings; lsp?: LspSettings; edit?: EditSettings; ttsr?: TtsrSettings; voice?: VoiceSettings; + providers?: ProviderSettings; disabledProviders?: string[]; // Discovery provider IDs that are disabled disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit") statusLine?: StatusLineSettings; // Status line configuration @@ -640,6 +654,31 @@ export class SettingsManager { this.save(); } + // Provider settings + getWebSearchProvider(): WebSearchProviderOption { + return this.settings.providers?.webSearch ?? "auto"; + } + + setWebSearchProvider(provider: WebSearchProviderOption): void { + if (!this.globalSettings.providers) { + this.globalSettings.providers = {}; + } + this.globalSettings.providers.webSearch = provider; + this.save(); + } + + getImageProvider(): ImageProviderOption { + return this.settings.providers?.image ?? "auto"; + } + + setImageProvider(provider: ImageProviderOption): void { + if (!this.globalSettings.providers) { + this.globalSettings.providers = {}; + } + this.globalSettings.providers.image = provider; + this.save(); + } + getBashInterceptorEnabled(): boolean { return this.settings.bashInterceptor?.enabled ?? false; } @@ -652,6 +691,18 @@ export class SettingsManager { this.save(); } + getGitToolEnabled(): boolean { + return this.settings.git?.enabled ?? false; + } + + setGitToolEnabled(enabled: boolean): void { + if (!this.globalSettings.git) { + this.globalSettings.git = {}; + } + this.globalSettings.git.enabled = enabled; + this.save(); + } + getMCPProjectConfigEnabled(): boolean { return this.settings.mcp?.enableProjectConfig ?? true; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index b340cfad3..4fb990b45 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -69,11 +69,12 @@ ${commitsText}`; const toolDescriptions: Record = { ask: "Ask user for input or clarification", read: "Read file contents", - bash: "Execute bash commands (git, npm, docker, etc.)", + bash: "Execute bash commands (npm, docker, etc.)", edit: "Make surgical edits to files (find exact text and replace)", write: "Create or overwrite files", grep: "Search file contents for patterns (respects .gitignore)", find: "Find files by glob pattern (respects .gitignore)", + git: "Structured Git operations with safety guards (status, diff, log, commit, push, pr, etc.)", ls: "List directory contents", lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.", notebook: "Edit Jupyter notebook cells", @@ -99,9 +100,10 @@ function generateAntiBashRules(tools: ToolName[]): string | null { const hasLs = tools.includes("ls"); const hasEdit = tools.includes("edit"); const hasLsp = tools.includes("lsp"); + const hasGit = tools.includes("git"); // Only show rules if we have specialized tools that should be preferred - const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit; + const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit || hasGit; if (!hasSpecializedTools) return null; const lines: string[] = []; @@ -114,6 +116,7 @@ function generateAntiBashRules(tools: ToolName[]): string | null { if (hasFind) lines.push("- **File finding**: Use `find` instead of find/fd/locate"); if (hasLs) lines.push("- **Directory listing**: Use `ls` instead of bash ls"); if (hasEdit) lines.push("- **File editing**: Use `edit` instead of sed/awk/perl -pi/echo >/cat < `${i + 1}. ${t}`).join("\n")); // Add LSP guidance if available @@ -137,6 +141,18 @@ function generateAntiBashRules(tools: ToolName[]): string | null { lines.push("- **Find symbol across codebase** → `lsp workspace_symbols`\n"); } + // Add Git guidance if available + if (hasGit) { + lines.push("\n### Git Tool — Preferred for Git Operations"); + lines.push("Use `git` instead of bash git when you need:"); + lines.push("- **Status/diff/log**: `git { operation: 'status' }`, `git { operation: 'diff' }`, `git { operation: 'log' }`"); + lines.push("- **Commit workflow**: `git { operation: 'add', paths: [...] }` then `git { operation: 'commit', message: '...' }`"); + lines.push("- **Branching**: `git { operation: 'branch', action: 'create', name: '...' }`"); + lines.push("- **GitHub PRs**: `git { operation: 'pr', action: 'create', title: '...', body: '...' }`"); + lines.push("- **GitHub Issues**: `git { operation: 'issue', action: 'list' }` or `{ operation: 'issue', number: 123 }`"); + lines.push("The git tool provides typed output, safety guards, and a clean API for all git and GitHub operations.\n"); + } + // Add search-first protocol if (hasGrep || hasFind) { lines.push("\n### Search-First Protocol"); diff --git a/packages/coding-agent/src/core/tools/bash-interceptor.ts b/packages/coding-agent/src/core/tools/bash-interceptor.ts index 345f63d13..dceef48fc 100644 --- a/packages/coding-agent/src/core/tools/bash-interceptor.ts +++ b/packages/coding-agent/src/core/tools/bash-interceptor.ts @@ -36,6 +36,13 @@ const forbiddenPatterns: Array<{ tool: "grep", message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.", }, + // Git operations + { + pattern: /^\s*git(\s+|$)/, + tool: "git", + message: + "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.", + }, // File finding { pattern: /^\s*(find|fd|locate)\s+.*(-name|-iname|-type|--type|-glob)/, diff --git a/packages/coding-agent/src/core/tools/gemini-image.ts b/packages/coding-agent/src/core/tools/gemini-image.ts index d57042c2a..26ed40bb7 100644 --- a/packages/coding-agent/src/core/tools/gemini-image.ts +++ b/packages/coding-agent/src/core/tools/gemini-image.ts @@ -1,4 +1,7 @@ -import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { type Static, Type } from "@sinclair/typebox"; import geminiImageDescription from "../../prompts/tools/gemini-image.md" with { type: "text" }; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime"; @@ -44,12 +47,6 @@ export const geminiImageSchema = Type.Object( description: `Image model. Default: ${DEFAULT_MODEL} (direct Gemini) or ${DEFAULT_OPENROUTER_MODEL} (OpenRouter).`, }), ), - response_modalities: Type.Optional( - Type.Array(responseModalitySchema, { - description: 'Response modalities (default: ["Image"]).', - minItems: 1, - }), - ), aspect_ratio: Type.Optional(aspectRatioSchema), image_size: Type.Optional(imageSizeSchema), input_images: Type.Optional( @@ -134,6 +131,8 @@ interface GeminiImageToolDetails { provider: ImageProvider; model: string; imageCount: number; + imagePaths: string[]; + images: InlineImageData[]; responseText?: string; promptFeedback?: GeminiPromptFeedback; usage?: GeminiUsageMetadata; @@ -164,7 +163,7 @@ function toDataUrl(image: InlineImageData): string { return `data:${image.mimeType};base64,${image.data}`; } -async function loadImageFromUrl(imageUrl: string): Promise { +async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise { if (imageUrl.startsWith("data:")) { const normalized = normalizeDataUrl(imageUrl.trim()); if (!normalized.mimeType) { @@ -176,7 +175,7 @@ async function loadImageFromUrl(imageUrl: string): Promise { return { data: normalized.data, mimeType: normalized.mimeType }; } - const response = await fetch(imageUrl); + const response = await fetch(imageUrl, { signal }); if (!response.ok) { const rawText = await response.text(); throw new Error(`Image download failed (${response.status}): ${rawText}`); @@ -228,7 +227,29 @@ function extractOpenRouterImageUrls(message: OpenRouterMessage | undefined): str return urls; } +/** Preferred provider set via settings (default: auto) */ +let preferredImageProvider: ImageProvider | "auto" = "auto"; + +/** Set the preferred image provider from settings */ +export function setPreferredImageProvider(provider: ImageProvider | "auto"): void { + preferredImageProvider = provider; +} + async function findImageApiKey(): Promise { + // If a specific provider is preferred, try it first + if (preferredImageProvider === "gemini") { + const geminiKey = await getEnv("GEMINI_API_KEY"); + if (geminiKey) return { provider: "gemini", apiKey: geminiKey }; + const googleKey = await getEnv("GOOGLE_API_KEY"); + if (googleKey) return { provider: "gemini", apiKey: googleKey }; + // Fall through to auto-detect if preferred provider key not found + } else if (preferredImageProvider === "openrouter") { + const openRouterKey = await getEnv("OPENROUTER_API_KEY"); + if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey }; + // Fall through to auto-detect if preferred provider key not found + } + + // Auto-detect: OpenRouter takes priority const openRouterKey = await getEnv("OPENROUTER_API_KEY"); if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey }; @@ -280,8 +301,29 @@ async function resolveInputImage(input: ImageInput, cwd: string): Promise = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + }; + return map[mimeType] ?? "png"; +} + +function saveImageToTemp(image: InlineImageData): string { + const ext = getExtensionForMime(image.mimeType); + const filename = `omp-image-${crypto.randomUUID()}.${ext}`; + const filepath = join(tmpdir(), filename); + fs.writeFileSync(filepath, Buffer.from(image.data, "base64")); + return filepath; +} + +function buildResponseSummary(model: string, imagePaths: string[], responseText: string | undefined): string { + const lines = [`Model: ${model}`, `Generated ${imagePaths.length} image(s):`]; + for (const p of imagePaths) { + lines.push(` ${p}`); + } if (responseText) { lines.push("", responseText.trim()); } @@ -352,7 +394,6 @@ export const geminiImageTool: CustomTool = []; if (inlineImages.length === 0) { const messageText = responseText ? `\n\n${responseText}` : ""; - content.push({ type: "text", text: `No image data returned.${messageText}` }); return { - content, + content: [{ type: "text", text: `No image data returned.${messageText}` }], details: { provider, model: resolvedModel, imageCount: 0, + imagePaths: [], + images: [], responseText, }, }; } - content.push({ - type: "text", - text: buildResponseSummary(resolvedModel, inlineImages.length, responseText), - }); - for (const image of inlineImages) { - content.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } + const imagePaths = inlineImages.map(saveImageToTemp); return { - content, + content: [{ type: "text", text: buildResponseSummary(resolvedModel, imagePaths, responseText) }], details: { provider, model: resolvedModel, imageCount: inlineImages.length, + imagePaths, + images: inlineImages, responseText, }, }; @@ -452,7 +489,7 @@ export const geminiImageTool: CustomTool = []; if (inlineImages.length === 0) { const blocked = data.promptFeedback?.blockReason ? `Blocked: ${data.promptFeedback.blockReason}` : "No image data returned."; - content.push({ type: "text", text: `${blocked}${responseText ? `\n\n${responseText}` : ""}` }); return { - content, + content: [{ type: "text", text: `${blocked}${responseText ? `\n\n${responseText}` : ""}` }], details: { provider, model, imageCount: 0, + imagePaths: [], + images: [], responseText, promptFeedback: data.promptFeedback, usage: data.usageMetadata, @@ -516,20 +553,16 @@ export const geminiImageTool: CustomTool, _signal?: AbortSignal) => { + if (params.operation === "commit" && !params.message) { + throw new Error("Git commit requires a message to avoid an interactive editor. Provide `message`."); + } + const result = await gitToolCore(params as GitParams, cwd); if ("error" in result) { const message = result._rendered ?? result.error; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 1a92147b6..dbea92826 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -5,6 +5,7 @@ export { createEditTool, type EditToolOptions, editTool } from "./edit"; export { exaTools } from "./exa/index"; export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types"; export { createFindTool, type FindToolDetails, findTool } from "./find"; +export { setPreferredImageProvider } from "./gemini-image"; export { createGitTool, type GitToolDetails, gitTool } from "./git"; export { createGrepTool, type GrepToolDetails, grepTool } from "./grep"; export { createLsTool, type LsToolDetails, lsTool } from "./ls"; @@ -39,6 +40,7 @@ export { getWebSearchTools, hasExaWebSearch, linkedinWebSearchTools, + setPreferredWebSearchProvider, type WebSearchProvider, type WebSearchResponse, type WebSearchToolsOptions, diff --git a/packages/coding-agent/src/core/tools/output.ts b/packages/coding-agent/src/core/tools/output.ts index 6b927b6b2..00615abcd 100644 --- a/packages/coding-agent/src/core/tools/output.ts +++ b/packages/coding-agent/src/core/tools/output.ts @@ -23,6 +23,18 @@ const outputSchema = Type.Object({ description: "Output format: raw (default), json (structured), stripped (no ANSI)", }), ), + offset: Type.Optional( + Type.Number({ + description: "Line number to start reading from (1-indexed)", + minimum: 1, + }), + ), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of lines to read", + minimum: 1, + }), + ), }); /** Metadata for a single output file */ @@ -31,6 +43,12 @@ interface OutputProvenance { index: number; } +interface OutputRange { + startLine: number; + endLine: number; + totalLines: number; +} + interface OutputEntry { id: string; path: string; @@ -38,6 +56,7 @@ interface OutputEntry { charCount: number; provenance?: OutputProvenance; previewLines?: string[]; + range?: OutputRange; } export interface OutputToolDetails { @@ -99,7 +118,7 @@ export function createOutputTool( parameters: outputSchema, execute: async ( _toolCallId: string, - params: { ids: string[]; format?: "raw" | "json" | "stripped" }, + params: { ids: string[]; format?: "raw" | "json" | "stripped"; offset?: number; limit?: number }, ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => { const sessionFile = sessionContext?.getSessionFile(); @@ -131,15 +150,37 @@ export function createOutputTool( continue; } - const content = fs.readFileSync(outputPath, "utf-8"); - outputContentById.set(id, content); + const rawContent = fs.readFileSync(outputPath, "utf-8"); + const rawLines = rawContent.split("\n"); + const totalLines = rawLines.length; + const totalChars = rawContent.length; + + let selectedContent = rawContent; + let range: OutputRange | undefined; + + if (params.offset !== undefined || params.limit !== undefined) { + const startLine = Math.max(1, params.offset ?? 1); + if (startLine > totalLines) { + throw new Error( + `Offset ${params.offset ?? startLine} is beyond end of output (${totalLines} lines) for ${id}`, + ); + } + const effectiveLimit = params.limit ?? totalLines - startLine + 1; + const endLine = Math.min(totalLines, startLine + effectiveLimit - 1); + const selectedLines = rawLines.slice(startLine - 1, endLine); + selectedContent = selectedLines.join("\n"); + range = { startLine, endLine, totalLines }; + } + + outputContentById.set(id, selectedContent); outputs.push({ id, path: outputPath, - lineCount: content.split("\n").length, - charCount: content.length, + lineCount: totalLines, + charCount: totalChars, provenance: parseOutputProvenance(id), - previewLines: extractPreviewLines(content, 4), + previewLines: extractPreviewLines(selectedContent, 4), + range, }); } @@ -167,6 +208,7 @@ export function createOutputTool( charCount: o.charCount, provenance: o.provenance, previewLines: o.previewLines, + range: o.range, content: outputContentById.get(o.id) ?? "", })); contentText = JSON.stringify(jsonData, null, 2); @@ -177,6 +219,10 @@ export function createOutputTool( if (format === "stripped") { content = stripAnsi(content); } + if (o.range && o.range.endLine < o.range.totalLines) { + const nextOffset = o.range.endLine + 1; + content += `\n\n[Showing lines ${o.range.startLine}-${o.range.endLine} of ${o.range.totalLines}. Use offset=${nextOffset} to continue]`; + } // Add header for multiple outputs if (outputs.length > 1) { return `=== ${o.id} (${o.lineCount} lines, ${formatBytes(o.charCount)}) ===\n${content}`; diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 901456d1f..aa4974133 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -11,7 +11,14 @@ import { ensureTool } from "../../utils/tools-manager"; import { untilAborted } from "../utils"; import { createLsTool } from "./ls"; import { resolveReadPath, resolveToCwd } from "./path-utils"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationResult, + truncateHead, + truncateStringToBytesFromStart, +} from "./truncate"; // Document types convertible via markitdown const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]); @@ -450,9 +457,9 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo let outputText = truncation.content; if (truncation.truncated) { - outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize( + outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize( DEFAULT_MAX_BYTES, - )]`; + )}]`; details = { truncation }; } @@ -498,11 +505,21 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo let outputText: string; if (truncation.firstLineExceedsLimit) { - // First line at offset exceeds 30KB - tell model to use bash - const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); - outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize( - DEFAULT_MAX_BYTES, - )} limit. Use bash: sed -n '${startLineDisplay}p' ${readPath} | head -c ${DEFAULT_MAX_BYTES}]`; + const firstLine = allLines[startLine] ?? ""; + const firstLineBytes = Buffer.byteLength(firstLine, "utf-8"); + const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES); + const shownSize = formatSize(snippet.bytes); + + outputText = snippet.text; + if (outputText.length > 0) { + outputText += `\n\n[Line ${startLineDisplay} is ${formatSize( + firstLineBytes, + )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`; + } else { + outputText = `[Line ${startLineDisplay} is ${formatSize( + firstLineBytes, + )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`; + } details = { truncation }; } else if (truncation.truncated) { // Truncation occurred - build actionable notice diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index d309e5553..f42069bd4 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -572,12 +572,20 @@ const lspRenderer: ToolRenderer = { interface OutputArgs { ids: string[]; format?: "raw" | "json" | "stripped"; + offset?: number; + limit?: number; } type OutputEntry = OutputToolDetails["outputs"][number]; function formatOutputMeta(entry: OutputEntry, theme: Theme): string { - const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)]; + const metaParts: string[] = []; + if (entry.range) { + metaParts.push(`lines ${entry.range.startLine}-${entry.range.endLine} of ${entry.range.totalLines}`); + } else { + metaParts.push(formatCount("line", entry.lineCount)); + } + metaParts.push(formatBytes(entry.charCount)); if (entry.provenance) { metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`); } @@ -592,6 +600,8 @@ const outputRenderer: ToolRenderer = { const meta: string[] = []; if (args.format && args.format !== "raw") meta.push(`format:${args.format}`); + if (args.offset !== undefined) meta.push(`offset:${args.offset}`); + if (args.limit !== undefined) meta.push(`limit:${args.limit}`); text += formatMeta(meta, theme); return new Text(text, 0, 0); diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts index f4742fa46..26017d4e0 100644 --- a/packages/coding-agent/src/core/tools/task/index.ts +++ b/packages/coding-agent/src/core/tools/task/index.ts @@ -175,6 +175,12 @@ async function buildDescription(cwd: string): Promise { lines.push(""); lines.push("Usage notes:"); lines.push("- Always include a short description of the task in the task parameter"); + lines.push( + "- Prefer plan-then-execute: put shared constraints in context, keep each task focused, and specify output format and acceptance criteria", + ); + lines.push( + "- Minimize tool chatter: avoid repeating large context and use the Output tool with output ids for full logs", + ); lines.push("- Launch multiple agents concurrently whenever possible, to maximize performance"); lines.push( "- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.", @@ -512,7 +518,12 @@ export async function createTaskTool( skippedSelfRecursion > 0 ? ` (${skippedSelfRecursion} ${blockedAgent} task${skippedSelfRecursion > 1 ? "s" : ""} skipped - self-recursion blocked)` : ""; - const summary = `${successCount}/${resultsWithUsage.length} succeeded${skippedNote} [${formatDuration(totalDuration)}]\n\n${summaries.join("\n\n---\n\n")}`; + const outputIds = resultsWithUsage.map((r) => `${r.agent}_${r.index}`); + const outputHint = + hasOutputTool && outputIds.length > 0 + ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}` + : ""; + const summary = `${successCount}/${resultsWithUsage.length} succeeded${skippedNote} [${formatDuration(totalDuration)}]\n\n${summaries.join("\n\n---\n\n")}${outputHint}`; // Cleanup temp directory if used if (tempArtifactsDir) { diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index 18ac5d74d..4ac08f53a 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -5,7 +5,8 @@ * - Line limit (default: 2000 lines) * - Byte limit (default: 50KB) * - * Never returns partial lines (except bash tail truncation edge case). + * Never returns partial lines (except bash tail truncation edge case + * and the read tool's long-line snippet fallback). */ export const DEFAULT_MAX_LINES = 2000; @@ -250,6 +251,31 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { return buf.slice(start).toString("utf-8"); } +/** + * Truncate a string to fit within a byte limit (from the start). + * Handles multi-byte UTF-8 characters correctly. + */ +export function truncateStringToBytesFromStart(str: string, maxBytes: number): { text: string; bytes: number } { + const buf = Buffer.from(str, "utf-8"); + if (buf.length <= maxBytes) { + return { text: str, bytes: buf.length }; + } + + let end = maxBytes; + + // Find a valid UTF-8 boundary (start of a character) + while (end > 0 && (buf[end] & 0xc0) === 0x80) { + end--; + } + + if (end <= 0) { + return { text: "", bytes: 0 }; + } + + const text = buf.slice(0, end).toString("utf-8"); + return { text, bytes: Buffer.byteLength(text, "utf-8") }; +} + /** * Truncate a single line to max characters, adding [truncated] suffix. * Used for grep match lines. diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 335496189..07c929e3a 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -66,8 +66,6 @@ const CONVERTIBLE_EXTENSIONS = new Set([ ".ogg", ]); -const isWindows = process.platform === "win32"; - const USER_AGENTS = [ "curl/8.0", "Mozilla/5.0 (compatible; TextBot/1.0)", @@ -211,13 +209,7 @@ function exec( * Check if a command exists (cross-platform) */ function hasCommand(cmd: string): boolean { - const checkCmd = isWindows ? "where" : "which"; - const result = Bun.spawnSync([checkCmd, cmd], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - return result.exitCode === 0; + return Boolean(Bun.which(cmd)); } /** @@ -626,7 +618,18 @@ async function fetchBinary( const contentType = response.headers.get("content-type") ?? ""; const contentDisposition = response.headers.get("content-disposition") ?? undefined; + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const size = Number.parseInt(contentLength, 10); + if (Number.isFinite(size) && size > MAX_BYTES) { + return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false }; + } + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > MAX_BYTES) { + return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false }; + } return { buffer, contentType, contentDisposition, ok: true }; } catch { @@ -1959,16 +1962,16 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr const notes: string[] = []; const fetchedAt = new Date().toISOString(); - // Step 0: Try special handlers for known sites (unless raw mode) + // Step 0: Normalize URL (ensure scheme for special handlers) + url = normalizeUrl(url); + const origin = getOrigin(url); + + // Step 1: Try special handlers for known sites (unless raw mode) if (!raw) { const specialResult = await handleSpecialUrls(url, timeout); if (specialResult) return specialResult; } - // Step 1: Normalize URL - url = normalizeUrl(url); - const origin = getOrigin(url); - // Step 2: Fetch page const response = await loadPage(url, { timeout }); if (!response.ok) { diff --git a/packages/coding-agent/src/core/tools/web-search/index.ts b/packages/coding-agent/src/core/tools/web-search/index.ts index 420152d0b..137d50edf 100644 --- a/packages/coding-agent/src/core/tools/web-search/index.ts +++ b/packages/coding-agent/src/core/tools/web-search/index.ts @@ -21,11 +21,13 @@ import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchRes import { renderExaCall, renderExaResult } from "../exa/render"; import type { ExaRenderDetails } from "../exa/types"; import { formatAge } from "../render-utils"; +import { findAnthropicAuth } from "./auth"; import { searchAnthropic } from "./providers/anthropic"; import { searchExa } from "./providers/exa"; import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity"; import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render"; import type { WebSearchProvider, WebSearchResponse } from "./types"; +import { WebSearchProviderError } from "./types"; /** Web search parameters schema */ export const webSearchSchema = Type.Object({ @@ -95,18 +97,78 @@ export type WebSearchParams = { return_related_questions?: boolean; }; -/** Detect provider based on available API keys (priority: exa > perplexity > anthropic) */ -async function detectProvider(): Promise { - // Exa takes highest priority if key exists +/** Preferred provider set via settings (default: auto) */ +let preferredProvider: WebSearchProvider | "auto" = "auto"; + +/** Set the preferred web search provider from settings */ +export function setPreferredWebSearchProvider(provider: WebSearchProvider | "auto"): void { + preferredProvider = provider; +} + +/** Determine which providers are configured (priority order) */ +async function getAvailableProviders(): Promise { + const providers: WebSearchProvider[] = []; + const exaKey = await findExaKey(); - if (exaKey) return "exa"; + if (exaKey) providers.push("exa"); - // Perplexity second priority const perplexityKey = await findPerplexityKey(); - if (perplexityKey) return "perplexity"; + if (perplexityKey) providers.push("perplexity"); - // Default to Anthropic - return "anthropic"; + const anthropicAuth = await findAnthropicAuth(); + if (anthropicAuth) providers.push("anthropic"); + + return providers; +} + +function formatProviderLabel(provider: WebSearchProvider): string { + switch (provider) { + case "exa": + return "Exa"; + case "perplexity": + return "Perplexity"; + case "anthropic": + return "Anthropic"; + default: + return provider; + } +} + +function formatProviderList(providers: WebSearchProvider[]): string { + return providers.map((provider) => formatProviderLabel(provider)).join(", "); +} + +function buildNoProviderError(): string { + return "No web search provider configured. Set EXA_API_KEY, PERPLEXITY_API_KEY, ANTHROPIC_SEARCH_API_KEY, or ANTHROPIC_API_KEY."; +} + +function formatProviderError(error: unknown, provider: WebSearchProvider): string { + if (error instanceof WebSearchProviderError) { + if (error.provider === "anthropic" && error.status === 404) { + return "Anthropic web search returned 404 (model or endpoint not found). Set ANTHROPIC_SEARCH_MODEL/ANTHROPIC_SEARCH_BASE_URL, or configure EXA_API_KEY or PERPLEXITY_API_KEY."; + } + if (error.status === 401 || error.status === 403) { + return `${formatProviderLabel(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)}`; +} + +async function resolveProviderChain( + requestedProvider?: WebSearchProvider | "auto", +): Promise<{ providers: WebSearchProvider[]; allowFallback: boolean }> { + if (requestedProvider && requestedProvider !== "auto") { + return { providers: [requestedProvider], allowFallback: false }; + } + + if (preferredProvider !== "auto") { + return { providers: [preferredProvider], allowFallback: false }; + } + + const providers = await getAvailableProviders(); + return { providers, allowFallback: true }; } /** Truncate text for tool output */ @@ -198,48 +260,71 @@ async function executeWebSearch( _toolCallId: string, params: WebSearchParams, ): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { - try { - const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider(); + const { providers, allowFallback } = await resolveProviderChain(params.provider); - let response: WebSearchResponse; - if (provider === "exa") { - response = await searchExa({ - query: params.query, - num_results: params.num_results, - }); - } else if (provider === "anthropic") { - response = await searchAnthropic({ - query: params.query, - system_prompt: params.system_prompt, - max_tokens: params.max_tokens, - num_results: params.num_results, - }); - } else { - response = await searchPerplexity({ - query: params.query, - model: params.model, - system_prompt: params.system_prompt, - search_recency_filter: params.search_recency_filter, - search_domain_filter: params.search_domain_filter, - search_context_size: params.search_context_size, - return_related_questions: params.return_related_questions, - num_results: params.num_results, - }); - } - - const text = formatForLLM(response); - - return { - content: [{ type: "text" as const, text }], - details: { response }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + if (providers.length === 0) { + const message = buildNoProviderError(); + const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider; return { content: [{ type: "text" as const, text: `Error: ${message}` }], - details: { response: { provider: "anthropic", sources: [] }, error: message }, + details: { response: { provider: fallbackProvider, sources: [] }, error: message }, }; } + + let lastError: unknown; + let lastProvider = providers[0]; + + for (const provider of providers) { + lastProvider = provider; + try { + let response: WebSearchResponse; + if (provider === "exa") { + response = await searchExa({ + query: params.query, + num_results: params.num_results, + }); + } else if (provider === "anthropic") { + response = await searchAnthropic({ + query: params.query, + system_prompt: params.system_prompt, + max_tokens: params.max_tokens, + num_results: params.num_results, + }); + } else { + response = await searchPerplexity({ + query: params.query, + model: params.model, + system_prompt: params.system_prompt, + search_recency_filter: params.search_recency_filter, + search_domain_filter: params.search_domain_filter, + search_context_size: params.search_context_size, + return_related_questions: params.return_related_questions, + num_results: params.num_results, + }); + } + + const text = formatForLLM(response); + + return { + content: [{ type: "text" as const, text }], + details: { response }, + }; + } catch (error) { + lastError = error; + if (!allowFallback) break; + } + } + + const baseMessage = formatProviderError(lastError, lastProvider); + const message = + allowFallback && providers.length > 1 + ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}` + : baseMessage; + + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { response: { provider: lastProvider, sources: [] }, error: message }, + }; } /** Web search tool as AgentTool (for allTools export) */ diff --git a/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts index fa672710f..793b71eea 100644 --- a/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts +++ b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts @@ -14,8 +14,9 @@ import type { WebSearchResponse, WebSearchSource, } from "../types"; +import { WebSearchProviderError } from "../types"; -const DEFAULT_MODEL = "claude-sonnet-4-5-20250514"; +const DEFAULT_MODEL = "claude-haiku-4-5"; const DEFAULT_MAX_TOKENS = 4096; export interface AnthropicSearchParams { @@ -36,7 +37,7 @@ async function callWebSearch( model: string, query: string, systemPrompt?: string, - maxTokens?: number, + maxTokens?: number ): Promise { const url = buildAnthropicUrl(auth); const headers = buildAnthropicHeaders(auth); @@ -80,7 +81,11 @@ async function callWebSearch( if (!response.ok) { const errorText = await response.text(); - throw new Error(`Anthropic API error (${response.status}): ${errorText}`); + throw new WebSearchProviderError( + "anthropic", + `Anthropic API error (${response.status}): ${errorText}`, + response.status + ); } return response.json() as Promise; @@ -180,7 +185,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise; diff --git a/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts b/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts index 173a1e494..28ae1656e 100644 --- a/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts +++ b/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts @@ -13,6 +13,7 @@ import type { WebSearchResponse, WebSearchSource, } from "../types"; +import { WebSearchProviderError } from "../types"; const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; @@ -92,7 +93,11 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi if (!response.ok) { const errorText = await response.text(); - throw new Error(`Perplexity API error (${response.status}): ${errorText}`); + throw new WebSearchProviderError( + "perplexity", + `Perplexity API error (${response.status}): ${errorText}`, + response.status, + ); } return response.json() as Promise; diff --git a/packages/coding-agent/src/core/tools/web-search/types.ts b/packages/coding-agent/src/core/tools/web-search/types.ts index 81d9a9a5f..493561378 100644 --- a/packages/coding-agent/src/core/tools/web-search/types.ts +++ b/packages/coding-agent/src/core/tools/web-search/types.ts @@ -57,6 +57,19 @@ export interface WebSearchResponse { requestId?: string; } +/** Provider-specific error with optional HTTP status */ +export class WebSearchProviderError extends Error { + provider: WebSearchProvider; + status?: number; + + constructor(provider: WebSearchProvider, message: string, status?: number) { + super(message); + this.name = "WebSearchProviderError"; + this.provider = provider; + this.status = status; + } +} + /** Auth configuration for Anthropic */ export interface AnthropicAuthConfig { apiKey: string; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts index 1670d0fda..e79efa809 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -11,11 +11,13 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getCapabilities } from "@oh-my-pi/pi-tui"; import type { + ImageProviderOption, NotificationMethod, SettingsManager, StatusLinePreset, StatusLineSeparatorStyle, SymbolPreset, + WebSearchProviderOption, } from "../../../core/settings-manager"; import { getPreset } from "./status-line/presets"; @@ -200,6 +202,15 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getBashInterceptorEnabled(), set: (sm, v) => sm.setBashInterceptorEnabled(v), }, + { + id: "gitTool", + tab: "config", + type: "boolean", + label: "Git tool", + description: "Enable structured Git tool", + get: (sm) => sm.getGitToolEnabled(), + set: (sm, v) => sm.setGitToolEnabled(v), + }, { id: "mcpProjectConfig", tab: "config", @@ -286,6 +297,35 @@ export const SETTINGS_DEFS: SettingDef[] = [ { value: "ascii", label: "ASCII", description: "ASCII-only characters (maximum compatibility)" }, ], }, + { + id: "webSearchProvider", + tab: "config", + type: "submenu", + label: "Web search provider", + description: "Provider for web search tool", + get: (sm) => sm.getWebSearchProvider(), + set: (sm, v) => sm.setWebSearchProvider(v as WebSearchProviderOption), + getOptions: () => [ + { value: "auto", label: "Auto", description: "Priority: Exa > Perplexity > Anthropic" }, + { value: "exa", label: "Exa", description: "Use Exa (requires EXA_API_KEY)" }, + { value: "perplexity", label: "Perplexity", description: "Use Perplexity (requires PERPLEXITY_API_KEY)" }, + { value: "anthropic", label: "Anthropic", description: "Use Anthropic web search" }, + ], + }, + { + id: "imageProvider", + tab: "config", + type: "submenu", + label: "Image provider", + description: "Provider for image generation tool", + get: (sm) => sm.getImageProvider(), + set: (sm, v) => sm.setImageProvider(v as ImageProviderOption), + getOptions: () => [ + { value: "auto", label: "Auto", description: "Priority: OpenRouter > Gemini" }, + { value: "gemini", label: "Gemini", description: "Use Gemini API directly (requires GEMINI_API_KEY)" }, + { value: "openrouter", label: "OpenRouter", description: "Use OpenRouter (requires OPENROUTER_API_KEY)" }, + ], + }, // LSP tab { diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 537c34f73..57aa35abc 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -460,6 +460,17 @@ export class ToolExecutionComponent extends Container { this.maybeConvertImagesForKitty(); } + /** + * Get all image blocks from result content and details.images. + * Some tools (like generate_image) store images in details to avoid bloating model context. + */ + private getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> { + if (!this.result) return []; + const contentImages = this.result.content?.filter((c: any) => c.type === "image") || []; + const detailImages = this.result.details?.images || []; + return [...contentImages, ...detailImages]; + } + /** * Convert non-PNG images to PNG for Kitty graphics protocol. * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display. @@ -470,7 +481,7 @@ export class ToolExecutionComponent extends Container { if (caps.images !== "kitty") return; if (!this.result) return; - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + const imageBlocks = this.getAllImageBlocks(); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; @@ -664,7 +675,7 @@ export class ToolExecutionComponent extends Container { this.imageSpacers = []; if (this.result) { - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + const imageBlocks = this.getAllImageBlocks(); const caps = getCapabilities(); for (let i = 0; i < imageBlocks.length; i++) { @@ -783,7 +794,7 @@ export class ToolExecutionComponent extends Container { if (!this.result) return ""; const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || []; - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + const imageBlocks = this.getAllImageBlocks(); let output = textBlocks .map((c: any) => { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index af947c2bc..9ca9a2029 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -31,6 +31,7 @@ import { getRecentSessions, type SessionContext, SessionManager } from "../../co import { loadSlashCommands } from "../../core/slash-commands"; import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify"; import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator"; +import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../core/tools/index"; import type { TruncationResult } from "../../core/tools/truncate"; import { VoiceSupervisor } from "../../core/voice-supervisor"; import { disableProvider, enableProvider } from "../../discovery"; @@ -2362,6 +2363,14 @@ export class InteractiveMode { break; } + // Provider settings - update runtime preferences + case "webSearchProvider": + setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic"); + break; + case "imageProvider": + setPreferredImageProvider(value as "auto" | "gemini" | "openrouter"); + break; + // All other settings are handled by the definitions (get/set on SettingsManager) // No additional side effects needed } diff --git a/packages/coding-agent/src/prompts/system-prompt.md b/packages/coding-agent/src/prompts/system-prompt.md index 4fdff8d25..497bc6a79 100644 --- a/packages/coding-agent/src/prompts/system-prompt.md +++ b/packages/coding-agent/src/prompts/system-prompt.md @@ -15,6 +15,10 @@ Core behavior: - If a command fails due to sandboxing or needs elevated access, request approval and rerun. - Follow project validation/testing guidance; if checks are not run, suggest them in next steps. - Resolve blockers before yielding; do not guess. +- Use tools to ground answers when external or deterministic info is needed; avoid speculation when a tool can verify. +- Ask for missing or ambiguous tool parameters instead of guessing; confirm before actions. +- Minimize tool calls and context usage by narrowing queries and summarizing only what is needed. +- After each tool result, check relevance; iterate or clarify if results conflict or are insufficient. - Use concise, scannable responses; include file paths in backticks; use short bullets for multi-item lists; avoid dumping large files. Documentation: diff --git a/packages/coding-agent/src/prompts/tools/gemini-image.md b/packages/coding-agent/src/prompts/tools/gemini-image.md index 1c1bd1a30..6e3a85b6e 100644 --- a/packages/coding-agent/src/prompts/tools/gemini-image.md +++ b/packages/coding-agent/src/prompts/tools/gemini-image.md @@ -2,3 +2,7 @@ Generate or edit images using Gemini image models directly or via OpenRouter. Provide a text prompt and optional input images. Use response modalities to request image-only output, set aspect ratio or image size, and choose the model explicitly when needed. + +Prompt tips: +- Describe subject, composition, style, and lighting in full sentences. +- For edits, reference the input image and specify the exact changes. diff --git a/packages/coding-agent/src/prompts/tools/output.md b/packages/coding-agent/src/prompts/tools/output.md index 83332b79e..16977c05f 100644 --- a/packages/coding-agent/src/prompts/tools/output.md +++ b/packages/coding-agent/src/prompts/tools/output.md @@ -21,3 +21,7 @@ Do NOT use when: - `"raw"` (default): Full output with ANSI codes preserved - `"json"`: Structured object with metadata - `"stripped"`: Plain text with ANSI codes removed for parsing +- `offset` (optional): Line number to start reading from (1-indexed) +- `limit` (optional): Maximum number of lines to read + +Use offset/limit for line ranges to reduce context usage on large outputs. diff --git a/packages/coding-agent/src/prompts/tools/web-fetch.md b/packages/coding-agent/src/prompts/tools/web-fetch.md index 8596d98d4..c7cbe148a 100644 --- a/packages/coding-agent/src/prompts/tools/web-fetch.md +++ b/packages/coding-agent/src/prompts/tools/web-fetch.md @@ -6,3 +6,4 @@ Use this tool when you need to: - Retrieve information from Stack Overflow, Wikipedia, Reddit, NPM, arXiv, or technical blogs - Access RSS/Atom feeds or JSON endpoints - Read PDF or DOCX files hosted at a URL +- Use `raw: true` for untouched HTML or debugging diff --git a/packages/coding-agent/src/prompts/tools/web-search.md b/packages/coding-agent/src/prompts/tools/web-search.md index 6ca136798..65aa53537 100644 --- a/packages/coding-agent/src/prompts/tools/web-search.md +++ b/packages/coding-agent/src/prompts/tools/web-search.md @@ -3,6 +3,8 @@ Allows OMP to search the web and use the results to inform responses - Returns search result information formatted as search result blocks, including links as markdown hyperlinks - Use this tool for accessing information beyond Claude's knowledge cutoff - Searches are performed automatically within a single API call +- Prefer primary sources (papers, official docs) and corroborate key claims with multiple sources +- Include links for cited sources in the final response Common: system_prompt (guides response style) Anthropic-specific: max_tokens