diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..26ae434b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "helicunate" + ] +} \ No newline at end of file diff --git a/apps/open-swe/package.json b/apps/open-swe/package.json index 251f7ec1..2ddfb041 100644 --- a/apps/open-swe/package.json +++ b/apps/open-swe/package.json @@ -27,7 +27,7 @@ "@langchain/core": "^0.3.56", "@langchain/google-genai": "^0.2.9", "@langchain/langgraph": "^0.3.3", - "@langchain/langgraph-sdk": "^0.0.83", + "@langchain/langgraph-sdk": "^0.0.84", "@langchain/openai": "^0.5.10", "@octokit/rest": "^22.0.0", "@open-swe/shared": "*", diff --git a/apps/open-swe/src/graphs/manager/index.ts b/apps/open-swe/src/graphs/manager/index.ts new file mode 100644 index 00000000..57d83dc6 --- /dev/null +++ b/apps/open-swe/src/graphs/manager/index.ts @@ -0,0 +1,24 @@ +import { END, START, StateGraph } from "@langchain/langgraph"; +import { GraphConfiguration } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphStateObj } from "./types.js"; +import { + initializeGithubIssue, + classifyMessage, + startPlanner, + createNewSession, +} from "./nodes/index.js"; + +const workflow = new StateGraph(ManagerGraphStateObj, GraphConfiguration) + .addNode("initialize-github-issue", initializeGithubIssue) + .addNode("classify-message", classifyMessage, { + ends: [END, "start-planner", "create-new-session"], + }) + .addNode("create-new-session", createNewSession) + .addNode("start-planner", startPlanner) + .addEdge(START, "initialize-github-issue") + .addEdge("initialize-github-issue", "classify-message") + .addEdge("create-new-session", END) + .addEdge("start-planner", END); + +export const graph = workflow.compile(); +graph.name = "Open SWE - Manager"; diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message.ts new file mode 100644 index 00000000..06d9f83b --- /dev/null +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message.ts @@ -0,0 +1,360 @@ +import { GraphConfig, TaskPlan } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphState, ManagerGraphUpdate } from "../types.js"; +import { createLangGraphClient } from "../../../utils/langgraph-client.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; +import { + BaseMessage, + HumanMessage, + isHumanMessage, + RemoveMessage, +} from "@langchain/core/messages"; +import { z } from "zod"; +import { removeLastHumanMessage } from "../../../utils/message/modify-array.js"; +import { formatPlanPrompt } from "../../../utils/plan-prompt.js"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; +import { getMessageString } from "../../../utils/message/content.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; +import { Command, END } from "@langchain/langgraph"; +import { getMessageContentString } from "@open-swe/shared/messages"; +import { createIssue, createIssueComment } from "../../../utils/github/api.js"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; +import { createIssueTitleAndBodyFromMessages } from "../utils/generate-issue-fields.js"; +import { ThreadStatus } from "@langchain/langgraph-sdk"; +import { + extractIssueTitleAndContentFromMessage, + formatContentForIssueBody, +} from "../../../utils/github/issue-messages.js"; + +// This should only be included in the state when the programmer is running. +const CODE_ROUTING_OPTION = `- code: Call this route if the user's message should be added to the programmer's currently running session. This should be called if you determine the user is trying to provide extra context to the programmer.`; + +// This should only be included when the programmer/planner is running. +const CREATE_ISSUE_ROUTING_OPTION = `- create_new_issue: Call this route if the user's request should create a new GitHub issue, and should be executed independently from the current request. This should only be called if the new request does not depend on the current request.`; + +// This should only be included if the task plan exists. +const TASK_PLAN_PROMPT = `# Task Plan +The following is the current state of the task plan generated by the planner. You should use this as context when determining where to route the user's message, and how to reply to them. +{TASK_PLAN} +\n\n`; + +const CONVERSATION_HISTORY_PROMPT = `# Conversation History +The following is the conversation history between the user and you. This does not include their most recent message, which is the one you are currently classifying. You should use this as context when determining where to route the user's message, and how to reply to them. +{CONVERSATION_HISTORY} +\n\n`; + +const CLASSIFICATION_SYSTEM_PROMPT = `# Identity +You're a highly intelligent AI software engineering manager, tasked with identifying the user's intent, and responding to their message, plus routing it to the proper AI assistant. +Your overall system is an AI coding agent, tasked with completing user's requests to improve their codebase. + +# Instructions +Carefully examine the user's message, along with the conversation history provided (or none, if it's the first message they sent) to you in this system message below. +Using their most recent request, the conversation history, and the current status of your two AI assistants (programmer and planner), generate a response to send to the user, along with the route you should take to handle the request. +Although you're only supposed to classify & respond to the latest message, this does not mean you should look at it in isolation. You should consider the conversation history as a whole, and the current status of your two AI assistants (programmer and planner) to determine how to respond to the user's new message. + +# Assistant Statuses +The planner's current status is: {PLANNER_STATUS} +The programmer's current status is: {PROGRAMMER_STATUS} + +{TASK_PLAN_PROMPT} +{CONVERSATION_HISTORY_PROMPT} + +# Routing Options +Based on all of the context provided above, determine how to respond to the user's new message, and how to route their request. +Your routing options are: +- no_op: This should be called when the user's message does not warrant starting a new planning session, or updating the running session, or the same with the programmer if it's already running. +- plan: Call this route if the user's message is a complete request which you can use to kickoff a new planning session (only if one is not already running), or it's an entirely new request which you should also start a new planning session for (only if both the planner and programmer are not running). You may also call this route if the planner is running, and the user's message contains updated instructions, or additional context which may be relevant/helpful to the planner. +{CREATE_ISSUE_ROUTING_OPTION} +{CODE_ROUTING_OPTION} +`; + +const baseClassificationSchema = z.object({ + response: z + .string() + .describe( + "The response to send to the user. This should be clear, concise, and include any additional context the user may need to know about how/why you're handling their new message.", + ), + route: z + .enum(["no_op", "plan"]) + .describe("The route to take to handle the user's new message."), +}); + +const createClassificationPromptAndToolSchema = (inputs: { + programmerStatus: ThreadStatus | "not_started"; + plannerStatus: ThreadStatus | "not_started"; + messages: BaseMessage[]; + taskPlan: TaskPlan; +}): { + prompt: string; + schema: z.ZodTypeAny; +} => { + const conversationHistoryWithoutLatest = removeLastHumanMessage( + inputs.messages, + ); + const formattedTaskPlanPrompt = inputs.taskPlan + ? TASK_PLAN_PROMPT.replaceAll( + "{TASK_PLAN}", + formatPlanPrompt(getActivePlanItems(inputs.taskPlan)), + ) + : null; + const formattedConversationHistoryPrompt = + conversationHistoryWithoutLatest?.length + ? CONVERSATION_HISTORY_PROMPT.replaceAll( + "{CONVERSATION_HISTORY}", + conversationHistoryWithoutLatest.map(getMessageString).join("\n"), + ) + : null; + + const showCodeRoutingOption = inputs.programmerStatus === "busy"; + const showCreateIssueRoutingOption = + inputs.programmerStatus !== "not_started" || + inputs.plannerStatus !== "not_started"; + const prompt = CLASSIFICATION_SYSTEM_PROMPT.replaceAll( + "{PROGRAMMER_STATUS}", + inputs.programmerStatus, + ) + .replaceAll("{PLANNER_STATUS}", inputs.plannerStatus) + .replaceAll( + "{CODE_ROUTING_OPTION}", + showCodeRoutingOption ? CODE_ROUTING_OPTION : "", + ) + .replaceAll( + "{CREATE_ISSUE_ROUTING_OPTION}", + // Do not show the create new issue option if both the planner & programmer have not started + // if either have started/currently running/completed, show the option + showCreateIssueRoutingOption ? CREATE_ISSUE_ROUTING_OPTION : "", + ) + .replaceAll("{TASK_PLAN_PROMPT}", formattedTaskPlanPrompt ?? "") + .replaceAll( + "{CONVERSATION_HISTORY_PROMPT}", + formattedConversationHistoryPrompt ?? "", + ); + + const schema = baseClassificationSchema.extend({ + route: z + .enum([ + "no_op", + "plan", + ...(showCodeRoutingOption ? ["code"] : []), + ...(showCreateIssueRoutingOption ? ["create_new_issue"] : []), + ]) + .describe("The route to take to handle the user's new message."), + }); + + return { + prompt, + schema, + }; +}; + +/** + * Classify the latest human message to determine how to route the request. + * Requests can be routed to: + * 1. reply - dont need to plan, just reply. This could be if the user sends a message which is not classified as a request, or if the programmer/planner is already running. + * a. if the planner/programmer is already running, we'll simply reply with + */ +export async function classifyMessage( + state: ManagerGraphState, + config: GraphConfig, +): Promise { + const langGraphClient = createLangGraphClient({ + defaultHeaders: { + [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", + [GITHUB_INSTALLATION_TOKEN_COOKIE]: + config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", + }, + }); + + const [programmerThread, plannerThread] = await Promise.all([ + state.programmerThreadId + ? langGraphClient.threads.get(state.programmerThreadId) + : undefined, + state.plannerThreadId + ? langGraphClient.threads.get(state.plannerThreadId) + : undefined, + ]); + const programmerStatus = programmerThread?.status ?? "not_started"; + const plannerStatus = plannerThread?.status ?? "not_started"; + + const { prompt, schema } = createClassificationPromptAndToolSchema({ + programmerStatus, + plannerStatus, + messages: state.messages, + taskPlan: state.taskPlan, + }); + const model = await loadModel(config, Task.CLASSIFICATION); + const respondAndRouteTool = { + name: "respond_and_route", + description: "Respond to the user's message and determine how to route it.", + schema, + }; + const modelWithTools = model.bindTools([respondAndRouteTool], { + tool_choice: respondAndRouteTool.name, + parallel_tool_calls: false, + }); + + const userMessage = state.messages.findLast(isHumanMessage); + if (!userMessage) { + throw new Error("No human message found."); + } + const response = await modelWithTools.invoke([ + { + role: "system", + content: prompt, + }, + userMessage, + ]); + + const toolCall = response.tool_calls?.[0]; + if (!toolCall) { + throw new Error("No tool call found."); + } + const toolCallArgs = toolCall.args as z.infer< + typeof baseClassificationSchema + >; + + if (toolCallArgs.route === "no_op") { + // If it's a no_op, just add the message to the state and return. + const commandUpdate: ManagerGraphUpdate = { + messages: [response], + }; + return new Command({ + update: commandUpdate, + goto: END, + }); + } + + if ((toolCallArgs.route as string) === "create_new_issue") { + // Route to node which kicks off new manager run, passing in the full conversation history. + const commandUpdate: ManagerGraphUpdate = { + messages: [response], + }; + return new Command({ + update: commandUpdate, + goto: "create-new-session", + }); + } + + const { githubAccessToken } = getGitHubTokensFromConfig(config); + let githubIssueId = state.githubIssueId; + + const newMessages: BaseMessage[] = []; + + // If it's not a no_op, ensure there is a GitHub issue with the user's request. + if (!githubIssueId) { + // If there are multiple human messages in the state, generate a github issue with an LLM. + // Otherwise extract it from the user's message. + let title = ""; + let body = ""; + if (state.messages.filter(isHumanMessage).length > 1) { + const titleAndContent = await createIssueTitleAndBodyFromMessages( + state.messages, + config, + ); + title = titleAndContent.title; + body = titleAndContent.body; + } else { + const titleAndContent = extractIssueTitleAndContentFromMessage( + getMessageContentString(userMessage.content), + ); + title = titleAndContent.title; + body = titleAndContent.content; + } + + const newIssue = await createIssue({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + title, + body: formatContentForIssueBody(body), + githubAccessToken, + }); + if (!newIssue) { + throw new Error("Failed to create issue."); + } + githubIssueId = newIssue.number; + // Ensure we remove the old message, and replace it with an exact copy, + // but with the issue ID & isOriginalIssue set in additional_kwargs. + newMessages.push( + ...[ + new RemoveMessage({ + id: userMessage.id ?? "", + }), + new HumanMessage({ + ...userMessage, + additional_kwargs: { + githubIssueId: githubIssueId, + isOriginalIssue: true, + }, + }), + ], + ); + } else if ( + githubIssueId && + state.messages.filter(isHumanMessage).length > 1 + ) { + // If there already is a GitHub issue ID in state, and multiple human messages, add any + // human messages to the issue which weren't already added. + const messagesNotInIssue = state.messages + .filter(isHumanMessage) + .filter((message) => { + // If the message doesn't contain `githubIssueId` in additional kwargs, it hasn't been added to the issue. + return !message.additional_kwargs?.githubIssueId; + }); + + const createCommentsPromise = messagesNotInIssue.map(async (message) => { + const createdIssue = await createIssueComment({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + issueNumber: githubIssueId, + body: getMessageContentString(message.content), + githubToken: githubAccessToken, + }); + if (!createdIssue?.id) { + throw new Error("Failed to create issue comment"); + } + newMessages.push( + ...[ + new RemoveMessage({ + id: message.id ?? "", + }), + new HumanMessage({ + ...message, + additional_kwargs: { + githubIssueId: githubIssueId, + githubIssueCommentId: createdIssue.id, + }, + }), + ], + ); + }); + + await Promise.all(createCommentsPromise); + } + + // Issue has been created, and any missing human messages have been added to it. + + const commandUpdate: ManagerGraphUpdate = { + messages: newMessages, + ...(githubIssueId ? { githubIssueId } : {}), + }; + + if ((toolCallArgs.route as any) === "code") { + // If the route was code, we don't need to do anything since the issue now contains the new messages, and the coding agent will handle pulling them in. + return new Command({ + update: commandUpdate, + goto: END, + }); + } + + if (toolCallArgs.route === "plan") { + // Always kickoff a new start planner node. This will enqueue new runs on the planner graph. + return new Command({ + update: commandUpdate, + goto: "start-planner", + }); + } + + throw new Error(`Invalid route: ${toolCallArgs.route}`); +} diff --git a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts new file mode 100644 index 00000000..211a5a1b --- /dev/null +++ b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts @@ -0,0 +1,107 @@ +import { v4 as uuidv4 } from "uuid"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphState, ManagerGraphUpdate } from "../types.js"; +import { createIssueTitleAndBodyFromMessages } from "../utils/generate-issue-fields.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; +import { createLangGraphClient } from "../../../utils/langgraph-client.js"; +import { createIssue } from "../../../utils/github/api.js"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; +import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; +import { + ISSUE_TITLE_CLOSE_TAG, + ISSUE_TITLE_OPEN_TAG, + ISSUE_CONTENT_CLOSE_TAG, + ISSUE_CONTENT_OPEN_TAG, + formatContentForIssueBody, +} from "../../../utils/github/issue-messages.js"; +import { getBranchName } from "../../../utils/github/git.js"; + +/** + * Create new manager session. + * This node will extract the issue title & body from the conversation history, + * create a new issue with those fields, then start a new manager session to + * handle the user's new request/GitHub issue. + */ +export async function createNewSession( + state: ManagerGraphState, + config: GraphConfig, +): Promise { + const titleAndContent = await createIssueTitleAndBodyFromMessages( + state.messages, + config, + ); + const { githubAccessToken } = getGitHubTokensFromConfig(config); + const newIssue = await createIssue({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + title: titleAndContent.title, + body: formatContentForIssueBody(titleAndContent.body), + githubAccessToken, + }); + if (!newIssue) { + throw new Error("Failed to create new issue"); + } + + const inputMessages: BaseMessage[] = [ + new HumanMessage({ + id: uuidv4(), + content: `${ISSUE_TITLE_OPEN_TAG} + ${titleAndContent.title} +${ISSUE_TITLE_CLOSE_TAG} + +${ISSUE_CONTENT_OPEN_TAG} + ${titleAndContent.body} +${ISSUE_CONTENT_CLOSE_TAG}`, + additional_kwargs: { + githubIssueId: newIssue.id, + isOriginalIssue: true, + }, + }), + new AIMessage({ + id: uuidv4(), + content: + "I've successfully created a new GitHub issue for your request, and started a planning session for it!", + }), + ]; + + const langGraphClient = createLangGraphClient({ + defaultHeaders: { + [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", + [GITHUB_INSTALLATION_TOKEN_COOKIE]: + config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", + }, + }); + + const newManagerThreadId = uuidv4(); + const commandUpdate: ManagerGraphUpdate = { + githubIssueId: newIssue.id, + targetRepository: state.targetRepository, + messages: inputMessages, + branchName: state.branchName ?? getBranchName(config), + }; + await langGraphClient.runs.create(newManagerThreadId, "manager", { + input: {}, + command: { + update: commandUpdate, + goto: "start-planner", + }, + config: { + recursion_limit: 400, + }, + ifNotExists: "create", + }); + + return { + messages: [ + new AIMessage({ + id: uuidv4(), + content: `Success! I just created a new session for your request. Thread ID: ${newManagerThreadId} + + TODO: Replace with link to new thread.`, + }), + ], + }; +} diff --git a/apps/open-swe/src/graphs/manager/nodes/index.ts b/apps/open-swe/src/graphs/manager/nodes/index.ts new file mode 100644 index 00000000..a1751bad --- /dev/null +++ b/apps/open-swe/src/graphs/manager/nodes/index.ts @@ -0,0 +1,4 @@ +export * from "./initialize-github-issue.js"; +export * from "./classify-message.js"; +export * from "./start-planner.js"; +export * from "./create-new-session.js"; diff --git a/apps/open-swe/src/graphs/manager/nodes/initialize-github-issue.ts b/apps/open-swe/src/graphs/manager/nodes/initialize-github-issue.ts new file mode 100644 index 00000000..7f58b537 --- /dev/null +++ b/apps/open-swe/src/graphs/manager/nodes/initialize-github-issue.ts @@ -0,0 +1,83 @@ +import { v4 as uuidv4 } from "uuid"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphState, ManagerGraphUpdate } from "../types.js"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; +import { HumanMessage, isHumanMessage } from "@langchain/core/messages"; +import { getIssue } from "../../../utils/github/api.js"; +import { extractTasksFromIssueContent } from "../../../utils/github/issue-task.js"; +import { getMessageContentFromIssue } from "../../../utils/github/issue-messages.js"; + +/** + * The initialize function will do nothing if there's already a human message + * in the state. If not, it will attempt to get the human message from the GitHub issue. + */ +export async function initializeGithubIssue( + state: ManagerGraphState, + config: GraphConfig, +): Promise { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + let taskPlan = state.taskPlan; + + if (state.messages.length && state.messages.some(isHumanMessage)) { + // If there are messages, & at least one is a human message, only attempt to read the updated plan from the issue. + if (state.githubIssueId) { + const issue = await getIssue({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + issueNumber: state.githubIssueId, + githubInstallationToken, + }); + if (!issue) { + throw new Error("Issue not found"); + } + if (issue.body) { + const extractedTaskPlan = extractTasksFromIssueContent(issue.body); + if (extractedTaskPlan) { + taskPlan = extractedTaskPlan; + } + } + } + + return { + taskPlan, + }; + } + + // If there are no messages, ensure there's a GitHub issue to fetch the message from. + if (!state.githubIssueId) { + throw new Error("GitHub issue ID not provided"); + } + if (!state.targetRepository) { + throw new Error("Target repository not provided"); + } + + const issue = await getIssue({ + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + issueNumber: state.githubIssueId, + githubInstallationToken, + }); + if (!issue) { + throw new Error("Issue not found"); + } + if (issue.body) { + const extractedTaskPlan = extractTasksFromIssueContent(issue.body); + if (extractedTaskPlan) { + taskPlan = extractedTaskPlan; + } + } + + const newMessage = new HumanMessage({ + id: uuidv4(), + content: getMessageContentFromIssue(issue), + additional_kwargs: { + githubIssueId: state.githubIssueId, + isOriginalIssue: true, + }, + }); + + return { + messages: [newMessage], + taskPlan, + }; +} diff --git a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts new file mode 100644 index 00000000..b97405f8 --- /dev/null +++ b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts @@ -0,0 +1,65 @@ +import { v4 as uuidv4 } from "uuid"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { ManagerGraphState } from "../types.js"; +import { createLangGraphClient } from "../../../utils/langgraph-client.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; +import { getBranchName } from "../../../utils/github/git.js"; + +const logger = createLogger(LogLevel.INFO, "StartPlanner"); + +/** + * Start planner node. + * This node will kickoff a new planner session using the LangGraph SDK. + */ +export async function startPlanner( + state: ManagerGraphState, + config: GraphConfig, +) { + const langGraphClient = createLangGraphClient({ + defaultHeaders: { + [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", + [GITHUB_INSTALLATION_TOKEN_COOKIE]: + config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", + }, + }); + + const plannerThreadId = state.plannerThreadId ?? uuidv4(); + try { + await langGraphClient.runs.create(plannerThreadId, "planner", { + input: { + // github issue ID & target repo so the planning agent can fetch the user's request, and clone the repo. + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + // Include the existing task plan, so the agent can use it as context when generating followup tasks. + taskPlan: state.taskPlan, + branchName: state.branchName ?? getBranchName(config), + }, + config: { + recursion_limit: 400, + }, + ifNotExists: "create", + multitaskStrategy: "enqueue", + }); + + return { + plannerThreadId, + }; + } catch (error) { + logger.error("Failed to start planner", { + ...(error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : { + error, + }), + }); + throw error; + } +} diff --git a/apps/open-swe/src/graphs/manager/types.ts b/apps/open-swe/src/graphs/manager/types.ts new file mode 100644 index 00000000..8d8c538c --- /dev/null +++ b/apps/open-swe/src/graphs/manager/types.ts @@ -0,0 +1,40 @@ +import { MessagesZodState } from "@langchain/langgraph"; +import { TargetRepository, TaskPlan } from "@open-swe/shared/open-swe/types"; +import { z } from "zod"; + +export const ManagerGraphStateObj = MessagesZodState.extend({ + /** + * The GitHub issue number that the user's request is associated with. + * If not provided when the graph is invoked, it will create an issue. + */ + githubIssueId: z.number(), + /** + * The GitHub pull request number of the PR which resolves the user's request. + * If not provided when the graph is invoked, it will create a PR. + */ + githubPullRequestId: z.number().optional(), + /** + * The target repository the request should be executed in. + */ + targetRepository: z.custom(), + /** + * The tasks generated for this request. + */ + taskPlan: z.custom(), + /** + * The programmer thread ID + */ + programmerThreadId: z.string().optional(), + /** + * The planner thread ID + */ + plannerThreadId: z.string().optional(), + /** + * The branch name to checkout and make changes on. + * Can be user specified, or defaults to `open-swe/ + */ + branchName: z.string(), +}); + +export type ManagerGraphState = z.infer; +export type ManagerGraphUpdate = Partial; diff --git a/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts b/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts new file mode 100644 index 00000000..45d47fbd --- /dev/null +++ b/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts @@ -0,0 +1,61 @@ +import { BaseMessage } from "@langchain/core/messages"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { traceable } from "langsmith/traceable"; +import { z } from "zod"; +import { loadModel, Task } from "../../../utils/load-model.js"; +import { getMessageString } from "../../../utils/message/content.js"; + +async function createIssueTitleAndBodyFromMessagesFunc( + messages: BaseMessage[], + config: GraphConfig, +): Promise<{ title: string; body: string }> { + const model = await loadModel(config, Task.ACTION_GENERATOR); + const githubIssueTool = { + name: "create_github_issue", + description: "Create a new GitHub issue with the given title and body.", + schema: z.object({ + title: z + .string() + .describe( + "The title of the issue to create. Should be concise and clear.", + ), + body: z + .string() + .describe( + "The body of the issue to create. This should be an extremely concise description of the issue. You should not over-explain the issue, as we do not want to waste the user's time. Do not include any additional context not found in the conversation history.", + ), + }), + }; + const modelWithTools = model + .bindTools([githubIssueTool], { + tool_choice: githubIssueTool.name, + parallel_tool_calls: false, + }) + .withConfig({ tags: ["nostream"] }); + + const prompt = `You're an AI programmer, tasked with taking the conversation history provided below, and creating a new GitHub issue. +Ensure the issue title and body are both clear and concise. Do not hallucinate any information not found in the conversation history. +You should mainly be looking at the human messages as context for the issue. + +# Conversation History +${messages.map(getMessageString).join("\n")} + +With the above conversation history in mind, please call the ${githubIssueTool.name} tool to create a new GitHub issue based on the user's request.`; + + const result = await modelWithTools.invoke([ + { + role: "user", + content: prompt, + }, + ]); + const toolCall = result.tool_calls?.[0]; + if (!toolCall) { + throw new Error("No tool call found in result"); + } + return toolCall.args as z.infer; +} + +export const createIssueTitleAndBodyFromMessages = traceable( + createIssueTitleAndBodyFromMessagesFunc, + { name: "create-issue-title-and-body-from-messages" }, +); diff --git a/apps/open-swe/src/subgraphs/planner/index.ts b/apps/open-swe/src/graphs/planner/index.ts similarity index 60% rename from apps/open-swe/src/subgraphs/planner/index.ts rename to apps/open-swe/src/graphs/planner/index.ts index d24541f2..eb6a471c 100644 --- a/apps/open-swe/src/subgraphs/planner/index.ts +++ b/apps/open-swe/src/graphs/planner/index.ts @@ -1,16 +1,5 @@ -/** - * Subgraph for gathering context & generating a plan. - * pre-requisites: - * VM is already booted & repo is cloned (init node) - * Steps: - */ - import { END, START, StateGraph } from "@langchain/langgraph"; -import { - PlannerGraphState, - PlannerGraphStateObj, - PlannerGraphUpdate, -} from "./types.js"; +import { PlannerGraphState, PlannerGraphStateObj } from "./types.js"; import { GraphConfig, GraphConfiguration, @@ -18,17 +7,20 @@ import { import { generateAction, generatePlan, + interruptProposedPlan, + prepareGraphState, summarizer, takeAction, } from "./nodes/index.js"; -import { isAIMessage, RemoveMessage } from "@langchain/core/messages"; +import { isAIMessage } from "@langchain/core/messages"; +import { initializeSandbox } from "../shared/initialize-sandbox.js"; function takeActionOrGeneratePlan( state: PlannerGraphState, config: GraphConfig, ): "take-plan-action" | "generate-plan" { - const { plannerMessages } = state; - const lastMessage = plannerMessages[plannerMessages.length - 1]; + const { messages } = state; + const lastMessage = messages[messages.length - 1]; // If the last message is a tool call, and we have executed less than 6 actions, take action. // Max actions count is calculated as: maxContextActions * 2 + 1 // This is because each action generates 2 messages (AI request + tool result) plus 1 initial human message @@ -37,7 +29,7 @@ function takeActionOrGeneratePlan( if ( isAIMessage(lastMessage) && lastMessage.tool_calls?.length && - plannerMessages.length < maxActionsCount + messages.length < maxActionsCount ) { return "take-plan-action"; } @@ -46,24 +38,18 @@ 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("prepare-graph-state", prepareGraphState, { + ends: [END, "initialize-sandbox"], + }) + .addNode("initialize-sandbox", initializeSandbox) .addNode("generate-plan-context-action", generateAction) .addNode("take-plan-action", takeAction) .addNode("generate-plan", generatePlan) .addNode("summarizer", summarizer) + .addNode("interrupt-proposed-plan", interruptProposedPlan) .addEdge(START, "prepare-graph-state") - .addEdge("prepare-graph-state", "generate-plan-context-action") + .addEdge("initialize-sandbox", "generate-plan-context-action") .addConditionalEdges( "generate-plan-context-action", takeActionOrGeneratePlan, @@ -71,8 +57,8 @@ const workflow = new StateGraph(PlannerGraphStateObj, GraphConfiguration) ) .addEdge("take-plan-action", "generate-plan-context-action") .addEdge("generate-plan", "summarizer") - .addEdge("summarizer", END); + .addEdge("summarizer", "interrupt-proposed-plan") + .addEdge("interrupt-proposed-plan", END); -// TODO: Fix zod types -export const plannerGraph = workflow.compile() as any; -plannerGraph.name = "Planner"; +export const graph = workflow.compile(); +graph.name = "Open SWE - Planner"; diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts b/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts similarity index 76% rename from apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts rename to apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts index b31f33fe..1bc32d03 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/generate-message/index.ts +++ b/apps/open-swe/src/graphs/planner/nodes/generate-message/index.ts @@ -4,20 +4,24 @@ import { PlannerGraphState, PlannerGraphUpdate } from "../../types.js"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; import { createLogger, LogLevel } from "../../../../utils/logger.js"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { getUserRequest } from "../../../../utils/user-request.js"; -import { isHumanMessage } from "@langchain/core/messages"; -import { formatFollowupMessagePrompt } from "../../utils/followup-prompt.js"; +import { + formatFollowupMessagePrompt, + isFollowupRequest, +} from "../../utils/followup.js"; import { SYSTEM_PROMPT } from "./prompt.js"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; +import { getMissingMessages } from "../../../../utils/github/issue-messages.js"; const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode"); function formatSystemPrompt(state: PlannerGraphState): string { // It's a followup if there's more than one human message. - const isFollowup = state.internalMessages.filter(isHumanMessage).length > 1; + const isFollowup = isFollowupRequest(state.taskPlan, state.proposedPlan); return SYSTEM_PROMPT.replace( "{FOLLOWUP_MESSAGE_PROMPT}", - isFollowup ? formatFollowupMessagePrompt(state.plan) : "", + isFollowup + ? formatFollowupMessagePrompt(state.taskPlan, state.proposedPlan) + : "", ) .replaceAll( "{CODEBASE_TREE}", @@ -40,10 +44,7 @@ export async function generateAction( parallel_tool_calls: false, }); - const userRequest = getUserRequest(state.internalMessages, { - returnFullMessage: true, - }); - + const missingMessages = await getMissingMessages(state, config); const response = await modelWithTools .withConfig({ tags: ["nostream"] }) .invoke([ @@ -51,8 +52,8 @@ export async function generateAction( role: "system", content: formatSystemPrompt(state), }, - userRequest, - ...state.plannerMessages, + ...state.messages, + ...missingMessages, ]); logger.info("Generated planning message", { @@ -66,7 +67,6 @@ export async function generateAction( }); return { - messages: [response], - plannerMessages: [response], + messages: [...missingMessages, response], }; } diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts b/apps/open-swe/src/graphs/planner/nodes/generate-message/prompt.ts similarity index 100% rename from apps/open-swe/src/subgraphs/planner/nodes/generate-message/prompt.ts rename to apps/open-swe/src/graphs/planner/nodes/generate-message/prompt.ts diff --git a/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts b/apps/open-swe/src/graphs/planner/nodes/generate-plan.ts similarity index 86% rename from apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts rename to apps/open-swe/src/graphs/planner/nodes/generate-plan.ts index a67ca6f1..c340bd0b 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/generate-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/generate-plan.ts @@ -1,14 +1,13 @@ -import { - isAIMessage, - isHumanMessage, - ToolMessage, -} from "@langchain/core/messages"; +import { isAIMessage, ToolMessage } from "@langchain/core/messages"; import { createSessionPlanToolFields } from "../../../tools/index.js"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; 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 { + formatFollowupMessagePrompt, + isFollowupRequest, +} from "../utils/followup.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. @@ -36,13 +35,15 @@ The user's request is as follows. Ensure you generate your plan in accordance wi function formatSystemPrompt(state: PlannerGraphState): string { // It's a followup if there's more than one human message. - const isFollowup = state.internalMessages.filter(isHumanMessage).length > 1; - const userRequest = getUserRequest(state.internalMessages); + const isFollowup = isFollowupRequest(state.taskPlan, state.proposedPlan); + const userRequest = getUserRequest(state.messages); return systemPrompt .replace( "{FOLLOWUP_MESSAGE_PROMPT}", - isFollowup ? formatFollowupMessagePrompt(state.plan) : "", + isFollowup + ? formatFollowupMessagePrompt(state.taskPlan, state.proposedPlan) + : "", ) .replace("{USER_REQUEST}", userRequest); } @@ -59,7 +60,7 @@ export async function generatePlan( }); let optionalToolMessage: ToolMessage | undefined; - const lastMessage = state.plannerMessages[state.plannerMessages.length - 1]; + const lastMessage = state.messages[state.messages.length - 1]; if (isAIMessage(lastMessage) && lastMessage.tool_calls?.[0]) { const lastMessageToolCall = lastMessage.tool_calls?.[0]; optionalToolMessage = new ToolMessage({ @@ -76,7 +77,7 @@ export async function generatePlan( role: "system", content: formatSystemPrompt(state), }, - ...state.plannerMessages, + ...state.messages, ...(optionalToolMessage ? [optionalToolMessage] : []), ]); @@ -94,7 +95,5 @@ export async function generatePlan( messages: [response], proposedPlan: response.tool_calls[0].args.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/index.ts b/apps/open-swe/src/graphs/planner/nodes/index.ts similarity index 67% rename from apps/open-swe/src/subgraphs/planner/nodes/index.ts rename to apps/open-swe/src/graphs/planner/nodes/index.ts index 8c38ef8f..e5f47b43 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/index.ts +++ b/apps/open-swe/src/graphs/planner/nodes/index.ts @@ -2,3 +2,5 @@ export * from "./generate-message/index.js"; export * from "./take-action.js"; export * from "./generate-plan.js"; export * from "./summarizer.js"; +export * from "./proposed-plan.js"; +export * from "./prepare-state.js"; diff --git a/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts b/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts new file mode 100644 index 00000000..fd68beec --- /dev/null +++ b/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts @@ -0,0 +1,117 @@ +import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; +import { Command, END } from "@langchain/langgraph"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; +import { getIssue, getIssueComments } from "../../../utils/github/api.js"; +import { v4 as uuidv4 } from "uuid"; +import { + AIMessage, + BaseMessage, + HumanMessage, + isHumanMessage, + RemoveMessage, +} from "@langchain/core/messages"; +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { + getMessageContentFromIssue, + getUntrackedComments, +} from "../../../utils/github/issue-messages.js"; + +export async function prepareGraphState( + state: PlannerGraphState, + config: GraphConfig, +): Promise { + if (!state.githubIssueId) { + throw new Error("No github issue id provided"); + } + if (!state.targetRepository) { + throw new Error("No target repository provided"); + } + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const baseGetIssueInputs = { + owner: state.targetRepository.owner, + repo: state.targetRepository.repo, + issueNumber: state.githubIssueId, + githubInstallationToken, + }; + const [issue, comments] = await Promise.all([ + getIssue(baseGetIssueInputs), + getIssueComments({ + ...baseGetIssueInputs, + filterBotComments: true, + }), + ]); + if (!issue) { + throw new Error(`Issue not found. Issue ID: ${state.githubIssueId}`); + } + + // Ensure the main issue & all comments are included in the state; + + // If the messages state is empty, we can just include all comments as human messages. + if (!state.messages?.length) { + const commandUpdate: PlannerGraphUpdate = { + messages: [ + new HumanMessage({ + id: uuidv4(), + content: getMessageContentFromIssue(issue), + additional_kwargs: { + githubIssueId: state.githubIssueId, + isOriginalIssue: true, + }, + }), + ...(comments ?? []).map( + (comment) => + new HumanMessage({ + id: uuidv4(), + content: getMessageContentFromIssue(comment), + additional_kwargs: { + githubIssueId: state.githubIssueId, + githubIssueCommentId: comment.id, + }, + }), + ), + ], + }; + return new Command({ + update: commandUpdate, + goto: "initialize-sandbox", + }); + } + + const untrackedComments = getUntrackedComments( + state.messages, + state.githubIssueId, + comments ?? [], + ); + if (!untrackedComments?.length) { + // If there are already messages in the state, and no comments, we can assume the issue is already handled. + return new Command({ + goto: END, + }); + } + + // Remove all messages not marked as summaryMessage, and not human messages. + const removedNonSummaryMessages = state.messages + .filter((m) => !m.additional_kwargs?.summaryMessage && !isHumanMessage(m)) + .map((m: BaseMessage) => new RemoveMessage({ id: m.id ?? "" })); + const summaryMessage = new AIMessage({ + id: uuidv4(), + content: state.planContextSummary, + additional_kwargs: { + summaryMessage: true, + }, + }); + const commandUpdate: PlannerGraphUpdate = { + messages: [ + ...removedNonSummaryMessages, + summaryMessage, + ...untrackedComments, + ], + // Reset plan context summary as it's now included in the messages array. + planContextSummary: "", + }; + + return new Command({ + update: commandUpdate, + goto: "initialize-sandbox", + }); +} diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts new file mode 100644 index 00000000..c7154d75 --- /dev/null +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -0,0 +1,134 @@ +import { v4 as uuidv4 } from "uuid"; +import { Command, END, interrupt } from "@langchain/langgraph"; +import { GraphUpdate, GraphConfig } from "@open-swe/shared/open-swe/types"; +import { + ActionRequest, + HumanInterrupt, + HumanResponse, +} from "@langchain/langgraph/prebuilt"; +import { startSandbox } from "../../../utils/sandbox.js"; +import { createNewTask } from "@open-swe/shared/open-swe/tasks"; +import { getUserRequest } from "../../../utils/user-request.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, + PLAN_INTERRUPT_ACTION_TITLE, + PLAN_INTERRUPT_DELIMITER, +} from "@open-swe/shared/constants"; +import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; +import { createLangGraphClient } from "../../../utils/langgraph-client.js"; +import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js"; + +export async function interruptProposedPlan( + state: PlannerGraphState, + config: GraphConfig, +): Promise { + const { proposedPlan } = state; + if (!proposedPlan.length) { + throw new Error("No proposed plan found."); + } + + const interruptRes = interrupt({ + action_request: { + action: PLAN_INTERRUPT_ACTION_TITLE, + args: { + plan: proposedPlan.join(`\n${PLAN_INTERRUPT_DELIMITER}\n`), + }, + }, + config: { + allow_accept: true, + allow_edit: true, + allow_respond: true, + allow_ignore: true, + }, + description: `A new plan has been generated for your request. Please review it and either approve it, edit it, respond to it, or ignore it. Responses will be passed to an LLM where it will rewrite then plan. + If editing the plan, ensure each step in the plan is separated by "${PLAN_INTERRUPT_DELIMITER}".`, + })[0]; + + if (!state.sandboxSessionId) { + // TODO: This should prob just create a sandbox? + throw new Error("No sandbox session ID found."); + } + + if (interruptRes.type === "response") { + // Plan was responded to, route to the rewrite plan node. + throw new Error("RESPONDING TO PLAN NOT IMPLEMENTED."); + } + + if (interruptRes.type === "ignore") { + // Plan was ignored, end the process. + return new Command({ + goto: END, + }); + } + + const langGraphClient = createLangGraphClient({ + defaultHeaders: { + [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", + [GITHUB_INSTALLATION_TOKEN_COOKIE]: + config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", + }, + }); + + const userRequest = getUserRequest(state.messages); + + const runInput: GraphUpdate = { + planContextSummary: state.planContextSummary, + branchName: state.branchName, + targetRepository: state.targetRepository, + githubIssueId: state.githubIssueId, + }; + // TODO: UPDATE ISSUE WITH PROGRAMMER THREAD ID. + // TODO: UPDATE ISSUE WITH TASK PLAN + const programmerThreadId = uuidv4(); + + if (interruptRes.type === "accept") { + const planItems = proposedPlan.map((p, index) => ({ + index, + plan: p, + completed: false, + })); + + runInput.taskPlan = createNewTask(userRequest, planItems, state.taskPlan); + } else if (interruptRes.type === "edit") { + const editedPlan = (interruptRes.args as ActionRequest).args.plan + .split(PLAN_INTERRUPT_DELIMITER) + .map((step: string) => step.trim()); + + const planItems = editedPlan.map((p: string, index: number) => ({ + index, + plan: p, + completed: false, + })); + + runInput.taskPlan = createNewTask(userRequest, planItems, state.taskPlan); + } else { + throw new Error("Unknown interrupt type." + interruptRes.type); + } + + // Restart the sandbox. + runInput.sandboxSessionId = (await startSandbox(state.sandboxSessionId)).id; + + await langGraphClient.runs.create(programmerThreadId, "programmer", { + input: runInput, + config: { + recursion_limit: 400, + }, + ifNotExists: "create", + }); + + await addTaskPlanToIssue( + { + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + }, + config, + runInput.taskPlan, + ); + + return { + programmerThreadId, + sandboxSessionId: runInput.sandboxSessionId, + taskPlan: runInput.taskPlan, + }; +} diff --git a/apps/open-swe/src/nodes/rewrite-plan.ts b/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts similarity index 92% rename from apps/open-swe/src/nodes/rewrite-plan.ts rename to apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts index 086288bf..9336e6c4 100644 --- a/apps/open-swe/src/nodes/rewrite-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts @@ -1,15 +1,14 @@ -import { - GraphState, - GraphConfig, - GraphUpdate, - PlanItem, -} from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; +// TODO: NOT HOOKED UP TO THE GRAPH YET +// TODO: WILL NEED TO REFACTOR TO ALLOW FOR CHATTING WITH PLANNING SUBGRAPH + +import { GraphConfig, PlanItem } from "@open-swe/shared/open-swe/types"; 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"; +import { PlannerGraphState, PlannerGraphUpdate } from "../types.js"; +import { getUserRequest } from "../../../utils/user-request.js"; +import { loadModel, Task } from "../../../utils/load-model.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. @@ -87,7 +86,7 @@ const formatSysPromptRewritePlan = ( }; async function identifyTasksToModifyFunc( - state: GraphState, + state: PlannerGraphState, model: ConfigurableModel, ): Promise { if (!state.planChangeRequest) { @@ -138,7 +137,7 @@ async function identifyTasksToModifyFunc( }, ); - const userRequest = getUserRequest(state.internalMessages); + const userRequest = getUserRequest(state.messages); const response = await modelWithIdentifyChangesTool.invoke([ { role: "user", @@ -168,7 +167,7 @@ const identifyTasksToModify = traceable(identifyTasksToModifyFunc, { }); async function updatePlanTasksFunc( - state: GraphState, + state: PlannerGraphState, tasksToModify: PlanItem[], model: ConfigurableModel, ): Promise { @@ -201,7 +200,7 @@ async function updatePlanTasksFunc( parallel_tool_calls: false, }); - const userRequest = getUserRequest(state.internalMessages); + const userRequest = getUserRequest(state.messages); const response = await modelWithUpdatePlanTasksTool.invoke([ { role: "user", @@ -231,9 +230,9 @@ const updatePlanTasks = traceable(updatePlanTasksFunc, { }); export async function rewritePlan( - state: GraphState, + state: PlannerGraphState, config: GraphConfig, -): Promise { +): Promise { if (!state.planChangeRequest) { throw new Error("No plan change request found."); } diff --git a/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts b/apps/open-swe/src/graphs/planner/nodes/summarizer.ts similarity index 96% rename from apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts rename to apps/open-swe/src/graphs/planner/nodes/summarizer.ts index 9f0bc991..d975d878 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/summarizer.ts +++ b/apps/open-swe/src/graphs/planner/nodes/summarizer.ts @@ -51,10 +51,10 @@ export async function summarizer( parallel_tool_calls: false, }); - const userRequest = getUserRequest(state.internalMessages); + const userRequest = getUserRequest(state.messages); const conversationHistoryStr = `Here is the full conversation history: -${state.plannerMessages.map(getMessageString).join("\n")}`; +${state.messages.map(getMessageString).join("\n")}`; const response = await modelWithTools.invoke([ { diff --git a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts b/apps/open-swe/src/graphs/planner/nodes/take-action.ts similarity index 96% rename from apps/open-swe/src/subgraphs/planner/nodes/take-action.ts rename to apps/open-swe/src/graphs/planner/nodes/take-action.ts index 6e3f2abb..3d2c1305 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts +++ b/apps/open-swe/src/graphs/planner/nodes/take-action.ts @@ -13,7 +13,7 @@ export async function takeAction( state: PlannerGraphState, _config: GraphConfig, ): Promise { - const { plannerMessages: messages } = state; + const { messages } = state; const lastMessage = messages[messages.length - 1]; if (!isAIMessage(lastMessage) || !lastMessage.tool_calls?.length) { @@ -26,13 +26,11 @@ export async function takeAction( }; const toolCall = lastMessage.tool_calls[0]; - if (!toolCall) { throw new Error("No tool call found."); } const tool = toolsMap[toolCall.name]; - if (!tool) { logger.error(`Unknown tool: ${toolCall.name}`); const toolMessage = new ToolMessage({ @@ -41,9 +39,9 @@ export async function takeAction( name: toolCall.name, status: "error", }); + return { messages: [toolMessage], - plannerMessages: [toolMessage], }; } @@ -95,9 +93,7 @@ export async function takeAction( tool_call_id: toolCall.id, status: toolCallStatus, }); - return { messages: [toolMessage], - plannerMessages: [toolMessage], }; } diff --git a/apps/open-swe/src/graphs/planner/types.ts b/apps/open-swe/src/graphs/planner/types.ts new file mode 100644 index 00000000..60f29114 --- /dev/null +++ b/apps/open-swe/src/graphs/planner/types.ts @@ -0,0 +1,73 @@ +import "@langchain/langgraph/zod"; +import { z } from "zod"; +import { MessagesZodState } from "@langchain/langgraph"; +import { TargetRepository, TaskPlan } from "@open-swe/shared/open-swe/types"; +import { withLangGraph } from "@langchain/langgraph/zod"; + +export const PlannerGraphStateObj = MessagesZodState.extend({ + sandboxSessionId: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + targetRepository: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + githubIssueId: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + codebaseTree: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + taskPlan: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + proposedPlan: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + default: (): string[] => [], + }), + planContextSummary: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + default: () => "", + }), + branchName: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + planChangeRequest: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), + programmerThreadId: withLangGraph(z.custom(), { + reducer: { + schema: z.custom(), + fn: (_state, update) => update, + }, + }), +}); + +export type PlannerGraphState = z.infer; +export type PlannerGraphUpdate = Partial; diff --git a/apps/open-swe/src/graphs/planner/utils/followup.ts b/apps/open-swe/src/graphs/planner/utils/followup.ts new file mode 100644 index 00000000..23b400e1 --- /dev/null +++ b/apps/open-swe/src/graphs/planner/utils/followup.ts @@ -0,0 +1,80 @@ +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; +import { TaskPlan } from "@open-swe/shared/open-swe/types"; + +const previousCompletedPlanPrompt = `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 previousProposedPlanPrompt = `Here is the complete list of the proposed plan you generated before the user sent their followup request: +{PREVIOUS_PROPOSED_PLAN}`; + +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. + - The summaries of the actions you took, and their results from previous planning sessions. + - You are only provided this information as context to reference when gathering context for the new plan, or for making changes to the previously generated plan. + +{PREVIOUS_PLAN} +`; + +const formatPreviousPlans = (tasks: TaskPlan): string => { + const formattedTasksAndRequests = 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"); + + return previousCompletedPlanPrompt.replace( + "{PREVIOUS_PLAN}", + formattedTasksAndRequests, + ); +}; + +const formatPreviousProposedPlan = (proposedPlan: string[]): string => { + const formattedProposedPlan = proposedPlan + .map((p) => `${p}`) + .join("\n"); + return previousProposedPlanPrompt.replace( + "{PREVIOUS_PROPOSED_PLAN}", + formattedProposedPlan, + ); +}; + +export function formatFollowupMessagePrompt( + tasks: TaskPlan, + proposedPlan: string[], +): string { + let isGeneratingNewPlan = false; + if (tasks && tasks.tasks?.length) { + const activePlanItems = getActivePlanItems(tasks); + isGeneratingNewPlan = activePlanItems.every((p) => p.completed); + if (!isGeneratingNewPlan && !proposedPlan.length) { + throw new Error( + "Can not format plan prompt if no proposed plan is provided.", + ); + } + } + + return followupMessagePrompt.replace( + "{PREVIOUS_PLAN}", + isGeneratingNewPlan + ? formatPreviousPlans(tasks) + : formatPreviousProposedPlan(proposedPlan), + ); +} + +export function isFollowupRequest( + taskPlan: TaskPlan | undefined, + proposedPlan: string[] | undefined, +) { + return taskPlan?.tasks?.length || proposedPlan?.length; +} diff --git a/apps/open-swe/src/index.ts b/apps/open-swe/src/graphs/programmer/index.ts similarity index 84% rename from apps/open-swe/src/index.ts rename to apps/open-swe/src/graphs/programmer/index.ts index 089f7438..9bc6a4e8 100644 --- a/apps/open-swe/src/index.ts +++ b/apps/open-swe/src/graphs/programmer/index.ts @@ -5,11 +5,8 @@ import { GraphState, } from "@open-swe/shared/open-swe/types"; import { - initialize, generateAction, takeAction, - rewritePlan, - interruptPlan, progressPlanStep, summarizeTaskSteps, generateConclusion, @@ -19,7 +16,7 @@ import { updatePlan, } from "./nodes/index.js"; import { isAIMessage } from "@langchain/core/messages"; -import { plannerGraph } from "./subgraphs/index.js"; +import { initializeSandbox } from "../shared/initialize-sandbox.js"; /** * Routes to the next appropriate node after taking action. @@ -61,12 +58,7 @@ async function routeGeneratedAction( } const workflow = new StateGraph(GraphAnnotation, GraphConfiguration) - .addNode("initialize", initialize) - .addNode("generate-plan-subgraph", plannerGraph) - .addNode("rewrite-plan", rewritePlan) - .addNode("interrupt-plan", interruptPlan, { - ends: [END, "rewrite-plan", "generate-action"], - }) + .addNode("initialize", initializeSandbox) .addNode("generate-action", generateAction) .addNode("take-action", takeAction, { ends: ["progress-plan-step", "diagnose-error"], @@ -85,10 +77,7 @@ const workflow = new StateGraph(GraphAnnotation, GraphConfiguration) .addNode("open-pr", openPullRequest) .addNode("diagnose-error", diagnoseError) .addEdge(START, "initialize") - .addEdge("initialize", "generate-plan-subgraph") - .addEdge("generate-plan-subgraph", "interrupt-plan") - // Always interrupt after rewriting the plan. - .addEdge("rewrite-plan", "interrupt-plan") + .addEdge("initialize", "generate-action") .addConditionalEdges("generate-action", routeGeneratedAction, [ "take-action", "request-help", @@ -102,4 +91,4 @@ const workflow = new StateGraph(GraphAnnotation, GraphConfiguration) // Zod types are messed up export const graph = workflow.compile() as any; -graph.name = "Open Codex"; +graph.name = "Open SWE - Programmer"; diff --git a/apps/open-swe/src/nodes/diagnose-error.ts b/apps/open-swe/src/graphs/programmer/nodes/diagnose-error.ts similarity index 91% rename from apps/open-swe/src/nodes/diagnose-error.ts rename to apps/open-swe/src/graphs/programmer/nodes/diagnose-error.ts index 7b5ea8a9..ab59e0c0 100644 --- a/apps/open-swe/src/nodes/diagnose-error.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/diagnose-error.ts @@ -9,16 +9,16 @@ import { GraphUpdate, PlanItem, } from "@open-swe/shared/open-swe/types"; -import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js"; -import { getMessageString } from "../utils/message/content.js"; +import { formatPlanPromptWithSummaries } from "../../../utils/plan-prompt.js"; +import { getMessageString } from "../../../utils/message/content.js"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { loadModel, Task } from "../utils/load-model.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; import { z } from "zod"; -import { createLogger, LogLevel } from "../utils/logger.js"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; import { getCompletedPlanItems, getCurrentPlanItem, -} from "../utils/current-task.js"; +} from "../../../utils/current-task.js"; import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; const logger = createLogger(LogLevel.INFO, "DiagnoseError"); @@ -68,11 +68,11 @@ const diagnoseErrorTool = { const formatSystemPrompt = ( lastFailedActionContent: string, - plan: PlanItem[], + taskPlan: PlanItem[], codebaseTree: string, ): string => { - const currentPlanItem = getCurrentPlanItem(plan); - const completedTasks = getCompletedPlanItems(plan); + const currentPlanItem = getCurrentPlanItem(taskPlan); + const completedTasks = getCompletedPlanItems(taskPlan); return systemPrompt .replace( @@ -121,7 +121,7 @@ export async function diagnoseError( role: "system", content: formatSystemPrompt( getMessageContentString(lastFailedAction.content), - getActivePlanItems(state.plan), + getActivePlanItems(state.taskPlan), state.codebaseTree, ), }, diff --git a/apps/open-swe/src/nodes/generate-conclusion.ts b/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts similarity index 79% rename from apps/open-swe/src/nodes/generate-conclusion.ts rename to apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts index 19e7dee0..0ec63188 100644 --- a/apps/open-swe/src/nodes/generate-conclusion.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts @@ -4,11 +4,11 @@ import { GraphUpdate, PlanItem, } from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { getMessageString } from "../utils/message/content.js"; -import { createLogger, LogLevel } from "../utils/logger.js"; -import { getUserRequest } from "../utils/user-request.js"; +import { getMessageString } from "../../../utils/message/content.js"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; +import { getUserRequest } from "../../../utils/user-request.js"; import { completeTask, getActivePlanItems, @@ -26,10 +26,10 @@ Since you've successfully completed the user's request, you should now generate Your concision message should be concise and to the point, you do NOT want to include any details which are not ABSOLUTELY NECESSARY. `; -const formatPrompt = (plan: PlanItem[]): string => { +const formatPrompt = (taskPlan: PlanItem[]): string => { return prompt.replace( "{COMPLETED_TASKS}", - plan.map((p) => `${p.index}. ${p.plan}`).join("\n"), + taskPlan.map((p) => `${p.index}. ${p.plan}`).join("\n"), ); }; @@ -53,7 +53,7 @@ Given all of this, please respond with the concise conclusion. Do not include an const response = await model.invoke([ { role: "system", - content: formatPrompt(getActivePlanItems(state.plan)), + content: formatPrompt(getActivePlanItems(state.taskPlan)), }, { role: "user", @@ -62,9 +62,9 @@ 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 activeTaskId = getActiveTask(state.taskPlan).id; const updatedTaskPlan = completeTask( - state.plan, + state.taskPlan, activeTaskId, getMessageContentString(response.content), ); @@ -72,6 +72,6 @@ Given all of this, please respond with the concise conclusion. Do not include an return { messages: [response], internalMessages: [response], - plan: updatedTaskPlan, + taskPlan: updatedTaskPlan, }; } diff --git a/apps/open-swe/src/nodes/generate-message/index.ts b/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts similarity index 68% rename from apps/open-swe/src/nodes/generate-message/index.ts rename to apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts index 0d317afb..bba74651 100644 --- a/apps/open-swe/src/nodes/generate-message/index.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/generate-message/index.ts @@ -3,39 +3,41 @@ import { GraphConfig, GraphUpdate, } from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../../utils/load-model.js"; +import { loadModel, Task } from "../../../../utils/load-model.js"; import { createShellTool, createApplyPatchTool, createRequestHumanHelpToolFields, createUpdatePlanToolFields, -} from "../../tools/index.js"; -import { formatPlanPrompt } from "../../utils/plan-prompt.js"; -import { stopSandbox } from "../../utils/sandbox.js"; -import { createLogger, LogLevel } from "../../utils/logger.js"; -import { getCurrentPlanItem } from "../../utils/current-task.js"; +} from "../../../../tools/index.js"; +import { formatPlanPrompt } from "../../../../utils/plan-prompt.js"; +import { stopSandbox } from "../../../../utils/sandbox.js"; +import { createLogger, LogLevel } from "../../../../utils/logger.js"; +import { getCurrentPlanItem } from "../../../../utils/current-task.js"; import { getMessageContentString } from "@open-swe/shared/messages"; import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; import { SYSTEM_PROMPT } from "./prompt.js"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; +import { getMissingMessages } from "../../../../utils/github/issue-messages.js"; +import { getTaskPlanFromIssue } from "../../../../utils/github/issue-task.js"; const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); const formatPrompt = (state: GraphState): string => { const repoDirectory = getRepoAbsolutePath(state.targetRepository); - const activePlanItems = getActivePlanItems(state.plan); + const activePlanItems = getActivePlanItems(state.taskPlan); const currentPlanItem = activePlanItems .filter((p) => !p.completed) .sort((a, b) => a.index - b.index)[0]; return SYSTEM_PROMPT.replaceAll( "{PLAN_PROMPT_WITH_SUMMARIES}", - formatPlanPrompt(getActivePlanItems(state.plan), { + formatPlanPrompt(getActivePlanItems(state.taskPlan), { includeSummaries: true, }), ) .replaceAll( "{PLAN_PROMPT}", - formatPlanPrompt(getActivePlanItems(state.plan)), + formatPlanPrompt(getActivePlanItems(state.taskPlan)), ) .replaceAll("{REPO_DIRECTORY}", repoDirectory) .replaceAll( @@ -66,12 +68,21 @@ export async function generateAction( parallel_tool_calls: false, }); + const [missingMessages, latestTaskPlan] = await Promise.all([ + getMissingMessages(state, config), + getTaskPlanFromIssue(state, config), + ]); + const response = await modelWithTools.invoke([ { role: "system", - content: formatPrompt(state), + content: formatPrompt({ + ...state, + taskPlan: latestTaskPlan ?? state.taskPlan, + }), }, ...state.internalMessages, + ...missingMessages, ]); const hasToolCalls = !!response.tool_calls?.length; @@ -83,7 +94,7 @@ export async function generateAction( } logger.info("Generated action", { - currentTask: getCurrentPlanItem(getActivePlanItems(state.plan)).plan, + currentTask: getCurrentPlanItem(getActivePlanItems(state.taskPlan)).plan, ...(getMessageContentString(response.content) && { content: getMessageContentString(response.content), }), @@ -93,9 +104,11 @@ export async function generateAction( }), }); + const newMessagesList = [...missingMessages, response]; return { - messages: [response], - internalMessages: [response], + messages: newMessagesList, + internalMessages: newMessagesList, ...(newSandboxSessionId && { sandboxSessionId: newSandboxSessionId }), + ...(latestTaskPlan && { taskPlan: latestTaskPlan }), }; } diff --git a/apps/open-swe/src/nodes/generate-message/prompt.ts b/apps/open-swe/src/graphs/programmer/nodes/generate-message/prompt.ts similarity index 100% rename from apps/open-swe/src/nodes/generate-message/prompt.ts rename to apps/open-swe/src/graphs/programmer/nodes/generate-message/prompt.ts diff --git a/apps/open-swe/src/nodes/index.ts b/apps/open-swe/src/graphs/programmer/nodes/index.ts similarity index 76% rename from apps/open-swe/src/nodes/index.ts rename to apps/open-swe/src/graphs/programmer/nodes/index.ts index 11b40138..799f7e28 100644 --- a/apps/open-swe/src/nodes/index.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/index.ts @@ -1,8 +1,5 @@ -export * from "./initialize.js"; export * from "./generate-message/index.js"; export * from "./take-action.js"; -export * from "./rewrite-plan.js"; -export * from "./interrupt-plan.js"; export * from "./progress-plan-step.js"; export * from "./summarize-task-steps.js"; export * from "./generate-conclusion.js"; diff --git a/apps/open-swe/src/nodes/open-pr.ts b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts similarity index 73% rename from apps/open-swe/src/nodes/open-pr.ts rename to apps/open-swe/src/graphs/programmer/nodes/open-pr.ts index d9613105..a6655850 100644 --- a/apps/open-swe/src/nodes/open-pr.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts @@ -6,18 +6,17 @@ import { } from "@open-swe/shared/open-swe/types"; import { checkoutBranchAndCommit, - createPullRequest, - getBranchName, getChangedFilesStatus, -} from "../utils/git.js"; -import { createLogger, LogLevel } from "../utils/logger.js"; +} from "../../../utils/github/git.js"; +import { createPullRequest } from "../../../utils/github/api.js"; +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 { getUserRequest } from "../utils/user-request.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; +import { formatPlanPromptWithSummaries } from "../../../utils/plan-prompt.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 { daytonaClient, deleteSandbox } from "../../../utils/sandbox.js"; +import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; @@ -39,13 +38,13 @@ const openPrToolSchema = z.object({ title: z .string() .describe( - "The title of the pull request. Ensure this is a concise and thoughtful title. You should follow conventional commit title format (e.g. prefixing with 'fix:', 'feat:', 'chore:', etc.).", + "The title of the pull request. Ensure this is a concise and thoughtful title. You should follow conventional commit title format (e.g. prefixing with '[open-swe] fix:', '[open-swe] feat:', '[open-swe] chore:', etc.). Remember to include the '[open-swe]' prefix in the title.", ), body: z .string() .optional() .describe( - "The body of the pull request. This should provide a detailed description of the changes you've made, and why you've made them. Ensure you do not over-explain the changes, as we do not want to waste the user's time.", + "The body of the pull request. This should provide a concise description what the PR changes. Do not over-explain, or add technical details unless they're the absolute minimum needed. The user should be able to quickly read your description, and understand what the PR does. Remember: if they want the technical details they can read the changed files, so you don't need to go into great detail here.", ), }); @@ -55,8 +54,8 @@ const openPrTool = { description: "Use this tool to open a pull request.", }; -const formatPrompt = (plan: PlanItem[], userRequest: string): string => { - const completedTasks = plan.filter((task) => task.completed); +const formatPrompt = (taskPlan: PlanItem[], userRequest: string): string => { + const completedTasks = taskPlan.filter((task) => task.completed); return openPrSysPrompt .replace("{COMPLETED_TASKS}", formatPlanPromptWithSummaries(completedTasks)) .replace("{USER_REQUEST}", userRequest); @@ -113,7 +112,7 @@ export async function openPullRequest( const response = await modelWithTool.invoke([ { role: "user", - content: formatPrompt(getActivePlanItems(state.plan), userRequest), + content: formatPrompt(getActivePlanItems(state.taskPlan), userRequest), }, ]); @@ -130,10 +129,11 @@ export async function openPullRequest( const pr = await createPullRequest({ owner, repo, - headBranch: branchName ?? getBranchName(config), + headBranch: branchName, title, - body, + body: `Fixes #${state.githubIssueId}\n\n${body}`, githubInstallationToken, + baseBranch: state.targetRepository.branch, }); let sandboxDeleted = false; diff --git a/apps/open-swe/src/nodes/progress-plan-step.ts b/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts similarity index 83% rename from apps/open-swe/src/nodes/progress-plan-step.ts rename to apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts index 06e2c46b..0b5188f3 100644 --- a/apps/open-swe/src/nodes/progress-plan-step.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts @@ -1,17 +1,17 @@ import { z } from "zod"; -import { createLogger, LogLevel } from "../utils/logger.js"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; import { GraphConfig, GraphState, GraphUpdate, PlanItem, } from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; -import { formatPlanPrompt } from "../utils/plan-prompt.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 { removeFirstHumanMessage } from "../utils/message/modify-array.js"; -import { getUserRequest } from "../utils/user-request.js"; +import { getMessageString } from "../../../utils/message/content.js"; +import { removeFirstHumanMessage } from "../../../utils/message/modify-array.js"; +import { getUserRequest } from "../../../utils/user-request.js"; import { completePlanItem, getActivePlanItems, @@ -20,8 +20,9 @@ import { import { getCurrentPlanItem, getRemainingPlanItems, -} from "../utils/current-task.js"; +} from "../../../utils/current-task.js"; import { ToolMessage } from "@langchain/core/messages"; +import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js"; const logger = createLogger(LogLevel.INFO, "ProgressPlanStep"); @@ -59,10 +60,10 @@ const setTaskStatusTool = { schema: setTaskStatusToolSchema, }; -const formatPrompt = (plan: PlanItem[]): string => { +const formatPrompt = (taskPlan: PlanItem[]): string => { return systemPrompt.replace( "{PLAN_PROMPT}", - formatPlanPrompt(plan, { includeSummaries: true }), + formatPlanPrompt(taskPlan, { includeSummaries: true }), ); }; @@ -86,7 +87,7 @@ ${removeFirstHumanMessage(state.internalMessages).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 activePlanItems = getActivePlanItems(state.taskPlan); const response = await modelWithTools.invoke([ { @@ -140,10 +141,19 @@ Once you've determined the status of the current task, call the \`set_task_statu // LLM marked as completed, so we need to update the plan to reflect that. const updatedPlanTasks = completePlanItem( - state.plan, - getActiveTask(state.plan).id, + state.taskPlan, + getActiveTask(state.taskPlan).id, currentTask.index, ); + // Update the github issue to reflect this task as completed. + await addTaskPlanToIssue( + { + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + }, + config, + updatedPlanTasks, + ); // This should in theory never happen, but ensure we route properly if it does. const remainingTask = getRemainingPlanItems(activePlanItems)?.[0]; @@ -155,7 +165,7 @@ Once you've determined the status of the current task, call the \`set_task_statu messages: newMessages, internalMessages: newMessages, // Even though there are no remaining tasks, still mark as completed so the UI reflects that the task is completed. - plan: updatedPlanTasks, + taskPlan: updatedPlanTasks, }; return new Command({ goto: "generate-conclusion", @@ -173,7 +183,7 @@ Once you've determined the status of the current task, call the \`set_task_statu const commandUpdate: GraphUpdate = { messages: newMessages, internalMessages: newMessages, - plan: updatedPlanTasks, + taskPlan: updatedPlanTasks, }; return new Command({ diff --git a/apps/open-swe/src/nodes/request-help.ts b/apps/open-swe/src/graphs/programmer/nodes/request-help.ts similarity index 96% rename from apps/open-swe/src/nodes/request-help.ts rename to apps/open-swe/src/graphs/programmer/nodes/request-help.ts index ee270e74..beb46c4c 100644 --- a/apps/open-swe/src/nodes/request-help.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/request-help.ts @@ -2,7 +2,7 @@ import { isAIMessage, ToolMessage } from "@langchain/core/messages"; import { GraphState, GraphUpdate } from "@open-swe/shared/open-swe/types"; import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt"; import { END, interrupt, Command } from "@langchain/langgraph"; -import { stopSandbox, startSandbox } from "../utils/sandbox.js"; +import { stopSandbox, startSandbox } from "../../../utils/sandbox.js"; const constructDescription = (helpRequest: string): string => { return `The agent has requested help. Here is the help request: diff --git a/apps/open-swe/src/nodes/summarize-task-steps.ts b/apps/open-swe/src/graphs/programmer/nodes/summarize-task-steps.ts similarity index 87% rename from apps/open-swe/src/nodes/summarize-task-steps.ts rename to apps/open-swe/src/graphs/programmer/nodes/summarize-task-steps.ts index 46027aec..9c9b09fc 100644 --- a/apps/open-swe/src/nodes/summarize-task-steps.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/summarize-task-steps.ts @@ -5,13 +5,13 @@ import { GraphUpdate, PlanItem, } from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; import { AIMessage, BaseMessage } from "@langchain/core/messages"; -import { formatPlanPrompt } from "../utils/plan-prompt.js"; -import { createLogger, LogLevel } from "../utils/logger.js"; +import { formatPlanPrompt } from "../../../utils/plan-prompt.js"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { getMessageString } from "../utils/message/content.js"; -import { removeLastTaskMessages } from "../utils/message/modify-array.js"; +import { getMessageString } from "../../../utils/message/content.js"; +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"; @@ -20,7 +20,8 @@ import { getActivePlanItems, getActiveTask, } from "@open-swe/shared/open-swe/tasks"; -import { getCompletedPlanItems } from "../utils/current-task.js"; +import { getCompletedPlanItems } from "../../../utils/current-task.js"; +import { addTaskPlanToIssue } from "../../../utils/github/issue-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. @@ -93,7 +94,7 @@ async function generateTaskSummaryFunc( state: GraphState, model: ConfigurableModel, ): Promise<{ planItemIndex: number; summary: string }> { - const activePlanItems = getActivePlanItems(state.plan); + const activePlanItems = getActivePlanItems(state.taskPlan); const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop(); if (!lastCompletedTask) { throw new Error("Unable to find last completed task."); @@ -125,7 +126,7 @@ export async function summarizeTaskSteps( state: GraphState, config: GraphConfig, ): Promise { - const activePlanItems = getActivePlanItems(state.plan); + const activePlanItems = getActivePlanItems(state.taskPlan); const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop(); if (!lastCompletedTask) { throw new Error("Unable to find last completed task."); @@ -134,11 +135,20 @@ export async function summarizeTaskSteps( const model = await loadModel(config, Task.SUMMARIZER); const taskSummary = await generateTaskSummary(state, model); const updatedTaskPlan = completePlanItem( - state.plan, - getActiveTask(state.plan).id, + state.taskPlan, + getActiveTask(state.taskPlan).id, taskSummary.planItemIndex, taskSummary.summary, ); + // Update the github issue to include the new task summary. + await addTaskPlanToIssue( + { + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + }, + config, + updatedTaskPlan, + ); const removedMessages = removeLastTaskMessages(state.internalMessages); logger.info(`Removing ${removedMessages.length} message(s) from state.`); @@ -157,7 +167,7 @@ export async function summarizeTaskSteps( const commandUpdate: GraphUpdate = { messages: [condensedTaskMessage], internalMessages: newMessagesStateUpdate, - plan: updatedTaskPlan, + taskPlan: updatedTaskPlan, }; return new Command({ goto: "generate-conclusion", @@ -168,7 +178,7 @@ export async function summarizeTaskSteps( const commandUpdate: GraphUpdate = { messages: [condensedTaskMessage], internalMessages: newMessagesStateUpdate, - plan: updatedTaskPlan, + taskPlan: updatedTaskPlan, }; return new Command({ goto: "generate-action", diff --git a/apps/open-swe/src/nodes/take-action.ts b/apps/open-swe/src/graphs/programmer/nodes/take-action.ts similarity index 87% rename from apps/open-swe/src/nodes/take-action.ts rename to apps/open-swe/src/graphs/programmer/nodes/take-action.ts index d9854e7a..fab60d76 100644 --- a/apps/open-swe/src/nodes/take-action.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/take-action.ts @@ -3,21 +3,25 @@ import { isToolMessage, ToolMessage, } from "@langchain/core/messages"; -import { createLogger, LogLevel } from "../utils/logger.js"; -import { createApplyPatchTool, createShellTool } from "../tools/index.js"; -import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; +import { createApplyPatchTool, createShellTool } from "../../../tools/index.js"; +import { + GraphState, + GraphConfig, + GraphUpdate, +} from "@open-swe/shared/open-swe/types"; import { checkoutBranchAndCommit, getChangedFilesStatus, -} from "../utils/git.js"; +} from "../../../utils/github/git.js"; import { formatBadArgsError, zodSchemaToString, -} from "../utils/zod-to-string.js"; +} from "../../../utils/zod-to-string.js"; import { Command } from "@langchain/langgraph"; -import { truncateOutput } from "../utils/truncate-outputs.js"; -import { daytonaClient } from "../utils/sandbox.js"; -import { getCodebaseTree } from "../utils/tree.js"; +import { truncateOutput } from "../../../utils/truncate-outputs.js"; +import { daytonaClient } from "../../../utils/sandbox.js"; +import { getCodebaseTree } from "../../../utils/tree.js"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; const logger = createLogger(LogLevel.INFO, "TakeAction"); @@ -160,13 +164,14 @@ export async function takeAction( const codebaseTree = await getCodebaseTree(); + const commandUpdate: GraphUpdate = { + messages: [toolMessage], + internalMessages: [toolMessage], + ...(branchName && { branchName }), + codebaseTree, + }; return new Command({ goto: shouldRouteDiagnoseNode ? "diagnose-error" : "progress-plan-step", - update: { - messages: [toolMessage], - internalMessages: [toolMessage], - ...(branchName && { branchName }), - codebaseTree, - }, + update: commandUpdate, }); } diff --git a/apps/open-swe/src/nodes/update-plan.ts b/apps/open-swe/src/graphs/programmer/nodes/update-plan.ts similarity index 82% rename from apps/open-swe/src/nodes/update-plan.ts rename to apps/open-swe/src/graphs/programmer/nodes/update-plan.ts index 1de15711..ba465ce8 100644 --- a/apps/open-swe/src/nodes/update-plan.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/update-plan.ts @@ -4,7 +4,7 @@ import { PlanItem, GraphUpdate, } from "@open-swe/shared/open-swe/types"; -import { loadModel, Task } from "../utils/load-model.js"; +import { loadModel, Task } from "../../../utils/load-model.js"; import { z } from "zod"; import { getActiveTask, @@ -15,9 +15,10 @@ import { isAIMessage, ToolMessage, } from "@langchain/core/messages"; -import { getMessageString } from "../utils/message/content.js"; -import { formatPlanPrompt } from "../utils/plan-prompt.js"; -import { createLogger, LogLevel } from "../utils/logger.js"; +import { getMessageString } from "../../../utils/message/content.js"; +import { formatPlanPrompt } from "../../../utils/plan-prompt.js"; +import { createLogger, LogLevel } from "../../../utils/logger.js"; +import { createUpdatePlanToolFields } from "@open-swe/shared/open-swe/tools"; const logger = createLogger(LogLevel.INFO, "UpdatePlanNode"); @@ -88,24 +89,26 @@ export async function updatePlan( state: GraphState, config: GraphConfig, ): Promise { - if (!state.planChangeRequest) { - throw new Error("No plan change request found."); - } const lastMessage = state.internalMessages[state.internalMessages.length - 1]; - if ( - !lastMessage || - !isAIMessage(lastMessage) || - !lastMessage.tool_calls?.length || - lastMessage.tool_calls[0].name !== updatePlanTool.name || - !lastMessage.tool_calls[0].id - ) { - throw new Error("Last message was not an update plan tool call."); + const updatePlanReasoningTool = createUpdatePlanToolFields(); + + if (!lastMessage || !isAIMessage(lastMessage)) { + throw new Error("Last message was not an AI message"); + } + + const updatePlanToolCall = lastMessage.tool_calls?.find( + (tc) => tc.name === updatePlanReasoningTool.name, + ); + const updatePlanToolCallId = updatePlanToolCall?.id; + const updatePlanToolCallArgs = updatePlanToolCall?.args as z.infer< + typeof updatePlanReasoningTool.schema + >; + if (!updatePlanToolCall || !updatePlanToolCallId || !updatePlanToolCallArgs) { + throw new Error("Update plan with reasoning tool call not found."); } - const updatePlanToolCallId = lastMessage.tool_calls[0].id; logger.info("Updating plan", { - updatePlanToolCallId, - planChangeRequest: state.planChangeRequest, + ...updatePlanToolCall, }); const model = await loadModel(config, Task.PLANNER); @@ -114,7 +117,7 @@ export async function updatePlan( parallel_tool_calls: false, }); - const activeTask = getActiveTask(state.plan); + const activeTask = getActiveTask(state.taskPlan); const request = activeTask.request; const activePlanItems = activeTask.planRevisions.find( (pr) => pr.revisionIndex === activeTask.activeRevisionIndex, @@ -125,7 +128,7 @@ export async function updatePlan( const systemPrompt = formatSystemPrompt( request, - state.planChangeRequest, + updatePlanToolCallArgs.update_plan_reasoning, activePlanItems, ); const userMessage = formatUserMessage(state.internalMessages); @@ -159,7 +162,7 @@ export async function updatePlan( ]; const newTaskPlan = updateTaskPlanItems( - state.plan, + state.taskPlan, activeTask.id, newPlanItems, "agent", @@ -179,7 +182,6 @@ export async function updatePlan( return { messages: [toolMessage], internalMessages: [toolMessage], - plan: newTaskPlan, - planChangeRequest: null, + taskPlan: newTaskPlan, }; } diff --git a/apps/open-swe/src/nodes/initialize.ts b/apps/open-swe/src/graphs/shared/initialize-sandbox.ts similarity index 67% rename from apps/open-swe/src/nodes/initialize.ts rename to apps/open-swe/src/graphs/shared/initialize-sandbox.ts index b25d6547..309676b1 100644 --- a/apps/open-swe/src/nodes/initialize.ts +++ b/apps/open-swe/src/graphs/shared/initialize-sandbox.ts @@ -1,36 +1,32 @@ -import { createLogger, LogLevel } from "../utils/logger.js"; -import { - GraphState, - GraphConfig, - GraphUpdate, -} from "@open-swe/shared/open-swe/types"; +import { getRepoAbsolutePath } from "@open-swe/shared/git"; +import { getGitHubTokensFromConfig } from "../../utils/github-tokens.js"; +import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types"; +import { createLogger, LogLevel } from "../../utils/logger.js"; +import { daytonaClient } from "../../utils/sandbox.js"; import { checkoutBranch, cloneRepo, configureGitUserInRepo, - getBranchName, pullLatestChanges, -} from "../utils/git.js"; -import { daytonaClient } from "../utils/sandbox.js"; +} from "../../utils/github/git.js"; +import { getCodebaseTree } from "../../utils/tree.js"; import { SNAPSHOT_NAME } from "@open-swe/shared/constants"; -import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; -import { getCodebaseTree } from "../utils/tree.js"; -import { getRepoAbsolutePath } from "@open-swe/shared/git"; -const logger = createLogger(LogLevel.INFO, "Initialize"); +const logger = createLogger(LogLevel.INFO, "InitializeSandbox"); -/** - * Initializes the session. This ensures there's an active VM session, and that - * the proper credentials are provided for taking actions on GitHub. - * It also clones the repository the user has specified to be used, and an optional - * branch. - */ -export async function initialize( - state: GraphState, +type InitializeSandboxState = { + targetRepository: TargetRepository; + branchName: string; + sandboxSessionId?: string; + codebaseTree?: string; +}; + +export async function initializeSandbox( + state: InitializeSandboxState, config: GraphConfig, -): Promise { +): Promise> { const { githubInstallationToken } = getGitHubTokensFromConfig(config); - const { sandboxSessionId, targetRepository } = state; + const { sandboxSessionId, targetRepository, branchName } = state; const absoluteRepoDir = getRepoAbsolutePath(targetRepository); if (sandboxSessionId) { @@ -59,13 +55,12 @@ export async function initialize( const res = await cloneRepo(sandbox, targetRepository, { githubInstallationToken, - stateBranchName: state.branchName, + stateBranchName: branchName, }); if (res.exitCode !== 0) { - // TODO: This should probably be an interrupt. - logger.error("Failed to clone repository", res.result); throw new Error(`Failed to clone repository.\n${res.result}`); } + logger.info("Repository cloned successfully."); logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`); @@ -78,7 +73,7 @@ export async function initialize( const checkoutBranchRes = await checkoutBranch( absoluteRepoDir, - state.branchName || getBranchName(config), + branchName, sandbox, ); @@ -92,7 +87,6 @@ export async function initialize( return { sandboxSessionId: sandbox.id, - targetRepository, codebaseTree, }; } diff --git a/apps/open-swe/src/nodes/interrupt-plan.ts b/apps/open-swe/src/nodes/interrupt-plan.ts deleted file mode 100644 index deb06e90..00000000 --- a/apps/open-swe/src/nodes/interrupt-plan.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Command, END, interrupt } from "@langchain/langgraph"; -import { GraphState, GraphUpdate } from "@open-swe/shared/open-swe/types"; -import { - ActionRequest, - HumanInterrupt, - HumanResponse, -} from "@langchain/langgraph/prebuilt"; -import { startSandbox } from "../utils/sandbox.js"; -import { createNewTask } from "@open-swe/shared/open-swe/tasks"; -import { getUserRequest } from "../utils/user-request.js"; -import { - PLAN_INTERRUPT_ACTION_TITLE, - PLAN_INTERRUPT_DELIMITER, -} from "@open-swe/shared/constants"; - -export async function interruptPlan(state: GraphState): Promise { - const { proposedPlan } = state; - if (!proposedPlan.length) { - throw new Error("No proposed plan found."); - } - - const interruptRes = interrupt({ - action_request: { - action: PLAN_INTERRUPT_ACTION_TITLE, - args: { - plan: proposedPlan.join(`\n${PLAN_INTERRUPT_DELIMITER}\n`), - }, - }, - config: { - allow_accept: true, - allow_edit: true, - allow_respond: true, - allow_ignore: true, - }, - description: `A new plan has been generated for your request. Please review it and either approve it, edit it, respond to it, or ignore it. Responses will be passed to an LLM where it will rewrite then plan. - If editing the plan, ensure each step in the plan is separated by "${PLAN_INTERRUPT_DELIMITER}".`, - })[0]; - - if (!state.sandboxSessionId) { - // TODO: This should prob just create a sandbox? - throw new Error("No sandbox session ID found."); - } - - const userRequest = getUserRequest(state.internalMessages); - - 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: commandUpdate, - }); - } - - if (interruptRes.type === "edit") { - const newSandboxSessionId = (await startSandbox(state.sandboxSessionId)).id; - - // Plan was edited, route to the generate-action node to start taking actions. - const editedPlan = (interruptRes.args as ActionRequest).args.plan - .split(PLAN_INTERRUPT_DELIMITER) - .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: 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: commandUpdate, - }); - } - - if (interruptRes.type === "ignore") { - // Plan was ignored, end the process. - return new Command({ - goto: END, - }); - } - - throw new Error("Unknown interrupt type." + interruptRes.type); -} diff --git a/apps/open-swe/src/subgraphs/index.ts b/apps/open-swe/src/subgraphs/index.ts deleted file mode 100644 index a5b79daa..00000000 --- a/apps/open-swe/src/subgraphs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./planner/index.js"; diff --git a/apps/open-swe/src/subgraphs/planner/types.ts b/apps/open-swe/src/subgraphs/planner/types.ts deleted file mode 100644 index 41ec633a..00000000 --- a/apps/open-swe/src/subgraphs/planner/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import "@langchain/langgraph/zod"; -import { z } from "zod"; -import { Messages, messagesStateReducer } from "@langchain/langgraph"; -import { BaseMessage } from "@langchain/core/messages"; -import { GraphAnnotation } from "@open-swe/shared/open-swe/types"; -import { withLangGraph } from "@langchain/langgraph/zod"; - -export const PlannerGraphStateObj = GraphAnnotation.extend({ - plannerMessages: withLangGraph(z.custom(), { - reducer: { - schema: z.custom(), - fn: messagesStateReducer, - }, - jsonSchemaExtra: { - langgraph_type: "messages", - }, - default: () => [], - }), -}); - -export type PlannerGraphState = z.infer; -export type PlannerGraphUpdate = Partial; diff --git a/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts b/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts deleted file mode 100644 index afaf5f86..00000000 --- a/apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { TaskPlan } from "@open-swe/shared/open-swe/types"; - -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/tools/shell.ts b/apps/open-swe/src/tools/shell.ts index b71dcb32..18ea2f99 100644 --- a/apps/open-swe/src/tools/shell.ts +++ b/apps/open-swe/src/tools/shell.ts @@ -15,7 +15,9 @@ const DEFAULT_ENV = { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", }; -export function createShellTool(state: GraphState) { +export function createShellTool( + state: Pick, +) { const shellTool = tool( async (input): Promise<{ result: string; status: "success" | "error" }> => { let sandbox: Sandbox | undefined; diff --git a/apps/open-swe/src/utils/github/api.ts b/apps/open-swe/src/utils/github/api.ts new file mode 100644 index 00000000..1d8bad7d --- /dev/null +++ b/apps/open-swe/src/utils/github/api.ts @@ -0,0 +1,342 @@ +import { Octokit } from "@octokit/rest"; +import { createLogger, LogLevel } from "../logger.js"; +import { GitHubIssue, GitHubIssueComment, GitHubPullRequest } from "./types.js"; + +const logger = createLogger(LogLevel.INFO, "GitHub-API"); + +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, + headBranch, + title, + body = "", + githubInstallationToken, + baseBranch, +}: { + owner: string; + repo: string; + headBranch: string; + title: string; + body?: string; + githubInstallationToken: string; + baseBranch?: string; +}) { + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + + let repoBaseBranch = baseBranch; + if (!repoBaseBranch) { + try { + logger.info("Fetching default branch from repo", { + owner, + repo, + }); + const { data: repository } = await octokit.repos.get({ + owner, + repo, + }); + + repoBaseBranch = repository.default_branch; + if (!repoBaseBranch) { + throw new Error("No base branch returned after fetching repo"); + } + logger.info("Fetched default branch from repo", { + owner, + repo, + baseBranch: repoBaseBranch, + }); + } catch (e) { + logger.error("Failed to fetch base branch from repo", { + owner, + repo, + ...(e instanceof Error && { + name: e.name, + message: e.message, + stack: e.stack, + }), + }); + return null; + } + } + + let pullRequest: GitHubPullRequest | null = null; + try { + logger.info( + `Creating pull request against default branch: ${repoBaseBranch}`, + ); + + // Step 2: Create the pull request + const { data: pullRequestData } = await octokit.pulls.create({ + owner, + repo, + title, + body, + head: headBranch, + base: repoBaseBranch, + }); + + pullRequest = pullRequestData; + logger.info(`🐙 Pull request created: ${pullRequest.html_url}`); + } 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, + githubInstallationToken, + ); + } + + logger.error(`Failed to create pull request`, { + error, + }); + return null; + } + + try { + logger.info("Adding 'open-swe' label to pull request", { + pullRequestNumber: pullRequest.number, + }); + await octokit.issues.addLabels({ + owner, + repo, + issue_number: pullRequest.number, + labels: ["open-swe"], + }); + logger.info("Added 'open-swe' label to pull request", { + pullRequestNumber: pullRequest.number, + }); + } catch (labelError) { + logger.warn("Failed to add 'open-swe' label to pull request", { + pullRequestNumber: pullRequest.number, + labelError, + }); + } + + return pullRequest; +} + +export async function getIssue({ + owner, + repo, + issueNumber, + githubInstallationToken, +}: { + owner: string; + repo: string; + issueNumber: number; + githubInstallationToken: string; +}): Promise { + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + + try { + const { data: issue } = await octokit.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + return issue; + } catch (error) { + logger.error(`Failed to get issue`, { + error, + }); + return null; + } +} + +export async function getIssueComments({ + owner, + repo, + issueNumber, + githubInstallationToken, + filterBotComments = true, +}: { + owner: string; + repo: string; + issueNumber: number; + githubInstallationToken: string; + filterBotComments?: boolean; +}): Promise { + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + + try { + const { data: comments } = await octokit.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + }); + + if (!filterBotComments) { + return comments; + } + + return comments.filter((comment) => { + return ( + comment.user?.type !== "Bot" || !comment.user?.name?.includes("[bot]") + ); + }); + } catch (error) { + logger.error(`Failed to get issue comments`, { + error, + }); + return null; + } +} + +export async function createIssue({ + owner, + repo, + title, + body, + githubAccessToken, +}: { + owner: string; + repo: string; + title: string; + body: string; + githubAccessToken: string; +}): Promise { + const octokit = new Octokit({ + auth: githubAccessToken, + }); + + try { + const { data: issue } = await octokit.issues.create({ + owner, + repo, + title, + body, + }); + + return issue; + } catch (error) { + logger.error(`Failed to create issue`, { + error, + }); + return null; + } +} + +export async function updateIssue({ + owner, + repo, + issueNumber, + githubInstallationToken, + body, + title, +}: { + owner: string; + repo: string; + issueNumber: number; + githubInstallationToken: string; + body?: string; + title?: string; +}) { + if (!body && !title) { + throw new Error("Must provide either body or title to update issue"); + } + + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + + try { + const { data: issue } = await octokit.issues.update({ + owner, + repo, + issue_number: issueNumber, + ...(body && { body }), + ...(title && { title }), + }); + + return issue; + } catch (error) { + logger.error(`Failed to update issue`, { + error, + }); + return null; + } +} + +export async function createIssueComment({ + owner, + repo, + issueNumber, + body, + githubToken, +}: { + owner: string; + repo: string; + issueNumber: number; + body: string; + /** + * Can be either the installation token if creating a bot comment, + * or an access token if creating a user comment. + */ + githubToken: string; +}): Promise { + const octokit = new Octokit({ + auth: githubToken, + }); + + try { + const { data: comment } = await octokit.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + + return comment; + } catch (error) { + logger.error(`Failed to create issue comment`, { + error, + }); + return null; + } +} diff --git a/apps/open-swe/src/utils/git.ts b/apps/open-swe/src/utils/github/git.ts similarity index 77% rename from apps/open-swe/src/utils/git.ts rename to apps/open-swe/src/utils/github/git.ts index 40c90322..6187a66f 100644 --- a/apps/open-swe/src/utils/git.ts +++ b/apps/open-swe/src/utils/github/git.ts @@ -1,13 +1,38 @@ -import { Octokit } from "@octokit/rest"; import { Sandbox } from "@daytonaio/sdk"; -import { createLogger, LogLevel } from "./logger.js"; +import { createLogger, LogLevel } from "../logger.js"; import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types"; import { TIMEOUT_SEC } from "@open-swe/shared/constants"; -import { getSandboxErrorFields } from "./sandbox-error-fields.js"; +import { getSandboxErrorFields } from "../sandbox-error-fields.js"; import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js"; import { getRepoAbsolutePath } from "@open-swe/shared/git"; -const logger = createLogger(LogLevel.INFO, "GitUtil"); +class ExecuteCommandError extends Error { + command: string; + result: string; + exitCode: number; + constructor(command: string, error: ExecuteResponse) { + super("Failed to execute command"); + this.name = "ExecuteCommandError"; + this.command = ExecuteCommandError.cleanCommand(command); + this.result = error.result; + this.exitCode = error.exitCode; + } + + static cleanCommand(command: string): string { + if ( + command.includes("x-access-token:") && + command.includes("@github.com/") + ) { + return command.replace( + /(x-access-token:)([^@]+)(@github\.com\/)/, + "$1ACCESS_TOKEN_REDACTED$3", + ); + } + return command; + } +} + +const logger = createLogger(LogLevel.INFO, "GitHub-Git"); export function getBranchName(config: GraphConfig): string { const threadId = config.configurable?.thread_id; @@ -376,126 +401,6 @@ 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, - headBranch, - title, - body = "", - githubInstallationToken, -}: { - owner: string; - repo: string; - headBranch: string; - title: string; - body?: string; - githubInstallationToken: string; -}) { - const octokit = new Octokit({ - auth: githubInstallationToken, - }); - - try { - // Step 1: Get repository information to find the default branch - const { data: repository } = await octokit.repos.get({ - owner, - repo, - }); - - const defaultBranch = repository.default_branch; - logger.info( - `Creating pull request against default branch: ${defaultBranch}`, - ); - - // Step 2: Create the pull request - const { data: pullRequest } = await octokit.pulls.create({ - owner, - repo, - title, - body, - head: headBranch, - base: defaultBranch, - }); - - logger.info(`🐙 Pull request created: ${pullRequest.html_url}`); - - // Step 3: Add the 'open-swe' label to the pull request - try { - await octokit.issues.addLabels({ - owner, - repo, - issue_number: pullRequest.number, - labels: ["open-swe"], - }); - logger.info( - `Added 'open-swe' label to pull request #${pullRequest.number}`, - ); - } catch (labelError) { - logger.warn( - `Failed to add 'open-swe' label to pull request #${pullRequest.number}`, - { - labelError, - }, - ); - } - - 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, - githubInstallationToken, - ); - } - - logger.error(`Failed to create pull request`, { - error, - }); - return null; - } -} - export async function pullLatestChanges( absoluteRepoDir: string, sandbox: Sandbox, @@ -530,6 +435,9 @@ export async function cloneRepo( stateBranchName?: string; }, ) { + const absoluteRepoDir = getRepoAbsolutePath(targetRepository); + let cloneResult: ExecuteResponse | null = null; + try { const gitCloneCommand = ["git", "clone"]; @@ -548,33 +456,92 @@ export async function cloneRepo( repoPath: `${targetRepository.owner}/${targetRepository.repo}`, branch: branchName, baseCommit: targetRepository.baseCommit, + cloneCommand: gitCloneCommand.join(" "), }); - const cloneResult = await sandbox.process.executeCommand( + cloneResult = await sandbox.process.executeCommand( gitCloneCommand.join(" "), ); if (!targetRepository.baseCommit) { if (cloneResult.exitCode !== 0) { - logger.error("Failed to clone repository", { - targetRepository, - cloneResult, - }); - throw new Error("Failed to clone repository"); + if (!cloneResult.result.includes("not found in upstream origin")) { + logger.error("Failed to clone repository", { + targetRepository, + }); + throw new ExecuteCommandError(gitCloneCommand.join(" "), cloneResult); + } else { + const cloneDefaultBranchCommand = ["git", "clone", repoUrlWithToken]; + logger.info( + "Branch not found in upstream origin. Cloning default & checking out branch", + { + targetRepository, + cloneDefaultBranchCommand: cloneDefaultBranchCommand.join(" "), + }, + ); + const cloneDefaultBranchResult = await sandbox.process.executeCommand( + cloneDefaultBranchCommand.join(" "), + ); + if (cloneDefaultBranchResult.exitCode !== 0) { + logger.error("Failed to clone default branch", { + targetRepository, + cloneDefaultBranchCommand: cloneDefaultBranchCommand.join(" "), + }); + throw new ExecuteCommandError( + cloneDefaultBranchCommand.join(" "), + cloneDefaultBranchResult, + ); + } + + cloneResult = cloneDefaultBranchResult; + + // Now checkout the branch. We're creating a new branch here since the above error indicated the branch doesn't exist. + const checkoutBranchCommand = ["git", "checkout", "-b", branchName]; + const checkoutBranchResult = await sandbox.process.executeCommand( + checkoutBranchCommand.join(" "), + absoluteRepoDir, + undefined, + TIMEOUT_SEC, + ); + if (checkoutBranchResult.exitCode !== 0) { + logger.error("Failed to checkout branch", { + targetRepository, + checkoutBranchCommand: checkoutBranchCommand.join(" "), + }); + throw new ExecuteCommandError( + checkoutBranchCommand.join(" "), + checkoutBranchResult, + ); + } + + logger.info("Successfully checked out branch", { + targetRepository, + checkoutBranchCommand: checkoutBranchCommand.join(" "), + }); + } } return cloneResult; } + } catch (e) { + const errorFields = getSandboxErrorFields(e); + logger.error("Clone repo failed\n", errorFields ?? e); + throw e; + } + try { // If a baseCommit is specified, checkout that commit after cloning - const absoluteRepoDir = getRepoAbsolutePath(targetRepository); - logger.info("Checking out base commit", { baseCommit: targetRepository.baseCommit, repoPath: `${targetRepository.owner}/${targetRepository.repo}`, }); + const checkoutCommitCommand = [ + "git", + "checkout", + targetRepository.baseCommit, + ]; const checkoutResult = await sandbox.process.executeCommand( - `git checkout ${targetRepository.baseCommit}`, + checkoutCommitCommand.join(" "), absoluteRepoDir, undefined, TIMEOUT_SEC, @@ -583,21 +550,23 @@ export async function cloneRepo( if (checkoutResult.exitCode !== 0) { logger.error("Failed to checkout base commit", { baseCommit: targetRepository.baseCommit, - checkoutResult, + checkoutCommitCommand: checkoutCommitCommand.join(" "), }); - throw new Error( - `Failed to checkout base commit ${targetRepository.baseCommit}: ${checkoutResult.result}`, + throw new ExecuteCommandError( + checkoutCommitCommand.join(" "), + checkoutResult, ); } logger.info("Successfully checked out base commit", { baseCommit: targetRepository.baseCommit, + checkoutCommitCommand: checkoutCommitCommand.join(" "), }); - - return cloneResult; } catch (e) { const errorFields = getSandboxErrorFields(e); - logger.error("Failed to clone repository", errorFields ?? e); + logger.error("Clone repo failed\n", errorFields ?? e); throw e; } + + return cloneResult; } diff --git a/apps/open-swe/src/utils/github/issue-messages.ts b/apps/open-swe/src/utils/github/issue-messages.ts new file mode 100644 index 00000000..9895999b --- /dev/null +++ b/apps/open-swe/src/utils/github/issue-messages.ts @@ -0,0 +1,156 @@ +import { v4 as uuidv4 } from "uuid"; +import { + BaseMessage, + HumanMessage, + isHumanMessage, +} from "@langchain/core/messages"; +import { GitHubIssue, GitHubIssueComment } from "./types.js"; +import { getIssue, getIssueComments } from "./api.js"; +import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types"; +import { getGitHubTokensFromConfig } from "../github-tokens.js"; + +export function getUntrackedComments( + existingMessages: BaseMessage[], + githubIssueId: number, + comments: GitHubIssueComment[], +): BaseMessage[] { + // Get all human messages which contain github comment content. Exclude the original issue message. + const humanMessages = existingMessages.filter( + (m) => isHumanMessage(m) && !m.additional_kwargs?.isOriginalIssue, + ); + // Iterate over the comments, and filter out any comment already tracked by a message. + // Then, map to create new human message(s). + const untrackedCommentMessages = comments + .filter( + (c) => + !humanMessages.some( + (m) => m.additional_kwargs?.githubIssueCommentId === c.id, + ), + ) + .map( + (c) => + new HumanMessage({ + id: uuidv4(), + content: getMessageContentFromIssue(c), + additional_kwargs: { + githubIssueId, + githubIssueCommentId: c.id, + }, + }), + ); + + return untrackedCommentMessages; +} + +type GetMissingMessagesInput = { + messages: BaseMessage[]; + githubIssueId: number; + targetRepository: TargetRepository; +}; + +export async function getMissingMessages( + input: GetMissingMessagesInput, + config: GraphConfig, +): Promise { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const [issue, comments] = await Promise.all([ + getIssue({ + owner: input.targetRepository.owner, + repo: input.targetRepository.repo, + issueNumber: input.githubIssueId, + githubInstallationToken, + }), + getIssueComments({ + owner: input.targetRepository.owner, + repo: input.targetRepository.repo, + issueNumber: input.githubIssueId, + githubInstallationToken, + filterBotComments: true, + }), + ]); + if (!issue && !comments?.length) { + return []; + } + + const isIssueMessageTracked = issue + ? input.messages.some( + (m) => + isHumanMessage(m) && + m.additional_kwargs?.isOriginalIssue && + m.additional_kwargs?.githubIssueId === input.githubIssueId, + ) + : false; + let issueMessage: HumanMessage | null = null; + if (issue && !isIssueMessageTracked) { + issueMessage = new HumanMessage({ + id: uuidv4(), + content: getMessageContentFromIssue(issue), + additional_kwargs: { + githubIssueId: input.githubIssueId, + isOriginalIssue: true, + }, + }); + } + const untrackedCommentMessages = comments?.length + ? getUntrackedComments(input.messages, input.githubIssueId, comments) + : []; + + return [...(issueMessage ? [issueMessage] : []), ...untrackedCommentMessages]; +} + +const DEFAULT_ISSUE_TITLE = "New Open SWE Request"; +export const ISSUE_TITLE_OPEN_TAG = ""; +export const ISSUE_TITLE_CLOSE_TAG = ""; +export const ISSUE_CONTENT_OPEN_TAG = ""; +export const ISSUE_CONTENT_CLOSE_TAG = ""; + +export function extractIssueTitleAndContentFromMessage(content: string) { + let messageTitle = DEFAULT_ISSUE_TITLE; + let messageContent = content; + if ( + content.includes(ISSUE_TITLE_OPEN_TAG) && + content.includes(ISSUE_TITLE_CLOSE_TAG) + ) { + messageTitle = content.substring( + content.indexOf(ISSUE_TITLE_OPEN_TAG) + ISSUE_TITLE_OPEN_TAG.length, + content.indexOf(ISSUE_TITLE_CLOSE_TAG), + ); + } + if ( + content.includes(ISSUE_CONTENT_OPEN_TAG) && + content.includes(ISSUE_CONTENT_CLOSE_TAG) + ) { + messageContent = content.substring( + content.indexOf(ISSUE_CONTENT_OPEN_TAG) + ISSUE_CONTENT_OPEN_TAG.length, + content.indexOf(ISSUE_CONTENT_CLOSE_TAG), + ); + } + return { title: messageTitle, content: messageContent }; +} + +export function formatContentForIssueBody(body: string): string { + return `${ISSUE_CONTENT_OPEN_TAG}${body}${ISSUE_CONTENT_CLOSE_TAG}`; +} + +function extractContentFromIssueBody(body: string): string { + if ( + !body.includes(ISSUE_CONTENT_OPEN_TAG) || + !body.includes(ISSUE_CONTENT_CLOSE_TAG) + ) { + return body; + } + + return body.substring( + body.indexOf(ISSUE_CONTENT_OPEN_TAG) + ISSUE_CONTENT_OPEN_TAG.length, + body.indexOf(ISSUE_CONTENT_CLOSE_TAG), + ); +} + +export function getMessageContentFromIssue( + issue: GitHubIssue | GitHubIssueComment, +): string { + if ("title" in issue) { + return `[original issue]\n**${issue.title}**\n${extractContentFromIssueBody(issue.body ?? "")}`; + } + return `[issue comment]\n${issue.body}`; +} diff --git a/apps/open-swe/src/utils/github/issue-task.ts b/apps/open-swe/src/utils/github/issue-task.ts new file mode 100644 index 00000000..e85e9678 --- /dev/null +++ b/apps/open-swe/src/utils/github/issue-task.ts @@ -0,0 +1,138 @@ +import { + GraphConfig, + TargetRepository, + TaskPlan, +} from "@open-swe/shared/open-swe/types"; +import { getIssue, updateIssue } from "./api.js"; +import { getGitHubTokensFromConfig } from "../github-tokens.js"; +import { createLogger, LogLevel } from "../logger.js"; + +const logger = createLogger(LogLevel.INFO, "IssueTaskString"); + +export const TASK_OPEN_TAG = ""; +export const TASK_CLOSE_TAG = ""; + +function typeNarrowTaskPlan(taskPlan: unknown): taskPlan is TaskPlan { + return !!( + typeof taskPlan === "object" && + !Array.isArray(taskPlan) && + taskPlan && + "tasks" in taskPlan && + Array.isArray(taskPlan.tasks) && + "activeTaskIndex" in taskPlan && + typeof taskPlan.activeTaskIndex === "number" + ); +} + +export function extractTasksFromIssueContent(content: string): TaskPlan | null { + if (!content.includes(TASK_OPEN_TAG) || !content.includes(TASK_CLOSE_TAG)) { + return null; + } + const taskPlanString = content + .split(TASK_OPEN_TAG)?.[1] + ?.split(TASK_CLOSE_TAG)?.[0]; + try { + const parsedTaskPlan = JSON.parse(taskPlanString.trim()); + if (!typeNarrowTaskPlan(parsedTaskPlan)) { + throw new Error("Invalid task plan parsed."); + } + return parsedTaskPlan; + } catch (e) { + logger.error("Failed to parse task plan", { + taskPlanString, + ...(e instanceof Error && { + name: e.name, + message: e.message, + stack: e.stack, + }), + }); + return null; + } +} + +type GetIssueTaskPlanInput = { + githubIssueId: number; + targetRepository: TargetRepository; +}; + +export async function getTaskPlanFromIssue( + input: GetIssueTaskPlanInput, + config: GraphConfig, +): Promise { + const issue = await getIssue({ + owner: input.targetRepository.owner, + repo: input.targetRepository.repo, + issueNumber: input.githubIssueId, + githubInstallationToken: + getGitHubTokensFromConfig(config).githubInstallationToken, + }); + if (!issue || !issue.body) { + throw new Error( + "No issue found when attempting to get task plan from issue", + ); + } + + return extractTasksFromIssueContent(issue.body); +} + +const DETAILS_OPEN_TAG = "
"; +const DETAILS_CLOSE_TAG = "
"; +const AGENT_CONTEXT_DETAILS_SUMMARY = "Agent Context"; + +export async function addTaskPlanToIssue( + input: GetIssueTaskPlanInput, + config: GraphConfig, + taskPlan: TaskPlan, +): Promise { + const issue = await getIssue({ + owner: input.targetRepository.owner, + repo: input.targetRepository.repo, + issueNumber: input.githubIssueId, + githubInstallationToken: + getGitHubTokensFromConfig(config).githubInstallationToken, + }); + + if (!issue || !issue.body) { + throw new Error("No issue found when attempting to add task plan to issue"); + } + + const taskPlanString = JSON.stringify(taskPlan, null, 2); + let newBody = ""; + + if ( + !issue.body.includes(TASK_OPEN_TAG) && + !issue.body.includes(TASK_CLOSE_TAG) + ) { + newBody = `${issue.body} + +${DETAILS_OPEN_TAG} +${AGENT_CONTEXT_DETAILS_SUMMARY} + +${TASK_OPEN_TAG} +${taskPlanString} +${TASK_CLOSE_TAG} + +${DETAILS_CLOSE_TAG}`; + } else { + const contentBeforeOpenTag = issue.body.split(TASK_OPEN_TAG)?.[0]; + const contentAfterCloseTag = issue.body.split(TASK_CLOSE_TAG)?.[1]; + const newTaskPlanString = JSON.stringify(taskPlan, null, 2); + + newBody = `${contentBeforeOpenTag} + +${TASK_OPEN_TAG} +${newTaskPlanString} +${TASK_CLOSE_TAG} + +${contentAfterCloseTag}`; + } + + await updateIssue({ + owner: input.targetRepository.owner, + repo: input.targetRepository.repo, + issueNumber: input.githubIssueId, + githubInstallationToken: + getGitHubTokensFromConfig(config).githubInstallationToken, + body: newBody, + }); +} diff --git a/apps/open-swe/src/utils/github/types.ts b/apps/open-swe/src/utils/github/types.ts new file mode 100644 index 00000000..b9249264 --- /dev/null +++ b/apps/open-swe/src/utils/github/types.ts @@ -0,0 +1,10 @@ +import type { Endpoints } from "@octokit/types"; + +export type GitHubIssue = + Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"]; + +export type GitHubIssueComment = + Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}/comments"]["response"]["data"][number]; + +export type GitHubPullRequest = + Endpoints["POST /repos/{owner}/{repo}/pulls"]["response"]["data"]; diff --git a/apps/open-swe/src/utils/langgraph-client.ts b/apps/open-swe/src/utils/langgraph-client.ts new file mode 100644 index 00000000..fe249260 --- /dev/null +++ b/apps/open-swe/src/utils/langgraph-client.ts @@ -0,0 +1,20 @@ +import { Client } from "@langchain/langgraph-sdk"; + +export function createLangGraphClient(options?: { + defaultHeaders?: Record; + includeApiKey?: boolean; +}) { + if (!process.env.LANGGRAPH_API_URL) { + throw new Error("LANGGRAPH_API_URL not found"); + } + if (options?.includeApiKey && !process.env.LANGGRAPH_API_KEY) { + throw new Error("LANGGRAPH_API_KEY not found"); + } + return new Client({ + ...(options?.includeApiKey && { + apiKey: process.env.LANGGRAPH_API_KEY, + }), + apiUrl: process.env.LANGGRAPH_API_URL, + defaultHeaders: options?.defaultHeaders, + }); +} diff --git a/apps/open-swe/src/utils/load-model.ts b/apps/open-swe/src/utils/load-model.ts index 163738b1..4be8a589 100644 --- a/apps/open-swe/src/utils/load-model.ts +++ b/apps/open-swe/src/utils/load-model.ts @@ -7,6 +7,7 @@ export enum Task { ACTION_GENERATOR = "actionGenerator", PROGRESS_PLAN_CHECKER = "progressPlanChecker", SUMMARIZER = "summarizer", + CLASSIFICATION = "classification", } const TASK_TO_CONFIG_DEFAULTS_MAP = { @@ -30,6 +31,10 @@ const TASK_TO_CONFIG_DEFAULTS_MAP = { modelName: "anthropic:claude-sonnet-4-0", temperature: 0, }, + [Task.CLASSIFICATION]: { + modelName: "anthropic:claude-3-5-haiku-latest", + temperature: 0, + }, }; export async function loadModel(config: GraphConfig, task: Task) { @@ -55,7 +60,13 @@ export async function loadModel(config: GraphConfig, task: Task) { } const thinkingBudgetTokens = 5000; - const maxTokens = thinkingBudgetTokens * 4; + const thinkingMaxTokens = thinkingBudgetTokens * 4; + + let maxTokens = config.configurable?.maxTokens ?? 10_000; + if (modelName.includes("claude-3-5-haiku")) { + // The max tokens for haiku is 8192 + maxTokens = maxTokens > 8_192 ? 8_192 : maxTokens; + } const model = await initChatModel(modelName, { modelProvider, @@ -63,9 +74,9 @@ export async function loadModel(config: GraphConfig, task: Task) { ...(thinkingModel && modelProvider === "anthropic" ? { thinking: { budget_tokens: thinkingBudgetTokens, type: "enabled" }, - maxTokens, + maxTokens: thinkingMaxTokens, } - : { maxTokens: config.configurable?.maxTokens ?? 10_000 }), + : { maxTokens }), }); return model; diff --git a/apps/open-swe/src/utils/message/modify-array.ts b/apps/open-swe/src/utils/message/modify-array.ts index 14434d18..7cdb028c 100644 --- a/apps/open-swe/src/utils/message/modify-array.ts +++ b/apps/open-swe/src/utils/message/modify-array.ts @@ -34,3 +34,11 @@ export function removeFirstHumanMessage( return true; }); } + +export function removeLastHumanMessage(messages: BaseMessage[]): BaseMessage[] { + const lastHumanMessage = messages.findLast(isHumanMessage); + if (!lastHumanMessage) { + return messages; + } + return messages.filter((m) => m.id !== lastHumanMessage.id); +} diff --git a/apps/open-swe/src/utils/plan-prompt.ts b/apps/open-swe/src/utils/plan-prompt.ts index 9df45c93..0b1e70db 100644 --- a/apps/open-swe/src/utils/plan-prompt.ts +++ b/apps/open-swe/src/utils/plan-prompt.ts @@ -12,21 +12,21 @@ export const PLAN_PROMPT = `## Completed Tasks /** * Formats a plan for use in a prompt. - * @param plan The plan to format + * @param taskPlan The plan to format * @param options Options for formatting the plan * @param options.useLastCompletedTask Whether to use the last completed task as the current task * @param options.includeSummaries Whether to include summaries of completed tasks * @returns The formatted plan */ export function formatPlanPrompt( - plan: PlanItem[], + taskPlan: PlanItem[], options?: { useLastCompletedTask?: boolean; includeSummaries?: boolean; }, ): string { - let completedTasks = plan.filter((p) => p.completed); - let remainingTasks = plan.filter((p) => !p.completed); + let completedTasks = taskPlan.filter((p) => p.completed); + let remainingTasks = taskPlan.filter((p) => !p.completed); let currentTask: PlanItem | undefined; if (options?.useLastCompletedTask) { currentTask = completedTasks.sort((a, b) => a.index - b.index)[0]; @@ -72,8 +72,8 @@ export function formatPlanPrompt( ); } -export function formatPlanPromptWithSummaries(plan: PlanItem[]): string { - return plan +export function formatPlanPromptWithSummaries(taskPlan: PlanItem[]): string { + return taskPlan .map( (p) => `<${p.completed ? "completed-" : ""}task index="${p.index}">\n${p.plan}\n \n${p.summary || "No task summary found"}\n \n`, diff --git a/apps/open-swe/src/utils/sandbox-error-fields.ts b/apps/open-swe/src/utils/sandbox-error-fields.ts index a3be0857..e067e3bd 100644 --- a/apps/open-swe/src/utils/sandbox-error-fields.ts +++ b/apps/open-swe/src/utils/sandbox-error-fields.ts @@ -8,13 +8,12 @@ export function getSandboxErrorFields( typeof error !== "object" || !("result" in error) || !error.result || - typeof error.result !== "object" || - !("exitCode" in error.result) || - !("stderr" in error.result) || - !("stdout" in error.result) + typeof error.result !== "string" || + !("exitCode" in error) || + typeof error.exitCode !== "number" ) { return undefined; } - return error.result as unknown as ExecuteResponse; + return error as ExecuteResponse; } diff --git a/apps/open-swe/src/utils/task-string-extraction.ts b/apps/open-swe/src/utils/task-string-extraction.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/open-swe/src/utils/user-request.ts b/apps/open-swe/src/utils/user-request.ts index 23c3a079..72142622 100644 --- a/apps/open-swe/src/utils/user-request.ts +++ b/apps/open-swe/src/utils/user-request.ts @@ -5,6 +5,8 @@ import { } from "@langchain/core/messages"; import { getMessageContentString } from "@open-swe/shared/messages"; +// TODO: Might want a better way of doing this. +// maybe add a new kwarg `isRequest` and have this return the last human message with that field? export function getUserRequest( messages: BaseMessage[], options?: { returnFullMessage?: never | false }, @@ -17,7 +19,9 @@ export function getUserRequest( messages: BaseMessage[], options?: { returnFullMessage?: boolean }, ): string | HumanMessage { - const recentUserMessage = messages.findLast(isHumanMessage); + const recentUserMessage = messages.findLast( + (m) => isHumanMessage(m) && m.additional_kwargs?.isOriginalIssue, + ); if (!recentUserMessage) { return ""; } diff --git a/apps/web/src/app/(v2)/chat/[thread_id]/layout.tsx b/apps/web/src/app/(v2)/chat/[thread_id]/layout.tsx new file mode 100644 index 00000000..50d4c101 --- /dev/null +++ b/apps/web/src/app/(v2)/chat/[thread_id]/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "../../../globals.css"; +import React from "react"; + +export const metadata: Metadata = { + title: "Open SWE - Thread", + description: "Open SWE thread view", + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/apps/web/src/app/(v2)/chat/[thread_id]/page.tsx b/apps/web/src/app/(v2)/chat/[thread_id]/page.tsx new file mode 100644 index 00000000..21d36b3c --- /dev/null +++ b/apps/web/src/app/(v2)/chat/[thread_id]/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { ThreadView } from "@/components/v2/thread-view"; +import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types"; +import { useThreads } from "@/hooks/useThreads"; +import { useStream } from "@langchain/langgraph-sdk/react"; +import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { use } from "react"; + +interface ThreadPageProps { + thread_id: string; +} + +export default function ThreadPage({ + params, +}: { + params: Promise; +}) { + const router = useRouter(); + const { thread_id } = use(params); + const stream = useStream({ + apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "", + assistantId: process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID ?? "", + threadId: thread_id, + reconnectOnMount: true, + }); + + const { threads } = useThreads(); + + // Find the thread by ID + const thread = threads?.find((t) => t.thread_id === thread_id); + // If thread not found, show 404 + if (!thread) { + return <>Loading...; + } + + // Convert all threads to display format + const displayThreads: ThreadDisplayInfo[] = + threads?.map(threadToDisplayInfo) ?? []; + const currentDisplayThread = threadToDisplayInfo(thread); + + const handleThreadSelect = (selectedThread: ThreadDisplayInfo) => { + router.push(`/chat/${selectedThread.id}`); + }; + + const handleBackToHome = () => { + router.push("/chat"); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/(v2)/chat/layout.tsx b/apps/web/src/app/(v2)/chat/layout.tsx new file mode 100644 index 00000000..9538dcf4 --- /dev/null +++ b/apps/web/src/app/(v2)/chat/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "../../globals.css"; +import React from "react"; + +export const metadata: Metadata = { + title: "Open SWE - Chat", + description: "Open SWE chat", + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/apps/web/src/app/(v2)/chat/page.tsx b/apps/web/src/app/(v2)/chat/page.tsx new file mode 100644 index 00000000..072852b0 --- /dev/null +++ b/apps/web/src/app/(v2)/chat/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { DefaultView } from "@/components/v2/default-view"; +import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types"; +import { useThreads } from "@/hooks/useThreads"; +import { GitHubAppProvider } from "@/providers/GitHubApp"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { Toaster } from "@/components/ui/sonner"; +import { Suspense } from "react"; + +export default function ChatPage() { + const { threads } = useThreads( + process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID, + ); + + // Convert Thread objects to ThreadDisplayInfo for UI + const displayThreads: ThreadDisplayInfo[] = + threads?.map(threadToDisplayInfo) ?? []; + + return ( +
+ Loading...
}> + + + + + + + ); +} diff --git a/apps/web/src/app/(v2)/chat/threads/layout.tsx b/apps/web/src/app/(v2)/chat/threads/layout.tsx new file mode 100644 index 00000000..5384c8e2 --- /dev/null +++ b/apps/web/src/app/(v2)/chat/threads/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "../../../globals.css"; +import React from "react"; + +export const metadata: Metadata = { + title: "Open SWE - All Threads", + description: "Open SWE view all threads", + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/apps/web/src/app/(v2)/chat/threads/page.tsx b/apps/web/src/app/(v2)/chat/threads/page.tsx new file mode 100644 index 00000000..2ed0e6ac --- /dev/null +++ b/apps/web/src/app/(v2)/chat/threads/page.tsx @@ -0,0 +1,348 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + ArrowLeft, + Search, + Filter, + CheckCircle, + XCircle, + Loader2, + GitBranch, + GitPullRequest, + Bug, + Calendar, + Clock, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types"; +import { useThreads } from "@/hooks/useThreads"; +import { GraphState } from "@open-swe/shared/open-swe/types"; + +type FilterStatus = "all" | "running" | "completed" | "failed" | "pending"; + +export default function AllThreadsPage() { + const router = useRouter(); + const { threads } = useThreads(); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + // Convert Thread objects to ThreadDisplayInfo for UI + const displayThreads: ThreadDisplayInfo[] = + threads?.map(threadToDisplayInfo) ?? []; + + const getStatusColor = (status: ThreadDisplayInfo["status"]) => { + switch (status) { + case "running": + return "bg-blue-950 text-blue-400"; + case "completed": + return "bg-green-950 text-green-400"; + case "failed": + return "bg-red-950 text-red-400"; + case "pending": + return "bg-yellow-950 text-yellow-400"; + default: + return "bg-gray-800 text-gray-400"; + } + }; + + const getStatusIcon = (status: ThreadDisplayInfo["status"]) => { + switch (status) { + case "running": + return ; + case "completed": + return ; + case "failed": + return ; + case "pending": + return ; + default: + return null; + } + }; + + const getPRStatusColor = (status: string) => { + switch (status) { + case "merged": + return "text-purple-400"; + case "open": + return "text-green-400"; + case "draft": + return "text-gray-400"; + case "closed": + return "text-red-400"; + default: + return "text-gray-400"; + } + }; + + // Filter and search threads + const filteredThreads = displayThreads.filter((thread) => { + const matchesSearch = + thread.title.toLowerCase().includes(searchQuery.toLowerCase()) || + thread.repository.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStatus = + statusFilter === "all" || thread.status === statusFilter; + return matchesSearch && matchesStatus; + }); + + // Group threads by status + const groupedThreads = { + running: filteredThreads.filter((t) => t.status === "running"), + completed: filteredThreads.filter((t) => t.status === "completed"), + failed: filteredThreads.filter((t) => t.status === "failed"), + pending: filteredThreads.filter((t) => t.status === "pending"), + }; + + const statusCounts = { + all: displayThreads.length, + running: displayThreads.filter((t) => t.status === "running").length, + completed: displayThreads.filter((t) => t.status === "completed").length, + failed: displayThreads.filter((t) => t.status === "failed").length, + pending: displayThreads.filter((t) => t.status === "pending").length, + }; + + const handleThreadClick = (thread: ThreadDisplayInfo) => { + router.push(`/chat/${thread.id}`); + }; + + return ( +
+ {/* Header */} +
+
+ +
+
+ All Threads +
+
+ + {filteredThreads.length} threads + +
+
+
+ + {/* Search and Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="border-gray-700 bg-gray-900 pl-10 text-gray-300 placeholder:text-gray-600" + /> +
+
+ + Filter: + {( + [ + "all", + "running", + "completed", + "failed", + "pending", + ] as FilterStatus[] + ).map((status) => ( + + ))} +
+
+
+ + {/* Content */} +
+
+ {statusFilter === "all" ? ( + // Show grouped view when "all" is selected +
+ {Object.entries(groupedThreads).map(([status, threads]) => { + if (threads.length === 0) return null; + return ( +
+
+

+ {status} Threads +

+ + {threads.length} + +
+
+ {threads.map((thread) => ( + handleThreadClick(thread)} + getStatusColor={getStatusColor} + getStatusIcon={getStatusIcon} + getPRStatusColor={getPRStatusColor} + /> + ))} +
+
+ ); + })} +
+ ) : ( + // Show flat list when specific status is selected +
+ {filteredThreads.map((thread) => ( + handleThreadClick(thread)} + getStatusColor={getStatusColor} + getStatusIcon={getStatusIcon} + getPRStatusColor={getPRStatusColor} + /> + ))} +
+ )} + + {filteredThreads.length === 0 && ( +
+
No threads found
+
+ {searchQuery + ? "Try adjusting your search query" + : "No threads match the selected filter"} +
+
+ )} +
+
+
+ ); +} + +interface ThreadCardProps { + thread: ThreadDisplayInfo; + onClick: () => void; + getStatusColor: (status: ThreadDisplayInfo["status"]) => string; + getStatusIcon: (status: ThreadDisplayInfo["status"]) => React.ReactNode; + getPRStatusColor: (status: string) => string; +} + +function ThreadCard({ + thread, + onClick, + getStatusColor, + getStatusIcon, + getPRStatusColor, +}: ThreadCardProps) { + return ( + + +
+
+ + {thread.title} + +
+ + + {thread.repository} + +
+
+ +
+ {getStatusIcon(thread.status)} + {thread.status} +
+
+
+
+ +
+
+ + {thread.taskCount} tasks + + • +
+ + + {thread.lastActivity} + +
+
+
+ {thread.githubIssue && ( + + )} + {thread.pullRequest && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a8ff7e4c..60afd2ed 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,9 +14,9 @@ export const metadata: Metadata = { title: "Open SWE", description: "Open SWE UX by LangChain", icons: { - icon: "/favicon.png", - shortcut: "/favicon.png", - apple: "/favicon.png", + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", }, }; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c94f6705..ce132525 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -3,7 +3,6 @@ import { Thread } from "@/components/thread"; import { StreamProvider } from "@/providers/Stream"; import { ThreadProvider } from "@/providers/Thread"; -import { ArtifactProvider } from "@/components/thread/artifact"; import { Toaster } from "@/components/ui/sonner"; import React from "react"; import { GitHubAppProvider } from "@/providers/GitHubApp"; @@ -15,9 +14,7 @@ export default function DemoPage(): React.ReactNode { - - - + diff --git a/apps/web/src/components/gen-ui/action-step.tsx b/apps/web/src/components/gen-ui/action-step.tsx index 4be1266a..8c0164ba 100644 --- a/apps/web/src/components/gen-ui/action-step.tsx +++ b/apps/web/src/components/gen-ui/action-step.tsx @@ -54,10 +54,10 @@ export function ActionStep(props: ActionStepProps) { switch (props.status) { case "loading": return ( -
+
); case "generating": - return ; + return ; case "done": return props.success ? ( @@ -106,13 +106,13 @@ export function ActionStep(props: ActionStepProps) { const renderHeaderIcon = () => { if (props.status === "loading" || !("actionType" in props)) { // In loading state, we don't know the type yet, use a generic icon - return ; + return ; } return props.actionType === "shell" ? ( - + ) : ( - + ); }; @@ -120,7 +120,7 @@ export function ActionStep(props: ActionStepProps) { const renderHeaderContent = () => { if (props.status === "loading" || !("actionType" in props)) { return ( - + Preparing action... ); @@ -130,18 +130,18 @@ export function ActionStep(props: ActionStepProps) { return (
{props.workdir && ( -
+
{props.workdir}
)} - + {props.command.join(" ")}
); } else { return ( - + {props.file} ); @@ -200,36 +200,36 @@ export function ActionStep(props: ActionStepProps) { }; return ( -
+
{props.reasoningText && ( -
+
{showReasoning && ( -

+

{props.reasoningText}

)}
)} -
+
{renderHeaderIcon()} {renderHeaderContent()}
- + {getStatusText()} {getStatusIcon()} {shouldShowToggle() && ( {showSummary && ( -

+

{props.summaryText}

)} diff --git a/apps/web/src/components/github/repo-branch-selectors/index.tsx b/apps/web/src/components/github/repo-branch-selectors/index.tsx index 79b83f2c..e4653cf9 100644 --- a/apps/web/src/components/github/repo-branch-selectors/index.tsx +++ b/apps/web/src/components/github/repo-branch-selectors/index.tsx @@ -1,40 +1,39 @@ import { BranchSelector } from "./branch-selector"; import { RepositorySelector } from "./repository-selector"; import { useQueryState } from "nuqs"; -import { useStreamContext } from "@/providers/Stream"; export function RepositoryBranchSelectors() { const [threadId] = useQueryState("threadId"); const chatStarted = !!threadId; const defaultButtonStyles = - "bg-inherit border-gray-300 rounded-full text-gray-500 hover:text-gray-700 text-xs"; + "bg-inherit border-none text-gray-500 hover:text-gray-300 text-xs p-0 h-fit hover:bg-inherit"; const defaultStylesChatStarted = - "hover:bg-inherit cursor-default hover:cursor-default hover:text-gray-500 hover:border-gray-300 hover:ring-inherit"; - - // Access stream context when chat has started - const stream = useStreamContext(); - const streamTargetRepository = chatStarted - ? stream?.values?.targetRepository - : undefined; + "hover:bg-inherit cursor-default hover:cursor-default hover:text-gray-300 hover:border-gray-300 hover:ring-inherit"; return (
- - +
+ ( + + ) +
+
+ ( + + ) +
); } diff --git a/apps/web/src/components/plan/plan-viewer.tsx b/apps/web/src/components/plan/plan-viewer.tsx index 71f5bcf6..e030d708 100644 --- a/apps/web/src/components/plan/plan-viewer.tsx +++ b/apps/web/src/components/plan/plan-viewer.tsx @@ -38,11 +38,11 @@ export function PlanViewer({ return (
-

+

{isProposedPlan ? "Proposed" : "Execution"} Plan

{!isProposedPlan && ( - + {planItems.filter((item) => item.completed).length} of{" "} {planItems.length} completed @@ -61,9 +61,9 @@ export function PlanViewer({ className={cn( "flex items-start gap-3 rounded-lg border p-3 transition-colors", { - "border-green-200 bg-green-50": status === "completed", - "border-blue-200 bg-blue-50": status === "current", - "border-gray-200 bg-gray-50": [ + "border-green-800 bg-green-900/30": status === "completed", + "border-blue-800 bg-blue-900/30": status === "current", + "border-gray-800 bg-gray-900/30": [ "remaining", "proposed", ].includes(status), @@ -73,35 +73,35 @@ export function PlanViewer({ {/* Status Icon */}
{status === "completed" && ( - + )} {status === "current" && ( - + )} {["remaining", "proposed"].includes(status) && ( - + )}
{/* Task Content */}
-
+
Step {item.index + 1} {status === "current" && ( - + In Progress )} {status === "completed" && ( - + Done )} {status === "proposed" && ( - + Proposed )} @@ -162,12 +162,13 @@ export function PlanViewer({ }); }); }} + className="text-gray-200" /> ) : (

-

+

+

Summary:

-

{item.summary}

+

{item.summary}

)}
@@ -192,12 +193,13 @@ export function PlanViewer({ {isProposedPlan && (