feat: add planner and manager changes (#602)

* openswe cli: add planner and manager changes

* openswe cli: fix programmer config

* openswe cli: formatting

* openswe cli: add config to reviewer

* Potential fix for code scanning alert no. 17: Incomplete string escaping or encoding

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* openswe cli: update code security

* feat: use commander

* fix: debug

* fix: comment

* fix: remove config

* fix: updates

* fix: local mode

* fix: repeated islocalmodes

* fix: update imports

* fix: revert wrap script and commands

* fix: adjust types

* fix: update function signatures

* fix: final shell exec fix

* fix: update tsconfig for test

* fix: add run input type

* fix: update types

* fix: reent user message

* fixes from code review

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: bracesproul <braceasproul@gmail.com>
This commit is contained in:
Palash Shah 2025-08-01 15:50:40 -04:00 • committed by GitHub
parent 0b23ca97c4
commit 46b435a543
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1709 additions and 377 deletions

View file

@ -19,6 +19,7 @@
"dependencies": {
"@langchain/langgraph-sdk": "^0.0.95",
"@open-swe/shared": "*",
"commander": "^12.0.0",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"ink": "^6.0.1",

View file

@ -0,0 +1 @@
export const OPEN_SWE_CLI_VERSION = "0.0.0";

View file

@ -1,6 +1,7 @@
#!/usr/bin/env node
import React, { useState, useEffect, useCallback } from "react";
import { render, Box, Text, useInput } from "ink";
import { Command } from "commander";
import {
startAuthServer,
getAccessToken,
@ -12,15 +13,31 @@ import {
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { Client } from "@langchain/langgraph-sdk";
import { Client, StreamMode } from "@langchain/langgraph-sdk";
import { submitFeedback } from "./utils.js";
import { StreamingService } from "./streaming.js";
type StreamMode = "values" | "updates" | "messages";
import { OPEN_SWE_CLI_VERSION } from "./constants.js";
const GITHUB_LOGIN_URL =
process.env.GITHUB_LOGIN_URL || "http://localhost:3000/api/auth/github/login";
// Set up Commander.js
const program = new Command();
program
.name("open-swe")
.description("Open SWE CLI")
.version(OPEN_SWE_CLI_VERSION)
.option(
"--local",
"Work directly on local codebase without GitHub authentication",
)
.helpOption("-h, --help", "Display help for command")
.parse();
const options = program.opts();
const isLocalMode = options.local;
startAuthServer();
const LoadingSpinner: React.FC<{ text: string }> = ({ text }) => {
@ -168,7 +185,7 @@ const App: React.FC = () => {
const [authInput, setAuthInput] = useState("");
const [exit, setExit] = useState(false);
const [authStarted, setAuthStarted] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(isLocalMode);
const [repos, setRepos] = useState<any[]>([]);
const [selectedRepo, setSelectedRepo] = useState<any | null>(null);
const [selectingRepo, setSelectingRepo] = useState(false);
@ -235,6 +252,18 @@ const App: React.FC = () => {
// On mount, check for existing token
useEffect(() => {
if (isLocalMode) {
setIsLoggedIn(true);
setLoadingRepos(false); // Ensure no loading state in local mode
// Set up local mode defaults
setSelectedRepo({
full_name: process.env.OPEN_SWE_LOCAL_PROJECT_PATH || "",
clone_url: process.env.OPEN_SWE_LOCAL_PROJECT_PATH || "",
default_branch: "main",
});
setInstallChecked(true);
return;
}
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
@ -243,6 +272,7 @@ const App: React.FC = () => {
// After login, fetch and store user repos
useEffect(() => {
if (isLocalMode) return;
if (isLoggedIn && repos.length === 0 && !loadingRepos) {
const token = getAccessToken();
if (token) {
@ -263,6 +293,7 @@ const App: React.FC = () => {
// Poll for installation_id after opening install page
useEffect(() => {
if (isLocalMode) return;
let interval: ReturnType<typeof setInterval>;
if (waitingForInstall) {
interval = setInterval(() => {
@ -324,6 +355,20 @@ const App: React.FC = () => {
}
}, [authPrompt, authStarted]);
// Poll for token after auth flow starts
useEffect(() => {
if (isLocalMode || !pollingForToken || isLoggedIn) return;
const interval = setInterval(() => {
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
setPollingForToken(false);
}
}, 1000);
return () => clearInterval(interval);
}, [pollingForToken, isLoggedIn]);
// Poll for token after auth flow starts
useEffect(() => {
if (pollingForToken && !isLoggedIn) {
@ -338,47 +383,52 @@ const App: React.FC = () => {
}
}, [pollingForToken, isLoggedIn]);
// Custom input for planner feedback (must be inside App)
const PlanFeedbackSelect: React.FC = () => {
const [highlighted, setHighlighted] = useState(0);
const [isMessage, setIsMessage] = useState(false);
const PlannerFeedbackInput: React.FC = () => {
const [selectedOption, setSelectedOption] = useState<
"approve" | "deny" | null
>(null);
const options = [
{ value: "approve", label: "Approve" },
{ value: "deny", label: "Deny" },
];
useInput((input: string, key: { [key: string]: any }) => {
useInput((inputChar: string, key: { [key: string]: any }) => {
if (streamingPhase !== "awaitingFeedback") return;
if (isMessage) return;
if (key.return) {
setIsMessage(true);
setPlannerFeedback(options[highlighted].value);
if (key.return && selectedOption) {
setPlannerFeedback(selectedOption);
setSelectedOption(null);
} else if (key.leftArrow) {
setHighlighted((h) => (h - 1 + options.length) % options.length);
setSelectedOption("approve");
} else if (key.rightArrow) {
setHighlighted((h) => (h + 1) % options.length);
setSelectedOption("deny");
}
});
if (streamingPhase !== "awaitingFeedback") return null;
if (streamingPhase !== "awaitingFeedback") {
return null;
}
return (
<Box flexDirection="row" alignItems="center" gap={2}>
<Text>Plan feedback:</Text>
{options.map((option, idx) => (
<Text
key={option.value}
dimColor={idx !== highlighted}
bold={idx === highlighted}
>
{idx === highlighted ? "[" : " "}
{option.label}
{idx === highlighted ? "]" : " "}
<Text>Plan feedback: </Text>
<Box
borderStyle="round"
borderColor={selectedOption === "approve" ? "green" : "white"}
paddingX={2}
paddingY={0}
>
<Text color={selectedOption === "approve" ? "green" : "white"}>
{selectedOption === "approve" ? "▶ " : " "}Approve
</Text>
))}
<Text dimColor>Use ←/→ to navigate, Enter to select</Text>
</Box>
<Box
borderStyle="round"
borderColor={selectedOption === "deny" ? "red" : "white"}
paddingX={2}
paddingY={0}
>
<Text color={selectedOption === "deny" ? "red" : "white"}>
{selectedOption === "deny" ? "▶ " : " "}Deny
</Text>
</Box>
<Text dimColor>(Use ←/→ to select, Enter to confirm)</Text>
</Box>
);
};
@ -390,9 +440,6 @@ const App: React.FC = () => {
plannerFeedback &&
plannerThreadId
) {
// Immediately switch to streaming mode to hide the feedback prompt
setStreamingPhase("streaming");
(async () => {
await submitFeedback({
plannerFeedback,
@ -400,6 +447,7 @@ const App: React.FC = () => {
selectedRepo,
setLogs,
setPlannerFeedback: () => setPlannerFeedback(null),
setStreamingPhase,
});
})();
}
@ -415,7 +463,12 @@ const App: React.FC = () => {
}
// Repo selection UI
if (isLoggedIn && repos.length > 0 && (selectingRepo || !selectedRepo)) {
if (
isLoggedIn &&
repos.length > 0 &&
(selectingRepo || !selectedRepo) &&
!isLocalMode
) {
return (
<Box flexDirection="column" padding={1}>
<Box justifyContent="center" marginBottom={1}>
@ -494,6 +547,13 @@ const App: React.FC = () => {
// Main UI: logs area + input prompt
if (isLoggedIn && selectedRepo) {
const modeIndicator = isLocalMode ? (
<Box paddingX={2} paddingY={1}>
<Text dimColor>
🏠 Local Mode - Working on {process.env.OPEN_SWE_LOCAL_PROJECT_PATH}
</Text>
</Box>
) : null;
// Calculate available space for logs based on whether welcome message is shown
const headerHeight = 0; // Welcome message is now above input bar, not at top
const inputHeight = 4; // Fixed input area height (increased due to padding)
@ -514,6 +574,7 @@ const App: React.FC = () => {
return (
<Box flexDirection="column" height={process.stdout.rows}>
{modeIndicator}
{/* Auto-scrolling logs area - strict boundary container */}
<Box
height={availableLogHeight}
@ -581,7 +642,9 @@ const App: React.FC = () => {
justifyContent="center"
>
<Box>
{!hasStartedChat ? (
{streamingPhase === "awaitingFeedback" ? (
<PlannerFeedbackInput />
) : !hasStartedChat ? (
<CustomInput
onSubmit={(value) => {
setHasStartedChat(true);
@ -608,19 +671,12 @@ const App: React.FC = () => {
)}
</Box>
</Box>
{/* Plan feedback below the input bar */}
{streamingPhase === "awaitingFeedback" && (
<Box flexDirection="column" paddingX={2} marginTop={1}>
<PlanFeedbackSelect />
</Box>
)}
</Box>
);
}
// Auth prompt UI
if (!isLoggedIn && authPrompt === null) {
if (!isLoggedIn && authPrompt === null && !isLocalMode) {
return (
<Box flexDirection="column" padding={1}>
<Box

View file

@ -8,6 +8,7 @@ import {
GITHUB_INSTALLATION_NAME,
GITHUB_INSTALLATION_ID,
OPEN_SWE_STREAM_MODE,
LOCAL_MODE_HEADER,
} from "@open-swe/shared/constants";
import {
getAccessToken,
@ -16,9 +17,13 @@ import {
} from "./auth-server.js";
import { formatDisplayLog } from "./logger.js";
import { isAgentInboxInterruptSchema } from "@open-swe/shared/agent-inbox-interrupt";
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
import { HumanMessage } from "@langchain/core/messages";
const LANGGRAPH_URL = process.env.LANGGRAPH_URL || "http://localhost:2024";
type RunInput = ManagerGraphUpdate;
interface StreamingCallbacks {
setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars
setPlannerThreadId: (id: string) => void; // eslint-disable-line no-unused-vars
@ -105,6 +110,7 @@ export class StreamingService {
interruptArr && interruptArr[0] && interruptArr[0].value
? interruptArr[0].value
: undefined;
if (isAgentInboxInterruptSchema(firstInterruptValue)) {
return { needsFeedback: true };
}
@ -165,49 +171,81 @@ export class StreamingService {
this.callbacks.setLoadingLogs(true);
try {
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
const isLocalMode = process.env.OPEN_SWE_LOCAL_MODE === "true";
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
this.callbacks.setLogs(() => [
`Missing secrets: ${userAccessToken ? "" : "userAccessToken, "}${installationAccessToken ? "" : "installationAccessToken, "}${encryptionKey ? "" : "encryptionKey"}`,
]);
return;
}
let headers: Record<string, string> = {};
let runInput: RunInput;
const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner, repoName] = selectedRepo.full_name.split("/");
const runInput = {
messages: [
{
id: uuidv4(),
type: "human",
content: [{ type: "text", text: prompt }],
if (isLocalMode) {
// Local mode: no GitHub authentication required
runInput = {
messages: [
new HumanMessage({
id: uuidv4(),
content: prompt,
}),
],
targetRepository: {
owner: "local",
repo: "local",
branch: "main",
},
],
targetRepository: {
owner,
repo: repoName,
branch: selectedRepo.default_branch || "main",
},
autoAcceptPlan: false,
};
autoAcceptPlan: false,
};
const installationId = getInstallationId();
const newClient = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
headers = {
[LOCAL_MODE_HEADER]: "true",
};
} else {
// Normal mode: require GitHub authentication
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
this.callbacks.setLogs(() => [
`Missing secrets: ${userAccessToken ? "" : "userAccessToken, "}${installationAccessToken ? "" : "installationAccessToken, "}${encryptionKey ? "" : "encryptionKey"}`,
]);
return;
}
const encryptedUserToken = encryptSecret(
userAccessToken,
encryptionKey,
);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner, repoName] = selectedRepo.full_name.split("/");
runInput = {
messages: [
new HumanMessage({
id: uuidv4(),
content: prompt,
}),
],
targetRepository: {
owner,
repo: repoName,
branch: selectedRepo.default_branch || "main",
},
autoAcceptPlan: false,
};
const installationId = getInstallationId();
headers = {
[GITHUB_TOKEN_COOKIE]: encryptedUserToken,
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptedInstallationToken,
[GITHUB_INSTALLATION_NAME]: owner,
[GITHUB_INSTALLATION_ID]: installationId,
},
};
}
const newClient = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: headers,
});
this.callbacks.setClient(newClient);

View file

@ -7,6 +7,7 @@ import { encryptSecret } from "@open-swe/shared/crypto";
import {
OPEN_SWE_STREAM_MODE,
PLANNER_GRAPH_ID,
LOCAL_MODE_HEADER,
} from "@open-swe/shared/constants";
import {
getAccessToken,
@ -25,44 +26,64 @@ export async function submitFeedback({
selectedRepo,
setLogs,
setPlannerFeedback,
setStreamingPhase,
}: {
plannerFeedback: string;
plannerThreadId: string;
selectedRepo: any;
setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
setLogs: (updater: (prev: string[]) => string[]) => void;
setPlannerFeedback: () => void;
// eslint-disable-next-line no-unused-vars
setStreamingPhase: (phase: "streaming" | "awaitingFeedback" | "done") => void;
}) {
try {
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
// Set streaming phase back to streaming when feedback submission starts
setStreamingPhase("streaming");
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
setLogs((prev) => [
...prev,
"Missing access tokens for feedback submission",
]);
return;
const isLocalMode = process.env.OPEN_SWE_LOCAL_MODE === "true";
let client: Client;
if (isLocalMode) {
// In local mode, create client without GitHub authentication
client = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
[LOCAL_MODE_HEADER]: "true", // Signal to server this is local mode
},
});
} else {
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
setLogs((prev) => [
...prev,
"Missing access tokens for feedback submission",
]);
return;
}
const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner] = selectedRepo?.full_name.split("/") || [];
const installationId = getInstallationId();
client = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
"x-github-access-token": encryptedUserToken,
"x-github-installation-token": encryptedInstallationToken,
"x-github-installation-name": owner,
"x-github-installation-id": installationId,
},
});
}
const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner] = selectedRepo?.full_name.split("/") || [];
const installationId = getInstallationId();
const client = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
"x-github-access-token": encryptedUserToken,
"x-github-installation-token": encryptedInstallationToken,
"x-github-installation-name": owner,
"x-github-installation-id": installationId,
},
});
const formatted = formatDisplayLog(`Human feedback: ${plannerFeedback}`);
if (formatted.length > 0) {
setLogs((prev) => [...prev, ...formatted]);
@ -110,10 +131,15 @@ export async function submitFeedback({
}
}
}
// Set streaming phase to done when complete
setStreamingPhase("done");
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
setLogs((prev) => [...prev, `Error submitting feedback: ${errorMessage}`]);
// Set streaming phase to done even on error
setStreamingPhase("done");
} finally {
// Clear feedback state
setPlannerFeedback();

View file

@ -1,4 +1,4 @@
import { GraphConfig, GraphState } from "@open-swe/shared/open-swe/types";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import {
ManagerGraphState,
ManagerGraphUpdate,
@ -38,11 +38,13 @@ import {
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { createLogger, LogLevel } from "../../../../utils/logger.js";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { createClassificationPromptAndToolSchema } from "./utils.js";
import { RequestSource } from "../../../../constants.js";
import { StreamMode } from "@langchain/langgraph-sdk";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { Thread } from "@langchain/langgraph-sdk";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { GraphState } from "@open-swe/shared/open-swe/types";
const logger = createLogger(LogLevel.INFO, "ClassifyMessage");
/**
@ -60,21 +62,23 @@ export async function classifyMessage(
throw new Error("No human message found.");
}
let plannerThread: Thread<PlannerGraphState> | undefined;
let programmerThread: Thread<GraphState> | undefined;
const langGraphClient = createLangGraphClient({
defaultHeaders: getDefaultHeaders(config),
});
const plannerThread = state.plannerSession?.threadId
? await langGraphClient.threads.get<PlannerGraphState>(
state.plannerSession.threadId,
)
: undefined;
const plannerThreadValues = plannerThread?.values;
const programmerThread = plannerThreadValues?.programmerSession?.threadId
? await langGraphClient.threads.get<GraphState>(
plannerThreadValues.programmerSession.threadId,
)
: undefined;
if (!isLocalMode(config)) {
plannerThread = state.plannerSession?.threadId
? await langGraphClient.threads.get(state.plannerSession.threadId)
: undefined;
const plannerThreadValues = plannerThread?.values;
programmerThread = plannerThreadValues?.programmerSession?.threadId
? await langGraphClient.threads.get(
plannerThreadValues.programmerSession.threadId,
)
: undefined;
}
const programmerStatus = programmerThread?.status ?? "not_started";
const plannerStatus = plannerThread?.status ?? "not_started";
@ -157,6 +161,28 @@ export async function classifyMessage(
});
}
if (isLocalMode(config)) {
// In local mode, just route to planner without GitHub issue creation
const newMessages: BaseMessage[] = [response];
const commandUpdate: ManagerGraphUpdate = {
messages: newMessages,
};
if (
toolCallArgs.route === "start_planner" ||
toolCallArgs.route === "start_planner_for_followup"
) {
return new Command({
update: commandUpdate,
goto: "start-planner",
});
}
throw new Error(
`Unsupported route for local mode received: ${toolCallArgs.route}`,
);
}
const { githubAccessToken } = getGitHubTokensFromConfig(config);
let githubIssueId = state.githubIssueId;
@ -260,6 +286,9 @@ export async function classifyMessage(
args: "resume planner",
};
logger.info("Resuming planner session");
if (!langGraphClient) {
throw new Error("LangGraph client not initialized");
}
const newPlannerRun = await langGraphClient.runs.create(
state.plannerSession?.threadId,
PLANNER_GRAPH_ID,

View file

@ -9,6 +9,7 @@ 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";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
/**
* The initialize function will do nothing if there's already a human message
@ -18,6 +19,11 @@ export async function initializeGithubIssue(
state: ManagerGraphState,
config: GraphConfig,
): Promise<ManagerGraphUpdate> {
if (isLocalMode(config)) {
// In local mode, we don't need GitHub issues
// The human message should already be in the state from the CLI input
return {};
}
const { githubInstallationToken } = getGitHubTokensFromConfig(config);
let taskPlan = state.taskPlan;

View file

@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import {
ManagerGraphState,
ManagerGraphUpdate,
@ -8,6 +9,7 @@ import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import {
OPEN_SWE_STREAM_MODE,
PLANNER_GRAPH_ID,
LOCAL_MODE_HEADER,
} from "@open-swe/shared/constants";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { getBranchName } from "../../../utils/github/git.js";
@ -22,20 +24,29 @@ const logger = createLogger(LogLevel.INFO, "StartPlanner");
/**
* Start planner node.
* This node will kickoff a new planner session using the LangGraph SDK.
* In local mode, creates a planner session with local mode headers.
*/
export async function startPlanner(
state: ManagerGraphState,
config: GraphConfig,
): Promise<ManagerGraphUpdate> {
const langGraphClient = createLangGraphClient({
defaultHeaders: getDefaultHeaders(config),
});
const plannerThreadId = state.plannerSession?.threadId ?? uuidv4();
const followupMessage = getRecentUserRequest(state.messages, {
returnFullMessage: true,
config,
});
const localMode = isLocalMode(config);
try {
const langGraphClient = createLangGraphClient({
defaultHeaders: localMode
? {
[LOCAL_MODE_HEADER]: "true",
}
: getDefaultHeaders(config),
});
const runInput: PlannerGraphUpdate = {
// github issue ID & target repo so the planning agent can fetch the user's request, and clone the repo.
githubIssueId: state.githubIssueId,
@ -44,8 +55,9 @@ export async function startPlanner(
taskPlan: state.taskPlan,
branchName: state.branchName ?? getBranchName(config),
autoAcceptPlan: state.autoAcceptPlan,
...(followupMessage && { messages: [followupMessage] }),
...(followupMessage || localMode ? { messages: [followupMessage] } : {}),
};
const run = await langGraphClient.runs.create(
plannerThreadId,
PLANNER_GRAPH_ID,

View file

@ -22,6 +22,10 @@ import {
} from "../../utils/followup.js";
import { SYSTEM_PROMPT } from "./prompt.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { getMissingMessages } from "../../../../utils/github/issue-messages.js";
import { getPlansFromIssue } from "../../../../utils/github/issue-task.js";
import { createGrepTool } from "../../../../tools/grep.js";
@ -39,7 +43,10 @@ import { createViewTool } from "../../../../tools/builtin-tools/view.js";
const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode");
function formatSystemPrompt(state: PlannerGraphState): string {
function formatSystemPrompt(
state: PlannerGraphState,
config: GraphConfig,
): string {
// It's a followup if there's more than one human message.
const isFollowup = isFollowupRequest(state.taskPlan, state.proposedPlan);
const scratchpad = getScratchpad(state.messages)
@ -57,7 +64,15 @@ function formatSystemPrompt(state: PlannerGraphState): string {
)
.replaceAll(
"{CURRENT_WORKING_DIRECTORY}",
getRepoAbsolutePath(state.targetRepository),
isLocalMode(config)
? getLocalWorkingDirectory()
: getRepoAbsolutePath(state.targetRepository),
)
.replaceAll(
"{LOCAL_MODE_NOTE}",
isLocalMode(config)
? "<local_mode_note>IMPORTANT: You are running in local mode. When specifying file paths, use relative paths from the current working directory or absolute paths that start with the current working directory. Do NOT use sandbox paths like '/home/daytona/project/'.</local_mode_note>"
: "",
)
.replaceAll(
"{CODEBASE_TREE}",
@ -81,9 +96,9 @@ export async function generateAction(
const mcpTools = await getMcpTools(config);
const tools = [
createGrepTool(state),
createShellTool(state),
createViewTool(state),
createGrepTool(state, config),
createShellTool(state, config),
createViewTool(state, config),
createScratchpadTool(
"when generating a final plan, after all context gathering is complete",
),
@ -129,10 +144,13 @@ export async function generateAction(
.invoke([
{
role: "system",
content: formatSystemPrompt({
...state,
taskPlan: latestTaskPlan ?? state.taskPlan,
}),
content: formatSystemPrompt(
{
...state,
taskPlan: latestTaskPlan ?? state.taskPlan,
},
config,
),
},
...inputMessagesWithCache,
]);

View file

@ -79,6 +79,7 @@ Your sole objective in this phase is to gather comprehensive context about the c
<workspace_information>
<current_working_directory>{CURRENT_WORKING_DIRECTORY}</current_working_directory>
<repository_status>Already cloned and accessible in the current directory</repository_status>
{LOCAL_MODE_NOTE}
<codebase_tree>
Generated via: \`git ls-files | tree --fromfile -L 3\`:

View file

@ -25,6 +25,7 @@ import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import { filterMessagesWithoutContent } from "../../../../utils/message/content.js";
import { getModelManager } from "../../../../utils/llms/model-manager.js";
import { trackCachePerformance } from "../../../../utils/caching.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
function formatSystemPrompt(state: PlannerGraphState): string {
// It's a followup if there's more than one human message.
@ -107,7 +108,7 @@ export async function generatePlan(
}
let newSessionId: string | undefined;
if (state.sandboxSessionId) {
if (state.sandboxSessionId && !isLocalMode(config)) {
// Stop before returning, as the next step will be to interrupt the graph.
newSessionId = await stopSandbox(state.sandboxSessionId);
}

View file

@ -20,11 +20,19 @@ import {
} from "../../../utils/github/issue-messages.js";
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
export async function prepareGraphState(
state: PlannerGraphState,
config: GraphConfig,
): Promise<Command> {
if (isLocalMode(config)) {
// In local mode, just proceed to initialize-sandbox with existing messages
return new Command({
update: {},
goto: "initialize-sandbox",
});
}
if (!state.githubIssueId) {
throw new Error("No github issue id provided");
}

View file

@ -25,6 +25,7 @@ import {
DO_NOT_RENDER_ID_PREFIX,
PROGRAMMER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
LOCAL_MODE_HEADER,
} from "@open-swe/shared/constants";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
@ -43,6 +44,7 @@ import {
postGitHubIssueComment,
cleanTaskItems,
} from "../../../utils/github/plan.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "ProposedPlan");
@ -86,7 +88,9 @@ async function startProgrammerRun(input: {
}) {
const { runInput, state, config, newMessages } = input;
const langGraphClient = createLangGraphClient({
defaultHeaders: getDefaultHeaders(config),
defaultHeaders: isLocalMode(config)
? { [LOCAL_MODE_HEADER]: "true" }
: getDefaultHeaders(config),
});
const programmerThreadId = uuidv4();
@ -121,14 +125,17 @@ async function startProgrammerRun(input: {
},
);
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
runInput.taskPlan,
);
// Skip GitHub operations in local mode
if (!isLocalMode(config)) {
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
runInput.taskPlan,
);
}
return new Command({
goto: END,
@ -153,6 +160,13 @@ export async function interruptProposedPlan(
throw new Error("No proposed plan found.");
}
logger.info("Interrupting proposed plan", {
autoAcceptPlan: state.autoAcceptPlan,
isLocalMode: isLocalMode(config),
proposedPlanLength: proposedPlan.length,
proposedPlanTitle: state.proposedPlanTitle,
});
let planItems: PlanItem[];
const userRequest = getInitialUserRequest(state.messages);
const userFollowupRequest = getRecentUserRequest(state.messages);
@ -167,7 +181,10 @@ export async function interruptProposedPlan(
};
if (state.autoAcceptPlan) {
logger.info("Auto accepting plan.");
logger.info("Auto accepting plan.", {
autoAcceptPlan: state.autoAcceptPlan,
isLocalMode: isLocalMode(config),
});
// Post comment to GitHub issue about auto-accepting the plan
await postGitHubIssueComment({
@ -206,22 +223,24 @@ export async function interruptProposedPlan(
});
}
await addProposedPlanToIssue(
{
if (!isLocalMode(config)) {
await addProposedPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
proposedPlan,
);
// Post comment to GitHub issue about plan being ready for approval
await postGitHubIssueComment({
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
proposedPlan,
);
// Post comment to GitHub issue about plan being ready for approval
await postGitHubIssueComment({
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
commentBody: `### 🟠 Plan Ready for Approval 🟠\n\nI've generated a plan for this issue and it's ready for your review.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${proposedPlan.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step)}`).join("\n")}\n\nPlease review the plan and let me know if you'd like me to proceed, make changes, or if you have any feedback.`,
config,
});
commentBody: `### 🟠 Plan Ready for Approval 🟠\n\nI've generated a plan for this issue and it's ready for your review.\n\n**Plan: ${state.proposedPlanTitle}**\n\n${proposedPlan.map((step, index) => `- Task ${index + 1}:\n${cleanTaskItems(step)}`).join("\n")}\n\nPlease review the plan and let me know if you'd like me to proceed, make changes, or if you have any feedback.`,
config,
});
}
const interruptResponse = interrupt<
HumanInterrupt,

View file

@ -4,6 +4,10 @@ import {
isToolMessage,
ToolMessage,
} from "@langchain/core/messages";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import {
createGetURLContentTool,
createShellTool,
@ -49,9 +53,9 @@ export async function takeActions(
throw new Error("Last message is not an AI message with tool calls.");
}
const viewTool = createViewTool(state);
const shellTool = createShellTool(state);
const searchTool = createGrepTool(state);
const viewTool = createViewTool(state, config);
const shellTool = createShellTool(state, config);
const searchTool = createGrepTool(state, config);
const scratchpadTool = createScratchpadTool("");
const getURLContentTool = createGetURLContentTool(state);
const searchDocumentForTool = createSearchDocumentForTool(state, config);
@ -198,31 +202,35 @@ export async function takeActions(
{ documentCache: {} } as { documentCache: Record<string, string> },
);
const repoPath = getRepoAbsolutePath(state.targetRepository);
const changedFiles = await getChangedFilesStatus(repoPath, sandbox);
if (changedFiles?.length > 0) {
logger.warn(
"Changes found in the codebase after taking action. Reverting.",
{
changedFiles,
},
);
await stashAndClearChanges(repoPath, sandbox);
if (!isLocalMode(config)) {
const repoPath = isLocalMode(config)
? getLocalWorkingDirectory()
: getRepoAbsolutePath(state.targetRepository);
const changedFiles = await getChangedFilesStatus(repoPath, sandbox);
if (changedFiles?.length > 0) {
logger.warn(
"Changes found in the codebase after taking action. Reverting.",
{
changedFiles,
},
);
await stashAndClearChanges(repoPath, sandbox);
// Rewrite the tool call contents to include a changed files warning.
toolCallResults = toolCallResults.map(
(tc) =>
new ToolMessage({
...tc,
content: `**WARNING**: THIS TOOL, OR A PREVIOUS TOOL HAS CHANGED FILES IN THE REPO.
Remember that you are only permitted to take **READ** actions during the planning step. The changes have been reverted.
Please ensure you only take read actions during the planning step to gather context. You may also call the \`take_notes\` tool at any time to record important information for the programmer step.
Command Output:\n
${tc.content}`,
}),
);
// Rewrite the tool call contents to include a changed files warning.
toolCallResults = toolCallResults.map(
(tc) =>
new ToolMessage({
...tc,
content: `**WARNING**: THIS TOOL, OR A PREVIOUS TOOL HAS CHANGED FILES IN THE REPO.
Remember that you are only permitted to take **READ** actions during the planning step. The changes have been reverted.
Please ensure you only take read actions during the planning step to gather context. You may also call the \`take_notes\` tool at any time to record important information for the programmer step.
Command Output:\n
${tc.content}`,
}),
);
}
}
logger.info("Completed planner tool action", {

View file

@ -173,12 +173,12 @@ async function createToolsAndPrompt(
}> {
const mcpTools = await getMcpTools(config);
const sharedTools = [
createGrepTool(state),
createShellTool(state),
createGrepTool(state, config),
createShellTool(state, config),
createRequestHumanHelpToolFields(),
createUpdatePlanToolFields(),
createGetURLContentTool(state),
createInstallDependenciesTool(state),
createInstallDependenciesTool(state, config),
createMarkTaskCompletedToolFields(),
createSearchDocumentForTool(state, config),
...mcpTools,

View file

@ -52,10 +52,10 @@ export async function takeAction(
}
const applyPatchTool = createApplyPatchTool(state);
const shellTool = createShellTool(state);
const searchTool = createGrepTool(state);
const textEditorTool = createTextEditorTool(state);
const installDependenciesTool = createInstallDependenciesTool(state);
const shellTool = createShellTool(state, config);
const searchTool = createGrepTool(state, config);
const textEditorTool = createTextEditorTool(state, config);
const installDependenciesTool = createInstallDependenciesTool(state, config);
const getURLContentTool = createGetURLContentTool(state);
const searchDocumentForTool = createSearchDocumentForTool(state, config);
const mcpTools = await getMcpTools(config);

View file

@ -120,15 +120,18 @@ ${messages.map(getMessageString).join("\n")}
];
}
function createToolsAndPrompt(state: ReviewerGraphState): {
function createToolsAndPrompt(
state: ReviewerGraphState,
config: GraphConfig,
): {
providerTools: Record<Provider, BindToolsInput[]>;
providerMessages: Record<Provider, BaseMessageLike[]>;
} {
const tools = [
createGrepTool(state),
createShellTool(state),
createViewTool(state),
createInstallDependenciesTool(state),
createGrepTool(state, config),
createShellTool(state, config),
createViewTool(state, config),
createInstallDependenciesTool(state, config),
createScratchpadTool(
"when generating a final review, after all context gathering and reviewing is complete",
),
@ -193,7 +196,10 @@ export async function generateReviewActions(
);
const isAnthropicModel = modelName.includes("claude-");
const { providerTools, providerMessages } = createToolsAndPrompt(state);
const { providerTools, providerMessages } = createToolsAndPrompt(
state,
config,
);
const model = await loadModel(config, Task.REVIEWER, {
providerTools,

View file

@ -46,10 +46,10 @@ export async function takeReviewerActions(
throw new Error("Last message is not an AI message with tool calls.");
}
const shellTool = createShellTool(state);
const searchTool = createGrepTool(state);
const viewTool = createViewTool(state);
const installDependenciesTool = createInstallDependenciesTool(state);
const shellTool = createShellTool(state, config);
const searchTool = createGrepTool(state, config);
const viewTool = createViewTool(state, config);
const installDependenciesTool = createInstallDependenciesTool(state, config);
const scratchpadTool = createScratchpadTool("");
const allTools = [
shellTool,

View file

@ -1,4 +1,5 @@
import { v4 as uuidv4 } from "uuid";
import * as crypto from "crypto";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getGitHubTokensFromConfig } from "../../utils/github-tokens.js";
import {
@ -23,6 +24,10 @@ import { AIMessage, BaseMessage } from "@langchain/core/messages";
import { DEFAULT_SANDBOX_CREATE_PARAMS } from "../../constants.js";
import { getCustomRules } from "../../utils/custom-rules.js";
import { withRetry } from "../../utils/retry.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "InitializeSandbox");
@ -40,7 +45,6 @@ export async function initializeSandbox(
state: InitializeSandboxState,
config: GraphConfig,
): Promise<Partial<InitializeSandboxState>> {
const { githubInstallationToken } = getGitHubTokensFromConfig(config);
const { sandboxSessionId, targetRepository, branchName } = state;
const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
const repoName = `${targetRepository.owner}/${targetRepository.repo}`;
@ -79,6 +83,18 @@ export async function initializeSandbox(
}),
];
// Check if we're in local mode before trying to get GitHub tokens
if (isLocalMode(config)) {
return initializeSandboxLocal(
state,
config,
emitStepEvent,
createEventsMessage,
);
}
const { githubInstallationToken } = getGitHubTokensFromConfig(config);
if (!sandboxSessionId) {
emitStepEvent(
{
@ -322,7 +338,7 @@ export async function initializeSandbox(
},
};
emitStepEvent(baseGenerateCodebaseTreeAction, "pending");
let codebaseTree = undefined;
let codebaseTree: string | undefined;
try {
codebaseTree = await getCodebaseTree(sandbox.id);
emitStepEvent(baseGenerateCodebaseTreeAction, "success");
@ -344,3 +360,114 @@ export async function initializeSandbox(
branchName: newBranchName,
};
}
/**
* Local mode version of initializeSandbox
* Skips sandbox creation and repository cloning, works directly with local filesystem
*/
async function initializeSandboxLocal(
state: InitializeSandboxState,
config: GraphConfig,
emitStepEvent: (
base: CustomNodeEvent,
status: "pending" | "success" | "error" | "skipped",
error?: string,
) => void,
createEventsMessage: () => BaseMessage[],
): Promise<Partial<InitializeSandboxState>> {
const { targetRepository, branchName } = state;
const absoluteRepoDir = getLocalWorkingDirectory(); // Use local working directory in local mode
const repoName = `${targetRepository.owner}/${targetRepository.repo}`;
// Skip sandbox creation in local mode
emitStepEvent(
{
nodeId: INITIALIZE_NODE_ID,
createdAt: new Date().toISOString(),
actionId: uuidv4(),
action: "Creating sandbox",
data: {
status: "skipped",
sandboxSessionId: null,
branch: branchName,
repo: repoName,
},
},
"skipped",
);
// Skip repository cloning in local mode
emitStepEvent(
{
nodeId: INITIALIZE_NODE_ID,
createdAt: new Date().toISOString(),
actionId: uuidv4(),
action: "Cloning repository",
data: {
status: "skipped",
sandboxSessionId: null,
branch: branchName,
repo: repoName,
},
},
"skipped",
);
// Skip branch checkout in local mode
emitStepEvent(
{
nodeId: INITIALIZE_NODE_ID,
createdAt: new Date().toISOString(),
actionId: uuidv4(),
action: "Checking out branch",
data: {
status: "skipped",
sandboxSessionId: null,
branch: branchName,
repo: repoName,
},
},
"skipped",
);
// Generate codebase tree locally
const generateCodebaseTreeActionId = uuidv4();
const baseGenerateCodebaseTreeAction: CustomNodeEvent = {
nodeId: INITIALIZE_NODE_ID,
createdAt: new Date().toISOString(),
actionId: generateCodebaseTreeActionId,
action: "Generating codebase tree",
data: {
status: "pending",
sandboxSessionId: null,
branch: branchName,
repo: repoName,
},
};
emitStepEvent(baseGenerateCodebaseTreeAction, "pending");
let codebaseTree = undefined;
try {
codebaseTree = await getCodebaseTree(undefined, targetRepository, config);
emitStepEvent(baseGenerateCodebaseTreeAction, "success");
} catch (_) {
emitStepEvent(
baseGenerateCodebaseTreeAction,
"error",
"Failed to generate codebase tree.",
);
}
// Create a mock sandbox ID for consistency
const mockSandboxId = `local-${Date.now()}-${crypto.randomBytes(16).toString("hex")}`;
return {
sandboxSessionId: mockSandboxId,
targetRepository,
codebaseTree,
messages: [...(state.messages || []), ...createEventsMessage()],
dependenciesInstalled: false,
customRules: await getCustomRules(null as any, absoluteRepoDir, config),
branchName: branchName,
};
}

View file

@ -11,6 +11,7 @@ import {
GITHUB_TOKEN_COOKIE,
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
LOCAL_MODE_HEADER,
} from "@open-swe/shared/constants";
import { decryptSecret } from "@open-swe/shared/crypto";
import { verifyGitHubWebhookOrThrow } from "./github.js";
@ -48,6 +49,21 @@ export const auth = new Auth()
};
}
// Check for local mode first
const localModeHeader = request.headers.get(LOCAL_MODE_HEADER);
const isRunningLocalModeEnv = process.env.OPEN_SWE_LOCAL_MODE === "true";
if (localModeHeader === "true" && isRunningLocalModeEnv) {
return {
identity: "local-user",
is_authenticated: true,
display_name: "Local User",
metadata: {
installation_name: "local-mode",
},
permissions: LANGGRAPH_USER_PERMISSIONS,
};
}
const ghSecretHashHeader = request.headers.get("X-Hub-Signature-256");
if (ghSecretHashHeader) {
// This will either return a valid user, or throw an error

View file

@ -1,26 +1,54 @@
import { Sandbox } from "@daytonaio/sdk";
import { readFile, writeFile } from "../../utils/read-write.js";
import { getSandboxErrorFields } from "../../utils/sandbox-error-fields.js";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import {
createShellExecutor,
LocalExecuteResponse,
} from "../../utils/shell-executor/index.js";
export async function handleViewCommand(
sandbox: Sandbox,
path: string,
workDir: string,
viewRange?: [number, number],
config?: GraphConfig,
): Promise<string> {
try {
// Check if path is a directory
const statOutput = await sandbox.process.executeCommand(
`stat -c %F "${path}"`,
workDir,
);
let statOutput: LocalExecuteResponse;
if (!config) {
// Fallback to direct sandbox execution if no config provided
statOutput = await sandbox.process.executeCommand(
`stat -c %F "${path}"`,
workDir,
);
} else {
const executor = createShellExecutor(config);
statOutput = await executor.executeCommand({
command: `stat -c %F "${path}"`,
workdir: workDir,
sandbox,
});
}
if (statOutput.exitCode === 0 && statOutput.result?.includes("directory")) {
// List directory contents
const lsOutput = await sandbox.process.executeCommand(
`ls -la "${path}"`,
workDir,
);
let lsOutput: LocalExecuteResponse;
if (!config) {
// Fallback to direct sandbox execution if no config provided
lsOutput = await sandbox.process.executeCommand(
`ls -la "${path}"`,
workDir,
);
} else {
const executor = createShellExecutor(config);
lsOutput = await executor.executeCommand({
command: `ls -la "${path}"`,
workdir: workDir,
sandbox,
});
}
if (lsOutput.exitCode !== 0) {
throw new Error(`Failed to list directory: ${lsOutput.result}`);
@ -34,6 +62,7 @@ export async function handleViewCommand(
sandbox,
filePath: path,
workDir,
config,
});
if (!success) {

View file

@ -1,5 +1,6 @@
import { join } from "path";
import { tool } from "@langchain/core/tools";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types";
import { createLogger, LogLevel } from "../../utils/logger.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getSandboxSessionOrThrow } from "../utils/get-sandbox-id.js";
@ -10,18 +11,22 @@ import {
handleCreateCommand,
handleInsertCommand,
} from "./handlers.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { getLocalShellExecutor } from "../../utils/shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "TextEditorTool");
export function createTextEditorTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
config: GraphConfig,
) {
const textEditorTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
try {
const sandbox = await getSandboxSessionOrThrow(input);
const workDir = getRepoAbsolutePath(state.targetRepository);
const {
command,
path,
@ -32,58 +37,184 @@ export function createTextEditorTool(
insert_line,
} = input;
const localMode = isLocalMode(config);
const localAbsolutePath = getLocalWorkingDirectory();
const sandboxAbsolutePath = getRepoAbsolutePath(state.targetRepository);
const workDir = localMode ? localAbsolutePath : sandboxAbsolutePath;
let result: string;
switch (command) {
case "view":
result = await handleViewCommand(
sandbox,
path,
workDir,
view_range,
);
break;
case "str_replace":
if (!old_str || new_str === undefined) {
throw new Error(
"str_replace command requires both old_str and new_str parameters",
if (localMode) {
// Local mode: use LocalShellExecutor for file operations
const executor = getLocalShellExecutor(localAbsolutePath);
// Convert sandbox path to local path
let localPath = path;
if (path.startsWith("/home/daytona/project/")) {
// Remove the sandbox prefix to get the relative path
localPath = path.replace("/home/daytona/project/", "");
}
const filePath = join(workDir, localPath);
switch (command) {
case "view": {
// Use cat command to view file content
const viewResponse = await executor.executeCommand(
`cat "${filePath}"`,
{
workdir: workDir,
timeout: TIMEOUT_SEC,
localMode: true,
},
);
if (viewResponse.exitCode !== 0) {
throw new Error(`Failed to read file: ${viewResponse.result}`);
}
result = viewResponse.result;
break;
}
result = await handleStrReplaceCommand(
sandbox,
path,
workDir,
old_str,
new_str,
);
break;
case "create":
if (!file_text) {
throw new Error("create command requires file_text parameter");
}
result = await handleCreateCommand(
sandbox,
path,
workDir,
file_text,
);
break;
case "insert":
if (insert_line === undefined || new_str === undefined) {
throw new Error(
"insert command requires both insert_line and new_str parameters",
case "str_replace": {
if (!old_str || new_str === undefined) {
throw new Error(
"str_replace command requires both old_str and new_str parameters",
);
}
// Use sed command for string replacement with proper escaping
const escapedOldStr = old_str
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/'/g, "'\"'\"'");
const escapedNewStr = new_str
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/'/g, "'\"'\"'");
const sedResponse = await executor.executeCommand(
`sed -i 's/${escapedOldStr}/${escapedNewStr}/g' "${filePath}"`,
{
workdir: workDir,
timeout: TIMEOUT_SEC,
localMode: true,
},
);
if (sedResponse.exitCode !== 0) {
throw new Error(
`Failed to replace string: ${sedResponse.result}`,
);
}
result = `Successfully replaced '${old_str}' with '${new_str}' in ${path}`;
break;
}
result = await handleInsertCommand(
sandbox,
path,
workDir,
insert_line,
new_str,
);
break;
default:
throw new Error(`Unknown command: ${command}`);
case "create": {
if (!file_text) {
throw new Error("create command requires file_text parameter");
}
// Create file with content using proper escaping
const escapedFileText = file_text
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\"'\"'");
const createResponse = await executor.executeCommand(
`echo '${escapedFileText}' > "${filePath}"`,
{
workdir: workDir,
timeout: TIMEOUT_SEC,
localMode: true,
},
);
if (createResponse.exitCode !== 0) {
throw new Error(
`Failed to create file: ${createResponse.result}`,
);
}
result = `Successfully created file ${path}`;
break;
}
case "insert": {
if (insert_line === undefined || new_str === undefined) {
throw new Error(
"insert command requires both insert_line and new_str parameters",
);
}
// Insert line at specific position with proper escaping
const escapedNewStr = new_str
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/'/g, "'\"'\"'");
const insertResponse = await executor.executeCommand(
`sed -i '${insert_line}i\\${escapedNewStr}' "${filePath}"`,
{
workdir: workDir,
timeout: TIMEOUT_SEC,
localMode: true,
},
);
if (insertResponse.exitCode !== 0) {
throw new Error(
`Failed to insert line: ${insertResponse.result}`,
);
}
result = `Successfully inserted line at position ${insert_line} in ${path}`;
break;
}
default:
throw new Error(`Unknown command: ${command}`);
}
} else {
// Sandbox mode: use existing handler
const sandbox = await getSandboxSessionOrThrow(input);
switch (command) {
case "view":
result = await handleViewCommand(
sandbox,
path,
workDir,
view_range,
);
break;
case "str_replace":
if (!old_str || new_str === undefined) {
throw new Error(
"str_replace command requires both old_str and new_str parameters",
);
}
result = await handleStrReplaceCommand(
sandbox,
path,
workDir,
old_str,
new_str,
);
break;
case "create":
if (!file_text) {
throw new Error("create command requires file_text parameter");
}
result = await handleCreateCommand(
sandbox,
path,
workDir,
file_text,
);
break;
case "insert":
if (insert_line === undefined || new_str === undefined) {
throw new Error(
"insert command requires both insert_line and new_str parameters",
);
}
result = await handleInsertCommand(
sandbox,
path,
workDir,
insert_line,
new_str,
);
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
logger.info(

View file

@ -1,33 +1,72 @@
import { join } from "path";
import { tool } from "@langchain/core/tools";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types";
import { createLogger, LogLevel } from "../../utils/logger.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getSandboxSessionOrThrow } from "../utils/get-sandbox-id.js";
import { createViewToolFields } from "@open-swe/shared/open-swe/tools";
import { handleViewCommand } from "./handlers.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { getLocalShellExecutor } from "../../utils/shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "ViewTool");
export function createViewTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
config: GraphConfig,
) {
const viewTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
try {
const sandbox = await getSandboxSessionOrThrow(input);
const workDir = getRepoAbsolutePath(state.targetRepository);
const { command, path, view_range } = input;
const { command, path, view_range } = input as any;
if (command !== "view") {
throw new Error(`Unknown command: ${command}`);
}
const result = await handleViewCommand(
sandbox,
path,
workDir,
view_range as [number, number] | undefined,
);
const localMode = isLocalMode(config);
const localAbsolutePath = getLocalWorkingDirectory();
const sandboxAbsolutePath = getRepoAbsolutePath(state.targetRepository);
const workDir = localMode ? localAbsolutePath : sandboxAbsolutePath;
let result: string;
if (localMode) {
// Local mode: use LocalShellExecutor for file viewing
const executor = getLocalShellExecutor(localAbsolutePath);
// Convert sandbox path to local path
let localPath = path;
if (path.startsWith("/home/daytona/project/")) {
// Remove the sandbox prefix to get the relative path
localPath = path.replace("/home/daytona/project/", "");
}
const filePath = join(workDir, localPath);
// Use cat command to view file content
const response = await executor.executeCommand(`cat "${filePath}"`, {
workdir: workDir,
timeout: TIMEOUT_SEC,
localMode: true,
});
if (response.exitCode !== 0) {
throw new Error(`Failed to read file: ${response.result}`);
}
result = response.result;
} else {
// Sandbox mode: use existing handler
const sandbox = await getSandboxSessionOrThrow(input);
result = await handleViewCommand(
sandbox,
path,
workDir,
view_range as [number, number] | undefined,
);
}
logger.info(`View command executed successfully on ${path}`);
return { result, status: "success" };

View file

@ -1,43 +1,54 @@
import { tool } from "@langchain/core/tools";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getSandboxSessionOrThrow } from "./utils/get-sandbox-id.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import {
createGrepToolFields,
formatGrepCommand,
} from "@open-swe/shared/open-swe/tools";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { wrapScript } from "../utils/wrap-script.js";
import { getSandboxSessionOrThrow } from "./utils/get-sandbox-id.js";
import { Sandbox } from "@daytonaio/sdk";
import { createShellExecutor } from "../utils/shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "GrepTool");
const DEFAULT_ENV = {
// Prevents corepack from showing a y/n download prompt which causes the command to hang
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
export function createGrepTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
config: GraphConfig,
) {
const grepTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
try {
const sandbox = await getSandboxSessionOrThrow(input);
const command = formatGrepCommand(input as any);
const localMode = isLocalMode(config);
const localAbsolutePath = getLocalWorkingDirectory();
const sandboxAbsolutePath = getRepoAbsolutePath(state.targetRepository);
const workDir = localMode ? localAbsolutePath : sandboxAbsolutePath;
const repoRoot = getRepoAbsolutePath(state.targetRepository);
const command = formatGrepCommand(input);
logger.info("Running grep search command", {
command: command.join(" "),
repoRoot,
workDir,
});
// Get sandbox if needed for sandbox mode
let sandbox: Sandbox | undefined;
if (!isLocalMode(config)) {
sandbox = await getSandboxSessionOrThrow(input);
}
const executor = createShellExecutor(config);
const response = await executor.executeCommand({
command,
workdir: workDir,
timeout: TIMEOUT_SEC,
sandbox,
});
const response = await sandbox.process.executeCommand(
wrapScript(command.join(" ")),
repoRoot,
DEFAULT_ENV,
TIMEOUT_SEC,
);
let successResult = response.result;
@ -58,17 +69,19 @@ export function createGrepTool(
result: successResult,
status: "success",
};
} catch (e) {
const errorFields = getSandboxErrorFields(e);
} catch (error: any) {
const errorFields = getSandboxErrorFields(error);
if (errorFields) {
const errorResult =
errorFields.result ?? errorFields.artifacts?.stdout;
throw new Error(
`Failed to run grep search command. Exit code: ${errorFields.exitCode}\nError: ${errorResult}`,
);
return {
result: `Error: ${errorFields.result ?? errorFields.artifacts?.stdout}`,
status: "error",
};
}
throw e;
return {
result: `Error: ${error.message || String(error)}`,
status: "error",
};
}
},
createGrepToolFields(state.targetRepository),

View file

@ -1,11 +1,17 @@
import { tool } from "@langchain/core/tools";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { createInstallDependenciesToolFields } from "@open-swe/shared/open-swe/tools";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getSandboxSessionOrThrow } from "./utils/get-sandbox-id.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { LocalExecuteResponse } from "../utils/shell-executor/types.js";
import { getLocalShellExecutor } from "../utils/shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "InstallDependenciesTool");
@ -16,12 +22,11 @@ const DEFAULT_ENV = {
export function createInstallDependenciesTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
config: GraphConfig,
) {
const installDependenciesTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
try {
const sandbox = await getSandboxSessionOrThrow(input);
const repoRoot = getRepoAbsolutePath(state.targetRepository);
const command = input.command.join(" ");
const workdir = input.workdir || repoRoot;
@ -29,12 +34,28 @@ export function createInstallDependenciesTool(
command,
workdir,
});
const response = await sandbox.process.executeCommand(
command,
workdir,
DEFAULT_ENV,
TIMEOUT_SEC * 2.5, // add a 2.5 min timeout
);
let response: LocalExecuteResponse;
if (isLocalMode(config)) {
// Local mode: use LocalShellExecutor
const executor = getLocalShellExecutor(getLocalWorkingDirectory());
response = await executor.executeCommand(command, {
workdir: workdir,
env: DEFAULT_ENV,
timeout: TIMEOUT_SEC * 2.5, // add a 2.5 min timeout
localMode: true,
});
} else {
// Sandbox mode: use existing sandbox logic
const sandbox = await getSandboxSessionOrThrow(input);
response = await sandbox.process.executeCommand(
command,
workdir,
DEFAULT_ENV,
TIMEOUT_SEC * 2.5, // add a 2.5 min timeout
);
}
if (response.exitCode !== 0) {
const errorResult = response.result ?? response.artifacts?.stdout;
@ -48,16 +69,22 @@ export function createInstallDependenciesTool(
status: "success",
};
} catch (e) {
const errorFields = getSandboxErrorFields(e);
if (errorFields) {
const errorResult =
errorFields.result ?? errorFields.artifacts?.stdout;
throw new Error(
`Failed to install dependencies. Exit code: ${errorFields.exitCode}\nError: ${errorResult}`,
);
}
if (isLocalMode(config)) {
// Local mode error handling
throw e;
} else {
// Sandbox mode error handling
const errorFields = getSandboxErrorFields(e);
if (errorFields) {
const errorResult =
errorFields.result ?? errorFields.artifacts?.stdout;
throw new Error(
`Failed to install dependencies. Exit code: ${errorFields.exitCode}\nError: ${errorResult}`,
);
}
throw e;
throw e;
}
}
},
createInstallDependenciesToolFields(state.targetRepository),

View file

@ -1,30 +1,35 @@
import { tool } from "@langchain/core/tools";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { GraphState, GraphConfig } from "@open-swe/shared/open-swe/types";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import { createShellToolFields } from "@open-swe/shared/open-swe/tools";
import { getSandboxSessionOrThrow } from "./utils/get-sandbox-id.js";
const DEFAULT_ENV = {
// Prevents corepack from showing a y/n download prompt which causes the command to hang
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
import { Sandbox } from "@daytonaio/sdk";
import { createShellExecutor } from "../utils/shell-executor/index.js";
export function createShellTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
config: GraphConfig,
) {
const shellTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
try {
const sandbox = await getSandboxSessionOrThrow(input);
const { command, workdir, timeout } = input;
const response = await sandbox.process.executeCommand(
command.join(" "),
// Get sandbox if needed for sandbox mode
let sandbox: Sandbox | undefined;
if (!isLocalMode(config)) {
sandbox = await getSandboxSessionOrThrow(input);
}
const executor = createShellExecutor(config);
const response = await executor.executeCommand({
command,
workdir,
DEFAULT_ENV,
timeout ?? TIMEOUT_SEC,
);
timeout: timeout ?? TIMEOUT_SEC,
sandbox,
});
if (response.exitCode !== 0) {
const errorResult = response.result ?? response.artifacts?.stdout;
@ -37,17 +42,19 @@ export function createShellTool(
result: response.result ?? `exit code: ${response.exitCode}`,
status: "success",
};
} catch (e) {
const errorFields = getSandboxErrorFields(e);
} catch (error: any) {
const errorFields = getSandboxErrorFields(error);
if (errorFields) {
const errorResult =
errorFields.result ?? errorFields.artifacts?.stdout;
throw new Error(
`Command failed. Exit code: ${errorFields.exitCode}\nError: ${errorResult}`,
);
return {
result: `Error: ${errorFields.result ?? errorFields.artifacts?.stdout}`,
status: "error",
};
}
throw e;
return {
result: `Error: ${error.message || String(error)}`,
status: "error",
};
}
},
createShellToolFields(state.targetRepository),

View file

@ -2,6 +2,13 @@ import { CustomRules } from "@open-swe/shared/open-swe/types";
import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "./logger.js";
import { getSandboxErrorFields } from "./sandbox-error-fields.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { promises as fs } from "fs";
import { join } from "path";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
const logger = createLogger(LogLevel.INFO, "CustomRules");
@ -117,8 +124,13 @@ export function parseCustomRulesFromString(
export async function getCustomRules(
sandbox: Sandbox,
rootDir: string,
config?: GraphConfig,
): Promise<CustomRules | undefined> {
try {
if (config && isLocalMode(config)) {
return getCustomRulesLocal(rootDir);
}
const catAgentsMdFileCommand = ["cat", "AGENTS.md"];
const agentsMdRes = await sandbox.process.executeCommand(
catAgentsMdFileCommand.join(" "),
@ -155,6 +167,48 @@ export async function getCustomRules(
return undefined;
}
/**
* Local version of getCustomRules using Node.js fs
*/
async function getCustomRulesLocal(
rootDir: string,
): Promise<CustomRules | undefined> {
try {
const workingDirectory = rootDir || getLocalWorkingDirectory();
// Try to read AGENTS.md first
try {
const agentsMdPath = join(workingDirectory, "AGENTS.md");
const agentsMdContent = await fs.readFile(agentsMdPath, "utf-8");
if (agentsMdContent && agentsMdContent.length > 0) {
return parseCustomRulesFromString(agentsMdContent);
}
} catch (error) {
logger.error("Failed to read AGENTS.md", { error });
}
// Try to read AGENT.md, CLAUDE.md, CURSOR.md
const filesToTry = ["AGENT.md", "CLAUDE.md", "CURSOR.md"];
for (const fileName of filesToTry) {
try {
const filePath = join(workingDirectory, fileName);
const content = await fs.readFile(filePath, "utf-8");
if (content && content.length > 0) {
return parseCustomRulesFromString(content);
}
} catch (error) {
// File doesn't exist, continue to next file
logger.error(`Failed to read ${fileName}`, { error });
}
}
} catch (error) {
logger.error("Failed to get custom rules in local mode", { error });
}
return undefined;
}
export const CUSTOM_RULES_PROMPT = `<custom_rules>
The following are custom rules provided by the user.
{EXTRA_CONTEXT}

View file

@ -19,6 +19,14 @@ import { createPullRequest } from "./api.js";
import { addTaskPlanToIssue } from "./issue-task.js";
import { DEFAULT_EXCLUDED_PATTERNS } from "./constants.js";
import { escapeRegExp } from "../string-utils.js";
import {
getLocalWorkingDirectory,
isLocalMode,
} from "@open-swe/shared/open-swe/local-mode";
import {
getLocalShellExecutor,
LocalExecuteResponse,
} from "../shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "GitHub-Git");
@ -43,12 +51,26 @@ async function getValidFilesToCommit(
sandbox: Sandbox,
excludePatterns: string[] = DEFAULT_EXCLUDED_PATTERNS,
): Promise<string[]> {
const gitStatusOutput = await sandbox.process.executeCommand(
"git status --porcelain",
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
let gitStatusOutput: LocalExecuteResponse;
// Check if we're in local mode (sandbox doesn't have process)
if (!sandbox.process) {
// Local mode: use LocalShellExecutor
const executor = getLocalShellExecutor(getLocalWorkingDirectory());
gitStatusOutput = await executor.executeCommand("git status --porcelain", {
workdir: absoluteRepoDir,
timeout: TIMEOUT_SEC,
localMode: true,
});
} else {
// Sandbox mode: use sandbox.process
gitStatusOutput = await sandbox.process.executeCommand(
"git status --porcelain",
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
}
if (gitStatusOutput.exitCode !== 0) {
logger.error(`Failed to get git status for file validation`, {
@ -120,12 +142,26 @@ export async function getChangedFilesStatus(
absoluteRepoDir: string,
sandbox: Sandbox,
): Promise<string[]> {
const gitStatusOutput = await sandbox.process.executeCommand(
"git status --porcelain",
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
let gitStatusOutput: LocalExecuteResponse;
// Check if we're in local mode (sandbox doesn't have process)
if (!sandbox.process) {
// Local mode: use LocalShellExecutor
const executor = getLocalShellExecutor(getLocalWorkingDirectory());
gitStatusOutput = await executor.executeCommand("git status --porcelain", {
workdir: absoluteRepoDir,
timeout: TIMEOUT_SEC,
localMode: true,
});
} else {
// Sandbox mode: use sandbox.process
gitStatusOutput = await sandbox.process.executeCommand(
"git status --porcelain",
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
}
if (gitStatusOutput.exitCode !== 0) {
logger.error(`Failed to get changed files status`, {
@ -139,9 +175,23 @@ export async function getChangedFilesStatus(
export async function stashAndClearChanges(
absoluteRepoDir: string,
sandbox: Sandbox,
sandbox: Sandbox | null,
config?: GraphConfig,
): Promise<ExecuteResponse | false> {
// In local mode, we don't want to stash and clear changes
if (config && isLocalMode(config)) {
logger.info("Skipping stash and clear changes in local mode");
return {
exitCode: 0,
result: "Skipped stash and clear in local mode",
};
}
try {
// Sandbox mode: use existing sandbox logic
if (!sandbox) {
throw new Error("Sandbox is required in non-local mode");
}
const gitStashOutput = await sandbox.process.executeCommand(
"git add -A && git stash && git reset --hard",
absoluteRepoDir,
@ -156,6 +206,7 @@ export async function stashAndClearChanges(
}
return gitStashOutput;
} catch (e) {
// Sandbox mode error handling
const errorFields = getSandboxErrorFields(e);
logger.error(`Failed to stash and clear changes`, {
...(errorFields && { errorFields }),

View file

@ -9,6 +9,7 @@ import { getIssue, getIssueComments } from "./api.js";
import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types";
import { getGitHubTokensFromConfig } from "../github-tokens.js";
import { DETAILS_OPEN_TAG } from "./issue-task.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
export function getUntrackedComments(
existingMessages: BaseMessage[],
@ -53,6 +54,10 @@ export async function getMissingMessages(
input: GetMissingMessagesInput,
config: GraphConfig,
): Promise<BaseMessage[]> {
if (isLocalMode(config)) {
return [];
}
const { githubInstallationToken } = getGitHubTokensFromConfig(config);
const [issue, comments] = await Promise.all([
getIssue({

View file

@ -6,7 +6,7 @@ import {
import { getIssue, updateIssue } from "./api.js";
import { getGitHubTokensFromConfig } from "../github-tokens.js";
import { createLogger, LogLevel } from "../logger.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "IssueTaskString");
export const TASK_OPEN_TAG = "<open-swe-do-not-edit-task-plan>";
@ -95,6 +95,12 @@ export async function getPlansFromIssue(
taskPlan: TaskPlan | null;
proposedPlan: string[] | null;
}> {
if (isLocalMode(config)) {
return {
taskPlan: null,
proposedPlan: null,
};
}
const issue = await getIssue({
owner: input.targetRepository.owner,
repo: input.targetRepository.repo,

View file

@ -6,6 +6,7 @@ import {
updateIssueComment,
} from "./api.js";
import { createLogger, LogLevel } from "../logger.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "GitHubPlan");
@ -40,6 +41,13 @@ export async function postGitHubIssueComment(input: {
config: GraphConfig;
}): Promise<void> {
const { githubIssueId, targetRepository, commentBody, config } = input;
if (isLocalMode(config)) {
// In local mode, we don't post GitHub comments
logger.info("Skipping GitHub comment posting in local mode");
return;
}
const githubAppName = process.env.GITHUB_APP_NAME;
if (!githubAppName) {
throw new Error("GITHUB_APP_NAME not set");

View file

@ -2,6 +2,13 @@ import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "./logger.js";
import { getSandboxErrorFields } from "./sandbox-error-fields.js";
import { traceable } from "langsmith/traceable";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { promises as fs } from "fs";
import { join, isAbsolute } from "path";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
const logger = createLogger(LogLevel.INFO, "ReadWriteUtil");
@ -11,7 +18,12 @@ async function handleCreateFile(
args?: {
workDir?: string;
},
config?: GraphConfig,
) {
if (config && isLocalMode(config)) {
return handleCreateFileLocal(filePath, args?.workDir);
}
try {
const touchCommand = `touch "${filePath}"`;
const touchOutput = await sandbox.process.executeCommand(
@ -37,11 +49,17 @@ async function readFileFunc(inputs: {
sandbox: Sandbox;
filePath: string;
workDir?: string;
config?: GraphConfig;
}): Promise<{
success: boolean;
output: string;
}> {
const { sandbox, filePath, workDir } = inputs;
const { sandbox, filePath, workDir, config } = inputs;
if (config && isLocalMode(config)) {
return readFileLocal(filePath, workDir);
}
try {
const readOutput = await sandbox.process.executeCommand(
`cat "${filePath}"`,
@ -62,13 +80,20 @@ async function readFileFunc(inputs: {
};
} catch (e: any) {
if (e instanceof Error && e.message.includes("No such file or directory")) {
const createOutput = await handleCreateFile(sandbox, filePath, {
workDir,
});
let createOutput;
if (config && isLocalMode(config)) {
// Local mode: use handleCreateFileLocal
createOutput = await handleCreateFileLocal(filePath, workDir);
} else {
// Sandbox mode: use handleCreateFile
createOutput = await handleCreateFile(sandbox, filePath, {
workDir,
});
}
if (createOutput.exitCode !== 0) {
return {
success: false,
output: `FAILED TO EXECUTE READ COMMAND for sandbox '${filePath}'. Error: ${(e as Error).message || String(e)}`,
output: `FAILED TO EXECUTE READ COMMAND for ${config && isLocalMode(config) ? "local" : "sandbox"} '${filePath}'. Error: ${(e as Error).message || String(e)}`,
};
} else {
// If the file was created successfully, try reading it again.
@ -108,7 +133,7 @@ async function readFileFunc(inputs: {
export const readFile = traceable(readFileFunc, {
name: "read_file",
processInputs: (inputs) => {
const { sandbox: _sandbox, ...rest } = inputs;
const { sandbox: _sandbox, config: _config, ...rest } = inputs;
return rest;
},
});
@ -118,11 +143,17 @@ async function writeFileFunc(inputs: {
filePath: string;
content: string;
workDir?: string;
config?: GraphConfig;
}): Promise<{
success: boolean;
output: string;
}> {
const { sandbox, filePath, content, workDir } = inputs;
const { sandbox, filePath, content, workDir, config } = inputs;
// Check if we're in local mode
if (config && isLocalMode(config)) {
return writeFileLocal(filePath, content, workDir);
}
try {
const delimiter = "EOF_" + Date.now() + "_" + Math.random().toString(36);
const writeCommand = `cat > "${filePath}" << '${delimiter}'
@ -173,7 +204,116 @@ ${delimiter}`;
export const writeFile = traceable(writeFileFunc, {
name: "write_file",
processInputs: (inputs) => {
const { sandbox: _sandbox, ...rest } = inputs;
const { sandbox: _sandbox, config: _config, ...rest } = inputs;
return rest;
},
});
/**
* Local version of readFile using Node.js fs
*/
async function readFileLocal(
filePath: string,
workDir?: string,
): Promise<{
success: boolean;
output: string;
}> {
try {
const workingDirectory = workDir || getLocalWorkingDirectory();
const fullPath = isAbsolute(filePath)
? filePath
: join(workingDirectory, filePath);
const content = await fs.readFile(fullPath, "utf-8");
return {
success: true,
output: content,
};
} catch (error: any) {
if (error.code === "ENOENT") {
// File doesn't exist, create it
try {
const workingDirectory = workDir || getLocalWorkingDirectory();
const fullPath = isAbsolute(filePath)
? filePath
: join(workingDirectory, filePath);
await fs.writeFile(fullPath, "", "utf-8");
return {
success: true,
output: "",
};
} catch (createError: any) {
return {
success: false,
output: `FAILED TO AUTOMATICALLY CREATE FILE '${filePath}' AFTER READING FILE ERRORED WITH CODE: ${error.code}. Error: ${createError.message}`,
};
}
}
return {
success: false,
output: `FAILED TO READ FILE '${filePath}'. Error: ${error.message}`,
};
}
}
/**
* Local version of writeFile using Node.js fs
*/
async function writeFileLocal(
filePath: string,
content: string,
workDir?: string,
): Promise<{
success: boolean;
output: string;
}> {
try {
const workingDirectory = workDir || getLocalWorkingDirectory();
const fullPath = isAbsolute(filePath)
? filePath
: join(workingDirectory, filePath);
await fs.writeFile(fullPath, content, "utf-8");
return {
success: true,
output: `Successfully wrote file '${filePath}' to local filesystem.`,
};
} catch (error: any) {
return {
success: false,
output: `FAILED TO WRITE FILE '${filePath}'. Error: ${error.message}`,
};
}
}
/**
* Local version of handleCreateFile using Node.js fs
*/
async function handleCreateFileLocal(
filePath: string,
workDir?: string,
): Promise<{
exitCode: number;
error?: string;
stdout: string;
stderr: string;
}> {
try {
const workingDirectory = workDir || getLocalWorkingDirectory();
const fullPath = isAbsolute(filePath)
? filePath
: join(workingDirectory, filePath);
await fs.writeFile(fullPath, "", "utf-8");
return {
exitCode: 0,
stdout: `Created file '${filePath}'`,
stderr: "",
};
} catch (error: any) {
return {
exitCode: 1,
error: error.message,
stdout: "",
stderr: error.message,
};
}
}

View file

@ -5,6 +5,7 @@ import { DEFAULT_SANDBOX_CREATE_PARAMS } from "../constants.js";
import { getGitHubTokensFromConfig } from "./github-tokens.js";
import { cloneRepo } from "./github/git.js";
import { FAILED_TO_GENERATE_TREE_MESSAGE, getCodebaseTree } from "./tree.js";
import { isLocalMode } from "@open-swe/shared/open-swe/local-mode";
const logger = createLogger(LogLevel.INFO, "Sandbox");
@ -96,6 +97,18 @@ export async function getSandboxWithErrorHandling(
codebaseTree: string | null;
dependenciesInstalled: boolean | null;
}> {
if (isLocalMode(config)) {
const mockSandbox = {
id: sandboxSessionId || "local-mock-sandbox",
state: "started",
} as Sandbox;
return {
sandbox: mockSandbox,
codebaseTree: null,
dependenciesInstalled: null,
};
}
try {
if (!sandboxSessionId) {
throw new Error("No sandbox ID provided.");

View file

@ -0,0 +1,3 @@
export * from "./shell-executor.js";
export * from "./local-shell-executor.js";
export * from "./types.js";

View file

@ -0,0 +1,149 @@
import { spawn } from "child_process";
import { LocalExecuteResponse } from "./types.js";
import { createLogger, LogLevel } from "../logger.js";
const logger = createLogger(LogLevel.INFO, "LocalShellExecutor");
export class LocalShellExecutor {
private workingDirectory: string;
constructor(workingDirectory: string = process.cwd()) {
this.workingDirectory = workingDirectory;
logger.info("LocalShellExecutor created", { workingDirectory });
}
async executeCommand(
command: string,
args?: {
workdir?: string;
env?: Record<string, string>;
timeout?: number;
localMode?: boolean;
},
): Promise<LocalExecuteResponse> {
const { workdir, env, timeout = 30, localMode = false } = args || {};
const cwd = workdir || this.workingDirectory;
const environment = { ...process.env, ...(env || {}) };
logger.info("Executing command locally", { command, cwd, localMode });
// In local mode, use spawn directly for better reliability
if (localMode) {
try {
const cleanEnv = Object.fromEntries(
Object.entries(environment).filter(([_, v]) => v !== undefined),
) as Record<string, string>;
const result = await this.executeWithSpawn(
command,
cwd,
cleanEnv,
timeout,
);
return result;
} catch (spawnError: any) {
logger.error("Spawn execution failed in local mode", {
command,
error: spawnError.message,
});
return {
exitCode: 1,
result: spawnError.message,
artifacts: {
stdout: "",
stderr: spawnError.message,
},
};
}
}
// Non-local mode: throw error as this executor is for local mode only
throw new Error("LocalShellExecutor is only for local mode operations");
}
private async executeWithSpawn(
command: string,
cwd: string,
env: Record<string, string>,
timeout: number,
): Promise<LocalExecuteResponse> {
return new Promise((resolve, reject) => {
// Try different shell paths
const shellPaths = [
"/bin/bash",
"/usr/bin/bash",
"/bin/sh",
"/usr/bin/sh",
];
let lastError: Error | null = null;
const tryShell = (shellPath: string) => {
const child = spawn(shellPath, ["-c", command], {
cwd,
env: { ...process.env, ...env },
timeout: timeout * 1000,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
resolve({
exitCode: code || 0,
result: stdout,
artifacts: {
stdout,
stderr: stderr || undefined,
},
});
});
child.on("error", (error) => {
lastError = error;
// Try next shell path
const nextIndex = shellPaths.indexOf(shellPath) + 1;
if (nextIndex < shellPaths.length) {
tryShell(shellPaths[nextIndex]);
} else {
reject(lastError);
}
});
};
// Start with the first shell path
tryShell(shellPaths[0]);
});
}
getWorkingDirectory(): string {
return this.workingDirectory;
}
setWorkingDirectory(directory: string): void {
this.workingDirectory = directory;
logger.info("Working directory changed", { workingDirectory: directory });
}
}
let sharedExecutor: LocalShellExecutor | null = null;
export function getLocalShellExecutor(
workingDirectory?: string,
): LocalShellExecutor {
if (
!sharedExecutor ||
(workingDirectory &&
sharedExecutor.getWorkingDirectory() !== workingDirectory)
) {
sharedExecutor = new LocalShellExecutor(workingDirectory);
}
return sharedExecutor;
}

View file

@ -0,0 +1,141 @@
import { Sandbox } from "@daytonaio/sdk";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { TIMEOUT_SEC } from "@open-swe/shared/constants";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { getLocalShellExecutor } from "./local-shell-executor.js";
import { createLogger, LogLevel } from "../logger.js";
import { ExecuteCommandOptions, LocalExecuteResponse } from "./types.js";
const logger = createLogger(LogLevel.INFO, "ShellExecutor");
const DEFAULT_ENV = {
// Prevents corepack from showing a y/n download prompt which causes the command to hang
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
/**
* Unified shell executor that handles both local and sandbox command execution
* This eliminates the need for if/else blocks in every tool that runs shell commands
*/
export class ShellExecutor {
private config: GraphConfig;
constructor(config: GraphConfig) {
this.config = config;
}
/**
* Execute a command either locally or in the sandbox based on the current mode
*/
async executeCommand(
options: ExecuteCommandOptions,
): Promise<LocalExecuteResponse> {
const {
command,
workdir,
env = {},
timeout = TIMEOUT_SEC,
sandbox,
} = options;
const commandString = Array.isArray(command) ? command.join(" ") : command;
const environment = { ...DEFAULT_ENV, ...env };
logger.info("Executing command", {
command: commandString,
workdir,
localMode: isLocalMode(this.config),
});
if (isLocalMode(this.config)) {
return this.executeLocal(commandString, workdir, environment, timeout);
} else {
return this.executeSandbox(
commandString,
workdir,
environment,
timeout,
sandbox,
);
}
}
/**
* Execute command locally using LocalShellExecutor
*/
private async executeLocal(
command: string,
workdir?: string,
env?: Record<string, string>,
timeout?: number,
): Promise<LocalExecuteResponse> {
const executor = getLocalShellExecutor(getLocalWorkingDirectory());
const localWorkdir = workdir || getLocalWorkingDirectory();
return await executor.executeCommand(command, {
workdir: localWorkdir,
env,
timeout,
localMode: true,
});
}
/**
* Execute command in sandbox
*/
private async executeSandbox(
command: string,
workdir?: string,
env?: Record<string, string>,
timeout?: number,
sandbox?: Sandbox,
): Promise<LocalExecuteResponse> {
if (!sandbox) {
throw new Error("Sandbox is required for sandbox mode execution");
}
return await sandbox.process.executeCommand(command, workdir, env, timeout);
}
/**
* Check if we're in local mode
*/
isLocalMode(): boolean {
return isLocalMode(this.config);
}
/**
* Get the appropriate working directory for the current mode
*/
getWorkingDirectory(): string {
if (isLocalMode(this.config)) {
return getLocalWorkingDirectory();
}
// For sandbox mode, this would need to be provided by the caller
// since it depends on the specific sandbox context
throw new Error(
"Working directory for sandbox mode must be provided explicitly",
);
}
}
/**
* Factory function to create a ShellExecutor instance
*/
export function createShellExecutor(config: GraphConfig): ShellExecutor {
return new ShellExecutor(config);
}
/**
* Convenience function for one-off command execution
*/
export async function executeCommand(
config: GraphConfig,
options: ExecuteCommandOptions,
): Promise<LocalExecuteResponse> {
const executor = createShellExecutor(config);
return await executor.executeCommand(options);
}

View file

@ -0,0 +1,20 @@
import { Sandbox } from "@daytonaio/sdk";
export interface LocalExecutionArtifacts {
stdout?: string;
stderr?: string;
}
export interface LocalExecuteResponse {
exitCode: number;
result: string;
artifacts?: LocalExecutionArtifacts;
}
export interface ExecuteCommandOptions {
command: string | string[];
workdir?: string;
env?: Record<string, string>;
timeout?: number;
sandbox?: Sandbox;
}

View file

@ -1,10 +1,19 @@
import { getCurrentTaskInput } from "@langchain/langgraph";
import { GraphState, TargetRepository } from "@open-swe/shared/open-swe/types";
import {
GraphState,
TargetRepository,
GraphConfig,
} from "@open-swe/shared/open-swe/types";
import { daytonaClient } from "./sandbox.js";
import { createLogger, LogLevel } from "./logger.js";
import path from "node:path";
import { SANDBOX_ROOT_DIR, TIMEOUT_SEC } from "@open-swe/shared/constants";
import { getSandboxErrorFields } from "./sandbox-error-fields.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "@open-swe/shared/open-swe/local-mode";
import { getLocalShellExecutor } from "./shell-executor/index.js";
const logger = createLogger(LogLevel.INFO, "Tree");
@ -14,12 +23,18 @@ export const FAILED_TO_GENERATE_TREE_MESSAGE =
export async function getCodebaseTree(
sandboxSessionId_?: string,
targetRepository_?: TargetRepository,
config?: GraphConfig,
): Promise<string> {
try {
const command = `git ls-files | tree --fromfile -L 3`;
let sandboxSessionId = sandboxSessionId_;
let targetRepository = targetRepository_;
// Check if we're in local mode
if (config && isLocalMode(config)) {
return getCodebaseTreeLocal();
}
// If sandbox session ID is not provided, try to get it from the current state.
if (!sandboxSessionId || !targetRepository) {
try {
@ -76,3 +91,45 @@ export async function getCodebaseTree(
return FAILED_TO_GENERATE_TREE_MESSAGE;
}
}
/**
* Local version of getCodebaseTree using LocalShellExecutor
*/
async function getCodebaseTreeLocal(): Promise<string> {
try {
// In local mode, always use the current working directory
const workingDirectory = getLocalWorkingDirectory();
const executor = getLocalShellExecutor(workingDirectory);
const command = `git ls-files | tree --fromfile -L 3`;
const response = await executor.executeCommand(command, {
workdir: workingDirectory,
timeout: TIMEOUT_SEC,
localMode: true,
});
if (response.exitCode !== 0) {
logger.error("Failed to generate tree in local mode", {
exitCode: response.exitCode,
result: response.result,
});
throw new Error(
`Failed to generate tree in local mode: ${response.result}`,
);
}
return response.result;
} catch (e) {
logger.error("Failed to generate tree in local mode", {
...(e instanceof Error
? {
name: e.name,
message: e.message,
stack: e.stack,
}
: { error: e }),
});
return FAILED_TO_GENERATE_TREE_MESSAGE;
}
}

View file

@ -5,6 +5,8 @@ import {
} from "@langchain/core/messages";
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";
// 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?
@ -41,19 +43,27 @@ export function getInitialUserRequest(
export function getRecentUserRequest(
messages: BaseMessage[],
options?: { returnFullMessage?: never | false },
options?: { returnFullMessage?: never | false; config?: GraphConfig },
): string;
export function getRecentUserRequest(
messages: BaseMessage[],
options?: { returnFullMessage?: true },
options?: { returnFullMessage?: true; config?: GraphConfig },
): HumanMessage;
export function getRecentUserRequest(
messages: BaseMessage[],
options?: { returnFullMessage?: boolean },
options?: { returnFullMessage?: boolean; config?: GraphConfig },
): string | HumanMessage {
const recentUserMessage = messages.findLast(
(m) => isHumanMessage(m) && m.additional_kwargs?.isFollowup,
);
let recentUserMessage: HumanMessage | undefined;
if (options?.config && isLocalMode(options.config)) {
// In local mode, get the last human message regardless of flags
recentUserMessage = messages.findLast(isHumanMessage);
} else {
// In normal mode, look for messages with isFollowup flag
recentUserMessage = messages.findLast(
(m) => isHumanMessage(m) && m.additional_kwargs?.isFollowup,
);
}
if (!recentUserMessage) {
return "";

View file

@ -17,6 +17,7 @@
"strict": true,
"strictFunctionTypes": false,
"outDir": "dist",
"rootDir": ".",
"types": ["jest", "node"],
"resolveJsonModule": true,
"isolatedModules": true

View file

@ -11,7 +11,7 @@ export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token";
export const GITHUB_INSTALLATION_NAME = "x-github-installation-name";
export const GITHUB_PAT = "x-github-pat";
export const GITHUB_INSTALLATION_ID = "x-github-installation-id";
export const LOCAL_MODE_HEADER = "x-local-mode";
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";
export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id";

View file

@ -1,9 +1,19 @@
import { SANDBOX_ROOT_DIR } from "./constants.js";
import { TargetRepository } from "./open-swe/types.js";
import { TargetRepository, GraphConfig } from "./open-swe/types.js";
import {
isLocalMode,
getLocalWorkingDirectory,
} from "./open-swe/local-mode.js";
export function getRepoAbsolutePath(
targetRepository: TargetRepository,
config?: GraphConfig,
): string {
// Check for local mode first
if (config && isLocalMode(config)) {
return getLocalWorkingDirectory();
}
const repoName = targetRepository.repo;
if (!repoName) {
throw new Error("No repository name provided");

View file

@ -0,0 +1,29 @@
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import path from "path";
/**
* Checks if the current execution context is in local mode
* (working on local files instead of sandbox/Daytona)
*/
export function isLocalMode(config: GraphConfig): boolean {
return (config.configurable as any)?.["x-local-mode"] === "true";
}
/**
* Gets the local working directory for local mode operations
* Defaults to a test folder on the desktop if not specified
*/
export function getLocalWorkingDirectory(): string {
return (
process.env.OPEN_SWE_PROJECT_PATH ||
path.join(process.env.HOME || "", "Desktop", "test")
);
}
/**
* Checks if we're in local mode based on environment variables
* (useful for contexts where GraphConfig is not available)
*/
export function isLocalModeFromEnv(): boolean {
return process.env.OPEN_SWE_LOCAL_MODE === "true";
}

View file

@ -1,7 +1,8 @@
import { z } from "zod";
import { TargetRepository } from "./types.js";
import { TargetRepository, GraphConfig } from "./types.js";
import { getRepoAbsolutePath } from "../git.js";
import { TIMEOUT_SEC } from "../constants.js";
import { isLocalMode, getLocalWorkingDirectory } from "./local-mode.js";
export function createApplyPatchToolFields(targetRepository: TargetRepository) {
const repoRoot = getRepoAbsolutePath(targetRepository);
@ -546,8 +547,14 @@ export function createTextEditorToolFields(targetRepository: TargetRepository) {
};
}
export function createViewToolFields(targetRepository: TargetRepository) {
const repoRoot = getRepoAbsolutePath(targetRepository);
export function createViewToolFields(
targetRepository: TargetRepository,
config?: GraphConfig,
) {
const repoRoot =
config && isLocalMode(config)
? getLocalWorkingDirectory()
: getRepoAbsolutePath(targetRepository);
const viewSchema = z.object({
command: z.enum(["view"]).describe("The command to execute: view"),
path: z

View file

@ -17,6 +17,7 @@
"strict": true,
"strictFunctionTypes": false,
"outDir": "dist",
"rootDir": "src",
"types": ["jest", "node"],
"resolveJsonModule": true
},

View file

@ -4153,6 +4153,7 @@ __metadata:
"@types/uuid": ^10.0.0
"@typescript-eslint/eslint-plugin": ^8.38.0
"@typescript-eslint/parser": ^8.38.0
commander: ^12.0.0
dotenv: ^16.6.1
eslint: ^9.19.0
eslint-config-prettier: ^8.8.0
@ -9203,6 +9204,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^12.0.0":
version: 12.1.0
resolution: "commander@npm:12.1.0"
checksum: 68e9818b00fc1ed9cdab9eb16905551c2b768a317ae69a5e3c43924c2b20ac9bb65b27e1cab36aeda7b6496376d4da908996ba2c0b5d79463e0fb1e77935d514
languageName: node
linkType: hard
"commander@npm:^13.0.0":
version: 13.1.0
resolution: "commander@npm:13.1.0"