mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
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:
parent
0b23ca97c4
commit
46b435a543
46 changed files with 1709 additions and 377 deletions
|
|
@ -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",
|
||||
|
|
|
|||
1
apps/cli/src/constants.ts
Normal file
1
apps/cli/src/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const OPEN_SWE_CLI_VERSION = "0.0.0";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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\`:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
3
apps/open-swe/src/utils/shell-executor/index.ts
Normal file
3
apps/open-swe/src/utils/shell-executor/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./shell-executor.js";
|
||||
export * from "./local-shell-executor.js";
|
||||
export * from "./types.js";
|
||||
149
apps/open-swe/src/utils/shell-executor/local-shell-executor.ts
Normal file
149
apps/open-swe/src/utils/shell-executor/local-shell-executor.ts
Normal 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;
|
||||
}
|
||||
141
apps/open-swe/src/utils/shell-executor/shell-executor.ts
Normal file
141
apps/open-swe/src/utils/shell-executor/shell-executor.ts
Normal 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);
|
||||
}
|
||||
20
apps/open-swe/src/utils/shell-executor/types.ts
Normal file
20
apps/open-swe/src/utils/shell-executor/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"strict": true,
|
||||
"strictFunctionTypes": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["jest", "node"],
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
29
packages/shared/src/open-swe/local-mode.ts
Normal file
29
packages/shared/src/open-swe/local-mode.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"strict": true,
|
||||
"strictFunctionTypes": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["jest", "node"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue