From c599b916a821ab29e76bc9465025a6b8c79e486f Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Fri, 6 Jun 2025 17:14:15 -0700 Subject: [PATCH] feat: Support followup requests (#60) * feat: Support followup requests * cr * cr * fix * account for pr already exists --- apps/open-swe/package.json | 4 +- apps/open-swe/scripts/run-e2e.ts | 105 ------- apps/open-swe/scripts/run-from-plan.ts | 153 ---------- apps/open-swe/src/nodes/diagnose-error.ts | 28 +- .../open-swe/src/nodes/generate-conclusion.ts | 21 +- apps/open-swe/src/nodes/generate-message.ts | 20 +- apps/open-swe/src/nodes/initialize.ts | 5 +- apps/open-swe/src/nodes/interrupt-plan.ts | 58 ++-- apps/open-swe/src/nodes/open-pr.ts | 28 +- apps/open-swe/src/nodes/progress-plan-step.ts | 71 +++-- apps/open-swe/src/nodes/request-help.ts | 24 +- apps/open-swe/src/nodes/rewrite-plan.ts | 18 +- .../src/nodes/summarize-task-steps.ts | 73 +++-- apps/open-swe/src/subgraphs/planner/index.ts | 22 +- .../planner/nodes/generate-message.ts | 43 ++- .../subgraphs/planner/nodes/generate-plan.ts | 27 +- .../src/subgraphs/planner/nodes/summarizer.ts | 18 +- .../planner/utils/followup-prompt.ts | 35 +++ apps/open-swe/src/types.ts | 80 ++++- apps/open-swe/src/utils/current-task.ts | 31 +- apps/open-swe/src/utils/git.ts | 51 +++- apps/open-swe/src/utils/plan-prompt.ts | 2 +- apps/open-swe/src/utils/sandbox.ts | 24 ++ apps/open-swe/src/utils/task-plan.ts | 274 ++++++++++++++++++ apps/open-swe/src/utils/user-request.ts | 27 ++ apps/web/src/components/thread/index.tsx | 20 +- .../web/src/components/thread/messages/ai.tsx | 36 +-- .../components/thread/messages/interrupt.tsx | 31 ++ 28 files changed, 838 insertions(+), 491 deletions(-) delete mode 100644 apps/open-swe/scripts/run-e2e.ts delete mode 100644 apps/open-swe/scripts/run-from-plan.ts create mode 100644 apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts create mode 100644 apps/open-swe/src/utils/task-plan.ts create mode 100644 apps/open-swe/src/utils/user-request.ts create mode 100644 apps/web/src/components/thread/messages/interrupt.tsx diff --git a/apps/open-swe/package.json b/apps/open-swe/package.json index 39b386a5..3b11fc45 100644 --- a/apps/open-swe/package.json +++ b/apps/open-swe/package.json @@ -17,9 +17,7 @@ "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", - "run:e2e": "npx tsx scripts/run-e2e.ts", - "run:from-plan": "npx tsx scripts/run-from-plan.ts" + "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000" }, "dependencies": { "@daytonaio/sdk": "^0.18.1", diff --git a/apps/open-swe/scripts/run-e2e.ts b/apps/open-swe/scripts/run-e2e.ts deleted file mode 100644 index d553ee9f..00000000 --- a/apps/open-swe/scripts/run-e2e.ts +++ /dev/null @@ -1,105 +0,0 @@ -import "dotenv/config"; -import { Client } from "@langchain/langgraph-sdk"; -import { v4 as uuidv4 } from "uuid"; -import { HumanResponse } from "@langchain/langgraph/prebuilt"; -import { createLogger, LogLevel } from "../src/utils/logger.js"; - -const logger = createLogger(LogLevel.INFO, "E2E Script"); - -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 = `The project is structured as a monorepo, with some apps located inside the /apps directory. In this directory, there is an /auth directory. This directory only contains the scaffolding for a new app in the monorepo, but is not yet implemented. Please take the following plan/task description and implement it in the /auth directory: -This monorepo is for an AI coding agent. The app runs and edits the code in the cloud in a sandboxed environment. Right now, we require users to generate a GitHub PAT, which we store in a .env file and can use to authenticate with GitHub. This is not idea, and instead we want to have a github oauth app which users can authenticate with. -Please implement a new auth server inside the /auth directory which can do this. -You will not have any access to secrets, so you will not be able to run the server to test it. -I want the server to be able to authenticate users with GitHub, such that we will be able to take the following actions: -1. clone repositories they give us access to -2. checkout existing and create new branches on the repositories they give us access to -3. make pull requests and push changes to the repositories they give us access to -Once you're done, ensure you've documented the development process in the readme of this new app.`; - - const targetRepository = { - owner: "langchain-ai", - repo: "open-swe", - }; - - const stream = client.runs.stream(threadId, "open-swe", { - input: { - messages: [{ role: "user", content: userRequest }], - targetRepository, - }, - config: { - recursion_limit: 400, - }, - ifNotExists: "create", - streamSubgraphs: true, - streamMode: "updates", - }); - - logger.info(`Run started with thread ID: "${threadId}"`); - - for await (const chunk of stream) { - const node = Object.keys(chunk.data)[0]; - logger.info(`${node} completed.`); - } -} - -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-swe", { - command: { - resume: resumeValue, - }, - config: { - recursion_limit: 400, - }, - streamSubgraphs: true, - streamMode: "updates", - }); - - for await (const chunk of stream) { - const node = Object.keys(chunk.data)[0]; - logger.info(`${node} completed.\n`); - } -} - -const args = process.argv.slice(2); // Skip node executable and script path - -if (args.length === 0) { - runE2E().catch((error) => { - logger.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) => { - logger.error(`Error resuming graph for thread ID ${threadId}:`, error); - process.exit(1); - }); -} else { - logger.info("Usage:"); - logger.info(" To run a new E2E test:"); - logger.info(" yarn run:e2e"); - logger.info(""); - logger.info(" To resume a graph with a thread ID:"); - logger.info(" yarn run:e2e --threadId "); - process.exit(1); -} diff --git a/apps/open-swe/scripts/run-from-plan.ts b/apps/open-swe/scripts/run-from-plan.ts deleted file mode 100644 index 8aa00657..00000000 --- a/apps/open-swe/scripts/run-from-plan.ts +++ /dev/null @@ -1,153 +0,0 @@ -import "dotenv/config"; -import { Client } from "@langchain/langgraph-sdk"; -import { v4 as uuidv4 } from "uuid"; -import { graph } from "../src/index.js"; -import { createLogger, LogLevel } from "../src/utils/logger.js"; -import { SANDBOX_ROOT_DIR } from "../src/constants.js"; - -const logger = createLogger(LogLevel.INFO, "From Plan Script"); - -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 targetRepository = { - owner: "langchain-ai", - repo: "open-swe", - }; - - const inputs = { - targetRepository, - messages: [ - { - role: "user", - content: `The project is structured as a monorepo, with some apps located inside the /apps directory. In this directory, there is an /auth directory. This directory only contains the scaffolding for a new app in the monorepo, but is not yet implemented. Please take the following plan/task description and implement it in the /auth directory: -This monorepo is for an AI coding agent. The app runs and edits the code in the cloud in a sandboxed environment. Right now, we require users to generate a GitHub PAT, which we store in a .env file and can use to authenticate with GitHub. This is not idea, and instead we want to have a github oauth app which users can authenticate with. -Please implement a new auth server inside the /auth directory which can do this. -You will not have any access to secrets, so you will not be able to run the server to test it. -I want the server to be able to authenticate users with GitHub, such that we will be able to take the following actions: -1. clone repositories they give us access to -2. checkout existing and create new branches on the repositories they give us access to -3. make pull requests and push changes to the repositories they give us access to -Once you're done, ensure you've documented the development process in the readme of this new app.`, - }, - ], - plan: [ - { - index: 0, - plan: "Set up the Express.js server with TypeScript configuration and necessary dependencies for GitHub OAuth authentication", - completed: false, - summary: undefined, - }, - { - index: 1, - plan: "Implement GitHub OAuth flow endpoints including authorization redirect and callback handling", - completed: false, - summary: undefined, - }, - { - index: 2, - plan: "Create middleware for JWT token generation and validation for authenticated sessions", - completed: false, - summary: undefined, - }, - { - index: 3, - plan: "Add environment configuration management for OAuth app credentials and server settings", - completed: false, - summary: undefined, - }, - { - index: 4, - plan: "Add comprehensive error handling throughout the authentication flow", - completed: false, - summary: undefined, - }, - { - index: 5, - plan: "Create comprehensive README documentation covering setup, configuration, and development process", - completed: false, - summary: undefined, - }, - ], - proposedPlan: [ - "Set up the Express.js server with TypeScript configuration and necessary dependencies for GitHub OAuth authentication", - "Implement GitHub OAuth flow endpoints including authorization redirect and callback handling", - "Create middleware for JWT token generation and validation for authenticated sessions", - "Add environment configuration management for OAuth app credentials and server settings", - "Add comprehensive error handling throughout the authentication flow", - "Create comprehensive README documentation covering setup, configuration, and development process", - ], - planContextSummary: `## User Request Summary -The user wants to implement a GitHub OAuth authentication server in the \`/apps/auth\` directory of a monorepo for an AI coding agent. The goal is to replace the current GitHub PAT authentication system with OAuth to enable: -1. Cloning repositories users give access to -2. Checking out existing and creating new branches -3. Making pull requests and pushing changes - -## Codebase Files and Descriptions -- **Project root**: \`${SANDBOX_ROOT_DIR}/open-swe/\` - Main monorepo directory -- **Apps directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/\` - Contains multiple apps including auth, docs, and open-swe -- **Auth app directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/\` - Target directory for implementation, currently contains only scaffolding -- **Auth package.json**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/package.json\` - Contains basic TypeScript/Node.js setup with name "@open-swe/auth", includes dev dependencies for TypeScript, Jest, ESLint, Prettier -- **Auth src directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/src/\` - Contains only an empty \`index.ts\` file -- **Auth config files**: Directory includes standard config files (.gitignore, .dockerignore, .prettierrc, eslint.config.js, jest.config.js, tsconfig.json, turbo.json) - -## Key Repository Insights and Learnings -- The monorepo uses Yarn as package manager (version 3.5.1) -- TypeScript is used throughout with version ~5.7.2 -- The auth app is set up as an ES module (type: "module" in package.json) -- Standard tooling includes ESLint, Prettier, Jest for testing -- The auth directory is completely empty except for scaffolding - no existing implementation -- The project appears to be part of the LangChain AI organization based on repository URL -- No access to secrets/environment variables for testing -- Need to document the development process in a README for the auth app - -## Implementation Requirements -- Implement GitHub OAuth flow for authentication -- Ensure the server can handle repository operations (clone, branch management, PR creation) -- Create comprehensive documentation in README -- Follow existing monorepo patterns and tooling setup`, - codebaseContext: "", - planChangeRequest: undefined, - sandboxSessionId: undefined, - branchName: `open-swe/${threadId}`, - }; - - logger.info("Initializing sandbox..."); - - const initResult = await graph.nodes.initialize.invoke(inputs as any); - if (!initResult.sandboxSessionId) { - throw new Error("Failed to initialize sandbox."); - } - - logger.info( - `Sandbox initialized successfully. ID: ${initResult.sandboxSessionId}\n\n`, - ); - - const stream = client.runs.stream(threadId, "open-swe", { - command: { - goto: "generate-action", - update: { - ...inputs, - sandboxSessionId: initResult.sandboxSessionId, - }, - }, - config: { - recursion_limit: 400, - }, - ifNotExists: "create", - streamSubgraphs: true, - streamMode: "updates", - }); - - for await (const chunk of stream) { - const node = Object.keys(chunk.data)[0]; - logger.info(`${node} completed.\n`); - } -} - -runFromPlan().catch(logger.error); diff --git a/apps/open-swe/src/nodes/diagnose-error.ts b/apps/open-swe/src/nodes/diagnose-error.ts index f564c653..03486230 100644 --- a/apps/open-swe/src/nodes/diagnose-error.ts +++ b/apps/open-swe/src/nodes/diagnose-error.ts @@ -12,24 +12,14 @@ import { import { loadModel, Task } from "../utils/load-model.js"; import { z } from "zod"; import { createLogger, LogLevel } from "../utils/logger.js"; +import { + getCompletedPlanItems, + getCurrentPlanItem, +} from "../utils/current-task.js"; +import { getActivePlanItems } from "../utils/task-plan.js"; const logger = createLogger(LogLevel.INFO, "DiagnoseError"); -/** - * Whether or not enough errored tool calls have occurred to interrupt the graph. - * This will return true if the last tool call was an error, and 7 of the last 10 - * tool calls have been errors. - * @param toolMessages - * - * @TODO Implement this. Should interrupt after generating a diagnosis for 7 consecutive errors. - */ -// function shouldInterruptError(toolMessages: ToolMessage[]): boolean { -// if (toolMessages[toolMessages.length - 1].status !== "error") { -// return false; -// } -// return toolMessages.slice(-10).filter((m) => m.status === "error").length >= 7; -// } - 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. The last command you tried to execute failed with an error. Please carefully diagnose the error, and provide a helpful explanation of exactly what the issue is, and how you can fix it. @@ -77,8 +67,8 @@ const formatSystemPrompt = ( plan: PlanItem[], codebaseContext: string, ): string => { - const currentTask = plan.find((p) => !p.completed); - const completedTasks = plan.filter((p) => p.completed); + const currentPlanItem = getCurrentPlanItem(plan); + const completedTasks = getCompletedPlanItems(plan); return systemPrompt .replace( @@ -87,7 +77,7 @@ const formatSystemPrompt = ( ) .replace( "{CURRENT_TASK}", - `${currentTask?.plan}`, + `${currentPlanItem.plan}`, ) .replace("{PLAN_PROMPT}", formatPlanPromptWithSummaries(completedTasks)) .replace("{CODEBASE_CONTEXT}", codebaseContext); @@ -123,7 +113,7 @@ export async function diagnoseError( role: "system", content: formatSystemPrompt( getMessageContentString(lastFailedAction.content), - state.plan, + getActivePlanItems(state.plan), state.codebaseContext, ), }, diff --git a/apps/open-swe/src/nodes/generate-conclusion.ts b/apps/open-swe/src/nodes/generate-conclusion.ts index 8d4c48f2..d45ae9e7 100644 --- a/apps/open-swe/src/nodes/generate-conclusion.ts +++ b/apps/open-swe/src/nodes/generate-conclusion.ts @@ -1,4 +1,3 @@ -import { isHumanMessage } from "@langchain/core/messages"; import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js"; import { loadModel, Task } from "../utils/load-model.js"; import { @@ -6,6 +5,12 @@ import { getMessageString, } from "../utils/message/content.js"; import { createLogger, LogLevel } from "../utils/logger.js"; +import { getUserRequest } from "../utils/user-request.js"; +import { + completeTask, + getActivePlanItems, + getActiveTask, +} from "../utils/task-plan.js"; const logger = createLogger(LogLevel.INFO, "GenerateConclusionNode"); @@ -31,10 +36,9 @@ export async function generateConclusion( ): Promise { const model = await loadModel(config, Task.SUMMARIZER); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages); const userMessage = `The user's initial request is as follows: -${getMessageContentString(firstUserMessage?.content || "No user message found")} +${userRequest || "No user message found"} The conversation history is as follows: ${state.messages.map(getMessageString).join("\n")} @@ -46,7 +50,7 @@ Given all of this, please respond with the concise conclusion. Do not include an const response = await model.invoke([ { role: "system", - content: formatPrompt(state.plan), + content: formatPrompt(getActivePlanItems(state.plan)), }, { role: "user", @@ -55,8 +59,15 @@ Given all of this, please respond with the concise conclusion. Do not include an ]); logger.info("✅ Successfully generated conclusion. Ending run. 👋"); + const activeTaskId = getActiveTask(state.plan).id; + const updatedTaskPlan = completeTask( + state.plan, + activeTaskId, + getMessageContentString(response.content), + ); return { messages: [response], + plan: updatedTaskPlan, }; } diff --git a/apps/open-swe/src/nodes/generate-message.ts b/apps/open-swe/src/nodes/generate-message.ts index 5810d1f6..1ad2b457 100644 --- a/apps/open-swe/src/nodes/generate-message.ts +++ b/apps/open-swe/src/nodes/generate-message.ts @@ -9,8 +9,9 @@ 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 { getCurrentTask } from "../utils/current-task.js"; +import { getCurrentPlanItem } from "../utils/current-task.js"; import { getMessageContentString } from "../utils/message/content.js"; +import { getActivePlanItems } from "../utils/task-plan.js"; const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); @@ -103,9 +104,14 @@ const formatPrompt = (state: GraphState): string => { return systemPrompt .replaceAll( "{PLAN_PROMPT_WITH_SUMMARIES}", - formatPlanPrompt(state.plan, { includeSummaries: true }), + formatPlanPrompt(getActivePlanItems(state.plan), { + includeSummaries: true, + }), + ) + .replaceAll( + "{PLAN_PROMPT}", + formatPlanPrompt(getActivePlanItems(state.plan)), ) - .replaceAll("{PLAN_PROMPT}", formatPlanPrompt(state.plan)) .replaceAll("{REPO_DIRECTORY}", repoDirectory) .replaceAll( "{PLAN_GENERATION_SUMMARY}", @@ -142,14 +148,14 @@ export async function generateAction( } logger.info("Generated action", { - currentTask: getCurrentTask(state.plan).plan, + 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, }), - ...(getMessageContentString(response.content) && { - content: getMessageContentString(response.content), - }), }); return { diff --git a/apps/open-swe/src/nodes/initialize.ts b/apps/open-swe/src/nodes/initialize.ts index cba56ac6..f773428d 100644 --- a/apps/open-swe/src/nodes/initialize.ts +++ b/apps/open-swe/src/nodes/initialize.ts @@ -50,7 +50,10 @@ export async function initialize( image: SNAPSHOT_NAME, }); - const res = await cloneRepo(sandbox, targetRepository, { githubToken }); + const res = await cloneRepo(sandbox, targetRepository, { + githubToken, + stateBranchName: state.branchName, + }); if (res.exitCode !== 0) { // TODO: This should probably be an interrupt. logger.error("Failed to clone repository", res.result); diff --git a/apps/open-swe/src/nodes/interrupt-plan.ts b/apps/open-swe/src/nodes/interrupt-plan.ts index 7703b5bf..a1192796 100644 --- a/apps/open-swe/src/nodes/interrupt-plan.ts +++ b/apps/open-swe/src/nodes/interrupt-plan.ts @@ -1,11 +1,13 @@ import { Command, END, interrupt } from "@langchain/langgraph"; -import { GraphState } from "../types.js"; +import { GraphState, GraphUpdate } from "../types.js"; import { ActionRequest, HumanInterrupt, HumanResponse, } from "@langchain/langgraph/prebuilt"; import { startSandbox } from "../utils/sandbox.js"; +import { createNewTask } from "../utils/task-plan.js"; +import { getUserRequest } from "../utils/user-request.js"; export async function interruptPlan(state: GraphState): Promise { const { proposedPlan } = state; @@ -35,21 +37,27 @@ export async function interruptPlan(state: GraphState): Promise { throw new Error("No sandbox session ID found."); } + const userRequest = getUserRequest(state.messages); + if (interruptRes.type === "accept") { const newSandboxSessionId = (await startSandbox(state.sandboxSessionId)).id; // Plan was accepted, route to the generate-action node to start taking actions. + const planItems = proposedPlan.map((p, index) => ({ + index, + plan: p, + completed: false, + })); + + const newTaskPlan = createNewTask(userRequest, planItems, state.plan); + + const commandUpdate: GraphUpdate = { + plan: newTaskPlan, + sandboxSessionId: newSandboxSessionId, + }; return new Command({ goto: "generate-action", - update: { - plan: proposedPlan.map((p, index) => ({ - index, - plan: p, - completed: false, - summary: undefined, - })), - sandboxSessionId: newSandboxSessionId, - }, + update: commandUpdate, }); } @@ -60,27 +68,33 @@ export async function interruptPlan(state: GraphState): Promise { const editedPlan = (interruptRes.args as ActionRequest).args.plan .split(":::") .map((step: string) => step.trim()); + + const planItems = editedPlan.map((p: string, index: number) => ({ + index, + plan: p, + completed: false, + })); + + const newTaskPlan = createNewTask(userRequest, planItems, state.plan); + + const commandUpdate: GraphUpdate = { + plan: newTaskPlan, + sandboxSessionId: newSandboxSessionId, + }; return new Command({ goto: "generate-action", - update: { - plan: editedPlan.map((p: string, index: number) => ({ - index, - plan: p, - completed: false, - summary: undefined, - })), - sandboxSessionId: newSandboxSessionId, - }, + update: commandUpdate, }); } if (interruptRes.type === "response") { // Plan was responded to, route to the rewrite plan node. + const commandUpdate: GraphUpdate = { + planChangeRequest: interruptRes.args as string, + }; return new Command({ goto: "rewrite-plan", - update: { - planChangeRequest: interruptRes.args as string, - }, + update: commandUpdate, }); } diff --git a/apps/open-swe/src/nodes/open-pr.ts b/apps/open-swe/src/nodes/open-pr.ts index 3cf72b43..b503e66d 100644 --- a/apps/open-swe/src/nodes/open-pr.ts +++ b/apps/open-swe/src/nodes/open-pr.ts @@ -10,10 +10,11 @@ import { createLogger, LogLevel } from "../utils/logger.js"; import { z } from "zod"; import { loadModel, Task } from "../utils/load-model.js"; import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js"; -import { isHumanMessage, ToolMessage } from "@langchain/core/messages"; -import { getMessageContentString } from "../utils/message/content.js"; -import { daytonaClient } from "../utils/sandbox.js"; +import { getUserRequest } from "../utils/user-request.js"; +import { ToolMessage } from "@langchain/core/messages"; +import { daytonaClient, deleteSandbox } from "../utils/sandbox.js"; import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; +import { getActivePlanItems } from "../utils/task-plan.js"; const logger = createLogger(LogLevel.INFO, "Open PR"); @@ -102,20 +103,11 @@ export async function openPullRequest( tool_choice: openPrTool.name, }); - const firstUserMessage = state.messages.find(isHumanMessage); - if (!firstUserMessage) { - throw new Error( - "Failed to open pull request: No user message found in state.", - ); - } - + const userRequest = getUserRequest(state.messages); const response = await modelWithTool.invoke([ { role: "user", - content: formatPrompt( - state.plan, - getMessageContentString(firstUserMessage.content), - ), + content: formatPrompt(getActivePlanItems(state.plan), userRequest), }, ]); @@ -138,6 +130,12 @@ export async function openPullRequest( githubToken, }); + let sandboxDeleted = false; + if (pr) { + // Delete the sandbox. + sandboxDeleted = await deleteSandbox(sandboxSessionId); + } + return { messages: [ response, @@ -152,5 +150,7 @@ export async function openPullRequest( }, }), ], + // If the sandbox was successfully deleted, we can remove it from the state. + ...(sandboxDeleted && { sandboxSessionId: undefined }), }; } diff --git a/apps/open-swe/src/nodes/progress-plan-step.ts b/apps/open-swe/src/nodes/progress-plan-step.ts index 294febcb..fded9edd 100644 --- a/apps/open-swe/src/nodes/progress-plan-step.ts +++ b/apps/open-swe/src/nodes/progress-plan-step.ts @@ -1,12 +1,22 @@ import { z } from "zod"; import { createLogger, LogLevel } from "../utils/logger.js"; -import { GraphConfig, GraphState, PlanItem } from "../types.js"; +import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js"; import { loadModel, Task } from "../utils/load-model.js"; import { formatPlanPrompt } from "../utils/plan-prompt.js"; import { Command } from "@langchain/langgraph"; import { getMessageString } from "../utils/message/content.js"; -import { isHumanMessage } from "@langchain/core/messages"; import { removeFirstHumanMessage } from "../utils/message/modify-array.js"; +import { getUserRequest } from "../utils/user-request.js"; +import { + completePlanItem, + getActivePlanItems, + getActiveTask, +} from "../utils/task-plan.js"; +import { + getCurrentPlanItem, + getRemainingPlanItems, +} from "../utils/current-task.js"; +import { ToolMessage } from "@langchain/core/messages"; const logger = createLogger(LogLevel.INFO, "ProgressPlanStep"); @@ -60,8 +70,9 @@ export async function progressPlanStep( tool_choice: setTaskStatusTool.name, }); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages, { + returnFullMessage: true, + }); const conversationHistoryStr = `Here is the full conversation history after the user's request: ${removeFirstHumanMessage(state.messages).map(getMessageString).join("\n")} @@ -69,12 +80,14 @@ ${removeFirstHumanMessage(state.messages).map(getMessageString).join("\n")} Take all of this information, and determine whether or not you have completed this task in the plan. Once you've determined the status of the current task, call the \`set_task_status\` tool.`; + const activePlanItems = getActivePlanItems(state.plan); + const response = await modelWithTools.invoke([ { role: "system", - content: formatPrompt(state.plan), + content: formatPrompt(activePlanItems), }, - ...(firstUserMessage ? [firstUserMessage] : []), + userRequest, { role: "user", content: conversationHistoryStr, @@ -91,15 +104,14 @@ Once you've determined the status of the current task, call the \`set_task_statu const isCompleted = (toolCall.args as z.infer).task_status === "completed"; - const currentTask = state.plan.filter((p) => !p.completed)?.[0]; - const toolMessage = { - role: "tool", - tool_call_id: toolCall.id, + const currentTask = getCurrentPlanItem(activePlanItems); + const toolMessage = new ToolMessage({ + tool_call_id: toolCall.id ?? "", content: `Saved task status as ${ toolCall.args.task_status } for task ${currentTask?.plan || "unknown"}`, name: toolCall.name, - }; + }); if (!isCompleted) { logger.info( @@ -108,21 +120,34 @@ Once you've determined the status of the current task, call the \`set_task_statu reasoning: toolCall.args.reasoning, }, ); + const commandUpdate: GraphUpdate = { messages: [response, toolMessage] }; return new Command({ goto: "generate-action", - update: { messages: [response, toolMessage] }, + update: commandUpdate, }); } + // LLM marked as completed, so we need to update the plan to reflect that. + const updatedPlanTasks = completePlanItem( + state.plan, + getActiveTask(state.plan).id, + currentTask.index, + ); + // This should in theory never happen, but ensure we route properly if it does. - const remainingTask = state.plan.find((p) => !p.completed); + const remainingTask = getRemainingPlanItems(activePlanItems)?.[0]; if (!remainingTask) { logger.info( "Found no remaining tasks in the plan during the check plan step. Continuing to the conclusion generation step.", ); + const commandUpdate: GraphUpdate = { + messages: [response, toolMessage], + // Even though there are no remaining tasks, still mark as completed so the UI reflects that the task is completed. + plan: updatedPlanTasks, + }; return new Command({ goto: "generate-conclusion", - update: { messages: [response, toolMessage] }, + update: commandUpdate, }); } @@ -133,19 +158,13 @@ Once you've determined the status of the current task, call the \`set_task_statu }, }); + const commandUpdate: GraphUpdate = { + messages: [response, toolMessage], + plan: updatedPlanTasks, + }; + return new Command({ goto: "summarize-task-steps", - update: { - messages: [response, toolMessage], - plan: state.plan.map((p) => { - if (p.index === remainingTask.index) { - return { - ...p, - completed: true, - }; - } - return p; - }), - }, + update: commandUpdate, }); } diff --git a/apps/open-swe/src/nodes/request-help.ts b/apps/open-swe/src/nodes/request-help.ts index 38755fe6..e96e4e77 100644 --- a/apps/open-swe/src/nodes/request-help.ts +++ b/apps/open-swe/src/nodes/request-help.ts @@ -1,5 +1,5 @@ -import { isAIMessage } from "@langchain/core/messages"; -import { GraphState } from "../types.js"; +import { isAIMessage, ToolMessage } from "@langchain/core/messages"; +import { GraphState, GraphUpdate } from "../types.js"; import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt"; import { END, interrupt, Command } from "@langchain/langgraph"; import { stopSandbox, startSandbox } from "../utils/sandbox.js"; @@ -53,18 +53,18 @@ export async function requestHelp(state: GraphState): Promise { throw new Error("Interrupt response expected to be a string."); } await startSandbox(sandboxSessionId); + const commandUpdate: GraphUpdate = { + messages: [ + new ToolMessage({ + tool_call_id: toolCall.id ?? "", + content: `Human response: ${interruptRes.args}`, + status: "success", + }), + ], + }; return new Command({ goto: "generate-action", - update: { - messages: [ - { - role: "tool", - tool_call_id: toolCall.id, - content: `Human response: ${interruptRes.args}`, - status: "success", - }, - ], - }, + update: commandUpdate, }); } diff --git a/apps/open-swe/src/nodes/rewrite-plan.ts b/apps/open-swe/src/nodes/rewrite-plan.ts index 3d12cf2a..6aa23b70 100644 --- a/apps/open-swe/src/nodes/rewrite-plan.ts +++ b/apps/open-swe/src/nodes/rewrite-plan.ts @@ -1,11 +1,10 @@ import { GraphState, GraphConfig, GraphUpdate, PlanItem } from "../types.js"; import { loadModel, Task } from "../utils/load-model.js"; -import { isHumanMessage } from "@langchain/core/messages"; -import { getMessageContentString } from "../utils/message/content.js"; import { z } from "zod"; import { tool } from "@langchain/core/tools"; import { ConfigurableModel } from "langchain/chat_models/universal"; import { traceable } from "langsmith/traceable"; +import { getUserRequest } from "../utils/user-request.js"; const systemPromptIdentifyChanges = `You are operating as an agentic coding assistant built by LangChain. You've previously been given a task to generate a plan of action for, to address the user's initial request. @@ -133,15 +132,12 @@ async function identifyTasksToModifyFunc( }, ); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages); const response = await modelWithIdentifyChangesTool.invoke([ { role: "user", content: formatSysPromptIdentifyTasks( - getMessageContentString( - firstUserMessage?.content || "No user message found", - ), + userRequest || "No user message found", state.planChangeRequest, state.proposedPlan, ), @@ -198,15 +194,12 @@ async function updatePlanTasksFunc( tool_choice: updatePlanTasksTool.name, }); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages); const response = await modelWithUpdatePlanTasksTool.invoke([ { role: "user", content: formatSysPromptRewritePlan( - getMessageContentString( - firstUserMessage?.content || "No user message found", - ), + userRequest || "No user message found", state.planChangeRequest, state.proposedPlan, tasksToModify, @@ -243,7 +236,6 @@ export async function rewritePlan( const updatedPlanTasks = await updatePlanTasks(state, tasksToModify, model); return { - plan: [], proposedPlan: updatedPlanTasks, }; } diff --git a/apps/open-swe/src/nodes/summarize-task-steps.ts b/apps/open-swe/src/nodes/summarize-task-steps.ts index e65f8394..0b78ad4b 100644 --- a/apps/open-swe/src/nodes/summarize-task-steps.ts +++ b/apps/open-swe/src/nodes/summarize-task-steps.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from "uuid"; -import { GraphConfig, GraphState, PlanItem } from "../types.js"; +import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js"; import { loadModel, Task } from "../utils/load-model.js"; import { AIMessage, BaseMessage } from "@langchain/core/messages"; import { formatPlanPrompt } from "../utils/plan-prompt.js"; @@ -12,6 +12,12 @@ import { removeLastTaskMessages } from "../utils/message/modify-array.js"; import { Command } from "@langchain/langgraph"; import { ConfigurableModel } from "langchain/chat_models/universal"; import { traceable } from "langsmith/traceable"; +import { + completePlanItem, + getActivePlanItems, + getActiveTask, +} from "../utils/task-plan.js"; +import { getCompletedPlanItems } from "../utils/current-task.js"; const taskSummarySysPrompt = `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. @@ -150,8 +156,9 @@ const formatUserCodebaseContextMessage = ( async function generateTaskSummaryFunc( state: GraphState, model: ConfigurableModel, -): Promise { - const lastCompletedTask = state.plan.findLast((p) => p.completed); +): Promise<{ planItemIndex: number; summary: string }> { + const activePlanItems = getActivePlanItems(state.plan); + const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop(); if (!lastCompletedTask) { throw new Error("Unable to find last completed task."); } @@ -160,26 +167,18 @@ async function generateTaskSummaryFunc( const response = await model.withConfig({ tags: ["nostream"] }).invoke([ { role: "system", - content: formatPrompt(state.plan, state.codebaseContext), + content: formatPrompt(activePlanItems, state.codebaseContext), }, { role: "user", - content: formatUserMessage(state.messages, state.plan), + content: formatUserMessage(state.messages, activePlanItems), }, ]); - const contentString = getMessageContentString(response.content); - const newPlanWithSummary = state.plan.map((p) => { - if (p.index !== lastCompletedTask.index) { - return p; - } - return { - ...p, - summary: contentString, - }; - }); - - return newPlanWithSummary; + return { + planItemIndex: lastCompletedTask.index, + summary: getMessageContentString(response.content), + }; } const generateTaskSummary = traceable(generateTaskSummaryFunc, { @@ -198,7 +197,10 @@ async function updateCodebaseContextFunc( }, { role: "user", - content: formatUserCodebaseContextMessage(state.messages, state.plan), + content: formatUserCodebaseContextMessage( + state.messages, + getActivePlanItems(state.plan), + ), }, ]); const contentString = getMessageContentString(response.content); @@ -213,16 +215,23 @@ export async function summarizeTaskSteps( state: GraphState, config: GraphConfig, ): Promise { - const lastCompletedTask = state.plan.findLast((p) => p.completed); + const activePlanItems = getActivePlanItems(state.plan); + const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop(); if (!lastCompletedTask) { throw new Error("Unable to find last completed task."); } const model = await loadModel(config, Task.SUMMARIZER); - const [updatedPlan, updatedCodebaseContext] = await Promise.all([ + const [taskSummary, updatedCodebaseContext] = await Promise.all([ generateTaskSummary(state, model), updateCodebaseContext(state, model), ]); + const updatedTaskPlan = completePlanItem( + state.plan, + getActiveTask(state.plan).id, + taskSummary.planItemIndex, + taskSummary.summary, + ); const removedMessages = removeLastTaskMessages(state.messages); logger.info(`Removing ${removedMessages.length} message(s) from state.`); @@ -236,24 +245,26 @@ export async function summarizeTaskSteps( }); const newMessagesStateUpdate = [...removedMessages, condensedTaskMessage]; - const allTasksCompleted = state.plan.every((p) => p.completed); + const allTasksCompleted = activePlanItems.every((p) => p.completed); if (allTasksCompleted) { + const commandUpdate: GraphUpdate = { + messages: newMessagesStateUpdate, + plan: updatedTaskPlan, + codebaseContext: updatedCodebaseContext, + }; return new Command({ goto: "generate-conclusion", - update: { - messages: newMessagesStateUpdate, - plan: updatedPlan, - codebaseContext: updatedCodebaseContext, - }, + update: commandUpdate, }); } + const commandUpdate: GraphUpdate = { + messages: newMessagesStateUpdate, + plan: updatedTaskPlan, + codebaseContext: updatedCodebaseContext, + }; return new Command({ goto: "generate-action", - update: { - messages: newMessagesStateUpdate, - plan: updatedPlan, - codebaseContext: updatedCodebaseContext, - }, + update: commandUpdate, }); } diff --git a/apps/open-swe/src/subgraphs/planner/index.ts b/apps/open-swe/src/subgraphs/planner/index.ts index 12e2f8ac..324c9df6 100644 --- a/apps/open-swe/src/subgraphs/planner/index.ts +++ b/apps/open-swe/src/subgraphs/planner/index.ts @@ -6,7 +6,11 @@ */ import { END, START, StateGraph } from "@langchain/langgraph"; -import { PlannerGraphState, PlannerGraphStateObj } from "./types.js"; +import { + PlannerGraphState, + PlannerGraphStateObj, + PlannerGraphUpdate, +} from "./types.js"; import { GraphConfig, GraphConfiguration } from "../../types.js"; import { generateAction, @@ -14,7 +18,7 @@ import { summarizer, takeAction, } from "./nodes/index.js"; -import { isAIMessage } from "@langchain/core/messages"; +import { isAIMessage, RemoveMessage } from "@langchain/core/messages"; function takeActionOrGeneratePlan( state: PlannerGraphState, @@ -39,12 +43,24 @@ function takeActionOrGeneratePlan( return "generate-plan"; } +function prepareGraphState(state: PlannerGraphState): PlannerGraphUpdate { + if (!state.plannerMessages?.length) return {}; + // Remove all planning messages if we're starting a new plan + return { + plannerMessages: state.plannerMessages.map( + (m) => new RemoveMessage({ id: m.id ?? "" }), + ), + }; +} + const workflow = new StateGraph(PlannerGraphStateObj, GraphConfiguration) + .addNode("prepare-graph-state", prepareGraphState) .addNode("generate-plan-context-action", generateAction) .addNode("take-plan-action", takeAction) .addNode("generate-plan", generatePlan) .addNode("summarizer", summarizer) - .addEdge(START, "generate-plan-context-action") + .addEdge(START, "prepare-graph-state") + .addEdge("prepare-graph-state", "generate-plan-context-action") .addConditionalEdges( "generate-plan-context-action", takeActionOrGeneratePlan, diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts b/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts index 1ee06ebd..7033c64e 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/generate-message.ts @@ -2,26 +2,44 @@ import { loadModel, Task } from "../../../utils/load-model.js"; import { shellTool } from "../../../tools/index.js"; import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; import { GraphConfig } from "../../../types.js"; -import { isHumanMessage } from "@langchain/core/messages"; import { createLogger, LogLevel } from "../../../utils/logger.js"; import { getMessageContentString } from "../../../utils/message/content.js"; +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"; 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. - -Your sole task is to gather context from the repository the user has provided which will be helpful when generating a plan to address the user's request. +{FOLLOWUP_MESSAGE_PROMPT} You MUST adhere to the following criteria when gathering context for the plan: -- You must ONLY take read actions to gather context. Write actions are NOT allowed. +- 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 first user message in this conversation contains the user's request. +- The repo is already cloned, and located inside {REPO_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.messages.filter(isHumanMessage).length > 1; + + return systemPrompt + .replace( + "{FOLLOWUP_MESSAGE_PROMPT}", + isFollowup ? formatFollowupMessagePrompt(state.plan) : "", + ) + .replace("{REPO_DIRECTORY}", getRepoAbsolutePath(state.targetRepository)); +} + export async function generateAction( state: PlannerGraphState, config: GraphConfig, @@ -30,27 +48,28 @@ export async function generateAction( const tools = [shellTool]; const modelWithTools = model.bindTools(tools, { tool_choice: "auto" }); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages, { + returnFullMessage: true, + }); const response = await modelWithTools .withConfig({ tags: ["nostream"] }) .invoke([ { role: "system", - content: systemPrompt, + content: formatSystemPrompt(state), }, - ...(firstUserMessage ? [firstUserMessage] : []), + 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, }), - ...(getMessageContentString(response.content) && { - content: getMessageContentString(response.content), - }), }); return { diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts b/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts index 9a4e4da7..8e3372a5 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts @@ -7,9 +7,12 @@ import { sessionPlanTool } from "../../../tools/index.js"; import { GraphConfig } from "../../../types.js"; import { loadModel, Task } from "../../../utils/load-model.js"; import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; +import { getUserRequest } from "../../../utils/user-request.js"; +import { formatFollowupMessagePrompt } from "../utils/followup-prompt.js"; import { stopSandbox } from "../../../utils/sandbox.js"; 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} In this step, you are expected to generate a high-level plan to address the user's request. The plan should be a list of actions to take, in order, to address the user's request. You should not include any code in the plan, only a list of actions to take. @@ -21,13 +24,29 @@ You MUST adhere to the following criteria when generating the plan: - Your goal is to complete the task outlined by the user in the least number of steps possible. - Do not pack multiple complex tasks into a single plan item. Each high level task you'll need to complete should have its own plan item. - When you are ready to generate the plan, ensure you call the 'session_plan' tool. You are REQUIRED to call this tool. -- The first user message in this conversation contains the user's request. - Your plan should be as simple as possible, while still containing all the tasks required to complete the user's request. - If the user did not explicitly request you write tests, do not include a task to write tests. - If the user did not explicitly request you write documentation, do not include a task to do so. - You should aim to complete the user's request in the least number of steps possible. + + +The user's request is as follows. Ensure you generate your plan in accordance with the user's request. +{USER_REQUEST} `; +function formatSystemPrompt(state: PlannerGraphState): string { + // It's a followup if there's more than one human message. + const isFollowup = state.messages.filter(isHumanMessage).length > 1; + const userRequest = getUserRequest(state.messages); + + return systemPrompt + .replace( + "{FOLLOWUP_MESSAGE_PROMPT}", + isFollowup ? formatFollowupMessagePrompt(state.plan) : "", + ) + .replace("{USER_REQUEST}", userRequest); +} + export async function generatePlan( state: PlannerGraphState, config: GraphConfig, @@ -37,8 +56,6 @@ export async function generatePlan( tool_choice: sessionPlanTool.name, }); - 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]) { @@ -55,9 +72,8 @@ export async function generatePlan( .invoke([ { role: "system", - content: systemPrompt, + content: formatSystemPrompt(state), }, - ...(firstUserMessage ? [firstUserMessage] : []), ...state.plannerMessages, ...(optionalToolMessage ? [optionalToolMessage] : []), ]); @@ -74,7 +90,6 @@ export async function generatePlan( return { 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/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts b/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts index 189e2de2..670f5a61 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts @@ -2,11 +2,8 @@ import { z } from "zod"; import { GraphConfig } from "../../../types.js"; import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; import { loadModel, Task } from "../../../utils/load-model.js"; -import { isHumanMessage } from "@langchain/core/messages"; -import { - getMessageContentString, - getMessageString, -} from "../../../utils/message/content.js"; +import { getMessageString } from "../../../utils/message/content.js"; +import { getUserRequest } from "../../../utils/user-request.js"; 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. @@ -22,7 +19,7 @@ You MUST adhere to the following criteria when summarizing the conversation hist - Do not retain any full file contents. - Ensure your summary is concise, but useful for future context. -Here is the user's initial request +Here is the user's request ## User request: {USER_REQUEST} @@ -53,8 +50,7 @@ export async function summarizer( tool_choice: condenseContextTool.name, }); - const firstUserMessage = state.messages.find(isHumanMessage); - + const userRequest = getUserRequest(state.messages); const conversationHistoryStr = `Here is the full conversation history: ${state.plannerMessages.map(getMessageString).join("\n")}`; @@ -62,11 +58,7 @@ ${state.plannerMessages.map(getMessageString).join("\n")}`; const response = await modelWithTools.invoke([ { role: "system", - content: formatPrompt( - getMessageContentString( - firstUserMessage?.content || "No user request provided.", - ), - ), + content: formatPrompt(userRequest || "No user request provided."), }, { role: "user", diff --git a/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts b/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts new file mode 100644 index 00000000..3060f394 --- /dev/null +++ b/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts @@ -0,0 +1,35 @@ +import { TaskPlan } from "../../../types.js"; + +const followupMessagePrompt = ` +The user is sending a followup request for you to generate a plan for. You are provided with the following context to aid in your new plan context gathering steps: + - The previous user requests, along with the tasks, and task summaries you generated for these previous requests. + - You are only provided this information as context to reference when gathering context for the new plan. + +Here is the complete list of previous requests made by the user, along with the tasks you generated to complete these requests, and the task summaries of each task you completed previously: +{PREVIOUS_PLAN} +`; + +const formatPreviousPlans = (tasks: TaskPlan): string => { + return tasks.tasks + .map((task) => { + const activePlanItems = + task.planRevisions[task.activeRevisionIndex].plans; + + return ` + User request: ${task.request} + + Overall task summary:\n\n${task.summary || "No overall task summary found"}\n + + Individual tasks you generated to complete this request: + ${activePlanItems.map((planItem) => `${planItem.plan}`).join("\n")} + `; + }) + .join("\n"); +}; + +export function formatFollowupMessagePrompt(tasks: TaskPlan): string { + return followupMessagePrompt.replace( + "{PREVIOUS_PLAN}", + formatPreviousPlans(tasks), + ); +} diff --git a/apps/open-swe/src/types.ts b/apps/open-swe/src/types.ts index 2521d693..8f42344b 100644 --- a/apps/open-swe/src/types.ts +++ b/apps/open-swe/src/types.ts @@ -27,6 +27,81 @@ export type PlanItem = { summary?: string; }; +export type PlanRevision = { + /** + * The revision index of the plan. + * This is used to track edits made to the plan by the agent or user + */ + revisionIndex: number; + /** + * The plans for this task & revision. + */ + plans: PlanItem[]; + /** + * Timestamp when this revision was created + */ + createdAt: number; + /** + * Who created this revision (agent or user) + */ + createdBy: "agent" | "user"; +}; + +export type Task = { + /** + * Unique identifier for the task + */ + id: string; + /** + * The index of the user's task in chronological order + */ + taskIndex: number; + /** + * The original user request that created this task + */ + request: string; + /** + * When the task was created + */ + createdAt: number; + /** + * Whether the task is completed + */ + completed: boolean; + /** + * When the task was completed (if applicable) + */ + completedAt?: number; + /** + * Overall summary of the completed task + */ + summary?: string; + /** + * The plans generated for this task. + * Ordered by revisionIndex, with the latest revision being the active one + */ + planRevisions: PlanRevision[]; + /** + * Index of the currently active plan revision + */ + activeRevisionIndex: number; + /** + * Optional parent task id if this task was derived from another task + */ + parentTaskId?: string; +}; + +export type TaskPlan = { + /** + * All tasks in the system + */ + tasks: Task[]; + /** + * Index of the currently active task + */ + activeTaskIndex: number; +}; + export type TargetRepository = { owner: string; repo: string; @@ -43,9 +118,8 @@ export const GraphAnnotation = z.object({ .default(() => []) .langgraph.reducer((_state, update) => update), plan: z - .custom() - .default(() => []) - .langgraph.reducer((_state, update) => update), + .custom() + .langgraph.reducer((_state, update) => update), planChangeRequest: z .string() .nullable() diff --git a/apps/open-swe/src/utils/current-task.ts b/apps/open-swe/src/utils/current-task.ts index 0c2f1670..8f6d936a 100644 --- a/apps/open-swe/src/utils/current-task.ts +++ b/apps/open-swe/src/utils/current-task.ts @@ -1,6 +1,6 @@ import { PlanItem } from "../types.js"; -export function getCurrentTask(plan: PlanItem[]) { +export function getCurrentPlanItem(plan: PlanItem[]): PlanItem { return ( plan.filter((p) => !p.completed).sort((a, b) => a.index - b.index)?.[0] || { plan: "No current task found.", @@ -10,3 +10,32 @@ export function getCurrentTask(plan: PlanItem[]) { } ); } + +/** + * Gets the completed plan items for the given plan. + * @param plan The list of plan items to get the completed plan items for. + * @returns The list of completed plan items. + */ +export function getCompletedPlanItems(plan: PlanItem[]): PlanItem[] { + return plan.filter((p) => p.completed); +} + +/** + * Gets the remaining plan items for the given plan. + * @param plan The list of plan items to get the remaining plan items for. + * @param includeCurrentPlanItem Whether to include the current plan item in the remaining plan items. + * Defaults to false. + * @returns The list of remaining plan items. + */ +export function getRemainingPlanItems( + plan: PlanItem[], + includeCurrentPlanItem = false, +): PlanItem[] { + return plan + .filter( + (p) => + !p.completed && + (includeCurrentPlanItem || p.index !== getCurrentPlanItem(plan).index), + ) + ?.sort((a, b) => a.index - b.index); +} diff --git a/apps/open-swe/src/utils/git.ts b/apps/open-swe/src/utils/git.ts index 9c8f1fd1..2098f7a1 100644 --- a/apps/open-swe/src/utils/git.ts +++ b/apps/open-swe/src/utils/git.ts @@ -451,6 +451,42 @@ export async function checkoutBranchAndCommit( return branchName; } +async function getExistingPullRequest( + owner: string, + repo: string, + branchName: string, + githubToken: string, +) { + try { + const octokit = new Octokit({ + auth: githubToken, + }); + + const { data: pullRequests } = await octokit.pulls.list({ + owner, + repo, + head: branchName, + }); + + if (pullRequests?.[0]) { + return pullRequests[0]; + } + } catch (e) { + logger.error(`Failed to get existing pull request`, { + branch: branchName, + owner, + repo, + ...(e instanceof Error && { + name: e.name, + message: e.message, + stack: e.stack, + }), + }); + } + + return null; +} + export async function createPullRequest({ owner, repo, @@ -495,6 +531,13 @@ export async function createPullRequest({ logger.info(`🐙 Pull request created: ${pullRequest.html_url}`); return pullRequest; } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + logger.info( + "Pull request already exists. Getting existing pull request...", + ); + return getExistingPullRequest(owner, repo, headBranch, githubToken); + } + logger.error(`Failed to create pull request`, { error, }); @@ -533,6 +576,7 @@ export async function cloneRepo( targetRepository: TargetRepository, args: { githubToken: string; + stateBranchName?: string; }, ) { try { @@ -541,8 +585,9 @@ export async function cloneRepo( // Use x-access-token format for better GitHub authentication const repoUrlWithToken = `https://x-access-token:${args.githubToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`; - if (targetRepository.branch) { - gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken); + const branchName = args.stateBranchName || targetRepository.branch; + if (branchName) { + gitCloneCommand.push("-b", branchName, repoUrlWithToken); } else { gitCloneCommand.push(repoUrlWithToken); } @@ -550,7 +595,7 @@ export async function cloneRepo( logger.info("Cloning repository", { // Don't log the full command with token for security reasons repoPath: `${targetRepository.owner}/${targetRepository.repo}`, - branch: targetRepository.branch || "default", + branch: branchName, }); return await sandbox.process.executeCommand(gitCloneCommand.join(" ")); } catch (e) { diff --git a/apps/open-swe/src/utils/plan-prompt.ts b/apps/open-swe/src/utils/plan-prompt.ts index 8b43a31d..a98d09a9 100644 --- a/apps/open-swe/src/utils/plan-prompt.ts +++ b/apps/open-swe/src/utils/plan-prompt.ts @@ -76,7 +76,7 @@ export function formatPlanPromptWithSummaries(plan: PlanItem[]): string { return plan .map( (p) => - `\n${p.plan}\n \n${p.summary || "No task summary found"}\n \n`, + `<${p.completed ? "completed-" : ""}task index="${p.index}">\n${p.plan}\n \n${p.summary || "No task summary found"}\n \n`, ) .join("\n"); } diff --git a/apps/open-swe/src/utils/sandbox.ts b/apps/open-swe/src/utils/sandbox.ts index 6eed0072..34a6dc11 100644 --- a/apps/open-swe/src/utils/sandbox.ts +++ b/apps/open-swe/src/utils/sandbox.ts @@ -1,4 +1,7 @@ import { Daytona, Sandbox, SandboxState } from "@daytonaio/sdk"; +import { createLogger, LogLevel } from "./logger.js"; + +const logger = createLogger(LogLevel.INFO, "Sandbox"); // Singleton instance of Daytona let daytonaInstance: Daytona | null = null; @@ -50,3 +53,24 @@ export async function startSandbox(sandboxSessionId: string): Promise { } return sandbox; } + +/** + * Deletes the sandbox. + * @param sandboxSessionId The ID of the sandbox to delete. + * @returns True if the sandbox was deleted, false if it failed to delete. + */ +export async function deleteSandbox( + sandboxSessionId: string, +): Promise { + try { + const sandbox = await daytonaClient().get(sandboxSessionId); + await daytonaClient().delete(sandbox); + return true; + } catch (error) { + logger.error("Failed to delete sandbox", { + sandboxSessionId, + error, + }); + return false; + } +} diff --git a/apps/open-swe/src/utils/task-plan.ts b/apps/open-swe/src/utils/task-plan.ts new file mode 100644 index 00000000..a884c6ff --- /dev/null +++ b/apps/open-swe/src/utils/task-plan.ts @@ -0,0 +1,274 @@ +import { v4 as uuidv4 } from "uuid"; +import { PlanItem, Task, TaskPlan, PlanRevision } from "../types.js"; + +/** + * Creates a new task with the provided plan items. + * Can either add to an existing TaskPlan or create a brand new one. + * + * @param request The original user request text that initiated this task + * @param planItems The plan items to include in the new task + * @param existingTaskPlan Optional existing TaskPlan to add the new task to + * @param parentTaskId Optional ID of a parent task if this task is derived from another + * @returns The updated TaskPlan with the new task added + */ +export function createNewTask( + request: string, + planItems: PlanItem[], + existingTaskPlan?: TaskPlan, + parentTaskId?: string, +): TaskPlan { + // Create the initial plan revision + const initialRevision: PlanRevision = { + revisionIndex: 0, + plans: planItems, + createdAt: Date.now(), + createdBy: "agent", + }; + + // Create the new task + const newTask: Task = { + id: uuidv4(), + taskIndex: existingTaskPlan ? existingTaskPlan.tasks.length : 0, + request, + createdAt: Date.now(), + completed: false, + planRevisions: [initialRevision], + activeRevisionIndex: 0, + parentTaskId, + }; + + // If there's an existing task plan, add the new task to it + if (existingTaskPlan) { + return { + tasks: [...existingTaskPlan.tasks, newTask], + activeTaskIndex: existingTaskPlan.tasks.length, // Set the new task as active + }; + } + + // Otherwise create a new task plan with just this task + return { + tasks: [newTask], + activeTaskIndex: 0, + }; +} + +/** + * Updates the plan items for an existing task by creating a new revision. + * + * @param taskPlan The current task plan + * @param taskId The ID of the task to update + * @param planItems The new plan items + * @param createdBy Who created this revision ('agent' or 'user') + * @returns The updated TaskPlan with the new revision + * @throws Error if the task ID doesn't exist + */ +export function updateTaskPlanItems( + taskPlan: TaskPlan, + taskId: string, + planItems: PlanItem[], + createdBy: "agent" | "user" = "agent", +): TaskPlan { + // Find the task to update + const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId); + + if (taskIndex === -1) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const task = taskPlan.tasks[taskIndex]; + + // Create a new revision with the updated plan items + const newRevision: PlanRevision = { + revisionIndex: task.planRevisions.length, + plans: planItems, + createdAt: Date.now(), + createdBy, + }; + + // Create an updated task with the new revision + const updatedTask: Task = { + ...task, + planRevisions: [...task.planRevisions, newRevision], + activeRevisionIndex: task.planRevisions.length, // Set the new revision as active + }; + + // Create a new array of tasks with the updated task + const updatedTasks = [...taskPlan.tasks]; + updatedTasks[taskIndex] = updatedTask; + + // Return the updated task plan + return { + ...taskPlan, + tasks: updatedTasks, + }; +} + +/** + * Helper function to get the active task from a TaskPlan + * + * @param taskPlan The task plan + * @returns The currently active task + * @throws Error if there are no tasks + */ +export function getActiveTask(taskPlan: TaskPlan): Task { + if (taskPlan.tasks.length === 0) { + throw new Error("No tasks available"); + } + + return taskPlan.tasks[taskPlan.activeTaskIndex]; +} + +/** + * Helper function to get the active plan items for the active task + * + * @param taskPlan The task plan + * @returns The currently active plan items + * @throws Error if there are no tasks or no plan revisions + */ +export function getActivePlanItems(taskPlan: TaskPlan): PlanItem[] { + const activeTask = getActiveTask(taskPlan); + + if (activeTask.planRevisions.length === 0) { + throw new Error("No plan revisions available for the active task"); + } + + return activeTask.planRevisions[activeTask.activeRevisionIndex].plans; +} + +/** + * Marks a specific plan item as completed and adds a summary. + * This operation modifies the current active revision directly and does NOT create a new revision. + * + * @param taskPlan The current task plan + * @param taskId The ID of the task containing the plan item + * @param planItemIndex The `index` property of the plan item to mark as completed + * @param summary Optional summary of the completed plan item. If undefined, existing summary is preserved. + * @returns The updated TaskPlan + * @throws Error if the task or plan item is not found, or if no active revision exists. + */ +export function completePlanItem( + taskPlan: TaskPlan, + taskId: string, + planItemIndex: number, + summary?: string, +): TaskPlan { + const taskIndexInPlan = taskPlan.tasks.findIndex( + (task) => task.id === taskId, + ); + + if (taskIndexInPlan === -1) { + throw new Error(`Task with ID ${taskId} not found in task plan`); + } + + const originalTask = taskPlan.tasks[taskIndexInPlan]; + + const activeRevisionIndex = originalTask.activeRevisionIndex; + + // Ensure there's a planRevisions array and the activeRevisionIndex is valid + if ( + !originalTask.planRevisions || + activeRevisionIndex < 0 || + activeRevisionIndex >= originalTask.planRevisions.length + ) { + throw new Error( + `Invalid active revision index (${activeRevisionIndex}) for task ${taskId}`, + ); + } + + const originalActiveRevision = + originalTask.planRevisions[activeRevisionIndex]; + + // This check should be covered by the index check, but reinforces intent + if (!originalActiveRevision) { + // This case implies an issue with activeRevisionIndex or planRevisions structure + throw new Error( + `Active revision (index ${activeRevisionIndex}) not found for task ${taskId}`, + ); + } + + const planItemToUpdateActualIndexInPlansArray = + originalActiveRevision.plans.findIndex( + (item) => item.index === planItemIndex, + ); + + if (planItemToUpdateActualIndexInPlansArray === -1) { + throw new Error( + `Plan item with .index ${planItemIndex} not found in active revision (index ${activeRevisionIndex}) of task ${taskId}`, + ); + } + + // Create a new 'plans' array with the specific item updated + const updatedPlansForRevision = originalActiveRevision.plans.map((item) => { + if (item.index === planItemIndex) { + const newSummary = summary !== undefined ? summary : item.summary; + return { ...item, completed: true, summary: newSummary }; + } + return item; + }); + + // Create a new 'PlanRevision' object for the active revision, with the updated 'plans' + const updatedActiveRevision: PlanRevision = { + ...originalActiveRevision, + plans: updatedPlansForRevision, + }; + + // Create a new 'planRevisions' array, replacing the active revision with the updated one + const updatedPlanRevisions = [...originalTask.planRevisions]; + updatedPlanRevisions[activeRevisionIndex] = updatedActiveRevision; + + // Create a new 'Task' object with the updated 'planRevisions' + const updatedTask: Task = { + ...originalTask, + planRevisions: updatedPlanRevisions, + }; + + // Create a new 'tasks' array for the TaskPlan, replacing the updated task + const updatedTasksArray = [...taskPlan.tasks]; + updatedTasksArray[taskIndexInPlan] = updatedTask; + + // Return the new TaskPlan object + return { + ...taskPlan, + tasks: updatedTasksArray, + }; +} + +/** + * Marks a task as completed + * + * @param taskPlan The current task plan + * @param taskId The ID of the task to mark as completed + * @param summary Optional summary of the completed task + * @returns The updated TaskPlan + */ +export function completeTask( + taskPlan: TaskPlan, + taskId: string, + summary?: string, +): TaskPlan { + const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId); + + if (taskIndex === -1) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const task = taskPlan.tasks[taskIndex]; + + // Create an updated task marked as completed + const updatedTask: Task = { + ...task, + completed: true, + completedAt: Date.now(), + summary, + }; + + // Create a new array of tasks with the updated task + const updatedTasks = [...taskPlan.tasks]; + updatedTasks[taskIndex] = updatedTask; + + // Return the updated task plan + return { + ...taskPlan, + tasks: updatedTasks, + }; +} diff --git a/apps/open-swe/src/utils/user-request.ts b/apps/open-swe/src/utils/user-request.ts new file mode 100644 index 00000000..4ee786a5 --- /dev/null +++ b/apps/open-swe/src/utils/user-request.ts @@ -0,0 +1,27 @@ +import { + BaseMessage, + isHumanMessage, + HumanMessage, +} from "@langchain/core/messages"; +import { getMessageContentString } from "./message/content.js"; + +export function getUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: never | false }, +): string; +export function getUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: true }, +): HumanMessage; +export function getUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: boolean }, +): string | HumanMessage { + const recentUserMessage = messages.findLast(isHumanMessage); + if (!recentUserMessage) { + return ""; + } + return options?.returnFullMessage + ? recentUserMessage + : getMessageContentString(recentUserMessage.content); +} diff --git a/apps/web/src/components/thread/index.tsx b/apps/web/src/components/thread/index.tsx index f43a5b8f..78014f71 100644 --- a/apps/web/src/components/thread/index.tsx +++ b/apps/web/src/components/thread/index.tsx @@ -101,7 +101,6 @@ export function Thread() { handleFileUpload, dropRef, removeBlock, - resetBlocks, dragOver, handlePaste, } = useFileUpload(); @@ -234,6 +233,7 @@ export function Thread() { const hasNoAIOrToolMessages = !messages.find( (m) => m.type === "ai" || m.type === "tool", ); + const isLastMessageHuman = messages[messages.length - 1]?.type === "human"; return (
@@ -399,14 +399,16 @@ export function Thread() { )} {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. We need to render it outside of the messages list, since there are no messages to render */} - {hasNoAIOrToolMessages && !!stream.interrupt && ( - - )} + {(hasNoAIOrToolMessages || isLastMessageHuman) && + !!stream.interrupt && ( + + )} {isLoading && !firstTokenReceived && ( )} diff --git a/apps/web/src/components/thread/messages/ai.tsx b/apps/web/src/components/thread/messages/ai.tsx index 7c23e974..f24949e9 100644 --- a/apps/web/src/components/thread/messages/ai.tsx +++ b/apps/web/src/components/thread/messages/ai.tsx @@ -9,11 +9,9 @@ import { cn } from "@/lib/utils"; import { ToolCalls, ToolResult } from "./tool-calls"; import { MessageContentComplex } from "@langchain/core/messages"; import { Fragment } from "react/jsx-runtime"; -import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt"; -import { ThreadView } from "../agent-inbox"; import { useQueryState, parseAsBoolean } from "nuqs"; -import { GenericInterruptView } from "./generic-interrupt"; import { useArtifact } from "../artifact"; +import { Interrupt } from "./interrupt"; function CustomComponent({ message, @@ -67,40 +65,16 @@ function parseAnthropicStreamedToolCalls( }); } -interface InterruptProps { - interruptValue?: unknown; - isLastMessage: boolean; - hasNoAIOrToolMessages: boolean; -} - -function Interrupt({ - interruptValue, - isLastMessage, - hasNoAIOrToolMessages, -}: InterruptProps) { - return ( - <> - {isAgentInboxInterruptSchema(interruptValue) && - (isLastMessage || hasNoAIOrToolMessages) && ( - - )} - {interruptValue && - !isAgentInboxInterruptSchema(interruptValue) && - isLastMessage ? ( - - ) : null} - - ); -} - export function AssistantMessage({ message, isLoading, handleRegenerate, + forceRenderInterrupt = false, }: { message: Message | undefined; isLoading: boolean; handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void; + forceRenderInterrupt?: boolean; }) { const content = message?.content ?? []; const contentString = getContentString(content); @@ -140,6 +114,8 @@ export function AssistantMessage({ return null; } + console.log("returning"); + return (
@@ -150,6 +126,7 @@ export function AssistantMessage({ interruptValue={threadInterrupt?.value} isLastMessage={isLastMessage} hasNoAIOrToolMessages={hasNoAIOrToolMessages} + forceRenderInterrupt={forceRenderInterrupt} /> ) : ( @@ -184,6 +161,7 @@ export function AssistantMessage({ interruptValue={threadInterrupt?.value} isLastMessage={isLastMessage} hasNoAIOrToolMessages={hasNoAIOrToolMessages} + forceRenderInterrupt={forceRenderInterrupt} />
+ {isAgentInboxInterruptSchema(interruptValue) && + (isLastMessage || hasNoAIOrToolMessages || forceRenderInterrupt) && ( + + )} + {interruptValue && + !isAgentInboxInterruptSchema(interruptValue) && + (isLastMessage || forceRenderInterrupt) ? ( + + ) : null} + + ); +}