This commit is contained in:
DhruvBhatia0 2026-05-27 11:33:17 +08:00 committed by GitHub
commit 03af821153
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 596 additions and 9 deletions

View file

@ -50,6 +50,8 @@ export const codebaseIndexConfigSchema = z.object({
codebaseIndexBedrockProfile: z.string().optional(),
// OpenRouter specific fields
codebaseIndexOpenRouterSpecificProvider: z.string().optional(),
// WarpGrep specific fields
warpGrepEnabled: z.boolean().optional(),
})
export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
@ -85,6 +87,7 @@ export const codebaseIndexProviderSchema = z.object({
codebaseIndexMistralApiKey: z.string().optional(),
codebaseIndexVercelAiGatewayApiKey: z.string().optional(),
codebaseIndexOpenRouterApiKey: z.string().optional(),
warpGrepApiKey: z.string().optional(),
})
export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>

View file

@ -272,6 +272,7 @@ export const SECRET_STATE_KEYS = [
"codebaseIndexMistralApiKey",
"codebaseIndexVercelAiGatewayApiKey",
"codebaseIndexOpenRouterApiKey",
"warpGrepApiKey",
"sambaNovaApiKey",
"zaiApiKey",
"fireworksApiKey",

View file

@ -614,6 +614,10 @@ export interface WebviewMessage {
codebaseIndexMistralApiKey?: string
codebaseIndexVercelAiGatewayApiKey?: string
codebaseIndexOpenRouterApiKey?: string
// WarpGrep specific fields
warpGrepEnabled?: boolean
warpGrepApiKey?: string
}
updatedSettings?: RooCodeSettings
/** Task configuration applied via `createTask()`. */

View file

@ -0,0 +1,43 @@
import type OpenAI from "openai"
import { filterNativeToolsForMode, isToolAllowedInMode } from "../filter-tools-for-mode"
function makeTool(name: string): OpenAI.Chat.ChatCompletionTool {
return {
type: "function",
function: {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
},
} as OpenAI.Chat.ChatCompletionTool
}
describe("filterNativeToolsForMode - WarpGrep", () => {
it("keeps codebase_search available when WarpGrep is enabled", () => {
const result = filterNativeToolsForMode(
[makeTool("codebase_search"), makeTool("read_file")],
"code",
undefined,
undefined,
undefined,
{
codebaseIndexConfig: {
warpGrepEnabled: true,
},
},
)
expect(result.map((tool) => (tool as any).function.name)).toContain("codebase_search")
})
it("reports codebase_search as allowed when WarpGrep is enabled", () => {
expect(
isToolAllowedInMode("codebase_search", "code", undefined, undefined, undefined, {
codebaseIndexConfig: {
warpGrepEnabled: true,
},
}),
).toBe(true)
})
})

View file

@ -269,9 +269,16 @@ export function filterNativeToolsForMode(
allowedToolNames = customizedTools
// Conditionally exclude codebase_search if feature is disabled or not configured
// WarpGrep can serve as an alternative backend for codebase_search
const warpGrepEnabled = settings?.codebaseIndexConfig?.warpGrepEnabled === true
if (
!codeIndexManager ||
!(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized)
!warpGrepEnabled &&
(!codeIndexManager ||
!(
codeIndexManager.isFeatureEnabled &&
codeIndexManager.isFeatureConfigured &&
codeIndexManager.isInitialized
))
) {
allowedToolNames.delete("codebase_search")
}
@ -363,11 +370,15 @@ export function isToolAllowedInMode(
if (ALWAYS_AVAILABLE_TOOLS.includes(toolName)) {
// But still check for conditional exclusions
if (toolName === "codebase_search") {
return !!(
codeIndexManager &&
codeIndexManager.isFeatureEnabled &&
codeIndexManager.isFeatureConfigured &&
codeIndexManager.isInitialized
const warpGrepEnabled = settings?.codebaseIndexConfig?.warpGrepEnabled === true
return (
warpGrepEnabled ||
!!(
codeIndexManager &&
codeIndexManager.isFeatureEnabled &&
codeIndexManager.isFeatureConfigured &&
codeIndexManager.isInitialized
)
)
}
if (toolName === "update_todo_list") {

View file

@ -101,6 +101,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
// Build settings object for tool filtering.
const filterSettings = {
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
codebaseIndexConfig: provider.contextProxy.getGlobalState("codebaseIndexConfig"),
disabledTools,
modelInfo,
}

View file

@ -6,6 +6,7 @@ import { CodeIndexManager } from "../../services/code-index/manager"
import { getWorkspacePath } from "../../utils/path"
import { formatResponse } from "../prompts/responses"
import { VectorStoreSearchResult } from "../../services/code-index/interfaces"
import { executeWarpGrepSearch } from "../../services/warpgrep"
import type { ToolUse } from "../../shared/tools"
import { BaseTool, ToolCallbacks } from "./BaseTool"
@ -52,7 +53,32 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> {
task.consecutiveMistakeCount = 0
try {
const context = task.providerRef.deref()?.context
const provider = task.providerRef.deref()
const contextProxy = provider?.contextProxy
// Try WarpGrep first if enabled
if (contextProxy) {
const codebaseIndexConfig = contextProxy.getGlobalState("codebaseIndexConfig")
const warpGrepApiKey = contextProxy.getSecret("warpGrepApiKey")
if (codebaseIndexConfig?.warpGrepEnabled && warpGrepApiKey) {
const result = await executeWarpGrepSearch(
workspacePath,
query,
warpGrepApiKey,
task.rooIgnoreController,
)
if (result.success) {
pushToolResult(`Query: ${query}\n\n${result.content}`)
return
}
// Fall through to CodeIndexManager if available
}
}
const context = provider?.context
if (!context) {
throw new Error("Extension context is not available.")
}

View file

@ -2202,6 +2202,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore,
codebaseIndexOpenRouterSpecificProvider: settings.codebaseIndexOpenRouterSpecificProvider,
warpGrepEnabled: settings.warpGrepEnabled,
}
// Save global state first
@ -2244,6 +2245,9 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
settings.codebaseIndexOpenRouterApiKey,
)
}
if (settings.warpGrepApiKey !== undefined) {
await provider.contextProxy.storeSecret("warpGrepApiKey", settings.warpGrepApiKey)
}
// Send success response first - settings are saved regardless of validation
await provider.postMessageToWebview({
@ -2382,6 +2386,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
"codebaseIndexVercelAiGatewayApiKey",
))
const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey"))
const hasWarpGrepApiKey = !!(await provider.context.secrets.get("warpGrepApiKey"))
provider.postMessageToWebview({
type: "codeIndexSecretStatus",
@ -2393,6 +2398,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
hasMistralApiKey,
hasVercelAiGatewayApiKey,
hasOpenRouterApiKey,
hasWarpGrepApiKey,
},
})
break

View file

@ -0,0 +1,447 @@
import * as childProcess from "child_process"
import * as path from "path"
import fs from "fs/promises"
import * as vscode from "vscode"
import { RooIgnoreController } from "../../core/ignore/RooIgnoreController"
import { getBinPath } from "../ripgrep"
const MAX_REPO_ENTRIES = 500
const MAX_REPO_DEPTH = 4
const MAX_READ_CHARS = 20_000
const MAX_RG_OUTPUT_CHARS = 20_000
const MAX_TOOL_CALLS_PER_TURN = 8
const MAX_TURNS = 4
const WARP_GREP_MODEL = "morph-warp-grep-v2"
const IGNORED_DIRECTORIES = new Set([".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", "out"])
export interface WarpGrepToolCall {
function: string
parameters: Record<string, string>
}
export interface WarpGrepSearchResult {
success: boolean
content: string
error?: string
}
function ensureWorkspacePath(cwd: string, targetPath: string): { absolutePath: string; relativePath: string } {
const absolutePath = path.resolve(cwd, targetPath)
const relativePath = path.relative(cwd, absolutePath)
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(`Path is outside the workspace: ${targetPath}`)
}
return { absolutePath, relativePath: relativePath || "." }
}
function truncateOutput(content: string, maxChars: number): string {
if (content.length <= maxChars) {
return content
}
return `${content.slice(0, maxChars)}\n... [truncated]`
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;")
}
function extractMessageContent(content: unknown): string {
if (typeof content === "string") {
return content
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part
}
if (part && typeof part === "object" && "text" in part && typeof part.text === "string") {
return part.text
}
return ""
})
.join("")
}
return ""
}
function parseLineRanges(lineRange?: string): Array<{ start: number; end: number }> {
if (!lineRange?.trim()) {
return []
}
return lineRange
.split(",")
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => {
const [rawStart, rawEnd] = segment.split("-").map((part) => part.trim())
const start = Number.parseInt(rawStart, 10)
const end = Number.parseInt(rawEnd || rawStart, 10)
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0 || end < start) {
throw new Error(`Invalid line range: ${segment}`)
}
return { start, end }
})
}
export async function buildRepoStructure(
cwd: string,
rooIgnoreController?: RooIgnoreController,
maxDepth: number = MAX_REPO_DEPTH,
maxEntries: number = MAX_REPO_ENTRIES,
): Promise<string> {
const lines = [path.basename(cwd) || "."]
let entryCount = 0
let wasTruncated = false
const walk = async (directoryPath: string, depth: number) => {
if (depth >= maxDepth || wasTruncated) {
return
}
const entries = await fs.readdir(directoryPath, { withFileTypes: true })
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
if (entryCount >= maxEntries) {
wasTruncated = true
return
}
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}
const absoluteEntryPath = path.join(directoryPath, entry.name)
const relativePath = path.relative(cwd, absoluteEntryPath) || entry.name
if (rooIgnoreController && !rooIgnoreController.validateAccess(relativePath)) {
continue
}
lines.push(`${" ".repeat(depth + 1)}- ${relativePath}${entry.isDirectory() ? "/" : ""}`)
entryCount += 1
if (entry.isDirectory()) {
await walk(absoluteEntryPath, depth + 1)
}
}
}
await walk(cwd, 0)
if (wasTruncated) {
lines.push(" ... [repo structure truncated]")
}
return lines.join("\n")
}
export function parseToolCalls(content: string): WarpGrepToolCall[] {
const toolCalls: WarpGrepToolCall[] = []
const toolCallRegex = /<tool_call>([\s\S]*?)<\/tool_call>/g
for (const match of content.matchAll(toolCallRegex)) {
const rawBody = match[1]
const directFunctionMatch = rawBody.match(/<function(?:=| name=)(["']?)([\w-]+)\1>/)
const blockFunctionMatch = rawBody.match(/<function>([\w-]+)<\/function>/)
const functionName = directFunctionMatch?.[2] || blockFunctionMatch?.[1]
if (!functionName) {
continue
}
const parameters: Record<string, string> = {}
const tagRegex = /<([a-zA-Z_][\w-]*)>([\s\S]*?)<\/\1>/g
for (const tagMatch of rawBody.matchAll(tagRegex)) {
const [, key, value] = tagMatch
if (key !== "function") {
parameters[key] = value.trim()
}
}
toolCalls.push({
function: functionName,
parameters,
})
}
return toolCalls
}
export async function executeRipgrep(
cwd: string,
pattern: string,
searchPath: string = ".",
glob?: string,
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
const { absolutePath, relativePath } = ensureWorkspacePath(cwd, searchPath)
if (rooIgnoreController && relativePath !== "." && !rooIgnoreController.validateAccess(relativePath)) {
throw new Error(`Path is blocked by .rooignore: ${searchPath}`)
}
const rgPath = await getBinPath(vscode.env.appRoot)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
const args = ["--line-number", "--with-filename", "--color", "never", "--no-heading"]
if (glob) {
args.push("--glob", glob)
}
args.push(pattern, absolutePath)
const result = await new Promise<string>((resolve, reject) => {
const rg = childProcess.spawn(rgPath, args, { cwd })
let stdout = ""
let stderr = ""
rg.stdout.on("data", (chunk) => {
if (stdout.length < MAX_RG_OUTPUT_CHARS) {
stdout += chunk.toString()
}
})
rg.stderr.on("data", (chunk) => {
stderr += chunk.toString()
})
rg.on("error", reject)
rg.on("close", (code) => {
if (code === 0) {
resolve(stdout)
return
}
if (code === 1) {
resolve("")
return
}
reject(new Error(stderr || `ripgrep exited with code ${code}`))
})
})
return result.trim() ? truncateOutput(result.trim(), MAX_RG_OUTPUT_CHARS) : "No matches found."
}
export async function readFile(
cwd: string,
filePath: string,
lineRange?: string,
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
const { absolutePath, relativePath } = ensureWorkspacePath(cwd, filePath)
if (rooIgnoreController && !rooIgnoreController.validateAccess(relativePath)) {
throw new Error(`Path is blocked by .rooignore: ${filePath}`)
}
const fileContents = await fs.readFile(absolutePath, "utf8")
if (!lineRange?.trim()) {
return truncateOutput(fileContents, MAX_READ_CHARS)
}
const lines = fileContents.split("\n")
const segments = parseLineRanges(lineRange).map(({ start, end }) => {
const selected = lines.slice(start - 1, end).join("\n")
return `Lines ${start}-${end}:\n${selected}`
})
return truncateOutput(segments.join("\n\n"), MAX_READ_CHARS)
}
export async function listDirectory(
cwd: string,
dirPath: string = ".",
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
const { absolutePath, relativePath } = ensureWorkspacePath(cwd, dirPath)
if (rooIgnoreController && relativePath !== "." && !rooIgnoreController.validateAccess(relativePath)) {
throw new Error(`Path is blocked by .rooignore: ${dirPath}`)
}
const entries = await fs.readdir(absolutePath, { withFileTypes: true })
const visibleEntries = entries
.filter((entry) => {
if (IGNORED_DIRECTORIES.has(entry.name)) {
return false
}
if (!rooIgnoreController) {
return true
}
const entryRelativePath = path.relative(cwd, path.join(absolutePath, entry.name))
return rooIgnoreController.validateAccess(entryRelativePath)
})
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => `${entry.isDirectory() ? "dir" : "file"} ${entry.name}`)
return visibleEntries.length > 0 ? visibleEntries.join("\n") : "(empty directory)"
}
async function executeToolCall(
cwd: string,
toolCall: WarpGrepToolCall,
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
switch (toolCall.function) {
case "ripgrep":
return executeRipgrep(
cwd,
toolCall.parameters.pattern ?? "",
toolCall.parameters.path ?? ".",
toolCall.parameters.glob,
rooIgnoreController,
)
case "read":
return readFile(cwd, toolCall.parameters.path ?? "", toolCall.parameters.lines, rooIgnoreController)
case "list_directory":
return listDirectory(cwd, toolCall.parameters.path ?? ".", rooIgnoreController)
default:
throw new Error(`Unsupported WarpGrep tool: ${toolCall.function}`)
}
}
export async function handleFinish(
cwd: string,
filesParam: string,
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
const fileSpecs = filesParam
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
if (fileSpecs.length === 0) {
return "WarpGrep finished without returning any file ranges."
}
const sections = await Promise.all(
fileSpecs.map(async (fileSpec) => {
const separatorIndex = fileSpec.indexOf(":")
const filePath = separatorIndex === -1 ? fileSpec : fileSpec.slice(0, separatorIndex)
const lineRange = separatorIndex === -1 ? undefined : fileSpec.slice(separatorIndex + 1)
const content = await readFile(cwd, filePath, lineRange, rooIgnoreController)
return `File: ${filePath}\n${content}`
}),
)
return sections.join("\n\n")
}
function buildInitialPrompt(repoStructure: string, query: string): string {
return [
"You are WarpGrep, a codebase search subagent.",
"Use the available XML tools to locate the most relevant files and return finish with precise file:line-range specs.",
"<repo_structure>",
repoStructure,
"</repo_structure>",
"<search_string>",
query,
"</search_string>",
].join("\n")
}
function buildToolResponseMessage(
toolResponses: Array<{ toolCall: WarpGrepToolCall; result: string }>,
turn: number,
): string {
return [
...toolResponses.map(
({ toolCall, result }) =>
`<tool_response><function>${toolCall.function}</function><result>${escapeXml(result)}</result></tool_response>`,
),
`<turn_context>Turn ${turn + 1} of ${MAX_TURNS}</turn_context>`,
].join("\n")
}
export async function executeWarpGrepSearch(
cwd: string,
query: string,
apiKey: string,
rooIgnoreController?: RooIgnoreController,
): Promise<WarpGrepSearchResult> {
try {
const repoStructure = await buildRepoStructure(cwd, rooIgnoreController)
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
{ role: "user", content: buildInitialPrompt(repoStructure, query) },
]
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
const response = await fetch("https://api.morphllm.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: WARP_GREP_MODEL,
temperature: 0,
max_tokens: 2048,
messages,
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`WarpGrep request failed (${response.status}): ${errorText}`)
}
const payload = await response.json()
const content = extractMessageContent(payload?.choices?.[0]?.message?.content).trim()
if (!content) {
throw new Error("WarpGrep returned an empty response")
}
const toolCalls = parseToolCalls(content).slice(0, MAX_TOOL_CALLS_PER_TURN)
if (toolCalls.length === 0) {
return { success: true, content }
}
const toolResponses: Array<{ toolCall: WarpGrepToolCall; result: string }> = []
for (const toolCall of toolCalls) {
if (toolCall.function === "finish") {
const filesParam = toolCall.parameters.files ?? ""
return {
success: true,
content: await handleFinish(cwd, filesParam, rooIgnoreController),
}
}
const result = await executeToolCall(cwd, toolCall, rooIgnoreController)
toolResponses.push({ toolCall, result })
}
messages.push({ role: "assistant", content })
messages.push({ role: "user", content: buildToolResponseMessage(toolResponses, turn) })
}
return {
success: false,
content: "",
error: `WarpGrep did not finish within ${MAX_TURNS} turns.`,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
success: false,
content: "",
error: message,
}
}
}

