From ed9862bbbb696a82b45193076c3c820332413431 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Wed, 28 May 2025 13:25:30 -0700 Subject: [PATCH] fix: web (#35) * fix: web * cr * cr --- apps/open-swe/src/nodes/take-action.ts | 12 +- .../subgraphs/planner/nodes/take-action.ts | 38 ++- apps/open-swe/src/utils/zod-to-string.ts | 9 + .../components/thread-actions-view.tsx | 2 +- .../hooks/use-interrupted-actions.tsx | 10 + apps/web/src/components/thread/index.tsx | 12 +- .../src/components/thread/messages/human.tsx | 10 + apps/web/src/components/thread/utils.ts | 2 +- apps/web/src/providers/Stream.tsx | 229 +++++------------- apps/web/src/providers/Thread.tsx | 6 +- 10 files changed, 140 insertions(+), 190 deletions(-) diff --git a/apps/open-swe/src/nodes/take-action.ts b/apps/open-swe/src/nodes/take-action.ts index e9531ef1..6025eba4 100644 --- a/apps/open-swe/src/nodes/take-action.ts +++ b/apps/open-swe/src/nodes/take-action.ts @@ -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 diff --git a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts b/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts index d18d2eb9..5503051c 100644 --- a/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts +++ b/apps/open-swe/src/subgraphs/planner/nodes/take-action.ts @@ -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 { diff --git a/apps/open-swe/src/utils/zod-to-string.ts b/apps/open-swe/src/utils/zod-to-string.ts index 4e66f177..d3be701b 100644 --- a/apps/open-swe/src/utils/zod-to-string.ts +++ b/apps/open-swe/src/utils/zod-to-string.ts @@ -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`; +} diff --git a/apps/web/src/components/thread/agent-inbox/components/thread-actions-view.tsx b/apps/web/src/components/thread/agent-inbox/components/thread-actions-view.tsx index 955e85f8..8d5cc6c3 100644 --- a/apps/web/src/components/thread/agent-inbox/components/thread-actions-view.tsx +++ b/apps/web/src/components/thread/agent-inbox/components/thread-actions-view.tsx @@ -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) { diff --git a/apps/web/src/components/thread/agent-inbox/hooks/use-interrupted-actions.tsx b/apps/web/src/components/thread/agent-inbox/hooks/use-interrupted-actions.tsx index 3cab16a9..e5d20103 100644 --- a/apps/web/src/components/thread/agent-inbox/hooks/use-interrupted-actions.tsx +++ b/apps/web/src/components/thread/agent-inbox/hooks/use-interrupted-actions.tsx @@ -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; diff --git a/apps/web/src/components/thread/index.tsx b/apps/web/src/components/thread/index.tsx index a3b8ad9e..1baeaaba 100644 --- a/apps/web/src/components/thread/index.tsx +++ b/apps/web/src/components/thread/index.tsx @@ -94,7 +94,7 @@ function OpenGitHubRepo() { @@ -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, + }, }, ); diff --git a/apps/web/src/components/thread/messages/human.tsx b/apps/web/src/components/thread/messages/human.tsx index f5168468..e422f92a 100644 --- a/apps/web/src/components/thread/messages/human.tsx +++ b/apps/web/src/components/thread/messages/human.tsx @@ -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, + }, }, ); }; diff --git a/apps/web/src/components/thread/utils.ts b/apps/web/src/components/thread/utils.ts index 1994f8ac..2afd5d3b 100644 --- a/apps/web/src/components/thread/utils.ts +++ b/apps/web/src/components/thread/utils.ts @@ -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); diff --git a/apps/web/src/providers/Stream.tsx b/apps/web/src/providers/Stream.tsx index b6d0c091..2f73a18d 100644 --- a/apps/web/src/providers/Stream.tsx +++ b/apps/web/src/providers/Stream.tsx @@ -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 { - 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: () => ( -

- Please ensure your graph is running at {apiUrl} and - your API key is correctly set (if connecting to a deployed graph). -

- ), - duration: 10000, - richColors: true, - closeButton: true, - }); - } - }); - }, [apiKey, apiUrl]); - return ( {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 (
-
-
+
+

- Agent Chat + Environment Variables Missing

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

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

- This is the URL of your LangGraph deployment. Can be a local, or - production deployment. -

- -
- -
- -

- This is the ID of the graph (can be the graph name), or - assistant to fetch threads from, and invoke when actions are - taken. -

- -
- -
- -

- This is NOT 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. -

- -
- -
- + {copyTooltipText === baseCopyTooltipText ? ( + + + + ) : ( + + + + )} + + + NEXT_PUBLIC_API_URL={apiUrl} + NEXT_PUBLIC_ASSISTANT_ID={assistantId} +
-
+
); @@ -264,7 +150,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ return ( diff --git a/apps/web/src/providers/Thread.tsx b/apps/web/src/providers/Thread.tsx index 759853f4..e93981ae 100644 --- a/apps/web/src/providers/Thread.tsx +++ b/apps/web/src/providers/Thread.tsx @@ -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([]); const [threadsLoading, setThreadsLoading] = useState(false);