feat(coding-agent): added configurable sub-providers and fixed a few

- Added webSearchProvider, imageProvider, and git.enabled settings for configurable provider selection and feature toggles.
- Added offset and limit parameters to Output tool for paginated reading of large outputs.
- Added provider fallback chain for web search with WebSearchProviderError class for graceful degradation.
- Changed generate_image tool to save images to temp files instead of inline base64 to reduce context bloat.
- Changed web search default model from claude-sonnet-4-5-20250514 to claude-haiku-4-5.
- Added git command interception in bash to redirect to dedicated git tool when enabled.
This commit is contained in:
can1357
2026-01-06 22:16:24 +00:00
parent 2e3de262fd
commit 6f128238f3
28 changed files with 576 additions and 127 deletions
+6
View File
@@ -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
+20 -1
View File
@@ -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
+16 -2
View File
@@ -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
@@ -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;
}
@@ -69,11 +69,12 @@ ${commitsText}`;
const toolDescriptions: Record<ToolName, string> = {
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 <<EOF");
if (hasGit) lines.push("- **Git operations**: Use `git` tool instead of bash git commands");
lines.push("\n### Tool Preference (highest → lowest priority)");
const ladder: string[] = [];
@@ -122,7 +125,8 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
if (hasFind) ladder.push("find (locate files by pattern)");
if (hasRead) ladder.push("read (view file contents)");
if (hasEdit) ladder.push("edit (precise text replacement)");
ladder.push("bash (ONLY for git, npm, docker, make, cargo, etc.)");
if (hasGit) ladder.push("git (structured git operations with safety guards)");
ladder.push(`bash (ONLY for ${hasGit ? "" : "git, "}npm, docker, make, cargo, etc.)`);
lines.push(ladder.map((t, i) => `${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");
@@ -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)/,
@@ -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<InlineImageData> {
async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise<InlineImageData> {
if (imageUrl.startsWith("data:")) {
const normalized = normalizeDataUrl(imageUrl.trim());
if (!normalized.mimeType) {
@@ -176,7 +175,7 @@ async function loadImageFromUrl(imageUrl: string): Promise<InlineImageData> {
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<ImageApiKey | null> {
// 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<Inline
throw new Error("input_images entries must include either path or data.");
}
function buildResponseSummary(model: string, imageCount: number, responseText: string | undefined): string {
const lines = [`Model: ${model}`, `Images: ${imageCount}`];
function getExtensionForMime(mimeType: string): string {
const map: Record<string, string> = {
"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<typeof geminiImageSchema, GeminiImageTo
const provider = apiKey.provider;
const model = params.model ?? (provider === "openrouter" ? DEFAULT_OPENROUTER_MODEL : DEFAULT_MODEL);
const resolvedModel = provider === "openrouter" ? resolveOpenRouterModel(model) : model;
const responseModalities = params.response_modalities ?? ["Image"];
const cwd = ctx.sessionManager.getCwd();
const resolvedImages: InlineImageData[] = [];
@@ -405,38 +446,34 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
const imageUrls = extractOpenRouterImageUrls(message);
const inlineImages: InlineImageData[] = [];
for (const imageUrl of imageUrls) {
inlineImages.push(await loadImageFromUrl(imageUrl));
inlineImages.push(await loadImageFromUrl(imageUrl, controller.signal));
}
const content: Array<TextContent | ImageContent> = [];
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<typeof geminiImageSchema, GeminiImageTo
responseModalities: GeminiResponseModality[];
imageConfig?: { aspectRatio?: string; imageSize?: string };
} = {
responseModalities,
responseModalities: ["Image"],
};
if (params.aspect_ratio || params.image_size) {
@@ -496,19 +533,19 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
const responseParts = combineParts(data);
const responseText = collectResponseText(responseParts);
const inlineImages = collectInlineImages(responseParts);
const content: Array<TextContent | ImageContent> = [];
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<typeof geminiImageSchema, GeminiImageTo
};
}
content.push({
type: "text",
text: buildResponseSummary(model, 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(model, imagePaths, responseText) }],
details: {
provider,
model,
imageCount: inlineImages.length,
imagePaths,
images: inlineImages,
responseText,
promptFeedback: data.promptFeedback,
usage: data.usageMetadata,
@@ -199,6 +199,10 @@ export function createGitTool(cwd: string): AgentTool<typeof gitSchema, GitToolD
description: gitDescription,
parameters: gitSchema,
execute: async (_toolCallId, params: Static<typeof gitSchema>, _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;
@@ -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,
+52 -6
View File
@@ -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}`;
+25 -8
View File
@@ -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
@@ -572,12 +572,20 @@ const lspRenderer: ToolRenderer<LspArgs, LspToolDetails> = {
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<OutputArgs, OutputToolDetails> = {
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);
@@ -175,6 +175,12 @@ async function buildDescription(cwd: string): Promise<string> {
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) {
@@ -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.
@@ -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) {
@@ -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<WebSearchProvider> {
// 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<WebSearchProvider[]> {
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) */
@@ -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<AnthropicApiResponse> {
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<AnthropicApiResponse>;
@@ -180,7 +185,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise<We
const auth = await findAnthropicAuth();
if (!auth) {
throw new Error(
"No Anthropic credentials found. Set ANTHROPIC_API_KEY or configure OAuth in ~/.omp/agent/auth.json",
"No Anthropic credentials found. Set ANTHROPIC_API_KEY or configure OAuth in ~/.omp/agent/auth.json"
);
}
@@ -8,6 +8,7 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import type { WebSearchResponse, WebSearchSource } from "../types";
import { WebSearchProviderError } from "../types";
const EXA_API_URL = "https://api.exa.ai/search";
@@ -142,7 +143,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Exa API error (${response.status}): ${errorText}`);
throw new WebSearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
}
return response.json() as Promise<ExaSearchResponse>;
@@ -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<PerplexityResponse>;
@@ -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;
@@ -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
{
@@ -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) => {
@@ -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
}
@@ -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:
@@ -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.
@@ -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.
@@ -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
@@ -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