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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user