[WIP] feat: New multi agent GitHub based workflow (#175)

* feat: New multi agent GitHub based workflow

* fix: read plan from gh issue in init issue node

* format

* cr

* fix how new planner threads are started, allow for creating new issues

* fix: read tasks from gh issue inside manager when taking any action

* cr

* update planner

* feat: port intrerrupt node to planner

* add interrupt routing in graph

* update programmer nodes and state

* read messages from issue in programmer

* lint fix

* tag issue when opening PR

* better pr title and body generation prompts

* start runs from planner interrupt and update issue with task plan

* fix lg server build issues

* cr

* fix cloning and pushing

* cr

* cr

* feat: v0 copy (#194)

* feat: v0 copy

* cr

* all pages 'working'

* cr

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2025-06-18 11:03:39 -07:00 • committed by GitHub
parent bd6a4b5cb2
commit ff8a0f7167
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 4277 additions and 972 deletions

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"cSpell.words": [
"helicunate"
]
}

View file

@ -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": "*",

View file

@ -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";

View file

@ -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<Command> {
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}`);
}

View file

@ -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<ManagerGraphUpdate> {
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.`,
}),
],
};
}

View file

@ -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";

View file

@ -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<ManagerGraphUpdate> {
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,
};
}

View file

@ -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;
}
}

View file

@ -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<TargetRepository>(),
/**
* The tasks generated for this request.
*/
taskPlan: z.custom<TaskPlan>(),
/**
* 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/<manager-thread-id>
*/
branchName: z.string(),
});
export type ManagerGraphState = z.infer<typeof ManagerGraphStateObj>;
export type ManagerGraphUpdate = Partial<ManagerGraphState>;

View file

@ -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<typeof githubIssueTool.schema>;
}
export const createIssueTitleAndBodyFromMessages = traceable(
createIssueTitleAndBodyFromMessagesFunc,
{ name: "create-issue-title-and-body-from-messages" },
);

View file

@ -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";

View file

@ -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],
};
}

View file

@ -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] }),
};
}

View file

@ -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";

View file

@ -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<Command> {
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",
});
}

View file

@ -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<PlannerGraphUpdate | Command> {
const { proposedPlan } = state;
if (!proposedPlan.length) {
throw new Error("No proposed plan found.");
}
const interruptRes = interrupt<HumanInterrupt, HumanResponse[]>({
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,
};
}

View file

@ -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<PlanItem[]> {
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<string[]> {
@ -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<GraphUpdate> {
): Promise<PlannerGraphUpdate> {
if (!state.planChangeRequest) {
throw new Error("No plan change request found.");
}

View file

@ -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([
{

View file

@ -13,7 +13,7 @@ export async function takeAction(
state: PlannerGraphState,
_config: GraphConfig,
): Promise<PlannerGraphUpdate> {
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],
};
}

View file

@ -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<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
targetRepository: withLangGraph(z.custom<TargetRepository>(), {
reducer: {
schema: z.custom<TargetRepository>(),
fn: (_state, update) => update,
},
}),
githubIssueId: withLangGraph(z.custom<number>(), {
reducer: {
schema: z.custom<number>(),
fn: (_state, update) => update,
},
}),
codebaseTree: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
taskPlan: withLangGraph(z.custom<TaskPlan>(), {
reducer: {
schema: z.custom<TaskPlan>(),
fn: (_state, update) => update,
},
}),
proposedPlan: withLangGraph(z.custom<string[]>(), {
reducer: {
schema: z.custom<string[]>(),
fn: (_state, update) => update,
},
default: (): string[] => [],
}),
planContextSummary: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
default: () => "",
}),
branchName: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
planChangeRequest: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
programmerThreadId: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
});
export type PlannerGraphState = z.infer<typeof PlannerGraphStateObj>;
export type PlannerGraphUpdate = Partial<PlannerGraphState>;

View file

@ -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 `<previous-task index="${task.taskIndex}">
User request: ${task.request}
Overall task summary:\n</task-summary>\n${task.summary || "No overall task summary found"}\n</task-summary>
Individual tasks you generated to complete this request:
${activePlanItems.map((planItem) => `<plan-item index="${planItem.index}">${planItem.plan}</plan-item>`).join("\n")}
</previous-task>`;
})
.join("\n");
return previousCompletedPlanPrompt.replace(
"{PREVIOUS_PLAN}",
formattedTasksAndRequests,
);
};
const formatPreviousProposedPlan = (proposedPlan: string[]): string => {
const formattedProposedPlan = proposedPlan
.map((p) => `<proposed-plan-item>${p}</proposed-plan-item>`)
.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;
}

View file

@ -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";

View file

@ -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,
),
},

View file

@ -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,
};
}

View file

@ -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 }),
};
}

View file

@ -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";

View file

@ -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;

View file

@ -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({

View file

@ -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:

View file

@ -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<Command> {
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",

View file

@ -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,
});
}

View file

@ -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<GraphUpdate> {
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,
};
}

View file

@ -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<GraphUpdate> {
): Promise<Partial<InitializeSandboxState>> {
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,
};
}

View file

@ -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<Command> {
const { proposedPlan } = state;
if (!proposedPlan.length) {
throw new Error("No proposed plan found.");
}
const interruptRes = interrupt<HumanInterrupt, HumanResponse[]>({
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);
}

View file

@ -1 +0,0 @@
export * from "./planner/index.js";

View file

@ -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<BaseMessage[]>(), {
reducer: {
schema: z.custom<Messages>(),
fn: messagesStateReducer,
},
jsonSchemaExtra: {
langgraph_type: "messages",
},
default: () => [],
}),
});
export type PlannerGraphState = z.infer<typeof PlannerGraphStateObj>;
export type PlannerGraphUpdate = Partial<PlannerGraphState>;

View file

@ -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 `<previous-task index="${task.taskIndex}">
User request: ${task.request}
Overall task summary:\n</task-summary>\n${task.summary || "No overall task summary found"}\n</task-summary>
Individual tasks you generated to complete this request:
${activePlanItems.map((planItem) => `<plan-item index="${planItem.index}">${planItem.plan}</plan-item>`).join("\n")}
</previous-task>`;
})
.join("\n");
};
export function formatFollowupMessagePrompt(tasks: TaskPlan): string {
return followupMessagePrompt.replace(
"{PREVIOUS_PLAN}",
formatPreviousPlans(tasks),
);
}

View file

@ -15,7 +15,9 @@ const DEFAULT_ENV = {
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
export function createShellTool(state: GraphState) {
export function createShellTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
) {
const shellTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
let sandbox: Sandbox | undefined;

View file

@ -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<GitHubIssue | null> {
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<GitHubIssueComment[] | null> {
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<GitHubIssue | null> {
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<GitHubIssueComment | null> {
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;
}
}

View file

@ -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;
}

View file

@ -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<BaseMessage[]> {
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 = "<open-swe-issue-title>";
export const ISSUE_TITLE_CLOSE_TAG = "</open-swe-issue-title>";
export const ISSUE_CONTENT_OPEN_TAG = "<open-swe-issue-content>";
export const ISSUE_CONTENT_CLOSE_TAG = "</open-swe-issue-content>";
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}`;
}

View file

@ -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 = "<oap-do-not-edit-task-plan>";
export const TASK_CLOSE_TAG = "</oap-do-not-edit-task-plan>";
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<TaskPlan | null> {
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 = "<details>";
const DETAILS_CLOSE_TAG = "</details>";
const AGENT_CONTEXT_DETAILS_SUMMARY = "<summary>Agent Context</summary>";
export async function addTaskPlanToIssue(
input: GetIssueTaskPlanInput,
config: GraphConfig,
taskPlan: TaskPlan,
): Promise<void> {
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,
});
}

View file

@ -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"];

View file

@ -0,0 +1,20 @@
import { Client } from "@langchain/langgraph-sdk";
export function createLangGraphClient(options?: {
defaultHeaders?: Record<string, string>;
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,
});
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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 <task-summary>\n${p.summary || "No task summary found"}\n </task-summary>\n</${p.completed ? "completed-" : ""}task>`,

View file

@ -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;
}

View file

@ -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 "";
}

View file

@ -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;
}

View file

@ -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<ThreadPageProps>;
}) {
const router = useRouter();
const { thread_id } = use(params);
const stream = useStream<ManagerGraphState>({
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
assistantId: process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID ?? "",
threadId: thread_id,
reconnectOnMount: true,
});
const { threads } = useThreads<GraphState>();
// 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 (
<div className="h-screen bg-black">
<ThreadView
stream={stream}
displayThread={currentDisplayThread}
allDisplayThreads={displayThreads}
onThreadSelect={handleThreadSelect}
onBackToHome={handleBackToHome}
/>
</div>
);
}

View file

@ -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;
}

View file

@ -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<GraphState>(
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID,
);
// Convert Thread objects to ThreadDisplayInfo for UI
const displayThreads: ThreadDisplayInfo[] =
threads?.map(threadToDisplayInfo) ?? [];
return (
<div className="h-screen overflow-y-hidden bg-black">
<Suspense fallback={<div>Loading...</div>}>
<Toaster />
<GitHubAppProvider>
<DefaultView threads={displayThreads} />
</GitHubAppProvider>
</Suspense>
</div>
);
}

View file

@ -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;
}

View file

