From 3b7dee85f7ba597a7468fe4644f6bfb1ec7a21d7 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Tue, 15 Jul 2025 12:52:25 -0700 Subject: [PATCH] feat: Settings page (#357) * feat: Settings page * fix build: minor syntax error * rm old GH page, settings page functionality + dark mode * Fix build add suspense to settings page * add no GH installation functionality * rm comments, add cn utilities * add plus install new github org button to settings page * cr * cr * cr * cr --------- Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com> Co-authored-by: starmorph --- apps/docs/setup/development.mdx | 4 +- .../manager/nodes/create-new-session.ts | 2 + .../src/graphs/manager/nodes/start-planner.ts | 2 + .../manager/utils/generate-issue-fields.ts | 2 +- apps/open-swe/src/graphs/planner/index.ts | 19 +- .../src/graphs/planner/nodes/proposed-plan.ts | 2 + .../src/graphs/planner/nodes/take-action.ts | 36 +- apps/open-swe/src/graphs/reviewer/index.ts | 20 +- .../reviewer/nodes/take-review-action.ts | 26 +- apps/open-swe/src/utils/config.ts | 25 + apps/web/.env.example | 2 +- apps/web/src/app/(v2)/settings/page.tsx | 28 ++ .../src/app/api/auth/github/callback/route.ts | 2 +- .../src/app/api/auth/github/login/route.ts | 2 +- apps/web/src/app/github/page.tsx | 134 ----- .../configuration-sidebar/config-section.tsx | 28 -- .../configuration-sidebar/index.tsx | 178 ------- .../config-field.tsx | 15 +- .../components/github/github-page-loading.tsx | 30 -- .../src/components/github/repository-list.tsx | 146 ------ apps/web/src/components/v2/default-view.tsx | 22 +- .../src/features/settings-page/api-keys.tsx | 214 ++++++++ .../features/settings-page/config-manager.tsx | 241 +++++++++ .../features/settings-page/github-manager.tsx | 475 ++++++++++++++++++ apps/web/src/features/settings-page/index.tsx | 103 ++++ packages/shared/src/open-swe/types.ts | 32 +- 26 files changed, 1202 insertions(+), 588 deletions(-) create mode 100644 apps/open-swe/src/utils/config.ts create mode 100644 apps/web/src/app/(v2)/settings/page.tsx delete mode 100644 apps/web/src/app/github/page.tsx delete mode 100644 apps/web/src/components/configuration-sidebar/config-section.tsx delete mode 100644 apps/web/src/components/configuration-sidebar/index.tsx rename apps/web/src/components/{configuration-sidebar => configuration}/config-field.tsx (94%) delete mode 100644 apps/web/src/components/github/github-page-loading.tsx delete mode 100644 apps/web/src/components/github/repository-list.tsx create mode 100644 apps/web/src/features/settings-page/api-keys.tsx create mode 100644 apps/web/src/features/settings-page/config-manager.tsx create mode 100644 apps/web/src/features/settings-page/github-manager.tsx create mode 100644 apps/web/src/features/settings-page/index.tsx diff --git a/apps/docs/setup/development.mdx b/apps/docs/setup/development.mdx index 772014ab..49e13032 100644 --- a/apps/docs/setup/development.mdx +++ b/apps/docs/setup/development.mdx @@ -58,7 +58,7 @@ Before starting, ensure you have the following installed: ```bash Environment Variables [expandable] # GitHub App OAuth settings (will be filled after creating GitHub App) - GITHUB_APP_CLIENT_ID="" + NEXT_PUBLIC_GITHUB_APP_ID="" GITHUB_APP_CLIENT_SECRET="" GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback" @@ -189,7 +189,7 @@ Before starting, ensure you have the following installed: - **GITHUB_APP_NAME**: The name you chose - **GITHUB_APP_ID**: Found in the "About" section (e.g., `12345678`) - - **GITHUB_APP_CLIENT_ID**: Found in the "About" section + - **NEXT_PUBLIC_GITHUB_APP_ID**: Found in the "About" section - **GITHUB_APP_CLIENT_SECRET**: 1. Scroll to "Client secrets" section 2. Click "Generate new client secret" diff --git a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts index 835eaed0..ae0c751d 100644 --- a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts +++ b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts @@ -19,6 +19,7 @@ import { } from "../../../utils/github/issue-messages.js"; import { getBranchName } from "../../../utils/github/git.js"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; +import { getCustomConfigurableFields } from "../../../utils/config.js"; /** * Create new manager session. @@ -87,6 +88,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`, }, config: { recursion_limit: 400, + configurable: getCustomConfigurableFields(config), }, ifNotExists: "create", streamResumable: true, diff --git a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts index 38084e7c..f04364eb 100644 --- a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts +++ b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts @@ -10,6 +10,7 @@ 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"; const logger = createLogger(LogLevel.INFO, "StartPlanner"); @@ -43,6 +44,7 @@ export async function startPlanner( input: runInput, config: { recursion_limit: 400, + configurable: getCustomConfigurableFields(config), }, ifNotExists: "create", multitaskStrategy: "enqueue", diff --git a/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts b/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts index 3a5e9c4d..2040261f 100644 --- a/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts +++ b/apps/open-swe/src/graphs/manager/utils/generate-issue-fields.ts @@ -8,7 +8,7 @@ export async function createIssueFieldsFromMessages( messages: BaseMessage[], configurable: GraphConfig["configurable"], ): Promise<{ title: string; body: string }> { - const model = await loadModel({ configurable }, Task.SUMMARIZER); + const model = await loadModel({ configurable }, Task.ROUTER); const githubIssueTool = { name: "create_github_issue", description: "Create a new GitHub issue with the given title and body.", diff --git a/apps/open-swe/src/graphs/planner/index.ts b/apps/open-swe/src/graphs/planner/index.ts index f5c494dc..1cc08b6c 100644 --- a/apps/open-swe/src/graphs/planner/index.ts +++ b/apps/open-swe/src/graphs/planner/index.ts @@ -3,10 +3,7 @@ import { PlannerGraphState, PlannerGraphStateObj, } from "@open-swe/shared/open-swe/planner/types"; -import { - GraphConfig, - GraphConfiguration, -} from "@open-swe/shared/open-swe/types"; +import { GraphConfiguration } from "@open-swe/shared/open-swe/types"; import { generateAction, generatePlan, @@ -22,20 +19,10 @@ import { diagnoseError } from "../shared/diagnose-error.js"; function takeActionOrGeneratePlan( state: PlannerGraphState, - config: GraphConfig, ): "take-plan-actions" | "generate-plan" { const { messages } = state; const lastMessage = messages[messages.length - 1]; - // If the last message is a tool call, and we have executed less than 75 actions, take action. - // Max actions count is calculated as: maxContextActions * 2 + 1 - // This is because each action generates 2 messages (AI request + tool result) plus 1 initial human message - const maxContextActions = config.configurable?.maxContextActions ?? 75; - const maxActionsCount = maxContextActions * 2 + 1; - if ( - isAIMessage(lastMessage) && - lastMessage.tool_calls?.length && - messages.length < maxActionsCount - ) { + if (isAIMessage(lastMessage) && lastMessage.tool_calls?.length) { return "take-plan-actions"; } @@ -50,7 +37,7 @@ const workflow = new StateGraph(PlannerGraphStateObj, GraphConfiguration) .addNode("initialize-sandbox", initializeSandbox) .addNode("generate-plan-context-action", generateAction) .addNode("take-plan-actions", takeActions, { - ends: ["generate-plan-context-action", "diagnose-error"], + ends: ["generate-plan-context-action", "diagnose-error", "generate-plan"], }) .addNode("generate-plan", generatePlan) .addNode("notetaker", notetaker) diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 81cf84c7..8ea3a91c 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -33,6 +33,7 @@ import { CustomNodeEvent, } from "@open-swe/shared/open-swe/custom-node-events"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; +import { getCustomConfigurableFields } from "../../../utils/config.js"; const logger = createLogger(LogLevel.INFO, "ProposedPlan"); @@ -100,6 +101,7 @@ async function startProgrammerRun(input: { input: runInput, config: { recursion_limit: 400, + configurable: getCustomConfigurableFields(config), }, ifNotExists: "create", streamResumable: true, diff --git a/apps/open-swe/src/graphs/planner/nodes/take-action.ts b/apps/open-swe/src/graphs/planner/nodes/take-action.ts index ba7aa852..bd33377f 100644 --- a/apps/open-swe/src/graphs/planner/nodes/take-action.ts +++ b/apps/open-swe/src/graphs/planner/nodes/take-action.ts @@ -1,4 +1,8 @@ -import { isAIMessage, ToolMessage } from "@langchain/core/messages"; +import { + isAIMessage, + isToolMessage, + ToolMessage, +} from "@langchain/core/messages"; import { createGetURLContentTool, createShellTool, @@ -25,6 +29,7 @@ import { getMcpTools } from "../../../utils/mcp-client.js"; import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js"; import { shouldDiagnoseError } from "../../../utils/tool-message-error.js"; import { Command } from "@langchain/langgraph"; +import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js"; const logger = createLogger(LogLevel.INFO, "TakeAction"); @@ -183,11 +188,6 @@ ${tc.content}`, })), }); - const shouldRouteDiagnoseNode = shouldDiagnoseError([ - ...state.messages, - ...toolCallResults, - ]); - const commandUpdate: PlannerGraphUpdate = { messages: toolCallResults, sandboxSessionId: sandbox.id, @@ -195,6 +195,30 @@ ${tc.content}`, ...(dependenciesInstalled !== null && { dependenciesInstalled }), }; + const maxContextActions = config.configurable?.maxContextActions ?? 75; + const maxActionsCount = maxContextActions * 2; + // Exclude hidden messages, and messages that are not AI messages or tool messages. + const filteredMessages = filterHiddenMessages([ + ...state.messages, + ...(commandUpdate.messages ?? []), + ]).filter((m) => isAIMessage(m) || isToolMessage(m)); + if (filteredMessages.length >= maxActionsCount) { + // If we've exceeded the max actions count, we should generate a plan. + logger.info("Exceeded max actions count, generating plan.", { + maxActionsCount, + filteredMessages, + }); + return new Command({ + goto: "generate-plan", + update: commandUpdate, + }); + } + + const shouldRouteDiagnoseNode = shouldDiagnoseError([ + ...state.messages, + ...toolCallResults, + ]); + return new Command({ goto: shouldRouteDiagnoseNode ? "diagnose-error" diff --git a/apps/open-swe/src/graphs/reviewer/index.ts b/apps/open-swe/src/graphs/reviewer/index.ts index 3f84b92f..ec73942d 100644 --- a/apps/open-swe/src/graphs/reviewer/index.ts +++ b/apps/open-swe/src/graphs/reviewer/index.ts @@ -3,10 +3,7 @@ import { ReviewerGraphState, ReviewerGraphStateObj, } from "@open-swe/shared/open-swe/reviewer/types"; -import { - GraphConfig, - GraphConfiguration, -} from "@open-swe/shared/open-swe/types"; +import { GraphConfiguration } from "@open-swe/shared/open-swe/types"; import { finalReview, generateReviewActions, @@ -18,18 +15,11 @@ import { diagnoseError } from "../shared/diagnose-error.js"; function takeReviewActionsOrFinalReview( state: ReviewerGraphState, - config: GraphConfig, ): "take-review-actions" | "final-review" { const { reviewerMessages } = state; const lastMessage = reviewerMessages[reviewerMessages.length - 1]; - const maxReviewActions = config.configurable?.maxReviewActions ?? 30; - const maxActionsCount = maxReviewActions * 2 + 1; - if ( - isAIMessage(lastMessage) && - lastMessage.tool_calls?.length && - reviewerMessages.length < maxActionsCount - ) { + if (isAIMessage(lastMessage) && lastMessage.tool_calls?.length) { return "take-review-actions"; } @@ -41,7 +31,11 @@ const workflow = new StateGraph(ReviewerGraphStateObj, GraphConfiguration) .addNode("initialize-state", initializeState) .addNode("generate-review-actions", generateReviewActions) .addNode("take-review-actions", takeReviewerActions, { - ends: ["generate-review-actions", "diagnose-reviewer-error"], + ends: [ + "generate-review-actions", + "diagnose-reviewer-error", + "final-review", + ], }) .addNode("diagnose-reviewer-error", diagnoseError) .addNode("final-review", finalReview) diff --git a/apps/open-swe/src/graphs/reviewer/nodes/take-review-action.ts b/apps/open-swe/src/graphs/reviewer/nodes/take-review-action.ts index 24446d4e..534cff83 100644 --- a/apps/open-swe/src/graphs/reviewer/nodes/take-review-action.ts +++ b/apps/open-swe/src/graphs/reviewer/nodes/take-review-action.ts @@ -1,5 +1,9 @@ import { v4 as uuidv4 } from "uuid"; -import { isAIMessage, ToolMessage } from "@langchain/core/messages"; +import { + isAIMessage, + isToolMessage, + ToolMessage, +} from "@langchain/core/messages"; import { createInstallDependenciesTool, createShellTool, @@ -22,6 +26,7 @@ import { getRepoAbsolutePath } from "@open-swe/shared/git"; import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js"; import { Command } from "@langchain/langgraph"; import { shouldDiagnoseError } from "../../../utils/tool-message-error.js"; +import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js"; const logger = createLogger(LogLevel.INFO, "TakeReviewAction"); @@ -174,6 +179,25 @@ export async function takeReviewerActions( }), }; + const maxReviewActions = config.configurable?.maxReviewActions ?? 30; + const maxActionsCount = maxReviewActions * 2; + // Exclude hidden messages, and messages that are not AI messages or tool messages. + const filteredMessages = filterHiddenMessages([ + ...state.reviewerMessages, + ...(commandUpdate.reviewerMessages ?? []), + ]).filter((m) => isAIMessage(m) || isToolMessage(m)); + // If we've reached the max allowed review actions, go to final review. + if (filteredMessages.length >= maxActionsCount) { + logger.info("Exceeded max actions count, going to final review.", { + maxActionsCount, + filteredMessages, + }); + return new Command({ + goto: "final-review", + update: commandUpdate, + }); + } + const shouldRouteDiagnoseNode = shouldDiagnoseError([ ...state.reviewerMessages, ...toolCallResults, diff --git a/apps/open-swe/src/utils/config.ts b/apps/open-swe/src/utils/config.ts new file mode 100644 index 00000000..9d6d988b --- /dev/null +++ b/apps/open-swe/src/utils/config.ts @@ -0,0 +1,25 @@ +import { + GraphConfig, + GraphConfigurationMetadata, +} from "@open-swe/shared/open-swe/types"; + +export function getCustomConfigurableFields( + config: GraphConfig, +): Partial { + if (!config.configurable) return {}; + + const result: Partial = {}; + + for (const [key, metadataValue] of Object.entries( + GraphConfigurationMetadata, + )) { + if (key in config.configurable) { + if (metadataValue.x_open_swe_ui_config.type !== "hidden") { + result[key as keyof GraphConfig["configurable"]] = + config.configurable[key as keyof GraphConfig["configurable"]]; + } + } + } + + return result; +} diff --git a/apps/web/.env.example b/apps/web/.env.example index 4628f665..0745c53e 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,6 +1,6 @@ # ------------------Github App Secrets----------------- # GitHub app client secrets. Used for the GitHub OAuth login flow. -GITHUB_APP_CLIENT_ID="" +NEXT_PUBLIC_GITHUB_APP_ID="" GITHUB_APP_CLIENT_SECRET="" # Should be updated to your production URL when deployed. # This value should match the redirect URL you have configured in diff --git a/apps/web/src/app/(v2)/settings/page.tsx b/apps/web/src/app/(v2)/settings/page.tsx new file mode 100644 index 00000000..817a1fc1 --- /dev/null +++ b/apps/web/src/app/(v2)/settings/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Suspense } from "react"; +import SettingsPage from "@/features/settings-page/index"; + +function SettingsPageLoading() { + return ( +
+
+

Settings

+

Loading your settings...

+
+
+
+
+
+
+
+ ); +} + +export default function Page() { + return ( + }> + + + ); +} diff --git a/apps/web/src/app/api/auth/github/callback/route.ts b/apps/web/src/app/api/auth/github/callback/route.ts index c70339cf..cb2192ec 100644 --- a/apps/web/src/app/api/auth/github/callback/route.ts +++ b/apps/web/src/app/api/auth/github/callback/route.ts @@ -38,7 +38,7 @@ export async function GET(request: NextRequest) { ); } - const clientId = process.env.GITHUB_APP_CLIENT_ID; + const clientId = process.env.NEXT_PUBLIC_GITHUB_APP_ID; const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; const redirectUri = process.env.GITHUB_APP_REDIRECT_URI; diff --git a/apps/web/src/app/api/auth/github/login/route.ts b/apps/web/src/app/api/auth/github/login/route.ts index ac4e3670..0ed3ccdf 100644 --- a/apps/web/src/app/api/auth/github/login/route.ts +++ b/apps/web/src/app/api/auth/github/login/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; export async function GET(_request: NextRequest) { try { - const clientId = process.env.GITHUB_APP_CLIENT_ID; + const clientId = process.env.NEXT_PUBLIC_GITHUB_APP_ID; const redirectUri = process.env.GITHUB_APP_REDIRECT_URI; if (!clientId || !redirectUri) { diff --git a/apps/web/src/app/github/page.tsx b/apps/web/src/app/github/page.tsx deleted file mode 100644 index 516b24eb..00000000 --- a/apps/web/src/app/github/page.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { Suspense } from "react"; -import { useGitHubApp } from "@/hooks/useGitHubApp"; -import { GitHubAppProvider } from "@/providers/GitHubApp"; -import { InstallationSelector } from "@/components/github/installation-selector"; -import { GithubPageLoading } from "@/components/github/github-page-loading"; - -function GitHubPageContent() { - const { repositories, isLoading, error, isInstalled, refreshRepositories } = - useGitHubApp(); - - const handleInstall = async () => { - window.location.href = "/api/github/installation"; - }; - - return ( -
-
-

GitHub Repository Access

- {isInstalled && ( -
- -
- )} -
- - {error && ( -
- {error} -
- )} - - {!isInstalled ? ( -
-

Install GitHub App

-

- To access your GitHub repositories, you need to install our GitHub - App and grant it access to the repositories you want to use. -

- -
- ) : ( -
-
-

Your Repositories

- -
- - {repositories.length === 0 && !isLoading ? ( -

- No repositories found. Make sure you've granted access to at least - one repository for the selected organization. -

- ) : isLoading ? ( -

- Loading repositories... -

- ) : ( -
    - {repositories.map((repo) => ( -
  • -
    -
    -

    - - {repo.full_name} - -

    -

    - {repo.description || "No description"} -

    -
    - - {repo.private ? "Private" : "Public"} - - Default branch: {repo.default_branch} -
    -
    -
    -
  • - ))} -
- )} -
- )} - -
-

Manage GitHub App

-

- You can manage your GitHub App installation, including adding or - removing repositories, through GitHub. -

- - Manage on GitHub - -
-
- ); -} - -export default function GitHubPage() { - return ( - }> - - - - - ); -} diff --git a/apps/web/src/components/configuration-sidebar/config-section.tsx b/apps/web/src/components/configuration-sidebar/config-section.tsx deleted file mode 100644 index 9c0761de..00000000 --- a/apps/web/src/components/configuration-sidebar/config-section.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { cn } from "@/lib/utils"; - -interface ConfigSectionProps { - title: string; - children: ReactNode; - className?: string; - action?: ReactNode; -} - -export function ConfigSection({ - title, - children, - className, - action, -}: ConfigSectionProps) { - return ( -
-
-

{title}

- {action} -
-
{children}
-
- ); -} diff --git a/apps/web/src/components/configuration-sidebar/index.tsx b/apps/web/src/components/configuration-sidebar/index.tsx deleted file mode 100644 index f5d1d71f..00000000 --- a/apps/web/src/components/configuration-sidebar/index.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import { forwardRef, ForwardedRef, useState, useEffect } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ConfigField } from "@/components/configuration-sidebar/config-field"; -import { ConfigSection } from "@/components/configuration-sidebar/config-section"; -import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { cn } from "@/lib/utils"; -import { Skeleton } from "@/components/ui/skeleton"; -import type { ConfigurableFieldUIMetadata } from "@open-swe/shared/configurable-metadata"; -import { Button } from "@/components/ui/button"; -import { PanelRightOpen } from "lucide-react"; -import { GraphConfigurationMetadata } from "@open-swe/shared/open-swe/types"; -import { useQueryState } from "nuqs"; -import { useStreamContext } from "@/providers/Stream"; - -/** - * Extract configuration metadata from the GraphConfiguration Zod schema - */ -function extractConfigurationsFromSchema( - configurable: Record, -): ConfigurableFieldUIMetadata[] { - const configurations: ConfigurableFieldUIMetadata[] = []; - - for (const [label, { x_open_swe_ui_config: metadata }] of Object.entries( - GraphConfigurationMetadata, - )) { - if (metadata.type === "hidden") { - continue; - } - configurations.push({ - label, - type: metadata.type, - default: configurable[label] || metadata.default, - description: metadata.description, - placeholder: metadata.placeholder, - options: metadata.options, - min: metadata.min, - max: metadata.max, - step: metadata.step, - }); - } - - return configurations; -} - -export interface AIConfigPanelProps { - className?: string; - open: boolean; - onClose?: () => void; -} - -export const ConfigurationSidebar = forwardRef< - HTMLDivElement, - AIConfigPanelProps ->(({ className, open, onClose }, ref: ForwardedRef) => { - const { configs, updateConfig } = useConfigStore(); - const stream = useStreamContext(); - - const [threadId] = useQueryState("threadId"); - - const [configurations, setConfigurations] = useState< - ConfigurableFieldUIMetadata[] - >([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - setLoading(true); - const configKey = threadId || DEFAULT_CONFIG_KEY; - - if (threadId) { - stream.client.threads.get(threadId).then((t) => { - if ( - !("config" in t) || - !(t as any).config || - !(t as any).config.configurable - ) { - console.error("Thread does not have config key", t); - return; - } - - const actualConfigs = extractConfigurationsFromSchema( - (t.config as any).configurable, - ); - actualConfigs.forEach((c) => { - // Always update the config store with either the default values, or the values from the thread. - updateConfig(configKey, c.label, c.default); - }); - - setConfigurations(actualConfigs); - }); - } else { - const actualConfigs = extractConfigurationsFromSchema({}); - actualConfigs.forEach((c) => { - updateConfig(configKey, c.label, c.default); - }); - setConfigurations(actualConfigs); - } - setLoading(false); - }, [threadId]); - - return ( -
- {open && ( -
-
-

Agent Configuration

- {onClose && ( - - )} -
- - - - General - - - - - - {loading ? ( -
- - - -
- ) : ( - configurations.map( - (c: ConfigurableFieldUIMetadata, index: number) => ( - - ), - ) - )} -
-
-
-
-
- )} -
- ); -}); - -ConfigurationSidebar.displayName = "ConfigurationSidebar"; diff --git a/apps/web/src/components/configuration-sidebar/config-field.tsx b/apps/web/src/components/configuration/config-field.tsx similarity index 94% rename from apps/web/src/components/configuration-sidebar/config-field.tsx rename to apps/web/src/components/configuration/config-field.tsx index 2e6a0d69..4a291048 100644 --- a/apps/web/src/components/configuration-sidebar/config-field.tsx +++ b/apps/web/src/components/configuration/config-field.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import _ from "lodash"; import { cn } from "@/lib/utils"; import { useQueryState } from "nuqs"; +import { BasicMarkdownText } from "../thread/markdown-text"; interface Option { label: string; @@ -63,8 +64,6 @@ export function ConfigField({ value: externalValue, // TODO: Rename to avoid conflict setValue: externalSetValue, // TODO: Rename to avoid conflict }: ConfigFieldProps) { - const [threadId] = useQueryState("threadId"); - const configKey = threadId || DEFAULT_CONFIG_KEY; const store = useConfigStore(); const [jsonError, setJsonError] = useState(null); @@ -73,14 +72,14 @@ export function ConfigField({ const currentValue = isExternallyManaged ? externalValue - : store.configs[configKey]?.[id]; + : store.configs[DEFAULT_CONFIG_KEY]?.[id]; const handleChange = (newValue: any) => { setJsonError(null); if (isExternallyManaged && externalSetValue) { externalSetValue(newValue); } else { - store.updateConfig(configKey, id, newValue); + store.updateConfig(DEFAULT_CONFIG_KEY, id, newValue); } }; @@ -100,7 +99,7 @@ export function ConfigField({ if (isExternallyManaged && externalSetValue) { externalSetValue(jsonString); } else { - store.updateConfig(configKey, id, jsonString); + store.updateConfig(DEFAULT_CONFIG_KEY, id, jsonString); } setJsonError("Invalid JSON format"); } @@ -121,7 +120,7 @@ export function ConfigField({
@@ -135,9 +134,9 @@ export function ConfigField({
{description && ( -

+ {description} -

+ )} {type === "text" && ( diff --git a/apps/web/src/components/github/github-page-loading.tsx b/apps/web/src/components/github/github-page-loading.tsx deleted file mode 100644 index 399242dd..00000000 --- a/apps/web/src/components/github/github-page-loading.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; - -export function GithubPageLoading() { - return ( -
-
-

GitHub Repository Access

-
-
-
-
-

Your Repositories

-
-
-
- {Array.from({ length: 3 }).map((_, index) => ( -
-
-
-
-
- ))} -
-
-
- ); -} diff --git a/apps/web/src/components/github/repository-list.tsx b/apps/web/src/components/github/repository-list.tsx deleted file mode 100644 index fe34029e..00000000 --- a/apps/web/src/components/github/repository-list.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; -import { useGitHubAppProvider } from "@/providers/GitHubApp"; -import { InstallationPrompt } from "./installation-prompt"; -import { Button } from "@/components/ui/button"; -import { RefreshCw } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface RepositoryListProps { - className?: string; -} - -export function RepositoryList({ className = "" }: RepositoryListProps) { - const { isInstalled, isLoading, error, repositories, refreshRepositories } = - useGitHubAppProvider(); - - if (isLoading) { - return ( -
-
- {[1, 2, 3].map((i) => ( -
-
-
-
- ))} -
- ); - } - - if (error) { - return ( -
-
-

{error}

-
- -
- ); - } - - if (!isInstalled) { - return ( -
- -
- ); - } - - return ( -
-
-

Your GitHub Repositories

- -
- - {repositories.length === 0 ? ( -
-

- No repositories found. You may need to grant access to repositories - in GitHub. -

- - Manage GitHub App permissions - -
- ) : ( -
- {repositories.map((repo) => ( -
-
-
-

- - {repo.full_name} - -

-

- {repo.description || "No description"} -

-
- - {repo.private ? "Private" : "Public"} - - - Default branch: {repo.default_branch} - -
-
-
-
- ))} -
- )} - -
-

- Need to add or remove repositories?{" "} - - Manage on GitHub - -

-
-
- ); -} diff --git a/apps/web/src/components/v2/default-view.tsx b/apps/web/src/components/v2/default-view.tsx index 9e86e8b5..c43e3ae4 100644 --- a/apps/web/src/components/v2/default-view.tsx +++ b/apps/web/src/components/v2/default-view.tsx @@ -25,6 +25,23 @@ import { GitHubLogoutButton } from "../github/github-oauth-button"; import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; import { TooltipIconButton } from "../ui/tooltip-icon-button"; import { InstallationSelector } from "../github/installation-selector"; +import { Settings } from "lucide-react"; +import NextLink from "next/link"; + +function OpenSettingsButton() { + return ( + + + + + + + + Open Settings + + + ); +} interface DefaultViewProps { threads: ThreadDisplayInfo[]; @@ -68,11 +85,12 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
+
ready -
+
- +
diff --git a/apps/web/src/features/settings-page/api-keys.tsx b/apps/web/src/features/settings-page/api-keys.tsx new file mode 100644 index 00000000..ddfd9497 --- /dev/null +++ b/apps/web/src/features/settings-page/api-keys.tsx @@ -0,0 +1,214 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Eye, EyeOff, Key, Trash2 } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface ApiKey { + id: string; + name: string; + value: string; + isVisible: boolean; + lastUsed?: string; +} + +interface ApiKeySection { + title: string; + keys: ApiKey[]; +} + +export function APIKeysTab() { + const [apiKeySections, setApiKeySections] = useState< + Record + >({ + llms: { + title: "LLMs", + keys: [ + { + id: "anthropicApiKey", + name: "Anthropic", + value: "", + isVisible: false, + }, + { id: "openaiApiKey", name: "OpenAI", value: "", isVisible: false }, + { + id: "googleApiKey", + name: "Google Gen AI", + value: "", + isVisible: false, + }, + ], + }, + infrastructure: { + title: "Infrastructure", + keys: [ + { id: "daytonaApiKey", name: "Daytona", value: "", isVisible: false }, + ], + }, + }); + + const toggleKeyVisibility = (sectionKey: string, keyId: string) => { + setApiKeySections((prev) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + keys: prev[sectionKey].keys.map((key) => + key.id === keyId ? { ...key, isVisible: !key.isVisible } : key, + ), + }, + })); + }; + + const updateApiKey = (sectionKey: string, keyId: string, value: string) => { + setApiKeySections((prev) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + keys: prev[sectionKey].keys.map((key) => + key.id === keyId ? { ...key, value } : key, + ), + }, + })); + }; + + const deleteApiKey = (sectionKey: string, keyId: string) => { + setApiKeySections((prev) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + keys: prev[sectionKey].keys.map((key) => + key.id === keyId ? { ...key, value: "" } : key, + ), + }, + })); + }; + + return ( +
+ {Object.entries(apiKeySections).map(([sectionKey, section]) => ( + + + + + {section.title} + + + Manage API keys for {section.title.toLowerCase()} services + + + + {section.keys.map((apiKey) => ( +
+
+
+

+ {apiKey.name} +

+ {apiKey.value && ( + + Configured + + )} + {apiKey.lastUsed && ( + + Last used {apiKey.lastUsed} + + )} +
+
+ +
+
+
+ +
+ + updateApiKey(sectionKey, apiKey.id, e.target.value) + } + placeholder={`Enter your ${apiKey.name} API key`} + className="font-mono text-sm" + /> + + {apiKey.value && ( + + )} +
+
+
+ +
+

+ Your API key is stored securely and encrypted +

+ {apiKey.value && ( + + )} +
+
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/features/settings-page/config-manager.tsx b/apps/web/src/features/settings-page/config-manager.tsx new file mode 100644 index 00000000..d2f1646a --- /dev/null +++ b/apps/web/src/features/settings-page/config-manager.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Settings } from "lucide-react"; +import { ConfigField } from "@/components/configuration/config-field"; +import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import type { ConfigurableFieldUIMetadata } from "@open-swe/shared/configurable-metadata"; +import { GraphConfigurationMetadata } from "@open-swe/shared/open-swe/types"; +import { cn } from "@/lib/utils"; + +/** + * Extract configuration metadata from the GraphConfiguration Zod schema + */ +function extractConfigurationsFromSchema( + configurable: Record, +): ConfigurableFieldUIMetadata[] { + const configurations: ConfigurableFieldUIMetadata[] = []; + + for (const [label, { x_open_swe_ui_config: metadata }] of Object.entries( + GraphConfigurationMetadata, + )) { + if (metadata.type === "hidden") { + continue; + } + configurations.push({ + label, + type: metadata.type, + default: configurable[label] || metadata.default, + description: metadata.description, + placeholder: metadata.placeholder, + options: metadata.options, + min: metadata.min, + max: metadata.max, + step: metadata.step, + }); + } + + return configurations; +} + +export function ConfigManager() { + const { configs, updateConfig, getConfig } = useConfigStore(); + const [defaultConfig, setDefaultConfig] = useState< + ConfigurableFieldUIMetadata[] + >([]); + const [configurations, setConfigurations] = useState< + ConfigurableFieldUIMetadata[] + >([]); + const [loading, setLoading] = useState(false); + + const loadConfigurations = async () => { + // TODO: If we implement a concept of users and start storing config on assistants, + // we will need to update this to fetch configs from the assistant first. + setLoading(true); + setDefaultConfig(extractConfigurationsFromSchema({})); + + if (getConfig(DEFAULT_CONFIG_KEY)) { + const actualConfigs = extractConfigurationsFromSchema( + getConfig(DEFAULT_CONFIG_KEY), + ); + setConfigurations(actualConfigs); + setLoading(false); + return; + } + const actualConfigs = extractConfigurationsFromSchema({}); + actualConfigs.forEach((c) => { + updateConfig(DEFAULT_CONFIG_KEY, c.label, c.default); + }); + setConfigurations(actualConfigs); + setLoading(false); + }; + + useEffect(() => { + loadConfigurations(); + }, []); + + const hasConfiguredValues = configurations.some((config) => { + const currentValue = configs[DEFAULT_CONFIG_KEY]?.[config.label]; + const defaultValue = defaultConfig.find( + (c) => c.label === config.label, + )?.default; + return currentValue !== undefined && currentValue !== defaultValue; + }); + + return ( +
+ + +
+
+ + + Agent Configuration + + + Configure agent behavior and model parameters. Will auto-save + changes. + +
+
+ {hasConfiguredValues && ( + + Customized + + )} +
+
+
+ + {loading ? ( +
+ + + +
+ ) : configurations.length > 0 ? ( + configurations.map( + (config: ConfigurableFieldUIMetadata, index: number) => ( +
+
+
+ {(() => { + const currentValue = + configs[DEFAULT_CONFIG_KEY]?.[config.label]; + const defaultValue = defaultConfig.find( + (c) => c.label === config.label, + )?.default; + const isModified = + currentValue !== undefined && + currentValue !== defaultValue; + + return ( + isModified && ( + + Modified + + ) + ); + })()} +
+
+ +
+ { + console.log("setting value!", value); + updateConfig(DEFAULT_CONFIG_KEY, config.label, value); + }} + /> + +
+ {(() => { + const currentValue = + configs[DEFAULT_CONFIG_KEY]?.[config.label]; + const defaultValue = defaultConfig.find( + (c) => c.label === config.label, + )?.default; + const isModified = + currentValue !== undefined && + currentValue !== defaultValue; + + return ( + isModified && ( + + ) + ); + })()} +
+
+
+ ), + ) + ) : ( +
+ +

+ No Configuration Available +

+

+ No configurable parameters found for the current context. +

+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/features/settings-page/github-manager.tsx b/apps/web/src/features/settings-page/github-manager.tsx new file mode 100644 index 00000000..cc35282d --- /dev/null +++ b/apps/web/src/features/settings-page/github-manager.tsx @@ -0,0 +1,475 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + RefreshCw, + GitBranch, + Lock, + Globe, + ExternalLink, + Building2, + User, + Github, + LogIn, + Plus, +} from "lucide-react"; +import { useGitHubAppProvider } from "@/providers/GitHubApp"; +import { InstallationSelector } from "@/components/github/installation-selector"; +import { cn } from "@/lib/utils"; + +export function GitHubManager() { + const { + isInstalled, + isLoading, + error, + installations, + currentInstallation, + installationsLoading, + installationsError, + switchInstallation, + refreshInstallations, + repositories, + repositoriesLoadingMore, + refreshRepositories, + loadMoreRepositories, + repositoriesHasMore, + } = useGitHubAppProvider(); + + const handleRefresh = async () => { + await Promise.all([refreshRepositories(), refreshInstallations()]); + }; + + const handleInstallApp = () => { + window.location.href = "/api/github/installation"; + }; + + const handleManageOnGitHub = () => { + const appId = process.env.NEXT_PUBLIC_GITHUB_APP_ID; + const fallbackInstallationsUrl = + "https://github.com/settings/installations"; + const applicationUrl = appId + ? `https://github.com/settings/connections/applications/${appId}` + : fallbackInstallationsUrl; + window.open(applicationUrl, "_blank"); + }; + + const handleLogin = () => { + window.location.href = "/api/auth/github/login"; + }; + + const isAuthError = (errorMessage: string | null) => { + return ( + errorMessage?.includes("GitHub access token not found") || + errorMessage?.includes("Please authenticate first") || + errorMessage?.includes("authentication") || + errorMessage?.includes("access token") + ); + }; + + if (isAuthError(installationsError) || isAuthError(error)) { + return ( +
+ + + + + GitHub Authentication Required + + + Sign in with GitHub to access your repositories and manage + installations + + + +
+
+ +

+ Connect Your GitHub Account +

+

+ To manage your GitHub repositories and app installations, you + need to authenticate with GitHub first. This will allow you to + select repositories and work with your code. +

+
+
+ + +
+
+
+
+
+ ); + } + + if (isLoading || installationsLoading) { + return ( +
+ + +
+
+ GitHub Integration + Loading your GitHub setup... +
+
+
+
+ + {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if ( + (error && !isAuthError(error)) || + (installationsError && !isAuthError(installationsError)) + ) { + return ( +
+ + + + GitHub Integration Error + + {error || installationsError} + + +
+ + +
+
+
+
+ ); + } + + if (!isInstalled) { + return ( +
+ + + GitHub App Installation + + Install the GitHub App to connect your repositories + + + +
+
+

+ Get Started with GitHub Integration +

+

+ To access your GitHub repositories, you need to install our + GitHub App and grant it access to the repositories you want to + use. +

+
+ +
+
+
+
+ ); + } + + return ( +
+ + + + GitHub{" "} + {currentInstallation?.accountType === "Organization" + ? "Organization" + : "User"} + + + Manage your GitHub App installations and switch between + organizations + + + +
+
+

+ Current{" "} + {currentInstallation?.accountType === "Organization" + ? "Organization" + : "User"} +

+

+ Select which GitHub organization or user account to work with +

+
+
+ + + +
+
+ + {currentInstallation && ( +
+ {`${currentInstallation.accountName} +
+
+

+ {currentInstallation.accountName} +

+ + {currentInstallation.accountType === "Organization" ? ( + <> + + Organization + + ) : ( + <> + + Personal + + )} + +
+

+ {repositories.length} repositories accessible +

+
+
+ )} +
+
+ + + +
+
+ Your Repositories + + Repositories you have access to through your GitHub integration + +
+
+ + {repositories.length} repositories + + +
+
+
+ + {repositories.length === 0 ? ( +
+

+ No repositories found. Make sure you've granted access to at + least one repository for the selected organization. +

+
+ ) : ( + <> + {repositories.map((repo, index) => ( +
+
+
+
+

+ {repo.full_name} +

+ + {repo.private ? ( + <> + + Private + + ) : ( + <> + + Public + + )} + +
+ + {repo.description && ( +

+ {repo.description} +

+ )} + +
+
+ + + Default: {repo.default_branch} + +
+
+
+ + +
+ {index < repositories.length - 1 && ( + + )} +
+ ))} + + {repositoriesHasMore && ( +
+ +
+ )} + + )} +
+
+ + + + GitHub App Management + + Manage your GitHub App installation and permissions + + + +
+
+

+ GitHub App Installation +

+

+ You can manage your GitHub App installation, including adding or + removing repositories, through GitHub. +

+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/features/settings-page/index.tsx b/apps/web/src/features/settings-page/index.tsx new file mode 100644 index 00000000..347ad36e --- /dev/null +++ b/apps/web/src/features/settings-page/index.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useQueryState } from "nuqs"; +import { useRouter } from "next/navigation"; +import { Key, Settings, ArrowLeft } from "lucide-react"; +import { GitHubManager } from "./github-manager"; +import { APIKeysTab } from "./api-keys"; +import { ConfigManager } from "./config-manager"; +import { GitHubSVG } from "@/components/icons/github"; +import { GitHubAppProvider } from "@/providers/GitHubApp"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export default function SettingsPage() { + const router = useRouter(); + const [activeTab, setActiveTab] = useQueryState("tab", { + defaultValue: "github" as "github" | "api-keys" | "configuration", + parse: (value: string) => { + if (["github", "api-keys", "configuration"].includes(value)) { + return value as "github" | "api-keys" | "configuration"; + } + return "github"; + }, + serialize: (value) => value, + }); + + const getTabClassName = (isActive: boolean) => + cn( + "relative border-b-2 px-4 py-3 text-sm font-medium transition-colors", + isActive + ? "border-primary bg-background text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground border-transparent", + ); + + return ( + +
+ {/* Header with back button and theme toggle */} +
+ + +
+ +
+

Settings

+

+ Manage your integrations and API configurations +

+
+ +
+
+ + + +
+
+ +
+ {activeTab === "github" && } + {activeTab === "api-keys" && } + {activeTab === "configuration" && } +
+
+
+ ); +} diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 40eb4234..ce72eb70 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -279,7 +279,7 @@ export const GraphConfigurationMetadata: { min: 1, max: 250, description: - "Maximum number of context gathering actions during planning", + "Maximum number of actions allowed during the context gathering phase. An action consists of a tool call.", }, }, maxReviewActions: { @@ -288,7 +288,8 @@ export const GraphConfigurationMetadata: { default: 30, min: 1, max: 250, - description: "Maximum number of review actions during planning", + description: + "Maximum number of review actions allowed during the review phase. An action consists of a tool call.", }, }, programmerModelName: { @@ -296,7 +297,7 @@ export const GraphConfigurationMetadata: { type: "select", default: "anthropic:claude-sonnet-4-0", description: - "The model to use for programming/other advanced technical tasks", + "The model to use for programming/other advanced technical tasks. This model should be very good at generating code, and have strong context understanding and reasoning capabilities. It will be used for the most complex tasks throughout the agent.", options: MODEL_OPTIONS_NO_THINKING, }, }, @@ -314,7 +315,8 @@ export const GraphConfigurationMetadata: { x_open_swe_ui_config: { type: "select", default: "anthropic:claude-3-5-haiku-latest", - description: "The model to use for routing tasks", + description: + "The model to use for routing tasks, and other simple generations. This model should be good at tool calling/structured output.", options: MODEL_OPTIONS, }, }, @@ -333,7 +335,7 @@ export const GraphConfigurationMetadata: { type: "select", default: "anthropic:claude-sonnet-4-0", description: - "The model to use for summarizing the conversation history, or extracting key context from large inputs", + "The model to use for summarizing the conversation history, or extracting key context from large inputs. This model should have strong context retention/understanding capabilities, and should be good at tool calling/structured output.", options: MODEL_OPTIONS_NO_THINKING, }, }, @@ -354,7 +356,7 @@ export const GraphConfigurationMetadata: { min: 1, max: 64_000, description: - "The maximum number of tokens to generate in an individual generation", + "The maximum number of tokens to generate in an individual LLM call. Increasing/decreasing this number _will_ effect how many tokens are generated by the model. It will _not_ simply cut off the generation after the specified number of tokens is reached.", }, }, [GITHUB_TOKEN_COOKIE]: { @@ -390,9 +392,9 @@ export const GraphConfigurationMetadata: { mcpServers: { x_open_swe_ui_config: { type: "json", - default: JSON.stringify(DEFAULT_MCP_SERVERS), + default: JSON.stringify(DEFAULT_MCP_SERVERS, null, 2), description: - "JSON configuration for custom MCP servers. LangGraph docs server is set by default.", + "JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).", }, }, }; @@ -464,13 +466,8 @@ export const GraphConfiguration = z.object({ * The maximum number of tokens to generate in an individual generation. * @default 10_000 */ - maxTokens: withLangGraph(z.number(), { + maxTokens: withLangGraph(z.number().optional(), { metadata: GraphConfigurationMetadata.maxTokens, - reducer: { - schema: z.number(), - fn: (_state, update) => update, - }, - default: () => 10_000, }), /** * The user's GitHub access token. To be used in requests to get information about the user. @@ -514,13 +511,8 @@ export const GraphConfiguration = z.object({ * Custom MCP servers configuration as JSON string. Merges with default servers. * @default Default LangGraph docs MCP server */ - mcpServers: withLangGraph(z.string(), { + mcpServers: withLangGraph(z.string().optional(), { metadata: GraphConfigurationMetadata.mcpServers, - default: () => JSON.stringify(DEFAULT_MCP_SERVERS), - reducer: { - schema: z.string(), - fn: (_state, update) => update, - }, }), });