feat: Optionally create gh issue from requests (#781)

* feat: Optionally create gh issue from requests

* cr

* cr

* cr

* cr

* docs
This commit is contained in:
Brace Sproul 2025-08-19 16:53:47 -07:00 • committed by GitHub
parent c42638f9a3
commit f316685de3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 261 additions and 355 deletions

View file

@ -16,6 +16,15 @@ description: Frequently Asked Questions
Yes. When using Anthropic models, all input tokens are cached on Anthropic's servers.
</Accordion>
<Accordion title="Can I disable Open SWE from creating an issue when I submit a request?">
Yes. There's two ways to disable Open SWE from creating an issue when you submit a request:
1. Toggle the 'eye' icon in the main chat area when submitting a request.
2. In the configuration tab in settings, toggle the 'Should Create Issue' switch.
By default, it's set to `true`. By modifying this setting in the configuration tab, all runs will default to that setting. You may override this setting on a per-run basis by toggling the 'eye' icon in the main chat area.
</Accordion>
<Accordion title="My run failed midway through. What now?">
We're sorry you're experiencing this! Open SWE will automatically commit any changes it makes to a draft pull request. Please check the draft pull request and make any necessary changes.

View file

@ -45,6 +45,7 @@ import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { Client } from "@langchain/langgraph-sdk";
import { shouldCreateIssue } from "../../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "ClassifyMessage");
/**
@ -186,6 +187,39 @@ export async function classifyMessage(
);
}
if (!shouldCreateIssue(config)) {
const commandUpdate: ManagerGraphUpdate = {
messages: [response],
};
if (
toolCallArgs.route === "start_planner" ||
toolCallArgs.route === "start_planner_for_followup"
) {
return new Command({
update: commandUpdate,
goto: "start-planner",
});
}
if (toolCallArgs.route === "create_new_issue") {
return new Command({
update: commandUpdate,
goto: "create-new-session",
});
}
if (toolCallArgs.route === "no_op") {
return new Command({
update: commandUpdate,
goto: END,
});
}
throw new Error(
`Unsupported route received: ${toolCallArgs.route}\nUnable to route message there when not creating GitHub issues for request.`,
);
}
const { githubAccessToken } = getGitHubTokensFromConfig(config);
let githubIssueId = state.githubIssueId;

View file

@ -31,6 +31,7 @@ import { StreamMode } from "@langchain/langgraph-sdk";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { regenerateInstallationToken } from "../../../utils/github/regenerate-token.js";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "CreateNewSession");
@ -48,16 +49,21 @@ export async function createNewSession(
state.messages,
config.configurable,
);
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");
let newIssueNumber: number | undefined;
if (shouldCreateIssue(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");
}
newIssueNumber = newIssue.number;
}
const inputMessages: BaseMessage[] = [
@ -71,7 +77,7 @@ ${ISSUE_CONTENT_OPEN_TAG}
${titleAndContent.body}
${ISSUE_CONTENT_CLOSE_TAG}`,
additional_kwargs: {
githubIssueId: newIssue.number,
githubIssueId: newIssueNumber,
isOriginalIssue: true,
},
}),
@ -102,7 +108,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`,
const newManagerThreadId = uuidv4();
const commandUpdate: ManagerGraphUpdate = {
githubIssueId: newIssue.number,
githubIssueId: newIssueNumber,
targetRepository: state.targetRepository,
messages: inputMessages,
branchName: state.branchName ?? getBranchName(config),

View file

@ -22,6 +22,7 @@ import { getCustomConfigurableFields } from "../../../utils/config.js";
import { getRecentUserRequest } from "../../../utils/user-request.js";
import { StreamMode } from "@langchain/langgraph-sdk";
import { regenerateInstallationToken } from "../../../utils/github/regenerate-token.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "StartPlanner");
@ -68,6 +69,9 @@ export async function startPlanner(
branchName: state.branchName ?? getBranchName(config),
autoAcceptPlan: state.autoAcceptPlan,
...(followupMessage || localMode ? { messages: [followupMessage] } : {}),
...(!shouldCreateIssue(config) && followupMessage
? { internalMessages: [followupMessage] }
: {}),
};
const run = await langGraphClient.runs.create(

View file

@ -18,6 +18,7 @@ import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { trackCachePerformance } from "../../../utils/caching.js";
import { getModelManager } from "../../../utils/llms/model-manager.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "DetermineNeedsContext");
@ -118,7 +119,7 @@ export async function determineNeedsContext(
config: GraphConfig,
): Promise<Command> {
const [missingMessages, model] = await Promise.all([
getMissingMessages(state, config),
shouldCreateIssue(config) ? getMissingMessages(state, config) : [],
loadModel(config, LLMTask.ROUTER),
]);
const modelManager = getModelManager();

View file

@ -40,6 +40,7 @@ import {
trackCachePerformance,
} from "../../../../utils/caching.js";
import { createViewTool } from "../../../../tools/builtin-tools/view.js";
import { shouldCreateIssue } from "../../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode");
@ -124,10 +125,14 @@ export async function generateAction(
: {}),
});
const [missingMessages, { taskPlan: latestTaskPlan }] = await Promise.all([
getMissingMessages(state, config),
getPlansFromIssue(state, config),
]);
const [missingMessages, { taskPlan: latestTaskPlan }] = shouldCreateIssue(
config,
)
? await Promise.all([
getMissingMessages(state, config),
getPlansFromIssue(state, config),
])
: [[], { taskPlan: null }];
const inputMessages = filterMessagesWithoutContent([
...state.messages,

View file

@ -21,24 +21,27 @@ import {
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
export async function prepareGraphState(
state: PlannerGraphState,
config: GraphConfig,
): Promise<Command> {
if (isLocalMode(config)) {
// In local mode, just proceed to initialize-sandbox with existing messages
if (isLocalMode(config) || !shouldCreateIssue(config)) {
return new Command({
update: {},
goto: "initialize-sandbox",
});
}
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,
@ -57,8 +60,6 @@ export async function prepareGraphState(
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 = {

View file

@ -49,6 +49,7 @@ import {
cleanTaskItems,
} from "../../../utils/github/plan.js";
import { regenerateInstallationToken } from "../../../utils/github/regenerate-token.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "ProposedPlan");
@ -149,7 +150,7 @@ async function startProgrammerRun(input: {
);
// Skip GitHub operations in local mode
if (!isLocalMode(config)) {
if (!isLocalMode(config) && shouldCreateIssue(config)) {
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,

View file

@ -1,288 +0,0 @@
// 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 { traceable } from "langsmith/traceable";
import {
PlannerGraphState,
PlannerGraphUpdate,
} from "@open-swe/shared/open-swe/planner/types";
import {
getInitialUserRequest,
getRecentUserRequest,
} from "../../../utils/user-request.js";
import {
loadModel,
supportsParallelToolCallsParam,
} from "../../../utils/llms/index.js";
import { LLMTask } from "@open-swe/shared/open-swe/llm-task";
import { FallbackRunnable } from "../../../utils/runtime-fallback.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.
Here is the user's initial request:
{USER_INITIAL_REQUEST}
After generating that plan, the user has submitted some feedback/change requests. You should now identify exactly which tasks in the plan should be modified based on their request.
Here is their request:
{USER_REQUEST}
The plan you generated originally, which they submitted the above request for is as follows:
{PLAN}
Please read over the generated plan, and the user's request, and identify exactly which tasks in the plan should be modified/removed. Call the 'identify_plan_changes' tool and use the indices of the tasks listed above when calling the tool.`;
const systemPrompt = `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.
In this step, the user has requested you rewrite/modify parts of a high-level plan. You have already identified the specific tasks in the plan that should be modified/removed based on the user's request.
Here is the user's initial request which you used to generate the initial plan:
{USER_INITIAL_REQUEST}
Here is the full plan you generated:
{PLAN}
Here is the request the user has just made which you should use to rewrite/modify the plan:
{USER_REQUEST}
And here are the specific tasks in the plan which were identified as tasks the user wants to modify/remove:
{TASKS_TO_MODIFY}
Given this context, please address the user's request to modify/remove/add the tasks in the plan.
You MUST adhere to the following criteria when generating the plan:
- Make as few changes as possible to the tasks, while still following the users request.
- You should NOT make ANY changes to the tasks in the plan that are NOT listed as tasks to modify/remove.
- Do NOT modify tasks in the plan not listed as tasks to modify/remove.
- When responding, ensure you include the unmodified tasks in the plan, as well as the modified/new tasks.
- To remove a specific task, simply do NOT include it in the response.
- To add a new task, simply include it in the response.
`;
const formatSysPromptIdentifyTasks = (
userInitialRequest: string,
userRequest: string,
previousPlan: string[],
) => {
return systemPromptIdentifyChanges
.replace("{USER_INITIAL_REQUEST}", userInitialRequest)
.replace("{USER_REQUEST}", userRequest)
.replace(
"{PLAN}",
previousPlan.map((plan, index) => `${index}: ${plan}`).join("\n"),
);
};
const formatSysPromptRewritePlan = (
userInitialRequest: string,
userRequest: string,
previousPlan: string[],
tasksToModify: PlanItem[],
) => {
return systemPrompt
.replace("{USER_INITIAL_REQUEST}", userInitialRequest)
.replace("{USER_REQUEST}", userRequest)
.replace(
"{PLAN}",
previousPlan.map((plan, index) => `${index}: ${plan}`).join("\n"),
)
.replace(
"{TASKS_TO_MODIFY}",
tasksToModify.map((p) => `${p.index}: ${p.plan}`).join("\n"),
);
};
async function identifyTasksToModifyFunc(
state: PlannerGraphState,
model: FallbackRunnable,
supportsParallelToolCallsParam: boolean,
): Promise<PlanItem[]> {
if (!state.planChangeRequest) {
throw new Error("No plan change request found.");
}
const identifyPlanChangesSchema = z.object({
task_change_indices: z
.array(z.number())
.describe(
"The indices of the tasks in the plan that should be modified/removed.",
),
});
const identifyPlanChangesTool = tool(
(input): PlanItem[] => {
const { task_change_indices } = input;
const tasksToModify = state.proposedPlan.flatMap((plan, planIndex) => {
const planItem = task_change_indices.some(
(changeIndex) => changeIndex === planIndex,
);
if (!planItem) {
return [];
}
return {
index: planIndex,
plan: plan,
completed: false,
};
});
return tasksToModify;
},
{
name: "identify_plan_changes",
schema: identifyPlanChangesSchema,
description:
"Identify which tasks in the plan should be modified/removed based on the user's request.",
},
);
const modelWithIdentifyChangesTool = model.bindTools(
[identifyPlanChangesTool],
{
// The model should always call the tool when identifying plan changes.
tool_choice: identifyPlanChangesTool.name,
...(supportsParallelToolCallsParam
? {
parallel_tool_calls: false,
}
: {}),
},
);
const userInitialRequest = getInitialUserRequest(state.messages);
const userFollowupRequest = getRecentUserRequest(state.messages);
const userRequest =
userFollowupRequest ?? userInitialRequest ?? "No user message found";
const response = await modelWithIdentifyChangesTool.invoke([
{
role: "user",
content: formatSysPromptIdentifyTasks(
userRequest,
state.planChangeRequest,
state.proposedPlan,
),
},
]);
const toolCall = response.tool_calls?.[0];
if (!toolCall) {
throw new Error(
"Tool call not returned when attempting to identify plan changes.",
);
}
const tasksToModify = await identifyPlanChangesTool.invoke(
toolCall.args as z.infer<typeof identifyPlanChangesSchema>,
);
return tasksToModify;
}
const identifyTasksToModify = traceable(identifyTasksToModifyFunc, {
name: "identify_tasks_to_modify",
});
async function updatePlanTasksFunc(
state: PlannerGraphState,
tasksToModify: PlanItem[],
model: FallbackRunnable,
supportsParallelToolCallsParam: boolean,
): Promise<string[]> {
if (!state.planChangeRequest) {
throw new Error("No plan change request found.");
}
const updatePlanTasksSchema = z.object({
updated_plan_tasks: z
.array(
z
.string()
.describe(
"The updated or unmodified plan for the task. Do NOT include the task index.",
),
)
.describe(
"The updated plan tasks. Must be in the order of which they should be executed in.",
),
});
const updatePlanTasksTool = {
name: "update_plan_tasks",
description: "Call this tool to respond with the updated plan.",
schema: updatePlanTasksSchema,
};
const modelWithUpdatePlanTasksTool = model.bindTools([updatePlanTasksTool], {
// The model should always call the tool when identifying plan changes.
tool_choice: updatePlanTasksTool.name,
...(supportsParallelToolCallsParam
? {
parallel_tool_calls: false,
}
: {}),
});
const userInitialRequest = getInitialUserRequest(state.messages);
const userFollowupRequest = getRecentUserRequest(state.messages);
const userRequest =
userFollowupRequest ?? userInitialRequest ?? "No user message found";
const response = await modelWithUpdatePlanTasksTool.invoke([
{
role: "user",
content: formatSysPromptRewritePlan(
userRequest,
state.planChangeRequest,
state.proposedPlan,
tasksToModify,
),
},
]);
const toolCall = response.tool_calls?.[0];
if (!toolCall) {
throw new Error(
"Tool call not returned when attempting to update plan tasks.",
);
}
return (
toolCall.args as z.infer<typeof updatePlanTasksSchema>
).updated_plan_tasks.map((p) => p);
}
const updatePlanTasks = traceable(updatePlanTasksFunc, {
name: "update_plan_tasks",
});
export async function rewritePlan(
state: PlannerGraphState,
config: GraphConfig,
): Promise<PlannerGraphUpdate> {
if (!state.planChangeRequest) {
throw new Error("No plan change request found.");
}
const model = await loadModel(config, LLMTask.PLANNER);
const modelSupportsParallelToolCallsParam = supportsParallelToolCallsParam(
config,
LLMTask.PLANNER,
);
const tasksToModify = await identifyTasksToModify(
state,
model,
modelSupportsParallelToolCallsParam,
);
const updatedPlanTasks = await updatePlanTasks(
state,
tasksToModify,
model,
modelSupportsParallelToolCallsParam,
);
return {
proposedPlan: updatedPlanTasks,
};
}

View file

@ -59,6 +59,7 @@ import {
HumanMessage,
} from "@langchain/core/messages";
import { BindToolsInput } from "@langchain/core/language_models/chat_models";
import { shouldCreateIssue } from "../../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "GenerateMessageNode");
@ -280,10 +281,14 @@ export async function generateAction(
const markTaskCompletedTool = createMarkTaskCompletedToolFields();
const isAnthropicModel = modelName.includes("claude-");
const [missingMessages, { taskPlan: latestTaskPlan }] = await Promise.all([
getMissingMessages(state, config),
getPlansFromIssue(state, config),
]);
const [missingMessages, { taskPlan: latestTaskPlan }] = shouldCreateIssue(
config,
)
? await Promise.all([
getMissingMessages(state, config),
getPlansFromIssue(state, config),
])
: [[], { taskPlan: null }];
const { providerTools, providerMessages } = await createToolsAndPrompt(
state,

View file

@ -25,6 +25,7 @@ import {
MAX_INTERNAL_TOKENS,
} from "../../../utils/tokens.js";
import { z } from "zod";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "HandleCompletedTask");
@ -88,7 +89,7 @@ export async function handleCompletedTask(
summary,
);
// Update the github issue to reflect this task as completed.
if (!isLocalMode(config)) {
if (!isLocalMode(config) && shouldCreateIssue(config)) {
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,

View file

@ -45,6 +45,7 @@ import {
} from "../../../utils/github/types.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { GITHUB_USER_LOGIN_HEADER } from "@open-swe/shared/constants";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "Open PR");
@ -191,6 +192,9 @@ export async function openPullRequest(
| GitHubPullRequestList[number]
| GitHubPullRequestUpdate
| null = null;
const prBody = `${shouldCreateIssue(config) ? `Fixes #${state.githubIssueId}` : ""}${userLogin ? `\n\nOwner: @${userLogin}` : ""}\n\n${body}`;
if (!prForTask) {
// No PR created yet. Shouldn't be possible, but we have a condition here anyway
pullRequest = await createPullRequest({
@ -198,7 +202,7 @@ export async function openPullRequest(
repo,
headBranch: branchName,
title,
body: `Fixes #${state.githubIssueId}${userLogin ? `\n\nOwner: @${userLogin}` : ""}\n\n${body}`,
body: prBody,
githubInstallationToken,
baseBranch: state.targetRepository.branch,
});
@ -208,7 +212,7 @@ export async function openPullRequest(
owner,
repo,
title,
body: `Fixes #${state.githubIssueId}${userLogin ? `\n\nOwner: @${userLogin}` : ""}\n\n${body}`,
body: prBody,
pullNumber: prForTask,
githubInstallationToken,
});

View file

@ -21,6 +21,8 @@ import {
REQUEST_HELP_NODE_ID,
} from "@open-swe/shared/open-swe/custom-node-events";
import { postGitHubIssueComment } from "../../../utils/github/plan.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const constructDescription = (helpRequest: string): string => {
return `The agent has requested help. Here is the help request:
@ -60,12 +62,13 @@ export async function requestHelp(
throw new Error("Thread ID not found in config");
}
const userLogin = config.configurable?.[GITHUB_USER_LOGIN_HEADER];
const userTag = userLogin ? `@${userLogin} ` : "";
if (!isLocalMode(config) && shouldCreateIssue(config)) {
const userLogin = config.configurable?.[GITHUB_USER_LOGIN_HEADER];
const userTag = userLogin ? `@${userLogin} ` : "";
const runUrl = getOpenSweAppUrl(threadId);
const runUrl = getOpenSweAppUrl(threadId);
const commentBody = runUrl
? `### 🤖 Open SWE Needs Help
const commentBody = runUrl
? `### 🤖 Open SWE Needs Help
${userTag}I've encountered a situation where I need human assistance to continue.
@ -75,7 +78,7 @@ ${toolCall.args.help_request}
You can view and respond to this request in the [Open SWE interface](${runUrl}).
Please provide guidance so I can continue working on this issue.`
: `### 🤖 Open SWE Needs Help
: `### 🤖 Open SWE Needs Help
${userTag}I've encountered a situation where I need human assistance to continue.
@ -84,12 +87,13 @@ ${toolCall.args.help_request}
Please check the Open SWE interface to respond to this request.`;
await postGitHubIssueComment({
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
commentBody,
config,
});
await postGitHubIssueComment({
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
commentBody,
config,
});
}
const interruptInput: HumanInterrupt = {
action_request: {

View file

@ -30,6 +30,8 @@ import { formatCustomRulesPrompt } from "../../../utils/custom-rules.js";
import { trackCachePerformance } from "../../../utils/caching.js";
import { getModelManager } from "../../../utils/llms/model-manager.js";
import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "UpdatePlanNode");
@ -217,15 +219,17 @@ export async function updatePlan(
newPlanItems,
"agent",
);
// Update the github issue to reflect the changes in the plan
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
newTaskPlan,
);
if (!isLocalMode(config) && shouldCreateIssue(config)) {
// Update the github issue to reflect the changes in the plan
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
newTaskPlan,
);
}
const toolMessage = new ToolMessage({
id: uuidv4(),

View file

@ -35,6 +35,7 @@ import {
import { trackCachePerformance } from "../../../utils/caching.js";
import { getModelManager } from "../../../utils/llms/model-manager.js";
import { createScratchpadTool } from "../../../tools/scratchpad.js";
import { shouldCreateIssue } from "../../../utils/should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "FinalReview");
@ -192,7 +193,7 @@ export async function finalReview(
"agent",
);
if (!isLocalMode(config)) {
if (!isLocalMode(config) && shouldCreateIssue(config)) {
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,

View file

@ -21,6 +21,7 @@ import { DEFAULT_EXCLUDED_PATTERNS } from "./constants.js";
import { escapeRegExp } from "../string-utils.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { createShellExecutor } from "../shell-executor/index.js";
import { shouldCreateIssue } from "../should-create-issue.js";
const logger = createLogger(LogLevel.INFO, "GitHub-Git");
@ -336,33 +337,39 @@ export async function checkoutBranchAndCommit(
let updatedTaskPlan: TaskPlan | undefined;
const activeTask = getActiveTask(options.taskPlan);
const prForTask = getPullRequestNumberFromActiveTask(options.taskPlan);
if (!prForTask) {
logger.info("First commit detected, creating a draft pull request.");
const hasIssue = shouldCreateIssue(config);
const pullRequest = await createPullRequest({
owner: targetRepository.owner,
repo: targetRepository.repo,
headBranch: branchName,
title: `[WIP]: ${activeTask?.title ?? "Open SWE task"}`,
body: `**WORK IN PROGRESS OPEN SWE PR**\n\nFixes: #${options.githubIssueId}`,
body: `**WORK IN PROGRESS OPEN SWE PR**${hasIssue ? `\n\nFixes: #${options.githubIssueId}` : ""}`,
githubInstallationToken: options.githubInstallationToken,
draft: true,
baseBranch: targetRepository.branch,
nullOnError: true,
});
if (pullRequest) {
updatedTaskPlan = addPullRequestNumberToActiveTask(
options.taskPlan,
pullRequest.number,
);
await addTaskPlanToIssue(
{
githubIssueId: options.githubIssueId,
targetRepository,
},
config,
updatedTaskPlan,
);
logger.info(`Draft pull request created: #${pullRequest.number}`);
if (hasIssue) {
await addTaskPlanToIssue(
{
githubIssueId: options.githubIssueId,
targetRepository,
},
config,
updatedTaskPlan,
);
logger.info(`Draft pull request created: #${pullRequest.number}`);
}
}
}

View file

@ -0,0 +1,5 @@
import { GraphConfig } from "@open-swe/shared/open-swe/types";
export function shouldCreateIssue(config: GraphConfig): boolean {
return !!config.configurable?.shouldCreateIssue;
}

View file

@ -7,6 +7,7 @@ import { getMessageContentString } from "@open-swe/shared/messages";
import { extractContentWithoutDetailsFromIssueBody } from "./github/issue-messages.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { shouldCreateIssue } from "./should-create-issue.js";
// TODO: Might want a better way of doing this.
// maybe add a new kwarg `isRequest` and have this return the last human message with that field?
@ -55,7 +56,10 @@ export function getRecentUserRequest(
): string | HumanMessage {
let recentUserMessage: HumanMessage | undefined;
if (options?.config && isLocalMode(options.config)) {
if (
options?.config &&
(isLocalMode(options.config) || !shouldCreateIssue(options.config))
) {
// In local mode, get the last human message regardless of flags
recentUserMessage = messages.findLast(isHumanMessage);
} else {

View file

@ -0,0 +1,22 @@
interface GitHubIssueSVGProps {
width?: string;
height?: string;
className?: string;
}
export const GitHubIssueSVG = ({
width = "100%",
height = "100%",
className,
}: GitHubIssueSVGProps) => (
<svg
viewBox="0 0 16 16"
width={width}
height={height}
fill="currentColor"
className={className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"></path>
</svg>
);

View file

@ -1,7 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Archive, ListChecks } from "lucide-react";
import { Archive, Eye, EyeOff, List, ListChecks } from "lucide-react";
import { useRouter } from "next/navigation";
import { TerminalInput } from "./terminal-input";
import { useFileUpload } from "@/hooks/useFileUpload";
@ -32,6 +32,7 @@ import { threadsToMetadata } from "@/lib/thread-utils";
import { Settings, BookOpen } from "lucide-react";
import NextLink from "next/link";
import { OpenSWELogoSVG } from "../icons/openswe";
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
function OpenSettingsButton() {
return (
@ -86,6 +87,8 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
const [draftToLoad, setDraftToLoad] = useState("");
const assistantId: string | undefined = MANAGER_GRAPH_ID;
const { getConfig } = useConfigStore();
const config = getConfig(DEFAULT_CONFIG_KEY);
const {
contentBlocks,
setContentBlocks,
@ -95,6 +98,9 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
handlePaste,
} = useFileUpload();
const [autoAccept, setAutoAccept] = useState(false);
const [shouldCreateIssue, setShouldCreateIssue] = useState(
config?.shouldCreateIssue != null ? !!config.shouldCreateIssue : true,
);
const threadsMetadata = useMemo(() => threadsToMetadata(threads), [threads]);
const displayThreads = threadsMetadata.slice(0, 4);
@ -172,11 +178,13 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
draftToLoad={draftToLoad}
autoAcceptPlan={autoAccept}
setAutoAcceptPlan={setAutoAccept}
shouldCreateIssue={shouldCreateIssue}
setShouldCreateIssue={setShouldCreateIssue}
/>
<div className="flex items-center gap-2">
<TooltipIconButton
variant={autoAccept ? "default" : "ghost"}
tooltip="Automatically accept the plan"
tooltip="Whether or not to automatically accept the plan"
className={cn(
"transition-colors duration-200",
autoAccept
@ -186,7 +194,29 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
onClick={() => setAutoAccept((prev) => !prev)}
side="bottom"
>
<ListChecks className="size-4" />
{autoAccept ? (
<ListChecks className="size-4" />
) : (
<List className="size-4" />
)}
</TooltipIconButton>
<TooltipIconButton
variant={shouldCreateIssue ? "ghost" : "default"}
tooltip="Whether or not to create a GitHub issue for the request"
className={cn(
"transition-colors duration-200",
shouldCreateIssue
? "text-muted-foreground hover:text-foreground"
: "bg-primary hover:bg-primary/90",
)}
onClick={() => setShouldCreateIssue((prev) => !prev)}
side="bottom"
>
{shouldCreateIssue ? (
<Eye className="size-4" />
) : (
<EyeOff className="size-4" />
)}
</TooltipIconButton>
</div>
</div>

View file

@ -77,6 +77,7 @@ interface ManagerChatProps {
name: string | null;
email: string | null;
};
disableSubmit?: boolean;
}
function extractResponseFromMessage(message: Message): string {
@ -162,6 +163,7 @@ export function ManagerChat({
cancelRun,
errorState,
githubUser,
disableSubmit,
}: ManagerChatProps) {
return (
<div className="border-border bg-muted/30 flex h-full w-1/3 flex-col overflow-hidden border-r">
@ -252,7 +254,13 @@ export function ManagerChat({
placeholder="Type your message..."
className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-[60px] flex-1 resize-none text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !isLoading) {
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
!isLoading &&
!disableSubmit &&
!!chatInput.trim()
) {
e.preventDefault();
handleSendMessage();
}
@ -270,7 +278,7 @@ export function ManagerChat({
) : (
<Button
onClick={handleSendMessage}
disabled={!chatInput.trim()}
disabled={!chatInput.trim() || disableSubmit}
size="icon"
variant="brand"
className="size-8 rounded-full border border-white/20 transition-all duration-200 hover:border-white/30 disabled:border-transparent"

View file

@ -38,6 +38,8 @@ interface TerminalInputProps {
setQuickActionPrompt?: Dispatch<SetStateAction<string>>;
autoAcceptPlan: boolean;
setAutoAcceptPlan: Dispatch<SetStateAction<boolean>>;
shouldCreateIssue: boolean;
setShouldCreateIssue: Dispatch<SetStateAction<boolean>>;
draftToLoad?: string;
}
@ -71,6 +73,8 @@ export function TerminalInput({
setQuickActionPrompt,
autoAcceptPlan,
setAutoAcceptPlan,
shouldCreateIssue,
setShouldCreateIssue,
draftToLoad,
}: TerminalInputProps) {
const { push } = useRouter();
@ -164,6 +168,7 @@ export function TerminalInput({
recursion_limit: 400,
configurable: {
...defaultConfig,
shouldCreateIssue,
[GITHUB_USER_LOGIN_HEADER]: user.login,
},
},
@ -199,6 +204,11 @@ export function TerminalInput({
setMessage("");
setContentBlocks([]);
setAutoAcceptPlan(false);
setShouldCreateIssue(
defaultConfig?.shouldCreateIssue != null
? !!defaultConfig.shouldCreateIssue
: true,
);
} catch (e) {
if (
typeof e === "object" &&

View file

@ -114,6 +114,14 @@ export function ThreadView({
});
const [errorState, setErrorState] = useState<ErrorState | null>(null);
const [hasGitHubIssue, setHasGitHubIssue] = useState(false);
useEffect(() => {
stream.client.threads.get(displayThread.id).then((thread) => {
const configurable = (thread as Record<string, any>).config?.configurable;
setHasGitHubIssue(!!configurable?.shouldCreateIssue);
});
}, [displayThread.id]);
// Load optimistic message from sessionStorage
useEffect(() => {
@ -348,6 +356,10 @@ export function ThreadView({
]
: filteredMessages;
const shouldDisableManagerInput = !hasGitHubIssue
? stream.isLoading || plannerStream.isLoading || programmerStream.isLoading
: false;
return (
<div className="bg-background flex h-screen flex-1 flex-col">
{/* Header */}
@ -397,6 +409,7 @@ export function ThreadView({
cancelRun={cancelRun}
errorState={errorState}
githubUser={user || undefined}
disableSubmit={shouldDisableManagerInput}
/>
{/* Right Side - Actions & Plan */}
<div

View file

@ -449,6 +449,14 @@ export const GraphConfigurationMetadata: {
"JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).",
},
},
shouldCreateIssue: {
x_open_swe_ui_config: {
type: "boolean",
default: true,
description:
"Whether or not to create GitHub issues for all requests. Can be overridden on a per-request basis via the 'eye' icon in the chat input area.",
},
},
apiKeys: {
x_open_swe_ui_config: {
type: "hidden",
@ -614,6 +622,13 @@ export const GraphConfiguration = z.object({
maxTokens: withLangGraph(z.number().optional(), {
metadata: GraphConfigurationMetadata.maxTokens,
}),
/**
* Whether or not to create an issue for this request.
* @default true
*/
shouldCreateIssue: withLangGraph(z.boolean().optional(), {
metadata: GraphConfigurationMetadata.shouldCreateIssue,
}),
/**
* User defined API keys to use
*/