feat: Add install dependencies tool (#291)

* feat: Add install dependencies tool

* cr

* hookup prompt

* frontend

* add prompt saying deps installed
This commit is contained in:
Brace Sproul 2025-06-22 15:47:29 -07:00 • committed by GitHub
parent 5ab7b94be9
commit 08a0945a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 318 additions and 29 deletions

View file

@ -0,0 +1,12 @@
import { SNAPSHOT_NAME } from "@open-swe/shared/constants";
import { CreateSandboxParams } from "@daytonaio/sdk";
export const DEFAULT_SANDBOX_CREATE_PARAMS: CreateSandboxParams = {
resources: {
cpu: 2,
memory: 4,
disk: 5,
},
user: "daytona",
image: SNAPSHOT_NAME,
};

View file

@ -16,11 +16,16 @@ import { createLogger, LogLevel } from "../../../../utils/logger.js";
import { getCurrentPlanItem } from "../../../../utils/current-task.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
import { SYSTEM_PROMPT } from "./prompt.js";
import {
DEPENDENCIES_INSTALLED_PROMPT,
INSTALL_DEPENDENCIES_TOOL_PROMPT,
SYSTEM_PROMPT,
} from "./prompt.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { getMissingMessages } from "../../../../utils/github/issue-messages.js";
import { getTaskPlanFromIssue } from "../../../../utils/github/issue-task.js";
import { createRgTool } from "../../../../tools/rg.js";
import { createInstallDependenciesTool } from "../../../../tools/install-dependencies.js";
const logger = createLogger(LogLevel.INFO, "GenerateMessageNode");
@ -50,7 +55,13 @@ const formatPrompt = (state: GraphState): string => {
state.codebaseTree || "No codebase tree generated yet.",
)
.replaceAll("{CURRENT_WORKING_DIRECTORY}", repoDirectory)
.replaceAll("{CURRENT_TASK_NUMBER}", currentPlanItem.index.toString());
.replaceAll("{CURRENT_TASK_NUMBER}", currentPlanItem.index.toString())
.replaceAll(
"{INSTALL_DEPENDENCIES_TOOL_PROMPT}",
!state.dependenciesInstalled
? INSTALL_DEPENDENCIES_TOOL_PROMPT
: DEPENDENCIES_INSTALLED_PROMPT,
);
};
export async function generateAction(
@ -64,6 +75,10 @@ export async function generateAction(
createApplyPatchTool(state),
createRequestHumanHelpToolFields(),
createUpdatePlanToolFields(),
// Only provide the dependencies installed tool if they're not already installed.
...(state.dependenciesInstalled
? []
: [createInstallDependenciesTool(state)]),
];
const modelWithTools = model.bindTools(tools, {
tool_choice: "auto",

View file

@ -1,3 +1,6 @@
export const INSTALL_DEPENDENCIES_TOOL_PROMPT = `* Use \`install_dependencies\` to install dependencies (skip if installation fails). IMPORTANT: You should only call this tool if you're executing a task which REQUIRES installing dependencies. Keep in mind that not all tasks will require installing dependencies.`;
export const DEPENDENCIES_INSTALLED_PROMPT = `* Dependencies have already been installed. *`;
export const SYSTEM_PROMPT = `# Identity
You are a terminal-based agentic coding assistant built by LangChain. You wrap LLM models to enable natural language interaction with local codebases. You are precise, safe, and helpful.
@ -33,6 +36,7 @@ You are currently executing a specific task from a pre-generated plan. You have
* Use \`apply_patch\` for file edits (accepts diffs and file paths)
* Use \`shell\` with \`touch\` to create new files (not \`apply_patch\`)
* Always use \`workdir\` parameter instead of \`cd\` when running commands via the \`shell\` tool
{INSTALL_DEPENDENCIES_TOOL_PROMPT}
### Tool Usage Best Practices

View file

@ -159,7 +159,10 @@ export async function openPullRequest(
return {
messages: newMessages,
internalMessages: newMessages,
// If the sandbox was successfully deleted, we can remove it from the state.
...(sandboxDeleted && { sandboxSessionId: undefined }),
// If the sandbox was successfully deleted, we can remove it from the state & reset the dependencies installed flag.
...(sandboxDeleted && {
sandboxSessionId: undefined,
dependenciesInstalled: false,
}),
};
}

View file

@ -20,6 +20,8 @@ import { daytonaClient } from "../../../utils/sandbox.js";
import { getCodebaseTree } from "../../../utils/tree.js";
import { getRepoAbsolutePath } from "@open-swe/shared/git";
import { shouldDiagnoseError } from "../utils/tool-message-error.js";
import { createInstallDependenciesTool } from "../../../tools/install-dependencies.js";
import { createRgTool } from "../../../tools/rg.js";
const logger = createLogger(LogLevel.INFO, "TakeAction");
@ -41,9 +43,13 @@ export async function takeAction(
const applyPatchTool = createApplyPatchTool(state);
const shellTool = createShellTool(state);
const rgTool = createRgTool(state);
const installDependenciesTool = createInstallDependenciesTool(state);
const toolsMap = {
[applyPatchTool.name]: applyPatchTool,
[shellTool.name]: shellTool,
[rgTool.name]: rgTool,
[installDependenciesTool.name]: installDependenciesTool,
};
const toolCalls = lastMessage.tool_calls;
@ -107,6 +113,13 @@ export async function takeAction(
const toolCallResults = await Promise.all(toolCallResultsPromise);
let wereDependenciesInstalled: boolean | null = null;
toolCallResults.forEach((toolCallResult) => {
if (toolCallResult.name === installDependenciesTool.name) {
wereDependenciesInstalled = toolCallResult.status === "success";
}
});
// Always check if there are changed files after running a tool.
// If there are, commit them.
const sandbox = await daytonaClient().get(state.sandboxSessionId);
@ -142,6 +155,9 @@ export async function takeAction(
internalMessages: toolCallResults,
...(branchName && { branchName }),
codebaseTree,
...(wereDependenciesInstalled !== null && {
dependenciesInstalled: wereDependenciesInstalled,
}),
};
return new Command({
goto: shouldRouteDiagnoseNode ? "diagnose-error" : "progress-plan-step",

View file

@ -11,16 +11,14 @@ import {
pullLatestChanges,
} from "../../utils/github/git.js";
import { getCodebaseTree } from "../../utils/tree.js";
import {
DO_NOT_RENDER_ID_PREFIX,
SNAPSHOT_NAME,
} from "@open-swe/shared/constants";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import {
CustomNodeEvent,
INITIALIZE_NODE_ID,
} from "@open-swe/shared/open-swe/custom-node-events";
import { Sandbox } from "@daytonaio/sdk";
import { AIMessage, BaseMessage } from "@langchain/core/messages";
import { DEFAULT_SANDBOX_CREATE_PARAMS } from "../../constants.js";
const logger = createLogger(LogLevel.INFO, "InitializeSandbox");
@ -30,6 +28,7 @@ type InitializeSandboxState = {
sandboxSessionId?: string;
codebaseTree?: string;
messages?: BaseMessage[];
dependenciesInstalled?: boolean;
};
export async function initializeSandbox(
@ -207,7 +206,7 @@ export async function initializeSandbox(
emitStepEvent(baseCreateSandboxAction, "pending");
let sandbox: Sandbox;
try {
sandbox = await daytonaClient().create({ image: SNAPSHOT_NAME });
sandbox = await daytonaClient().create(DEFAULT_SANDBOX_CREATE_PARAMS);
emitStepEvent(baseCreateSandboxAction, "success");
} catch {
emitStepEvent(
@ -332,5 +331,6 @@ export async function initializeSandbox(
targetRepository,
codebaseTree,
messages: createEventsMessage(),
dependenciesInstalled: false,
};
}

View file

@ -1,9 +1,10 @@
/* eslint-disable no-console */
import { test, expect } from "@jest/globals";
import { daytonaClient } from "../utils/sandbox.js";
import { SNAPSHOT_NAME } from "@open-swe/shared/constants";
import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants";
import { DEFAULT_SANDBOX_CREATE_PARAMS } from "../constants.js";
test("Can execute rg commands", async () => {
test.skip("Can execute rg commands", async () => {
const githubToken = process.env.GITHUB_PAT;
if (!githubToken) {
throw new Error("GITHUB_PAT environment variable is not set");
@ -12,10 +13,7 @@ test("Can execute rg commands", async () => {
const client = daytonaClient();
console.log("Setting up sandbox...");
const sandbox = await client.create({
image: SNAPSHOT_NAME,
user: "daytona",
});
const sandbox = await client.create(DEFAULT_SANDBOX_CREATE_PARAMS);
console.log("Setup sandbox:", sandbox.id);
const repoUrlWithToken = `https://x-access-token:${githubToken}@github.com/langchain-ai/open-swe.git`;
@ -41,3 +39,51 @@ OPEN_SWE_X
expect(testRes.exitCode).toBe(0);
});
const DEFAULT_ENV = {
// Prevents corepack from showing a y/n download prompt which causes the command to hang
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
};
test("Installing dependencies", async () => {
const githubToken = process.env.GITHUB_PAT;
if (!githubToken) {
throw new Error("GITHUB_PAT environment variable is not set");
}
const client = daytonaClient();
console.log("Setting up sandbox...");
const sandbox = await client.create(DEFAULT_SANDBOX_CREATE_PARAMS);
console.log("Setup sandbox:", sandbox.id);
try {
const repoUrlWithToken = `https://x-access-token:${githubToken}@github.com/langchain-ai/open-swe.git`;
const cloneCommand = `git clone ${repoUrlWithToken}`;
console.log("Cloning repo...");
const cloneRes = await sandbox.process.executeCommand(
cloneCommand,
SANDBOX_ROOT_DIR,
);
expect(cloneRes.exitCode).toBe(0);
const installCommand = "yarn install";
const installRes = await sandbox.process.executeCommand(
installCommand,
`${SANDBOX_ROOT_DIR}/open-swe`,
DEFAULT_ENV,
120, // 120 seconds timeout
);
console.log(
`install res status: ${installRes.exitCode}\ninstall res output: ${installRes.result}`,
);
console.log(
`Install res exit code: ${installRes.exitCode}\nInstall res output: ${installRes.result}`,
);
expect(installRes.exitCode).toBe(0);
} finally {
await sandbox.delete();
console.log("Deleted sandbox:", sandbox.id);
}
}, 300_000); // 5 minutes timeout

View file

@ -0,0 +1,101 @@
import { tool } from "@langchain/core/tools";
import { Sandbox } from "@daytonaio/sdk";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { getCurrentTaskInput } from "@langchain/langgraph";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { daytonaClient } from "../utils/sandbox.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";
const logger = createLogger(LogLevel.INFO, "InstallDependenciesTool");
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 createInstallDependenciesTool(
state: Pick<GraphState, "sandboxSessionId" | "targetRepository">,
) {
const installDependenciesTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
let sandbox: Sandbox | undefined;
try {
const state = getCurrentTaskInput<GraphState>();
const { sandboxSessionId } = state;
if (!sandboxSessionId) {
logger.error(
"FAILED TO INSTALL DEPENDENCIES: No sandbox session ID provided",
{
input,
},
);
throw new Error(
"FAILED TO INSTALL DEPENDENCIES: No sandbox session ID provided",
);
}
const repoRoot = getRepoAbsolutePath(state.targetRepository);
sandbox = await daytonaClient().get(sandboxSessionId);
const command = input.command.join(" ");
const workdir = input.workdir || repoRoot;
logger.info("Running install dependencies command", {
command,
workdir,
});
const response = await sandbox.process.executeCommand(
command,
workdir,
DEFAULT_ENV,
TIMEOUT_SEC * 2.5, // add a 2.5 min timeout
);
if (response.exitCode !== 0) {
logger.error("Failed to install dependencies", {
error: response.result,
error_result: response,
input,
});
throw new Error(
`Command failed. Exit code: ${response.exitCode}\nResult: ${response.result}\nStdout:\n${response.artifacts?.stdout}`,
);
}
return {
result: response.result,
status: "success",
};
} catch (e) {
const errorFields = getSandboxErrorFields(e);
if (errorFields) {
logger.error("Failed to install dependencies", {
input,
error: errorFields,
});
throw new Error(
`Command failed. Exit code: ${errorFields.exitCode}\nError: ${errorFields.result ?? errorFields.artifacts?.stdout}`,
);
}
logger.error(
"Failed to install dependencies: " +
(e instanceof Error ? e.message : "Unknown error"),
{
error: e,
input,
},
);
throw new Error(
"FAILED TO INSTALL DEPENDENCIES: " +
(e instanceof Error ? e.message : "Unknown error"),
);
}
},
createInstallDependenciesToolFields(state.targetRepository),
);
return installDependenciesTool;
}

