fix: web (#35)

* fix: web

* cr

* cr
This commit is contained in:
Brace Sproul 2025-05-28 13:25:30 -07:00 • committed by GitHub
parent a12ff5114e
commit ed9862bbbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 140 additions and 190 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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`;
}

View file

@ -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) {

View file

@ -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;

View file

@ -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,
},
},
);

View file

@ -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,
},
},
);
};

View file

@ -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);

View file

@ -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&apos;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}
>

View file

@ -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);