diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts index 57e690cd..83507030 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts @@ -25,6 +25,7 @@ import { import { getGitHubTokensFromConfig } from "../../../../utils/github-tokens.js"; import { createIssueFieldsFromMessages } from "../../utils/generate-issue-fields.js"; import { + extractContentWithoutDetailsFromIssueBody, extractIssueTitleAndContentFromMessage, formatContentForIssueBody, } from "../../../../utils/github/issue-messages.js"; @@ -110,7 +111,12 @@ export async function classifyMessage( role: "system", content: prompt, }, - userMessage, + { + role: "user", + content: extractContentWithoutDetailsFromIssueBody( + getMessageContentString(userMessage.content), + ), + }, ]); const toolCall = response.tool_calls?.[0]; @@ -217,8 +223,14 @@ export async function classifyMessage( new HumanMessage({ ...message, additional_kwargs: { - githubIssueId: githubIssueId, + githubIssueId, githubIssueCommentId: createdIssue.id, + ...((toolCallArgs.route as string) === + "start_planner_for_followup" + ? { + isFollowup: true, + } + : {}), }, }), ], @@ -228,6 +240,8 @@ export async function classifyMessage( await Promise.all(createCommentsPromise); let newPlannerId: string | undefined; + let goto = END; + if (plannerStatus === "interrupted") { if (!state.plannerSession?.threadId) { throw new Error("No planner session found. Unable to resume planner."); @@ -255,6 +269,10 @@ export async function classifyMessage( }); } + if (toolCallArgs.route === "start_planner_for_followup") { + goto = "start-planner"; + } + // After creating the new comment, we can add the message to state and end. const commandUpdate: ManagerGraphUpdate = { messages: newMessages, @@ -269,7 +287,7 @@ export async function classifyMessage( }; return new Command({ update: commandUpdate, - goto: END, + goto, }); } @@ -294,7 +312,10 @@ export async function classifyMessage( }); } - if (toolCallArgs.route === "start_planner") { + if ( + toolCallArgs.route === "start_planner" || + toolCallArgs.route === "start_planner_for_followup" + ) { // Always kickoff a new start planner node. This will enqueue new runs on the planner graph. return new Command({ update: commandUpdate, diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts index 7c470055..58030b44 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message/prompts.ts @@ -2,7 +2,7 @@ export const UPDATE_PROGRAMMER_ROUTING_OPTION = `- update_programmer: You should export const START_PLANNER_ROUTING_OPTION = `- start_planner: You should call this route if the user's message is a complete request you can send to the planner, which it can use to generate a plan. This route may be called when the planner has not started yet.\n`; -export const START_PLANNER_FOR_FOLLOWUP_ROUTING_OPTION = `- start_planner: You should call this route if the user's message is a complete followup request you can send to the planner, which it can use to generate a plan new plan to address the user's feedback/followup request. This route may be called when the planner and programmer are no longer running (e.g. after the user's initial request has been completed).\n`; +export const START_PLANNER_FOR_FOLLOWUP_ROUTING_OPTION = `- start_planner_for_followup: You should call this route if the user's message is a followup request you can send to the planner, which it can use to generate a plan new plan to address the user's feedback/followup request. This route may be called when the planner and programmer are no longer running (e.g. after the user's initial request has been completed).\n`; export const UPDATE_PLANNER_ROUTING_OPTION = `- update_planner: You should call this route if the user sends a new message containing anything from a related request that the planner should plan for, additional context about their previous request/the codebase, or something which the planner should be aware of.\n`; @@ -54,8 +54,8 @@ The programmer's current status is: {PROGRAMMER_STATUS} # Routing Options Based on all of the context provided above, generate a response to send to the user, including messaging about the route you'll select from the below options in your next step. Your routing options are: -- no_op: This should be called when the user's message is not a new request, additional context, or a new issue to create. This should only be called when none of the routing options are appropriate. {UPDATE_PROGRAMMER_ROUTING_OPTION}{START_PLANNER_ROUTING_OPTION}{UPDATE_PLANNER_ROUTING_OPTION}{RESUME_AND_UPDATE_PLANNER_ROUTING_OPTION}{CREATE_NEW_ISSUE_ROUTING_OPTION}{START_PLANNER_FOR_FOLLOWUP_ROUTING_OPTION} +- no_op: This should be called when the user's message is not a new request, additional context, or a new issue to create. This should only be called when none of the routing options are appropriate. # Response Your response should be clear, concise and straight to the point. Do NOT include any additional context, such as an idea for how to implement their request. diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message/utils.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message/utils.ts index 584f30d1..aeebcb07 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message/utils.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message/utils.ts @@ -116,15 +116,14 @@ export function createClassificationPromptAndToolSchema(inputs: { inputs.programmerStatus !== "not_started" || inputs.plannerStatus !== "not_started"; - const routingOptions: [string, ...string[]] = [ - "no_op", + const routingOptions = [ ...(programmerRunning ? ["update_programmer"] : []), - ...((plannerNotStarted ?? plannerAndProgrammerIdle) - ? ["start_planner"] - : []), + ...(plannerNotStarted ? ["start_planner"] : []), + ...(plannerAndProgrammerIdle ? ["start_planner_for_followup"] : []), ...(plannerRunning ? ["update_planner"] : []), ...(plannerInterrupted ? ["resume_and_update_planner"] : []), ...(showCreateIssueOption ? ["create_new_issue"] : []), + "no_op", ]; const prompt = CLASSIFICATION_SYSTEM_PROMPT.replaceAll( @@ -169,7 +168,9 @@ export function createClassificationPromptAndToolSchema(inputs: { formattedConversationHistoryPrompt ?? "", ); - const schema = createClassificationSchema(routingOptions); + const schema = createClassificationSchema( + routingOptions as [string, ...string[]], + ); return { prompt, diff --git a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts index 251dbe61..b3314ba5 100644 --- a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts +++ b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts @@ -11,6 +11,7 @@ import { getBranchName } from "../../../utils/github/git.js"; import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; import { getCustomConfigurableFields } from "../../../utils/config.js"; +import { getRecentUserRequest } from "../../../utils/user-request.js"; const logger = createLogger(LogLevel.INFO, "StartPlanner"); @@ -27,6 +28,9 @@ export async function startPlanner( }); const plannerThreadId = state.plannerSession?.threadId ?? uuidv4(); + const followupMessage = getRecentUserRequest(state.messages, { + returnFullMessage: true, + }); try { const runInput: PlannerGraphUpdate = { // github issue ID & target repo so the planning agent can fetch the user's request, and clone the repo. @@ -36,6 +40,7 @@ export async function startPlanner( taskPlan: state.taskPlan, branchName: state.branchName ?? getBranchName(config), autoAcceptPlan: state.autoAcceptPlan, + ...(followupMessage && { messages: [followupMessage] }), }; const run = await langGraphClient.runs.create( plannerThreadId, diff --git a/apps/open-swe/src/graphs/planner/nodes/generate-plan/index.ts b/apps/open-swe/src/graphs/planner/nodes/generate-plan/index.ts index 2da0aa50..80f7ea56 100644 --- a/apps/open-swe/src/graphs/planner/nodes/generate-plan/index.ts +++ b/apps/open-swe/src/graphs/planner/nodes/generate-plan/index.ts @@ -11,7 +11,7 @@ import { PlannerGraphState, PlannerGraphUpdate, } from "@open-swe/shared/open-swe/planner/types"; -import { getUserRequest } from "../../../../utils/user-request.js"; +import { formatUserRequestPrompt } from "../../../../utils/user-request.js"; import { formatFollowupMessagePrompt, isFollowupRequest, @@ -27,7 +27,6 @@ import { filterMessagesWithoutContent } from "../../../../utils/message/content. function formatSystemPrompt(state: PlannerGraphState): string { // It's a followup if there's more than one human message. const isFollowup = isFollowupRequest(state.taskPlan, state.proposedPlan); - const userRequest = getUserRequest(state.messages); const plannerNotes = getPlannerNotes(state.messages) .map((n) => `- ${n}`) .join("\n"); @@ -39,7 +38,7 @@ function formatSystemPrompt(state: PlannerGraphState): string { "\n\n" : "", ) - .replace("{USER_REQUEST}", userRequest) + .replace("{USER_REQUEST_PROMPT}", formatUserRequestPrompt(state.messages)) .replaceAll("{CUSTOM_RULES}", formatCustomRulesPrompt(state.customRules)) .replaceAll( "{PLANNER_NOTES}", diff --git a/apps/open-swe/src/graphs/planner/nodes/generate-plan/prompt.ts b/apps/open-swe/src/graphs/planner/nodes/generate-plan/prompt.ts index 3de9bf5b..8cb00699 100644 --- a/apps/open-swe/src/graphs/planner/nodes/generate-plan/prompt.ts +++ b/apps/open-swe/src/graphs/planner/nodes/generate-plan/prompt.ts @@ -15,7 +15,7 @@ Generate an execution plan to address the user's request. Your plan will guide t It should contain enough information to not require many additional context gathering steps to execute. -{USER_REQUEST} +{USER_REQUEST_PROMPT} diff --git a/apps/open-swe/src/graphs/planner/nodes/notetaker.ts b/apps/open-swe/src/graphs/planner/nodes/notetaker.ts index 07f0c56b..6b4384c4 100644 --- a/apps/open-swe/src/graphs/planner/nodes/notetaker.ts +++ b/apps/open-swe/src/graphs/planner/nodes/notetaker.ts @@ -11,7 +11,7 @@ import { Task, } from "../../../utils/load-model.js"; import { getMessageString } from "../../../utils/message/content.js"; -import { getUserRequest } from "../../../utils/user-request.js"; +import { formatUserRequestPrompt } from "../../../utils/user-request.js"; import { formatCustomRulesPrompt } from "../../../utils/custom-rules.js"; import { getPlannerNotes } from "../utils/get-notes.js"; import { ToolMessage } from "@langchain/core/messages"; @@ -47,9 +47,7 @@ You MUST adhere to the following criteria when generating your notes: - Carefully inspect the proposed plan. Your notes should be focused on context which will be most useful to you when you execute the plan. You may reference specific proposed plan items in your notes. {EXTRA_RULES} -Here is the user's request -## User request: -{USER_REQUEST} +{USER_REQUEST_PROMPT} Here is the conversation history: ## Conversation history: @@ -63,14 +61,12 @@ With all of this in mind, please carefully inspect the conversation history, and `; const formatPrompt = (state: PlannerGraphState): string => { - const userRequest = - getUserRequest(state.messages) || "No user request provided."; const plannerNotes = getPlannerNotes(state.messages) .map((n) => ` - ${n}`) .join("\n"); return systemPrompt - .replace("{USER_REQUEST}", userRequest) + .replace("{USER_REQUEST_PROMPT}", formatUserRequestPrompt(state.messages)) .replace( "{CONVERSATION_HISTORY}", state.messages.map(getMessageString).join("\n"), diff --git a/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts b/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts index f886c764..49365d6d 100644 --- a/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts +++ b/apps/open-swe/src/graphs/planner/nodes/prepare-state.ts @@ -2,7 +2,7 @@ import { PlannerGraphState, PlannerGraphUpdate, } from "@open-swe/shared/open-swe/planner/types"; -import { Command, END } from "@langchain/langgraph"; +import { Command } from "@langchain/langgraph"; import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; import { getIssue, getIssueComments } from "../../../utils/github/api.js"; import { v4 as uuidv4 } from "uuid"; @@ -19,6 +19,7 @@ import { getUntrackedComments, } from "../../../utils/github/issue-messages.js"; import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js"; +import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants"; export async function prepareGraphState( state: PlannerGraphState, @@ -86,28 +87,27 @@ export async function prepareGraphState( 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, hidden, and not human messages. const removedNonSummaryMessages = filterHiddenMessages(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.contextGatheringNotes, - additional_kwargs: { - summaryMessage: true, - }, - }); + + // TODO: We should prob have a UI component for "Previous Task Notes" so we can surface this in the UI. + const summaryMessage = state.contextGatheringNotes + ? new AIMessage({ + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + content: `Here are the notes taken while planning for the previous task:\n${state.contextGatheringNotes}`, + additional_kwargs: { + summaryMessage: true, + }, + }) + : undefined; + const commandUpdate: PlannerGraphUpdate = { messages: [ ...removedNonSummaryMessages, - summaryMessage, + ...(summaryMessage ? [summaryMessage] : []), ...untrackedComments, ], // Reset plan context summary as it's now included in the messages array. diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 9178eb65..1eb2872b 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -14,7 +14,10 @@ import { } from "@langchain/langgraph/prebuilt"; import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js"; import { createNewTask } from "@open-swe/shared/open-swe/tasks"; -import { getUserRequest } from "../../../utils/user-request.js"; +import { + getInitialUserRequest, + getRecentUserRequest, +} from "../../../utils/user-request.js"; import { PLAN_INTERRUPT_ACTION_TITLE, PLAN_INTERRUPT_DELIMITER, @@ -41,8 +44,9 @@ function createAcceptedPlanMessage(input: { planTitle: string; planItems: PlanItem[]; interruptType: HumanResponse["type"]; + runId: string; }) { - const { planTitle, planItems, interruptType } = input; + const { planTitle, planItems, interruptType, runId } = input; const acceptedPlanEvent: CustomNodeEvent = { nodeId: ACCEPTED_PLAN_NODE_ID, actionId: uuidv4(), @@ -53,6 +57,7 @@ function createAcceptedPlanMessage(input: { planTitle, planItems, interruptType, + runId, }, }; @@ -143,7 +148,9 @@ export async function interruptProposedPlan( } let planItems: PlanItem[]; - const userRequest = getUserRequest(state.messages); + const userRequest = getInitialUserRequest(state.messages); + const userFollowupRequest = getRecentUserRequest(state.messages); + const userTaskRequest = userFollowupRequest || userRequest; const runInput: GraphUpdate = { contextGatheringNotes: state.contextGatheringNotes, branchName: state.branchName, @@ -160,7 +167,7 @@ export async function interruptProposedPlan( completed: false, })); runInput.taskPlan = createNewTask( - userRequest, + userTaskRequest, state.proposedPlanTitle, planItems, { existingTaskPlan: state.taskPlan }, @@ -177,6 +184,7 @@ export async function interruptProposedPlan( planTitle: state.proposedPlanTitle, planItems, interruptType: "accept", + runId: config.configurable?.run_id ?? "", }), ], }); @@ -238,7 +246,7 @@ export async function interruptProposedPlan( })); runInput.taskPlan = createNewTask( - userRequest, + userTaskRequest, state.proposedPlanTitle, planItems, { existingTaskPlan: state.taskPlan }, @@ -255,7 +263,7 @@ export async function interruptProposedPlan( })); runInput.taskPlan = createNewTask( - userRequest, + userTaskRequest, state.proposedPlanTitle, planItems, { existingTaskPlan: state.taskPlan }, @@ -275,6 +283,7 @@ export async function interruptProposedPlan( planTitle: state.proposedPlanTitle, planItems, interruptType: humanResponse.type, + runId: config.configurable?.run_id ?? "", }), ], }); diff --git a/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts b/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts index 95eb22b3..6853864e 100644 --- a/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/rewrite-plan.ts @@ -10,7 +10,10 @@ import { PlannerGraphState, PlannerGraphUpdate, } from "@open-swe/shared/open-swe/planner/types"; -import { getUserRequest } from "../../../utils/user-request.js"; +import { + getInitialUserRequest, + getRecentUserRequest, +} from "../../../utils/user-request.js"; import { loadModel, supportsParallelToolCallsParam, @@ -149,12 +152,16 @@ async function identifyTasksToModifyFunc( }, ); - const userRequest = getUserRequest(state.messages); + const userInitialRequest = getInitialUserRequest(state.messages); + const userFollowupRequest = getRecentUserRequest(state.messages); + const userRequest = + userFollowupRequest ?? userInitialRequest ?? "No user message found"; + const response = await modelWithIdentifyChangesTool.invoke([ { role: "user", content: formatSysPromptIdentifyTasks( - userRequest || "No user message found", + userRequest, state.planChangeRequest, state.proposedPlan, ), @@ -217,12 +224,16 @@ async function updatePlanTasksFunc( : {}), }); - const userRequest = getUserRequest(state.messages); + const userInitialRequest = getInitialUserRequest(state.messages); + const userFollowupRequest = getRecentUserRequest(state.messages); + const userRequest = + userFollowupRequest ?? userInitialRequest ?? "No user message found"; + const response = await modelWithUpdatePlanTasksTool.invoke([ { role: "user", content: formatSysPromptRewritePlan( - userRequest || "No user message found", + userRequest, state.planChangeRequest, state.proposedPlan, tasksToModify, diff --git a/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts b/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts index dcca2e3d..ce7570b0 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/generate-conclusion.ts @@ -8,7 +8,7 @@ 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 { formatUserRequestPrompt } from "../../../utils/user-request.js"; import { completeTask, getActivePlanItems, @@ -40,11 +40,10 @@ export async function generateConclusion( ): Promise { const model = await loadModel(config, Task.SUMMARIZER); - const userRequest = getUserRequest(state.internalMessages); - const userMessage = `The user's initial request is as follows: -${userRequest || "No user message found"} + const userRequestPrompt = formatUserRequestPrompt(state.messages); + const userMessage = `${userRequestPrompt} -The conversation history is as follows: +The full conversation history is as follows: ${state.internalMessages.map(getMessageString).join("\n")} Given all of this, please respond with the concise conclusion. Do not include any additional text besides the conclusion.`; diff --git a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts index 3900172a..c106fd06 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/open-pr.ts @@ -18,8 +18,8 @@ import { Task, } from "../../../utils/load-model.js"; import { formatPlanPromptWithSummaries } from "../../../utils/plan-prompt.js"; -import { getUserRequest } from "../../../utils/user-request.js"; -import { AIMessage, ToolMessage } from "@langchain/core/messages"; +import { formatUserRequestPrompt } from "../../../utils/user-request.js"; +import { AIMessage, BaseMessage, ToolMessage } from "@langchain/core/messages"; import { deleteSandbox, getSandboxWithErrorHandling, @@ -38,16 +38,18 @@ You have just completed all of your tasks, and are now ready to open a pull requ Here are all of the tasks you completed: {COMPLETED_TASKS} -And here is the user's original request: -{USER_REQUEST} +{USER_REQUEST_PROMPT} With all of this in mind, please use the \`open_pr\` tool to open a pull request.`; -const formatPrompt = (taskPlan: PlanItem[], userRequest: string): string => { +const formatPrompt = ( + taskPlan: PlanItem[], + messages: BaseMessage[], +): string => { const completedTasks = taskPlan.filter((task) => task.completed); return openPrSysPrompt .replace("{COMPLETED_TASKS}", formatPlanPromptWithSummaries(completedTasks)) - .replace("{USER_REQUEST}", userRequest); + .replace("{USER_REQUEST_PROMPT}", formatUserRequestPrompt(messages)); }; export async function openPullRequest( @@ -108,11 +110,13 @@ export async function openPullRequest( : {}), }); - const userRequest = getUserRequest(state.internalMessages); const response = await modelWithTool.invoke([ { role: "user", - content: formatPrompt(getActivePlanItems(state.taskPlan), userRequest), + content: formatPrompt( + getActivePlanItems(state.taskPlan), + state.internalMessages, + ), }, ]); diff --git a/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts b/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts index a483421a..60fa285b 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/progress-plan-step.ts @@ -14,8 +14,7 @@ import { 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 { formatUserRequestPrompt } from "../../../utils/user-request.js"; import { completePlanItem, getActivePlanItems, @@ -88,12 +87,11 @@ export async function progressPlanStep( }, ); - const userRequest = getUserRequest(state.internalMessages, { - returnFullMessage: true, - }); - const conversationHistoryStr = `Here is the full conversation history after the user's request: + const conversationHistoryStr = `Here is the full conversation history including the user's request(s): -${removeFirstHumanMessage(state.internalMessages).map(getMessageString).join("\n")} +${state.internalMessages.map(getMessageString).join("\n")} + +${formatUserRequestPrompt(state.internalMessages)} 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 either the \`mark_task_completed\` or \`mark_task_not_completed\` tool.`; @@ -105,7 +103,6 @@ Once you've determined the status of the current task, call either the \`mark_ta role: "system", content: formatPrompt(activePlanItems), }, - userRequest, { role: "user", content: conversationHistoryStr, diff --git a/apps/open-swe/src/graphs/programmer/nodes/summarize-history.ts b/apps/open-swe/src/graphs/programmer/nodes/summarize-history.ts index 6818c523..d8e0a829 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/summarize-history.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/summarize-history.ts @@ -18,9 +18,24 @@ import { getMessageContentString } from "@open-swe/shared/messages"; import { getMessageString } from "../../../utils/message/content.js"; import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; import { createConversationHistorySummaryToolFields } from "@open-swe/shared/open-swe/tools"; -import { getUserRequest } from "../../../utils/user-request.js"; +import { formatUserRequestPrompt } from "../../../utils/user-request.js"; import { getMessagesSinceLastSummary } from "../../../utils/tokens.js"; +const SINGLE_USER_REQUEST_PROMPT = `Here is the user's request: + +{USER_REQUEST} +`; + +const USER_SENDING_FOLLOWUP_PROMPT = `Here is the user's initial request: + +{USER_REQUEST} + + +And here is the user's followup request you're now processing: + +{USER_FOLLOWUP_REQUEST} +`; + 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. @@ -37,10 +52,7 @@ This context will then overwrite the conversation history presented below. Becau To aid with this, you'll be provided with the user's request, as well as all of the tasks in the plan you generated to fulfil the user's request. Additionally, if a task has already been completed you'll be provided with the summary of the steps taken to complete it. -Here is the user's request: - -{USER_REQUEST} - +{USER_REQUEST_PROMPT} Here is the full list of tasks in the plan you're in the middle of, as well as the summary of the completed tasks: @@ -70,7 +82,7 @@ Respond ONLY with the extracted context. Do not include any additional informati const logger = createLogger(LogLevel.INFO, "SummarizeConversationHistory"); const formatPrompt = (inputs: { - userRequest: string; + messages: BaseMessage[]; plan: PlanItem[]; conversationHistoryToSummarize: BaseMessage[]; }): string => { @@ -82,7 +94,14 @@ const formatPrompt = (inputs: { includeSummaries: true, }), ) - .replace("{USER_REQUEST}", inputs.userRequest) + .replace( + "{USER_REQUEST_PROMPT}", + formatUserRequestPrompt( + inputs.messages, + SINGLE_USER_REQUEST_PROMPT, + USER_SENDING_FOLLOWUP_PROMPT, + ), + ) .replace( "{CONVERSATION_HISTORY}", inputs.conversationHistoryToSummarize.map(getMessageString).join("\n"), @@ -129,7 +148,6 @@ export async function summarizeHistory( ): Promise { const model = await loadModel(config, Task.SUMMARIZER); - const userRequest = getUserRequest(state.messages); const plan = getActivePlanItems(state.taskPlan); const conversationHistoryToSummarize = await getMessagesSinceLastSummary( state.internalMessages, @@ -147,7 +165,7 @@ export async function summarizeHistory( { role: "user", content: formatPrompt({ - userRequest, + messages: state.messages, plan, conversationHistoryToSummarize, }), diff --git a/apps/open-swe/src/graphs/reviewer/nodes/final-review.ts b/apps/open-swe/src/graphs/reviewer/nodes/final-review.ts index 3f67eb16..9a8eae34 100644 --- a/apps/open-swe/src/graphs/reviewer/nodes/final-review.ts +++ b/apps/open-swe/src/graphs/reviewer/nodes/final-review.ts @@ -3,7 +3,7 @@ import { ReviewerGraphState, ReviewerGraphUpdate, } from "@open-swe/shared/open-swe/reviewer/types"; -import { getUserRequest } from "../../../utils/user-request.js"; +import { formatUserRequestPrompt } from "../../../utils/user-request.js"; import { formatPlanPromptWithSummaries } from "../../../utils/plan-prompt.js"; import { getActivePlanItems, @@ -41,8 +41,7 @@ If you determine that the task has not been fully completed, you may call the \` Here is the full list of actions you took during your review: {REVIEW_ACTIONS} -Here is the user's original request: -{USER_REQUEST} +{USER_REQUEST_PROMPT} And here are the tasks which were outlined in the plan, and completed by the Programmer Assistant: {PLANNED_TASKS} @@ -60,14 +59,17 @@ const formatSystemPrompt = (state: ReviewerGraphState) => { const markCompletedToolName = createCodeReviewMarkTaskCompletedFields().name; const markNotCompleteToolName = createCodeReviewMarkTaskNotCompleteFields().name; - const userRequest = getUserRequest(state.messages); const activePlan = getActivePlanItems(state.taskPlan); const tasksString = formatPlanPromptWithSummaries(activePlan); const messagesString = state.reviewerMessages .map(getMessageString) .join("\n"); + return SYSTEM_PROMPT.replaceAll("{REVIEW_ACTIONS}", messagesString) - .replaceAll("{USER_REQUEST}", userRequest) + .replaceAll( + "{USER_REQUEST_PROMPT}", + formatUserRequestPrompt(state.messages), + ) .replaceAll("{PLANNED_TASKS}", tasksString) .replaceAll("{COMPLETE_TOOL_NAME}", markCompletedToolName) .replaceAll("{NOT_COMPLETE_TOOL_NAME}", markNotCompleteToolName); diff --git a/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/index.ts b/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/index.ts index 8ee09409..4f0c7b46 100644 --- a/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/index.ts +++ b/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/index.ts @@ -18,7 +18,7 @@ import { createInstallDependenciesTool, } from "../../../../tools/index.js"; import { formatCustomRulesPrompt } from "../../../../utils/custom-rules.js"; -import { getUserRequest } from "../../../../utils/user-request.js"; +import { formatUserRequestPrompt } from "../../../../utils/user-request.js"; import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; import { formatPlanPromptWithSummaries } from "../../../../utils/plan-prompt.js"; import { @@ -31,7 +31,6 @@ import { getMessageString } from "../../../../utils/message/content.js"; const logger = createLogger(LogLevel.INFO, "GenerateReviewActionsNode"); function formatSystemPrompt(state: ReviewerGraphState): string { - const userRequest = getUserRequest(state.messages); const activePlan = getActivePlanItems(state.taskPlan); const tasksString = formatPlanPromptWithSummaries(activePlan); const codeReview = getCodeReviewFields(state.internalMessages); @@ -52,7 +51,10 @@ function formatSystemPrompt(state: ReviewerGraphState): string { "{DEPENDENCIES_INSTALLED}", state.dependenciesInstalled ? "Yes" : "No", ) - .replaceAll("{USER_REQUEST}", userRequest) + .replaceAll( + "{USER_REQUEST_PROMPT}", + formatUserRequestPrompt(state.messages), + ) .replaceAll( "{PREVIOUS_REVIEW_PROMPT}", codeReview diff --git a/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/prompt.ts b/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/prompt.ts index c8c13600..fcc3cd11 100644 --- a/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/prompt.ts +++ b/apps/open-swe/src/graphs/reviewer/nodes/generate-review-actions/prompt.ts @@ -117,6 +117,5 @@ Generated via: \`git diff {BASE_BRANCH_NAME} --name-only\` {PREVIOUS_REVIEW_PROMPT} -The user's request is as follows (it's also included in the conversation history below). -{USER_REQUEST} +{USER_REQUEST_PROMPT} `; diff --git a/apps/open-swe/src/graphs/shared/initialize-sandbox.ts b/apps/open-swe/src/graphs/shared/initialize-sandbox.ts index 280c580b..ea8c6199 100644 --- a/apps/open-swe/src/graphs/shared/initialize-sandbox.ts +++ b/apps/open-swe/src/graphs/shared/initialize-sandbox.ts @@ -62,6 +62,7 @@ export async function initializeSandbox( ...base.data, status, ...(error ? { error } : {}), + runId: config.configurable?.run_id ?? "", }, }; events.push(event); diff --git a/apps/open-swe/src/utils/github/issue-messages.ts b/apps/open-swe/src/utils/github/issue-messages.ts index ef74faac..3f1f3274 100644 --- a/apps/open-swe/src/utils/github/issue-messages.ts +++ b/apps/open-swe/src/utils/github/issue-messages.ts @@ -8,6 +8,7 @@ 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"; +import { DETAILS_OPEN_TAG } from "./issue-task.js"; export function getUntrackedComments( existingMessages: BaseMessage[], @@ -146,6 +147,19 @@ function extractContentFromIssueBody(body: string): string { ); } +export function extractContentWithoutDetailsFromIssueBody( + body: string, +): string { + if (!body.includes(DETAILS_OPEN_TAG)) { + return extractContentFromIssueBody(body); + } + + const bodyWithoutDetails = extractContentFromIssueBody( + body.split(DETAILS_OPEN_TAG)[0], + ); + return bodyWithoutDetails; +} + export function getMessageContentFromIssue( issue: GitHubIssue | GitHubIssueComment, ): string { diff --git a/apps/open-swe/src/utils/github/issue-task.ts b/apps/open-swe/src/utils/github/issue-task.ts index 9690a4e7..7ee3c5ea 100644 --- a/apps/open-swe/src/utils/github/issue-task.ts +++ b/apps/open-swe/src/utils/github/issue-task.ts @@ -15,8 +15,8 @@ export const TASK_CLOSE_TAG = ""; export const PROPOSED_PLAN_OPEN_TAG = ""; export const PROPOSED_PLAN_CLOSE_TAG = ""; -const DETAILS_OPEN_TAG = "
"; -const DETAILS_CLOSE_TAG = "
"; +export const DETAILS_OPEN_TAG = "
"; +export const DETAILS_CLOSE_TAG = "
"; const AGENT_CONTEXT_DETAILS_SUMMARY = "Agent Context"; function typeNarrowTaskPlan(taskPlan: unknown): taskPlan is TaskPlan { diff --git a/apps/open-swe/src/utils/user-request.ts b/apps/open-swe/src/utils/user-request.ts index 72142622..8ebfaf24 100644 --- a/apps/open-swe/src/utils/user-request.ts +++ b/apps/open-swe/src/utils/user-request.ts @@ -4,28 +4,95 @@ import { HumanMessage, } from "@langchain/core/messages"; import { getMessageContentString } from "@open-swe/shared/messages"; +import { extractContentWithoutDetailsFromIssueBody } from "./github/issue-messages.js"; // 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( +export function getInitialUserRequest( messages: BaseMessage[], options?: { returnFullMessage?: never | false }, ): string; -export function getUserRequest( +export function getInitialUserRequest( messages: BaseMessage[], options?: { returnFullMessage?: true }, ): HumanMessage; -export function getUserRequest( +export function getInitialUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: boolean }, +): string | HumanMessage { + const initialMessage = messages.findLast( + (m) => isHumanMessage(m) && m.additional_kwargs?.isOriginalIssue, + ); + + if (!initialMessage) { + return ""; + } + + const parsedContent = extractContentWithoutDetailsFromIssueBody( + getMessageContentString(initialMessage.content), + ); + return options?.returnFullMessage + ? new HumanMessage({ + ...initialMessage, + content: parsedContent, + }) + : parsedContent; +} + +export function getRecentUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: never | false }, +): string; +export function getRecentUserRequest( + messages: BaseMessage[], + options?: { returnFullMessage?: true }, +): HumanMessage; +export function getRecentUserRequest( messages: BaseMessage[], options?: { returnFullMessage?: boolean }, ): string | HumanMessage { const recentUserMessage = messages.findLast( - (m) => isHumanMessage(m) && m.additional_kwargs?.isOriginalIssue, + (m) => isHumanMessage(m) && m.additional_kwargs?.isFollowup, ); + if (!recentUserMessage) { return ""; } + + const parsedContent = extractContentWithoutDetailsFromIssueBody( + getMessageContentString(recentUserMessage.content), + ); return options?.returnFullMessage - ? recentUserMessage - : getMessageContentString(recentUserMessage.content); + ? new HumanMessage({ + ...recentUserMessage, + content: parsedContent, + }) + : parsedContent; +} + +const DEFAULT_SINGLE_USER_REQUEST_PROMPT = `Here is the user's request: +{USER_REQUEST}`; + +const DEFAULT_USER_SENDING_FOLLOWUP_PROMPT = `Here is the user's initial request: +{USER_REQUEST} + +And here is the user's followup request you're now processing: +{USER_FOLLOWUP_REQUEST}`; + +export function formatUserRequestPrompt( + messages: BaseMessage[], + singleRequestPrompt: string = DEFAULT_SINGLE_USER_REQUEST_PROMPT, + followupRequestPrompt: string = DEFAULT_USER_SENDING_FOLLOWUP_PROMPT, +): string { + const noRequestMessage = "No user request provided."; + const userRequest = getInitialUserRequest(messages) || noRequestMessage; + const userFollowupRequest = getRecentUserRequest(messages); + + if (userFollowupRequest) { + return singleRequestPrompt + .replace("{USER_REQUEST}", userRequest) + .replace("{USER_FOLLOWUP_REQUEST}", userFollowupRequest); + } + + return followupRequestPrompt.replace("{USER_REQUEST}", userRequest); } diff --git a/apps/web/src/components/gen-ui/initialize-step.tsx b/apps/web/src/components/gen-ui/initialize-step.tsx index f08361fb..aa21f5f2 100644 --- a/apps/web/src/components/gen-ui/initialize-step.tsx +++ b/apps/web/src/components/gen-ui/initialize-step.tsx @@ -7,37 +7,22 @@ import { GitBranch, ChevronDown, } from "lucide-react"; -import { useState, useEffect, useRef } from "react"; +import { useState } from "react"; import { cn } from "@/lib/utils"; import { Step } from "@open-swe/shared/open-swe/custom-node-events"; -import { Button } from "../ui/button"; type InitializeStepProps = { status: "loading" | "generating" | "done"; success?: boolean; steps?: Step[]; - collapse?: boolean; }; export function InitializeStep({ status, success, steps, - collapse: collapseProp, }: InitializeStepProps) { - const [collapsed, setCollapsed] = useState(collapseProp ?? false); - const wasDone = useRef(false); - - // Auto-collapse when status is 'done' and success is true - useEffect(() => { - if (status === "done" && success && !collapsed && !wasDone.current) { - setCollapsed(true); - wasDone.current = true; - } - if (status !== "done") { - wasDone.current = false; - } - }, [status, success, collapsed]); + const [collapsed, setCollapsed] = useState(false); const stepStatusIcon = { waiting: ( @@ -97,11 +82,10 @@ export function InitializeStep({ {getStatusText()} {getStatusIcon()} - + {/* Only render the rest if not collapsed */} {!collapsed && steps && steps.length > 0 && ( diff --git a/apps/web/src/components/v2/actions-renderer.tsx b/apps/web/src/components/v2/actions-renderer.tsx index 1d7bcbfc..2538d27b 100644 --- a/apps/web/src/components/v2/actions-renderer.tsx +++ b/apps/web/src/components/v2/actions-renderer.tsx @@ -1,7 +1,14 @@ import { isAIMessageSDK, isHumanMessageSDK } from "@/lib/langchain-messages"; import { UseStream, useStream } from "@langchain/langgraph-sdk/react"; import { AssistantMessage } from "../thread/messages/ai"; -import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import { + Dispatch, + SetStateAction, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; import { useCancelStream } from "@/hooks/useCancelStream"; import { @@ -130,12 +137,20 @@ export function ActionsRenderer({ streamName: graphId === "planner" ? "Planner" : "Programmer", }); - const initializeEvents = customNodeEvents.filter( - (e) => e.nodeId === INITIALIZE_NODE_ID, + const initializeEvents = useMemo( + () => + customNodeEvents.filter( + (e) => e.nodeId === INITIALIZE_NODE_ID && e.data.runId === runId, + ), + [customNodeEvents, runId], ); - const acceptedPlanEvents = customNodeEvents.filter( - (e) => e.nodeId === ACCEPTED_PLAN_NODE_ID, + const acceptedPlanEvents = useMemo( + () => + customNodeEvents.filter( + (e) => e.nodeId === ACCEPTED_PLAN_NODE_ID && e.data.runId === runId, + ), + [customNodeEvents, runId], ); const steps = mapCustomEventsToSteps(initializeEvents); @@ -243,10 +258,6 @@ export function ActionsRenderer({ } }, [stream.values, graphId]); - useEffect(() => { - console.log(stream.messages); - }, [stream.messages]); - if (streamLoading) { return ; } @@ -258,7 +269,6 @@ export function ActionsRenderer({ status={initStatus} steps={steps} success={allSuccess} - collapse={initStatus === "done" && allSuccess} /> )} {filteredMessages?.map((m) => ( @@ -274,9 +284,16 @@ export function ActionsRenderer({ {acceptedPlanEvents.length > 0 && isAcceptedPlanEvents(acceptedPlanEvents) && ( )} {/* If the last message is hidden, but there's an interrupt, we must manually render the interrupt */} diff --git a/apps/web/src/components/v2/thread-view.tsx b/apps/web/src/components/v2/thread-view.tsx index 8542fbeb..12f0f252 100644 --- a/apps/web/src/components/v2/thread-view.tsx +++ b/apps/web/src/components/v2/thread-view.tsx @@ -48,11 +48,27 @@ export function ThreadView({ const [selectedTab, setSelectedTab] = useState<"planner" | "programmer">( "planner", ); - const plannerThreadId = stream.values?.plannerSession?.threadId; - const plannerRunId = stream.values?.plannerSession?.runId; + const [plannerSession, setPlannerSession] = + useState(); const [programmerSession, setProgrammerSession] = useState(); + useEffect(() => { + if ( + stream?.values?.plannerSession && + plannerSession?.runId !== stream.values.plannerSession.runId + ) { + // State shouldn't update before we use it below, but still create a copy to avoid race conditions + const prevPlannerSession = plannerSession; + setPlannerSession(stream.values.plannerSession); + + if (prevPlannerSession && selectedTab === "programmer") { + // If we already has a planner session, and the user is currently on the programmer tab, bring them back to the planner tab + setSelectedTab("planner"); + } + } + }, [stream?.values]); + const { status: realTimeStatus } = useThreadStatus(displayThread.id); const getStatusDotColor = (status: string) => { @@ -185,8 +201,8 @@ export function ThreadView({ plannerCancelRef.current && ( )} @@ -195,8 +211,8 @@ export function ThreadView({ programmerCancelRef.current && ( )} @@ -206,11 +222,11 @@ export function ThreadView({ - {plannerThreadId && plannerRunId && ( + {plannerSession && ( graphId={PLANNER_GRAPH_ID} - threadId={plannerThreadId} - runId={plannerRunId} + threadId={plannerSession.threadId} + runId={plannerSession.runId} setProgrammerSession={setProgrammerSession} programmerSession={programmerSession} setSelectedTab={setSelectedTab} @@ -223,7 +239,7 @@ export function ThreadView({ }} /> )} - {!(plannerThreadId && plannerRunId) && ( + {!plannerSession && (
diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 377b0fd9..7ecf7c1a 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -364,6 +364,14 @@ export const GraphConfigurationMetadata: { "The maximum number of tokens to generate in an individual LLM call. Increasing/decreasing this number _will_ effect how many tokens are generated by the model. It will _not_ simply cut off the generation after the specified number of tokens is reached.", }, }, + mcpServers: { + x_open_swe_ui_config: { + type: "json", + default: JSON.stringify(DEFAULT_MCP_SERVERS, null, 2), + description: + "JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).", + }, + }, [GITHUB_TOKEN_COOKIE]: { x_open_swe_ui_config: { type: "hidden", @@ -394,17 +402,31 @@ export const GraphConfigurationMetadata: { type: "hidden", }, }, - mcpServers: { + thread_id: { x_open_swe_ui_config: { - type: "json", - default: JSON.stringify(DEFAULT_MCP_SERVERS, null, 2), - description: - "JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).", + type: "hidden", + }, + }, + run_id: { + x_open_swe_ui_config: { + type: "hidden", }, }, }; export const GraphConfiguration = z.object({ + /** + * The thread ID, generated by the manager graph. + */ + thread_id: withLangGraph(z.string().optional(), { + metadata: GraphConfigurationMetadata.thread_id, + }), + /** + * The run ID, generated by the manager graph. + */ + run_id: withLangGraph(z.string().optional(), { + metadata: GraphConfigurationMetadata.run_id, + }), /** * The maximum number of context gathering actions to take during planning. * Each action consists of 2 messages (request & result), plus 1 human message.