View file

@ -81,6 +81,9 @@ interface LocalCodeIndexSettings {
codebaseIndexVercelAiGatewayApiKey?: string
codebaseIndexOpenRouterApiKey?: string
codebaseIndexOpenRouterSpecificProvider?: string
warpGrepEnabled: boolean
warpGrepApiKey?: string
}
// Validation schema for codebase index settings
@ -225,6 +228,8 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
codebaseIndexVercelAiGatewayApiKey: "",
codebaseIndexOpenRouterApiKey: "",
codebaseIndexOpenRouterSpecificProvider: "",
warpGrepEnabled: false,
warpGrepApiKey: "",
})
// Initial settings state - stores the settings when popover opens
@ -265,6 +270,8 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
codebaseIndexOpenRouterApiKey: "",
codebaseIndexOpenRouterSpecificProvider:
codebaseIndexConfig.codebaseIndexOpenRouterSpecificProvider || "",
warpGrepEnabled: codebaseIndexConfig.warpGrepEnabled ?? false,
warpGrepApiKey: "",
}
setInitialSettings(settings)
setCurrentSettings(settings)
@ -389,6 +396,9 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
? SECRET_PLACEHOLDER
: ""
}
if (!prev.warpGrepApiKey || prev.warpGrepApiKey === SECRET_PLACEHOLDER) {
updated.warpGrepApiKey = secretStatus.hasWarpGrepApiKey ? SECRET_PLACEHOLDER : ""
}
return updated
}
@ -553,8 +563,9 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
settingsToSave[key] = value
}
// Always include codebaseIndexEnabled to ensure it's persisted
// Always include toggle states to ensure they're persisted
settingsToSave.codebaseIndexEnabled = currentSettings.codebaseIndexEnabled
settingsToSave.warpGrepEnabled = currentSettings.warpGrepEnabled
// Save settings to backend
vscode.postMessage({
@ -1590,6 +1601,40 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
)}
</div>
{/* WarpGrep Section */}
<div className="mt-4 pt-4 border-t border-vscode-dropdown-border">
<h4 className="text-sm font-semibold mb-2">WarpGrep</h4>
<p className="text-xs text-vscode-descriptionForeground mb-3">
Alternative codebase search backend that uses an agentic loop with ripgrep and file
reads to find relevant code spans.
</p>
<div className="mb-3">
<VSCodeCheckbox
checked={currentSettings.warpGrepEnabled}
onChange={(e: any) => updateSetting("warpGrepEnabled", e.target.checked)}>
<span className="font-medium">Enable WarpGrep</span>
</VSCodeCheckbox>
</div>
{currentSettings.warpGrepEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium">API Key</label>
<VSCodeTextField
type="password"
value={currentSettings.warpGrepApiKey || ""}
onInput={(e: any) => updateSetting("warpGrepApiKey", e.target.value)}
placeholder="Enter your Morph API key"
className="w-full"
/>
<p className="text-xs text-vscode-descriptionForeground mt-1 mb-0">
Get your API key at{" "}
<VSCodeLink href="https://morphllm.com" style={{ display: "inline" }}>
morphllm.com
</VSCodeLink>
</p>
</div>
)}
</div>
{/* Auto-enable default */}
{currentSettings.codebaseIndexEnabled && (
<div className="flex items-center gap-2 pt-4 pb-1">