From 4ec7c31733492c7b1239115700bccc6ab7e7c08f Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Fri, 23 May 2025 15:56:06 -0700 Subject: [PATCH] feat: Commit changes after writing (#14) * feat: Add a planning summary step in planner subgraph * feat: Commit changes after writing * cr * always check and commit after taking an action * cr --- e2b.Dockerfile | 8 + e2b.js.Dockerfile | 21 -- e2b.python.Dockerfile | 20 -- e2b.toml | 2 +- package.json | 5 +- scripts/run-e2e.ts | 94 +++++ scripts/run-from-plan.ts | 150 ++++++++ src/nodes/generate-message.ts | 2 + src/nodes/initialize.ts | 67 ++-- src/nodes/take-action.ts | 27 +- src/subgraphs/planner/index.ts | 23 +- src/subgraphs/planner/nodes/generate-plan.ts | 20 +- src/tools/apply-patch.ts | 3 + src/types.ts | 30 +- src/utils/git/index.ts | 346 +++++++++++++++++++ src/utils/read-write.ts | 34 +- src/utils/sandbox-error-fields.ts | 20 ++ src/utils/shell.ts | 15 + yarn.lock | 10 + 19 files changed, 776 insertions(+), 121 deletions(-) create mode 100644 e2b.Dockerfile delete mode 100644 e2b.js.Dockerfile delete mode 100644 e2b.python.Dockerfile create mode 100644 scripts/run-e2e.ts create mode 100644 scripts/run-from-plan.ts create mode 100644 src/utils/git/index.ts create mode 100644 src/utils/sandbox-error-fields.ts create mode 100644 src/utils/shell.ts diff --git a/e2b.Dockerfile b/e2b.Dockerfile new file mode 100644 index 00000000..d3dde88c --- /dev/null +++ b/e2b.Dockerfile @@ -0,0 +1,8 @@ +# Always start from the base E2B image +FROM e2bdev/code-interpreter:latest + +# Ensure git is installed +RUN apt-get update && apt-get install -y git curl ripgrep + +# Set the working directory +WORKDIR /app diff --git a/e2b.js.Dockerfile b/e2b.js.Dockerfile deleted file mode 100644 index 3467d170..00000000 --- a/e2b.js.Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Always start from the base E2B image -FROM e2bdev/code-interpreter:latest - -# Ensure git is installed -RUN apt-get update && apt-get install -y git curl ripgrep - -# Set the working directory -WORKDIR /app - -# Download and install nvm, then Node.js, then Yarn -ENV NVM_DIR /root/.nvm -ENV NODE_VERSION 22 -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \ - && . "$NVM_DIR/nvm.sh" \ - && nvm install "$NODE_VERSION" \ - && nvm alias default "$NODE_VERSION" \ - && nvm use default \ - && corepack enable yarn - -# Add NVM's bin to the PATH for subsequent commands -ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH diff --git a/e2b.python.Dockerfile b/e2b.python.Dockerfile deleted file mode 100644 index cf8ca786..00000000 --- a/e2b.python.Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Always start from the base E2B image -FROM e2bdev/code-interpreter:latest - -# Ensure git is installed -RUN apt-get update && apt-get install -y git curl - -# Set the working directory -WORKDIR /app - -# Ensure Python and pip are installed -RUN apt-get install -y python3 python3-pip python3-venv - -# Upgrade pip -RUN python3 -m pip install --upgrade pip - -# Install common Python development tools -RUN python3 -m pip install pytest black isort mypy - -# Set Python 3 as the default python -RUN ln -sf /usr/bin/python3 /usr/bin/python diff --git a/e2b.toml b/e2b.toml index ad62c53b..8f07f9ff 100644 --- a/e2b.toml +++ b/e2b.toml @@ -12,5 +12,5 @@ team_id = "af4eaf9c-3d40-415c-bb98-4831c47745cf" start_cmd = "/root/.jupyter/start-up.sh" -dockerfile = "e2b.js.Dockerfile" +dockerfile = "e2b.Dockerfile" template_id = "eh0860emqx28qyxmbctu" diff --git a/package.json b/package.json index 96c2b9de..74706fee 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "format:check": "prettier --check .", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$", "test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$", - "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000" + "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000", + "run:e2e": "npx tsx scripts/run-e2e.ts", + "run:from-plan": "npx tsx scripts/run-from-plan.ts" }, "dependencies": { "@e2b/code-interpreter": "^1.2.0-beta.4", @@ -25,6 +27,7 @@ "@langchain/core": "^0.3.56", "@langchain/google-genai": "^0.2.9", "@langchain/langgraph": "^0.2.73", + "@langchain/langgraph-sdk": "^0.0.78", "@langchain/openai": "^0.5.10", "diff": "^8.0.1", "langchain": "^0.3.26", diff --git a/scripts/run-e2e.ts b/scripts/run-e2e.ts new file mode 100644 index 00000000..5df0bdb1 --- /dev/null +++ b/scripts/run-e2e.ts @@ -0,0 +1,94 @@ +import "dotenv/config"; +import { Client } from "@langchain/langgraph-sdk"; +import { v4 as uuidv4 } from "uuid"; +import { GraphConfig } from "../src/types.js"; +import { HumanResponse } from "@langchain/langgraph/prebuilt"; + +async function runE2E() { + const client = new Client({ + apiKey: process.env.LANGCHAIN_API_KEY, + apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", + }); + + const threadId = uuidv4(); + + const userRequest = "ADD YOUR REQUEST HERE"; + + const configurable: Omit< + GraphConfig["configurable"], + "thread_id" | "assistant_id" + > = { + target_repository: { + owner: "YOUR_USERNAME", + repo: "YOUR_REPO", + branch: "OPTIONAL BRANCH NAME", + }, + }; + + const stream = client.runs.stream(threadId, "open-codex", { + input: { + messages: [{ role: "user", content: userRequest }], + }, + config: { + configurable, + recursion_limit: 200, + }, + ifNotExists: "create", + streamSubgraphs: true, + streamMode: "updates", + }); + + for await (const chunk of stream) { + console.dir(chunk.data, { depth: null }); + } +} + +async function resumeGraph(threadId: string) { + const client = new Client({ + apiKey: process.env.LANGCHAIN_API_KEY, + apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", + }); + + // EDIT THIS IF YOU DO NOT WANT TO ACCEPT + const resumeValue: HumanResponse[] = [ + { + type: "accept", + args: null, + }, + ]; + + const stream = client.runs.stream(threadId, "open-codex", { + command: { + resume: resumeValue, + }, + streamSubgraphs: true, + streamMode: "updates", + }); + + for await (const chunk of stream) { + console.dir(chunk.data, { depth: null }); + } +} + +const args = process.argv.slice(2); // Skip node executable and script path + +if (args.length === 0) { + runE2E().catch((error) => { + console.error("Error running E2E test:", error); + process.exit(1); + }); +} else if (args.length === 2 && args[0] === "--threadId") { + const threadId = args[1]; + resumeGraph(threadId).catch((error) => { + console.error(`Error resuming graph for thread ID ${threadId}:`, error); + process.exit(1); + }); +} else { + console.log("Usage:"); + console.log(" To run a new E2E test:"); + console.log(" yarn run:e2e"); + console.log(""); + console.log(" To resume a graph with a thread ID:"); + console.log(" yarn run:e2e --threadId "); + process.exit(1); +} diff --git a/scripts/run-from-plan.ts b/scripts/run-from-plan.ts new file mode 100644 index 00000000..7109d987 --- /dev/null +++ b/scripts/run-from-plan.ts @@ -0,0 +1,150 @@ +import "dotenv/config"; +import { Client } from "@langchain/langgraph-sdk"; +import { v4 as uuidv4 } from "uuid"; +import { GraphConfig } from "../src/types.js"; +import { graph } from "../src/index.js"; + +async function runFromPlan() { + const client = new Client({ + apiKey: process.env.LANGCHAIN_API_KEY, + apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", + }); + + const threadId = uuidv4(); + + const inputs = { + messages: [ + { + role: "user", + content: + "This repo contains the react/next.js code for my persona/portfolio site. It currently has static values set for the number of stars on the repositories I highlight. I want this to be accurate, but I do NOT want it to make requests to GitHub every time a user visits. Instead, please implement a solution which will run once a day, fetch the number of stars from a list of repos, then write them to vercel's KV store. Finally, update the UI to make a request to the KV store when the user visits my page and render the accurate star counts.", + }, + { + id: "toolu_01BDCDYr7oGxJnfgY6MjXGnx", + role: "assistant", + content: [ + { + index: 0, + type: "tool_use", + id: "toolu_01BDCDYr7oGxJnfgY6MjXGnx", + name: "condense_planning_context", + input: + '{"context": "The user has a React/Next.js portfolio site with static star counts for GitHub repositories. The project structure shows:\\n\\n1. **Current Implementation**: \\n - Main page at `./personal-site/src/app/page.tsx` contains hardcoded star values for 6 repositories:\\n - langchain-ai/langchainjs (14.3k stars)\\n - langchain-ai/langgraphjs (1.3k stars) \\n - langchain-ai/open-canvas (4.4k stars)\\n - langchain-ai/agent-inbox (496 stars)\\n - langchain-ai/agent-chat-ui (583 stars)\\n - langchain-ai/social-media-agent (1.2k stars)\\n - Stars are displayed in ProjectCard components with a stars prop\\n\\n2. **Dependencies**: \\n - Project already has `@vercel/kv` dependency installed\\n - Uses Next.js 15.2.3 with TypeScript\\n - Has existing API routes in `./personal-site/src/app/api/` including gh-viewer route that already uses Vercel KV\\n\\n3. **Requirements**:\\n - Implement daily GitHub API fetching for star counts\\n - Store results in Vercel KV store \\n - Update UI to fetch from KV store instead of static values\\n - Avoid GitHub API calls on every user visit\\n\\n4. **Repository URLs to track**:\\n - https://github.com/langchain-ai/langchainjs\\n - https://github.com/langchain-ai/langgraphjs \\n - https://github.com/langchain-ai/open-canvas\\n - https://github.com/langchain-ai/agent-inbox\\n - https://github.com/langchain-ai/agent-chat-ui\\n - https://github.com/langchain-ai/social-media-agent"}', + }, + ], + tool_calls: [ + { + name: "condense_planning_context", + args: { + context: + "The user has a React/Next.js portfolio site with static star counts for GitHub repositories. The project structure shows:\n\n1. **Current Implementation**: \n - Main page at `./personal-site/src/app/page.tsx` contains hardcoded star values for 6 repositories:\n - langchain-ai/langchainjs (14.3k stars)\n - langchain-ai/langgraphjs (1.3k stars) \n - langchain-ai/open-canvas (4.4k stars)\n - langchain-ai/agent-inbox (496 stars)\n - langchain-ai/agent-chat-ui (583 stars)\n - langchain-ai/social-media-agent (1.2k stars)\n - Stars are displayed in ProjectCard components with a stars prop\n\n2. **Dependencies**: \n - Project already has `@vercel/kv` dependency installed\n - Uses Next.js 15.2.3 with TypeScript\n - Has existing API routes in `./personal-site/src/app/api/` including gh-viewer route that already uses Vercel KV\n\n3. **Requirements**:\n - Implement daily GitHub API fetching for star counts\n - Store results in Vercel KV store \n - Update UI to fetch from KV store instead of static values\n - Avoid GitHub API calls on every user visit\n\n4. **Repository URLs to track**:\n - https://github.com/langchain-ai/langchainjs\n - https://github.com/langchain-ai/langgraphjs \n - https://github.com/langchain-ai/open-canvas\n - https://github.com/langchain-ai/agent-inbox\n - https://github.com/langchain-ai/agent-chat-ui\n - https://github.com/langchain-ai/social-media-agent", + }, + id: "toolu_01BDCDYr7oGxJnfgY6MjXGnx", + type: "tool_call", + }, + ], + }, + { + role: "tool", + tool_call_id: "toolu_01BDCDYr7oGxJnfgY6MjXGnx", + name: "condense_planning_context", + content: "Successfully summarized planning context.", + }, + ], + plan: [ + { + index: 0, + plan: "Create a list of GitHub repositories with their URLs extracted from the current static star values in page.tsx", + completed: false, + }, + { + index: 1, + plan: "Create a new API route `/api/github-stars` that fetches star counts from GitHub API for the repository list", + completed: false, + }, + { + index: 2, + plan: "Create a new API route `/api/stars` that retrieves cached star counts from Vercel KV store", + completed: false, + }, + { + index: 3, + plan: "Implement a daily cron job using Vercel Cron Jobs (vercel.json) that calls the GitHub stars API and updates the KV store", + completed: false, + }, + { + index: 4, + plan: "Update the ProjectCard component to fetch star counts from the KV store API instead of using static values", + completed: false, + }, + { + index: 5, + plan: "Add error handling and fallback to static values if KV store is unavailable", + completed: false, + }, + { + index: 6, + plan: "Update the page.tsx to remove static star values and implement dynamic fetching", + completed: false, + }, + ], + proposedPlan: [ + "Create a list of GitHub repositories with their URLs extracted from the current static star values in page.tsx", + "Create a new API route `/api/github-stars` that fetches star counts from GitHub API for the repository list", + "Create a new API route `/api/stars` that retrieves cached star counts from Vercel KV store", + "Implement a daily cron job using Vercel Cron Jobs (vercel.json) that calls the GitHub stars API and updates the KV store", + "Update the ProjectCard component to fetch star counts from the KV store API instead of using static values", + "Add error handling and fallback to static values if KV store is unavailable", + "Update the page.tsx to remove static star values and implement dynamic fetching", + ], + planChangeRequest: undefined, + sandboxSessionId: undefined, + branchName: `open-swe/${threadId}`, + }; + + const configurable: Omit< + GraphConfig["configurable"], + "thread_id" | "assistant_id" + > = { + target_repository: { + owner: "bracesproul", + repo: "personal-site", + }, + }; + + console.log("\nInitializing sandbox..."); + + const initResult = await graph.nodes.initialize.invoke(inputs as any, { + configurable, + }); + if (!initResult.sandboxSessionId) { + throw new Error("Failed to initialize sandbox."); + } + + console.log( + `Sandbox initialized successfully. ID: ${initResult.sandboxSessionId}\n\n`, + ); + + const stream = client.runs.stream(threadId, "open-codex", { + command: { + goto: "generate-action", + update: { + ...inputs, + sandboxSessionId: initResult.sandboxSessionId, + }, + }, + config: { + configurable, + recursion_limit: 200, + }, + ifNotExists: "create", + streamSubgraphs: true, + streamMode: "updates", + }); + + for await (const chunk of stream) { + console.dir(chunk.data, { depth: null }); + } +} + +runFromPlan().catch(console.error); diff --git a/src/nodes/generate-message.ts b/src/nodes/generate-message.ts index 9dd030d2..3ebea352 100644 --- a/src/nodes/generate-message.ts +++ b/src/nodes/generate-message.ts @@ -28,6 +28,7 @@ You MUST adhere to the following criteria when executing the task: - Use \`apply_patch\` to edit files. This tool accepts diffs and file paths. It will then apply the given diff to the file. - If completing the user's task requires writing or modifying files: - Your code and final answer should follow these *CODING GUIDELINES*: + - Avoid writing to files which you have not already read. - Fix the problem at the root cause rather than applying surface-level patches, when possible. - Avoid unneeded complexity in your solution. - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them. @@ -35,6 +36,7 @@ You MUST adhere to the following criteria when executing the task: - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task. - Use \`git log\` and \`git blame\` to search the history of the codebase if additional context is required; internet access is disabled. - NEVER add copyright or license headers unless specifically requested. + - If creating a new file or directory plus file, always remember to create both before trying to read/write the file. Keep in mind you can not write to files which don't exist. - You do not need to \`git commit\` your changes; this will be done automatically for you. - If there is a .pre-commit-config.yaml, use \`pre-commit run --files ...\` to check that your changes pass the pre-commit checks. However, do not fix pre-existing errors on lines you didn't touch. - If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken. diff --git a/src/nodes/initialize.ts b/src/nodes/initialize.ts index 5743ad24..a6eaace7 100644 --- a/src/nodes/initialize.ts +++ b/src/nodes/initialize.ts @@ -6,8 +6,14 @@ import { TargetRepository, } from "../types.js"; import { TIMEOUT_EXTENSION_OPT } from "../constants.js"; +import { + checkoutBranch, + configureGitUserInRepo, + getBranchName, + getRepoAbsolutePath, +} from "../utils/git/index.js"; -const JS_SANDBOX_TEMPLATE_ID = "eh0860emqx28qyxmbctu"; +const SANDBOX_TEMPLATE_ID = "eh0860emqx28qyxmbctu"; async function cloneRepo(sandbox: Sandbox, targetRepository: TargetRepository) { if (!process.env.GITHUB_PAT) { @@ -67,36 +73,47 @@ export async function initialize( } } - const { target_repository, sandbox_language } = config.configurable; + const { target_repository } = config.configurable; - if (!sandbox_language || !target_repository) { + if (!target_repository) { throw new Error( - "Missing required configuration. Please provide a sandbox language and git repository URL.", + "Missing required configuration. Please provide a git repository URL.", ); } - if (sandbox_language === "js") { - console.log("Creating JS sandbox..."); - const sandbox = await Sandbox.create( - JS_SANDBOX_TEMPLATE_ID, - TIMEOUT_EXTENSION_OPT, - ); + console.log("Creating sandbox..."); + const sandbox = await Sandbox.create( + SANDBOX_TEMPLATE_ID, + TIMEOUT_EXTENSION_OPT, + ); - const res = await cloneRepo(sandbox, target_repository); - if (res.error) { - // TODO: This should probably be an interrupt. - console.error("Failed to clone repository.", res.error); - throw new Error(`Failed to clone repository.\n${res.error}`); - } - console.log("Repository cloned successfully."); - return { - sandboxSessionId: sandbox.sandboxId, - }; + const res = await cloneRepo(sandbox, target_repository); + if (res.error) { + // TODO: This should probably be an interrupt. + console.error("Failed to clone repository.", res.error); + throw new Error(`Failed to clone repository.\n${res.error}`); + } + console.log("Repository cloned successfully."); + + const absoluteRepoDir = getRepoAbsolutePath(config); + + console.log(`Configuring git user for repository at "${absoluteRepoDir}"...`); + await configureGitUserInRepo(absoluteRepoDir, sandbox); + console.log("Git user configured successfully."); + + const checkoutBranchRes = await checkoutBranch( + absoluteRepoDir, + state.branchName ?? getBranchName(config), + sandbox, + ); + + if (!checkoutBranchRes) { + // TODO: This should probably be an interrupt. + console.error("\nFailed to checkout branch."); + throw new Error("Failed to checkout branch"); } - if (sandbox_language === "python") { - throw new Error("Python sandbox not implemented yet."); - } - - throw new Error("Unsupported sandbox language: " + sandbox_language); + return { + sandboxSessionId: sandbox.sandboxId, + }; } diff --git a/src/nodes/take-action.ts b/src/nodes/take-action.ts index c0cc45ab..5a7a6611 100644 --- a/src/nodes/take-action.ts +++ b/src/nodes/take-action.ts @@ -1,10 +1,16 @@ import { isAIMessage, ToolMessage } from "@langchain/core/messages"; import { applyPatchTool, shellTool } from "../tools/index.js"; import { GraphState, GraphConfig, GraphUpdate } from "../types.js"; +import { + checkoutBranchAndCommit, + getChangedFilesStatus, + getRepoAbsolutePath, +} from "../utils/git/index.js"; +import { Sandbox } from "@e2b/code-interpreter"; export async function takeAction( state: GraphState, - _config: GraphConfig, + config: GraphConfig, ): Promise { const lastMessage = state.messages[state.messages.length - 1]; @@ -42,7 +48,26 @@ export async function takeAction( name: toolCall.name, }); + // Always check if there are changed files after running a tool. + // If there are, commit them. + const sandbox = await Sandbox.connect(state.sandboxSessionId); + const changedFiles = await getChangedFilesStatus( + getRepoAbsolutePath(config), + sandbox, + ); + + let branchName: string | undefined = state.branchName; + if (changedFiles.length > 0) { + console.log(`\nHas ${changedFiles.length} changed files. Committing...`, { + changedFiles, + }); + branchName = await checkoutBranchAndCommit(config, sandbox, { + branchName, + }); + } + return { messages: [toolMessage], + ...(branchName && { branchName }), }; } diff --git a/src/subgraphs/planner/index.ts b/src/subgraphs/planner/index.ts index 0c42424e..1c564825 100644 --- a/src/subgraphs/planner/index.ts +++ b/src/subgraphs/planner/index.ts @@ -5,7 +5,7 @@ * Steps: */ -import { END, Send, START, StateGraph } from "@langchain/langgraph"; +import { END, START, StateGraph } from "@langchain/langgraph"; import { PlannerGraphState, PlannerGraphStateObj } from "./types.js"; import { GraphConfiguration } from "../../types.js"; import { @@ -14,11 +14,11 @@ import { summarizer, takeAction, } from "./nodes/index.js"; -import { isAIMessage, ToolMessage } from "@langchain/core/messages"; +import { isAIMessage } from "@langchain/core/messages"; function takeActionOrGeneratePlan( state: PlannerGraphState, -): "take-plan-action" | "generate-plan" | Send { +): "take-plan-action" | "generate-plan" { const { plannerMessages } = state; const lastMessage = plannerMessages[plannerMessages.length - 1]; // If the last message is a tool call, and we have executed less than 6 actions, take action. @@ -32,23 +32,6 @@ function takeActionOrGeneratePlan( return "take-plan-action"; } - if (isAIMessage(lastMessage) && lastMessage.tool_calls?.length) { - // If this is true, we need to return a `Command` adding a ToolMessage to the state - // so that the last AI message has a tool message pair - const lastMessageToolCall = lastMessage.tool_calls[0]; - return new Send("generate-plan", { - ...state, - plannerMessages: [ - ...state.plannerMessages, - new ToolMessage({ - tool_call_id: lastMessageToolCall.id ?? "", - name: lastMessageToolCall.name, - content: "Tool call not executed. Max actions reached.", - }), - ], - }); - } - // If the last message does not have tool calls, continue to generate plan without modifications. return "generate-plan"; } diff --git a/src/subgraphs/planner/nodes/generate-plan.ts b/src/subgraphs/planner/nodes/generate-plan.ts index 8e17f639..b84edf08 100644 --- a/src/subgraphs/planner/nodes/generate-plan.ts +++ b/src/subgraphs/planner/nodes/generate-plan.ts @@ -1,4 +1,8 @@ -import { isHumanMessage } from "@langchain/core/messages"; +import { + isAIMessage, + isHumanMessage, + ToolMessage, +} from "@langchain/core/messages"; import { sessionPlanTool } from "../../../tools/index.js"; import { GraphConfig } from "../../../types.js"; import { loadModel, Task } from "../../../utils/load-model.js"; @@ -29,6 +33,17 @@ export async function generatePlan( const firstUserMessage = state.messages.find(isHumanMessage); + let optionalToolMessage: ToolMessage | undefined; + const lastMessage = state.plannerMessages[state.plannerMessages.length - 1]; + if (isAIMessage(lastMessage) && lastMessage.tool_calls?.[0]) { + const lastMessageToolCall = lastMessage.tool_calls?.[0]; + optionalToolMessage = new ToolMessage({ + tool_call_id: lastMessageToolCall.id ?? "", + name: lastMessageToolCall.name, + content: "Tool call not executed. Max actions reached.", + }); + } + const response = await modelWithTools .bind({ tags: ["langsmith:nostream"] }) .invoke([ @@ -38,6 +53,7 @@ export async function generatePlan( }, ...(firstUserMessage ? [firstUserMessage] : []), ...state.plannerMessages, + ...(optionalToolMessage ? [optionalToolMessage] : []), ]); if (!response.tool_calls?.length) { @@ -54,5 +70,7 @@ export async function generatePlan( proposedPlan: response.tool_calls[0].args.plan, plan: [], ...(newSessionId && { sandboxSessionId: newSessionId }), + // Do this so that the planner state is up to date with the tool call. + ...(optionalToolMessage && { plannerMessages: [optionalToolMessage] }), }; } diff --git a/src/tools/apply-patch.ts b/src/tools/apply-patch.ts index 7475d1a3..f7b13dd8 100644 --- a/src/tools/apply-patch.ts +++ b/src/tools/apply-patch.ts @@ -34,6 +34,9 @@ export const applyPatchTool = tool( return readFileOutput; } + console.log(`\nApplying patch to file ${file_path}\n`); + console.log("\nreadFileOutput\n", readFileOutput); + console.log("\ndiff\n", diff); const patchedContent = applyPatch(readFileOutput, diff); if (patchedContent === false) { diff --git a/src/types.ts b/src/types.ts index a1716d23..bb20b61c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,13 @@ export const GraphAnnotation = z.object({ .string() .optional() .langgraph.reducer((_state, update) => update), + /** + * The name of the branch changes in this thread will be pushed to + */ + branchName: z + .string() + .optional() + .langgraph.reducer((_state, update) => update), }); export type GraphState = z.infer; @@ -143,29 +150,6 @@ export const GraphConfiguration = z.object({ }`, }, }), - /** - * The language of the sandbox to use. - */ - sandbox_language: z - .enum(["js", "python"]) - .optional() - .langgraph.metadata({ - x_oap_ui_config: { - type: "select", - default: "js", - description: "The primary language of the sandbox to use.", - options: [ - { - label: "JavaScript/TypeScript", - value: "js", - }, - { - label: "Python", - value: "python", - }, - ], - }, - }), /** * The model ID to use for the planning step. * This includes initial planning, and rewriting. diff --git a/src/utils/git/index.ts b/src/utils/git/index.ts new file mode 100644 index 00000000..5383f2b4 --- /dev/null +++ b/src/utils/git/index.ts @@ -0,0 +1,346 @@ +import { CommandResult, Sandbox } from "@e2b/code-interpreter"; +import { GraphConfig } from "../../types.js"; +import { TIMEOUT_MS } from "../../constants.js"; +import { getSandboxErrorFields } from "../sandbox-error-fields.js"; + +export function getRepoAbsolutePath(config: GraphConfig): string { + const repoName = config.configurable?.target_repository.repo; + if (!repoName) { + throw new Error("No repository name provided"); + } + + return `/home/user/${repoName}`; +} + +export function getBranchName(config: GraphConfig): string { + const threadId = config.configurable?.thread_id; + if (!threadId) { + throw new Error("No thread ID provided"); + } + + return `open-swe/${threadId}`; +} + +export async function checkoutBranch( + absoluteRepoDir: string, + branchName: string, + sandbox: Sandbox, +): Promise { + console.log("\nChecking out branch...", { + branchName, + }); + + try { + const getCurrentBranchOutput = await sandbox.commands.run( + "git branch --show-current", + { cwd: absoluteRepoDir }, + ); + await sandbox.setTimeout(TIMEOUT_MS); + + if (getCurrentBranchOutput.exitCode !== 0) { + console.error("Failed to get current branch", getCurrentBranchOutput); + } else { + const currentBranch = getCurrentBranchOutput.stdout.trim(); + if (currentBranch === branchName) { + console.log(`\nAlready on branch '${branchName}'. No checkout needed.`); + return { + stdout: `Already on branch ${branchName}`, + stderr: "", + exitCode: 0, + }; + } + } + } catch (e) { + const errorFields = getSandboxErrorFields(e); + console.error("Failed to get current branch", errorFields ?? e); + return false; + } + + let checkoutCommand: string; + try { + console.log("\nChecking if branch exists...", { + command: `git rev-parse --verify --quiet "refs/heads/${branchName}"`, + }); + // Check if branch exists using git rev-parse for robustness + const checkBranchExistsOutput = await sandbox.commands.run( + `git rev-parse --verify --quiet "refs/heads/${branchName}"`, + { cwd: absoluteRepoDir }, + ); + await sandbox.setTimeout(TIMEOUT_MS); + + if (checkBranchExistsOutput.exitCode === 0) { + // Branch exists (rev-parse exit code 0 means success) + checkoutCommand = `git checkout "${branchName}"`; + } else { + // Branch does not exist (rev-parse non-zero exit code) or other error. + // Attempt to create it. + checkoutCommand = `git checkout -b "${branchName}"`; + } + } catch (e: unknown) { + const errorFields = getSandboxErrorFields(e); + if ( + errorFields && + errorFields.exitCode === 1 && + errorFields.stderr === "" + ) { + checkoutCommand = `git checkout -b "${branchName}"`; + } else { + console.error("\nError checking if branch exists", e); + return false; + } + } + + try { + const gitCheckoutOutput = await sandbox.commands.run(checkoutCommand, { + cwd: absoluteRepoDir, + }); + + if (gitCheckoutOutput.exitCode !== 0) { + console.error("\nFailed to checkout branch", gitCheckoutOutput); + return false; + } + + console.log("\nChecked out branch successfully.", { + branchName, + gitCheckoutOutput: gitCheckoutOutput.stdout, + }); + + return gitCheckoutOutput; + } catch (e) { + const errorFields = getSandboxErrorFields(e); + console.error("Error checking out branch", errorFields ?? e); + return false; + } +} + +interface GitHubUserResponse { + login: string; + id: number; + name: string | null; + email: string | null; +} + +async function getGitUserDetailsFromGitHub(): Promise<{ + userName?: string; + userEmail?: string; +}> { + const githubToken = process.env.GITHUB_PAT; + if (!githubToken) { + console.warn( + "GITHUB_PAT environment variable is not set. Cannot fetch user details from GitHub.", + ); + return {}; + } + + try { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `token ${githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (!response.ok) { + console.error( + `Failed to fetch GitHub user info: ${response.status} ${response.statusText}. Response: ${await response.text()}`, + ); + return {}; + } + + const userData = (await response.json()) as GitHubUserResponse; + const fetchedUserName = userData.name || userData.login; + let fetchedUserEmail = userData.email; // This can be string | null + + if (!fetchedUserEmail && userData.id && userData.login) { + fetchedUserEmail = `${userData.id}+${userData.login}@users.noreply.github.com`; + } else if (!fetchedUserEmail && userData.login) { + fetchedUserEmail = `${userData.login}@users.noreply.github.com`; + } + + const finalUserName = fetchedUserName || undefined; + const finalUserEmail = fetchedUserEmail || undefined; + + if (!finalUserName) { + console.warn("Could not determine GitHub username from API response."); + } + if (!finalUserEmail) { + console.warn("Could not determine GitHub user email from API response."); + } + return { userName: finalUserName, userEmail: finalUserEmail }; + } catch (e) { + console.error("Error fetching GitHub user info:", e); + return {}; + } +} + +export async function configureGitUserInRepo( + absoluteRepoDir: string, + sandbox: Sandbox, +): Promise { + let needsGitConfig = false; + try { + const nameCheck = await sandbox.commands.run("git config user.name", { + cwd: absoluteRepoDir, + }); + await sandbox.setTimeout(TIMEOUT_MS); + const emailCheck = await sandbox.commands.run("git config user.email", { + cwd: absoluteRepoDir, + }); + await sandbox.setTimeout(TIMEOUT_MS); + + if ( + nameCheck.exitCode !== 0 || + nameCheck.stdout.trim() === "" || + emailCheck.exitCode !== 0 || + emailCheck.stdout.trim() === "" + ) { + needsGitConfig = true; + } + } catch (checkError) { + console.warn( + "Could not check existing git config, will attempt to set it:", + checkError, + ); + needsGitConfig = true; + } + + if (needsGitConfig) { + const { userName, userEmail } = await getGitUserDetailsFromGitHub(); + + if (userName) { + const configUserNameOutput = await sandbox.commands.run( + `git config user.name "${userName}"`, + { cwd: absoluteRepoDir }, + ); + await sandbox.setTimeout(TIMEOUT_MS); + if (configUserNameOutput.exitCode !== 0) { + console.error( + "Failed to set git user.name:", + configUserNameOutput.stderr || configUserNameOutput.stdout, + ); + } else { + console.log("\nSet git user.name successfully.", { + userName, + }); + } + } + + if (userEmail) { + const configUserEmailOutput = await sandbox.commands.run( + `git config user.email "${userEmail}"`, + { cwd: absoluteRepoDir }, + ); + await sandbox.setTimeout(TIMEOUT_MS); + if (configUserEmailOutput.exitCode !== 0) { + console.error( + "Failed to set git user.email:", + configUserEmailOutput.stderr || configUserEmailOutput.stdout, + ); + } else { + console.log("\nSet git user.email successfully.", { + userEmail, + }); + } + } + } else { + console.log( + "Git user.name and user.email are already configured in this repository.", + ); + } +} + +export async function commitAll( + absoluteRepoDir: string, + message: string, + sandbox: Sandbox, +): Promise { + try { + const gitAddOutput = await sandbox.commands.run( + `git add -A && git commit -m "${message}"`, + { cwd: absoluteRepoDir }, + ); + await sandbox.setTimeout(TIMEOUT_MS); + + if (gitAddOutput.exitCode !== 0) { + console.error( + "Failed to commit all changes to git repository", + gitAddOutput, + ); + } + return gitAddOutput; + } catch (e) { + console.error("Failed to commit all changes to git repository", e); + return false; + } +} + +export async function commitAllAndPush( + absoluteRepoDir: string, + message: string, + sandbox: Sandbox, +): Promise { + try { + const commitOutput = await commitAll(absoluteRepoDir, message, sandbox); + + const pushCurrentBranchCmd = + "git push -u origin $(git rev-parse --abbrev-ref HEAD)"; + + if (!commitOutput || commitOutput.exitCode !== 0) { + return false; + } + + const gitPushOutput = await sandbox.commands.run(pushCurrentBranchCmd, { + cwd: absoluteRepoDir, + }); + await sandbox.setTimeout(TIMEOUT_MS); + + if (gitPushOutput.exitCode !== 0) { + console.error("Failed to push changes to git repository", gitPushOutput); + return false; + } + + return gitPushOutput; + } catch (e) { + console.error("Failed to commit all and push changes to git repository", e); + return false; + } +} + +export async function getChangedFilesStatus( + absoluteRepoDir: string, + sandbox: Sandbox, +): Promise { + const gitStatusOutput = await sandbox.commands.run("git status --porcelain", { + cwd: absoluteRepoDir, + }); + + if (gitStatusOutput.exitCode !== 0) { + console.error("Failed to get changed files status", gitStatusOutput); + return []; + } + + return gitStatusOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); +} + +export async function checkoutBranchAndCommit( + config: GraphConfig, + sandbox: Sandbox, + options?: { + branchName?: string; + }, +): Promise { + console.log("\nChecking out branch and committing changes..."); + const absoluteRepoDir = getRepoAbsolutePath(config); + const branchName = options?.branchName || getBranchName(config); + + await checkoutBranch(absoluteRepoDir, branchName, sandbox); + + console.log(`Committing changes to branch ${branchName}`); + await commitAllAndPush(absoluteRepoDir, "Apply patch", sandbox); + console.log("Successfully checked out & committed changes.\n"); + + return branchName; +} diff --git a/src/utils/read-write.ts b/src/utils/read-write.ts index 945fce9f..58cc13e0 100644 --- a/src/utils/read-write.ts +++ b/src/utils/read-write.ts @@ -1,5 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { TIMEOUT_MS } from "../constants.js"; +import { getSandboxErrorFields } from "./sandbox-error-fields.js"; export async function readFile( sandbox: Sandbox, @@ -15,7 +16,7 @@ export async function readFile( if (readOutput.exitCode !== 0) { console.error( - `Error reading file '${filePath}' from sandbox via cat:`, + `\nError reading file '${filePath}' from sandbox via cat:`, readOutput, ); return { @@ -25,7 +26,7 @@ export async function readFile( } if (readOutput.stderr) { console.warn( - `Stderr while reading file '${filePath}' from sandbox via cat: ${readOutput.stderr}`, + `\nStderr while reading file '${filePath}' from sandbox via cat: ${readOutput.stderr}`, ); } return { @@ -34,12 +35,20 @@ export async function readFile( }; } catch (e: any) { console.error( - `Exception while trying to read file '${filePath}' from sandbox via cat:`, + `\nException while trying to read file '${filePath}' from sandbox via cat:`, e, ); + let outputMessage = `FAILED TO EXECUTE READ COMMAND for sandbox '${filePath}'.`; + const errorFields = getSandboxErrorFields(e); + if (errorFields) { + outputMessage += `\nExit code: ${errorFields.exitCode}.\nStderr: ${errorFields.stderr}.\nStdout: ${errorFields.stdout}`; + } else { + outputMessage += ` Error: ${(e as Error).message || String(e)}`; + } + return { success: false, - output: `FAILED TO EXECUTE READ COMMAND for sandbox '${filePath}'. Error: ${(e as Error).message || String(e)}`, + output: outputMessage, }; } } @@ -60,7 +69,7 @@ export async function writeFile( if (writeOutput.exitCode !== 0) { console.error( - `Error writing file '${filePath}' to sandbox via printf:`, + `\nError writing file '${filePath}' to sandbox via printf:`, writeOutput, ); return { @@ -70,7 +79,7 @@ export async function writeFile( } if (writeOutput.stderr) { console.warn( - `Stderr while writing file '${filePath}' to sandbox via printf: ${writeOutput.stderr}`, + `\nStderr while writing file '${filePath}' to sandbox via printf: ${writeOutput.stderr}`, ); } return { @@ -79,12 +88,21 @@ export async function writeFile( }; } catch (e: any) { console.error( - `Exception while trying to write file '${filePath}' to sandbox via printf:`, + `\nException while trying to write file '${filePath}' to sandbox via printf:`, e, ); + + let outputMessage = `FAILED TO EXECUTE WRITE COMMAND for sandbox '${filePath}'.`; + const errorFields = getSandboxErrorFields(e); + if (errorFields) { + outputMessage += `\nExit code: ${errorFields.exitCode}.\nStderr: ${errorFields.stderr}.\nStdout: ${errorFields.stdout}`; + } else { + outputMessage += ` Error: ${(e as Error).message || String(e)}`; + } + return { success: false, - output: `FAILED TO EXECUTE WRITE COMMAND for sandbox '${filePath}'. Error: ${(e as Error).message || String(e)}`, + output: outputMessage, }; } } diff --git a/src/utils/sandbox-error-fields.ts b/src/utils/sandbox-error-fields.ts new file mode 100644 index 00000000..d03ccf87 --- /dev/null +++ b/src/utils/sandbox-error-fields.ts @@ -0,0 +1,20 @@ +import { CommandResult } from "@e2b/code-interpreter"; + +export function getSandboxErrorFields( + error: unknown, +): CommandResult | undefined { + if ( + !error || + typeof error !== "object" || + !("result" in error) || + !error.result || + typeof error.result !== "object" || + !("exitCode" in error.result) || + !("stderr" in error.result) || + !("stdout" in error.result) + ) { + return undefined; + } + + return error.result as CommandResult; +} diff --git a/src/utils/shell.ts b/src/utils/shell.ts new file mode 100644 index 00000000..8196b853 --- /dev/null +++ b/src/utils/shell.ts @@ -0,0 +1,15 @@ +export function isWriteCommand(command: string[]): boolean { + const writeCommands = [ + "cat", + "echo", + "printf", + "tee", + "cp", + "mv", + "ln", + "install", + "rsync", + ]; + + return writeCommands.includes(command[0]); +} diff --git a/yarn.lock b/yarn.lock index 81902113..535a8cba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -934,6 +934,16 @@ yaml "^2.7.0" zod "^3.23.8" +"@langchain/langgraph-sdk@^0.0.78": + version "0.0.78" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.78.tgz#898fbd5d844e3b8a74341df89af008d878956766" + integrity sha512-skkUDmEhClWzlsr8jRaS1VpXVBISm5OFd0MUtS1jKRL5pn08K+IJRvHnlzgum9x7Dste9KXGcIGVoR7cNKJQrw== + dependencies: + "@types/json-schema" "^7.0.15" + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + "@langchain/langgraph-sdk@~0.0.32": version "0.0.74" resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.74.tgz#ef74d34d3c36437cb3d5249ea79091b8192c9b0c"