From 01a514b1fa0623e4ea2b850e80d26c2a6ee6a4c3 Mon Sep 17 00:00:00 2001 From: Palash Shah <35114859+Palashio@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:37:13 -0400 Subject: [PATCH] feat: add updates to cli application (#503) * open swe cli: initial cli updates * openswe cli: add working authentication * open swe cli: remove unecessary console log statements * openswe cli: typescript modifications * openswe cli: updates to formats * openswe cli: updates to formatting * openswe cli: ran prettier * openswe cli: update urls * openswe cli: update eslint * openswe cli: add env example * openswe cli: add updates * openswe cli: formatting * openswe cli: add app installation * openswe cli: clean up lint and formatting * temp logs * l * working human feedback prompting * stop the re-renders * NOT WORKING -- KICKOFF PROGRAMMER * approve programmer * detected programmer working * some sembleance of logging * some changes * working logs * concatenated tool results * ui improvements * update ui * formatting * simple interrupt working * working multiple sessions + padding * working dissapearance of the approve/deny * TEST PUSH * cr * generally working tool calls * access thread ip properly * fix naming of env example * add readme * formatting * linting + formatting * dist updates * updates to yarn * no tests for cli * updates to readme * format * update readme * remove auth print out * add loading indicators * formatting changes * remove shared workspace * add lock * add dependency * update yarn lock * add back the installation token * replace with variable constants * Update apps/cli/src/auth-server.ts Co-authored-by: Brace Sproul * openswe cli: updates * openswe cli: cleanup lint * openswe cli: update stream mode * openswe cli: update stream mode * openswe cli: streaming service * openswe cli: create selector for approval * openswe cli: remove streaming service * openswe cli: update types to langchain * openswe cli: update all streamModes * openswe cli: update formatting * openswe cli: update stream mode * openswe cli: notify of in development * openswe cli: change to istoolmessage --------- Co-authored-by: bracesproul --- apps/cli/.env.example | 12 +- apps/cli/README.md | 30 + apps/cli/package.json | 13 +- apps/cli/src/TerminalInterface.tsx | 9 +- apps/cli/src/auth-server.ts | 188 ++++-- apps/cli/src/index.tsx | 598 ++++++++++++++++-- apps/cli/src/logger.ts | 294 +++++++++ apps/cli/src/streaming.ts | 237 +++++++ apps/cli/src/utils.ts | 121 ++++ apps/cli/tsconfig.json | 1 + .../manager/nodes/classify-message/index.ts | 8 +- .../manager/nodes/create-new-session.ts | 8 +- .../src/graphs/manager/nodes/start-planner.ts | 8 +- .../src/graphs/planner/nodes/proposed-plan.ts | 4 +- .../src/routes/github/issue-webhook.ts | 4 +- apps/web/src/components/v2/terminal-input.tsx | 4 +- packages/shared/src/agent-inbox-interrupt.ts | 19 + packages/shared/src/constants.ts | 8 + yarn.lock | 15 +- 19 files changed, 1441 insertions(+), 140 deletions(-) create mode 100644 apps/cli/README.md create mode 100644 apps/cli/src/logger.ts create mode 100644 apps/cli/src/streaming.ts create mode 100644 apps/cli/src/utils.ts create mode 100644 packages/shared/src/agent-inbox-interrupt.ts diff --git a/apps/cli/.env.example b/apps/cli/.env.example index bc5d4e89..3ec75640 100644 --- a/apps/cli/.env.example +++ b/apps/cli/.env.example @@ -1,12 +1,14 @@ -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" -GITHUB_CALLBACK_URL="" -GITHUB_LOGIN_URL="" -GITHUB_ACCESS_TOKEN_LOGIN_LINK= +GITHUB_APP_CLIENT_ID="" +GITHUB_APP_CLIENT_SECRET="" GITHUB_APP_NAME="open-swe-dev" GITHUB_APP_ID="" GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- ...add your private key here... -----END RSA PRIVATE KEY----- " + +GITHUB_CALLBACK_URL="" +GITHUB_LOGIN_URL="" +GITHUB_ACCESS_TOKEN_LOGIN_LINK= + SECRETS_ENCRYPTION_KEY="" \ No newline at end of file diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 00000000..d9d0d5c5 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,30 @@ +# Open SWE CLI + +> **⚠️ Under Development** +> This CLI is currently under active development and may contain bugs or incomplete features. + +A command-line interface for Open SWE that provides a terminal-based chat experience to interact with the autonomous coding agent. Built with React and Ink, it offers real-time streaming of agent logs, repository selection, and GitHub integration directly from your terminal. + +## Documentation + +## Development + +1. Copy the environment file: `cp .env.example .env` and fill in the required values +2. Install dependencies: `yarn install` +3. Build the CLI: `yarn build` +4. Run the CLI: `yarn cli` + +## Usage + +Run the CLI and follow the interactive prompts: + +```bash +yarn cli +``` + +The CLI will guide you through: + +1. GitHub authentication (if not already logged in) +2. Repository selection +3. GitHub App installation +4. Interactive chat with the Open SWE agent diff --git a/apps/cli/package.json b/apps/cli/package.json index e08f6fcf..5d8292c2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -12,26 +12,31 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "echo \"Error: no test specified\" && exit 0", - "dev": "tsx src/index.tsx" + "test": "echo \"No tests yet\" && exit 0", + "dev": "tsx src/index.tsx", + "cli": "npx tsc && node dist/index.js" }, "dependencies": { + "@langchain/langgraph-sdk": "^0.0.95", + "@open-swe/shared": "*", "dotenv": "^16.6.1", "express": "^4.18.2", "ink": "^6.0.1", "keytar": "^7.9.0", "node-fetch": "^2.6.7", "open": "^9.1.0", - "react": "^19.1.0" + "react": "^19.1.0", + "uuid": "^10.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.19.0", "@tsconfig/recommended": "^1.0.8", "@types/express": "^4.17.21", - "@types/node": "^22.13.5", + "@types/node": "^24.1.0", "@types/node-fetch": "^2.6.4", "@types/react": "^19.1.8", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "eslint": "^9.19.0", diff --git a/apps/cli/src/TerminalInterface.tsx b/apps/cli/src/TerminalInterface.tsx index 5cc66278..ae0edcf4 100644 --- a/apps/cli/src/TerminalInterface.tsx +++ b/apps/cli/src/TerminalInterface.tsx @@ -5,12 +5,14 @@ interface TerminalInterfaceProps { message: string | null; setMessage: () => void; CustomInput: React.FC<{ onSubmit: () => void }>; + repoName: string; } const TerminalInterface: React.FC = ({ message, setMessage, CustomInput, + repoName, }) => { return ( @@ -28,13 +30,18 @@ const TerminalInterface: React.FC = ({ marginTop={0} marginBottom={0} > - + setMessage()} /> {message && ( You typed: {message} )} + {repoName && ( + + Repository: {repoName} + + )} ); }; diff --git a/apps/cli/src/auth-server.ts b/apps/cli/src/auth-server.ts index 0b6b80ad..f3a75fb4 100644 --- a/apps/cli/src/auth-server.ts +++ b/apps/cli/src/auth-server.ts @@ -4,11 +4,14 @@ import type { Request, Response } from "express"; import fs from "fs"; import os from "os"; import path from "path"; +import jwt from "jsonwebtoken"; -const CLIENT_ID = process.env.GITHUB_CLIENT_ID || ""; -const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ""; -const PORT = 3000; -const CALLBACK_URL = "http://localhost:3000/api/auth/github/callback"; +const CLIENT_ID = process.env.GITHUB_APP_CLIENT_ID || ""; +const CLIENT_SECRET = process.env.GITHUB_APP_CLIENT_SECRET || ""; +const PORT = process.env.PORT || 3000; +const CALLBACK_URL = + process.env.GITHUB_CALLBACK_URL || + `http://localhost:${PORT}/api/auth/github/callback`; const TOKEN_PATH = path.join( os.homedir(), @@ -16,38 +19,25 @@ const TOKEN_PATH = path.join( "github_token.json", ); +const APP_ID = process.env.GITHUB_APP_ID || ""; +const GITHUB_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY || ""; + let accessToken: string | null = null; +let serverStarted = false; -interface GitHubTokenResponse { - access_token?: string; - token_type?: string; - scope?: string; - error?: string; - error_description?: string; - error_uri?: string; -} - -function saveToken(tokenData: GitHubTokenResponse) { +function saveToken(tokenData: any) { const dir = path.dirname(TOKEN_PATH); - try { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2), { - mode: 0o600, - }); - } catch (err) { - console.error("Failed to save token:", err); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } + fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2), { + mode: 0o600, + }); } function loadToken() { - try { - if (fs.existsSync(TOKEN_PATH)) { - return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")); - } - } catch (err) { - console.error("Failed to load token:", err); + if (fs.existsSync(TOKEN_PATH)) { + return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")); } return null; } @@ -57,52 +47,73 @@ const app = express(); // 1. Start OAuth flow app.get("/api/auth/github/login", (_req: Request, res: Response) => { const state = Math.random().toString(36).substring(2); - const baseGithubAuthUrl = "https://github.com/login/oauth/authorize"; - const url = new URL(baseGithubAuthUrl); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("redirect_uri", CALLBACK_URL); - url.searchParams.set("state", state); - return res.redirect(url.toString()); + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${state}`; + res.redirect(githubAuthUrl); }); // 2. Handle OAuth callback app.get("/api/auth/github/callback", async (req: Request, res: Response) => { const code = req.query.code as string; - // Optionally validate state here - if (!code) { - return res.status(400).send("Missing code parameter"); + const installationId = req.query.installation_id as string | undefined; + let tokenData: any = loadToken() || {}; + + // If OAuth code is present, exchange for access token + if (code) { + // Exchange code for access token + const tokenRes = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: CALLBACK_URL, + }), + }, + ); + const fetchedTokenData = await tokenRes.json(); + if (fetchedTokenData.error) { + return res + .status(400) + .send("Error exchanging code for token: " + fetchedTokenData.error); + } + accessToken = fetchedTokenData.access_token; + tokenData.access_token = accessToken; + // Store the token in a config file + try { + saveToken(tokenData); + } catch (err) { + console.error("Failed to store token in config file:", err); + } } - const GITHUB_ACCESS_TOKEN_LOGIN_LINK = - process.env.GITHUB_ACCESS_TOKEN_LOGIN_LINK || - "https://github.com/login/oauth/access_token"; - // Exchange code for access token - const tokenRes = await fetch(GITHUB_ACCESS_TOKEN_LOGIN_LINK, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code, - redirect_uri: CALLBACK_URL, - }), - }); - const tokenData = await tokenRes.json(); - if (tokenData.error) { - return res - .status(400) - .send("Error exchanging code for token: " + tokenData.error); + + // If installation_id is present, save it + if (installationId) { + tokenData.installation_id = installationId; + try { + saveToken(tokenData); + } catch (err) { + console.error("Failed to store installation_id in config file:", err); + } + // Immediately fetch the installation access token to test the fetch and print info + await getInstallationAccessToken(); } - accessToken = tokenData.access_token; - // Store the token in a config file - try { - saveToken(tokenData); - } catch (err) { - console.error("Failed to store token in config file:", err); + + if (code || installationId) { + res.send("Authentication successful! You can close this window."); + } else { + res.status(400).send("Missing code or installation_id parameter"); } - return res.send("Authentication successful! You can close this window."); }); export function startAuthServer() { + if (serverStarted) return; + serverStarted = true; app.listen(PORT, () => {}); } @@ -112,3 +123,52 @@ export function getAccessToken() { const tokenData = loadToken(); return tokenData ? tokenData.access_token : null; } + +export function getInstallationId() { + const tokenData = loadToken(); + return tokenData ? tokenData.installation_id : null; +} + +export async function getInstallationAccessToken(): Promise { + // Load installation_id from config file + const tokenData = loadToken(); + const installationId = tokenData?.installation_id; + if (!installationId) { + console.error("No installation_id found in config file."); + return null; + } + if (!APP_ID || !GITHUB_PRIVATE_KEY) { + console.error("GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY not set."); + return null; + } + // Use the key contents from the env var, replacing escaped newlines + const privateKey = GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"); + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, + exp: now + 10 * 60, + iss: APP_ID, + }; + const jwtToken = jwt.sign(payload, privateKey, { algorithm: "RS256" }); + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "open-swe-cli", + }, + }, + ); + if (!res.ok) { + console.error( + "Failed to fetch installation access token:", + await res.text(), + ); + return null; + } + const data = await res.json(); + + return data.token; +} diff --git a/apps/cli/src/index.tsx b/apps/cli/src/index.tsx index 98b607fe..56b704aa 100644 --- a/apps/cli/src/index.tsx +++ b/apps/cli/src/index.tsx @@ -1,22 +1,68 @@ #!/usr/bin/env node -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { render, Box, Text, useInput } from "ink"; -import { startAuthServer, getAccessToken } from "./auth-server.js"; +import { + startAuthServer, + getAccessToken, + getInstallationId, +} from "./auth-server.js"; import open from "open"; -import TerminalInterface from "./TerminalInterface.js"; +import { v4 as uuidv4 } from "uuid"; +import { + MANAGER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, +} from "@open-swe/shared/constants"; +import { Client } from "@langchain/langgraph-sdk"; +import { submitFeedback } from "./utils.js"; +import { StreamingService } from "./streaming.js"; + +type StreamMode = "values" | "updates" | "messages"; const GITHUB_LOGIN_URL = process.env.GITHUB_LOGIN_URL || "http://localhost:3000/api/auth/github/login"; -const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { +startAuthServer(); + +const LoadingSpinner: React.FC<{ text: string }> = ({ text }) => { + const [dots, setDots] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? "" : prev + ".")); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + {text} + {dots} + + + ); +}; + +// eslint-disable-next-line no-unused-vars +const CustomInput: React.FC<{ onSubmit: (value: string) => void }> = ({ + onSubmit, +}) => { const [input, setInput] = useState(""); const [isSubmitted, setIsSubmitted] = useState(false); - useInput((inputChar, key) => { + useInput((inputChar: string, key: { [key: string]: any }) => { if (isSubmitted) return; if (key.return) { - setIsSubmitted(true); - onSubmit(); + if (input.trim()) { + // Only submit if there's actual content + setIsSubmitted(true); + onSubmit(input); + // Reset for next input + setTimeout(() => { + setInput(""); + setIsSubmitted(false); + }, 100); + } } else if (key.backspace || key.delete) { setInput((prev) => prev.slice(0, -1)); } else if (inputChar) { @@ -24,11 +70,95 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { } }); - if (isSubmitted) return null; - return ( - > {input} + > {input} + + ); +}; + +async function fetchUserRepos(token: string) { + const allRepos = []; + let page = 1; + const perPage = 100; + while (true) { + const res = await fetch( + `https://api.github.com/user/repos?per_page=${perPage}&page=${page}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "open-swe-cli", + }, + }, + ); + if (!res.ok) throw new Error("Failed to fetch repos"); + const repos = await res.json(); + allRepos.push(...repos); + if (repos.length < perPage) break; + page++; + } + return allRepos; +} + +const RepoSearchSelect: React.FC<{ + repos: any[]; + // eslint-disable-next-line no-unused-vars + onSelect: (repo: any) => void; +}> = ({ repos, onSelect }) => { + const [search, setSearch] = useState(""); + const [highlighted, setHighlighted] = useState(0); + const [isMessage, setIsMessage] = useState(false); + + const filtered = repos.filter((repo) => + repo.full_name.toLowerCase().includes(search.toLowerCase()), + ); + const shown = filtered.slice(0, 10); + + useInput((input: string, key: { [key: string]: any }) => { + if (isMessage) return; + if (key.return) { + if (shown.length > 0) { + setIsMessage(true); + onSelect(shown[highlighted]); + } + } else if (key.upArrow) { + setHighlighted((h) => (h - 1 + shown.length) % shown.length); + } else if (key.downArrow) { + setHighlighted((h) => (h + 1) % shown.length); + } else if (key.backspace || key.delete) { + setSearch((prev) => prev.slice(0, -1)); + setHighlighted(0); + } else if (input && !key.ctrl && !key.meta) { + setSearch((prev) => prev + input); + setHighlighted(0); + } + }); + + if (isMessage) return null; + + return ( + + + Search repositories: {search} + + {shown.length === 0 ? ( + + No matches found. + + ) : ( + + {shown.map((_, idx) => ( + + {idx === highlighted ? "> " : " "} + {shown[idx].full_name} + + ))} + + )} + + Use ↑/↓ to navigate, Enter to select + ); }; @@ -36,10 +166,72 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { const App: React.FC = () => { const [authPrompt, setAuthPrompt] = useState(null); const [authInput, setAuthInput] = useState(""); - const [message, setMessage] = useState(null); const [exit, setExit] = useState(false); const [authStarted, setAuthStarted] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false); + const [repos, setRepos] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const [selectingRepo, setSelectingRepo] = useState(false); + const [waitingForInstall, setWaitingForInstall] = useState(false); + const [installChecked, setInstallChecked] = useState(false); + const [installError, setInstallError] = useState(null); + const [appSlug, setAppSlug] = useState(process.env.GITHUB_APP_NAME || ""); + const INSTALLATION_CALLBACK_URL = process.env.GITHUB_CALLBACK_URL || ""; + const [pollingForToken, setPollingForToken] = useState(false); + const [logs, setLogs] = useState([]); + + const [plannerFeedback, setPlannerFeedback] = useState(null); + const [streamingPhase, setStreamingPhase] = useState< + "streaming" | "awaitingFeedback" | "done" + >("streaming"); + const [threadId, setThreadId] = useState(null); + const [plannerThreadId, setPlannerThreadId] = useState(null); + const [hasStartedChat, setHasStartedChat] = useState(false); + const [client, setClient] = useState(null); + const [loadingRepos, setLoadingRepos] = useState(false); + const [loadingLogs, setLoadingLogs] = useState(false); + + const sendInterruptMessage = useCallback( + async (message: string) => { + if (!client || !threadId || !selectedRepo) { + return; + } + + setLogs((prev) => [...prev, `📤 Interrupt Response: "${message}"`]); + + try { + const [owner, repoName] = selectedRepo.full_name.split("/"); + const interruptInput = { + messages: [ + { + id: uuidv4(), + type: "human", + content: [{ type: "text", text: message }], + }, + ], + targetRepository: { + owner, + repo: repoName, + branch: selectedRepo.default_branch || "main", + }, + }; + await client.runs.create(threadId, MANAGER_GRAPH_ID, { + input: interruptInput, + config: { recursion_limit: 400 }, + ifNotExists: "create", + streamResumable: true, + multitaskStrategy: "enqueue", + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }); + + // Just submit the interrupt - existing planner session will pick it up automatically + setLogs((prev) => [...prev, `✅ Interrupt sent to existing session`]); + } catch (err: any) { + setLogs((prev) => [...prev, `Error sending interrupt: ${err.message}`]); + } + }, + [client, threadId, selectedRepo, setLogs], + ); // On mount, check for existing token useEffect(() => { @@ -49,22 +241,56 @@ const App: React.FC = () => { } }, []); - // Poll for token after auth flow starts + // After login, fetch and store user repos useEffect(() => { - if (authStarted && !isLoggedIn) { - const interval = setInterval(() => { - const token = getAccessToken(); - if (token) { - setIsLoggedIn(true); - clearInterval(interval); - } - }, 1000); - return () => clearInterval(interval); + if (isLoggedIn && repos.length === 0 && !loadingRepos) { + const token = getAccessToken(); + if (token) { + setLoadingRepos(true); + fetchUserRepos(token) + .then((repos) => { + setRepos(repos); + setSelectingRepo(true); + setLoadingRepos(false); + }) + .catch((err) => { + console.error("Failed to fetch repos:", err); + setLoadingRepos(false); + }); + } } - }, [authStarted, isLoggedIn]); + }, [isLoggedIn, repos.length, loadingRepos]); + + // Poll for installation_id after opening install page + useEffect(() => { + let interval: ReturnType; + if (waitingForInstall) { + interval = setInterval(() => { + // Check if installation_id is present in config file + const installationId = getInstallationId(); + if (installationId) { + setInstallChecked(true); + setWaitingForInstall(false); + } + }, 1500); + } + return () => clearInterval(interval); + }, [waitingForInstall]); + + // Listen for Cmd+C/Ctrl+C to re-select repo + useInput((input: string, key: { [key: string]: any }) => { + if (installChecked && !waitingForInstall && key.return) { + setInstallChecked(false); + setSelectingRepo(false); + } + if (selectedRepo && (key.ctrl || key.meta) && input.toLowerCase() === "c") { + setSelectingRepo(true); + setSelectedRepo(null); + } + }); // Handle yes/no input for auth prompt - useInput((input, key) => { + useInput((input: string, key: { [key: string]: any }) => { if (authPrompt === null && !isLoggedIn) { if (key.return) { if (authInput.toLowerCase() === "y") { @@ -76,7 +302,6 @@ const App: React.FC = () => { } else if (key.backspace || key.delete) { setAuthInput((prev) => prev.slice(0, -1)); } else if (input && authInput.length < 1) { - // Only allow a single character (y/n) setAuthInput(input); } } @@ -95,38 +320,312 @@ const App: React.FC = () => { setAuthStarted(true); startAuthServer(); open(GITHUB_LOGIN_URL); + setPollingForToken(true); } }, [authPrompt, authStarted]); - if (isLoggedIn) { + // Poll for token after auth flow starts + useEffect(() => { + if (pollingForToken && !isLoggedIn) { + const interval = setInterval(() => { + const token = getAccessToken(); + if (token) { + setIsLoggedIn(true); + setPollingForToken(false); + } + }, 1000); + return () => clearInterval(interval); + } + }, [pollingForToken, isLoggedIn]); + + // Custom input for planner feedback (must be inside App) + const PlanFeedbackSelect: React.FC = () => { + const [highlighted, setHighlighted] = useState(0); + const [isMessage, setIsMessage] = useState(false); + + const options = [ + { value: "approve", label: "Approve" }, + { value: "deny", label: "Deny" }, + ]; + + useInput((input: string, key: { [key: string]: any }) => { + if (streamingPhase !== "awaitingFeedback") return; + if (isMessage) return; + + if (key.return) { + setIsMessage(true); + setPlannerFeedback(options[highlighted].value); + } else if (key.leftArrow) { + setHighlighted((h) => (h - 1 + options.length) % options.length); + } else if (key.rightArrow) { + setHighlighted((h) => (h + 1) % options.length); + } + }); + + if (streamingPhase !== "awaitingFeedback") return null; + return ( - - setMessage(null)} - CustomInput={CustomInput} - /> - - - - logged in + + Plan feedback: + {options.map((option, idx) => ( + + {idx === highlighted ? "[" : " "} + {option.label} + {idx === highlighted ? "]" : " "} - + ))} + Use ←/→ to navigate, Enter to select + + ); + }; + + // Add this where we handle planner feedback + useEffect(() => { + if ( + streamingPhase === "awaitingFeedback" && + plannerFeedback && + plannerThreadId + ) { + // Immediately switch to streaming mode to hide the feedback prompt + setStreamingPhase("streaming"); + + (async () => { + await submitFeedback({ + plannerFeedback, + plannerThreadId, + selectedRepo, + setLogs, + setPlannerFeedback: () => setPlannerFeedback(null), + }); + })(); + } + }, [streamingPhase, plannerFeedback, selectedRepo, plannerThreadId]); + + // Loading repos after login + if (isLoggedIn && loadingRepos) { + return ( + + ); } - if (authPrompt === null) { + // Repo selection UI + if (isLoggedIn && repos.length > 0 && (selectingRepo || !selectedRepo)) { return ( - - LangChain Open SWE CLI - + LangChain Open SWE CLI + + + Select a repository to work with (type to search): + { + let slug = appSlug; + const installationId = getInstallationId(); + setSelectedRepo(repo); + setSelectingRepo(false); + if (installationId) { + setInstallChecked(true); + setWaitingForInstall(false); + setInstallError(null); + return; + } + if (!slug) { + console.log( + "Please enter your GitHub App slug (as in https://github.com/apps/):", + ); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + slug = await new Promise((resolve) => { + process.stdin.once("data", (data) => + resolve(String(data).trim()), + ); + }); + setAppSlug(slug); + } + const installUrl = `https://github.com/apps/${slug}/installations/new?redirect_uri=${encodeURIComponent(INSTALLATION_CALLBACK_URL)}`; + console.log( + "Opening GitHub App installation page in your browser...", + ); + await open(installUrl); + setWaitingForInstall(true); + setInstallChecked(false); + setInstallError(null); + }} + /> + + {waitingForInstall && ( + + Waiting for GitHub App installation to complete... + + After installing the app, return here to continue. + + + )} + {installChecked && !waitingForInstall && ( + + GitHub App installation detected! You can now proceed. + Press Enter to continue. + + )} + {installError && ( + + {installError} + + )} + + ); + } + + // Main UI: logs area + input prompt + if (isLoggedIn && selectedRepo) { + // Calculate available space for logs based on whether welcome message is shown + const headerHeight = 0; // Welcome message is now above input bar, not at top + const inputHeight = 4; // Fixed input area height (increased due to padding) + const welcomeHeight = hasStartedChat ? 0 : 8; // Welcome message height when shown + const paddingHeight = 3; // Extra padding to prevent overlap + const availableLogHeight = Math.max( + 5, + process.stdout.rows - + headerHeight - + inputHeight - + welcomeHeight - + paddingHeight, + ); + + // Always show the most recent logs (auto-scroll to bottom) + const visibleLogs = + logs.length > availableLogHeight ? logs.slice(-availableLogHeight) : logs; + + return ( + + {/* Auto-scrolling logs area - strict boundary container */} + + + {loadingLogs && logs.length === 0 ? ( + + ) : ( + visibleLogs.map((log, index) => ( + + + {log} + + + )) + )} + + + + {/* Welcome message right above input bar */} + {!hasStartedChat ? ( + + + + {` + +## ### ## ## ###### ###### ## ## ### #### ## ## +## ## ## ### ## ## ## ## ## ## ## ## ## ## ### ## +## ## ## #### ## ## ## ## ## ## ## ## #### ## +## ## ## ## ## ## ## #### ## ######### ## ## ## ## ## ## +## ######### ## #### ## ## ## ## ## ######### ## ## #### +## ## ## ## ### ## ## ## ## ## ## ## ## ## ## ### +######## ## ## ## ## ###### ###### ## ## ## ## #### ## ## +`} + + + + + Describe your coding problem. It'll run in the sandbox and a PR + will be created. + + + + ) : ( + + )} + + {/* Fixed input area at bottom */} + + + {!hasStartedChat ? ( + { + setHasStartedChat(true); + setPlannerFeedback(null); + + const streamingService = new StreamingService({ + setLogs, + setPlannerThreadId, + setStreamingPhase, + setLoadingLogs, + setClient, + setThreadId, + }); + + streamingService.startNewSession(value, selectedRepo); + }} + /> + ) : ( + { + sendInterruptMessage(value); + }} + /> + )} + + + + {/* Plan feedback below the input bar */} + {streamingPhase === "awaitingFeedback" && ( + + + + )} + + ); + } + + // Auth prompt UI + if (!isLoggedIn && authPrompt === null) { + return ( + + { ); } - return ( - - - - LangChain Open SWE CLI - - - {!message && setMessage(null)} />} - {message && ( - - You typed: {message} - - )} - - ); + // Fallback + return ; }; render(); diff --git a/apps/cli/src/logger.ts b/apps/cli/src/logger.ts new file mode 100644 index 00000000..c5e4f761 --- /dev/null +++ b/apps/cli/src/logger.ts @@ -0,0 +1,294 @@ +import { + coerceMessageLikeToMessage, + ToolMessage, + isAIMessage, + isHumanMessage, + isToolMessage, +} from "@langchain/core/messages"; +import { getMessageContentString } from "@open-swe/shared/messages"; +import { createWriteTechnicalNotesToolFields } from "@open-swe/shared/open-swe/tools"; + +interface LogChunk { + event: string; + data: any; + ops?: Array<{ value: string }>; +} + +/** + * Format a tool result based on its type and content + */ +function formatToolResult(message: ToolMessage): string { + const content = getMessageContentString(message.content); + + if (!content) return ""; + + // For successful tool executions, format nicely + const toolName = message.name || "tool"; + switch (toolName.toLowerCase()) { + case "shell": + case "grep_search": + case "search": + return content; + case "apply_patch": + return content.includes("Error") + ? `Error: ${content}` + : "Patch applied successfully"; + case "install_dependencies": + return content.includes("Error") + ? `Error: ${content}` + : "Dependencies installed successfully"; + default: + if (content.length > 200) { + return content.slice(0, 200) + "..."; + } + return content; + } +} + +export function formatDisplayLog(chunk: LogChunk | string): string[] { + if (typeof chunk === "string") { + if (chunk.startsWith("Human feedback:")) { + return [ + `[HUMAN FEEDBACK RECEIVED] ${chunk.replace("Human feedback:", "").trim()}`, + ]; + } + if (chunk.startsWith("Interrupt:")) { + const message = chunk.replace("Interrupt:", "").trim(); + return [ + "═══════════════════════════════════════", + `📤 INTERRUPT: "${message}"`, + "═══════════════════════════════════════", + ]; + } + // Filter out raw file content and object references + if ( + chunk === "[object Object]" || + chunk.includes("total 4") || + chunk.includes("drwxr-xr-x") || + chunk.includes("Exit code 1") || + chunk.startsWith("#") || + chunk.startsWith("-") || + chunk.startsWith("./") + ) { + return []; + } + // Single line system messages + const cleanChunk = chunk.replace(/\s+/g, " ").trim(); + const maxLength = 150; + const truncated = + cleanChunk.length > maxLength + ? cleanChunk.slice(0, maxLength) + "... [trunc]" + : cleanChunk; + return [`[SYSTEM] ${truncated}`]; + } + + const data = chunk.data; + const logs: string[] = []; + + // Handle session events + if (data.plannerSession) { + logs.push("[PLANNER SESSION STARTED]"); + } + if (data.programmerSession) { + logs.push("[PROGRAMMER SESSION STARTED]"); + } + + // Handle messages + const nestedDataObj = Object.values(data)[0] as unknown as Record< + string, + any + >; + if ( + nestedDataObj && + typeof nestedDataObj === "object" && + "messages" in nestedDataObj + ) { + const messages = Array.isArray(nestedDataObj.messages) + ? nestedDataObj.messages + : [nestedDataObj.messages]; + for (const msg of messages) { + try { + const message = coerceMessageLikeToMessage(msg); + + // Handle tool messages + if (isToolMessage(message)) { + const toolName = message.name || "tool"; + const result = formatToolResult(message); + if (result) { + // Concatenate long tool results to a single line (truncate if too long) + const maxLength = 500; + let formattedResult = result.replace(/\s+/g, " "); + if (formattedResult.length > maxLength) { + formattedResult = + formattedResult.slice(0, maxLength) + "... [trunc]"; + } + logs.push(`[TOOL RESULT] ${toolName}: ${formattedResult}`); + } + continue; + } + + // Handle AI messages + if (isAIMessage(message)) { + // Handle reasoning if present + if (message.additional_kwargs?.reasoning) { + const reasoning = String(message.additional_kwargs.reasoning) + .replace(/\s+/g, " ") + .trim(); + const maxLength = 150; + const truncated = + reasoning.length > maxLength + ? reasoning.slice(0, maxLength) + "... [trunc]" + : reasoning; + logs.push(`[REASONING] ${truncated}`); + } + + // Handle tool calls + if (message.tool_calls && message.tool_calls.length > 0) { + const technicalNotesToolName = + createWriteTechnicalNotesToolFields().name; + + message.tool_calls.forEach((tool) => { + let argsString = ""; + if (typeof tool.args === "string") { + argsString = tool.args; + } else if (tool.args !== undefined) { + try { + argsString = JSON.stringify(tool.args, null, 2); + } catch { + argsString = String(tool.args); + } + } + // Truncate the string if too long + const maxLength = 150; + const truncatedArgs = + argsString.length > maxLength + ? argsString.slice(0, maxLength) + "... [trunc]" + : argsString; + const toolName = tool.name || "unknown"; + logs.push(`[TOOL CALL] ${toolName}: ${truncatedArgs}`); + + // Handle technical notes from tool call + if ( + tool.name === technicalNotesToolName && + tool.args && + typeof tool.args === "object" && + "notes" in tool.args + ) { + const notes = (tool.args as any).notes; + if (Array.isArray(notes)) { + logs.push( + "[TECHNICAL NOTES]", + ...notes.map((note: string) => ` • ${note}`), + ); + } + } + }); + } + + // Handle regular AI messages + const text = getMessageContentString(message.content); + if (text) { + // Always single line, remove newlines and truncate + const cleanText = text.replace(/\s+/g, " ").trim(); + const maxLength = 200; + const truncated = + cleanText.length > maxLength + ? cleanText.slice(0, maxLength) + "... [trunc]" + : cleanText; + logs.push(`[AI] ${truncated}`); + } + } + + // Handle human messages + if (isHumanMessage(message)) { + const text = getMessageContentString(message.content); + if (text) { + // Single line human messages + const cleanText = text.replace(/\s+/g, " ").trim(); + const maxLength = 150; + const truncated = + cleanText.length > maxLength + ? cleanText.slice(0, maxLength) + "... [trunc]" + : cleanText; + logs.push(`[HUMAN] ${truncated}`); + } + } + } catch (error: any) { + console.error("Error formatting log:", error.message); + // Fallback to original message if conversion fails + if (msg.type === "tool") { + const toolName = msg.name || "tool"; + const content = getMessageContentString(msg.content); + if (content) { + logs.push(`[TOOL RESULT] ${toolName}: ${content}`); + } + } else if (msg.type === "ai") { + const text = getMessageContentString(msg.content); + if (text) { + const cleanText = text.replace(/\s+/g, " ").trim(); + const maxLength = 200; + const truncated = + cleanText.length > maxLength + ? cleanText.slice(0, maxLength) + "... [trunc]" + : cleanText; + logs.push(`[AI] ${truncated}`); + } + } else if (msg.type === "human") { + const text = getMessageContentString(msg.content); + if (text) { + const cleanText = text.replace(/\s+/g, " ").trim(); + const maxLength = 150; + const truncated = + cleanText.length > maxLength + ? cleanText.slice(0, maxLength) + "... [trunc]" + : cleanText; + logs.push(`[HUMAN] ${truncated}`); + } + } + } + } + } + // Handle feedback messages + if (data.command?.resume?.[0]?.type) { + const type = data.command.resume[0].type; + logs.push(`[HUMAN FEEDBACK RECEIVED] ${type}`); + } + + // Handle interrupts and plans + if (data.__interrupt__) { + const interrupt = data.__interrupt__[0]?.value; + if (interrupt?.action_request?.args?.plan) { + const plan = interrupt.action_request.args.plan; + const steps = plan + .split(":::") + .map((s: string) => s.trim()) + .filter(Boolean); + + // Add clear visual separation and format nicely + logs.push( + " ", // Blank line for separation + + "🎯 PROPOSED PLAN", + ...steps.map((step: string, idx: number) => ` ${idx + 1}. ${step}`), + + " ", // Blank line after + ); + } else { + logs.push( + " ", // Blank line for separation + "⏳ INTERRUPT: Waiting for feedback...", + " ", // Blank line after + ); + } + } + return logs; +} + +/** + * Formats a log chunk for debug purposes, showing all raw data. + * This should only be used during development. + */ +export function formatDebugLog(chunk: LogChunk | string): string { + if (typeof chunk === "string") return chunk; + return JSON.stringify(chunk, null, 2); +} diff --git a/apps/cli/src/streaming.ts b/apps/cli/src/streaming.ts new file mode 100644 index 00000000..c8c091b9 --- /dev/null +++ b/apps/cli/src/streaming.ts @@ -0,0 +1,237 @@ +import { Client, StreamMode } from "@langchain/langgraph-sdk"; +import { v4 as uuidv4 } from "uuid"; +import { encryptSecret } from "@open-swe/shared/crypto"; +import { + MANAGER_GRAPH_ID, + GITHUB_TOKEN_COOKIE, + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_INSTALLATION_NAME, + GITHUB_INSTALLATION_ID, + OPEN_SWE_STREAM_MODE, +} from "@open-swe/shared/constants"; +import { + getAccessToken, + getInstallationAccessToken, + getInstallationId, +} from "./auth-server.js"; +import { formatDisplayLog } from "./logger.js"; +import { isAgentInboxInterruptSchema } from "@open-swe/shared/agent-inbox-interrupt"; + +const LANGGRAPH_URL = process.env.LANGGRAPH_URL || "http://localhost:2024"; + +interface StreamingCallbacks { + setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars + setPlannerThreadId: (id: string) => void; // eslint-disable-line no-unused-vars + setStreamingPhase: (phase: "streaming" | "awaitingFeedback" | "done") => void; // eslint-disable-line no-unused-vars + setLoadingLogs: (loading: boolean) => void; // eslint-disable-line no-unused-vars + setClient: (client: Client) => void; // eslint-disable-line no-unused-vars + setThreadId: (id: string) => void; // eslint-disable-line no-unused-vars +} + +export class StreamingService { + private callbacks: StreamingCallbacks; + + constructor(callbacks: StreamingCallbacks) { + this.callbacks = callbacks; + } + + private async handleProgrammerStream( + client: Client, + programmerThreadId: string, + programmerRunId: string, + ) { + for await (const programmerChunk of client.runs.joinStream( + programmerThreadId, + programmerRunId, + { + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }, + )) { + if (programmerChunk.event === "updates") { + const formatted = formatDisplayLog(programmerChunk); + if (formatted.length > 0) { + this.callbacks.setLogs((prev) => [...prev, ...formatted]); + } + } + } + } + + private async handlePlannerStream( + client: Client, + plannerThreadId: string, + plannerRunId: string, + ): Promise<{ needsFeedback: boolean }> { + let programmerStreamed = false; + + for await (const subChunk of client.runs.joinStream( + plannerThreadId, + plannerRunId, + { + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }, + )) { + if (subChunk.event === "updates") { + const formatted = formatDisplayLog(subChunk); + // Filter out human messages from planner stream (already logged in manager) + const filteredFormatted = formatted.filter( + (log) => !log.startsWith("[HUMAN]"), + ); + if (filteredFormatted.length > 0) { + this.callbacks.setLogs((prev) => [...prev, ...filteredFormatted]); + } + } + + // Check for programmer session + if ( + !programmerStreamed && + subChunk.data?.programmerSession?.threadId && + typeof subChunk.data.programmerSession.threadId === "string" && + typeof subChunk.data.programmerSession.runId === "string" + ) { + programmerStreamed = true; + await this.handleProgrammerStream( + client, + subChunk.data.programmerSession.threadId, + subChunk.data.programmerSession.runId, + ); + } + + // Detect HumanInterrupt in planner stream + const interruptArr = + subChunk.data && Array.isArray(subChunk.data["__interrupt__"]) + ? subChunk.data["__interrupt__"] + : undefined; + const firstInterruptValue = + interruptArr && interruptArr[0] && interruptArr[0].value + ? interruptArr[0].value + : undefined; + if (isAgentInboxInterruptSchema(firstInterruptValue)) { + return { needsFeedback: true }; + } + } + + return { needsFeedback: false }; + } + + private async startManagerStream( + client: Client, + threadId: string, + runId: string, + ) { + let plannerStreamed = false; + + for await (const chunk of client.runs.joinStream(threadId, runId)) { + if (chunk.event === "updates") { + const formatted = formatDisplayLog(chunk); + if (formatted.length > 0) { + this.callbacks.setLogs((prev) => { + if (prev.length === 0) { + this.callbacks.setLoadingLogs(false); + } + return [...prev, ...formatted]; + }); + } + } + + // Check for plannerSession + if ( + !plannerStreamed && + chunk.data && + chunk.data.plannerSession && + typeof chunk.data.plannerSession.threadId === "string" && + typeof chunk.data.plannerSession.runId === "string" + ) { + plannerStreamed = true; + this.callbacks.setPlannerThreadId(chunk.data.plannerSession.threadId); + + const result = await this.handlePlannerStream( + client, + chunk.data.plannerSession.threadId, + chunk.data.plannerSession.runId, + ); + + if (result.needsFeedback) { + this.callbacks.setStreamingPhase("awaitingFeedback"); + return; // Pause streaming, let React render feedback prompt + } + } + } + + this.callbacks.setStreamingPhase("done"); + } + + async startNewSession(prompt: string, selectedRepo: any) { + this.callbacks.setLogs(() => []); + this.callbacks.setLoadingLogs(true); + + try { + const userAccessToken = getAccessToken(); + const installationAccessToken = await getInstallationAccessToken(); + const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY; + + if (!userAccessToken || !installationAccessToken || !encryptionKey) { + this.callbacks.setLogs(() => [ + `Missing secrets: ${userAccessToken ? "" : "userAccessToken, "}${installationAccessToken ? "" : "installationAccessToken, "}${encryptionKey ? "" : "encryptionKey"}`, + ]); + return; + } + + const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey); + const encryptedInstallationToken = encryptSecret( + installationAccessToken, + encryptionKey, + ); + const [owner, repoName] = selectedRepo.full_name.split("/"); + + const runInput = { + messages: [ + { + id: uuidv4(), + type: "human", + content: [{ type: "text", text: prompt }], + }, + ], + targetRepository: { + owner, + repo: repoName, + branch: selectedRepo.default_branch || "main", + }, + autoAcceptPlan: false, + }; + + const installationId = getInstallationId(); + const newClient = new Client({ + apiUrl: LANGGRAPH_URL, + defaultHeaders: { + [GITHUB_TOKEN_COOKIE]: encryptedUserToken, + [GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptedInstallationToken, + [GITHUB_INSTALLATION_NAME]: owner, + [GITHUB_INSTALLATION_ID]: installationId, + }, + }); + + this.callbacks.setClient(newClient); + const thread = await newClient.threads.create(); + const threadId = thread.thread_id; + this.callbacks.setThreadId(threadId); + + const run = await newClient.runs.create(threadId, MANAGER_GRAPH_ID, { + input: runInput, + config: { recursion_limit: 400 }, + ifNotExists: "create", + streamResumable: true, + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }); + + await this.startManagerStream(newClient, threadId, run.run_id); + } catch (err: any) { + this.callbacks.setLogs((prev) => [ + ...prev, + `Error during streaming: ${err.message}`, + ]); + this.callbacks.setLoadingLogs(false); + } finally { + this.callbacks.setLoadingLogs(false); + } + } +} diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts new file mode 100644 index 00000000..19936b6a --- /dev/null +++ b/apps/cli/src/utils.ts @@ -0,0 +1,121 @@ +/** + * Utility functions for CLI app + */ + +import { Client, StreamMode } from "@langchain/langgraph-sdk"; +import { encryptSecret } from "@open-swe/shared/crypto"; +import { + OPEN_SWE_STREAM_MODE, + PLANNER_GRAPH_ID, +} from "@open-swe/shared/constants"; +import { + getAccessToken, + getInstallationAccessToken, + getInstallationId, +} from "./auth-server.js"; +import { formatDisplayLog } from "./logger.js"; +const LANGGRAPH_URL = process.env.LANGGRAPH_URL || "http://localhost:2024"; + +/** + * Submit feedback to the planner + */ +export async function submitFeedback({ + plannerFeedback, + plannerThreadId, + selectedRepo, + setLogs, + setPlannerFeedback, +}: { + plannerFeedback: string; + plannerThreadId: string; + selectedRepo: any; + setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars + setPlannerFeedback: () => void; +}) { + try { + const userAccessToken = getAccessToken(); + const installationAccessToken = await getInstallationAccessToken(); + const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY; + + if (!userAccessToken || !installationAccessToken || !encryptionKey) { + setLogs((prev) => [ + ...prev, + "Missing access tokens for feedback submission", + ]); + return; + } + + const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey); + const encryptedInstallationToken = encryptSecret( + installationAccessToken, + encryptionKey, + ); + const [owner] = selectedRepo?.full_name.split("/") || []; + + const installationId = getInstallationId(); + const client = new Client({ + apiUrl: LANGGRAPH_URL, + defaultHeaders: { + "x-github-access-token": encryptedUserToken, + "x-github-installation-token": encryptedInstallationToken, + "x-github-installation-name": owner, + "x-github-installation-id": installationId, + }, + }); + + const formatted = formatDisplayLog(`Human feedback: ${plannerFeedback}`); + if (formatted.length > 0) { + setLogs((prev) => [...prev, ...formatted]); + } + + // Create a new stream with the feedback + const stream = await client.runs.stream(plannerThreadId, PLANNER_GRAPH_ID, { + command: { + resume: [ + { + type: plannerFeedback === "approve" ? "accept" : "ignore", + args: null, + }, + ], + }, + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], + }); + + let programmerStreamed = false; + // Process the stream response + for await (const chunk of stream) { + const formatted = formatDisplayLog(chunk); + if (formatted.length > 0) { + setLogs((prev) => [...prev, ...formatted]); + } + + // Check for programmer session in the resumed planner stream + const chunkData = chunk.data as any; + if ( + !programmerStreamed && + chunkData?.programmerSession?.threadId && + typeof chunkData.programmerSession.threadId === "string" && + typeof chunkData.programmerSession.runId === "string" + ) { + programmerStreamed = true; + // Join programmer stream + for await (const programmerChunk of client.runs.joinStream( + chunkData.programmerSession.threadId, + chunkData.programmerSession.runId, + )) { + const formatted = formatDisplayLog(programmerChunk); + if (formatted.length > 0) { + setLogs((prev) => [...prev, ...formatted]); + } + } + } + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setLogs((prev) => [...prev, `Error submitting feedback: ${errorMessage}`]); + } finally { + // Clear feedback state + setPlannerFeedback(); + } +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 03bcff17..d3119bac 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -7,6 +7,7 @@ "outDir": "dist", "jsx": "react-jsx", "strict": true, + "types": ["node"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts index 5a2201e3..59f60794 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message/index.ts @@ -33,11 +33,15 @@ import { getDefaultHeaders } from "../../../../utils/default-headers.js"; import { BASE_CLASSIFICATION_SCHEMA } from "./schemas.js"; import { getPlansFromIssue } from "../../../../utils/github/issue-task.js"; import { HumanResponse } from "@langchain/langgraph/prebuilt"; -import { PLANNER_GRAPH_ID } from "@open-swe/shared/constants"; +import { + OPEN_SWE_STREAM_MODE, + PLANNER_GRAPH_ID, +} from "@open-swe/shared/constants"; import { createLogger, LogLevel } from "../../../../utils/logger.js"; import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; import { createClassificationPromptAndToolSchema } from "./utils.js"; import { RequestSource } from "../../../../constants.js"; +import { StreamMode } from "@langchain/langgraph-sdk"; const logger = createLogger(LogLevel.INFO, "ClassifyMessage"); @@ -263,7 +267,7 @@ export async function classifyMessage( command: { resume: plannerResume, }, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }, ); newPlannerId = newPlannerRun.run_id; diff --git a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts index 560627a4..dbe234f0 100644 --- a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts +++ b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts @@ -5,7 +5,10 @@ import { ManagerGraphUpdate, } from "@open-swe/shared/open-swe/manager/types"; import { createIssueFieldsFromMessages } from "../utils/generate-issue-fields.js"; -import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; +import { + MANAGER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, +} from "@open-swe/shared/constants"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; import { createIssue } from "../../../utils/github/api.js"; import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; @@ -20,6 +23,7 @@ import { import { getBranchName } from "../../../utils/github/git.js"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; import { getCustomConfigurableFields } from "../../../utils/config.js"; +import { StreamMode } from "@langchain/langgraph-sdk"; /** * Create new manager session. @@ -92,7 +96,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`, }, ifNotExists: "create", streamResumable: true, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }); return { diff --git a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts index b3314ba5..74d8ce00 100644 --- a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts +++ b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts @@ -5,13 +5,17 @@ import { ManagerGraphUpdate, } from "@open-swe/shared/open-swe/manager/types"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; -import { PLANNER_GRAPH_ID } from "@open-swe/shared/constants"; +import { + OPEN_SWE_STREAM_MODE, + PLANNER_GRAPH_ID, +} from "@open-swe/shared/constants"; import { createLogger, LogLevel } from "../../../utils/logger.js"; import { getBranchName } from "../../../utils/github/git.js"; import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; import { getCustomConfigurableFields } from "../../../utils/config.js"; import { getRecentUserRequest } from "../../../utils/user-request.js"; +import { StreamMode } from "@langchain/langgraph-sdk"; const logger = createLogger(LogLevel.INFO, "StartPlanner"); @@ -54,7 +58,7 @@ export async function startPlanner( ifNotExists: "create", multitaskStrategy: "enqueue", streamResumable: true, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }, ); diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index a81c7bbe..49d0deef 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import { AIMessage, BaseMessage } from "@langchain/core/messages"; import { Command, END, interrupt } from "@langchain/langgraph"; +import { StreamMode } from "@langchain/langgraph-sdk"; import { GraphUpdate, GraphConfig, @@ -23,6 +24,7 @@ import { PLAN_INTERRUPT_DELIMITER, DO_NOT_RENDER_ID_PREFIX, PROGRAMMER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, } from "@open-swe/shared/constants"; import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; @@ -200,7 +202,7 @@ async function startProgrammerRun(input: { ifNotExists: "create", streamResumable: true, streamSubgraphs: true, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }, ); diff --git a/apps/open-swe/src/routes/github/issue-webhook.ts b/apps/open-swe/src/routes/github/issue-webhook.ts index 66c8c34d..3e2e0f41 100644 --- a/apps/open-swe/src/routes/github/issue-webhook.ts +++ b/apps/open-swe/src/routes/github/issue-webhook.ts @@ -12,6 +12,7 @@ import { GITHUB_USER_ID_HEADER, GITHUB_USER_LOGIN_HEADER, MANAGER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, } from "@open-swe/shared/constants"; import { encryptSecret } from "@open-swe/shared/crypto"; import { HumanMessage } from "@langchain/core/messages"; @@ -24,6 +25,7 @@ import { import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; import { RequestSource } from "../../constants.js"; import { isAllowedUser } from "@open-swe/shared/github/allowed-users"; +import { StreamMode } from "@langchain/langgraph-sdk"; const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook"); @@ -202,7 +204,7 @@ webhooks.on("issues.labeled", async ({ payload }) => { config, ifNotExists: "create", streamResumable: true, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }); logger.info("Created new run from GitHub issue.", { diff --git a/apps/web/src/components/v2/terminal-input.tsx b/apps/web/src/components/v2/terminal-input.tsx index 36ccd419..8874a249 100644 --- a/apps/web/src/components/v2/terminal-input.tsx +++ b/apps/web/src/components/v2/terminal-input.tsx @@ -8,6 +8,7 @@ import { Loader2, Send } from "lucide-react"; import { RepositoryBranchSelectors } from "../github/repo-branch-selectors"; import { Button } from "../ui/button"; import { useStream } from "@langchain/langgraph-sdk/react"; +import { StreamMode } from "@langchain/langgraph-sdk"; import { useRouter } from "next/navigation"; import { useGitHubAppProvider } from "@/providers/GitHubApp"; import { GraphState } from "@open-swe/shared/open-swe/types"; @@ -17,6 +18,7 @@ import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore"; import { API_KEY_REQUIRED_MESSAGE, MANAGER_GRAPH_ID, + OPEN_SWE_STREAM_MODE, } from "@open-swe/shared/constants"; import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; import { useDraftStorage } from "@/hooks/useDraftStorage"; @@ -104,7 +106,7 @@ export function TerminalInput({ }, ifNotExists: "create", streamResumable: true, - streamMode: ["values", "messages-tuple", "custom"], + streamMode: OPEN_SWE_STREAM_MODE as StreamMode[], }, ); diff --git a/packages/shared/src/agent-inbox-interrupt.ts b/packages/shared/src/agent-inbox-interrupt.ts new file mode 100644 index 00000000..ca1457d4 --- /dev/null +++ b/packages/shared/src/agent-inbox-interrupt.ts @@ -0,0 +1,19 @@ +import { HumanInterrupt } from "@langchain/langgraph/prebuilt"; + +export function isAgentInboxInterruptSchema( + value: unknown, +): value is HumanInterrupt | HumanInterrupt[] { + const valueAsObject = Array.isArray(value) ? value[0] : value; + return ( + valueAsObject && + typeof valueAsObject === "object" && + "action_request" in valueAsObject && + typeof valueAsObject.action_request === "object" && + "config" in valueAsObject && + typeof valueAsObject.config === "object" && + "allow_respond" in valueAsObject.config && + "allow_accept" in valueAsObject.config && + "allow_edit" in valueAsObject.config && + "allow_ignore" in valueAsObject.config + ); +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b74ef1c3..cf25d7b7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -42,3 +42,11 @@ export const DEFAULT_MCP_SERVERS = { export const API_KEY_REQUIRED_MESSAGE = "Unknown users must provide API keys to use the Open SWE demo application"; + +export const OPEN_SWE_STREAM_MODE = [ + "values", + "updates", + "messages", + "messages-tuple", + "custom", +]; diff --git a/yarn.lock b/yarn.lock index 720892ab..18a76b5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4143,11 +4143,14 @@ __metadata: dependencies: "@eslint/eslintrc": ^3.1.0 "@eslint/js": ^9.19.0 + "@langchain/langgraph-sdk": ^0.0.95 + "@open-swe/shared": "*" "@tsconfig/recommended": ^1.0.8 "@types/express": ^4.17.21 - "@types/node": ^22.13.5 + "@types/node": ^24.1.0 "@types/node-fetch": ^2.6.4 "@types/react": ^19.1.8 + "@types/uuid": ^10.0.0 "@typescript-eslint/eslint-plugin": ^8.38.0 "@typescript-eslint/parser": ^8.38.0 dotenv: ^16.6.1 @@ -4165,6 +4168,7 @@ __metadata: react: ^19.1.0 tsx: ^4.20.3 typescript: ^5.8.3 + uuid: ^10.0.0 languageName: unknown linkType: soft @@ -7051,6 +7055,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.1.0": + version: 24.1.0 + resolution: "@types/node@npm:24.1.0" + dependencies: + undici-types: ~7.8.0 + checksum: 01f9a97909eec619d937af3bc00ac49461e1846656b4d060f648145df06508eb6d77c200637a792481ee93a73976ced46cfbbdfe307e79be0f58ccdc415dfcd2 + languageName: node + linkType: hard + "@types/prismjs@npm:^1.26.0": version: 1.26.5 resolution: "@types/prismjs@npm:1.26.5"