diff --git a/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts b/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts index 4c032cd0..a58b8a2a 100644 --- a/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts +++ b/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts @@ -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, diff --git a/apps/open-swe/src/graphs/planner/nodes/take-action.ts b/apps/open-swe/src/graphs/planner/nodes/take-action.ts index 2b80c4e8..faa8b4de 100644 --- a/apps/open-swe/src/graphs/planner/nodes/take-action.ts +++ b/apps/open-swe/src/graphs/planner/nodes/take-action.ts @@ -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( diff --git a/apps/open-swe/src/tools/find-instances-of.ts b/apps/open-swe/src/tools/find-instances-of.ts new file mode 100644 index 00000000..fb3a2b5d --- /dev/null +++ b/apps/open-swe/src/tools/find-instances-of.ts @@ -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, +) { + const findInstancesOfFields = createFindInstancesOfToolFields( + state.targetRepository, + ); + const formatFindInstancesOfCommand = ( + input: z.infer, + ): 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, + ): Promise<{ result: string; status: "success" | "error" }> => { + let sandbox: Sandbox | undefined; + try { + const state = getCurrentTaskInput(); + 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; +} diff --git a/apps/open-swe/src/tools/rg.ts b/apps/open-swe/src/tools/rg.ts index 610180ca..0980ab8a 100644 --- a/apps/open-swe/src/tools/rg.ts +++ b/apps/open-swe/src/tools/rg.ts @@ -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"); diff --git a/apps/open-swe/src/utils/wrap-script.ts b/apps/open-swe/src/utils/wrap-script.ts new file mode 100644 index 00000000..e58396e3 --- /dev/null +++ b/apps/open-swe/src/utils/wrap-script.ts @@ -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`; +} diff --git a/apps/web/src/components/gen-ui/action-step.tsx b/apps/web/src/components/gen-ui/action-step.tsx index ae7809a9..fc76d155 100644 --- a/apps/web/src/components/gen-ui/action-step.tsx +++ b/apps/web/src/components/gen-ui/action-step.tsx @@ -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; const getURLContentTool = createGetURLContentToolFields(); type GetURLContentToolArgs = z.infer; +const findInstancesOfTool = createFindInstancesOfToolFields(dummyRepo); +type FindInstancesOfToolArgs = z.infer; // Common props for all action types type BaseActionProps = { @@ -91,6 +102,13 @@ type GetURLContentActionProps = BaseActionProps & output?: string; }; +type FindInstancesOfActionProps = BaseActionProps & + Partial & { + 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 ( + + + {icon} + {toolNamePretty} + + + ); +} + +function MatchCaseIcon({ matchCase }: { matchCase: boolean }) { + return ( + + + +

Aa

+
+ Match case {matchCase ? "on" : "off"} +
+
+ ); +} + +function MatchWholeWordIcon({ matchWholeWord }: { matchWholeWord: boolean }) { + return ( + + + + +

ab

+
+
+
+
+
+ + Match whole word {matchWholeWord ? "on" : "off"} + +
+
+ ); +} + 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 ; + return ( + } + /> + ); } if (props.actionType === "planner_notes") { - return ; + return ( + } + /> + ); } else if (props.actionType === "install_dependencies") { - return ; + return ( + } + /> + ); } else if (props.actionType === "apply-patch") { - return ; + return ( + } + /> + ); } else if (props.actionType === "rg") { - return ; + return ( + } + /> + ); } else if (props.actionType === "get_url_content") { - return ; + return ( + } + /> + ); + } else if (props.actionType === "find_instances_of") { + return ( + } + /> + ); } else { - return ; + return ( + } + /> + ); } }; @@ -237,6 +382,32 @@ function ActionItem(props: ActionItemProps) { ); } + if (props.actionType === "find_instances_of") { + return ( +
+
+ + {props.query} + +
+ + +
+ {(props.include_files || props.exclude_files) && ( +
+ {props.include_files && ( + Include: {props.include_files} + )} + {props.include_files && props.exclude_files && | } + {props.exclude_files && ( + Exclude: {props.exclude_files} + )} +
+ )} +
+ ); + } + 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 ) { diff --git a/apps/web/src/components/thread/messages/ai.tsx b/apps/web/src/components/thread/messages/ai.tsx index 311279cb..992ee7e2 100644 --- a/apps/web/src/components/thread/messages/ai.tsx +++ b/apps/web/src/components/thread/messages/ai.tsx @@ -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; const getURLContentTool = createGetURLContentToolFields(); type GetURLContentToolArgs = z.infer; +const findInstancesOfTool = createFindInstancesOfToolFields(dummyRepo); +type FindInstancesOfToolArgs = z.infer; + 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; diff --git a/packages/shared/src/open-swe/tools.ts b/packages/shared/src/open-swe/tools.ts index 57d70f09..bb2c658b 100644 --- a/packages/shared/src/open-swe/tools.ts +++ b/packages/shared/src/open-swe/tools.ts @@ -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