From 0bfbf99db983684acad90cccc03e0c7a536b5409 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Fri, 13 Jun 2025 10:14:33 -0700 Subject: [PATCH] refactor: Prompt improvements (#148) * refactor: Prompt improvements * format n lint * cleanup --- .../src/nodes/generate-message/index.ts | 101 +++++++++ .../prompt.ts} | 176 +++++++-------- apps/open-swe/src/nodes/index.ts | 2 +- apps/open-swe/src/nodes/take-action.ts | 4 +- .../planner/nodes/generate-message.ts | 95 -------- .../planner/nodes/generate-message/index.ts | 72 ++++++ .../planner/nodes/generate-message/prompt.ts | 67 ++++++ .../src/subgraphs/planner/nodes/index.ts | 2 +- .../subgraphs/planner/nodes/take-action.ts | 3 +- apps/open-swe/src/tools/apply-patch.ts | 210 +++++++++--------- apps/open-swe/src/tools/shell.ts | 184 ++++++++------- apps/open-swe/src/utils/git.ts | 3 +- 12 files changed, 539 insertions(+), 380 deletions(-) create mode 100644 apps/open-swe/src/nodes/generate-message/index.ts rename apps/open-swe/src/nodes/{generate-message.ts => generate-message/prompt.ts} (72%) delete mode 100644 apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts create mode 100644 apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts create mode 100644 apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts diff --git a/apps/open-swe/src/nodes/generate-message/index.ts b/apps/open-swe/src/nodes/generate-message/index.ts new file mode 100644 index 00000000..7ead37a9 --- /dev/null +++ b/apps/open-swe/src/nodes/generate-message/index.ts @@ -0,0 +1,101 @@ +import { + GraphState, + GraphConfig, + GraphUpdate, +} from "@open-swe/shared/open-swe/types"; +import { loadModel, Task } from "../../utils/load-model.js"; +import { + createShellTool, + createApplyPatchTool, + requestHumanHelpTool, + updatePlanTool, +} from "../../tools/index.js"; +import { getRepoAbsolutePath } from "../../utils/git.js"; +import { formatPlanPrompt } from "../../utils/plan-prompt.js"; +import { stopSandbox } from "../../utils/sandbox.js"; +import { createLogger, LogLevel } from "../../utils/logger.js"; +import { getCurrentPlanItem } from "../../utils/current-task.js"; +import { getMessageContentString } from "@open-swe/shared/messages"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; +import { SYSTEM_PROMPT } from "./prompt.js"; + +const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); + +const formatPrompt = (state: GraphState): string => { + const repoDirectory = getRepoAbsolutePath(state.targetRepository); + const activePlanItems = getActivePlanItems(state.plan); + const currentPlanItem = activePlanItems + .filter((p) => !p.completed) + .sort((a, b) => a.index - b.index)[0]; + return SYSTEM_PROMPT.replaceAll( + "{PLAN_PROMPT_WITH_SUMMARIES}", + formatPlanPrompt(getActivePlanItems(state.plan), { + includeSummaries: true, + }), + ) + .replaceAll( + "{PLAN_PROMPT}", + formatPlanPrompt(getActivePlanItems(state.plan)), + ) + .replaceAll("{REPO_DIRECTORY}", repoDirectory) + .replaceAll( + "{PLAN_GENERATION_SUMMARY}", + `\n${state.planContextSummary}\n`, + ) + .replaceAll( + "{CODEBASE_TREE}", + state.codebaseTree || "No codebase tree generated yet.", + ) + .replaceAll("{CURRENT_WORKING_DIRECTORY}", repoDirectory) + .replaceAll("{CURRENT_TASK_NUMBER}", currentPlanItem.index.toString()); +}; + +export async function generateAction( + state: GraphState, + config: GraphConfig, +): Promise { + const model = await loadModel(config, Task.ACTION_GENERATOR); + const tools = [ + createShellTool(state), + createApplyPatchTool(state), + requestHumanHelpTool, + updatePlanTool, + ]; + const modelWithTools = model.bindTools(tools, { + tool_choice: "auto", + parallel_tool_calls: false, + }); + + const response = await modelWithTools.invoke([ + { + role: "system", + content: formatPrompt(state), + }, + ...state.internalMessages, + ]); + + const hasToolCalls = !!response.tool_calls?.length; + // No tool calls means the graph is going to end. Stop the sandbox. + let newSandboxSessionId: string | undefined; + if (!hasToolCalls && state.sandboxSessionId) { + logger.info("No tool calls found. Stopping sandbox..."); + newSandboxSessionId = await stopSandbox(state.sandboxSessionId); + } + + logger.info("Generated action", { + currentTask: getCurrentPlanItem(getActivePlanItems(state.plan)).plan, + ...(getMessageContentString(response.content) && { + content: getMessageContentString(response.content), + }), + ...(response.tool_calls?.[0] && { + name: response.tool_calls?.[0].name, + args: response.tool_calls?.[0].args, + }), + }); + + return { + messages: [response], + internalMessages: [response], + ...(newSandboxSessionId && { sandboxSessionId: newSandboxSessionId }), + }; +} diff --git a/apps/open-swe/src/nodes/generate-message.ts b/apps/open-swe/src/nodes/generate-message/prompt.ts similarity index 72% rename from apps/open-swe/src/nodes/generate-message.ts rename to apps/open-swe/src/nodes/generate-message/prompt.ts index 05559d84..735f1d31 100644 --- a/apps/open-swe/src/nodes/generate-message.ts +++ b/apps/open-swe/src/nodes/generate-message/prompt.ts @@ -1,27 +1,4 @@ -import { - GraphState, - GraphConfig, - GraphUpdate, -} from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; -import { - shellTool, - applyPatchTool, - requestHumanHelpTool, - updatePlanTool, -} from "../tools/index.js"; -import { getRepoAbsolutePath } from "../utils/git.js"; -import { formatPlanPrompt } from "../utils/plan-prompt.js"; -import { stopSandbox } from "../utils/sandbox.js"; -import { createLogger, LogLevel } from "../utils/logger.js"; -import { getCurrentPlanItem } from "../utils/current-task.js"; -import { getMessageContentString } from "@open-swe/shared/messages"; -import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; -import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants"; - -const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); - -const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. +export const ORIGINAL_SYSTEM_PROMPT = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. You can: - Receive user prompts, project context, and files. @@ -111,77 +88,90 @@ Once again, here are the completed tasks, remaining tasks, and the current task {PLAN_PROMPT} `; -const formatPrompt = (state: GraphState): string => { - const repoDirectory = getRepoAbsolutePath(state.targetRepository); - return systemPrompt - .replaceAll( - "{PLAN_PROMPT_WITH_SUMMARIES}", - formatPlanPrompt(getActivePlanItems(state.plan), { - includeSummaries: true, - }), - ) - .replaceAll( - "{PLAN_PROMPT}", - formatPlanPrompt(getActivePlanItems(state.plan)), - ) - .replaceAll("{REPO_DIRECTORY}", repoDirectory) - .replaceAll( - "{PLAN_GENERATION_SUMMARY}", - `\n${state.planContextSummary}\n`, - ) - .replaceAll( - "{CODEBASE_TREE}", - `\n${state.codebaseTree || "No codebase tree generated yet."}\n`, - ) - .replaceAll("{CURRENT_WORKING_DIRECTORY}", SANDBOX_ROOT_DIR); -}; +// The original system prompt, but refactored by Claude. +// Additional prompting & context from OpenAI's prompt +// engineering guide. +export const SYSTEM_PROMPT = `# Identity -export async function generateAction( - state: GraphState, - config: GraphConfig, -): Promise { - const model = await loadModel(config, Task.ACTION_GENERATOR); - const tools = [ - shellTool, - applyPatchTool, - requestHumanHelpTool, - updatePlanTool, - ]; - const modelWithTools = model.bindTools(tools, { - tool_choice: "auto", - parallel_tool_calls: false, - }); +You are a terminal-based agentic coding assistant built by LangChain. You wrap LLM models to enable natural language interaction with local codebases. You are precise, safe, and helpful. - const response = await modelWithTools.invoke([ - { - role: "system", - content: formatPrompt(state), - }, - ...state.internalMessages, - ]); +You are currently executing a specific task from a pre-generated plan. You have access to: +- Project context and files +- Shell commands and code editing tools +- A sandboxed, git-backed workspace with rollback support - const hasToolCalls = !!response.tool_calls?.length; - // No tool calls means the graph is going to end. Stop the sandbox. - let newSandboxSessionId: string | undefined; - if (!hasToolCalls && state.sandboxSessionId) { - logger.info("No tool calls found. Stopping sandbox..."); - newSandboxSessionId = await stopSandbox(state.sandboxSessionId); - } +# Instructions - logger.info("Generated action", { - currentTask: getCurrentPlanItem(getActivePlanItems(state.plan)).plan, - ...(getMessageContentString(response.content) && { - content: getMessageContentString(response.content), - }), - ...(response.tool_calls?.[0] && { - name: response.tool_calls?.[0].name, - args: response.tool_calls?.[0].args, - }), - }); +## Core Behavior - return { - messages: [response], - internalMessages: [response], - ...(newSandboxSessionId && { sandboxSessionId: newSandboxSessionId }), - }; -} +* **Persistence**: Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete. +* **Accuracy**: Never guess or make up information. Always use tools to gather accurate data about files and codebase structure. +* **Planning**: Leverage the plan context and task summaries heavily - they contain critical information about completed work and the overall strategy. + +## Task Execution Guidelines + +### Working with the Plan + +* You are executing task #{CURRENT_TASK_NUMBER} from the following plan: + - Previous completed tasks and their summaries contain crucial context - always review them first + - Condensed context messages in conversation history summarize previous work - read these to avoid duplication + - The plan generation summary provides important codebase insights + +### File and Code Management + +* **Repository location**: {REPO_DIRECTORY} +* **Current directory**: {CURRENT_WORKING_DIRECTORY} +* All changes are auto-committed - no manual commits needed +* Work only within the existing Git repository +* Use \`apply_patch\` for file edits (accepts diffs and file paths) +* Use \`shell\` with \`touch\` to create new files (not \`apply_patch\`) +* Always use \`workdir\` parameter instead of \`cd\` when running commands via the \`shell\` tool + +### Tool Usage Best Practices + +* **Search**: Use \`rg\` (not grep/ls -R) with glob patterns (e.g., \`rg -i pattern -g **/*.tsx\`) +* **Dependencies**: Use the correct package manager; skip if installation fails +* **Pre-commit**: Run \`pre-commit run --files ...\` if .pre-commit-config.yaml exists +* **History**: Use \`git log\` and \`git blame\` for additional context when needed + +### Coding Standards + +When modifying files: +* Read files before modifying them +* Fix root causes, not symptoms +* Maintain existing code style +* Update documentation as needed +* Remove unnecessary inline comments after completion +* Never add copyright/license headers unless requested +* Ignore unrelated bugs or broken tests +* Write concise and clear code. Do not write overly verbose code. + +### Communication Guidelines + +* For coding tasks: Focus on implementation and provide brief summaries + +## Special Tools + +* **request_human_help**: Use only after exhausting all attempts to gather context +* **update_plan**: Use for major plan changes (adding/removing tasks) + +# Context + + +## Generated Plan with Summaries +{PLAN_PROMPT_WITH_SUMMARIES} + +## Plan Generation Summary +{PLAN_GENERATION_SUMMARY} + +## Current Task Status +{PLAN_PROMPT} + + + +## Codebase Tree (3 levels deep, respecting .gitignore) +Generated via: \`git ls-files | tree --fromfile -L 3\` +Location: {REPO_DIRECTORY} + +{CODEBASE_TREE} +`; diff --git a/apps/open-swe/src/nodes/index.ts b/apps/open-swe/src/nodes/index.ts index 5391722c..11b40138 100644 --- a/apps/open-swe/src/nodes/index.ts +++ b/apps/open-swe/src/nodes/index.ts @@ -1,5 +1,5 @@ export * from "./initialize.js"; -export * from "./generate-message.js"; +export * from "./generate-message/index.js"; export * from "./take-action.js"; export * from "./rewrite-plan.js"; export * from "./interrupt-plan.js"; diff --git a/apps/open-swe/src/nodes/take-action.ts b/apps/open-swe/src/nodes/take-action.ts index e7eba9ab..682e4494 100644 --- a/apps/open-swe/src/nodes/take-action.ts +++ b/apps/open-swe/src/nodes/take-action.ts @@ -4,7 +4,7 @@ import { ToolMessage, } from "@langchain/core/messages"; import { createLogger, LogLevel } from "../utils/logger.js"; -import { applyPatchTool, shellTool } from "../tools/index.js"; +import { createApplyPatchTool, createShellTool } from "../tools/index.js"; import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types"; import { checkoutBranchAndCommit, @@ -53,6 +53,8 @@ export async function takeAction( throw new Error("Last message is not an AI message with tool calls."); } + const applyPatchTool = createApplyPatchTool(state); + const shellTool = createShellTool(state); const toolsMap = { [applyPatchTool.name]: applyPatchTool, [shellTool.name]: shellTool, diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts b/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts deleted file mode 100644 index d11b979d..00000000 --- a/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { loadModel, Task } from "../../../utils/load-model.js"; -import { shellTool } from "../../../tools/index.js"; -import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; -import { GraphConfig } from "@open-swe/shared/open-swe/types"; -import { createLogger, LogLevel } from "../../../utils/logger.js"; -import { getMessageContentString } from "@open-swe/shared/messages"; -import { getUserRequest } from "../../../utils/user-request.js"; -import { isHumanMessage } from "@langchain/core/messages"; -import { formatFollowupMessagePrompt } from "../utils/followup-prompt.js"; -import { getRepoAbsolutePath } from "../../../utils/git.js"; -import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants"; - -const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode"); - -const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. -{FOLLOWUP_MESSAGE_PROMPT} - -You MUST adhere to the following criteria when gathering context for the plan: -- Your ONLY job is to gather context for the plan. - - You are NOT allowed to take any write/update actions, instead you must only take read actions to gather context. - - All write/update actions will be taken in a later step, only after you've gathered all the necessary context. -- Keep in mind you are only permitted to make a maximum of 6 tool calls to gather all your context. Ensure each action is of high quality, and targeted to aid in generating a plan. -- Always use \`rg\` instead of \`grep/ls -R\` because it is much faster and respects gitignore. - - Always use glob patterns when searching with \`rg\` for specific file types. For example, to search for all TSX files, use \`rg -i star -g **/*.tsx project-directory/\`. This is because \`rg\` does not have built in file types for every language. -- If you determine you've gathered enough context to generate a plan, simply reply with 'done' and do NOT call any tools. -- Not generating a tool call will be interpreted as an indication that you've gathered enough context to generate a plan. -- The repo is already cloned, and located inside {REPO_DIRECTORY} - -Below is an up to date tree of the codebase (going 3 levels deep). This is up to date, and is updated after every action you take. Always assume this is the most up to date context about the codebase. -It was generated by using the \`tree\` command, passing in the gitignore file to ignore files and directories you should not have access to (\`git ls-files | tree --fromfile -L 3\`). It is always executed inside the repo directory: {REPO_DIRECTORY} -{CODEBASE_TREE} - -Your current working directory is: {CURRENT_WORKING_DIRECTORY} - -The user's request is the first user message in the conversation below. Ensure you generate your plan in accordance with the user's request. -`; - -function formatSystemPrompt(state: PlannerGraphState): string { - // It's a followup if there's more than one human message. - const isFollowup = state.internalMessages.filter(isHumanMessage).length > 1; - - return systemPrompt - .replace( - "{FOLLOWUP_MESSAGE_PROMPT}", - isFollowup ? formatFollowupMessagePrompt(state.plan) : "", - ) - .replaceAll("{REPO_DIRECTORY}", getRepoAbsolutePath(state.targetRepository)) - .replaceAll( - "{CODEBASE_TREE}", - `\n${state.codebaseTree || "No codebase tree generated yet."}\n`, - ) - .replaceAll("{CURRENT_WORKING_DIRECTORY}", SANDBOX_ROOT_DIR); -} - -export async function generateAction( - state: PlannerGraphState, - config: GraphConfig, -): Promise { - const model = await loadModel(config, Task.ACTION_GENERATOR); - const tools = [shellTool]; - const modelWithTools = model.bindTools(tools, { - tool_choice: "auto", - parallel_tool_calls: false, - }); - - const userRequest = getUserRequest(state.internalMessages, { - returnFullMessage: true, - }); - - const response = await modelWithTools - .withConfig({ tags: ["nostream"] }) - .invoke([ - { - role: "system", - content: formatSystemPrompt(state), - }, - userRequest, - ...state.plannerMessages, - ]); - - logger.info("Generated planning message", { - ...(getMessageContentString(response.content) && { - content: getMessageContentString(response.content), - }), - ...(response.tool_calls?.[0] && { - name: response.tool_calls?.[0].name, - args: response.tool_calls?.[0].args, - }), - }); - - return { - messages: [response], - plannerMessages: [response], - }; -} diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts b/apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts new file mode 100644 index 00000000..f7e29a56 --- /dev/null +++ b/apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts @@ -0,0 +1,72 @@ +import { loadModel, Task } from "../../../../utils/load-model.js"; +import { createShellTool } from "../../../../tools/index.js"; +import { PlannerGraphState, PlannerGraphUpdate } from "../../types.js"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { createLogger, LogLevel } from "../../../../utils/logger.js"; +import { getMessageContentString } from "@open-swe/shared/messages"; +import { getUserRequest } from "../../../../utils/user-request.js"; +import { isHumanMessage } from "@langchain/core/messages"; +import { formatFollowupMessagePrompt } from "../../utils/followup-prompt.js"; +import { getRepoAbsolutePath } from "../../../../utils/git.js"; +import { SYSTEM_PROMPT } from "./prompt.js"; + +const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode"); + +function formatSystemPrompt(state: PlannerGraphState): string { + // It's a followup if there's more than one human message. + const isFollowup = state.internalMessages.filter(isHumanMessage).length > 1; + return SYSTEM_PROMPT.replace( + "{FOLLOWUP_MESSAGE_PROMPT}", + isFollowup ? formatFollowupMessagePrompt(state.plan) : "", + ) + .replaceAll( + "{CODEBASE_TREE}", + state.codebaseTree || "No codebase tree generated yet.", + ) + .replaceAll( + "{CURRENT_WORKING_DIRECTORY}", + getRepoAbsolutePath(state.targetRepository), + ); +} + +export async function generateAction( + state: PlannerGraphState, + config: GraphConfig, +): Promise { + const model = await loadModel(config, Task.ACTION_GENERATOR); + const tools = [createShellTool(state)]; + const modelWithTools = model.bindTools(tools, { + tool_choice: "auto", + parallel_tool_calls: false, + }); + + const userRequest = getUserRequest(state.internalMessages, { + returnFullMessage: true, + }); + + const response = await modelWithTools + .withConfig({ tags: ["nostream"] }) + .invoke([ + { + role: "system", + content: formatSystemPrompt(state), + }, + userRequest, + ...state.plannerMessages, + ]); + + logger.info("Generated planning message", { + ...(getMessageContentString(response.content) && { + content: getMessageContentString(response.content), + }), + ...(response.tool_calls?.[0] && { + name: response.tool_calls?.[0].name, + args: response.tool_calls?.[0].args, + }), + }); + + return { + messages: [response], + plannerMessages: [response], + }; +} diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts b/apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts new file mode 100644 index 00000000..cfc5ec28 --- /dev/null +++ b/apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts @@ -0,0 +1,67 @@ +export const ORIGINAL_SYSTEM_PROMPT = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. +{FOLLOWUP_MESSAGE_PROMPT} + +You MUST adhere to the following criteria when gathering context for the plan: +- Your ONLY job is to gather context for the plan. + - You are NOT allowed to take any write/update actions, instead you must only take read actions to gather context. +- Ensure each tool call you generate is of an extremely high quality, and targeted to aid in generating a plan. +- Always use \`rg\` instead of \`grep/ls -R\` because it is much faster and respects gitignore. + - Always use glob patterns when searching with \`rg\` for specific file types. For example, to search for all TSX files, use \`rg -i star -g **/*.tsx project-directory/\`. This is because \`rg\` does not have built in file types for every language. +- When calling the \`shell\` tool it is incredibly important your commands are properly formatted. You should ALWAYS remember to include proper quotes, and ensure the command is formatted correctly. +- If you determine you've gathered enough context to generate a plan, simply reply with 'done' and do NOT call any tools. +- Not generating a tool call will be interpreted as an indication that you've gathered enough context to generate a plan. +- The repo is already cloned, and located inside your current working directory: {CURRENT_WORKING_DIRECTORY} + +Below is an up to date tree of the codebase (going 3 levels deep). You should assume this is always up to date. +It was generated by using the \`tree\` command, passing in the gitignore file to ignore files and directories you should not have access to (\`git ls-files | tree --fromfile -L 3\`). +It is always executed inside the repo directory (also your current working directory): {CURRENT_WORKING_DIRECTORY} +{CODEBASE_TREE} + +Your current working directory is: {CURRENT_WORKING_DIRECTORY} + +The user's request is the first user message in the conversation below. Ensure you generate your plan in accordance with the user's request. +`; + +// The original system prompt, but refactored by Claude. +// Additional prompting & context from Anthropic's prompt +// engineering guide. +export const SYSTEM_PROMPT = `You are a terminal-based agentic coding assistant built by LangChain that enables natural language interaction with local codebases. You excel at being precise, safe, and helpful in your analysis. + + +Context Gathering Assistant - Read-Only Phase + + + +Your sole objective in this phase is to gather comprehensive context about the codebase to inform plan generation. Focus on understanding the code structure, dependencies, and relevant implementation details through targeted read operations. + + +{FOLLOWUP_MESSAGE_PROMPT} + + +1. **Use only read operations**: Execute commands that inspect and analyze the codebase without modifying any files. This ensures we understand the current state before making changes. + +2. **Make high-quality, targeted tool calls**: Each command should have a clear purpose in building your understanding of the codebase. Think strategically about what information you need. + +3. **Leverage efficient search tools**: Use \`rg\` (ripgrep) for all file searches because it respects .gitignore patterns and provides significantly faster results than alternatives like grep or ls -R. + - When searching for specific file types, use glob patterns: \`rg -i pattern -g **/*.tsx project-directory/\` + - This explicit pattern matching ensures accurate results across all file extensions + +4. **Format shell commands precisely**: Ensure all shell commands include proper quoting and escaping. Well-formatted commands prevent errors and provide reliable results. + +5. **Signal completion clearly**: When you have gathered sufficient context, respond with exactly 'done' without any tool calls. This indicates readiness to proceed to the planning phase. + + + +**Current Working Directory**: {CURRENT_WORKING_DIRECTORY} +**Repository Status**: Already cloned and accessible in the current directory + +**Codebase Structure** (3 levels deep, respecting .gitignore): +Generated via: \`git ls-files | tree --fromfile -L 3\` + +{CODEBASE_TREE} + + + + +The user's request appears as the first message in the conversation below. Your context gathering should specifically target information needed to address this request effectively. +`; diff --git a/apps/open-swe/src/subgraphs/planner/nodes/index.ts b/apps/open-swe/src/subgraphs/planner/nodes/index.ts index 26a054e9..8c38ef8f 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/index.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/index.ts @@ -1,4 +1,4 @@ -export * from "./generate-message.js"; +export * from "./generate-message/index.js"; export * from "./take-action.js"; export * from "./generate-plan.js"; export * from "./summarizer.js"; diff --git a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts b/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts index a4fd3491..6e3f2abb 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts @@ -1,5 +1,5 @@ import { isAIMessage, ToolMessage } from "@langchain/core/messages"; -import { shellTool } from "../../../tools/index.js"; +import { createShellTool } from "../../../tools/index.js"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; import { createLogger, LogLevel } from "../../../utils/logger.js"; @@ -20,6 +20,7 @@ export async function takeAction( throw new Error("Last message is not an AI message with tool calls."); } + const shellTool = createShellTool(state); const toolsMap = { [shellTool.name]: shellTool, }; diff --git a/apps/open-swe/src/tools/apply-patch.ts b/apps/open-swe/src/tools/apply-patch.ts index faf74201..7383b852 100644 --- a/apps/open-swe/src/tools/apply-patch.ts +++ b/apps/open-swe/src/tools/apply-patch.ts @@ -7,124 +7,128 @@ import { getCurrentTaskInput } from "@langchain/langgraph"; import { fixGitPatch } from "../utils/diff.js"; import { createLogger, LogLevel } from "../utils/logger.js"; import { daytonaClient } from "../utils/sandbox.js"; -import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants"; +import { getRepoAbsolutePath } from "../utils/git.js"; const logger = createLogger(LogLevel.INFO, "ApplyPatchTool"); +const createApplyPatchToolDescription = (state: GraphState) => { + const repoRoot = getRepoAbsolutePath(state.targetRepository); + return ( + "Applies a diff to a file given a file path and diff content." + + `The working directory this diff will be applied to is \`${repoRoot}\`. Ensure the file paths you provide are relative to this directory.` + ); +}; + const applyPatchToolSchema = z.object({ diff: z .string() .describe( - "The diff to apply. Use a standard diff format. Ensure this field is ALWAYS provided.", + `The diff to apply. Use a standard diff format. Ensure this field is ALWAYS provided.`, ), file_path: z.string().describe("The file path to apply the diff to."), - workdir: z - .string() - .default(SANDBOX_ROOT_DIR) - .describe( - `The working directory for the command. Ensure this path is NOT included in any command arguments, as it will be added automatically. Defaults to '${SANDBOX_ROOT_DIR}' as this is the root directory of the sandbox.`, - ), }); -export const applyPatchTool = tool( - async (input): Promise<{ result: string; status: "success" | "error" }> => { - 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 { diff, file_path, workdir } = input; - - const sandbox = await daytonaClient().get(sandboxSessionId); - - const { success: readFileSuccess, output: readFileOutput } = await readFile( - sandbox, - file_path, - { - workDir: workdir, - }, - ); - if (!readFileSuccess) { - logger.error(readFileOutput); - throw new Error(readFileOutput); - } - - let patchedContent: string | false; - let fixedDiff: string | false = false; - let errorApplyingPatchMessage: string | undefined; - try { - logger.info(`Applying patch to file ${file_path}`); - patchedContent = applyPatch(readFileOutput, diff); - } catch (e) { - errorApplyingPatchMessage = e instanceof Error ? e.message : undefined; - try { - logger.warn("Failed to apply patch, trying to fix diff", { - error: e, +export function createApplyPatchTool(state: GraphState) { + const applyPatchTool = tool( + async (input): Promise<{ result: string; status: "success" | "error" }> => { + const state = getCurrentTaskInput(); + const { sandboxSessionId } = state; + if (!sandboxSessionId) { + logger.error("FAILED TO RUN COMMAND: No sandbox session ID provided", { + input, }); - const fixedDiff_ = fixGitPatch(diff, { - [file_path]: readFileOutput, - }); - patchedContent = applyPatch(readFileOutput, fixedDiff_); - logger.info("Successfully fixed diff and applied patch to file", { - file_path, - }); - if (patchedContent) { - fixedDiff = fixedDiff_; - } - } catch (_) { - logger.error("Failed to apply patch", { - ...(e instanceof Error - ? { name: e.name, message: e.message, stack: e.stack } - : { error: e }), - }); - const errMessage = e instanceof Error ? e.message : "Unknown error"; throw new Error( - `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'.\n\nError: ${errMessage}`, + "FAILED TO RUN COMMAND: No sandbox session ID provided", ); } - } - if (patchedContent === false) { - logger.error( - `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'. This may be due to an invalid diff format or conflicting changes with the file's current content. Original content length: ${readFileOutput.length}, Diff: ${diff.substring(0, 100)}...`, - ); - throw new Error( - `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'. This may be due to an invalid diff format or conflicting changes with the file's current content. Original content length: ${readFileOutput.length}, Diff: ${diff.substring(0, 100)}...`, - ); - } + const { diff, file_path } = input; + const workDir = getRepoAbsolutePath(state.targetRepository); - const { success: writeFileSuccess, output: writeFileOutput } = - await writeFile(sandbox, file_path, patchedContent, { - workDir: workdir, - }); - if (!writeFileSuccess) { - logger.error("Failed to write file", { - writeFileOutput, - }); - throw new Error(writeFileOutput); - } + const sandbox = await daytonaClient().get(sandboxSessionId); - let resultMessage = `Successfully applied diff to \`${file_path}\` and saved changes.`; - logger.info(resultMessage); - if (fixedDiff) { - resultMessage += - "\n\nNOTE: The generated diff was NOT formatted properly, and had to be fixed." + - `\nHere is the error that was thrown when your generated diff was applied:\n\n${errorApplyingPatchMessage}\n` + - `\nThe diff which was applied is:\n\n${fixedDiff}\n`; - } - return { - result: resultMessage, - status: "success", - }; - }, - { - name: "apply_patch", - description: - "Applies a diff to a file given a file path and diff content. Ensure you ALWAYS pass a valid file path to this tool. The combination of `workdir` and `file_path` should point to a valid file in the sandbox. Ensure you do not omit parts of the path between `workdir` and `file_path`.", - schema: applyPatchToolSchema, - }, -); + const { success: readFileSuccess, output: readFileOutput } = + await readFile(sandbox, file_path, { + workDir, + }); + if (!readFileSuccess) { + logger.error(readFileOutput); + throw new Error(readFileOutput); + } + + let patchedContent: string | false; + let fixedDiff: string | false = false; + let errorApplyingPatchMessage: string | undefined; + try { + logger.info(`Applying patch to file ${file_path}`); + patchedContent = applyPatch(readFileOutput, diff); + } catch (e) { + errorApplyingPatchMessage = e instanceof Error ? e.message : undefined; + try { + logger.warn("Failed to apply patch, trying to fix diff", { + error: e, + }); + const fixedDiff_ = fixGitPatch(diff, { + [file_path]: readFileOutput, + }); + patchedContent = applyPatch(readFileOutput, fixedDiff_); + logger.info("Successfully fixed diff and applied patch to file", { + file_path, + }); + if (patchedContent) { + fixedDiff = fixedDiff_; + } + } catch (_) { + logger.error("Failed to apply patch", { + ...(e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { error: e }), + }); + const errMessage = e instanceof Error ? e.message : "Unknown error"; + throw new Error( + `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'.\n\nError: ${errMessage}`, + ); + } + } + + if (patchedContent === false) { + logger.error( + `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'. This may be due to an invalid diff format or conflicting changes with the file's current content. Original content length: ${readFileOutput.length}, Diff: ${diff.substring(0, 100)}...`, + ); + throw new Error( + `FAILED TO APPLY PATCH: The diff could not be applied to file '${file_path}'. This may be due to an invalid diff format or conflicting changes with the file's current content. Original content length: ${readFileOutput.length}, Diff: ${diff.substring(0, 100)}...`, + ); + } + + const { success: writeFileSuccess, output: writeFileOutput } = + await writeFile(sandbox, file_path, patchedContent, { + workDir, + }); + if (!writeFileSuccess) { + logger.error("Failed to write file", { + writeFileOutput, + }); + throw new Error(writeFileOutput); + } + + let resultMessage = `Successfully applied diff to \`${file_path}\` and saved changes.`; + logger.info(resultMessage); + if (fixedDiff) { + resultMessage += + "\n\nNOTE: The generated diff was NOT formatted properly, and had to be fixed." + + `\nHere is the error that was thrown when your generated diff was applied:\n\n${errorApplyingPatchMessage}\n` + + `\nThe diff which was applied is:\n\n${fixedDiff}\n`; + } + return { + result: resultMessage, + status: "success", + }; + }, + { + name: "apply_patch", + description: createApplyPatchToolDescription(state), + schema: applyPatchToolSchema, + }, + ); + return applyPatchTool; +} diff --git a/apps/open-swe/src/tools/shell.ts b/apps/open-swe/src/tools/shell.ts index 6ace0f1d..a07519ad 100644 --- a/apps/open-swe/src/tools/shell.ts +++ b/apps/open-swe/src/tools/shell.ts @@ -6,7 +6,8 @@ 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 { SANDBOX_ROOT_DIR, TIMEOUT_SEC } from "@open-swe/shared/constants"; +import { TIMEOUT_SEC } from "@open-swe/shared/constants"; +import { getRepoAbsolutePath } from "../utils/git.js"; const logger = createLogger(LogLevel.INFO, "ShellTool"); @@ -15,91 +16,106 @@ const DEFAULT_ENV = { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", }; -const shellToolSchema = z.object({ - command: z.array(z.string()).describe("The command to run"), - workdir: z - .string() - .default(SANDBOX_ROOT_DIR) - .describe( - `The working directory for the command. Ensure this path is NOT included in any command arguments, as it will be added automatically. Defaults to '${SANDBOX_ROOT_DIR}' as this is the root directory of the sandbox.`, - ), - timeout: z - .number() - .optional() - .default(TIMEOUT_SEC) - .describe( - "The maximum time to wait for the command to complete in seconds.", - ), -}); +const createShellToolSchema = (state: GraphState) => { + const repoRoot = getRepoAbsolutePath(state.targetRepository); + const shellToolSchema = z.object({ + command: z + .array(z.string()) + .describe( + "The command to run. Ensure the command is properly formatted, with arguments in the correct order, and including any wrapping strings, quotes, etc. By default, this command will be executed in the root of the repository, unless a custom workdir is specified.", + ), + workdir: z + .string() + .default(repoRoot) + .describe( + `The working directory for the command. Defaults to the root of the repository (${repoRoot}). You should only specify this if the command you're running can not be executed from the root of the repository.`, + ), + timeout: z + .number() + .optional() + .default(TIMEOUT_SEC) + .describe( + "The maximum time to wait for the command to complete in seconds.", + ), + }); + return shellToolSchema; +}; -export const shellTool = tool( - async (input): 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, - }); +export function createShellTool(state: GraphState) { + const shellTool = tool( + async (input): 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", + ); + } + + sandbox = await daytonaClient().get(sandboxSessionId); + const { command, workdir, timeout } = input; + const response = await sandbox.process.executeCommand( + command.join(" "), + workdir, + DEFAULT_ENV, + timeout ?? TIMEOUT_SEC, + ); + + if (response.exitCode !== 0) { + logger.error("Failed to run command", { + error: response.result, + error_result: response, + input, + }); + throw new Error( + `Command failed. Exit code: ${response.exitCode}\nResult: ${response.result}\nStdout:\n${response.artifacts?.stdout}`, + ); + } + + return { + result: response.result, + status: "success", + }; + } catch (e) { + const errorFields = getSandboxErrorFields(e); + if (errorFields) { + logger.error("Failed to run command", { + input, + error: errorFields, + }); + throw new Error( + `Command failed. Exit code: ${errorFields.exitCode}\nError: ${errorFields.result}\nStdout:\n${errorFields.artifacts?.stdout}`, + ); + } + + logger.error( + "Failed to run command: " + + (e instanceof Error ? e.message : "Unknown error"), + { + error: e, + input, + }, + ); throw new Error( - "FAILED TO RUN COMMAND: No sandbox session ID provided", + "FAILED TO RUN COMMAND: " + + (e instanceof Error ? e.message : "Unknown error"), ); } + }, + { + name: "shell", + description: "Runs a shell command, and returns its output.", + schema: createShellToolSchema(state), + }, + ); - sandbox = await daytonaClient().get(sandboxSessionId); - const { command, workdir, timeout } = input; - const response = await sandbox.process.executeCommand( - command.join(" "), - workdir, - DEFAULT_ENV, - timeout ?? TIMEOUT_SEC, - ); - - if (response.exitCode !== 0) { - logger.error("Failed to run command", { - error: response.result, - error_result: response, - input, - }); - throw new Error( - `Command failed. Exit code: ${response.exitCode}\nResult: ${response.result}\nStdout:\n${response.artifacts?.stdout}`, - ); - } - - return { - result: response.result, - status: "success", - }; - } catch (e) { - const errorFields = getSandboxErrorFields(e); - if (errorFields) { - logger.error("Failed to run command", { - input, - error: errorFields, - }); - throw new Error( - `Command failed. Exit code: ${errorFields.exitCode}\nError: ${errorFields.result}\nStdout:\n${errorFields.artifacts?.stdout}`, - ); - } - - logger.error( - "Failed to run command: " + - (e instanceof Error ? e.message : "Unknown error"), - { - error: e, - input, - }, - ); - throw new Error( - "FAILED TO RUN COMMAND: " + - (e instanceof Error ? e.message : "Unknown error"), - ); - } - }, - { - name: "shell", - description: "Runs a shell command, and returns its output.", - schema: shellToolSchema, - }, -); + return shellTool; +} diff --git a/apps/open-swe/src/utils/git.ts b/apps/open-swe/src/utils/git.ts index 7ce68c6d..89c527d7 100644 --- a/apps/open-swe/src/utils/git.ts +++ b/apps/open-swe/src/utils/git.ts @@ -5,6 +5,7 @@ import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types"; import { TIMEOUT_SEC, SANDBOX_ROOT_DIR } from "@open-swe/shared/constants"; import { getSandboxErrorFields } from "./sandbox-error-fields.js"; import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js"; +import path from "node:path"; const logger = createLogger(LogLevel.INFO, "GitUtil"); @@ -16,7 +17,7 @@ export function getRepoAbsolutePath( throw new Error("No repository name provided"); } - return `${SANDBOX_ROOT_DIR}/${repoName}`; + return path.join(SANDBOX_ROOT_DIR, repoName); } export function getBranchName(config: GraphConfig): string {