diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index ea6b6a60f..e6057351f 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -186,6 +186,12 @@ "type": "(id: string) => void", "description": "Optional callback invoked when a new thread is created", "optional": true + }, + { + "name": "clientOptions", + "type": "LangGraphClientOptions", + "description": "Optional SDK client tuning (e.g. `maxRetries`)", + "optional": true } ], "examples": [ @@ -886,6 +892,12 @@ "description": "Agent or graph identifier on the LangGraph platform.", "optional": true }, + { + "name": "clientOptions", + "type": "LangGraphClientOptions", + "description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).", + "optional": true + }, { "name": "filterSubagentMessages", "type": "boolean", @@ -1002,7 +1014,7 @@ { "name": "AgentOptions", "kind": "interface", - "description": "Options for creating a LangGraph-backed agent via agent.", + "description": "", "properties": [ { "name": "apiUrl", @@ -1016,6 +1028,12 @@ "description": "Agent or graph identifier on the LangGraph platform.", "optional": false }, + { + "name": "clientOptions", + "type": "LangGraphClientOptions", + "description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).", + "optional": true + }, { "name": "filterSubagentMessages", "type": "boolean", @@ -1626,6 +1644,20 @@ ], "examples": [] }, + { + "name": "LangGraphClientOptions", + "kind": "interface", + "description": "Tuning options for the underlying LangGraph SDK `Client` constructed by the\ndefault FetchStreamTransport. Ignored when a custom `transport` is\nsupplied (the transport owns its own client).", + "properties": [ + { + "name": "maxRetries", + "type": "number", + "description": "How many times a failed request — including the initial stream connect —\nis retried with exponential backoff before the error surfaces. Maps to the\nSDK's `callerOptions.maxRetries`. Omitted → the SDK default (currently 4).\n\nSet `0` to fail fast: useful for e2e tests that force a connection failure\nand assert the error surfaces promptly, rather than after the full\nmulti-second backoff window.", + "optional": true + } + ], + "examples": [] + }, { "name": "LangGraphSubmitOptions", "kind": "interface", @@ -2319,14 +2351,20 @@ { "name": "createLangGraphClient", "kind": "function", - "description": "Construct a LangGraph SDK Client that accepts both absolute URLs\n(`http://localhost:2024`) and relative `/api`-style paths that get\nproxied by middleware in production. The SDK itself rejects\nrelative URLs, so this helper rewrites them against\n`window.location.origin` when running in the browser.\n\nSingle source of truth for the absolute-URL rewrite — the streaming\ntransport (`fetch-stream.transport.ts`) and the threads adapter\n(`LangGraphThreadsAdapter`) both go through here.", - "signature": "createLangGraphClient(apiUrl: string): Client<>", + "description": "Construct a LangGraph SDK Client that accepts both absolute URLs\n(`http://localhost:2024`) and relative `/api`-style paths that get\nproxied by middleware in production. The SDK itself rejects\nrelative URLs, so this helper rewrites them against\n`window.location.origin` when running in the browser.\n\nSingle source of truth for the absolute-URL rewrite — the streaming\ntransport (`fetch-stream.transport.ts`) and the threads adapter\n(`LangGraphThreadsAdapter`) both go through here.\n\n`clientOptions.maxRetries` maps to the SDK's `callerOptions.maxRetries`,\nwhich governs how many times a failed request (including the initial\nstream connect) is retried with exponential backoff before the error\nsurfaces. Omitted → the SDK default (currently 4). Apps under test set\n`0` so a forced connection failure surfaces immediately instead of after\nthe full backoff window.", + "signature": "createLangGraphClient(apiUrl: string, clientOptions: LangGraphClientOptions): Client<>", "params": [ { "name": "apiUrl", "type": "string", "description": "", "optional": false + }, + { + "name": "clientOptions", + "type": "LangGraphClientOptions", + "description": "", + "optional": true } ], "returns": { diff --git a/examples/chat/angular/e2e/error-handling.spec.ts b/examples/chat/angular/e2e/error-handling.spec.ts index 2f13d0603..7a770ed6c 100644 --- a/examples/chat/angular/e2e/error-handling.spec.ts +++ b/examples/chat/angular/e2e/error-handling.spec.ts @@ -5,6 +5,14 @@ import { messageInput, openDemo, sendButton, waitForFinalAssistant } from './tes test('error handling: failed stream surfaces an alert and the next send recovers', async ({ page, }) => { + // The LangGraph SDK retries a failed stream connect with exponential backoff + // (~15s) before surfacing the error — fine for production resilience, too slow + // to assert here. Opt this app instance into fail-fast (0 connect retries) so + // the alert appears promptly. Set before bootstrap via addInitScript. + await page.addInitScript(() => { + localStorage.setItem('THREADPLANE_E2E_MAX_RETRIES', '0'); + }); + await openDemo(page, '/embed'); await page.route('**/runs/stream', async (route) => { diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 01950a7f6..27f3f95d6 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -33,6 +33,7 @@ import { import { PalettePersistence } from './palette-persistence.service'; import { ProjectsService } from './projects.service'; import { DEMO_AGENT } from './shell-tokens'; +import { e2eClientOptions } from './e2e-overrides'; import { createCanonicalDemoRuntimeTelemetrySink } from './runtime-telemetry'; import { environment } from '../../environments/environment'; @@ -85,9 +86,17 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // model selection — all per-instance. The telemetry sink delegates to the // shell-built sink (populated in the constructor) since the real sink needs // the injected telemetry service + live model() read. - provideAgent({ + // Factory form: the config is resolved lazily at injection time (when the + // AGENT singleton is first constructed), not at module-load. This matters + // for `clientOptions` below — the e2e flag in localStorage is only reliably + // readable once the app is running, after bootstrap. + provideAgent(() => ({ apiUrl: environment.langGraphApiUrl, assistantId: environment.assistantId, + // Production keeps the SDK's default connect-retry budget. e2e specs that + // force a connection failure set localStorage['THREADPLANE_E2E_MAX_RETRIES'] + // so the error surfaces immediately instead of after the backoff window. + ...(e2eClientOptions() ? { clientOptions: e2eClientOptions() } : {}), threadId: threadIdState, onThreadId: (id: string) => { // The signal→URL effect picks this up and stamps the new id @@ -100,7 +109,7 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // resulting tools:-namespaced stream events. subagentToolNames: ['research'], telemetry: (event) => telemetrySink?.(event), - }), + })), { provide: DEMO_AGENT, useFactory: () => inject(DemoShell).agent }, ], }) diff --git a/examples/chat/angular/src/app/shell/e2e-overrides.spec.ts b/examples/chat/angular/src/app/shell/e2e-overrides.spec.ts new file mode 100644 index 000000000..604f72f90 --- /dev/null +++ b/examples/chat/angular/src/app/shell/e2e-overrides.spec.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +import { e2eClientOptions } from './e2e-overrides'; + +const KEY = 'THREADPLANE_E2E_MAX_RETRIES'; + +describe('e2eClientOptions', () => { + afterEach(() => localStorage.removeItem(KEY)); + + it('returns undefined when the flag is absent (production default preserved)', () => { + expect(e2eClientOptions()).toBeUndefined(); + }); + + it('maps "0" to { maxRetries: 0 } (fail-fast under test)', () => { + localStorage.setItem(KEY, '0'); + expect(e2eClientOptions()).toEqual({ maxRetries: 0 }); + }); + + it('maps a positive integer string to that retry budget', () => { + localStorage.setItem(KEY, '3'); + expect(e2eClientOptions()).toEqual({ maxRetries: 3 }); + }); + + it('ignores non-integer / negative / garbage values', () => { + for (const bad of ['', 'abc', '-1', '1.5', 'NaN']) { + localStorage.setItem(KEY, bad); + expect(e2eClientOptions()).toBeUndefined(); + } + }); +}); diff --git a/examples/chat/angular/src/app/shell/e2e-overrides.ts b/examples/chat/angular/src/app/shell/e2e-overrides.ts new file mode 100644 index 000000000..fd0c13910 --- /dev/null +++ b/examples/chat/angular/src/app/shell/e2e-overrides.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import type { LangGraphClientOptions } from '@threadplane/langgraph'; + +/** + * Test-only escape hatch for the LangGraph SDK client tuning. + * + * Production keeps the SDK's resilient default (connect failures retry with + * exponential backoff before the error surfaces — good for transient blips, + * but a deliberate ~15s delay on a hard failure). e2e specs that force a + * connection failure can't wait that long, so they set + * `localStorage['THREADPLANE_E2E_MAX_RETRIES'] = '0'` (via + * `page.addInitScript`, before the app bootstraps) to make the error surface + * immediately. + * + * Returns `undefined` when the flag is absent — i.e. always in real use — so + * the SDK default is preserved. Never reads anything in production. + */ +const E2E_MAX_RETRIES_KEY = 'THREADPLANE_E2E_MAX_RETRIES'; + +export function e2eClientOptions(): LangGraphClientOptions | undefined { + if (typeof localStorage === 'undefined') return undefined; + let raw: string | null = null; + try { + raw = localStorage.getItem(E2E_MAX_RETRIES_KEY); + } catch { + return undefined; // storage blocked (e.g. sandboxed iframe) — ignore + } + if (raw === null || raw.trim() === '') return undefined; + const maxRetries = Number(raw); + if (!Number.isInteger(maxRetries) || maxRetries < 0) return undefined; + return { maxRetries }; +} diff --git a/libs/langgraph/src/lib/agent.provider.ts b/libs/langgraph/src/lib/agent.provider.ts index 79be72503..884cef351 100644 --- a/libs/langgraph/src/lib/agent.provider.ts +++ b/libs/langgraph/src/lib/agent.provider.ts @@ -7,6 +7,7 @@ import { agent } from './agent.fn'; import type { AgentTransport, LangGraphAgent, + LangGraphClientOptions, } from './agent.types'; /** @@ -38,6 +39,8 @@ export interface AgentConfig< toMessage?: (msg: unknown) => BaseMessage; /** Custom transport. Defaults to {@link FetchStreamTransport}. */ transport?: AgentTransport; + /** Tuning options for the default transport's LangGraph SDK client (e.g. retry budget). */ + clientOptions?: LangGraphClientOptions; /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ telemetry?: AgentRuntimeTelemetrySink | false; /** When true, subagent messages are filtered from the main messages signal. */ @@ -117,6 +120,7 @@ export function provideAgent>( ...(config.throttle !== undefined ? { throttle: config.throttle } : {}), ...(config.toMessage !== undefined ? { toMessage: config.toMessage } : {}), ...(config.transport !== undefined ? { transport: config.transport } : {}), + ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index c90189b34..675db8c8a 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -240,6 +240,24 @@ export interface AgentTransport { // The second generic is retained for source compatibility with existing typed // AgentOptions references even though the options shape no longer depends on it. // eslint-disable-next-line @typescript-eslint/no-unused-vars +/** + * Tuning options for the underlying LangGraph SDK `Client` constructed by the + * default {@link FetchStreamTransport}. Ignored when a custom `transport` is + * supplied (the transport owns its own client). + */ +export interface LangGraphClientOptions { + /** + * How many times a failed request — including the initial stream connect — + * is retried with exponential backoff before the error surfaces. Maps to the + * SDK's `callerOptions.maxRetries`. Omitted → the SDK default (currently 4). + * + * Set `0` to fail fast: useful for e2e tests that force a connection failure + * and assert the error surfaces promptly, rather than after the full + * multi-second backoff window. + */ + maxRetries?: number; +} + export interface AgentOptions { /** Base URL of the LangGraph Platform API. Defaults to `provideAgent({ apiUrl })` when omitted. */ apiUrl?: string; @@ -257,6 +275,8 @@ export interface AgentOptions { toMessage?: (msg: unknown) => BaseMessage; /** Custom transport. Defaults to FetchStreamTransport. */ transport?: AgentTransport; + /** Tuning options for the default transport's LangGraph SDK client (e.g. retry budget). */ + clientOptions?: LangGraphClientOptions; /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ telemetry?: AgentRuntimeTelemetrySink | false; /** When true, subagent messages are filtered from the main messages signal. */ diff --git a/libs/langgraph/src/lib/client/create-langgraph-client.ts b/libs/langgraph/src/lib/client/create-langgraph-client.ts index 34e8e8664..98f022fc7 100644 --- a/libs/langgraph/src/lib/client/create-langgraph-client.ts +++ b/libs/langgraph/src/lib/client/create-langgraph-client.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import { Client } from '@langchain/langgraph-sdk'; +import type { LangGraphClientOptions } from '../agent.types'; /** * Construct a LangGraph SDK Client that accepts both absolute URLs @@ -12,14 +13,29 @@ import { Client } from '@langchain/langgraph-sdk'; * transport (`fetch-stream.transport.ts`) and the threads adapter * (`LangGraphThreadsAdapter`) both go through here. * + * `clientOptions.maxRetries` maps to the SDK's `callerOptions.maxRetries`, + * which governs how many times a failed request (including the initial + * stream connect) is retried with exponential backoff before the error + * surfaces. Omitted → the SDK default (currently 4). Apps under test set + * `0` so a forced connection failure surfaces immediately instead of after + * the full backoff window. + * * @example * ```ts * const client = createLangGraphClient(environment.langGraphApiUrl); * const threads = await client.threads.search({ limit: 50 }); * ``` */ -export function createLangGraphClient(apiUrl: string): Client { - return new Client({ apiUrl: toAbsoluteApiUrl(apiUrl) }); +export function createLangGraphClient( + apiUrl: string, + clientOptions?: LangGraphClientOptions, +): Client { + return new Client({ + apiUrl: toAbsoluteApiUrl(apiUrl), + ...(clientOptions?.maxRetries !== undefined + ? { callerOptions: { maxRetries: clientOptions.maxRetries } } + : {}), + }); } /** Exported separately so non-Client callers (e.g. raw fetch) can diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index de88a35f3..2cedcf131 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -104,7 +104,7 @@ export function createStreamManagerBridge void) { + constructor(apiUrl: string, onThreadId?: (id: string) => void, clientOptions?: LangGraphClientOptions) { // createLangGraphClient handles the absolute-URL normalization // required by the SDK when `apiUrl` is a relative `/api`-style // path proxied by middleware in production. - this.client = createLangGraphClient(apiUrl); + this.client = createLangGraphClient(apiUrl, clientOptions); this.onThreadId = onThreadId; } diff --git a/libs/langgraph/src/public-api.ts b/libs/langgraph/src/public-api.ts index ea645e020..c2bd9179f 100644 --- a/libs/langgraph/src/public-api.ts +++ b/libs/langgraph/src/public-api.ts @@ -14,6 +14,7 @@ export { AgentLifecycleRegistry } from './lib/agent-lifecycle-registry'; // Public types export type { AgentOptions, + LangGraphClientOptions, AgentBranchTree, AgentBranchTreeFork, AgentBranchTreeNode,