@ -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<GraphState>();
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<FilterStatus>("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 <Loader2 className="h-4 w-4 animate-spin" />;
case "completed":
return <CheckCircle className="h-4 w-4" />;
case "failed":
return <XCircle className="h-4 w-4" />;
case "pending":
return <Clock className="h-4 w-4" />;
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 (
<div className="flex h-screen flex-col bg-black">
{/* Header */}
<div className="border-b border-gray-900 bg-black px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-600 hover:bg-gray-900 hover:text-gray-400"
onClick={() => router.push("/chat")}
>
<ArrowLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="font-mono text-sm text-gray-400">All Threads</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-gray-600">
{filteredThreads.length} threads
</span>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="border-b border-gray-900 bg-gray-950 px-4 py-3">
<div className="flex items-center gap-3">
<div className="relative max-w-md flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-500" />
<Input
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-gray-700 bg-gray-900 pl-10 text-gray-300 placeholder:text-gray-600"
/>
</div>
<div className="flex items-center gap-1">
<Filter className="h-4 w-4 text-gray-500" />
<span className="mr-2 text-xs text-gray-500">Filter:</span>
{(
[
"all",
"running",
"completed",
"failed",
"pending",
] as FilterStatus[]
).map((status) => (
<Button
key={status}
variant={statusFilter === status ? "secondary" : "ghost"}
size="sm"
className={`h-7 text-xs ${
statusFilter === status
? "bg-gray-700 text-gray-200"
: "text-gray-500 hover:bg-gray-800 hover:text-gray-300"
}`}
onClick={() => setStatusFilter(status)}
>
{status === "all"
? "All"
: status.charAt(0).toUpperCase() + status.slice(1)}
<Badge
variant="secondary"
className="ml-1 bg-gray-800 text-xs text-gray-400"
>
{statusCounts[status]}
</Badge>
</Button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl p-4">
{statusFilter === "all" ? (
// Show grouped view when "all" is selected
<div className="space-y-6">
{Object.entries(groupedThreads).map(([status, threads]) => {
if (threads.length === 0) return null;
return (
<div key={status}>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-base font-semibold text-gray-300 capitalize">
{status} Threads
</h2>
<Badge
variant="secondary"
className="bg-gray-800 text-xs text-gray-400"
>
{threads.length}
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{threads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
onClick={() => handleThreadClick(thread)}
getStatusColor={getStatusColor}
getStatusIcon={getStatusIcon}
getPRStatusColor={getPRStatusColor}
/>
))}
</div>
</div>
);
})}
</div>
) : (
// Show flat list when specific status is selected
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
onClick={() => handleThreadClick(thread)}
getStatusColor={getStatusColor}
getStatusIcon={getStatusIcon}
getPRStatusColor={getPRStatusColor}
/>
))}
</div>
)}
{filteredThreads.length === 0 && (
<div className="py-12 text-center">
<div className="mb-2 text-gray-500">No threads found</div>
<div className="text-xs text-gray-600">
{searchQuery
? "Try adjusting your search query"
: "No threads match the selected filter"}
</div>
</div>
)}
</div>
</div>
</div>
);
}
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 (
<Card
className="cursor-pointer border-gray-800 bg-gray-950 transition-shadow hover:bg-gray-900 hover:shadow-lg"
onClick={onClick}
>
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium text-gray-300">
{thread.title}
</CardTitle>
<div className="mt-1 flex items-center gap-1">
<GitBranch className="h-2 w-2 text-gray-600" />
<span className="truncate text-xs text-gray-500">
{thread.repository}
</span>
</div>
</div>
<Badge
variant="secondary"
className={`${getStatusColor(thread.status)} text-xs`}
>
<div className="flex items-center gap-1">
{getStatusIcon(thread.status)}
<span className="capitalize">{thread.status}</span>
</div>
</Badge>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">
{thread.taskCount} tasks
</span>
<span className="text-xs text-gray-600">•</span>
<div className="flex items-center gap-1">
<Calendar className="h-2 w-2 text-gray-600" />
<span className="text-xs text-gray-600">
{thread.lastActivity}
</span>
</div>
</div>
<div className="flex items-center gap-1">
{thread.githubIssue && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-gray-500 hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
window.open(thread.githubIssue!.url, "_blank");
}}
>
<Bug className="h-3 w-3" />
</Button>
)}
{thread.pullRequest && (
<Button
variant="ghost"
size="sm"
className={`h-5 w-5 p-0 hover:text-gray-300 ${getPRStatusColor(thread.pullRequest.status)}`}
onClick={(e) => {
e.stopPropagation();
window.open(thread.pullRequest!.url, "_blank");
}}
>
<GitPullRequest className="h-3 w-3" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -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",
},
};

View file

