[open-swe] feat: Add find_instances_of tool for comprehensive string search (#350)

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* cr

* cr

* fix

* cr

---------

Co-authored-by: open-swe-dev[bot] <open-swe-dev@users.noreply.github.com>
Co-authored-by: bracesproul <braceasproul@gmail.com>
This commit is contained in:
open-swe[bot] 2025-07-09 02:25:38 +00:00 • committed by GitHub
parent 23f72d0669
commit 77fe0e9a05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 434 additions and 19 deletions

View file

@ -22,6 +22,7 @@ import { getTaskPlanFromIssue } from "../../../../utils/github/issue-task.js";
import { createRgTool } from "../../../../tools/rg.js";
import { formatCustomRulesPrompt } from "../../../../utils/custom-rules.js";
import { createPlannerNotesTool } from "../../../../tools/planner-notes.js";
import { createFindInstancesOfTool } from "../../../../tools/find-instances-of.js";
import { getMcpTools } from "../../../../utils/mcp-client.js";
const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode");
@ -56,6 +57,7 @@ export async function generateAction(
const tools = [
createRgTool(state),
createShellTool(state),
createFindInstancesOfTool(state),
createPlannerNotesTool(),
createGetURLContentTool(),
...mcpTools,

View file

@ -19,6 +19,7 @@ import {
getChangedFilesStatus,
stashAndClearChanges,
} from "../../../utils/github/git.js";
import { createFindInstancesOfTool } from "../../../tools/find-instances-of.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { daytonaClient } from "../../../utils/sandbox.js";
import { createPlannerNotesTool } from "../../../tools/planner-notes.js";
@ -40,8 +41,8 @@ export async function takeActions(
const shellTool = createShellTool(state);
const rgTool = createRgTool(state);
const plannerNotesTool = createPlannerNotesTool();
const findInstancesOfTool = createFindInstancesOfTool(state);
const getURLContentTool = createGetURLContentTool();
const mcpTools = await getMcpTools(config);
const allTools = [
@ -49,6 +50,7 @@ export async function takeActions(
rgTool,
plannerNotesTool,
getURLContentTool,
findInstancesOfTool,
...mcpTools,
];
const toolsMap = Object.fromEntries(

View file

@ -0,0 +1,154 @@
import { tool } from "@langchain/core/tools";
import { Sandbox } from "@daytonaio/sdk";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { getCurrentTaskInput } from "@langchain/langgraph";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { daytonaClient } from "../utils/sandbox.js";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { createFindInstancesOfToolFields } from "@open-swe/shared/open-swe/tools";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { z } from "zod";
import { wrapScript } from "../utils/wrap-script.js";
const logger = createLogger(LogLevel.INFO, "FindInstancesOfTool");
const DEFAULT_ENV = {
// Prevents corepack from showing a y/n download prompt which causes the command to hang
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
export function createFindInstancesOfTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
) {
const findInstancesOfFields = createFindInstancesOfToolFields(
state.targetRepository,
);
const formatFindInstancesOfCommand = (
input: z.infer<typeof findInstancesOfFields.schema>,
): string[] => {
const args = ["rg"];
// Always include these flags for consistent output
args.push("--color", "never", "--line-number", "--heading");
// Add context lines (3 above and 3 below)
args.push("-A", "3", "-B", "3");
// Handle case sensitivity
if (!input.case_sensitive) {
args.push("-i");
}
// Handle word matching
if (input.match_word) {
args.push("--word-regexp");
}
// Handle file inclusion/exclusion patterns
if (input.exclude_files) {
args.push("-g", `!${input.exclude_files}`);
}
if (input.include_files) {
args.push("-g", input.include_files);
}
// For literal string matching (not regex)
args.push("--fixed-strings");
// Add the search query as the last argument (ensure it's properly quoted)
const formattedQuery = `'${input.query.replace(/^'|'$/g, "")}'`;
args.push(formattedQuery);
return args;
};
const findInstancesOfTool = tool(
async (
input: z.infer<typeof findInstancesOfFields.schema>,
): Promise<{ result: string; status: "success" | "error" }> => {
let sandbox: Sandbox | undefined;
try {
const state = getCurrentTaskInput<GraphState>();
const { sandboxSessionId } = state;
if (!sandboxSessionId) {
logger.error(
"FAILED TO RUN COMMAND: No sandbox session ID provided",
{
input,
},
);
throw new Error(
"FAILED TO RUN COMMAND: No sandbox session ID provided",
);
}
const repoRoot = getRepoAbsolutePath(state.targetRepository);
sandbox = await daytonaClient().get(sandboxSessionId);
const command = formatFindInstancesOfCommand(input);
logger.info("Running find_instances_of command", {
command: command.join(" "),
repoRoot,
});
const response = await sandbox.process.executeCommand(
wrapScript(command.join(" ")),
repoRoot,
DEFAULT_ENV,
TIMEOUT_SEC,
);
let successResult = response.result;
if (
response.exitCode === 1 ||
(response.exitCode === 127 && response.result.startsWith("sh: 1: "))
) {
logger.info("Exit code 1. no results found", {
...response,
});
successResult = `Exit code 1. No results found.\n\n${response.result}`;
} else if (response.exitCode > 1) {
logger.error("Failed to run find_instances_of command", {
error: response.result,
error_result: response,
input,
});
throw new Error("Command failed. Exit code: " + response.exitCode);
}
return {
result: successResult,
status: "success",
};
} catch (e) {
const errorFields = getSandboxErrorFields(e);
if (errorFields) {
logger.error("Failed to run find_instances_of command", {
input,
error: errorFields,
});
throw new Error("Command failed. Exit code: " + errorFields.exitCode);
}
logger.error(
"Failed to run find_instances_of command: " +
(e instanceof Error ? e.message : "Unknown error"),
{
error: e,
input,
},
);
throw new Error(
"FAILED TO RUN FIND_INSTANCES_OF COMMAND: " +
(e instanceof Error ? e.message : "Unknown error"),
);
}
},
findInstancesOfFields,
);
return findInstancesOfTool;
}

View file

@ -11,13 +11,7 @@ import {
formatRgCommand,
} from "@open-swe/shared/open-swe/tools";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
const wrapScript = (command: string): string => {
return `script --return --quiet -c "$(cat <<'OPEN_SWE_X'
${command}
OPEN_SWE_X
)" /dev/null`;
};
import { wrapScript } from "../utils/wrap-script.js";
const logger = createLogger(LogLevel.INFO, "RgTool");

View file

@ -0,0 +1,6 @@
export function wrapScript(command: string): string {
return `script --return --quiet -c "$(cat <<'OPEN_SWE_X'
${command}
OPEN_SWE_X
)" /dev/null`;
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { JSX, useState } from "react";
import {
Terminal,
FileCode,
@ -14,6 +14,7 @@ import {
CloudDownload,
Search,
Globe,
Hash,
} from "lucide-react";
import {
createApplyPatchToolFields,
@ -21,10 +22,18 @@ import {
createInstallDependenciesToolFields,
createTakePlannerNotesFields,
createGetURLContentToolFields,
createFindInstancesOfToolFields,
formatRgCommand,
RipgrepCommand,
} from "@open-swe/shared/open-swe/tools";
import { z } from "zod";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { cn } from "@/lib/utils";
// Used only for Zod type inference.
const dummyRepo = { owner: "dummy", repo: "dummy" };
@ -40,6 +49,8 @@ const plannerNotesTool = createTakePlannerNotesFields();
type PlannerNotesToolArgs = z.infer<typeof plannerNotesTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
const findInstancesOfTool = createFindInstancesOfToolFields(dummyRepo);
type FindInstancesOfToolArgs = z.infer<typeof findInstancesOfTool.schema>;
// Common props for all action types
type BaseActionProps = {
@ -91,6 +102,13 @@ type GetURLContentActionProps = BaseActionProps &
output?: string;
};
type FindInstancesOfActionProps = BaseActionProps &
Partial<FindInstancesOfToolArgs> & {
actionType: "find_instances_of";
output?: string;
errorCode?: number;
};
export type ActionItemProps =
| (BaseActionProps & { status: "loading" })
| ShellActionProps
@ -98,7 +116,8 @@ export type ActionItemProps =
| RgActionProps
| InstallDependenciesActionProps
| PlannerNotesActionProps
| GetURLContentActionProps;
| GetURLContentActionProps
| FindInstancesOfActionProps;
export type ActionStepProps = {
actions: ActionItemProps[];
@ -113,8 +132,88 @@ const ACTION_GENERATING_TEXT_MAP = {
[installDependenciesTool.name]: "Installing dependencies...",
[plannerNotesTool.name]: "Saving notes...",
[getURLContentTool.name]: "Fetching URL content...",
[findInstancesOfTool.name]: "Finding instances...",
};
function ToolIconWithTooltip({
toolNamePretty,
icon,
}: {
toolNamePretty: string;
icon: JSX.Element;
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent>{toolNamePretty}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function MatchCaseIcon({ matchCase }: { matchCase: boolean }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
className={cn(
"rounded-sm border border-gray-300 px-1 py-[2px] text-xs dark:border-gray-600",
matchCase
? "border-blue-500 bg-blue-500/80 text-white"
: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
)}
>
<p className="font-mono">Aa</p>
</TooltipTrigger>
<TooltipContent>Match case {matchCase ? "on" : "off"}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function MatchWholeWordIcon({ matchWholeWord }: { matchWholeWord: boolean }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
className={cn(
"rounded-sm border border-gray-300 px-1 py-[2px] text-xs dark:border-gray-600",
matchWholeWord
? "border-blue-500 bg-blue-500/80 text-white"
: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
)}
>
<span className="relative inline-block px-0.5">
<p className="relative z-10 m-0 text-center font-mono">ab</p>
<div
className={cn(
"absolute bottom-0 left-0 h-1/5 w-[1px]",
matchWholeWord ? "bg-white" : "bg-gray-600 dark:bg-gray-300",
)}
></div>
<div
className={cn(
"absolute right-0 bottom-0 left-0 h-[1px] w-full",
matchWholeWord ? "bg-white" : "bg-gray-600 dark:bg-gray-300",
)}
></div>
<div
className={cn(
"absolute right-0 bottom-0 h-1/5 w-[1px]",
matchWholeWord ? "bg-white" : "bg-gray-600 dark:bg-gray-300",
)}
></div>
</span>
</TooltipTrigger>
<TooltipContent>
Match whole word {matchWholeWord ? "on" : "off"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function ActionItem(props: ActionItemProps) {
const [expanded, setExpanded] = useState(false);
@ -159,6 +258,8 @@ function ActionItem(props: ActionItemProps) {
return props.success
? "URL content fetched"
: "Failed to fetch URL content";
} else if (props.actionType === "find_instances_of") {
return props.success ? "Search completed" : "Search failed";
}
}
@ -173,7 +274,8 @@ function ActionItem(props: ActionItemProps) {
props.actionType === "shell" ||
props.actionType === "rg" ||
props.actionType === "install_dependencies" ||
props.actionType === "get_url_content"
props.actionType === "get_url_content" ||
props.actionType === "find_instances_of"
) {
return !!props.output;
} else if (props.actionType === "apply-patch") {
@ -187,23 +289,66 @@ function ActionItem(props: ActionItemProps) {
// Render the header icon based on action type
const renderHeaderIcon = () => {
const defaultIconStyling = "text-muted-foreground mr-2 size-3.5";
if (props.status === "loading" || !("actionType" in props)) {
// In loading state, we don't know the type yet, use a generic icon
return <Loader2 className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Loading"
icon={<Loader2 className={cn(defaultIconStyling)} />}
/>
);
}
if (props.actionType === "planner_notes") {
return <FileText className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Planner Notes"
icon={<FileText className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "install_dependencies") {
return <CloudDownload className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Install Dependencies"
icon={<CloudDownload className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "apply-patch") {
return <FileCode className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Apply Patch"
icon={<FileCode className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "rg") {
return <Search className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Ripgrep (rg)"
icon={<Search className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "get_url_content") {
return <Globe className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Get URL Contents"
icon={<Globe className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "find_instances_of") {
return (
<ToolIconWithTooltip
toolNamePretty="Find Instances of"
icon={<Hash className={cn(defaultIconStyling)} />}
/>
);
} else {
return <Terminal className="text-muted-foreground mr-2 size-3.5" />;
return (
<ToolIconWithTooltip
toolNamePretty="Tool Call"
icon={<Terminal className={cn(defaultIconStyling)} />}
/>
);
}
};
@ -237,6 +382,32 @@ function ActionItem(props: ActionItemProps) {
);
}
if (props.actionType === "find_instances_of") {
return (
<div className="flex-1">
<div className="flex items-center gap-2">
<code className="text-foreground/80 text-xs font-normal">
{props.query}
</code>
<div className="bg-border h-4 w-[1px] dark:bg-white"></div>
<MatchCaseIcon matchCase={!!props.case_sensitive} />
<MatchWholeWordIcon matchWholeWord={!!props.match_word} />
</div>
{(props.include_files || props.exclude_files) && (
<div className="text-muted-foreground mt-0.5 text-xs font-normal">
{props.include_files && (
<span>Include: {props.include_files}</span>
)}
{props.include_files && props.exclude_files && <span> | </span>}
{props.exclude_files && (
<span>Exclude: {props.exclude_files}</span>
)}
</div>
)}
</div>
);
}
if (
props.actionType === "shell" ||
props.actionType === "install_dependencies"
@ -305,6 +476,7 @@ function ActionItem(props: ActionItemProps) {
if (
(props.actionType === "shell" ||
props.actionType === "rg" ||
props.actionType === "find_instances_of" ||
props.actionType === "install_dependencies") &&
props.output
) {

View file

@ -33,6 +33,7 @@ import {
createTakePlannerNotesFields,
createDiagnoseErrorToolFields,
createGetURLContentToolFields,
createFindInstancesOfToolFields,
} from "@open-swe/shared/open-swe/tools";
import { z } from "zod";
import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
@ -63,6 +64,9 @@ type DiagnoseErrorToolArgs = z.infer<typeof diagnoseErrorTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
const findInstancesOfTool = createFindInstancesOfToolFields(dummyRepo);
type FindInstancesOfToolArgs = z.infer<typeof findInstancesOfTool.schema>;
function CustomComponent({
message,
thread,
@ -199,6 +203,24 @@ export function mapToolMessageToActionStepProps(
output: getContentString(message.content),
reasoningText,
};
} else if (toolCall?.name === findInstancesOfTool.name) {
const args = toolCall.args as FindInstancesOfToolArgs;
// case_sensitive and match_word both default to true.
const caseSensitive =
args.case_sensitive === undefined ? true : args.case_sensitive;
const matchWord = args.match_word === undefined ? true : args.match_word;
return {
actionType: "find_instances_of",
status,
success,
query: args.query || "",
case_sensitive: caseSensitive,
match_word: matchWord,
include_files: args.include_files,
exclude_files: args.exclude_files,
output: getContentString(message.content),
reasoningText,
};
}
return {
status: "loading",
@ -264,7 +286,8 @@ export function AssistantMessage({
tc.name === rgTool.name ||
tc.name === installDependenciesTool.name ||
tc.name === plannerNotesTool.name ||
tc.name === getURLContentTool.name,
tc.name === getURLContentTool.name ||
tc.name === findInstancesOfTool.name,
)
: [];
@ -420,6 +443,23 @@ export function AssistantMessage({
url: args?.url || "",
output: "",
} as ActionItemProps;
} else if (toolCall.name === findInstancesOfTool.name) {
const args = toolCall.args as FindInstancesOfToolArgs;
// case_sensitive and match_word both default to true.
const caseSensitive =
args.case_sensitive === undefined ? true : args.case_sensitive;
const matchWord =
args.match_word === undefined ? true : args.match_word;
return {
actionType: "find_instances_of",
status: "generating",
query: args?.query || "",
case_sensitive: caseSensitive,
match_word: matchWord,
include_files: args?.include_files,
exclude_files: args?.exclude_files,
output: "",
} as ActionItemProps;
} else {
if (isShellTool) {
const args = toolCall.args as ShellToolArgs;

View file

@ -180,6 +180,51 @@ export function formatRgCommand(cmd: RipgrepCommand): string[] {
return args;
}
export function createFindInstancesOfToolFields(
targetRepository: TargetRepository,
) {
const repoRoot = getRepoAbsolutePath(targetRepository);
const findInstancesOfSchema = z.object({
query: z
.string()
.describe(
"The query/keyword to search for. This should be a literal string, not a regex.",
),
case_sensitive: z
.boolean()
.optional()
.default(true)
.describe(
"Whether or not to make the query search case sensitive. Defaults to true",
),
match_word: z
.boolean()
.optional()
.default(true)
.describe(
"Whether or not to only show results which match the exact keyword. Defaults to true",
),
exclude_files: z
.string()
.optional()
.describe("Glob pattern of files to exclude"),
include_files: z
.string()
.optional()
.describe("Glob pattern of files to include"),
});
return {
name: "find_instances_of",
schema: findInstancesOfSchema,
description: `Find all instances of a string in the repository. Returns results with 3 lines of context above and below each match, absolute file paths, and total result count. The working directory this command will be executed in is \`${repoRoot}\`.`,
};
}
export function createSetTaskStatusToolFields() {
const setTaskStatusToolSchema = z.object({
reasoning: z