View file

@ -6,18 +6,17 @@ export function truncateOutput(
output: string,
options?: {
/**
* @default 10000
* @default 2500
*/
numStartCharacters?: number;
/**
* @default 10000
* @default 2500
*/
numEndCharacters?: number;
},
) {
const { numStartCharacters = 10000, numEndCharacters = 10000 } =
options ?? {};
const { numStartCharacters = 2500, numEndCharacters = 2500 } = options ?? {};
if (numStartCharacters < 0 || numEndCharacters < 0) {
throw new Error("numStartCharacters and numEndCharacters must be >= 0");

View file

@ -11,10 +11,13 @@ import {
ChevronUp,
MessageSquare,
FileText,
CloudDownload,
Search,
} from "lucide-react";
import {
createApplyPatchToolFields,
createShellToolFields,
createInstallDependenciesToolFields,
formatRgCommand,
RipgrepCommand,
} from "@open-swe/shared/open-swe/tools";
@ -26,6 +29,10 @@ const shellTool = createShellToolFields(dummyRepo);
type ShellToolArgs = z.infer<typeof shellTool.schema>;
const applyPatchTool = createApplyPatchToolFields(dummyRepo);
type ApplyPatchToolArgs = z.infer<typeof applyPatchTool.schema>;
const installDependenciesTool = createInstallDependenciesToolFields(dummyRepo);
type InstallDependenciesToolArgs = z.infer<
typeof installDependenciesTool.schema
>;
// Common props for all action types
type BaseActionProps = {
@ -59,11 +66,19 @@ type RgActionProps = BaseActionProps &
errorCode?: number;
};
type InstallDependenciesActionProps = BaseActionProps &
Partial<InstallDependenciesToolArgs> & {
actionType: "install_dependencies";
output?: string;
errorCode?: number;
};
export type ActionItemProps =
| (BaseActionProps & { status: "loading" })
| ShellActionProps
| PatchActionProps
| RgActionProps;
| RgActionProps
| InstallDependenciesActionProps;
export type ActionStepProps = {
actions: ActionItemProps[];
@ -109,6 +124,8 @@ function ActionItem(props: ActionItemProps) {
return props.success ? "Patch applied" : "Patch failed";
} else if (props.actionType === "rg") {
return props.success ? "Search completed" : "Search failed";
} else if (props.actionType === "install_dependencies") {
return props.success ? "Dependencies installed" : "Installation failed";
}
}
@ -119,7 +136,11 @@ function ActionItem(props: ActionItemProps) {
const shouldShowToggle = () => {
if (props.status !== "done") return false;
if (props.actionType === "shell" || props.actionType === "rg") {
if (
props.actionType === "shell" ||
props.actionType === "rg" ||
props.actionType === "install_dependencies"
) {
return !!props.output;
} else if (props.actionType === "apply-patch") {
return !!props.diff;
@ -135,11 +156,15 @@ function ActionItem(props: ActionItemProps) {
return <Loader2 className="text-muted-foreground mr-2 size-3.5" />;
}
return props.actionType === "shell" ? (
<Terminal className="text-muted-foreground mr-2 size-3.5" />
) : (
<FileCode className="text-muted-foreground mr-2 size-3.5" />
);
if (props.actionType === "install_dependencies") {
return <CloudDownload className="text-muted-foreground mr-2 size-3.5" />;
} else if (props.actionType === "apply-patch") {
return <FileCode className="text-muted-foreground mr-2 size-3.5" />;
} else if (props.actionType === "rg") {
return <Search className="text-muted-foreground mr-2 size-3.5" />;
} else {
return <Terminal className="text-muted-foreground mr-2 size-3.5" />;
}
};
// Render the header content based on action type
@ -152,7 +177,10 @@ function ActionItem(props: ActionItemProps) {
);
}
if (props.actionType === "shell") {
if (
props.actionType === "shell" ||
props.actionType === "install_dependencies"
) {
let commandStr = "";
if (props.command) {
if (Array.isArray(props.command)) {
@ -215,7 +243,9 @@ function ActionItem(props: ActionItemProps) {
if (!expanded) return null;
if (
(props.actionType === "shell" || props.actionType === "rg") &&
(props.actionType === "shell" ||
props.actionType === "rg" ||
props.actionType === "install_dependencies") &&
props.output
) {
return (

View file

@ -26,6 +26,7 @@ import {
createShellToolFields,
createSetTaskStatusToolFields,
createRgToolFields,
createInstallDependenciesToolFields,
} from "@open-swe/shared/open-swe/tools";
import { z } from "zod";
import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
@ -41,6 +42,10 @@ const setTaskStatusTool = createSetTaskStatusToolFields();
type SetTaskStatusToolArgs = z.infer<typeof setTaskStatusTool.schema>;
const rgTool = createRgToolFields(dummyRepo);
type RgToolArgs = z.infer<typeof rgTool.schema>;
const installDependenciesTool = createInstallDependenciesToolFields(dummyRepo);
type InstallDependenciesToolArgs = z.infer<
typeof installDependenciesTool.schema
>;
function CustomComponent({
message,
@ -148,6 +153,17 @@ export function mapToolMessageToActionStepProps(
output: getContentString(message.content),
reasoningText,
};
} else if (toolCall?.name === installDependenciesTool.name) {
const args = toolCall.args as InstallDependenciesToolArgs;
return {
actionType: "install_dependencies",
status,
success,
command: args.command || "",
workdir: args.workdir || "",
output: getContentString(message.content),
reasoningText,
};
}
return {
status: "loading",
@ -210,7 +226,8 @@ export function AssistantMessage({
(tc) =>
tc.name === shellTool.name ||
tc.name === applyPatchTool.name ||
tc.name === rgTool.name,
tc.name === rgTool.name ||
tc.name === installDependenciesTool.name,
)
: [];
@ -248,6 +265,8 @@ export function AssistantMessage({
const isShellTool = toolCall.name === shellTool.name;
const isRgTool = toolCall.name === rgTool.name;
const isInstallDependenciesTool =
toolCall.name === installDependenciesTool.name;
if (correspondingToolResult) {
// If we have a tool result, map it to action props
@ -261,6 +280,15 @@ export function AssistantMessage({
paths: args?.paths || [],
output: "",
} as ActionItemProps;
} else if (isInstallDependenciesTool) {
const args = toolCall.args as InstallDependenciesToolArgs;
return {
actionType: "install_dependencies",
status: "generating",
command: args?.command || "",
workdir: args?.workdir || "",
output: "",
} as ActionItemProps;
} else {
if (isShellTool) {
const args = toolCall.args as ShellToolArgs;

View file

@ -179,3 +179,28 @@ export function createSetTaskStatusToolFields() {
return setTaskStatusTool;
}
export function createInstallDependenciesToolFields(
targetRepository: TargetRepository,
) {
const repoRoot = getRepoAbsolutePath(targetRepository);
const installDependenciesToolSchema = z.object({
command: z
.array(z.string())
.describe("The command to run to install dependencies."),
workdir: z
.string()
.default(repoRoot)
.describe(
`The working directory to run the command in. The default working directory this command will be executed in is the root of the repository: \`${repoRoot}\`. If you want to execute this install command inside a different location, pass a path to this field.`,
),
});
return {
name: "install_dependencies",
description:
"Installs dependencies for the repository. You should only call this tool if you need to install dependencies for a specific task. Ensure you only call this tool after gathering context on how to install dependencies, such as the package manager, proper install command, etc.",
schema: installDependenciesToolSchema,
};
}

View file

@ -205,6 +205,16 @@ export const GraphAnnotation = MessagesZodState.extend({
fn: (_state, update) => update,
},
}),
/**
* Whether or not the dependencies have been installed already in the sandbox.
*/
dependenciesInstalled: withLangGraph(z.custom<boolean>(), {
reducer: {
schema: z.custom<boolean>(),
fn: (_state, update) => update,
},
default: () => false,
}),
// ---NOT USED---
ui: z