@ -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 {
<GitHubAppProvider>
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
<Thread />
</StreamProvider>
</ThreadProvider>
</GitHubAppProvider>

View file

@ -54,10 +54,10 @@ export function ActionStep(props: ActionStepProps) {
switch (props.status) {
case "loading":
return (
<div className="h-3.5 w-3.5 rounded-full border border-gray-300" />
<div className="h-3.5 w-3.5 rounded-full border border-gray-600" />
);
case "generating":
return <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-500" />;
return <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />;
case "done":
return props.success ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
@ -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 <Loader2 className="mr-2 h-3.5 w-3.5 text-gray-500" />;
return <Loader2 className="mr-2 h-3.5 w-3.5 text-gray-400" />;
}
return props.actionType === "shell" ? (
<Terminal className="mr-2 h-3.5 w-3.5 text-gray-500" />
<Terminal className="mr-2 h-3.5 w-3.5 text-gray-400" />
) : (
<FileCode className="mr-2 h-3.5 w-3.5 text-gray-500" />
<FileCode className="mr-2 h-3.5 w-3.5 text-gray-400" />
);
};
@ -120,7 +120,7 @@ export function ActionStep(props: ActionStepProps) {
const renderHeaderContent = () => {
if (props.status === "loading" || !("actionType" in props)) {
return (
<span className="text-xs font-normal text-gray-800">
<span className="text-xs font-normal text-gray-300">
Preparing action...
</span>
);
@ -130,18 +130,18 @@ export function ActionStep(props: ActionStepProps) {
return (
<div className="flex-1">
{props.workdir && (
<div className="mb-0.5 text-xs font-normal text-gray-500">
<div className="mb-0.5 text-xs font-normal text-gray-400">
{props.workdir}
</div>
)}
<code className="text-xs font-normal text-gray-800">
<code className="text-xs font-normal text-gray-300">
{props.command.join(" ")}
</code>
</div>
);
} else {
return (
<code className="flex-1 text-xs font-normal text-gray-800">
<code className="flex-1 text-xs font-normal text-gray-300">
{props.file}
</code>
);
@ -200,36 +200,36 @@ export function ActionStep(props: ActionStepProps) {
};
return (
<div className="overflow-hidden rounded-md border border-gray-200">
<div className="overflow-hidden rounded-md border border-gray-700">
{props.reasoningText && (
<div className="border-b border-blue-100 bg-blue-50 p-2">
<div className="border-b border-blue-800 bg-blue-900/50 p-2">
<button
onClick={() => setShowReasoning(!showReasoning)}
className="flex items-center gap-1 text-xs font-normal text-blue-700 hover:text-blue-800"
className="flex items-center gap-1 text-xs font-normal text-blue-400 hover:text-blue-300"
>
<MessageSquare className="h-3 w-3" />
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800">
<p className="mt-1 text-xs font-normal text-blue-300">
{props.reasoningText}
</p>
)}
</div>
)}
<div className="flex items-center border-b border-gray-200 bg-gray-50 p-2">
<div className="flex items-center border-b border-gray-700 bg-gray-800 p-2">
{renderHeaderIcon()}
{renderHeaderContent()}
<div className="flex items-center gap-2">
<span className="text-xs font-normal text-gray-500">
<span className="text-xs font-normal text-gray-400">
{getStatusText()}
</span>
{getStatusIcon()}
{shouldShowToggle() && (
<button
onClick={() => setExpanded(!expanded)}
className="text-gray-500 hover:text-gray-700"
className="text-gray-400 hover:text-gray-300"
>
{expanded ? (
<ChevronUp className="h-3.5 w-3.5" />
@ -244,16 +244,16 @@ export function ActionStep(props: ActionStepProps) {
{renderContent()}
{props.summaryText && props.status === "done" && (
<div className="border-t border-green-100 bg-green-50 p-2">
<div className="border-t border-green-800 bg-green-900/50 p-2">
<button
onClick={() => setShowSummary(!showSummary)}
className="flex items-center gap-1 text-xs font-normal text-green-700 hover:text-green-800"
className="flex items-center gap-1 text-xs font-normal text-green-400 hover:text-green-300"
>
<FileText className="h-3 w-3" />
{showSummary ? "Hide summary" : "Show summary"}
</button>
{showSummary && (
<p className="mt-1 text-xs font-normal text-green-800">
<p className="mt-1 text-xs font-normal text-green-300">
{props.summaryText}
</p>
)}

View file

@ -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 (
<div className="flex items-center gap-2">
<RepositorySelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
streamTargetRepository={streamTargetRepository}
/>
<BranchSelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
streamTargetRepository={streamTargetRepository}
/>
<div className="flex items-center gap-0">
<span className="-mr-2 text-gray-500">(</span>
<RepositorySelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
/>
<span className="-ml-2 text-gray-500">)</span>
</div>
<div className="flex items-center gap-0">
<span className="-mr-2 text-gray-500">(</span>
<BranchSelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
/>
<span className="-ml-2 text-gray-500">)</span>
</div>
</div>
);
}

View file

@ -38,11 +38,11 @@ export function PlanViewer({
return (
<div className={cn("w-full space-y-3", className)}>
<div className="mb-4 flex items-center gap-2">
<h3 className="text-sm font-medium text-gray-900">
<h3 className="text-sm font-medium text-white">
{isProposedPlan ? "Proposed" : "Execution"} Plan
</h3>
{!isProposedPlan && (
<span className="text-xs text-gray-500">
<span className="text-xs text-gray-400">
{planItems.filter((item) => item.completed).length} of{" "}
{planItems.length} completed
</span>
@ -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 */}
<div className="mt-0.5 flex-shrink-0">
{status === "completed" && (
<Check className="h-4 w-4 text-green-600" />
<Check className="h-4 w-4 text-green-400" />
)}
{status === "current" && (
<Play className="h-4 w-4 text-blue-600" />
<Play className="h-4 w-4 text-blue-400" />
)}
{["remaining", "proposed"].includes(status) && (
<Clock className="h-4 w-4 text-gray-400" />
<Clock className="h-4 w-4 text-gray-500" />
)}
</div>
{/* Task Content */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between text-gray-500">
<div className="mb-1 flex items-center justify-between text-gray-400">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
Step {item.index + 1}
</span>
{status === "current" && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
<span className="rounded-full bg-blue-900/50 px-2 py-0.5 text-xs text-blue-300">
In Progress
</span>
)}
{status === "completed" && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
<span className="rounded-full bg-green-900/50 px-2 py-0.5 text-xs text-green-300">
Done
</span>
)}
{status === "proposed" && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-300">
Proposed
</span>
)}
@ -162,12 +162,13 @@ export function PlanViewer({
});
});
}}
className="text-gray-200"
/>
) : (
<p
className={cn("text-sm leading-relaxed", {
"text-gray-900": status === "current",
"text-gray-600":
"text-white": status === "current",
"text-gray-300":
status === "completed" ||
["remaining", "proposed"].includes(status),
})}
@ -178,11 +179,11 @@ export function PlanViewer({
{/* Summary for completed tasks */}
{item.summary && status === "completed" && (
<div className="bg-green-25 mt-2 rounded border border-green-100 p-2">
<p className="mb-1 text-xs font-medium text-green-700">
<div className="mt-2 rounded border border-green-800 bg-green-900/30 p-2">
<p className="mb-1 text-xs font-medium text-green-300">
Summary:
</p>
<p className="text-xs text-green-600">{item.summary}</p>
<p className="text-xs text-green-400">{item.summary}</p>
</div>
)}
</div>
@ -192,12 +193,13 @@ export function PlanViewer({
{isProposedPlan && (
<div
key="add-new-plan-item"
className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 transition-colors"
className="flex items-start gap-3 rounded-lg border border-gray-800 bg-gray-900/30 p-3 transition-colors"
>
<Textarea
placeholder="Add new plan item"
value={newPlanItem}
onChange={(e) => setNewPlanItem(e.target.value)}
className="text-gray-200"
/>
<Button
onClick={() => {
@ -212,7 +214,7 @@ export function PlanViewer({
]);
setNewPlanItem("");
}}
className="mt-auto"
className="mt-auto bg-inherit text-gray-200 transition-colors hover:bg-gray-800 hover:text-gray-100"
size="sm"
variant="outline"
>

View file

@ -2,11 +2,14 @@ import { PlanViewer } from "./plan-viewer";
import { Button } from "../ui/button";
import { useProposedPlan } from "../thread/agent-inbox/hooks/useProposedPlan";
import { PlanItem } from "@open-swe/shared/open-swe/types";
import { useStream } from "@langchain/langgraph-sdk/react";
export function ProposedPlan({
originalPlanItems,
stream,
}: {
originalPlanItems: PlanItem[];
stream: ReturnType<typeof useStream>;
}) {
const {
planItems,
@ -14,7 +17,7 @@ export function ProposedPlan({
changesMade,
handleResumePlan,
handleRejectPlan,
} = useProposedPlan(originalPlanItems);
} = useProposedPlan(originalPlanItems, stream);
if (!planItems.length) return null;

View file

@ -6,7 +6,7 @@ import {
PanelRightOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreads } from "@/providers/Thread";
import { useThreadsContext } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState, useCallback } from "react";
import { ThreadItem } from "./thread-item";
@ -22,7 +22,7 @@ interface TaskListSidebarProps {
export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading, handleThreadClick } = useThreads();
const { threads, threadsLoading, handleThreadClick } = useThreadsContext();
const onThreadClick = useCallback(
(thread: Thread<GraphState>) => {

View file

@ -1,7 +1,7 @@
"use client";
import { Archive, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreads } from "@/providers/Thread";
import { useThreadsContext } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState, useCallback } from "react";
import { ThreadItem } from "./thread-item";
@ -14,7 +14,7 @@ export default function TaskList() {
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading, handleThreadClick } = useThreads();
const { threads, threadsLoading, handleThreadClick } = useThreadsContext();
const isDashboardMode = !taskId;

View file

@ -8,11 +8,11 @@ export function useTaskPlan() {
useEffect(() => {
const currentPlanStr = JSON.stringify(taskPlan, null, 2);
const newPlanStr = JSON.stringify(values?.plan, null, 2);
const newPlanStr = JSON.stringify(values?.taskPlan, null, 2);
if (currentPlanStr !== newPlanStr) {
setTaskPlan(values?.plan);
setTaskPlan(values?.taskPlan);
}
}, [values?.plan]);
}, [values?.taskPlan]);
return {
taskPlan,

View file

@ -2,7 +2,7 @@
import { memo } from "react";
import { differenceInHours, differenceInMinutes, format } from "date-fns";
import { GitBranch, ArrowRight, ListTodo } from "lucide-react";
import { useThreads } from "@/providers/Thread";
import { useThreadsContext } from "@/providers/Thread";
import { cn } from "@/lib/utils";
import { StatusIndicator } from "@/components/status-indicator";
import { GitHubSVG } from "./icons/github";
@ -46,7 +46,7 @@ export const ThreadItem = memo(function ThreadItem({
className,
}: ThreadItemProps) {
const [threadId] = useQueryState("threadId");
const { recentlyUpdatedThreads } = useThreads();
const { recentlyUpdatedThreads } = useThreadsContext();
const isSelected = thread.thread_id === threadId;
const isSidebar = variant === "sidebar";
const isRecentlyUpdated = recentlyUpdatedThreads.has(thread.thread_id);

View file

@ -22,7 +22,7 @@ export const ContentBlocksPreview: React.FC<ContentBlocksPreviewProps> = ({
}) => {
if (!blocks.length) return null;
return (
<div className={cn("flex flex-wrap gap-2 p-3.5 pb-0", className)}>
<div className={cn("flex flex-wrap gap-2 pb-1", className)}>
{blocks.map((block, idx) => (
<MultimodalPreview
key={idx}

View file

@ -41,7 +41,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
{removable && (
<button
type="button"
className="absolute top-1 right-1 z-10 rounded-full bg-gray-500 text-white hover:bg-gray-700"
className="absolute top-1 right-1 z-10 cursor-pointer rounded-full p-1 text-white hover:bg-gray-800"
onClick={onRemove}
aria-label="Remove image"
>
@ -63,7 +63,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
return (
<div
className={cn(
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
"relative flex items-center justify-start gap-2 rounded-md border-[1px] border-gray-800 bg-inherit px-3 py-2",
className,
)}
>
@ -76,7 +76,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
/>
</div>
<span
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
className={cn("min-w-0 flex-1 text-sm break-all text-gray-300")}
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
>
{String(filename)}
@ -84,7 +84,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
{removable && (
<button
type="button"
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
className="cursor-pointer self-start rounded-full p-1 text-teal-700 hover:bg-gray-800"
onClick={onRemove}
aria-label="Remove PDF"
>
@ -99,7 +99,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500",
"flex items-center gap-2 rounded-md border-[1px] border-gray-800 bg-inherit px-3 py-2 text-gray-500",
className,
)}
>
@ -108,7 +108,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
{removable && (
<button
type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-gray-500 hover:bg-gray-300"
className="cursor-pointer self-start rounded-full p-1 text-gray-500 hover:bg-gray-800"
onClick={onRemove}
aria-label="Remove file"
>

View file

@ -7,12 +7,14 @@ import { toast } from "sonner";
import { useQueryState } from "nuqs";
import { constructOpenInStudioURL } from "../utils";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { useStream } from "@langchain/langgraph-sdk/react";
interface ThreadActionsViewProps {
interrupt: HumanInterrupt;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
showState: boolean;
showDescription: boolean;
stream: ReturnType<typeof useStream>;
}
function ButtonGroup({
@ -59,6 +61,7 @@ export function ThreadActionsView({
handleShowSidePanel,
showDescription,
showState,
stream,
}: ThreadActionsViewProps) {
const [threadId] = useQueryState("threadId");
const {
@ -80,6 +83,7 @@ export function ThreadActionsView({
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt,
stream,
});
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";

View file

@ -12,11 +12,12 @@ import { createDefaultHumanResponse } from "../utils";
import { toast } from "sonner";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
import { END } from "@langchain/langgraph/web";
import { useStreamContext } from "@/providers/Stream";
import { useThreads } from "@/providers/Thread";
import { useThreadsContext } from "@/providers/Thread";
import { useStream } from "@langchain/langgraph-sdk/react";
interface UseInterruptedActionsInput {
interrupt: HumanInterrupt;
stream: ReturnType<typeof useStream>;
}
interface UseInterruptedActionsValue {
@ -53,9 +54,9 @@ interface UseInterruptedActionsValue {
export default function useInterruptedActions({
interrupt,
stream: thread,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const { refreshThreads } = useThreads();
const { refreshThreads } = useThreadsContext();
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
[],
);

View file

@ -2,11 +2,13 @@ import { HumanResponse } from "@langchain/langgraph/prebuilt";
import { useEffect, useState } from "react";
import { PlanItem } from "@open-swe/shared/open-swe/types";
import { convertPlanItemsToInterruptString } from "@/lib/plan-utils";
import { useStreamContext } from "@/providers/Stream";
import { PLAN_INTERRUPT_ACTION_TITLE } from "@open-swe/shared/constants";
import { useStream } from "@langchain/langgraph-sdk/react";
export function useProposedPlan(originalPlanItems: PlanItem[]) {
const stream = useStreamContext();
export function useProposedPlan(
originalPlanItems: PlanItem[],
stream: ReturnType<typeof useStream>,
) {
const [planItems, setPlanItems] = useState<PlanItem[]>(originalPlanItems);
const [changesMade, setChangesMade] = useState(false);

View file

@ -2,17 +2,17 @@ import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
import { useState } from "react";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { useStreamContext } from "@/providers/Stream";
import { parsePlanData } from "@/lib/plan-utils";
import { ProposedPlan } from "@/components/plan/proposed-plan";
import { useStream } from "@langchain/langgraph-sdk/react";
interface ThreadViewProps {
interrupt: HumanInterrupt | HumanInterrupt[];
thread: ReturnType<typeof useStream>;
}
export function ThreadView({ interrupt }: ThreadViewProps) {
export function ThreadView({ interrupt, thread }: ThreadViewProps) {
const interruptObj = Array.isArray(interrupt) ? interrupt[0] : interrupt;
const thread = useStreamContext();
const [showDescription, setShowDescription] = useState(false);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
@ -39,7 +39,12 @@ export function ThreadView({ interrupt }: ThreadViewProps) {
const planItems = parsePlanData(interruptObj.action_request.args);
if (planItems?.length) {
return <ProposedPlan originalPlanItems={planItems} />;
return (
<ProposedPlan
originalPlanItems={planItems}
stream={thread}
/>
);
}
return (
@ -57,6 +62,7 @@ export function ThreadView({ interrupt }: ThreadViewProps) {
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
stream={thread}
/>
)}
</div>

View file

@ -1,189 +0,0 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const ArtifactSlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
context: [Record<string, unknown>, Setter<Record<string, unknown>>];
}>(null!);
/**
* Headless component that will obtain the title and content of the artifact
* and render them in place of the `ArtifactContent` and `ArtifactTitle` components via
* React Portals.
*/
const ArtifactSlot = (props: {
id: string;
children?: ReactNode;
title?: ReactNode;
}) => {
const context = useContext(ArtifactSlotContext);
const [ctxMounted, ctxSetMounted] = context.mounted;
const [content] = context.content;
const [title] = context.title;
const isMounted = ctxMounted === props.id;
const isEmpty = props.children == null && props.title == null;
useEffect(() => {
if (isEmpty) {
ctxSetMounted((open) => (open === props.id ? null : open));
}
}, [isEmpty, ctxSetMounted, props.id]);
if (!isMounted) return null;
return (
<>
{title != null ? createPortal(<>{props.title}</>, title) : null}
{content != null ? createPortal(<>{props.children}</>, content) : null}
</>
);
};
export function ArtifactContent(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactProvider(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
const context = useState<Record<string, unknown>>({});
return (
<ArtifactSlotContext.Provider
value={{ open, mounted, title, content, context }}
>
{props.children}
</ArtifactSlotContext.Provider>
);
}
/**
* Provides a value to be passed into `meta.artifact` field
* of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook
* on the generative UI side.
*/
export function useArtifact() {
const id = useId();
const context = useContext(ArtifactSlotContext);
const [ctxOpen, ctxSetOpen] = context.open;
const [ctxContext, ctxSetContext] = context.context;
const [, ctxSetMounted] = context.mounted;
const open = ctxOpen === id;
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
if (typeof value === "boolean") {
ctxSetOpen(value ? id : null);
} else {
ctxSetOpen((open) => (open === id ? null : id));
}
ctxSetMounted(id);
},
[ctxSetOpen, ctxSetMounted, id],
);
const ArtifactContent = useCallback(
(props: { title?: React.ReactNode; children: React.ReactNode }) => {
return (
<ArtifactSlot
id={id}
title={props.title}
>
{props.children}
</ArtifactSlot>
);
},
[id],
);
return [
ArtifactContent,
{ open, setOpen, context: ctxContext, setContext: ctxSetContext },
] as [
typeof ArtifactContent,
{
open: typeof open;
setOpen: typeof setOpen;
context: typeof ctxContext;
setContext: typeof ctxSetContext;
},
];
}
/**
* General hook for detecting if any artifact is open.
*/
export function useArtifactOpen() {
const context = useContext(ArtifactSlotContext);
const [ctxOpen, setCtxOpen] = context.open;
const open = ctxOpen !== null;
const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]);
return [open, onClose] as const;
}
/**
* Artifacts may at their discretion provide additional context
* that will be used when creating a new run.
*/
export function useArtifactContext() {
const context = useContext(ArtifactSlotContext);
return context.context;
}

View file

@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { useThreads } from "@/providers/Thread";
import { useThreadsContext } from "@/providers/Thread";
import { Thread } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
@ -82,7 +82,7 @@ export default function ThreadHistory() {
parseAsBoolean.withDefault(false),
);
const { refreshThreads, threads, threadsLoading } = useThreads();
const { refreshThreads, threads, threadsLoading } = useThreadsContext();
useEffect(() => {
if (typeof window === "undefined") return;

View file

@ -32,12 +32,6 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { useFileUpload } from "@/hooks/useFileUpload";
import { ContentBlocksPreview } from "./ContentBlocksPreview";
import {
useArtifactOpen,
ArtifactContent,
ArtifactTitle,
useArtifactContext,
} from "./artifact";
import { GitHubOAuthButton } from "../github/github-oauth-button";
import { useGitHubAppProvider } from "@/providers/GitHubApp";
import TaskList from "../task-list";
@ -101,8 +95,6 @@ function ScrollToBottom(props: { className?: string }) {
export function Thread() {
const { push } = useRouter();
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
const { selectedRepository } = useGitHubAppProvider();
const { getConfig } = useConfigStore();
const { taskPlan } = useTaskPlan();
@ -166,8 +158,6 @@ export function Thread() {
if (id === null) {
setTaskId(null);
closeArtifact();
setArtifactContext({});
}
};
@ -267,9 +257,6 @@ export function Thread() {
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
const newMessages = [
...toolMessages,
newHumanMessage,
@ -278,14 +265,12 @@ export function Thread() {
{
messages: newMessages,
internalMessages: newMessages,
context,
targetRepository: selectedRepository,
},
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
@ -362,12 +347,7 @@ export function Thread() {
</motion.div>
</div>
<div
className={cn(
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
artifactOpen && "grid-cols-[3fr_2fr]",
)}
>
<div className="grid w-full grid-cols-[1fr_0fr] transition-all duration-500">
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
@ -514,6 +494,7 @@ export function Thread() {
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
thread={stream}
/>
),
)}
@ -527,6 +508,7 @@ export function Thread() {
isLoading={isLoading}
handleRegenerate={handleRegenerate}
forceRenderInterrupt={true}
thread={stream}
/>
)}
{isLoading && !firstTokenReceived && (
@ -658,20 +640,6 @@ export function Thread() {
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
</div>
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
<ConfigurationSidebar

View file

@ -1,5 +1,4 @@
import { parsePartialJson } from "@langchain/core/output_parsers";
import { useStreamContext } from "@/providers/Stream";
import {
AIMessage,
Checkpoint,
@ -9,13 +8,15 @@ import {
import { getContentString } from "../utils";
import { BranchSwitcher, CommandBar } from "./shared";
import { MarkdownText } from "../markdown-text";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import {
LoadExternalComponent,
UIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import { cn } from "@/lib/utils";
import { ToolCalls, ToolResult } from "./tool-calls";
import { MessageContentComplex } from "@langchain/core/messages";
import { Fragment } from "react/jsx-runtime";
import { useQueryState, parseAsBoolean } from "nuqs";
import { useArtifact } from "../artifact";
import { Interrupt } from "./interrupt";
import {
ActionStep,
@ -28,6 +29,7 @@ import {
} from "@open-swe/shared/open-swe/tools";
import { z } from "zod";
import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
import { useStream } from "@langchain/langgraph-sdk/react";
// Used only for Zod type inference.
const dummyRepo = { owner: "dummy", repo: "dummy" };
@ -41,13 +43,15 @@ function CustomComponent({
thread,
}: {
message: Message;
thread: ReturnType<typeof useStreamContext>;
thread: ReturnType<typeof useStream>;
}) {
const artifact = useArtifact();
const { values } = useStreamContext();
const customComponents = values.ui?.filter(
(ui) => ui.metadata?.message_id === message.id,
);
const values = thread.values;
const customComponents =
"ui" in values
? (values.ui as UIMessage[]).filter(
(ui) => ui.metadata?.message_id === message.id,
)
: [];
if (!customComponents?.length) return null;
return (
@ -57,7 +61,7 @@ function CustomComponent({
key={customComponent.id}
stream={thread}
message={customComponent}
meta={{ ui: customComponent, artifact }}
meta={{ ui: customComponent }}
/>
))}
</Fragment>
@ -141,11 +145,13 @@ export function AssistantMessage({
isLoading,
handleRegenerate,
forceRenderInterrupt = false,
thread,
}: {
message: Message | undefined;
isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
forceRenderInterrupt?: boolean;
thread: ReturnType<typeof useStream<Record<string, unknown>>>;
}) {
const content = message?.content ?? [];
const contentString = getContentString(content);
@ -154,7 +160,6 @@ export function AssistantMessage({
parseAsBoolean.withDefault(false),
);
const thread = useStreamContext();
const messages = thread.messages;
const idx = message ? messages.findIndex((m) => m.id === message.id) : -1;
const nextMessage = idx >= 0 ? messages[idx + 1] : undefined;
@ -289,7 +294,7 @@ export function AssistantMessage({
}
return (
<div className="group mr-auto flex w-full max-w-3xl items-start gap-2">
<div className="group mr-auto flex w-full items-start gap-2">
<div className="flex w-full flex-col gap-2">
{isToolResult ? (
<span>
@ -299,6 +304,7 @@ export function AssistantMessage({
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
forceRenderInterrupt={forceRenderInterrupt}
thread={thread}
/>
</span>
) : (
@ -334,6 +340,7 @@ export function AssistantMessage({
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
forceRenderInterrupt={forceRenderInterrupt}
thread={thread}
/>
<div
className={cn(

View file

@ -1,12 +1,14 @@
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
import { ThreadView } from "../agent-inbox";
import { GenericInterruptView } from "./generic-interrupt";
import { useStream } from "@langchain/langgraph-sdk/react";
interface InterruptProps {
interruptValue?: unknown;
isLastMessage: boolean;
hasNoAIOrToolMessages: boolean;
forceRenderInterrupt?: boolean;
thread: ReturnType<typeof useStream>;
}
export function Interrupt({
@ -14,12 +16,16 @@ export function Interrupt({
isLastMessage,
hasNoAIOrToolMessages,
forceRenderInterrupt,
thread,
}: InterruptProps) {
return (
<>
{isAgentInboxInterruptSchema(interruptValue) &&
(isLastMessage || hasNoAIOrToolMessages || forceRenderInterrupt) && (
<ThreadView interrupt={interruptValue} />
<ThreadView
interrupt={interruptValue}
thread={thread}
/>
)}
{interruptValue &&
!isAgentInboxInterruptSchema(interruptValue) &&

View file

@ -15,36 +15,36 @@ export function ToolCalls({
if (!toolCalls || toolCalls.length === 0) return null;
return (
<div className="grid w-full max-w-3xl grid-rows-[1fr_auto] gap-2">
<div className="grid w-full grid-rows-[1fr_auto] gap-2">
{toolCalls.map((tc, idx) => {
const args = tc.args as Record<string, any>;
const hasArgs = Object.keys(args).length > 0;
return (
<div
key={idx}
className="overflow-hidden rounded-lg border border-gray-200"
className="overflow-hidden rounded-lg border border-gray-700"
>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<h3 className="font-medium text-gray-900">
<div className="border-b border-gray-700 bg-gray-800 px-4 py-2">
<h3 className="font-medium text-gray-100">
{tc.name}
{tc.id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
<code className="ml-2 rounded bg-gray-700 px-2 py-1 text-sm">
{tc.id}
</code>
)}
</h3>
</div>
{hasArgs ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
<table className="min-w-full divide-y divide-gray-700">
<tbody className="divide-y divide-gray-700">
{Object.entries(args).map(([key, value], argIdx) => (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-100">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
<td className="px-4 py-2 text-sm text-gray-300">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
<code className="rounded bg-gray-800 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
@ -94,29 +94,29 @@ export function ToolResult({ message }: { message: ToolMessage }) {
: contentStr;
return (
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<div className="mx-auto grid grid-rows-[1fr_auto] gap-2">
<div className="overflow-hidden rounded-lg border border-gray-700">
<div className="border-b border-gray-700 bg-gray-800 px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{message.name ? (
<h3 className="font-medium text-gray-900">
<h3 className="font-medium text-gray-100">
Tool Result:{" "}
<code className="rounded bg-gray-100 px-2 py-1">
<code className="rounded bg-gray-700 px-2 py-1">
{message.name}
</code>
</h3>
) : (
<h3 className="font-medium text-gray-900">Tool Result</h3>
<h3 className="font-medium text-gray-100">Tool Result</h3>
)}
{message.tool_call_id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
<code className="ml-2 rounded bg-gray-700 px-2 py-1 text-sm text-gray-100">
{message.tool_call_id}
</code>
)}
</div>
</div>
<motion.div
className="min-w-full bg-gray-100"
className="min-w-full bg-gray-700"
initial={false}
animate={{ height: "auto" }}
transition={{ duration: 0.3 }}
@ -134,8 +134,8 @@ export function ToolResult({ message }: { message: ToolMessage }) {
transition={{ duration: 0.2 }}
>
{isJsonContent ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
<table className="min-w-full divide-y divide-gray-700">
<tbody className="divide-y divide-gray-700">
{(Array.isArray(parsedContent)
? isExpanded
? parsedContent
@ -147,12 +147,12 @@ export function ToolResult({ message }: { message: ToolMessage }) {
: [item[0], item[1]];
return (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-100">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
<td className="px-4 py-2 text-sm text-gray-300">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
<code className="rounded bg-gray-800 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
@ -165,7 +165,9 @@ export function ToolResult({ message }: { message: ToolMessage }) {
</tbody>
</table>
) : (
<code className="block text-sm">{displayedContent}</code>
<code className="block text-sm text-gray-300">
{displayedContent}
</code>
)}
</motion.div>
</AnimatePresence>
@ -176,7 +178,7 @@ export function ToolResult({ message }: { message: ToolMessage }) {
parsedContent.length > 5)) && (
<motion.button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-700 py-2 text-gray-300 transition-all duration-200 ease-in-out hover:bg-gray-800 hover:text-gray-200"
initial={{ scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}

View file

@ -0,0 +1,54 @@
import { isHumanMessageSDK } from "@/lib/langchain-messages";
import { UseStream, useStream } from "@langchain/langgraph-sdk/react";
import { AssistantMessage } from "../thread/messages/ai";
import { useEffect } from "react";
interface ActionsRendererProps {
graphId: string;
threadId: string;
setProgrammerThreadId?: (threadId: string) => void;
programmerThreadId?: string;
}
export function ActionsRenderer<State extends Record<string, unknown>>({
graphId,
threadId,
setProgrammerThreadId,
programmerThreadId,
}: ActionsRendererProps) {
const stream = useStream<State>({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: graphId,
reconnectOnMount: true,
threadId,
});
const nonHumanMessages = stream.messages?.filter(
(m) => !isHumanMessageSDK(m),
);
// TODO: Need a better way to handle this. Not great like this...
useEffect(() => {
if (
stream.values?.programmerThreadId &&
typeof stream.values.programmerThreadId === "string" &&
!programmerThreadId
) {
setProgrammerThreadId?.(stream.values.programmerThreadId as string);
}
}, [stream.values]);
return (
<div className="flex w-full flex-col gap-2">
{nonHumanMessages?.map((m) => (
<AssistantMessage
key={m.id}
thread={stream as UseStream<Record<string, unknown>>}
message={m}
isLoading={false}
handleRegenerate={() => {}}
/>
))}
</div>
);
}

View file

@ -0,0 +1,310 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
CheckCircle,
XCircle,
Loader2,
GitBranch,
GitPullRequest,
Bug,
FilePlus2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { ThreadDisplayInfo } from "./types";
import { TerminalInput } from "./terminal-input";
import { useFileUpload } from "@/hooks/useFileUpload";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { Label } from "../ui/label";
import { ContentBlocksPreview } from "../thread/ContentBlocksPreview";
interface DefaultViewProps {
threads: ThreadDisplayInfo[];
}
export function DefaultView({ threads }: DefaultViewProps) {
const router = useRouter();
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
const assistantId: string | undefined =
process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID ?? "";
const {
contentBlocks,
setContentBlocks,
handleFileUpload,
dropRef,
removeBlock,
dragOver,
handlePaste,
} = useFileUpload();
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 <Loader2 className="h-4 w-4 animate-spin" />;
case "completed":
return <CheckCircle className="h-4 w-4" />;
case "failed":
return <XCircle className="h-4 w-4" />;
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";
}
};
if (!apiUrl || !assistantId) {
return <div>Missing API URL or Assistant ID</div>;
}
return (
<div className="flex flex-1 flex-col">
{/* Header */}
<div className="border-b border-gray-900 bg-black px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="font-mono text-sm text-gray-400">Open SWE</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">ready</span>
<div className="h-1 w-1 rounded-full bg-gray-600"></div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-4xl space-y-6 p-4">
{/* Terminal Chat Input */}
<Card
className={cn(
"border-gray-800 bg-gray-950 py-0",
dragOver
? "border-primary border-2 border-dotted"
: "border border-solid",
)}
ref={dropRef}
>
<CardContent className="p-4">
<ContentBlocksPreview
blocks={contentBlocks}
onRemove={removeBlock}
/>
<input
id="file-input"
type="file"
onChange={handleFileUpload}
multiple
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
className="hidden"
/>
<div className="space-y-3">
<TerminalInput
placeholder="Describe your coding task or ask a question..."
apiUrl={apiUrl}
assistantId={assistantId}
contentBlocks={contentBlocks}
setContentBlocks={setContentBlocks}
onPaste={handlePaste}
/>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center justify-center rounded-full bg-inherit text-gray-500 hover:text-gray-300"
>
<FilePlus2 className="size-4" />
</Label>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
{/* Recent & Running Threads */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-300">
Recent & Running Threads
</h2>
<Button
variant="outline"
size="sm"
className="h-7 border-gray-700 bg-gray-900 text-xs text-gray-400 hover:bg-gray-800 hover:text-gray-300"
onClick={() => router.push("/chat/threads")}
>
View All
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{threads.slice(0, 4).map((thread) => (
<Card
key={thread.id}
className="cursor-pointer border-gray-800 bg-gray-950 px-0 py-3 transition-shadow hover:bg-gray-900 hover:shadow-lg"
onClick={() => {
router.push(`/chat/${thread.id}`);
}}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium text-gray-300">
{thread.title}
</CardTitle>
<div className="mt-1 flex items-center gap-1">
<GitBranch className="h-2 w-2 text-gray-600" />
<span className="truncate text-xs text-gray-500">
{thread.repository}
</span>
</div>
</div>
<Badge
variant="secondary"
className={`${getStatusColor(thread.status)} text-xs`}
>
<div className="flex items-center gap-1">
{getStatusIcon(thread.status)}
<span className="capitalize">{thread.status}</span>
</div>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">
{thread.taskCount === 0
? "No tasks"
: `${thread.taskCount} tasks`}
</span>
<span className="text-xs text-gray-600">•</span>
<span className="text-xs text-gray-600">
{thread.lastActivity}
</span>
</div>
<div className="flex items-center gap-1">
{thread.githubIssue && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-gray-500 hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
window.open(thread.githubIssue!.url, "_blank");
}}
>
<Bug className="h-3 w-3" />
</Button>
)}
{thread.pullRequest && (
<Button
variant="ghost"
size="sm"
className={`h-5 w-5 p-0 hover:text-gray-300 ${getPRStatusColor(thread.pullRequest.status)}`}
onClick={(e) => {
e.stopPropagation();
window.open(thread.pullRequest!.url, "_blank");
}}
>
<GitPullRequest className="h-3 w-3" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Quick Actions */}
<div>
<h2 className="mb-3 text-base font-semibold text-gray-300">
Quick Actions
</h2>
<div className="grid gap-3 md:grid-cols-3">
<Card className="cursor-pointer border-gray-800 bg-gray-950 py-3 transition-shadow hover:bg-gray-900 hover:shadow-lg">
<CardHeader className="px-3">
<CardTitle className="text-sm text-gray-300">
Debug Code
</CardTitle>
<CardDescription className="text-xs text-gray-500">
Find and fix issues in your codebase
</CardDescription>
</CardHeader>
</Card>
<Card className="cursor-pointer border-gray-800 bg-gray-950 py-3 transition-shadow hover:bg-gray-900 hover:shadow-lg">
<CardHeader className="px-3">
<CardTitle className="text-sm text-gray-300">
Add Feature
</CardTitle>
<CardDescription className="text-xs text-gray-500">
Implement new functionality
</CardDescription>
</CardHeader>
</Card>
<Card className="cursor-pointer border-gray-800 bg-gray-950 py-3 transition-shadow hover:bg-gray-900 hover:shadow-lg">
<CardHeader className="px-3">
<CardTitle className="text-sm text-gray-300">
Refactor Code
</CardTitle>
<CardDescription className="text-xs text-gray-500">
Improve code structure and performance
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,147 @@
"use client";
import type React from "react";
import { v4 as uuidv4 } from "uuid";
import { Dispatch, SetStateAction, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Send } from "lucide-react";
import { RepositoryBranchSelectors } from "../github/repo-branch-selectors";
import { Button } from "../ui/button";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useRouter } from "next/navigation";
import { useGitHubAppProvider } from "@/providers/GitHubApp";
import { Message } from "@langchain/langgraph-sdk";
import { useFileUpload } from "@/hooks/useFileUpload";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
import { toast } from "sonner";
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
interface TerminalInputProps {
placeholder?: string;
disabled?: boolean;
apiUrl: string;
assistantId: string;
contentBlocks: Base64ContentBlock[];
setContentBlocks: Dispatch<SetStateAction<Base64ContentBlock[]>>;
onPaste?: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
}
export function TerminalInput({
placeholder = "Enter your command...",
disabled = false,
apiUrl,
assistantId,
contentBlocks,
setContentBlocks,
onPaste,
}: TerminalInputProps) {
const { push } = useRouter();
const [message, setMessage] = useState("");
const { getConfig } = useConfigStore();
const { selectedRepository } = useGitHubAppProvider();
const stream = useStream<GraphState>({
apiUrl,
assistantId,
reconnectOnMount: true,
threadId: null,
onThreadId: (id) => {
push(`/chat/${id}`);
},
});
const handleSend = () => {
if (!selectedRepository) {
toast.error("Please select a repository first", {
richColors: true,
closeButton: true,
});
return;
}
const trimmedMessage = message.trim();
if (trimmedMessage.length > 0 || contentBlocks.length > 0) {
const newHumanMessage = new HumanMessage({
id: uuidv4(),
content: [
...(trimmedMessage.length > 0
? [{ type: "text", text: trimmedMessage }]
: []),
...contentBlocks,
],
});
stream.submit(
{
messages: [newHumanMessage],
targetRepository: selectedRepository,
},
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
messages: [...(prev.messages ?? []), newHumanMessage],
}),
config: {
recursion_limit: 400,
configurable: {
...getConfig(DEFAULT_CONFIG_KEY),
},
},
},
);
setMessage("");
setContentBlocks([]);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
}
};
return (
<div className="rounded-md border border-gray-600 bg-black p-2 font-mono text-xs">
<div className="flex items-start gap-1 text-gray-300">
{/* User@Host */}
<span className="text-gray-400">open-swe</span>
<span className="text-gray-500">@</span>
<span className="text-gray-400">github</span>
<span className="text-gray-500">:</span>
{/* Repository & Branch Selectors */}
<RepositoryBranchSelectors />
{/* Prompt */}
<span className="text-gray-400">$</span>
</div>
{/* Multiline Input */}
<div className="mt-1 flex gap-2">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={disabled}
className="min-h-[40px] flex-1 resize-none border-none bg-transparent p-0 font-mono text-xs text-white placeholder:text-gray-600 focus-visible:ring-0 focus-visible:ring-offset-0"
rows={3}
onPaste={onPaste}
/>
<Button
onClick={handleSend}
disabled={disabled || !message.trim()}
size="sm"
className="h-7 w-7 self-end bg-gray-700 p-0 hover:bg-gray-600"
>
<Send className="h-3 w-3" />
</Button>
</div>
{/* Help text */}
<div className="mt-1 text-xs text-gray-600">Press Cmd+Enter to send</div>
</div>
);
}

View file

@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
CheckCircle,
XCircle,
Loader2,
GitBranch,
Layers3,
Plus,
Bug,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { getThreadTitle } from "@/lib/thread";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
import { ThreadDisplayInfo } from "./types";
interface ThreadSwitcherProps {
currentThread: ThreadDisplayInfo;
allThreads: ThreadDisplayInfo[];
onThreadSelect: (thread: ThreadDisplayInfo) => void;
onNewChat: () => void;
}
export function ThreadSwitcher({
currentThread,
allThreads,
onThreadSelect,
onNewChat,
}: ThreadSwitcherProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const getStatusIcon = (status: ThreadDisplayInfo["status"]) => {
switch (status) {
case "running":
return <Loader2 className="h-3 w-3 animate-spin text-blue-400" />;
case "completed":
return <CheckCircle className="h-3 w-3 text-green-400" />;
case "failed":
return <XCircle className="h-3 w-3 text-red-400" />;
default:
return <div className="h-3 w-3 rounded-full bg-gray-700" />;
}
};
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";
default:
return "bg-gray-800 text-gray-400";
}
};
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";
}
};
const otherThreads = allThreads.filter((t) => t.id !== currentThread.id);
const runningCount = otherThreads.filter(
(t) => t.status === "running",
).length;
return (
<Sheet
open={open}
onOpenChange={setOpen}
>
<SheetTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 border-gray-700 bg-gray-900 text-xs text-gray-400 hover:bg-gray-800 hover:text-gray-300"
>
<Layers3 className="h-3 w-3" />
<span className="hidden sm:inline">Switch Thread</span>
{runningCount > 0 && (
<Badge
variant="secondary"
className="h-4 bg-blue-950 px-1 text-xs text-blue-400"
>
{runningCount}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-80 border-gray-800 bg-gray-950 sm:w-96"
>
<SheetHeader className="pb-4">
<SheetTitle className="text-base text-gray-300">
All Threads
</SheetTitle>
</SheetHeader>
<div className="space-y-3">
{/* New Chat Button */}
<Button
onClick={() => {
router.push("/chat");
setOpen(false);
}}
className="h-8 w-full justify-start gap-2 border-gray-700 bg-gray-900 text-xs text-gray-300 hover:bg-gray-800"
variant="outline"
>
<Plus className="h-3 w-3" />
Start New Chat
</Button>
{/* Current Thread */}
<div className="space-y-2">
<h3 className="text-xs font-medium tracking-wide text-gray-500 uppercase">
Current Thread
</h3>
<div className="rounded-lg border-2 border-blue-800 bg-blue-950 p-3">
<div className="flex items-start gap-2">
{getStatusIcon(currentThread.status)}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-gray-300">
{currentThread.title}
</div>
<div className="mt-1 flex items-center gap-1">
<GitBranch className="h-2 w-2 text-gray-600" />
<span className="truncate text-xs text-gray-500">
{currentThread.repository}
</span>
</div>
<div className="mt-2 flex items-center justify-between">
<Badge
variant="secondary"
className={`${getStatusColor(currentThread.status)} text-xs`}
>
{currentThread.status}
</Badge>
<div className="flex items-center gap-1">
{currentThread.githubIssue && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-gray-500 hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
// TODO: Open issue in GitHub
alert("Open issue in github not implemented.");
}}
>
<Bug className="h-3 w-3" />
</Button>
)}
{/* TODO: Add PR info to state, then hook this up. */}
{/* {currentThread.pullRequest && (
<Button
variant="ghost"
size="sm"
className={`h-5 w-5 p-0 hover:text-gray-300 ${getPRStatusColor(currentThread.pullRequest.status)}`}
onClick={(e) => {
e.stopPropagation()
window.open(currentThread.pullRequest!.url, "_blank")
}}
>
<GitPullRequest className="h-3 w-3" />
</Button>
)} */}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Other Threads */}
{otherThreads.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-medium tracking-wide text-gray-500 uppercase">
Other Threads
</h3>
<ScrollArea className="h-96">
<div className="space-y-1">
{otherThreads.map((thread) => (
<Button
key={thread.id}
variant="ghost"
className="h-auto w-full justify-start p-3 text-left text-gray-400 hover:bg-gray-800"
onClick={() => {
router.push(`/chat/${thread.id}`);
setOpen(false);
}}
>
<div className="flex w-full items-start gap-2">
{getStatusIcon(thread.status)}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-gray-300">
{thread.title}
</div>
<div className="mt-1 flex items-center gap-1">
<GitBranch className="h-2 w-2 text-gray-600" />
<span className="truncate text-xs text-gray-500">
{thread.repository}
</span>
</div>
<div className="mt-1 flex items-center justify-between">
{thread.taskCount && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">
{thread.taskCount} tasks
</span>
<Badge
variant="secondary"
className={`${getStatusColor(thread.status)} text-xs`}
>
{thread.status}
</Badge>
</div>
)}
<div className="flex items-center gap-1">
{thread.githubIssue && (
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 text-gray-600 hover:text-gray-400"
onClick={(e) => {
e.stopPropagation();
// TODO: Open issue in GitHub
alert(
"Open issue in github not implemented.",
);
}}
>
<Bug className="h-2 w-2" />
</Button>
)}
{/* TODO: Add PR info to state, then hook this up. */}
{/* {thread.pullRequest && (
<Button
variant="ghost"
size="sm"
className={`h-4 w-4 p-0 hover:text-gray-400 ${getPRStatusColor(thread.pullRequest.status)}`}
onClick={(e) => {
e.stopPropagation()
window.open(thread.pullRequest!.url, "_blank")
}}
>
<GitPullRequest className="h-2 w-2" />
</Button>
)} */}
</div>
</div>
</div>
</div>
</Button>
))}
</div>
</ScrollArea>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, GitBranch, Send, User, Bot } from "lucide-react";
import { getMessageContentString } from "@open-swe/shared/messages";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ThreadSwitcher } from "./thread-switcher";
import { ThreadDisplayInfo } from "./types";
import { useStream } from "@langchain/langgraph-sdk/react";
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { ActionsRenderer } from "./actions-renderer";
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_ASSISTANT_ID;
const PLANNER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PLANNER_ASSISTANT_ID;
interface ThreadViewProps {
stream: ReturnType<typeof useStream<ManagerGraphState>>;
displayThread: ThreadDisplayInfo;
allDisplayThreads: ThreadDisplayInfo[];
onThreadSelect: (thread: ThreadDisplayInfo) => void;
onBackToHome: () => void;
}
export function ThreadView({
stream,
displayThread,
allDisplayThreads,
onThreadSelect,
onBackToHome,
}: ThreadViewProps) {
const [chatInput, setChatInput] = useState("");
const plannerThreadId = stream.values?.plannerThreadId;
const [programmerThreadId, setProgrammerThreadId] = useState("");
if (!stream.messages?.length) {
return null;
}
const handleSendMessage = () => {
if (chatInput.trim()) {
alert("SENDING MANAGER FOLLOWUPS NOT HOOKED UP YET");
setChatInput("");
}
};
return (
<div className="flex h-screen flex-1 flex-col bg-black">
{/* Header */}
<div className="absolute top-0 right-0 left-0 z-10 border-b border-gray-900 bg-black px-4 py-2">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-600 hover:bg-gray-900 hover:text-gray-400"
onClick={onBackToHome}
>
<ArrowLeft className="h-3 w-3" />
</Button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
displayThread.status === "running"
? "bg-blue-500"
: displayThread.status === "completed"
? "bg-green-500"
: "bg-red-500"
}`}
></div>
<span className="truncate font-mono text-sm text-gray-400">
{displayThread.title}
</span>
<span className="text-xs text-gray-600">•</span>
<GitBranch className="h-3 w-3 text-gray-600" />
<span className="truncate text-xs text-gray-600">
{displayThread.repository}
</span>
</div>
<ThreadSwitcher
currentThread={displayThread}
allThreads={allDisplayThreads}
onThreadSelect={onThreadSelect}
onNewChat={onBackToHome}
/>
</div>
</div>
{/* Main Content - Split Layout */}
<div className="flex h-full w-full pt-12">
{/* Left Side - Chat Interface */}
<div className="flex h-full w-1/3 flex-col border-r border-gray-900 bg-gray-950">
{/* Chat Messages */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{stream.messages.map((message) => (
<div
key={message.id}
className="flex gap-3"
>
<div className="flex-shrink-0">
{message.type === "human" ? (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-700">
<User className="h-3 w-3 text-gray-400" />
</div>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-900">
<Bot className="h-3 w-3 text-blue-400" />
</div>
)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-400">
{message.type === "human" ? "You" : "AI Agent"}
</span>
</div>
<div className="text-sm leading-relaxed text-gray-300">
{getMessageContentString(message.content)}
</div>
</div>
</div>
))}
</div>
{/* Chat Input - Fixed at bottom */}
<div className="border-t border-gray-800 bg-gray-950 p-4">
<div className="flex gap-2">
<Textarea
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Type your message..."
className="min-h-[60px] flex-1 resize-none border-gray-700 bg-gray-900 text-sm text-gray-300 placeholder:text-gray-600"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
onClick={handleSendMessage}
disabled={!chatInput.trim()}
size="sm"
className="h-10 w-10 self-end bg-gray-700 p-0 hover:bg-gray-600"
>
<Send className="h-4 w-4" />
</Button>
</div>
<div className="mt-2 text-xs text-gray-600">
Press Cmd+Enter to send
</div>
</div>
</div>
{/* Right Side - Actions & Plan */}
<div className="flex h-full flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<Tabs
defaultValue="planner"
className="w-full"
>
<TabsList>
<TabsTrigger value="planner">Planner</TabsTrigger>
<TabsTrigger value="programmer">Programmer</TabsTrigger>
</TabsList>
<TabsContent value="planner">
<Card className="border-gray-800 bg-gray-950 px-0 py-4">
<CardHeader>
<CardTitle className="text-base text-gray-300">
Planning Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 p-3 pt-0">
{plannerThreadId && PLANNER_ASSISTANT_ID && (
<ActionsRenderer<PlannerGraphState>
graphId={PLANNER_ASSISTANT_ID}
threadId={plannerThreadId}
setProgrammerThreadId={setProgrammerThreadId}
programmerThreadId={programmerThreadId}
/>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="programmer">
<Card className="border-gray-800 bg-gray-950 px-0 py-4">
<CardHeader>
<CardTitle className="text-base text-gray-300">
Code Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 p-3 pt-0">
{programmerThreadId && PROGRAMMER_ASSISTANT_ID && (
<ActionsRenderer<PlannerGraphState>
graphId={PROGRAMMER_ASSISTANT_ID}
threadId={programmerThreadId}
/>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { getThreadTitle } from "@/lib/thread";
import { Thread } from "@langchain/langgraph-sdk";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
export interface ThreadDisplayInfo {
id: string;
title: string;
status: "running" | "completed" | "failed" | "pending";
lastActivity: string;
taskCount: number;
repository: string;
branch: string;
githubIssue?: {
number: number;
url: string;
};
pullRequest?: {
number: number;
url: string;
status: "draft" | "open" | "merged" | "closed";
};
}
// Utility functions to convert between Thread and ThreadDisplayInfo
export function threadToDisplayInfo(
thread: Thread<ManagerGraphState>,
): ThreadDisplayInfo {
const values = thread.values;
const activePlanItems = values?.taskPlan
? getActivePlanItems(values.taskPlan)
: [];
const completedTasksLen = activePlanItems.filter((t) => t.completed).length;
// Determine UI status from thread status and task completion
let uiStatus: ThreadDisplayInfo["status"];
switch (thread.status) {
case "busy":
uiStatus = "running";
break;
case "idle":
uiStatus =
completedTasksLen === activePlanItems.length ? "completed" : "pending";
break;
case "error":
uiStatus = "failed";
break;
case "interrupted":
uiStatus = "pending";
break;
default:
uiStatus = "pending";
}
// Calculate time since last update
const lastUpdate = new Date(thread.updated_at);
const now = new Date();
const diffMs = now.getTime() - lastUpdate.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
let lastActivity: string;
if (diffMins < 1) {
lastActivity = "just now";
} else if (diffMins < 60) {
lastActivity = `${diffMins} min ago`;
} else if (diffHours < 24) {
lastActivity = `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
} else {
lastActivity = `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
}
return {
id: thread.thread_id,
title: getThreadTitle(thread),
status: uiStatus,
lastActivity,
taskCount: values?.taskPlan?.tasks.length ?? 0,
repository: `${values?.targetRepository.owner}/${values?.targetRepository.repo}`,
branch: values?.targetRepository.branch || "main",
githubIssue: values?.githubIssueId
? {
number: values?.githubIssueId,
url: `https://github.com/${values?.targetRepository.owner}/${values?.targetRepository.repo}/issues/${values?.githubIssueId}`,
}
: undefined,
};
}

View file

@ -0,0 +1,54 @@
import { createClient } from "@/providers/client";
import { Thread } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useState } from "react";
export function useThreads<State extends Record<string, any>>(
assistantId?: string,
) {
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
const [threads, setThreads] = useState<Thread<State>[] | null>(null);
const getThread = useCallback(
async (threadId: string): Promise<Thread<State> | null> => {
if (!apiUrl) return null;
const client = createClient(apiUrl);
try {
const thread = await client.threads.get<State>(threadId);
return thread;
} catch (error) {
console.error("Failed to fetch thread:", threadId, error);
return null;
}
},
[apiUrl],
);
const getThreads = useCallback(async (): Promise<Thread<State>[] | null> => {
if (!apiUrl) return null;
const client = createClient(apiUrl);
try {
const searchArgs = assistantId
? {
metadata: {
graph_id: assistantId,
},
}
: undefined;
const threads = await client.threads.search<State>(searchArgs);
return threads;
} catch (error) {
console.error("Failed to fetch threads:", error);
return null;
}
}, [apiUrl]);
useEffect(() => {
getThreads().then((threads) => {
setThreads(threads);
});
}, [getThreads]);
return { threads, setThreads, getThread, getThreads };
}

View file

@ -12,3 +12,7 @@ export function isAIMessageSDK(m: Message): m is AIMessage {
export function isToolMessageSDK(m: Message): m is ToolMessage {
return m.type === "tool";
}
export function isHumanMessageSDK(m: Message): m is HumanMessage {
return m.type === "human";
}

View file

@ -100,10 +100,8 @@ export class ThreadPoller {
getThreadTitle(current) !== getThreadTitle(updated) ||
currentTargetRepo.repo !== updatedTargetRepo.repo ||
currentTargetRepo.branch !== updatedTargetRepo.branch ||
JSON.stringify(current.values?.plan) !==
JSON.stringify(updated.values?.plan) ||
JSON.stringify(current.values?.proposedPlan) !==
JSON.stringify(updated.values?.proposedPlan)
JSON.stringify(current.values?.taskPlan) !==
JSON.stringify(updated.values?.taskPlan)
);
}
}

View file

@ -3,7 +3,9 @@ import { getMessageContentString } from "@open-swe/shared/messages";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
export function getThreadTitle(thread: Thread<GraphState>): string {
export function getThreadTitle<State extends Record<string, any> = GraphState>(
thread: Thread<State>,
): string {
const messages = thread?.values?.messages;
if (!messages?.length || !messages[0]?.content) {
return `Thread ${thread.thread_id.substring(0, 8)}`;
@ -12,17 +14,19 @@ export function getThreadTitle(thread: Thread<GraphState>): string {
return threadTitle;
}
export function getThreadTasks(thread: Thread<GraphState>): {
export function getThreadTasks<State extends Record<string, any> = GraphState>(
thread: Thread<State>,
): {
totalTasks: number;
completedTasks: number;
} {
if (!thread.values || !thread.values?.plan) {
if (!thread.values || !thread.values?.taskPlan) {
return {
totalTasks: 0,
completedTasks: 0,
};
}
const activePlanItems = getActivePlanItems(thread.values.plan);
const activePlanItems = getActivePlanItems(thread.values.taskPlan);
const totalTasks = activePlanItems.length;
const completedTasks = activePlanItems.filter((p) => p.completed).length;
return {

View file

@ -15,7 +15,7 @@ import {
} from "@langchain/langgraph-sdk/react-ui";
import { useQueryState } from "nuqs";
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
import { useThreads } from "./Thread";
import { useThreadsContext } from "./Thread";
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
import { Copy, CopyCheck, ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
@ -51,7 +51,7 @@ const StreamSession = ({
githubToken: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { refreshThreads, setThreads } = useThreads();
const { refreshThreads, setThreads } = useThreadsContext();
const streamValue = useTypedStream({
apiUrl,
assistantId,

View file

@ -165,10 +165,10 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
);
}
export function useThreads() {
export function useThreadsContext() {
const context = useContext(ThreadContext);
if (context === undefined) {
throw new Error("useThreads must be used within a ThreadProvider");
throw new Error("useThreadsContext must be used within a ThreadProvider");
}
return context;
}

View file

@ -1,7 +1,9 @@
{
"node_version": "20",
"graphs": {
"open_swe": "./apps/open-swe/src/index.ts:graph"
"programmer": "./apps/open-swe/src/graphs/programmer/index.ts:graph",
"planner": "./apps/open-swe/src/graphs/planner/index.ts:graph",
"manager": "./apps/open-swe/src/graphs/manager/index.ts:graph"
},
"env": "./apps/open-swe/.env",
"dependencies": ["./apps/open-swe"],

View file

@ -0,0 +1,40 @@
import { MessagesZodState } from "@langchain/langgraph";
import { TargetRepository, TaskPlan } from "../types.js";
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<TargetRepository>(),
/**
* The tasks generated for this request.
*/
taskPlan: z.custom<TaskPlan>(),
/**
* 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/<manager-thread-id>
*/
branchName: z.string(),
});
export type ManagerGraphState = z.infer<typeof ManagerGraphStateObj>;
export type ManagerGraphUpdate = Partial<ManagerGraphState>;

View file

@ -23,6 +23,10 @@ export const MODEL_OPTIONS = [
label: "Claude 3.5 Sonnet",
value: "anthropic:claude-3-5-sonnet-latest",
},
{
label: "Claude 3.5 Haiku",
value: "anthropic:claude-3-5-haiku-latest",
},
// {
// label: "o4",
// value: "openai:o4",
@ -43,10 +47,18 @@ export const MODEL_OPTIONS = [
label: "GPT 4o",
value: "openai:gpt-4o",
},
{
label: "GPT 4o mini",
value: "openai:gpt-4o-mini",
},
{
label: "GPT 4.1",
value: "openai:gpt-4.1",
},
{
label: "GPT 4.1 mini",
value: "openai:gpt-4.1-mini",
},
{
label: "Gemini 2.5 Pro Preview",
value: "google-genai:gemini-2.5-pro-preview-05-06",

View file

@ -0,0 +1,73 @@
import "@langchain/langgraph/zod";
import { z } from "zod";
import { MessagesZodState } from "@langchain/langgraph";
import { TargetRepository, TaskPlan } from "../types.js";
import { withLangGraph } from "@langchain/langgraph/zod";
export const PlannerGraphStateObj = MessagesZodState.extend({
sandboxSessionId: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
targetRepository: withLangGraph(z.custom<TargetRepository>(), {
reducer: {
schema: z.custom<TargetRepository>(),
fn: (_state, update) => update,
},
}),
githubIssueId: withLangGraph(z.custom<number>(), {
reducer: {
schema: z.custom<number>(),
fn: (_state, update) => update,
},
}),
codebaseTree: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
taskPlan: withLangGraph(z.custom<TaskPlan>(), {
reducer: {
schema: z.custom<TaskPlan>(),
fn: (_state, update) => update,
},
}),
proposedPlan: withLangGraph(z.custom<string[]>(), {
reducer: {
schema: z.custom<string[]>(),
fn: (_state, update) => update,
},
default: (): string[] => [],
}),
planContextSummary: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
default: () => "",
}),
branchName: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
planChangeRequest: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
programmerThreadId: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
});
export type PlannerGraphState = z.infer<typeof PlannerGraphStateObj>;
export type PlannerGraphUpdate = Partial<PlannerGraphState>;

View file

@ -138,46 +138,75 @@ export const GraphAnnotation = MessagesZodState.extend({
},
default: () => [],
}),
proposedPlan: z
.array(z.string())
.default(() => [])
.langgraph.reducer((_state, update) => update),
plan: z
.custom<TaskPlan>()
.langgraph.reducer<TaskPlan>((_state, update) => update),
planChangeRequest: z
.string()
.nullable()
.default(() => null)
.langgraph.reducer((_state, update) => update),
planContextSummary: z
.string()
.default(() => "")
.langgraph.reducer((_state, update) => update),
/**
* The task plan generated by the planning agent, including any
* user modifications/agent modifications.
*/
taskPlan: withLangGraph(z.custom<TaskPlan>(), {
reducer: {
schema: z.custom<TaskPlan>(),
fn: (_state, update) => update,
},
}),
/**
* The summary of actions taken by the planning agent.
*/
planContextSummary: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
default: () => "",
}),
/**
* The session ID of the Sandbox to use.
*/
sandboxSessionId: z
.string()
.optional()
.langgraph.reducer((_state, update) => update),
sandboxSessionId: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
default: () => "",
}),
/**
* The name of the branch changes in this thread will be pushed to
*/
branchName: z
.string()
.optional()
.langgraph.reducer((_state, update) => update),
branchName: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
default: () => "",
}),
/**
* The target repository information
*/
targetRepository: z
.custom<TargetRepository>()
.langgraph.reducer((_state, update) => update),
codebaseTree: z
.string()
.default(() => "")
.langgraph.reducer((_state, update) => update),
targetRepository: withLangGraph(z.custom<TargetRepository>(), {
reducer: {
schema: z.custom<TargetRepository>(),
fn: (_state, update) => update,
},
}),
/**
* The current tree of the codebase the agent is working with.
*/
codebaseTree: withLangGraph(z.custom<string>(), {
reducer: {
schema: z.custom<string>(),
fn: (_state, update) => update,
},
}),
/**
* The ID of the Github issue this thread is associated with
*/
githubIssueId: withLangGraph(z.custom<number>(), {
reducer: {
schema: z.custom<number>(),
fn: (_state, update) => update,
},
}),
// ---NOT USED---
ui: z
.custom<UIMessage[]>()
.default(() => [])
@ -298,6 +327,24 @@ export const GraphConfigurationMetadata: {
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
classificationModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-3-5-haiku-latest",
description: "The model to use for classifying the user's message",
options: MODEL_OPTIONS_NO_THINKING,
},
},
classificationTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
maxTokens: {
x_open_swe_ui_config: {
type: "number",
@ -429,6 +476,24 @@ export const GraphConfiguration = z.object({
.optional()
.langgraph.metadata(GraphConfigurationMetadata.summarizerTemperature),
/**
* The model ID to use for classifying the user's message.
* @default "anthropic:claude-3-5-haiku-latest"
*/
classificationModelName: z
.string()
.optional()
.langgraph.metadata(GraphConfigurationMetadata.classificationModelName),
/**
* The temperature to use for classifying the user's message.
* If selecting a reasoning model, this will be ignored.
* @default 0
*/
classificationTemperature: z
.number()
.optional()
.langgraph.metadata(GraphConfigurationMetadata.classificationTemperature),
/**
* The maximum number of tokens to generate in an individual generation.
* @default 10_000

View file

@ -2248,7 +2248,7 @@ __metadata:
"@langchain/google-genai": ^0.2.9
"@langchain/langgraph": ^0.3.3
"@langchain/langgraph-cli": latest
"@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": "*"