mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 09:43:15 +00:00
feat: Support followup requests (#60)
* feat: Support followup requests * cr * cr * fix * account for pr already exists
This commit is contained in:
parent
93d2979f84
commit
c599b916a8
28 changed files with 838 additions and 491 deletions
|
|
@ -17,9 +17,7 @@
|
|||
"format:check": "prettier --check .",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$",
|
||||
"test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$",
|
||||
"test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000",
|
||||
"run:e2e": "npx tsx scripts/run-e2e.ts",
|
||||
"run:from-plan": "npx tsx scripts/run-from-plan.ts"
|
||||
"test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@daytonaio/sdk": "^0.18.1",
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import "dotenv/config";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import { createLogger, LogLevel } from "../src/utils/logger.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "E2E Script");
|
||||
|
||||
async function runE2E() {
|
||||
const client = new Client({
|
||||
apiKey: process.env.LANGCHAIN_API_KEY,
|
||||
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
||||
});
|
||||
|
||||
const threadId = uuidv4();
|
||||
|
||||
const userRequest = `The project is structured as a monorepo, with some apps located inside the /apps directory. In this directory, there is an /auth directory. This directory only contains the scaffolding for a new app in the monorepo, but is not yet implemented. Please take the following plan/task description and implement it in the /auth directory:
|
||||
This monorepo is for an AI coding agent. The app runs and edits the code in the cloud in a sandboxed environment. Right now, we require users to generate a GitHub PAT, which we store in a .env file and can use to authenticate with GitHub. This is not idea, and instead we want to have a github oauth app which users can authenticate with.
|
||||
Please implement a new auth server inside the /auth directory which can do this.
|
||||
You will not have any access to secrets, so you will not be able to run the server to test it.
|
||||
I want the server to be able to authenticate users with GitHub, such that we will be able to take the following actions:
|
||||
1. clone repositories they give us access to
|
||||
2. checkout existing and create new branches on the repositories they give us access to
|
||||
3. make pull requests and push changes to the repositories they give us access to
|
||||
Once you're done, ensure you've documented the development process in the readme of this new app.`;
|
||||
|
||||
const targetRepository = {
|
||||
owner: "langchain-ai",
|
||||
repo: "open-swe",
|
||||
};
|
||||
|
||||
const stream = client.runs.stream(threadId, "open-swe", {
|
||||
input: {
|
||||
messages: [{ role: "user", content: userRequest }],
|
||||
targetRepository,
|
||||
},
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
ifNotExists: "create",
|
||||
streamSubgraphs: true,
|
||||
streamMode: "updates",
|
||||
});
|
||||
|
||||
logger.info(`Run started with thread ID: "${threadId}"`);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const node = Object.keys(chunk.data)[0];
|
||||
logger.info(`${node} completed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeGraph(threadId: string) {
|
||||
const client = new Client({
|
||||
apiKey: process.env.LANGCHAIN_API_KEY,
|
||||
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
||||
});
|
||||
|
||||
// EDIT THIS IF YOU DO NOT WANT TO ACCEPT
|
||||
const resumeValue: HumanResponse[] = [
|
||||
{
|
||||
type: "accept",
|
||||
args: null,
|
||||
},
|
||||
];
|
||||
|
||||
const stream = client.runs.stream(threadId, "open-swe", {
|
||||
command: {
|
||||
resume: resumeValue,
|
||||
},
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
streamSubgraphs: true,
|
||||
streamMode: "updates",
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const node = Object.keys(chunk.data)[0];
|
||||
logger.info(`${node} completed.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2); // Skip node executable and script path
|
||||
|
||||
if (args.length === 0) {
|
||||
runE2E().catch((error) => {
|
||||
logger.error("Error running E2E test:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (args.length === 2 && args[0] === "--threadId") {
|
||||
const threadId = args[1];
|
||||
resumeGraph(threadId).catch((error) => {
|
||||
logger.error(`Error resuming graph for thread ID ${threadId}:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
logger.info("Usage:");
|
||||
logger.info(" To run a new E2E test:");
|
||||
logger.info(" yarn run:e2e");
|
||||
logger.info("");
|
||||
logger.info(" To resume a graph with a thread ID:");
|
||||
logger.info(" yarn run:e2e --threadId <thread_id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import "dotenv/config";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { graph } from "../src/index.js";
|
||||
import { createLogger, LogLevel } from "../src/utils/logger.js";
|
||||
import { SANDBOX_ROOT_DIR } from "../src/constants.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "From Plan Script");
|
||||
|
||||
async function runFromPlan() {
|
||||
const client = new Client({
|
||||
apiKey: process.env.LANGCHAIN_API_KEY,
|
||||
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
||||
});
|
||||
|
||||
const threadId = uuidv4();
|
||||
|
||||
const targetRepository = {
|
||||
owner: "langchain-ai",
|
||||
repo: "open-swe",
|
||||
};
|
||||
|
||||
const inputs = {
|
||||
targetRepository,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `The project is structured as a monorepo, with some apps located inside the /apps directory. In this directory, there is an /auth directory. This directory only contains the scaffolding for a new app in the monorepo, but is not yet implemented. Please take the following plan/task description and implement it in the /auth directory:
|
||||
This monorepo is for an AI coding agent. The app runs and edits the code in the cloud in a sandboxed environment. Right now, we require users to generate a GitHub PAT, which we store in a .env file and can use to authenticate with GitHub. This is not idea, and instead we want to have a github oauth app which users can authenticate with.
|
||||
Please implement a new auth server inside the /auth directory which can do this.
|
||||
You will not have any access to secrets, so you will not be able to run the server to test it.
|
||||
I want the server to be able to authenticate users with GitHub, such that we will be able to take the following actions:
|
||||
1. clone repositories they give us access to
|
||||
2. checkout existing and create new branches on the repositories they give us access to
|
||||
3. make pull requests and push changes to the repositories they give us access to
|
||||
Once you're done, ensure you've documented the development process in the readme of this new app.`,
|
||||
},
|
||||
],
|
||||
plan: [
|
||||
{
|
||||
index: 0,
|
||||
plan: "Set up the Express.js server with TypeScript configuration and necessary dependencies for GitHub OAuth authentication",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
plan: "Implement GitHub OAuth flow endpoints including authorization redirect and callback handling",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
plan: "Create middleware for JWT token generation and validation for authenticated sessions",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
plan: "Add environment configuration management for OAuth app credentials and server settings",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
plan: "Add comprehensive error handling throughout the authentication flow",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
{
|
||||
index: 5,
|
||||
plan: "Create comprehensive README documentation covering setup, configuration, and development process",
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
},
|
||||
],
|
||||
proposedPlan: [
|
||||
"Set up the Express.js server with TypeScript configuration and necessary dependencies for GitHub OAuth authentication",
|
||||
"Implement GitHub OAuth flow endpoints including authorization redirect and callback handling",
|
||||
"Create middleware for JWT token generation and validation for authenticated sessions",
|
||||
"Add environment configuration management for OAuth app credentials and server settings",
|
||||
"Add comprehensive error handling throughout the authentication flow",
|
||||
"Create comprehensive README documentation covering setup, configuration, and development process",
|
||||
],
|
||||
planContextSummary: `## User Request Summary
|
||||
The user wants to implement a GitHub OAuth authentication server in the \`/apps/auth\` directory of a monorepo for an AI coding agent. The goal is to replace the current GitHub PAT authentication system with OAuth to enable:
|
||||
1. Cloning repositories users give access to
|
||||
2. Checking out existing and creating new branches
|
||||
3. Making pull requests and pushing changes
|
||||
|
||||
## Codebase Files and Descriptions
|
||||
- **Project root**: \`${SANDBOX_ROOT_DIR}/open-swe/\` - Main monorepo directory
|
||||
- **Apps directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/\` - Contains multiple apps including auth, docs, and open-swe
|
||||
- **Auth app directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/\` - Target directory for implementation, currently contains only scaffolding
|
||||
- **Auth package.json**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/package.json\` - Contains basic TypeScript/Node.js setup with name "@open-swe/auth", includes dev dependencies for TypeScript, Jest, ESLint, Prettier
|
||||
- **Auth src directory**: \`${SANDBOX_ROOT_DIR}/open-swe/apps/auth/src/\` - Contains only an empty \`index.ts\` file
|
||||
- **Auth config files**: Directory includes standard config files (.gitignore, .dockerignore, .prettierrc, eslint.config.js, jest.config.js, tsconfig.json, turbo.json)
|
||||
|
||||
## Key Repository Insights and Learnings
|
||||
- The monorepo uses Yarn as package manager (version 3.5.1)
|
||||
- TypeScript is used throughout with version ~5.7.2
|
||||
- The auth app is set up as an ES module (type: "module" in package.json)
|
||||
- Standard tooling includes ESLint, Prettier, Jest for testing
|
||||
- The auth directory is completely empty except for scaffolding - no existing implementation
|
||||
- The project appears to be part of the LangChain AI organization based on repository URL
|
||||
- No access to secrets/environment variables for testing
|
||||
- Need to document the development process in a README for the auth app
|
||||
|
||||
## Implementation Requirements
|
||||
- Implement GitHub OAuth flow for authentication
|
||||
- Ensure the server can handle repository operations (clone, branch management, PR creation)
|
||||
- Create comprehensive documentation in README
|
||||
- Follow existing monorepo patterns and tooling setup`,
|
||||
codebaseContext: "",
|
||||
planChangeRequest: undefined,
|
||||
sandboxSessionId: undefined,
|
||||
branchName: `open-swe/${threadId}`,
|
||||
};
|
||||
|
||||
logger.info("Initializing sandbox...");
|
||||
|
||||
const initResult = await graph.nodes.initialize.invoke(inputs as any);
|
||||
if (!initResult.sandboxSessionId) {
|
||||
throw new Error("Failed to initialize sandbox.");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sandbox initialized successfully. ID: ${initResult.sandboxSessionId}\n\n`,
|
||||
);
|
||||
|
||||
const stream = client.runs.stream(threadId, "open-swe", {
|
||||
command: {
|
||||
goto: "generate-action",
|
||||
update: {
|
||||
...inputs,
|
||||
sandboxSessionId: initResult.sandboxSessionId,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
ifNotExists: "create",
|
||||
streamSubgraphs: true,
|
||||
streamMode: "updates",
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const node = Object.keys(chunk.data)[0];
|
||||
logger.info(`${node} completed.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
runFromPlan().catch(logger.error);
|
||||
|
|
@ -12,24 +12,14 @@ import {
|
|||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import { z } from "zod";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import {
|
||||
getCompletedPlanItems,
|
||||
getCurrentPlanItem,
|
||||
} from "../utils/current-task.js";
|
||||
import { getActivePlanItems } from "../utils/task-plan.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "DiagnoseError");
|
||||
|
||||
/**
|
||||
* Whether or not enough errored tool calls have occurred to interrupt the graph.
|
||||
* This will return true if the last tool call was an error, and 7 of the last 10
|
||||
* tool calls have been errors.
|
||||
* @param toolMessages
|
||||
*
|
||||
* @TODO Implement this. Should interrupt after generating a diagnosis for 7 consecutive errors.
|
||||
*/
|
||||
// function shouldInterruptError(toolMessages: ToolMessage[]): boolean {
|
||||
// if (toolMessages[toolMessages.length - 1].status !== "error") {
|
||||
// return false;
|
||||
// }
|
||||
// return toolMessages.slice(-10).filter((m) => m.status === "error").length >= 7;
|
||||
// }
|
||||
|
||||
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
The last command you tried to execute failed with an error. Please carefully diagnose the error, and provide a helpful explanation of exactly what the issue is, and how you can fix it.
|
||||
|
|
@ -77,8 +67,8 @@ const formatSystemPrompt = (
|
|||
plan: PlanItem[],
|
||||
codebaseContext: string,
|
||||
): string => {
|
||||
const currentTask = plan.find((p) => !p.completed);
|
||||
const completedTasks = plan.filter((p) => p.completed);
|
||||
const currentPlanItem = getCurrentPlanItem(plan);
|
||||
const completedTasks = getCompletedPlanItems(plan);
|
||||
|
||||
return systemPrompt
|
||||
.replace(
|
||||
|
|
@ -87,7 +77,7 @@ const formatSystemPrompt = (
|
|||
)
|
||||
.replace(
|
||||
"{CURRENT_TASK}",
|
||||
`<current-task index="${currentTask?.index}">${currentTask?.plan}</current-task>`,
|
||||
`<current-task index="${currentPlanItem.index}">${currentPlanItem.plan}</current-task>`,
|
||||
)
|
||||
.replace("{PLAN_PROMPT}", formatPlanPromptWithSummaries(completedTasks))
|
||||
.replace("{CODEBASE_CONTEXT}", codebaseContext);
|
||||
|
|
@ -123,7 +113,7 @@ export async function diagnoseError(
|
|||
role: "system",
|
||||
content: formatSystemPrompt(
|
||||
getMessageContentString(lastFailedAction.content),
|
||||
state.plan,
|
||||
getActivePlanItems(state.plan),
|
||||
state.codebaseContext,
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import {
|
||||
|
|
@ -6,6 +5,12 @@ import {
|
|||
getMessageString,
|
||||
} from "../utils/message/content.js";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import { getUserRequest } from "../utils/user-request.js";
|
||||
import {
|
||||
completeTask,
|
||||
getActivePlanItems,
|
||||
getActiveTask,
|
||||
} from "../utils/task-plan.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "GenerateConclusionNode");
|
||||
|
||||
|
|
@ -31,10 +36,9 @@ export async function generateConclusion(
|
|||
): Promise<GraphUpdate> {
|
||||
const model = await loadModel(config, Task.SUMMARIZER);
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
const userMessage = `The user's initial request is as follows:
|
||||
${getMessageContentString(firstUserMessage?.content || "No user message found")}
|
||||
${userRequest || "No user message found"}
|
||||
|
||||
The conversation history is as follows:
|
||||
${state.messages.map(getMessageString).join("\n")}
|
||||
|
|
@ -46,7 +50,7 @@ Given all of this, please respond with the concise conclusion. Do not include an
|
|||
const response = await model.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: formatPrompt(state.plan),
|
||||
content: formatPrompt(getActivePlanItems(state.plan)),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -55,8 +59,15 @@ Given all of this, please respond with the concise conclusion. Do not include an
|
|||
]);
|
||||
|
||||
logger.info("✅ Successfully generated conclusion. Ending run. 👋");
|
||||
const activeTaskId = getActiveTask(state.plan).id;
|
||||
const updatedTaskPlan = completeTask(
|
||||
state.plan,
|
||||
activeTaskId,
|
||||
getMessageContentString(response.content),
|
||||
);
|
||||
|
||||
return {
|
||||
messages: [response],
|
||||
plan: updatedTaskPlan,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { getRepoAbsolutePath } from "../utils/git.js";
|
|||
import { formatPlanPrompt } from "../utils/plan-prompt.js";
|
||||
import { stopSandbox } from "../utils/sandbox.js";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import { getCurrentTask } from "../utils/current-task.js";
|
||||
import { getCurrentPlanItem } from "../utils/current-task.js";
|
||||
import { getMessageContentString } from "../utils/message/content.js";
|
||||
import { getActivePlanItems } from "../utils/task-plan.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "GenerateMessageNode");
|
||||
|
||||
|
|
@ -103,9 +104,14 @@ const formatPrompt = (state: GraphState): string => {
|
|||
return systemPrompt
|
||||
.replaceAll(
|
||||
"{PLAN_PROMPT_WITH_SUMMARIES}",
|
||||
formatPlanPrompt(state.plan, { includeSummaries: true }),
|
||||
formatPlanPrompt(getActivePlanItems(state.plan), {
|
||||
includeSummaries: true,
|
||||
}),
|
||||
)
|
||||
.replaceAll(
|
||||
"{PLAN_PROMPT}",
|
||||
formatPlanPrompt(getActivePlanItems(state.plan)),
|
||||
)
|
||||
.replaceAll("{PLAN_PROMPT}", formatPlanPrompt(state.plan))
|
||||
.replaceAll("{REPO_DIRECTORY}", repoDirectory)
|
||||
.replaceAll(
|
||||
"{PLAN_GENERATION_SUMMARY}",
|
||||
|
|
@ -142,14 +148,14 @@ export async function generateAction(
|
|||
}
|
||||
|
||||
logger.info("Generated action", {
|
||||
currentTask: getCurrentTask(state.plan).plan,
|
||||
currentTask: getCurrentPlanItem(getActivePlanItems(state.plan)).plan,
|
||||
...(getMessageContentString(response.content) && {
|
||||
content: getMessageContentString(response.content),
|
||||
}),
|
||||
...(response.tool_calls?.[0] && {
|
||||
name: response.tool_calls?.[0].name,
|
||||
args: response.tool_calls?.[0].args,
|
||||
}),
|
||||
...(getMessageContentString(response.content) && {
|
||||
content: getMessageContentString(response.content),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ export async function initialize(
|
|||
image: SNAPSHOT_NAME,
|
||||
});
|
||||
|
||||
const res = await cloneRepo(sandbox, targetRepository, { githubToken });
|
||||
const res = await cloneRepo(sandbox, targetRepository, {
|
||||
githubToken,
|
||||
stateBranchName: state.branchName,
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
// TODO: This should probably be an interrupt.
|
||||
logger.error("Failed to clone repository", res.result);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Command, END, interrupt } from "@langchain/langgraph";
|
||||
import { GraphState } from "../types.js";
|
||||
import { GraphState, GraphUpdate } from "../types.js";
|
||||
import {
|
||||
ActionRequest,
|
||||
HumanInterrupt,
|
||||
HumanResponse,
|
||||
} from "@langchain/langgraph/prebuilt";
|
||||
import { startSandbox } from "../utils/sandbox.js";
|
||||
import { createNewTask } from "../utils/task-plan.js";
|
||||
import { getUserRequest } from "../utils/user-request.js";
|
||||
|
||||
export async function interruptPlan(state: GraphState): Promise<Command> {
|
||||
const { proposedPlan } = state;
|
||||
|
|
@ -35,21 +37,27 @@ export async function interruptPlan(state: GraphState): Promise<Command> {
|
|||
throw new Error("No sandbox session ID found.");
|
||||
}
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
|
||||
if (interruptRes.type === "accept") {
|
||||
const newSandboxSessionId = (await startSandbox(state.sandboxSessionId)).id;
|
||||
|
||||
// Plan was accepted, route to the generate-action node to start taking actions.
|
||||
const planItems = proposedPlan.map((p, index) => ({
|
||||
index,
|
||||
plan: p,
|
||||
completed: false,
|
||||
}));
|
||||
|
||||
const newTaskPlan = createNewTask(userRequest, planItems, state.plan);
|
||||
|
||||
const commandUpdate: GraphUpdate = {
|
||||
plan: newTaskPlan,
|
||||
sandboxSessionId: newSandboxSessionId,
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-action",
|
||||
update: {
|
||||
plan: proposedPlan.map((p, index) => ({
|
||||
index,
|
||||
plan: p,
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
})),
|
||||
sandboxSessionId: newSandboxSessionId,
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -60,27 +68,33 @@ export async function interruptPlan(state: GraphState): Promise<Command> {
|
|||
const editedPlan = (interruptRes.args as ActionRequest).args.plan
|
||||
.split(":::")
|
||||
.map((step: string) => step.trim());
|
||||
|
||||
const planItems = editedPlan.map((p: string, index: number) => ({
|
||||
index,
|
||||
plan: p,
|
||||
completed: false,
|
||||
}));
|
||||
|
||||
const newTaskPlan = createNewTask(userRequest, planItems, state.plan);
|
||||
|
||||
const commandUpdate: GraphUpdate = {
|
||||
plan: newTaskPlan,
|
||||
sandboxSessionId: newSandboxSessionId,
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-action",
|
||||
update: {
|
||||
plan: editedPlan.map((p: string, index: number) => ({
|
||||
index,
|
||||
plan: p,
|
||||
completed: false,
|
||||
summary: undefined,
|
||||
})),
|
||||
sandboxSessionId: newSandboxSessionId,
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (interruptRes.type === "response") {
|
||||
// Plan was responded to, route to the rewrite plan node.
|
||||
const commandUpdate: GraphUpdate = {
|
||||
planChangeRequest: interruptRes.args as string,
|
||||
};
|
||||
return new Command({
|
||||
goto: "rewrite-plan",
|
||||
update: {
|
||||
planChangeRequest: interruptRes.args as string,
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ import { createLogger, LogLevel } from "../utils/logger.js";
|
|||
import { z } from "zod";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js";
|
||||
import { isHumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import { getMessageContentString } from "../utils/message/content.js";
|
||||
import { daytonaClient } from "../utils/sandbox.js";
|
||||
import { getUserRequest } from "../utils/user-request.js";
|
||||
import { ToolMessage } from "@langchain/core/messages";
|
||||
import { daytonaClient, deleteSandbox } from "../utils/sandbox.js";
|
||||
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
|
||||
import { getActivePlanItems } from "../utils/task-plan.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "Open PR");
|
||||
|
||||
|
|
@ -102,20 +103,11 @@ export async function openPullRequest(
|
|||
tool_choice: openPrTool.name,
|
||||
});
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
if (!firstUserMessage) {
|
||||
throw new Error(
|
||||
"Failed to open pull request: No user message found in state.",
|
||||
);
|
||||
}
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
const response = await modelWithTool.invoke([
|
||||
{
|
||||
role: "user",
|
||||
content: formatPrompt(
|
||||
state.plan,
|
||||
getMessageContentString(firstUserMessage.content),
|
||||
),
|
||||
content: formatPrompt(getActivePlanItems(state.plan), userRequest),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -138,6 +130,12 @@ export async function openPullRequest(
|
|||
githubToken,
|
||||
});
|
||||
|
||||
let sandboxDeleted = false;
|
||||
if (pr) {
|
||||
// Delete the sandbox.
|
||||
sandboxDeleted = await deleteSandbox(sandboxSessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [
|
||||
response,
|
||||
|
|
@ -152,5 +150,7 @@ export async function openPullRequest(
|
|||
},
|
||||
}),
|
||||
],
|
||||
// If the sandbox was successfully deleted, we can remove it from the state.
|
||||
...(sandboxDeleted && { sandboxSessionId: undefined }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { z } from "zod";
|
||||
import { createLogger, LogLevel } from "../utils/logger.js";
|
||||
import { GraphConfig, GraphState, PlanItem } from "../types.js";
|
||||
import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import { formatPlanPrompt } from "../utils/plan-prompt.js";
|
||||
import { Command } from "@langchain/langgraph";
|
||||
import { getMessageString } from "../utils/message/content.js";
|
||||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import { removeFirstHumanMessage } from "../utils/message/modify-array.js";
|
||||
import { getUserRequest } from "../utils/user-request.js";
|
||||
import {
|
||||
completePlanItem,
|
||||
getActivePlanItems,
|
||||
getActiveTask,
|
||||
} from "../utils/task-plan.js";
|
||||
import {
|
||||
getCurrentPlanItem,
|
||||
getRemainingPlanItems,
|
||||
} from "../utils/current-task.js";
|
||||
import { ToolMessage } from "@langchain/core/messages";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "ProgressPlanStep");
|
||||
|
||||
|
|
@ -60,8 +70,9 @@ export async function progressPlanStep(
|
|||
tool_choice: setTaskStatusTool.name,
|
||||
});
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages, {
|
||||
returnFullMessage: true,
|
||||
});
|
||||
const conversationHistoryStr = `Here is the full conversation history after the user's request:
|
||||
|
||||
${removeFirstHumanMessage(state.messages).map(getMessageString).join("\n")}
|
||||
|
|
@ -69,12 +80,14 @@ ${removeFirstHumanMessage(state.messages).map(getMessageString).join("\n")}
|
|||
Take all of this information, and determine whether or not you have completed this task in the plan.
|
||||
Once you've determined the status of the current task, call the \`set_task_status\` tool.`;
|
||||
|
||||
const activePlanItems = getActivePlanItems(state.plan);
|
||||
|
||||
const response = await modelWithTools.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: formatPrompt(state.plan),
|
||||
content: formatPrompt(activePlanItems),
|
||||
},
|
||||
...(firstUserMessage ? [firstUserMessage] : []),
|
||||
userRequest,
|
||||
{
|
||||
role: "user",
|
||||
content: conversationHistoryStr,
|
||||
|
|
@ -91,15 +104,14 @@ Once you've determined the status of the current task, call the \`set_task_statu
|
|||
const isCompleted =
|
||||
(toolCall.args as z.infer<typeof setTaskStatusToolSchema>).task_status ===
|
||||
"completed";
|
||||
const currentTask = state.plan.filter((p) => !p.completed)?.[0];
|
||||
const toolMessage = {
|
||||
role: "tool",
|
||||
tool_call_id: toolCall.id,
|
||||
const currentTask = getCurrentPlanItem(activePlanItems);
|
||||
const toolMessage = new ToolMessage({
|
||||
tool_call_id: toolCall.id ?? "",
|
||||
content: `Saved task status as ${
|
||||
toolCall.args.task_status
|
||||
} for task ${currentTask?.plan || "unknown"}`,
|
||||
name: toolCall.name,
|
||||
};
|
||||
});
|
||||
|
||||
if (!isCompleted) {
|
||||
logger.info(
|
||||
|
|
@ -108,21 +120,34 @@ Once you've determined the status of the current task, call the \`set_task_statu
|
|||
reasoning: toolCall.args.reasoning,
|
||||
},
|
||||
);
|
||||
const commandUpdate: GraphUpdate = { messages: [response, toolMessage] };
|
||||
return new Command({
|
||||
goto: "generate-action",
|
||||
update: { messages: [response, toolMessage] },
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// LLM marked as completed, so we need to update the plan to reflect that.
|
||||
const updatedPlanTasks = completePlanItem(
|
||||
state.plan,
|
||||
getActiveTask(state.plan).id,
|
||||
currentTask.index,
|
||||
);
|
||||
|
||||
// This should in theory never happen, but ensure we route properly if it does.
|
||||
const remainingTask = state.plan.find((p) => !p.completed);
|
||||
const remainingTask = getRemainingPlanItems(activePlanItems)?.[0];
|
||||
if (!remainingTask) {
|
||||
logger.info(
|
||||
"Found no remaining tasks in the plan during the check plan step. Continuing to the conclusion generation step.",
|
||||
);
|
||||
const commandUpdate: GraphUpdate = {
|
||||
messages: [response, toolMessage],
|
||||
// Even though there are no remaining tasks, still mark as completed so the UI reflects that the task is completed.
|
||||
plan: updatedPlanTasks,
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-conclusion",
|
||||
update: { messages: [response, toolMessage] },
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -133,19 +158,13 @@ Once you've determined the status of the current task, call the \`set_task_statu
|
|||
},
|
||||
});
|
||||
|
||||
const commandUpdate: GraphUpdate = {
|
||||
messages: [response, toolMessage],
|
||||
plan: updatedPlanTasks,
|
||||
};
|
||||
|
||||
return new Command({
|
||||
goto: "summarize-task-steps",
|
||||
update: {
|
||||
messages: [response, toolMessage],
|
||||
plan: state.plan.map((p) => {
|
||||
if (p.index === remainingTask.index) {
|
||||
return {
|
||||
...p,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}),
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { isAIMessage } from "@langchain/core/messages";
|
||||
import { GraphState } from "../types.js";
|
||||
import { isAIMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import { GraphState, GraphUpdate } from "../types.js";
|
||||
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import { END, interrupt, Command } from "@langchain/langgraph";
|
||||
import { stopSandbox, startSandbox } from "../utils/sandbox.js";
|
||||
|
|
@ -53,18 +53,18 @@ export async function requestHelp(state: GraphState): Promise<Command> {
|
|||
throw new Error("Interrupt response expected to be a string.");
|
||||
}
|
||||
await startSandbox(sandboxSessionId);
|
||||
const commandUpdate: GraphUpdate = {
|
||||
messages: [
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id ?? "",
|
||||
content: `Human response: ${interruptRes.args}`,
|
||||
status: "success",
|
||||
}),
|
||||
],
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-action",
|
||||
update: {
|
||||
messages: [
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: toolCall.id,
|
||||
content: `Human response: ${interruptRes.args}`,
|
||||
status: "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { GraphState, GraphConfig, GraphUpdate, PlanItem } from "../types.js";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import { getMessageContentString } from "../utils/message/content.js";
|
||||
import { z } from "zod";
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { ConfigurableModel } from "langchain/chat_models/universal";
|
||||
import { traceable } from "langsmith/traceable";
|
||||
import { getUserRequest } from "../utils/user-request.js";
|
||||
|
||||
const systemPromptIdentifyChanges = `You are operating as an agentic coding assistant built by LangChain. You've previously been given a task to generate a plan of action for, to address the user's initial request.
|
||||
|
||||
|
|
@ -133,15 +132,12 @@ async function identifyTasksToModifyFunc(
|
|||
},
|
||||
);
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
const response = await modelWithIdentifyChangesTool.invoke([
|
||||
{
|
||||
role: "user",
|
||||
content: formatSysPromptIdentifyTasks(
|
||||
getMessageContentString(
|
||||
firstUserMessage?.content || "No user message found",
|
||||
),
|
||||
userRequest || "No user message found",
|
||||
state.planChangeRequest,
|
||||
state.proposedPlan,
|
||||
),
|
||||
|
|
@ -198,15 +194,12 @@ async function updatePlanTasksFunc(
|
|||
tool_choice: updatePlanTasksTool.name,
|
||||
});
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
const response = await modelWithUpdatePlanTasksTool.invoke([
|
||||
{
|
||||
role: "user",
|
||||
content: formatSysPromptRewritePlan(
|
||||
getMessageContentString(
|
||||
firstUserMessage?.content || "No user message found",
|
||||
),
|
||||
userRequest || "No user message found",
|
||||
state.planChangeRequest,
|
||||
state.proposedPlan,
|
||||
tasksToModify,
|
||||
|
|
@ -243,7 +236,6 @@ export async function rewritePlan(
|
|||
const updatedPlanTasks = await updatePlanTasks(state, tasksToModify, model);
|
||||
|
||||
return {
|
||||
plan: [],
|
||||
proposedPlan: updatedPlanTasks,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { GraphConfig, GraphState, PlanItem } from "../types.js";
|
||||
import { GraphConfig, GraphState, GraphUpdate, PlanItem } from "../types.js";
|
||||
import { loadModel, Task } from "../utils/load-model.js";
|
||||
import { AIMessage, BaseMessage } from "@langchain/core/messages";
|
||||
import { formatPlanPrompt } from "../utils/plan-prompt.js";
|
||||
|
|
@ -12,6 +12,12 @@ import { removeLastTaskMessages } from "../utils/message/modify-array.js";
|
|||
import { Command } from "@langchain/langgraph";
|
||||
import { ConfigurableModel } from "langchain/chat_models/universal";
|
||||
import { traceable } from "langsmith/traceable";
|
||||
import {
|
||||
completePlanItem,
|
||||
getActivePlanItems,
|
||||
getActiveTask,
|
||||
} from "../utils/task-plan.js";
|
||||
import { getCompletedPlanItems } from "../utils/current-task.js";
|
||||
|
||||
const taskSummarySysPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
|
|
@ -150,8 +156,9 @@ const formatUserCodebaseContextMessage = (
|
|||
async function generateTaskSummaryFunc(
|
||||
state: GraphState,
|
||||
model: ConfigurableModel,
|
||||
): Promise<PlanItem[]> {
|
||||
const lastCompletedTask = state.plan.findLast((p) => p.completed);
|
||||
): Promise<{ planItemIndex: number; summary: string }> {
|
||||
const activePlanItems = getActivePlanItems(state.plan);
|
||||
const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop();
|
||||
if (!lastCompletedTask) {
|
||||
throw new Error("Unable to find last completed task.");
|
||||
}
|
||||
|
|
@ -160,26 +167,18 @@ async function generateTaskSummaryFunc(
|
|||
const response = await model.withConfig({ tags: ["nostream"] }).invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: formatPrompt(state.plan, state.codebaseContext),
|
||||
content: formatPrompt(activePlanItems, state.codebaseContext),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: formatUserMessage(state.messages, state.plan),
|
||||
content: formatUserMessage(state.messages, activePlanItems),
|
||||
},
|
||||
]);
|
||||
|
||||
const contentString = getMessageContentString(response.content);
|
||||
const newPlanWithSummary = state.plan.map((p) => {
|
||||
if (p.index !== lastCompletedTask.index) {
|
||||
return p;
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
summary: contentString,
|
||||
};
|
||||
});
|
||||
|
||||
return newPlanWithSummary;
|
||||
return {
|
||||
planItemIndex: lastCompletedTask.index,
|
||||
summary: getMessageContentString(response.content),
|
||||
};
|
||||
}
|
||||
|
||||
const generateTaskSummary = traceable(generateTaskSummaryFunc, {
|
||||
|
|
@ -198,7 +197,10 @@ async function updateCodebaseContextFunc(
|
|||
},
|
||||
{
|
||||
role: "user",
|
||||
content: formatUserCodebaseContextMessage(state.messages, state.plan),
|
||||
content: formatUserCodebaseContextMessage(
|
||||
state.messages,
|
||||
getActivePlanItems(state.plan),
|
||||
),
|
||||
},
|
||||
]);
|
||||
const contentString = getMessageContentString(response.content);
|
||||
|
|
@ -213,16 +215,23 @@ export async function summarizeTaskSteps(
|
|||
state: GraphState,
|
||||
config: GraphConfig,
|
||||
): Promise<Command> {
|
||||
const lastCompletedTask = state.plan.findLast((p) => p.completed);
|
||||
const activePlanItems = getActivePlanItems(state.plan);
|
||||
const lastCompletedTask = getCompletedPlanItems(activePlanItems).pop();
|
||||
if (!lastCompletedTask) {
|
||||
throw new Error("Unable to find last completed task.");
|
||||
}
|
||||
|
||||
const model = await loadModel(config, Task.SUMMARIZER);
|
||||
const [updatedPlan, updatedCodebaseContext] = await Promise.all([
|
||||
const [taskSummary, updatedCodebaseContext] = await Promise.all([
|
||||
generateTaskSummary(state, model),
|
||||
updateCodebaseContext(state, model),
|
||||
]);
|
||||
const updatedTaskPlan = completePlanItem(
|
||||
state.plan,
|
||||
getActiveTask(state.plan).id,
|
||||
taskSummary.planItemIndex,
|
||||
taskSummary.summary,
|
||||
);
|
||||
|
||||
const removedMessages = removeLastTaskMessages(state.messages);
|
||||
logger.info(`Removing ${removedMessages.length} message(s) from state.`);
|
||||
|
|
@ -236,24 +245,26 @@ export async function summarizeTaskSteps(
|
|||
});
|
||||
const newMessagesStateUpdate = [...removedMessages, condensedTaskMessage];
|
||||
|
||||
const allTasksCompleted = state.plan.every((p) => p.completed);
|
||||
const allTasksCompleted = activePlanItems.every((p) => p.completed);
|
||||
if (allTasksCompleted) {
|
||||
const commandUpdate: GraphUpdate = {
|
||||
messages: newMessagesStateUpdate,
|
||||
plan: updatedTaskPlan,
|
||||
codebaseContext: updatedCodebaseContext,
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-conclusion",
|
||||
update: {
|
||||
messages: newMessagesStateUpdate,
|
||||
plan: updatedPlan,
|
||||
codebaseContext: updatedCodebaseContext,
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const commandUpdate: GraphUpdate = {
|
||||
messages: newMessagesStateUpdate,
|
||||
plan: updatedTaskPlan,
|
||||
codebaseContext: updatedCodebaseContext,
|
||||
};
|
||||
return new Command({
|
||||
goto: "generate-action",
|
||||
update: {
|
||||
messages: newMessagesStateUpdate,
|
||||
plan: updatedPlan,
|
||||
codebaseContext: updatedCodebaseContext,
|
||||
},
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
*/
|
||||
|
||||
import { END, START, StateGraph } from "@langchain/langgraph";
|
||||
import { PlannerGraphState, PlannerGraphStateObj } from "./types.js";
|
||||
import {
|
||||
PlannerGraphState,
|
||||
PlannerGraphStateObj,
|
||||
PlannerGraphUpdate,
|
||||
} from "./types.js";
|
||||
import { GraphConfig, GraphConfiguration } from "../../types.js";
|
||||
import {
|
||||
generateAction,
|
||||
|
|
@ -14,7 +18,7 @@ import {
|
|||
summarizer,
|
||||
takeAction,
|
||||
} from "./nodes/index.js";
|
||||
import { isAIMessage } from "@langchain/core/messages";
|
||||
import { isAIMessage, RemoveMessage } from "@langchain/core/messages";
|
||||
|
||||
function takeActionOrGeneratePlan(
|
||||
state: PlannerGraphState,
|
||||
|
|
@ -39,12 +43,24 @@ function takeActionOrGeneratePlan(
|
|||
return "generate-plan";
|
||||
}
|
||||
|
||||
function prepareGraphState(state: PlannerGraphState): PlannerGraphUpdate {
|
||||
if (!state.plannerMessages?.length) return {};
|
||||
// Remove all planning messages if we're starting a new plan
|
||||
return {
|
||||
plannerMessages: state.plannerMessages.map(
|
||||
(m) => new RemoveMessage({ id: m.id ?? "" }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = new StateGraph(PlannerGraphStateObj, GraphConfiguration)
|
||||
.addNode("prepare-graph-state", prepareGraphState)
|
||||
.addNode("generate-plan-context-action", generateAction)
|
||||
.addNode("take-plan-action", takeAction)
|
||||
.addNode("generate-plan", generatePlan)
|
||||
.addNode("summarizer", summarizer)
|
||||
.addEdge(START, "generate-plan-context-action")
|
||||
.addEdge(START, "prepare-graph-state")
|
||||
.addEdge("prepare-graph-state", "generate-plan-context-action")
|
||||
.addConditionalEdges(
|
||||
"generate-plan-context-action",
|
||||
takeActionOrGeneratePlan,
|
||||
|
|
|
|||
|
|
@ -2,26 +2,44 @@ import { loadModel, Task } from "../../../utils/load-model.js";
|
|||
import { shellTool } from "../../../tools/index.js";
|
||||
import { PlannerGraphState, PlannerGraphUpdate } from "../types.js";
|
||||
import { GraphConfig } from "../../../types.js";
|
||||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import { createLogger, LogLevel } from "../../../utils/logger.js";
|
||||
import { getMessageContentString } from "../../../utils/message/content.js";
|
||||
import { getUserRequest } from "../../../utils/user-request.js";
|
||||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import { formatFollowupMessagePrompt } from "../utils/followup-prompt.js";
|
||||
import { getRepoAbsolutePath } from "../../../utils/git.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode");
|
||||
|
||||
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
Your sole task is to gather context from the repository the user has provided which will be helpful when generating a plan to address the user's request.
|
||||
{FOLLOWUP_MESSAGE_PROMPT}
|
||||
|
||||
You MUST adhere to the following criteria when gathering context for the plan:
|
||||
- You must ONLY take read actions to gather context. Write actions are NOT allowed.
|
||||
- Your ONLY job is to gather context for the plan.
|
||||
- You are NOT allowed to take any write/update actions, instead you must only take read actions to gather context.
|
||||
- All write/update actions will be taken in a later step, only after you've gathered all the necessary context.
|
||||
- Keep in mind you are only permitted to make a maximum of 6 tool calls to gather all your context. Ensure each action is of high quality, and targeted to aid in generating a plan.
|
||||
- Always use \`rg\` instead of \`grep/ls -R\` because it is much faster and respects gitignore.
|
||||
- Always use glob patterns when searching with \`rg\` for specific file types. For example, to search for all TSX files, use \`rg -i star -g **/*.tsx project-directory/\`. This is because \`rg\` does not have built in file types for every language.
|
||||
- If you determine you've gathered enough context to generate a plan, simply reply with 'done' and do NOT call any tools.
|
||||
- Not generating a tool call will be interpreted as an indication that you've gathered enough context to generate a plan.
|
||||
- The first user message in this conversation contains the user's request.
|
||||
- The repo is already cloned, and located inside {REPO_DIRECTORY}
|
||||
|
||||
The user's request is the first user message in the conversation below. Ensure you generate your plan in accordance with the user's request.
|
||||
`;
|
||||
|
||||
function formatSystemPrompt(state: PlannerGraphState): string {
|
||||
// It's a followup if there's more than one human message.
|
||||
const isFollowup = state.messages.filter(isHumanMessage).length > 1;
|
||||
|
||||
return systemPrompt
|
||||
.replace(
|
||||
"{FOLLOWUP_MESSAGE_PROMPT}",
|
||||
isFollowup ? formatFollowupMessagePrompt(state.plan) : "",
|
||||
)
|
||||
.replace("{REPO_DIRECTORY}", getRepoAbsolutePath(state.targetRepository));
|
||||
}
|
||||
|
||||
export async function generateAction(
|
||||
state: PlannerGraphState,
|
||||
config: GraphConfig,
|
||||
|
|
@ -30,27 +48,28 @@ export async function generateAction(
|
|||
const tools = [shellTool];
|
||||
const modelWithTools = model.bindTools(tools, { tool_choice: "auto" });
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages, {
|
||||
returnFullMessage: true,
|
||||
});
|
||||
const response = await modelWithTools
|
||||
.withConfig({ tags: ["nostream"] })
|
||||
.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
content: formatSystemPrompt(state),
|
||||
},
|
||||
...(firstUserMessage ? [firstUserMessage] : []),
|
||||
userRequest,
|
||||
...state.plannerMessages,
|
||||
]);
|
||||
|
||||
logger.info("Generated planning message", {
|
||||
...(getMessageContentString(response.content) && {
|
||||
content: getMessageContentString(response.content),
|
||||
}),
|
||||
...(response.tool_calls?.[0] && {
|
||||
name: response.tool_calls?.[0].name,
|
||||
args: response.tool_calls?.[0].args,
|
||||
}),
|
||||
...(getMessageContentString(response.content) && {
|
||||
content: getMessageContentString(response.content),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import { sessionPlanTool } from "../../../tools/index.js";
|
|||
import { GraphConfig } from "../../../types.js";
|
||||
import { loadModel, Task } from "../../../utils/load-model.js";
|
||||
import { PlannerGraphState, PlannerGraphUpdate } from "../types.js";
|
||||
import { getUserRequest } from "../../../utils/user-request.js";
|
||||
import { formatFollowupMessagePrompt } from "../utils/followup-prompt.js";
|
||||
import { stopSandbox } from "../../../utils/sandbox.js";
|
||||
|
||||
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
{FOLLOWUP_MESSAGE_PROMPT}
|
||||
|
||||
In this step, you are expected to generate a high-level plan to address the user's request. The plan should be a list of actions to take, in order, to address the user's request. You should not include any code in the plan, only a list of actions to take.
|
||||
|
||||
|
|
@ -21,13 +24,29 @@ You MUST adhere to the following criteria when generating the plan:
|
|||
- Your goal is to complete the task outlined by the user in the least number of steps possible.
|
||||
- Do not pack multiple complex tasks into a single plan item. Each high level task you'll need to complete should have its own plan item.
|
||||
- When you are ready to generate the plan, ensure you call the 'session_plan' tool. You are REQUIRED to call this tool.
|
||||
- The first user message in this conversation contains the user's request.
|
||||
- Your plan should be as simple as possible, while still containing all the tasks required to complete the user's request.
|
||||
- If the user did not explicitly request you write tests, do not include a task to write tests.
|
||||
- If the user did not explicitly request you write documentation, do not include a task to do so.
|
||||
- You should aim to complete the user's request in the least number of steps possible.
|
||||
|
||||
|
||||
The user's request is as follows. Ensure you generate your plan in accordance with the user's request.
|
||||
{USER_REQUEST}
|
||||
`;
|
||||
|
||||
function formatSystemPrompt(state: PlannerGraphState): string {
|
||||
// It's a followup if there's more than one human message.
|
||||
const isFollowup = state.messages.filter(isHumanMessage).length > 1;
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
|
||||
return systemPrompt
|
||||
.replace(
|
||||
"{FOLLOWUP_MESSAGE_PROMPT}",
|
||||
isFollowup ? formatFollowupMessagePrompt(state.plan) : "",
|
||||
)
|
||||
.replace("{USER_REQUEST}", userRequest);
|
||||
}
|
||||
|
||||
export async function generatePlan(
|
||||
state: PlannerGraphState,
|
||||
config: GraphConfig,
|
||||
|
|
@ -37,8 +56,6 @@ export async function generatePlan(
|
|||
tool_choice: sessionPlanTool.name,
|
||||
});
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
let optionalToolMessage: ToolMessage | undefined;
|
||||
const lastMessage = state.plannerMessages[state.plannerMessages.length - 1];
|
||||
if (isAIMessage(lastMessage) && lastMessage.tool_calls?.[0]) {
|
||||
|
|
@ -55,9 +72,8 @@ export async function generatePlan(
|
|||
.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
content: formatSystemPrompt(state),
|
||||
},
|
||||
...(firstUserMessage ? [firstUserMessage] : []),
|
||||
...state.plannerMessages,
|
||||
...(optionalToolMessage ? [optionalToolMessage] : []),
|
||||
]);
|
||||
|
|
@ -74,7 +90,6 @@ export async function generatePlan(
|
|||
|
||||
return {
|
||||
proposedPlan: response.tool_calls[0].args.plan,
|
||||
plan: [],
|
||||
...(newSessionId && { sandboxSessionId: newSessionId }),
|
||||
// Do this so that the planner state is up to date with the tool call.
|
||||
...(optionalToolMessage && { plannerMessages: [optionalToolMessage] }),
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@ import { z } from "zod";
|
|||
import { GraphConfig } from "../../../types.js";
|
||||
import { PlannerGraphState, PlannerGraphUpdate } from "../types.js";
|
||||
import { loadModel, Task } from "../../../utils/load-model.js";
|
||||
import { isHumanMessage } from "@langchain/core/messages";
|
||||
import {
|
||||
getMessageContentString,
|
||||
getMessageString,
|
||||
} from "../../../utils/message/content.js";
|
||||
import { getMessageString } from "../../../utils/message/content.js";
|
||||
import { getUserRequest } from "../../../utils/user-request.js";
|
||||
|
||||
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
|
|
@ -22,7 +19,7 @@ You MUST adhere to the following criteria when summarizing the conversation hist
|
|||
- Do not retain any full file contents.
|
||||
- Ensure your summary is concise, but useful for future context.
|
||||
|
||||
Here is the user's initial request
|
||||
Here is the user's request
|
||||
## User request:
|
||||
{USER_REQUEST}
|
||||
|
||||
|
|
@ -53,8 +50,7 @@ export async function summarizer(
|
|||
tool_choice: condenseContextTool.name,
|
||||
});
|
||||
|
||||
const firstUserMessage = state.messages.find(isHumanMessage);
|
||||
|
||||
const userRequest = getUserRequest(state.messages);
|
||||
const conversationHistoryStr = `Here is the full conversation history:
|
||||
|
||||
${state.plannerMessages.map(getMessageString).join("\n")}`;
|
||||
|
|
@ -62,11 +58,7 @@ ${state.plannerMessages.map(getMessageString).join("\n")}`;
|
|||
const response = await modelWithTools.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: formatPrompt(
|
||||
getMessageContentString(
|
||||
firstUserMessage?.content || "No user request provided.",
|
||||
),
|
||||
),
|
||||
content: formatPrompt(userRequest || "No user request provided."),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
|
|
|
|||
35
apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts
Normal file
35
apps/open-swe/src/subgraphs/planner/utils/followup-prompt.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { TaskPlan } from "../../../types.js";
|
||||
|
||||
const followupMessagePrompt = `
|
||||
The user is sending a followup request for you to generate a plan for. You are provided with the following context to aid in your new plan context gathering steps:
|
||||
- The previous user requests, along with the tasks, and task summaries you generated for these previous requests.
|
||||
- You are only provided this information as context to reference when gathering context for the new plan.
|
||||
|
||||
Here is the complete list of previous requests made by the user, along with the tasks you generated to complete these requests, and the task summaries of each task you completed previously:
|
||||
{PREVIOUS_PLAN}
|
||||
`;
|
||||
|
||||
const formatPreviousPlans = (tasks: TaskPlan): string => {
|
||||
return tasks.tasks
|
||||
.map((task) => {
|
||||
const activePlanItems =
|
||||
task.planRevisions[task.activeRevisionIndex].plans;
|
||||
|
||||
return `<previous-task index="${task.taskIndex}">
|
||||
User request: ${task.request}
|
||||
|
||||
Overall task summary:\n</task-summary>\n${task.summary || "No overall task summary found"}\n</task-summary>
|
||||
|
||||
Individual tasks you generated to complete this request:
|
||||
${activePlanItems.map((planItem) => `<plan-item index="${planItem.index}">${planItem.plan}</plan-item>`).join("\n")}
|
||||
</previous-task>`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
export function formatFollowupMessagePrompt(tasks: TaskPlan): string {
|
||||
return followupMessagePrompt.replace(
|
||||
"{PREVIOUS_PLAN}",
|
||||
formatPreviousPlans(tasks),
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,81 @@ export type PlanItem = {
|
|||
summary?: string;
|
||||
};
|
||||
|
||||
export type PlanRevision = {
|
||||
/**
|
||||
* The revision index of the plan.
|
||||
* This is used to track edits made to the plan by the agent or user
|
||||
*/
|
||||
revisionIndex: number;
|
||||
/**
|
||||
* The plans for this task & revision.
|
||||
*/
|
||||
plans: PlanItem[];
|
||||
/**
|
||||
* Timestamp when this revision was created
|
||||
*/
|
||||
createdAt: number;
|
||||
/**
|
||||
* Who created this revision (agent or user)
|
||||
*/
|
||||
createdBy: "agent" | "user";
|
||||
};
|
||||
|
||||
export type Task = {
|
||||
/**
|
||||
* Unique identifier for the task
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The index of the user's task in chronological order
|
||||
*/
|
||||
taskIndex: number;
|
||||
/**
|
||||
* The original user request that created this task
|
||||
*/
|
||||
request: string;
|
||||
/**
|
||||
* When the task was created
|
||||
*/
|
||||
createdAt: number;
|
||||
/**
|
||||
* Whether the task is completed
|
||||
*/
|
||||
completed: boolean;
|
||||
/**
|
||||
* When the task was completed (if applicable)
|
||||
*/
|
||||
completedAt?: number;
|
||||
/**
|
||||
* Overall summary of the completed task
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* The plans generated for this task.
|
||||
* Ordered by revisionIndex, with the latest revision being the active one
|
||||
*/
|
||||
planRevisions: PlanRevision[];
|
||||
/**
|
||||
* Index of the currently active plan revision
|
||||
*/
|
||||
activeRevisionIndex: number;
|
||||
/**
|
||||
* Optional parent task id if this task was derived from another task
|
||||
*/
|
||||
parentTaskId?: string;
|
||||
};
|
||||
|
||||
export type TaskPlan = {
|
||||
/**
|
||||
* All tasks in the system
|
||||
*/
|
||||
tasks: Task[];
|
||||
/**
|
||||
* Index of the currently active task
|
||||
*/
|
||||
activeTaskIndex: number;
|
||||
};
|
||||
|
||||
export type TargetRepository = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
|
|
@ -43,9 +118,8 @@ export const GraphAnnotation = z.object({
|
|||
.default(() => [])
|
||||
.langgraph.reducer((_state, update) => update),
|
||||
plan: z
|
||||
.custom<PlanItem[]>()
|
||||
.default(() => [])
|
||||
.langgraph.reducer<PlanItem[]>((_state, update) => update),
|
||||
.custom<TaskPlan>()
|
||||
.langgraph.reducer<TaskPlan>((_state, update) => update),
|
||||
planChangeRequest: z
|
||||
.string()
|
||||
.nullable()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PlanItem } from "../types.js";
|
||||
|
||||
export function getCurrentTask(plan: PlanItem[]) {
|
||||
export function getCurrentPlanItem(plan: PlanItem[]): PlanItem {
|
||||
return (
|
||||
plan.filter((p) => !p.completed).sort((a, b) => a.index - b.index)?.[0] || {
|
||||
plan: "No current task found.",
|
||||
|
|
@ -10,3 +10,32 @@ export function getCurrentTask(plan: PlanItem[]) {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the completed plan items for the given plan.
|
||||
* @param plan The list of plan items to get the completed plan items for.
|
||||
* @returns The list of completed plan items.
|
||||
*/
|
||||
export function getCompletedPlanItems(plan: PlanItem[]): PlanItem[] {
|
||||
return plan.filter((p) => p.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the remaining plan items for the given plan.
|
||||
* @param plan The list of plan items to get the remaining plan items for.
|
||||
* @param includeCurrentPlanItem Whether to include the current plan item in the remaining plan items.
|
||||
* Defaults to false.
|
||||
* @returns The list of remaining plan items.
|
||||
*/
|
||||
export function getRemainingPlanItems(
|
||||
plan: PlanItem[],
|
||||
includeCurrentPlanItem = false,
|
||||
): PlanItem[] {
|
||||
return plan
|
||||
.filter(
|
||||
(p) =>
|
||||
!p.completed &&
|
||||
(includeCurrentPlanItem || p.index !== getCurrentPlanItem(plan).index),
|
||||
)
|
||||
?.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,6 +451,42 @@ export async function checkoutBranchAndCommit(
|
|||
return branchName;
|
||||
}
|
||||
|
||||
async function getExistingPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
githubToken: string,
|
||||
) {
|
||||
try {
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
});
|
||||
|
||||
const { data: pullRequests } = await octokit.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
head: branchName,
|
||||
});
|
||||
|
||||
if (pullRequests?.[0]) {
|
||||
return pullRequests[0];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get existing pull request`, {
|
||||
branch: branchName,
|
||||
owner,
|
||||
repo,
|
||||
...(e instanceof Error && {
|
||||
name: e.name,
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createPullRequest({
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -495,6 +531,13 @@ export async function createPullRequest({
|
|||
logger.info(`🐙 Pull request created: ${pullRequest.html_url}`);
|
||||
return pullRequest;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
logger.info(
|
||||
"Pull request already exists. Getting existing pull request...",
|
||||
);
|
||||
return getExistingPullRequest(owner, repo, headBranch, githubToken);
|
||||
}
|
||||
|
||||
logger.error(`Failed to create pull request`, {
|
||||
error,
|
||||
});
|
||||
|
|
@ -533,6 +576,7 @@ export async function cloneRepo(
|
|||
targetRepository: TargetRepository,
|
||||
args: {
|
||||
githubToken: string;
|
||||
stateBranchName?: string;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
|
|
@ -541,8 +585,9 @@ export async function cloneRepo(
|
|||
// Use x-access-token format for better GitHub authentication
|
||||
const repoUrlWithToken = `https://x-access-token:${args.githubToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
|
||||
|
||||
if (targetRepository.branch) {
|
||||
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
|
||||
const branchName = args.stateBranchName || targetRepository.branch;
|
||||
if (branchName) {
|
||||
gitCloneCommand.push("-b", branchName, repoUrlWithToken);
|
||||
} else {
|
||||
gitCloneCommand.push(repoUrlWithToken);
|
||||
}
|
||||
|
|
@ -550,7 +595,7 @@ export async function cloneRepo(
|
|||
logger.info("Cloning repository", {
|
||||
// Don't log the full command with token for security reasons
|
||||
repoPath: `${targetRepository.owner}/${targetRepository.repo}`,
|
||||
branch: targetRepository.branch || "default",
|
||||
branch: branchName,
|
||||
});
|
||||
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export function formatPlanPromptWithSummaries(plan: PlanItem[]): string {
|
|||
return plan
|
||||
.map(
|
||||
(p) =>
|
||||
`<task index="${p.index}">\n${p.plan}\n <task-summary>\n${p.summary || "No task summary found"}\n </task-summary>\n</task>`,
|
||||
`<${p.completed ? "completed-" : ""}task index="${p.index}">\n${p.plan}\n <task-summary>\n${p.summary || "No task summary found"}\n </task-summary>\n</${p.completed ? "completed-" : ""}task>`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { Daytona, Sandbox, SandboxState } from "@daytonaio/sdk";
|
||||
import { createLogger, LogLevel } from "./logger.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "Sandbox");
|
||||
|
||||
// Singleton instance of Daytona
|
||||
let daytonaInstance: Daytona | null = null;
|
||||
|
|
@ -50,3 +53,24 @@ export async function startSandbox(sandboxSessionId: string): Promise<Sandbox> {
|
|||
}
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the sandbox.
|
||||
* @param sandboxSessionId The ID of the sandbox to delete.
|
||||
* @returns True if the sandbox was deleted, false if it failed to delete.
|
||||
*/
|
||||
export async function deleteSandbox(
|
||||
sandboxSessionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sandbox = await daytonaClient().get(sandboxSessionId);
|
||||
await daytonaClient().delete(sandbox);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete sandbox", {
|
||||
sandboxSessionId,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
274
apps/open-swe/src/utils/task-plan.ts
Normal file
274
apps/open-swe/src/utils/task-plan.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PlanItem, Task, TaskPlan, PlanRevision } from "../types.js";
|
||||
|
||||
/**
|
||||
* Creates a new task with the provided plan items.
|
||||
* Can either add to an existing TaskPlan or create a brand new one.
|
||||
*
|
||||
* @param request The original user request text that initiated this task
|
||||
* @param planItems The plan items to include in the new task
|
||||
* @param existingTaskPlan Optional existing TaskPlan to add the new task to
|
||||
* @param parentTaskId Optional ID of a parent task if this task is derived from another
|
||||
* @returns The updated TaskPlan with the new task added
|
||||
*/
|
||||
export function createNewTask(
|
||||
request: string,
|
||||
planItems: PlanItem[],
|
||||
existingTaskPlan?: TaskPlan,
|
||||
parentTaskId?: string,
|
||||
): TaskPlan {
|
||||
// Create the initial plan revision
|
||||
const initialRevision: PlanRevision = {
|
||||
revisionIndex: 0,
|
||||
plans: planItems,
|
||||
createdAt: Date.now(),
|
||||
createdBy: "agent",
|
||||
};
|
||||
|
||||
// Create the new task
|
||||
const newTask: Task = {
|
||||
id: uuidv4(),
|
||||
taskIndex: existingTaskPlan ? existingTaskPlan.tasks.length : 0,
|
||||
request,
|
||||
createdAt: Date.now(),
|
||||
completed: false,
|
||||
planRevisions: [initialRevision],
|
||||
activeRevisionIndex: 0,
|
||||
parentTaskId,
|
||||
};
|
||||
|
||||
// If there's an existing task plan, add the new task to it
|
||||
if (existingTaskPlan) {
|
||||
return {
|
||||
tasks: [...existingTaskPlan.tasks, newTask],
|
||||
activeTaskIndex: existingTaskPlan.tasks.length, // Set the new task as active
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise create a new task plan with just this task
|
||||
return {
|
||||
tasks: [newTask],
|
||||
activeTaskIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the plan items for an existing task by creating a new revision.
|
||||
*
|
||||
* @param taskPlan The current task plan
|
||||
* @param taskId The ID of the task to update
|
||||
* @param planItems The new plan items
|
||||
* @param createdBy Who created this revision ('agent' or 'user')
|
||||
* @returns The updated TaskPlan with the new revision
|
||||
* @throws Error if the task ID doesn't exist
|
||||
*/
|
||||
export function updateTaskPlanItems(
|
||||
taskPlan: TaskPlan,
|
||||
taskId: string,
|
||||
planItems: PlanItem[],
|
||||
createdBy: "agent" | "user" = "agent",
|
||||
): TaskPlan {
|
||||
// Find the task to update
|
||||
const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId);
|
||||
|
||||
if (taskIndex === -1) {
|
||||
throw new Error(`Task with ID ${taskId} not found`);
|
||||
}
|
||||
|
||||
const task = taskPlan.tasks[taskIndex];
|
||||
|
||||
// Create a new revision with the updated plan items
|
||||
const newRevision: PlanRevision = {
|
||||
revisionIndex: task.planRevisions.length,
|
||||
plans: planItems,
|
||||
createdAt: Date.now(),
|
||||
createdBy,
|
||||
};
|
||||
|
||||
// Create an updated task with the new revision
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
planRevisions: [...task.planRevisions, newRevision],
|
||||
activeRevisionIndex: task.planRevisions.length, // Set the new revision as active
|
||||
};
|
||||
|
||||
// Create a new array of tasks with the updated task
|
||||
const updatedTasks = [...taskPlan.tasks];
|
||||
updatedTasks[taskIndex] = updatedTask;
|
||||
|
||||
// Return the updated task plan
|
||||
return {
|
||||
...taskPlan,
|
||||
tasks: updatedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the active task from a TaskPlan
|
||||
*
|
||||
* @param taskPlan The task plan
|
||||
* @returns The currently active task
|
||||
* @throws Error if there are no tasks
|
||||
*/
|
||||
export function getActiveTask(taskPlan: TaskPlan): Task {
|
||||
if (taskPlan.tasks.length === 0) {
|
||||
throw new Error("No tasks available");
|
||||
}
|
||||
|
||||
return taskPlan.tasks[taskPlan.activeTaskIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the active plan items for the active task
|
||||
*
|
||||
* @param taskPlan The task plan
|
||||
* @returns The currently active plan items
|
||||
* @throws Error if there are no tasks or no plan revisions
|
||||
*/
|
||||
export function getActivePlanItems(taskPlan: TaskPlan): PlanItem[] {
|
||||
const activeTask = getActiveTask(taskPlan);
|
||||
|
||||
if (activeTask.planRevisions.length === 0) {
|
||||
throw new Error("No plan revisions available for the active task");
|
||||
}
|
||||
|
||||
return activeTask.planRevisions[activeTask.activeRevisionIndex].plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a specific plan item as completed and adds a summary.
|
||||
* This operation modifies the current active revision directly and does NOT create a new revision.
|
||||
*
|
||||
* @param taskPlan The current task plan
|
||||
* @param taskId The ID of the task containing the plan item
|
||||
* @param planItemIndex The `index` property of the plan item to mark as completed
|
||||
* @param summary Optional summary of the completed plan item. If undefined, existing summary is preserved.
|
||||
* @returns The updated TaskPlan
|
||||
* @throws Error if the task or plan item is not found, or if no active revision exists.
|
||||
*/
|
||||
export function completePlanItem(
|
||||
taskPlan: TaskPlan,
|
||||
taskId: string,
|
||||
planItemIndex: number,
|
||||
summary?: string,
|
||||
): TaskPlan {
|
||||
const taskIndexInPlan = taskPlan.tasks.findIndex(
|
||||
(task) => task.id === taskId,
|
||||
);
|
||||
|
||||
if (taskIndexInPlan === -1) {
|
||||
throw new Error(`Task with ID ${taskId} not found in task plan`);
|
||||
}
|
||||
|
||||
const originalTask = taskPlan.tasks[taskIndexInPlan];
|
||||
|
||||
const activeRevisionIndex = originalTask.activeRevisionIndex;
|
||||
|
||||
// Ensure there's a planRevisions array and the activeRevisionIndex is valid
|
||||
if (
|
||||
!originalTask.planRevisions ||
|
||||
activeRevisionIndex < 0 ||
|
||||
activeRevisionIndex >= originalTask.planRevisions.length
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid active revision index (${activeRevisionIndex}) for task ${taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const originalActiveRevision =
|
||||
originalTask.planRevisions[activeRevisionIndex];
|
||||
|
||||
// This check should be covered by the index check, but reinforces intent
|
||||
if (!originalActiveRevision) {
|
||||
// This case implies an issue with activeRevisionIndex or planRevisions structure
|
||||
throw new Error(
|
||||
`Active revision (index ${activeRevisionIndex}) not found for task ${taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const planItemToUpdateActualIndexInPlansArray =
|
||||
originalActiveRevision.plans.findIndex(
|
||||
(item) => item.index === planItemIndex,
|
||||
);
|
||||
|
||||
if (planItemToUpdateActualIndexInPlansArray === -1) {
|
||||
throw new Error(
|
||||
`Plan item with .index ${planItemIndex} not found in active revision (index ${activeRevisionIndex}) of task ${taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new 'plans' array with the specific item updated
|
||||
const updatedPlansForRevision = originalActiveRevision.plans.map((item) => {
|
||||
if (item.index === planItemIndex) {
|
||||
const newSummary = summary !== undefined ? summary : item.summary;
|
||||
return { ...item, completed: true, summary: newSummary };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
// Create a new 'PlanRevision' object for the active revision, with the updated 'plans'
|
||||
const updatedActiveRevision: PlanRevision = {
|
||||
...originalActiveRevision,
|
||||
plans: updatedPlansForRevision,
|
||||
};
|
||||
|
||||
// Create a new 'planRevisions' array, replacing the active revision with the updated one
|
||||
const updatedPlanRevisions = [...originalTask.planRevisions];
|
||||
updatedPlanRevisions[activeRevisionIndex] = updatedActiveRevision;
|
||||
|
||||
// Create a new 'Task' object with the updated 'planRevisions'
|
||||
const updatedTask: Task = {
|
||||
...originalTask,
|
||||
planRevisions: updatedPlanRevisions,
|
||||
};
|
||||
|
||||
// Create a new 'tasks' array for the TaskPlan, replacing the updated task
|
||||
const updatedTasksArray = [...taskPlan.tasks];
|
||||
updatedTasksArray[taskIndexInPlan] = updatedTask;
|
||||
|
||||
// Return the new TaskPlan object
|
||||
return {
|
||||
...taskPlan,
|
||||
tasks: updatedTasksArray,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task as completed
|
||||
*
|
||||
* @param taskPlan The current task plan
|
||||
* @param taskId The ID of the task to mark as completed
|
||||
* @param summary Optional summary of the completed task
|
||||
* @returns The updated TaskPlan
|
||||
*/
|
||||
export function completeTask(
|
||||
taskPlan: TaskPlan,
|
||||
taskId: string,
|
||||
summary?: string,
|
||||
): TaskPlan {
|
||||
const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId);
|
||||
|
||||
if (taskIndex === -1) {
|
||||
throw new Error(`Task with ID ${taskId} not found`);
|
||||
}
|
||||
|
||||
const task = taskPlan.tasks[taskIndex];
|
||||
|
||||
// Create an updated task marked as completed
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
completed: true,
|
||||
completedAt: Date.now(),
|
||||
summary,
|
||||
};
|
||||
|
||||
// Create a new array of tasks with the updated task
|
||||
const updatedTasks = [...taskPlan.tasks];
|
||||
updatedTasks[taskIndex] = updatedTask;
|
||||
|
||||
// Return the updated task plan
|
||||
return {
|
||||
...taskPlan,
|
||||
tasks: updatedTasks,
|
||||
};
|
||||
}
|
||||
27
apps/open-swe/src/utils/user-request.ts
Normal file
27
apps/open-swe/src/utils/user-request.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
BaseMessage,
|
||||
isHumanMessage,
|
||||
HumanMessage,
|
||||
} from "@langchain/core/messages";
|
||||
import { getMessageContentString } from "./message/content.js";
|
||||
|
||||
export function getUserRequest(
|
||||
messages: BaseMessage[],
|
||||
options?: { returnFullMessage?: never | false },
|
||||
): string;
|
||||
export function getUserRequest(
|
||||
messages: BaseMessage[],
|
||||
options?: { returnFullMessage?: true },
|
||||
): HumanMessage;
|
||||
export function getUserRequest(
|
||||
messages: BaseMessage[],
|
||||
options?: { returnFullMessage?: boolean },
|
||||
): string | HumanMessage {
|
||||
const recentUserMessage = messages.findLast(isHumanMessage);
|
||||
if (!recentUserMessage) {
|
||||
return "";
|
||||
}
|
||||
return options?.returnFullMessage
|
||||
? recentUserMessage
|
||||
: getMessageContentString(recentUserMessage.content);
|
||||
}
|
||||
|
|
@ -101,7 +101,6 @@ export function Thread() {
|
|||
handleFileUpload,
|
||||
dropRef,
|
||||
removeBlock,
|
||||
resetBlocks,
|
||||
dragOver,
|
||||
handlePaste,
|
||||
} = useFileUpload();
|
||||
|
|
@ -234,6 +233,7 @@ export function Thread() {
|
|||
const hasNoAIOrToolMessages = !messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
const isLastMessageHuman = messages[messages.length - 1]?.type === "human";
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
|
|
@ -399,14 +399,16 @@ export function Thread() {
|
|||
)}
|
||||
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
|
||||
We need to render it outside of the messages list, since there are no messages to render */}
|
||||
{hasNoAIOrToolMessages && !!stream.interrupt && (
|
||||
<AssistantMessage
|
||||
key="interrupt-msg"
|
||||
message={undefined}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
{(hasNoAIOrToolMessages || isLastMessageHuman) &&
|
||||
!!stream.interrupt && (
|
||||
<AssistantMessage
|
||||
key="interrupt-msg"
|
||||
message={undefined}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
forceRenderInterrupt={true}
|
||||
/>
|
||||
)}
|
||||
{isLoading && !firstTokenReceived && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@ import { cn } from "@/lib/utils";
|
|||
import { ToolCalls, ToolResult } from "./tool-calls";
|
||||
import { MessageContentComplex } from "@langchain/core/messages";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { GenericInterruptView } from "./generic-interrupt";
|
||||
import { useArtifact } from "../artifact";
|
||||
import { Interrupt } from "./interrupt";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
|
|
@ -67,40 +65,16 @@ function parseAnthropicStreamedToolCalls(
|
|||
});
|
||||
}
|
||||
|
||||
interface InterruptProps {
|
||||
interruptValue?: unknown;
|
||||
isLastMessage: boolean;
|
||||
hasNoAIOrToolMessages: boolean;
|
||||
}
|
||||
|
||||
function Interrupt({
|
||||
interruptValue,
|
||||
isLastMessage,
|
||||
hasNoAIOrToolMessages,
|
||||
}: InterruptProps) {
|
||||
return (
|
||||
<>
|
||||
{isAgentInboxInterruptSchema(interruptValue) &&
|
||||
(isLastMessage || hasNoAIOrToolMessages) && (
|
||||
<ThreadView interrupt={interruptValue} />
|
||||
)}
|
||||
{interruptValue &&
|
||||
!isAgentInboxInterruptSchema(interruptValue) &&
|
||||
isLastMessage ? (
|
||||
<GenericInterruptView interrupt={interruptValue} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
message,
|
||||
isLoading,
|
||||
handleRegenerate,
|
||||
forceRenderInterrupt = false,
|
||||
}: {
|
||||
message: Message | undefined;
|
||||
isLoading: boolean;
|
||||
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
|
||||
forceRenderInterrupt?: boolean;
|
||||
}) {
|
||||
const content = message?.content ?? [];
|
||||
const contentString = getContentString(content);
|
||||
|
|
@ -140,6 +114,8 @@ export function AssistantMessage({
|
|||
return null;
|
||||
}
|
||||
|
||||
console.log("returning");
|
||||
|
||||
return (
|
||||
<div className="group mr-auto flex w-full max-w-3xl items-start gap-2">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
|
|
@ -150,6 +126,7 @@ export function AssistantMessage({
|
|||
interruptValue={threadInterrupt?.value}
|
||||
isLastMessage={isLastMessage}
|
||||
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
|
||||
forceRenderInterrupt={forceRenderInterrupt}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -184,6 +161,7 @@ export function AssistantMessage({
|
|||
interruptValue={threadInterrupt?.value}
|
||||
isLastMessage={isLastMessage}
|
||||
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
|
||||
forceRenderInterrupt={forceRenderInterrupt}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
31
apps/web/src/components/thread/messages/interrupt.tsx
Normal file
31
apps/web/src/components/thread/messages/interrupt.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
import { GenericInterruptView } from "./generic-interrupt";
|
||||
|
||||
interface InterruptProps {
|
||||
interruptValue?: unknown;
|
||||
isLastMessage: boolean;
|
||||
hasNoAIOrToolMessages: boolean;
|
||||
forceRenderInterrupt?: boolean;
|
||||
}
|
||||
|
||||
export function Interrupt({
|
||||
interruptValue,
|
||||
isLastMessage,
|
||||
hasNoAIOrToolMessages,
|
||||
forceRenderInterrupt,
|
||||
}: InterruptProps) {
|
||||
return (
|
||||
<>
|
||||
{isAgentInboxInterruptSchema(interruptValue) &&
|
||||
(isLastMessage || hasNoAIOrToolMessages || forceRenderInterrupt) && (
|
||||
<ThreadView interrupt={interruptValue} />
|
||||
)}
|
||||
{interruptValue &&
|
||||
!isAgentInboxInterruptSchema(interruptValue) &&
|
||||
(isLastMessage || forceRenderInterrupt) ? (
|
||||
<GenericInterruptView interrupt={interruptValue} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue