mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
parent
a12ff5114e
commit
ed9862bbbb
10 changed files with 140 additions and 190 deletions
|
|
@ -13,23 +13,13 @@ import {
|
|||
} from "../utils/git/index.js";
|
||||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
import {
|
||||
getMissingKeysFromObjectSchema,
|
||||
formatBadArgsError,
|
||||
zodSchemaToString,
|
||||
} from "../utils/zod-to-string.js";
|
||||
import { z } from "zod";
|
||||
import { Command } from "@langchain/langgraph";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "TakeAction");
|
||||
|
||||
function formatBadArgsError(schema: z.ZodTypeAny, args: any) {
|
||||
const missingKeys = getMissingKeysFromObjectSchema(schema, args);
|
||||
return `Invalid arguments for tool call. Expected:\n${zodSchemaToString(
|
||||
schema,
|
||||
)}.\nGot:\n${JSON.stringify(args)}\nMissing keys:\n - ${missingKeys.join(
|
||||
"\n - ",
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not to route to the diagnose error step. This is true if:
|
||||
* - the last two tool messages are of an error status
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { isAIMessage, ToolMessage } from "@langchain/core/messages";
|
|||
import { applyPatchTool, shellTool } from "../../../tools/index.js";
|
||||
import { GraphConfig } from "../../../types.js";
|
||||
import { PlannerGraphState, PlannerGraphUpdate } from "../types.js";
|
||||
import { createLogger, LogLevel } from "../../../utils/logger.js";
|
||||
import { zodSchemaToString } from "../../../utils/zod-to-string.js";
|
||||
import { formatBadArgsError } from "../../../utils/zod-to-string.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "TakeAction");
|
||||
|
||||
export async function takeAction(
|
||||
state: PlannerGraphState,
|
||||
|
|
@ -31,12 +36,41 @@ export async function takeAction(
|
|||
throw new Error(`Unknown tool: ${toolCall.name}`);
|
||||
}
|
||||
|
||||
// @ts-expect-error tool.invoke types are weird here...
|
||||
const result: string = await tool.invoke(toolCall.args);
|
||||
let result = "";
|
||||
let toolCallStatus: "success" | "error" = "success";
|
||||
try {
|
||||
const toolResult: { result: string; status: "success" | "error" } =
|
||||
// @ts-expect-error tool.invoke types are weird here...
|
||||
await tool.invoke(toolCall.args);
|
||||
result = toolResult.result;
|
||||
toolCallStatus = toolResult.status;
|
||||
} catch (e) {
|
||||
toolCallStatus = "error";
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message === "Received tool input did not match expected schema"
|
||||
) {
|
||||
logger.error("Received tool input did not match expected schema", {
|
||||
toolCall,
|
||||
expectedSchema: zodSchemaToString(tool.schema),
|
||||
});
|
||||
result = formatBadArgsError(tool.schema, toolCall.args);
|
||||
} else {
|
||||
logger.error("Failed to call tool", {
|
||||
...(e instanceof Error
|
||||
? { name: e.name, message: e.message, stack: e.stack }
|
||||
: { error: e }),
|
||||
});
|
||||
const errMessage = e instanceof Error ? e.message : "Unknown error";
|
||||
result = `FAILED TO CALL TOOL: "${toolCall.name}"\n\nError: ${errMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
const toolMessage = new ToolMessage({
|
||||
tool_call_id: toolCall.id ?? "",
|
||||
content: result,
|
||||
name: toolCall.name,
|
||||
status: toolCallStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -59,3 +59,12 @@ function getZodType(schema: z.ZodTypeAny): string {
|
|||
|
||||
return def.typeName || "unknown";
|
||||
}
|
||||
|
||||
export function formatBadArgsError(schema: z.ZodTypeAny, args: any) {
|
||||
const missingKeys = getMissingKeysFromObjectSchema(schema, args);
|
||||
return `Invalid arguments for tool call. Expected:\n${zodSchemaToString(
|
||||
schema,
|
||||
)}.\nGot:\n${JSON.stringify(args)}\nMissing keys:\n - ${missingKeys.join(
|
||||
"\n - ",
|
||||
)}\n`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function ThreadActionsView({
|
|||
} = useInterruptedActions({
|
||||
interrupt,
|
||||
});
|
||||
const [apiUrl] = useQueryState("apiUrl");
|
||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
const handleOpenInStudio = () => {
|
||||
if (!apiUrl) {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,16 @@ export default function useInterruptedActions({
|
|||
command: {
|
||||
resume: response,
|
||||
},
|
||||
config: {
|
||||
// TODO: Make configurable (just that target_repository object) & recursion limit in the UI.
|
||||
configurable: {
|
||||
target_repository: {
|
||||
owner: "langchain-ai",
|
||||
repo: "open-swe",
|
||||
},
|
||||
},
|
||||
recursion_limit: 400,
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ function OpenGitHubRepo() {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/langchain-ai/agent-chat-ui"
|
||||
href="https://github.com/langchain-ai/open-swe"
|
||||
target="_blank"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
|
|
@ -228,6 +228,16 @@ export function Thread() {
|
|||
newHumanMessage,
|
||||
],
|
||||
}),
|
||||
config: {
|
||||
// TODO: Make configurable (just that target_repository object) & recursion limit in the UI.
|
||||
configurable: {
|
||||
target_repository: {
|
||||
owner: "langchain-ai",
|
||||
repo: "open-swe",
|
||||
},
|
||||
},
|
||||
recursion_limit: 400,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,16 @@ export function HumanMessage({
|
|||
messages: [...(values.messages ?? []), newMessage],
|
||||
};
|
||||
},
|
||||
config: {
|
||||
// TODO: Make configurable (just that target_repository object) & recursion limit in the UI.
|
||||
configurable: {
|
||||
target_repository: {
|
||||
owner: "langchain-ai",
|
||||
repo: "open-swe",
|
||||
},
|
||||
},
|
||||
recursion_limit: 400,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||
* - If unknown, returns 'Multimodal message'.
|
||||
*/
|
||||
export function getContentString(content: Message["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (typeof content === "string" || !content) return content;
|
||||
const texts = content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import React, { createContext, useContext, ReactNode, useState } from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { type Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
|
|
@ -15,15 +9,11 @@ import {
|
|||
type RemoveUIMessage,
|
||||
} from "@langchain/langgraph-sdk/react-ui";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { getApiKey } from "@/lib/api-key";
|
||||
import { useThreads } from "./Thread";
|
||||
import { toast } from "sonner";
|
||||
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
|
||||
import { Copy, CopyCheck } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export type StateType = { messages: Message[]; ui?: UIMessage[] };
|
||||
|
||||
|
|
@ -46,34 +36,12 @@ async function sleep(ms = 4000) {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function checkGraphStatus(
|
||||
apiUrl: string,
|
||||
apiKey: string | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/info`, {
|
||||
...(apiKey && {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const StreamSession = ({
|
||||
children,
|
||||
apiKey,
|
||||
apiUrl,
|
||||
assistantId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
apiKey: string | null;
|
||||
apiUrl: string;
|
||||
assistantId: string;
|
||||
}) => {
|
||||
|
|
@ -81,7 +49,6 @@ const StreamSession = ({
|
|||
const { getThreads, setThreads } = useThreads();
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl,
|
||||
apiKey: apiKey ?? undefined,
|
||||
assistantId,
|
||||
threadId: threadId ?? null,
|
||||
onCustomEvent: (event, options) => {
|
||||
|
|
@ -100,24 +67,6 @@ const StreamSession = ({
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
checkGraphStatus(apiUrl, apiKey).then((ok) => {
|
||||
if (!ok) {
|
||||
toast.error("Failed to connect to LangGraph server", {
|
||||
description: () => (
|
||||
<p>
|
||||
Please ensure your graph is running at <code>{apiUrl}</code> and
|
||||
your API key is correctly set (if connecting to a deployed graph).
|
||||
</p>
|
||||
),
|
||||
duration: 10000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [apiKey, apiUrl]);
|
||||
|
||||
return (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
{children}
|
||||
|
|
@ -125,138 +74,75 @@ const StreamSession = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Default values for the form
|
||||
const DEFAULT_API_URL = "http://localhost:2024";
|
||||
const DEFAULT_ASSISTANT_ID = "agent";
|
||||
|
||||
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
// Get environment variables
|
||||
const envApiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL;
|
||||
const envAssistantId: string | undefined =
|
||||
process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||
const baseCopyTooltipText = "Copy environment variables";
|
||||
const [copyTooltipText, setCopyTooltipText] = useState(baseCopyTooltipText);
|
||||
|
||||
// Use URL params with env var fallbacks
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
|
||||
defaultValue: envApiUrl || "",
|
||||
});
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId", {
|
||||
defaultValue: envAssistantId || "",
|
||||
});
|
||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
const assistantId: string | undefined =
|
||||
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
|
||||
|
||||
// For API key, use localStorage with env var fallback
|
||||
const [apiKey, _setApiKey] = useState(() => {
|
||||
const storedKey = getApiKey();
|
||||
return storedKey || "";
|
||||
});
|
||||
|
||||
const setApiKey = (key: string) => {
|
||||
window.localStorage.setItem("lg:chat:apiKey", key);
|
||||
_setApiKey(key);
|
||||
};
|
||||
|
||||
// Determine final values to use, prioritizing URL params then env vars
|
||||
const finalApiUrl = apiUrl || envApiUrl;
|
||||
const finalAssistantId = assistantId || envAssistantId;
|
||||
|
||||
// Show the form if we: don't have an API URL, or don't have an assistant ID
|
||||
if (!finalApiUrl || !finalAssistantId) {
|
||||
if (!apiUrl || !assistantId) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 bg-background flex max-w-3xl flex-col rounded-lg border shadow-lg">
|
||||
<div className="mt-14 flex flex-col gap-2 border-b p-6">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 flex max-w-3xl flex-col rounded-lg border bg-red-50 shadow-lg">
|
||||
<div className="flex flex-col gap-4 border-b p-6">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<LangGraphLogoSVG className="h-7" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
Environment Variables Missing
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Agent Chat! Before you get started, you need to enter
|
||||
the URL of the deployment and the assistant / graph ID.
|
||||
Whoops, looks like you don't have an API URL or assistant ID
|
||||
set in your environment variables. Please make sure you have both
|
||||
of these set before continuing.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const apiUrl = formData.get("apiUrl") as string;
|
||||
const assistantId = formData.get("assistantId") as string;
|
||||
const apiKey = formData.get("apiKey") as string;
|
||||
|
||||
setApiUrl(apiUrl);
|
||||
setApiKey(apiKey);
|
||||
setAssistantId(assistantId);
|
||||
|
||||
form.reset();
|
||||
}}
|
||||
className="bg-muted/50 flex flex-col gap-6 p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiUrl">
|
||||
Deployment URL<span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is the URL of your LangGraph deployment. Can be a local, or
|
||||
production deployment.
|
||||
</p>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
className="bg-background"
|
||||
defaultValue={apiUrl || DEFAULT_API_URL}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assistantId">
|
||||
Assistant / Graph ID<span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is the ID of the graph (can be the graph name), or
|
||||
assistant to fetch threads from, and invoke when actions are
|
||||
taken.
|
||||
</p>
|
||||
<Input
|
||||
id="assistantId"
|
||||
name="assistantId"
|
||||
className="bg-background"
|
||||
defaultValue={assistantId || DEFAULT_ASSISTANT_ID}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiKey">LangSmith API Key</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is <strong>NOT</strong> required if using a local LangGraph
|
||||
server. This value is stored in your browser's local storage and
|
||||
is only used to authenticate requests sent to your LangGraph
|
||||
server.
|
||||
</p>
|
||||
<PasswordInput
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
defaultValue={apiKey ?? ""}
|
||||
className="bg-background"
|
||||
placeholder="lsv2_pt_..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
<div className="relative">
|
||||
<TooltipIconButton
|
||||
onClick={() => {
|
||||
const textToCopy = `NEXT_PUBLIC_API_URL=${apiUrl}\nNEXT_PUBLIC_ASSISTANT_ID=${assistantId}`;
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
setCopyTooltipText("Copied!");
|
||||
setTimeout(
|
||||
() => setCopyTooltipText(baseCopyTooltipText),
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}}
|
||||
className="absolute top-2 right-2 cursor-pointer"
|
||||
tooltip={copyTooltipText}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-5" />
|
||||
</Button>
|
||||
{copyTooltipText === baseCopyTooltipText ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<CopyCheck className="text-green-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
<code className="bg-muted flex flex-col gap-2 rounded-md border border-red-200 px-4 py-3 text-sm">
|
||||
<span>NEXT_PUBLIC_API_URL={apiUrl}</span>
|
||||
<span>NEXT_PUBLIC_ASSISTANT_ID={assistantId}</span>
|
||||
</code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -264,7 +150,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
|||
|
||||
return (
|
||||
<StreamSession
|
||||
apiKey={apiKey}
|
||||
apiUrl={apiUrl}
|
||||
assistantId={assistantId}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ function getThreadSearchMetadata(
|
|||
}
|
||||
|
||||
export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||
const [apiUrl] = useQueryState("apiUrl");
|
||||
const [assistantId] = useQueryState("assistantId");
|
||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
const assistantId: string | undefined =
|
||||
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
|
||||
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [threadsLoading, setThreadsLoading] = useState(false);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue