mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 08:33:14 +00:00
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 <braceasproul@gmail.com> * 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 <braceasproul@gmail.com>
This commit is contained in:
parent
68216a88e6
commit
01a514b1fa
19 changed files with 1441 additions and 140 deletions
|
|
@ -1,12 +1,14 @@
|
||||||
GITHUB_CLIENT_ID=""
|
GITHUB_APP_CLIENT_ID=""
|
||||||
GITHUB_CLIENT_SECRET=""
|
GITHUB_APP_CLIENT_SECRET=""
|
||||||
GITHUB_CALLBACK_URL=""
|
|
||||||
GITHUB_LOGIN_URL=""
|
|
||||||
GITHUB_ACCESS_TOKEN_LOGIN_LINK=
|
|
||||||
GITHUB_APP_NAME="open-swe-dev"
|
GITHUB_APP_NAME="open-swe-dev"
|
||||||
GITHUB_APP_ID=""
|
GITHUB_APP_ID=""
|
||||||
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||||
...add your private key here...
|
...add your private key here...
|
||||||
-----END RSA PRIVATE KEY-----
|
-----END RSA PRIVATE KEY-----
|
||||||
"
|
"
|
||||||
|
|
||||||
|
GITHUB_CALLBACK_URL=""
|
||||||
|
GITHUB_LOGIN_URL=""
|
||||||
|
GITHUB_ACCESS_TOKEN_LOGIN_LINK=
|
||||||
|
|
||||||
SECRETS_ENCRYPTION_KEY=""
|
SECRETS_ENCRYPTION_KEY=""
|
||||||
30
apps/cli/README.md
Normal file
30
apps/cli/README.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -12,26 +12,31 @@
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test": "echo \"Error: no test specified\" && exit 0",
|
"test": "echo \"No tests yet\" && exit 0",
|
||||||
"dev": "tsx src/index.tsx"
|
"dev": "tsx src/index.tsx",
|
||||||
|
"cli": "npx tsc && node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@langchain/langgraph-sdk": "^0.0.95",
|
||||||
|
"@open-swe/shared": "*",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"ink": "^6.0.1",
|
"ink": "^6.0.1",
|
||||||
"keytar": "^7.9.0",
|
"keytar": "^7.9.0",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"open": "^9.1.0",
|
"open": "^9.1.0",
|
||||||
"react": "^19.1.0"
|
"react": "^19.1.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@tsconfig/recommended": "^1.0.8",
|
"@tsconfig/recommended": "^1.0.8",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^24.1.0",
|
||||||
"@types/node-fetch": "^2.6.4",
|
"@types/node-fetch": "^2.6.4",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||||
"@typescript-eslint/parser": "^8.38.0",
|
"@typescript-eslint/parser": "^8.38.0",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ interface TerminalInterfaceProps {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
setMessage: () => void;
|
setMessage: () => void;
|
||||||
CustomInput: React.FC<{ onSubmit: () => void }>;
|
CustomInput: React.FC<{ onSubmit: () => void }>;
|
||||||
|
repoName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
|
const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
|
||||||
message,
|
message,
|
||||||
setMessage,
|
setMessage,
|
||||||
CustomInput,
|
CustomInput,
|
||||||
|
repoName,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" padding={1}>
|
||||||
|
|
@ -28,13 +30,18 @@ const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
|
||||||
marginTop={0}
|
marginTop={0}
|
||||||
marginBottom={0}
|
marginBottom={0}
|
||||||
>
|
>
|
||||||
<CustomInput onSubmit={setMessage} />
|
<CustomInput onSubmit={() => setMessage()} />
|
||||||
</Box>
|
</Box>
|
||||||
{message && (
|
{message && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color="green">You typed: {message}</Text>
|
<Text color="green">You typed: {message}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{repoName && (
|
||||||
|
<Box marginTop={0} marginBottom={0}>
|
||||||
|
<Text color="gray">Repository: {repoName}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import type { Request, Response } from "express";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
const CLIENT_ID = process.env.GITHUB_CLIENT_ID || "";
|
const CLIENT_ID = process.env.GITHUB_APP_CLIENT_ID || "";
|
||||||
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || "";
|
const CLIENT_SECRET = process.env.GITHUB_APP_CLIENT_SECRET || "";
|
||||||
const PORT = 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const CALLBACK_URL = "http://localhost:3000/api/auth/github/callback";
|
const CALLBACK_URL =
|
||||||
|
process.env.GITHUB_CALLBACK_URL ||
|
||||||
|
`http://localhost:${PORT}/api/auth/github/callback`;
|
||||||
|
|
||||||
const TOKEN_PATH = path.join(
|
const TOKEN_PATH = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
|
|
@ -16,38 +19,25 @@ const TOKEN_PATH = path.join(
|
||||||
"github_token.json",
|
"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 accessToken: string | null = null;
|
||||||
|
let serverStarted = false;
|
||||||
|
|
||||||
interface GitHubTokenResponse {
|
function saveToken(tokenData: any) {
|
||||||
access_token?: string;
|
|
||||||
token_type?: string;
|
|
||||||
scope?: string;
|
|
||||||
error?: string;
|
|
||||||
error_description?: string;
|
|
||||||
error_uri?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveToken(tokenData: GitHubTokenResponse) {
|
|
||||||
const dir = path.dirname(TOKEN_PATH);
|
const dir = path.dirname(TOKEN_PATH);
|
||||||
try {
|
if (!fs.existsSync(dir)) {
|
||||||
if (!fs.existsSync(dir)) {
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadToken() {
|
function loadToken() {
|
||||||
try {
|
if (fs.existsSync(TOKEN_PATH)) {
|
||||||
if (fs.existsSync(TOKEN_PATH)) {
|
return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8"));
|
||||||
return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8"));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load token:", err);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -57,52 +47,73 @@ const app = express();
|
||||||
// 1. Start OAuth flow
|
// 1. Start OAuth flow
|
||||||
app.get("/api/auth/github/login", (_req: Request, res: Response) => {
|
app.get("/api/auth/github/login", (_req: Request, res: Response) => {
|
||||||
const state = Math.random().toString(36).substring(2);
|
const state = Math.random().toString(36).substring(2);
|
||||||
const baseGithubAuthUrl = "https://github.com/login/oauth/authorize";
|
const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${state}`;
|
||||||
const url = new URL(baseGithubAuthUrl);
|
res.redirect(githubAuthUrl);
|
||||||
url.searchParams.set("client_id", CLIENT_ID);
|
|
||||||
url.searchParams.set("redirect_uri", CALLBACK_URL);
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
return res.redirect(url.toString());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Handle OAuth callback
|
// 2. Handle OAuth callback
|
||||||
app.get("/api/auth/github/callback", async (req: Request, res: Response) => {
|
app.get("/api/auth/github/callback", async (req: Request, res: Response) => {
|
||||||
const code = req.query.code as string;
|
const code = req.query.code as string;
|
||||||
// Optionally validate state here
|
const installationId = req.query.installation_id as string | undefined;
|
||||||
if (!code) {
|
let tokenData: any = loadToken() || {};
|
||||||
return res.status(400).send("Missing code parameter");
|
|
||||||
|
// 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 ||
|
// If installation_id is present, save it
|
||||||
"https://github.com/login/oauth/access_token";
|
if (installationId) {
|
||||||
// Exchange code for access token
|
tokenData.installation_id = installationId;
|
||||||
const tokenRes = await fetch(GITHUB_ACCESS_TOKEN_LOGIN_LINK, {
|
try {
|
||||||
method: "POST",
|
saveToken(tokenData);
|
||||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
} catch (err) {
|
||||||
body: JSON.stringify({
|
console.error("Failed to store installation_id in config file:", err);
|
||||||
client_id: CLIENT_ID,
|
}
|
||||||
client_secret: CLIENT_SECRET,
|
// Immediately fetch the installation access token to test the fetch and print info
|
||||||
code,
|
await getInstallationAccessToken();
|
||||||
redirect_uri: CALLBACK_URL,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tokenData = await tokenRes.json();
|
|
||||||
if (tokenData.error) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.send("Error exchanging code for token: " + tokenData.error);
|
|
||||||
}
|
}
|
||||||
accessToken = tokenData.access_token;
|
|
||||||
// Store the token in a config file
|
if (code || installationId) {
|
||||||
try {
|
res.send("Authentication successful! You can close this window.");
|
||||||
saveToken(tokenData);
|
} else {
|
||||||
} catch (err) {
|
res.status(400).send("Missing code or installation_id parameter");
|
||||||
console.error("Failed to store token in config file:", err);
|
|
||||||
}
|
}
|
||||||
return res.send("Authentication successful! You can close this window.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function startAuthServer() {
|
export function startAuthServer() {
|
||||||
|
if (serverStarted) return;
|
||||||
|
serverStarted = true;
|
||||||
app.listen(PORT, () => {});
|
app.listen(PORT, () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,3 +123,52 @@ export function getAccessToken() {
|
||||||
const tokenData = loadToken();
|
const tokenData = loadToken();
|
||||||
return tokenData ? tokenData.access_token : null;
|
return tokenData ? tokenData.access_token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInstallationId() {
|
||||||
|
const tokenData = loadToken();
|
||||||
|
return tokenData ? tokenData.installation_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInstallationAccessToken(): Promise<string | null> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,68 @@
|
||||||
#!/usr/bin/env node
|
#!/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 { 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 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 =
|
const GITHUB_LOGIN_URL =
|
||||||
process.env.GITHUB_LOGIN_URL || "http://localhost:3000/api/auth/github/login";
|
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 (
|
||||||
|
<Box justifyContent="center" paddingY={2}>
|
||||||
|
<Text>
|
||||||
|
{text}
|
||||||
|
{dots}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const CustomInput: React.FC<{ onSubmit: (value: string) => void }> = ({
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
useInput((inputChar, key) => {
|
useInput((inputChar: string, key: { [key: string]: any }) => {
|
||||||
if (isSubmitted) return;
|
if (isSubmitted) return;
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
setIsSubmitted(true);
|
if (input.trim()) {
|
||||||
onSubmit();
|
// 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) {
|
} else if (key.backspace || key.delete) {
|
||||||
setInput((prev) => prev.slice(0, -1));
|
setInput((prev) => prev.slice(0, -1));
|
||||||
} else if (inputChar) {
|
} else if (inputChar) {
|
||||||
|
|
@ -24,11 +70,95 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSubmitted) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color="cyan">> {input}</Text>
|
<Text>> {input}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text>Search repositories: {search}</Text>
|
||||||
|
</Box>
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>No matches found.</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
{shown.map((_, idx) => (
|
||||||
|
<Text key={shown[idx].id} dimColor={idx !== highlighted}>
|
||||||
|
{idx === highlighted ? "> " : " "}
|
||||||
|
{shown[idx].full_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>Use ↑/↓ to navigate, Enter to select</Text>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -36,10 +166,72 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [authPrompt, setAuthPrompt] = useState<null | boolean>(null);
|
const [authPrompt, setAuthPrompt] = useState<null | boolean>(null);
|
||||||
const [authInput, setAuthInput] = useState("");
|
const [authInput, setAuthInput] = useState("");
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [exit, setExit] = useState(false);
|
const [exit, setExit] = useState(false);
|
||||||
const [authStarted, setAuthStarted] = useState(false);
|
const [authStarted, setAuthStarted] = useState(false);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [repos, setRepos] = useState<any[]>([]);
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<any | null>(null);
|
||||||
|
const [selectingRepo, setSelectingRepo] = useState(false);
|
||||||
|
const [waitingForInstall, setWaitingForInstall] = useState(false);
|
||||||
|
const [installChecked, setInstallChecked] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState<string | null>(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<string[]>([]);
|
||||||
|
|
||||||
|
const [plannerFeedback, setPlannerFeedback] = useState<string | null>(null);
|
||||||
|
const [streamingPhase, setStreamingPhase] = useState<
|
||||||
|
"streaming" | "awaitingFeedback" | "done"
|
||||||
|
>("streaming");
|
||||||
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||||||
|
const [plannerThreadId, setPlannerThreadId] = useState<string | null>(null);
|
||||||
|
const [hasStartedChat, setHasStartedChat] = useState(false);
|
||||||
|
const [client, setClient] = useState<Client | null>(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
|
// On mount, check for existing token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -49,22 +241,56 @@ const App: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll for token after auth flow starts
|
// After login, fetch and store user repos
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authStarted && !isLoggedIn) {
|
if (isLoggedIn && repos.length === 0 && !loadingRepos) {
|
||||||
const interval = setInterval(() => {
|
const token = getAccessToken();
|
||||||
const token = getAccessToken();
|
if (token) {
|
||||||
if (token) {
|
setLoadingRepos(true);
|
||||||
setIsLoggedIn(true);
|
fetchUserRepos(token)
|
||||||
clearInterval(interval);
|
.then((repos) => {
|
||||||
}
|
setRepos(repos);
|
||||||
}, 1000);
|
setSelectingRepo(true);
|
||||||
return () => clearInterval(interval);
|
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<typeof setInterval>;
|
||||||
|
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
|
// Handle yes/no input for auth prompt
|
||||||
useInput((input, key) => {
|
useInput((input: string, key: { [key: string]: any }) => {
|
||||||
if (authPrompt === null && !isLoggedIn) {
|
if (authPrompt === null && !isLoggedIn) {
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
if (authInput.toLowerCase() === "y") {
|
if (authInput.toLowerCase() === "y") {
|
||||||
|
|
@ -76,7 +302,6 @@ const App: React.FC = () => {
|
||||||
} else if (key.backspace || key.delete) {
|
} else if (key.backspace || key.delete) {
|
||||||
setAuthInput((prev) => prev.slice(0, -1));
|
setAuthInput((prev) => prev.slice(0, -1));
|
||||||
} else if (input && authInput.length < 1) {
|
} else if (input && authInput.length < 1) {
|
||||||
// Only allow a single character (y/n)
|
|
||||||
setAuthInput(input);
|
setAuthInput(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,38 +320,312 @@ const App: React.FC = () => {
|
||||||
setAuthStarted(true);
|
setAuthStarted(true);
|
||||||
startAuthServer();
|
startAuthServer();
|
||||||
open(GITHUB_LOGIN_URL);
|
open(GITHUB_LOGIN_URL);
|
||||||
|
setPollingForToken(true);
|
||||||
}
|
}
|
||||||
}, [authPrompt, authStarted]);
|
}, [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 (
|
return (
|
||||||
<Box flexDirection="column" height={"100%"}>
|
<Box flexDirection="row" alignItems="center" gap={2}>
|
||||||
<TerminalInterface
|
<Text>Plan feedback:</Text>
|
||||||
message={message}
|
{options.map((option, idx) => (
|
||||||
setMessage={() => setMessage(null)}
|
<Text
|
||||||
CustomInput={CustomInput}
|
key={option.value}
|
||||||
/>
|
dimColor={idx !== highlighted}
|
||||||
<Box flexGrow={1} />
|
bold={idx === highlighted}
|
||||||
<Box width="100%" justifyContent="flex-end">
|
>
|
||||||
<Text color="gray" dimColor>
|
{idx === highlighted ? "[" : " "}
|
||||||
logged in
|
{option.label}
|
||||||
|
{idx === highlighted ? "]" : " "}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
))}
|
||||||
|
<Text dimColor>Use ←/→ to navigate, Enter to select</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<LoadingSpinner text="Loading your repositories" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authPrompt === null) {
|
// Repo selection UI
|
||||||
|
if (isLoggedIn && repos.length > 0 && (selectingRepo || !selectedRepo)) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" padding={1}>
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text bold color="magenta">
|
<Text bold>LangChain Open SWE CLI</Text>
|
||||||
LangChain Open SWE CLI
|
</Box>
|
||||||
</Text>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text>Select a repository to work with (type to search):</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor="gray"
|
borderColor="white"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginBottom={1}
|
||||||
|
>
|
||||||
|
<RepoSearchSelect
|
||||||
|
repos={repos}
|
||||||
|
onSelect={async (repo) => {
|
||||||
|
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/<slug>):",
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{waitingForInstall && (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<Text>Waiting for GitHub App installation to complete...</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
After installing the app, return here to continue.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{installChecked && !waitingForInstall && (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<Text>GitHub App installation detected! You can now proceed.</Text>
|
||||||
|
<Text dimColor>Press Enter to continue.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{installError && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text>{installError}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box flexDirection="column" height={process.stdout.rows}>
|
||||||
|
{/* Auto-scrolling logs area - strict boundary container */}
|
||||||
|
<Box
|
||||||
|
height={availableLogHeight}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
overflowY="hidden"
|
||||||
|
flexShrink={0}
|
||||||
|
justifyContent="flex-end"
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{loadingLogs && logs.length === 0 ? (
|
||||||
|
<LoadingSpinner text="Starting agent" />
|
||||||
|
) : (
|
||||||
|
visibleLogs.map((log, index) => (
|
||||||
|
<Box key={`${logs.length}-${index}`}>
|
||||||
|
<Text
|
||||||
|
dimColor={!log.startsWith("[AI]")}
|
||||||
|
bold={log.startsWith("[AI]")}
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Welcome message right above input bar */}
|
||||||
|
{!hasStartedChat ? (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
{`
|
||||||
|
|
||||||
|
## ### ## ## ###### ###### ## ## ### #### ## ##
|
||||||
|
## ## ## ### ## ## ## ## ## ## ## ## ## ## ### ##
|
||||||
|
## ## ## #### ## ## ## ## ## ## ## ## #### ##
|
||||||
|
## ## ## ## ## ## ## #### ## ######### ## ## ## ## ## ##
|
||||||
|
## ######### ## #### ## ## ## ## ## ######### ## ## ####
|
||||||
|
## ## ## ## ### ## ## ## ## ## ## ## ## ## ## ###
|
||||||
|
######## ## ## ## ## ###### ###### ## ## ## ## #### ## ##
|
||||||
|
`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={2} marginBottom={1}>
|
||||||
|
<Text dimColor>
|
||||||
|
Describe your coding problem. It'll run in the sandbox and a PR
|
||||||
|
will be created.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box height={8} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fixed input area at bottom */}
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={2}
|
||||||
|
borderStyle="single"
|
||||||
|
borderTop
|
||||||
|
height={3}
|
||||||
|
flexShrink={0}
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
{!hasStartedChat ? (
|
||||||
|
<CustomInput
|
||||||
|
onSubmit={(value) => {
|
||||||
|
setHasStartedChat(true);
|
||||||
|
setPlannerFeedback(null);
|
||||||
|
|
||||||
|
const streamingService = new StreamingService({
|
||||||
|
setLogs,
|
||||||
|
setPlannerThreadId,
|
||||||
|
setStreamingPhase,
|
||||||
|
setLoadingLogs,
|
||||||
|
setClient,
|
||||||
|
setThreadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingService.startNewSession(value, selectedRepo);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CustomInput
|
||||||
|
onSubmit={(value) => {
|
||||||
|
sendInterruptMessage(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Plan feedback below the input bar */}
|
||||||
|
{streamingPhase === "awaitingFeedback" && (
|
||||||
|
<Box flexDirection="column" paddingX={2} marginTop={1}>
|
||||||
|
<PlanFeedbackSelect />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth prompt UI
|
||||||
|
if (!isLoggedIn && authPrompt === null) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor="white"
|
||||||
paddingX={2}
|
paddingX={2}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
|
|
@ -141,21 +640,8 @@ const App: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Fallback
|
||||||
<Box flexDirection="column" padding={1}>
|
return <Box flexDirection="column" padding={1}></Box>;
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
|
||||||
<Text bold color="magenta">
|
|
||||||
LangChain Open SWE CLI
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{!message && <CustomInput onSubmit={() => setMessage(null)} />}
|
|
||||||
{message && (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color="green">You typed: {message}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
|
||||||
294
apps/cli/src/logger.ts
Normal file
294
apps/cli/src/logger.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
237
apps/cli/src/streaming.ts
Normal file
237
apps/cli/src/streaming.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/cli/src/utils.ts
Normal file
121
apps/cli/src/utils.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"types": ["node"],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,15 @@ import { getDefaultHeaders } from "../../../../utils/default-headers.js";
|
||||||
import { BASE_CLASSIFICATION_SCHEMA } from "./schemas.js";
|
import { BASE_CLASSIFICATION_SCHEMA } from "./schemas.js";
|
||||||
import { getPlansFromIssue } from "../../../../utils/github/issue-task.js";
|
import { getPlansFromIssue } from "../../../../utils/github/issue-task.js";
|
||||||
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
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 { createLogger, LogLevel } from "../../../../utils/logger.js";
|
||||||
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||||
import { createClassificationPromptAndToolSchema } from "./utils.js";
|
import { createClassificationPromptAndToolSchema } from "./utils.js";
|
||||||
import { RequestSource } from "../../../../constants.js";
|
import { RequestSource } from "../../../../constants.js";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
const logger = createLogger(LogLevel.INFO, "ClassifyMessage");
|
const logger = createLogger(LogLevel.INFO, "ClassifyMessage");
|
||||||
|
|
||||||
|
|
@ -263,7 +267,7 @@ export async function classifyMessage(
|
||||||
command: {
|
command: {
|
||||||
resume: plannerResume,
|
resume: plannerResume,
|
||||||
},
|
},
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
newPlannerId = newPlannerRun.run_id;
|
newPlannerId = newPlannerRun.run_id;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import {
|
||||||
ManagerGraphUpdate,
|
ManagerGraphUpdate,
|
||||||
} from "@open-swe/shared/open-swe/manager/types";
|
} from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { createIssueFieldsFromMessages } from "../utils/generate-issue-fields.js";
|
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 { createLangGraphClient } from "../../../utils/langgraph-client.js";
|
||||||
import { createIssue } from "../../../utils/github/api.js";
|
import { createIssue } from "../../../utils/github/api.js";
|
||||||
import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js";
|
import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js";
|
||||||
|
|
@ -20,6 +23,7 @@ import {
|
||||||
import { getBranchName } from "../../../utils/github/git.js";
|
import { getBranchName } from "../../../utils/github/git.js";
|
||||||
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
||||||
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new manager session.
|
* Create new manager session.
|
||||||
|
|
@ -92,7 +96,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`,
|
||||||
},
|
},
|
||||||
ifNotExists: "create",
|
ifNotExists: "create",
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,17 @@ import {
|
||||||
ManagerGraphUpdate,
|
ManagerGraphUpdate,
|
||||||
} from "@open-swe/shared/open-swe/manager/types";
|
} from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
|
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 { createLogger, LogLevel } from "../../../utils/logger.js";
|
||||||
import { getBranchName } from "../../../utils/github/git.js";
|
import { getBranchName } from "../../../utils/github/git.js";
|
||||||
import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types";
|
import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types";
|
||||||
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
||||||
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
||||||
import { getRecentUserRequest } from "../../../utils/user-request.js";
|
import { getRecentUserRequest } from "../../../utils/user-request.js";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
const logger = createLogger(LogLevel.INFO, "StartPlanner");
|
const logger = createLogger(LogLevel.INFO, "StartPlanner");
|
||||||
|
|
||||||
|
|
@ -54,7 +58,7 @@ export async function startPlanner(
|
||||||
ifNotExists: "create",
|
ifNotExists: "create",
|
||||||
multitaskStrategy: "enqueue",
|
multitaskStrategy: "enqueue",
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { AIMessage, BaseMessage } from "@langchain/core/messages";
|
import { AIMessage, BaseMessage } from "@langchain/core/messages";
|
||||||
import { Command, END, interrupt } from "@langchain/langgraph";
|
import { Command, END, interrupt } from "@langchain/langgraph";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
import {
|
import {
|
||||||
GraphUpdate,
|
GraphUpdate,
|
||||||
GraphConfig,
|
GraphConfig,
|
||||||
|
|
@ -23,6 +24,7 @@ import {
|
||||||
PLAN_INTERRUPT_DELIMITER,
|
PLAN_INTERRUPT_DELIMITER,
|
||||||
DO_NOT_RENDER_ID_PREFIX,
|
DO_NOT_RENDER_ID_PREFIX,
|
||||||
PROGRAMMER_GRAPH_ID,
|
PROGRAMMER_GRAPH_ID,
|
||||||
|
OPEN_SWE_STREAM_MODE,
|
||||||
} from "@open-swe/shared/constants";
|
} from "@open-swe/shared/constants";
|
||||||
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||||
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
|
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
|
||||||
|
|
@ -200,7 +202,7 @@ async function startProgrammerRun(input: {
|
||||||
ifNotExists: "create",
|
ifNotExists: "create",
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
streamSubgraphs: true,
|
streamSubgraphs: true,
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
GITHUB_USER_ID_HEADER,
|
GITHUB_USER_ID_HEADER,
|
||||||
GITHUB_USER_LOGIN_HEADER,
|
GITHUB_USER_LOGIN_HEADER,
|
||||||
MANAGER_GRAPH_ID,
|
MANAGER_GRAPH_ID,
|
||||||
|
OPEN_SWE_STREAM_MODE,
|
||||||
} from "@open-swe/shared/constants";
|
} from "@open-swe/shared/constants";
|
||||||
import { encryptSecret } from "@open-swe/shared/crypto";
|
import { encryptSecret } from "@open-swe/shared/crypto";
|
||||||
import { HumanMessage } from "@langchain/core/messages";
|
import { HumanMessage } from "@langchain/core/messages";
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { RequestSource } from "../../constants.js";
|
import { RequestSource } from "../../constants.js";
|
||||||
import { isAllowedUser } from "@open-swe/shared/github/allowed-users";
|
import { isAllowedUser } from "@open-swe/shared/github/allowed-users";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
|
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
|
||||||
|
|
||||||
|
|
@ -202,7 +204,7 @@ webhooks.on("issues.labeled", async ({ payload }) => {
|
||||||
config,
|
config,
|
||||||
ifNotExists: "create",
|
ifNotExists: "create",
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("Created new run from GitHub issue.", {
|
logger.info("Created new run from GitHub issue.", {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Loader2, Send } from "lucide-react";
|
||||||
import { RepositoryBranchSelectors } from "../github/repo-branch-selectors";
|
import { RepositoryBranchSelectors } from "../github/repo-branch-selectors";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import { StreamMode } from "@langchain/langgraph-sdk";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useGitHubAppProvider } from "@/providers/GitHubApp";
|
import { useGitHubAppProvider } from "@/providers/GitHubApp";
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||||
|
|
@ -17,6 +18,7 @@ import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
|
||||||
import {
|
import {
|
||||||
API_KEY_REQUIRED_MESSAGE,
|
API_KEY_REQUIRED_MESSAGE,
|
||||||
MANAGER_GRAPH_ID,
|
MANAGER_GRAPH_ID,
|
||||||
|
OPEN_SWE_STREAM_MODE,
|
||||||
} from "@open-swe/shared/constants";
|
} from "@open-swe/shared/constants";
|
||||||
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { useDraftStorage } from "@/hooks/useDraftStorage";
|
import { useDraftStorage } from "@/hooks/useDraftStorage";
|
||||||
|
|
@ -104,7 +106,7 @@ export function TerminalInput({
|
||||||
},
|
},
|
||||||
ifNotExists: "create",
|
ifNotExists: "create",
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
streamMode: ["values", "messages-tuple", "custom"],
|
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
19
packages/shared/src/agent-inbox-interrupt.ts
Normal file
19
packages/shared/src/agent-inbox-interrupt.ts
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -42,3 +42,11 @@ export const DEFAULT_MCP_SERVERS = {
|
||||||
|
|
||||||
export const API_KEY_REQUIRED_MESSAGE =
|
export const API_KEY_REQUIRED_MESSAGE =
|
||||||
"Unknown users must provide API keys to use the Open SWE demo application";
|
"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",
|
||||||
|
];
|
||||||
|
|
|
||||||
15
yarn.lock
15
yarn.lock
|
|
@ -4143,11 +4143,14 @@ __metadata:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint/eslintrc": ^3.1.0
|
"@eslint/eslintrc": ^3.1.0
|
||||||
"@eslint/js": ^9.19.0
|
"@eslint/js": ^9.19.0
|
||||||
|
"@langchain/langgraph-sdk": ^0.0.95
|
||||||
|
"@open-swe/shared": "*"
|
||||||
"@tsconfig/recommended": ^1.0.8
|
"@tsconfig/recommended": ^1.0.8
|
||||||
"@types/express": ^4.17.21
|
"@types/express": ^4.17.21
|
||||||
"@types/node": ^22.13.5
|
"@types/node": ^24.1.0
|
||||||
"@types/node-fetch": ^2.6.4
|
"@types/node-fetch": ^2.6.4
|
||||||
"@types/react": ^19.1.8
|
"@types/react": ^19.1.8
|
||||||
|
"@types/uuid": ^10.0.0
|
||||||
"@typescript-eslint/eslint-plugin": ^8.38.0
|
"@typescript-eslint/eslint-plugin": ^8.38.0
|
||||||
"@typescript-eslint/parser": ^8.38.0
|
"@typescript-eslint/parser": ^8.38.0
|
||||||
dotenv: ^16.6.1
|
dotenv: ^16.6.1
|
||||||
|
|
@ -4165,6 +4168,7 @@ __metadata:
|
||||||
react: ^19.1.0
|
react: ^19.1.0
|
||||||
tsx: ^4.20.3
|
tsx: ^4.20.3
|
||||||
typescript: ^5.8.3
|
typescript: ^5.8.3
|
||||||
|
uuid: ^10.0.0
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
|
@ -7051,6 +7055,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/prismjs@npm:^1.26.0":
|
||||||
version: 1.26.5
|
version: 1.26.5
|
||||||
resolution: "@types/prismjs@npm:1.26.5"
|
resolution: "@types/prismjs@npm:1.26.5"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue