mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +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_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=""
|
||||
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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ interface TerminalInterfaceProps {
|
|||
message: string | null;
|
||||
setMessage: () => void;
|
||||
CustomInput: React.FC<{ onSubmit: () => void }>;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
|
||||
message,
|
||||
setMessage,
|
||||
CustomInput,
|
||||
repoName,
|
||||
}) => {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
|
|
@ -28,13 +30,18 @@ const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
|
|||
marginTop={0}
|
||||
marginBottom={0}
|
||||
>
|
||||
<CustomInput onSubmit={setMessage} />
|
||||
<CustomInput onSubmit={() => setMessage()} />
|
||||
</Box>
|
||||
{message && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="green">You typed: {message}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{repoName && (
|
||||
<Box marginTop={0} marginBottom={0}>
|
||||
<Text color="gray">Repository: {repoName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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 (
|
||||
<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 [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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,10 +166,72 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
|
|||
const App: React.FC = () => {
|
||||
const [authPrompt, setAuthPrompt] = useState<null | boolean>(null);
|
||||
const [authInput, setAuthInput] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [exit, setExit] = useState(false);
|
||||
const [authStarted, setAuthStarted] = 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
|
||||
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<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
|
||||
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 (
|
||||
<Box flexDirection="column" height={"100%"}>
|
||||
<TerminalInterface
|
||||
message={message}
|
||||
setMessage={() => setMessage(null)}
|
||||
CustomInput={CustomInput}
|
||||
/>
|
||||
<Box flexGrow={1} />
|
||||
<Box width="100%" justifyContent="flex-end">
|
||||
<Text color="gray" dimColor>
|
||||
logged in
|
||||
<Box flexDirection="row" alignItems="center" gap={2}>
|
||||
<Text>Plan feedback:</Text>
|
||||
{options.map((option, idx) => (
|
||||
<Text
|
||||
key={option.value}
|
||||
dimColor={idx !== highlighted}
|
||||
bold={idx === highlighted}
|
||||
>
|
||||
{idx === highlighted ? "[" : " "}
|
||||
{option.label}
|
||||
{idx === highlighted ? "]" : " "}
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
if (authPrompt === null) {
|
||||
// Repo selection UI
|
||||
if (isLoggedIn && repos.length > 0 && (selectingRepo || !selectedRepo)) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color="magenta">
|
||||
LangChain Open SWE CLI
|
||||
</Text>
|
||||
<Text bold>LangChain Open SWE CLI</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>Select a repository to work with (type to search):</Text>
|
||||
</Box>
|
||||
<Box
|
||||
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}
|
||||
paddingY={1}
|
||||
marginTop={1}
|
||||
|
|
@ -141,21 +640,8 @@ const App: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<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>
|
||||
);
|
||||
// Fallback
|
||||
return <Box flexDirection="column" padding={1}></Box>;
|
||||
};
|
||||
|
||||
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",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.", {
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
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 =
|
||||
"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:
|
||||
"@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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue