Skip to content
Merged
501 changes: 501 additions & 0 deletions docs/superpowers/plans/2026-06-18-langgraph-shared-client-options.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Shared LangGraph client options (single-source retry budget) — Design

**Status:** Approved (brainstorm 2026-06-17)

**Goal:** Provide one app-wide place to configure the LangGraph SDK client's
retry/tuning options (`LangGraphClientOptions`) so that **both** the streaming
agent transport and the threads adapter obey it — set once, not per subsystem.

## Background

PR #677 added `clientOptions.maxRetries` to the agent path
(`provideAgent` → `agent()` → `FetchStreamTransport` → `createLangGraphClient`
→ SDK `callerOptions`) to let a failed stream connect fail fast under test
while keeping the SDK's resilient default in production.

The threads adapter (`LangGraphThreadsAdapter`) constructs its own client via
`createLangGraphClient(this.config.apiUrl)` **without** options, so thread CRUD
(create / getHistory / getState / updateState) still retries ~15s on a hard
failure. An existing escape hatch — the `LANGGRAPH_CLIENT` injection token —
already lets a consumer inject a fully-built client, but that requires building
the client by hand and is per-subsystem.

This design adds a single shared knob both subsystems read.

## Non-goals (YAGNI)

- No refactor of `apiUrl` wiring (`provideAgent` and `LANGGRAPH_THREADS_CONFIG`
keep taking `apiUrl` independently).
- No change to the SDK default retry behavior in production.
- No new thread-CRUD-abort e2e (would require aborting `/threads`; tracked as an
optional follow-up).
- `LANGGRAPH_CLIENT` (inject a pre-built client) stays as-is.

## Architecture

### New DI token (libs/langgraph)

```ts
// LangGraphClientOptions already exists (agent.types.ts, exported).
export const LANGGRAPH_CLIENT_OPTIONS =
new InjectionToken<LangGraphClientOptions>('LANGGRAPH_CLIENT_OPTIONS');
```

Exported from `libs/langgraph/src/public-api.ts`. Provided once at app root.

### Precedence helper (pure, testable)

A small pure function resolves the effective options without reaching into SDK
internals:

```ts
// libs/langgraph/src/lib/client/resolve-client-options.ts
export function resolveClientOptions(
...layers: Array<LangGraphClientOptions | undefined | null>
): LangGraphClientOptions | undefined {
for (const layer of layers) if (layer) return layer;
return undefined;
}
```

It returns the first defined layer (highest precedence first). Whole-object
precedence (not per-field merge) keeps semantics obvious; `maxRetries` is the
only field today.

### Read sites

**Agent** (`agent.fn.ts`) — already in an injection context (it calls
`inject(DestroyRef)` / `inject(AGENT_CONFIG, { optional: true })`). Add:

```ts
const sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true });
const clientOptions = resolveClientOptions(
options.clientOptions, // 1. agent({ clientOptions }) call-site
globalConfig?.clientOptions, // 2. provideAgent({ clientOptions }) (AGENT_CONFIG)
sharedClientOptions, // 3. LANGGRAPH_CLIENT_OPTIONS token
);
```

`clientOptions` is then passed into the bridge options (already threaded to
`FetchStreamTransport` in #677). The bridge/transport wiring is unchanged.

**Threads adapter** (`threads-adapter.ts`) — `@Injectable({ providedIn: 'root' })`,
so it can inject the token. On the path where `LANGGRAPH_CLIENT` is **not**
provided:

```ts
private readonly sharedClientOptions =
inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }) ?? undefined;
private readonly client: Client =
inject(LANGGRAPH_CLIENT, { optional: true })
?? createLangGraphClient(this.config.apiUrl, this.sharedClientOptions ?? undefined);
```

The threads adapter has no per-call layer, so its precedence is: shared token →
SDK default (or a fully injected `LANGGRAPH_CLIENT` bypasses both).

### Data flow

```
consumer provides LANGGRAPH_CLIENT_OPTIONS at root
├─ agent() → resolveClientOptions(call, providerCfg, token) → bridge → FetchStreamTransport → createLangGraphClient(apiUrl, opts) → SDK callerOptions
└─ LangGraphThreadsAdapter → createLangGraphClient(apiUrl, token) → SDK callerOptions
```

## Example app migration (examples/chat)

- `app.config.ts`: add
`{ provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: () => e2eClientOptions() }`.
The factory runs at injection time (post-bootstrap) so the
`THREADPLANE_E2E_MAX_RETRIES` localStorage flag is readable; returning
`undefined` is fine (read sites treat absent/undefined as "SDK default").
- `DemoShell`: revert `provideAgent` to the **static** object form and drop the
per-agent `clientOptions: e2eClientOptions()`. The shared token supplies it
now. This unwinds the factory-form timing workaround added in #677 (net
simplification).
- `error-handling.spec.ts`, `e2e-overrides.ts` (+ its spec): unchanged. The flag
now flows through the root token to both the agent and the threads adapter.

## Error handling

No new failure modes. The token is optional everywhere; absence preserves the
SDK default. Providing `undefined` (factory returns `undefined`) behaves
identically to not providing the token.

## Testing

- **Unit (lib):** `resolveClientOptions()` precedence — table-driven (call-site
wins over provider over token over none).
- **Unit (lib):** `LangGraphThreadsAdapter` threads the injected token into the
constructed client — `vi.mock` (or spy) `createLangGraphClient` and assert it
is called with `(apiUrl, { maxRetries: N })` when `LANGGRAPH_CLIENT_OPTIONS` is
provided, and with `(apiUrl, undefined)` when it is not. Also assert the
`LANGGRAPH_CLIENT` bypass still wins (createLangGraphClient not called).
- **Unit (example):** existing `e2eClientOptions` spec stays. New specs added
under `src/` MUST `import { describe, it, expect, ... } from 'vitest'`
(tsconfig.app.json type-checks specs with `types: []`).
- **e2e:** existing `error-handling.spec.ts` stays green (agent path). No new
thread-CRUD-abort e2e (out of scope).

## Gates (lesson applied)

- `nx run-many -t lint test --projects=langgraph`
- **`nx build examples-chat-angular`** AND `nx test examples-chat-angular`
(the app build type-checks specs — `nx test` alone does not catch a spec that
breaks the app build, which regressed main at #677).
- `npm run generate-api-docs` (the new `LANGGRAPH_CLIENT_OPTIONS` token is public
API).
- Build one example before claiming green.

## Public API delta

- New export: `LANGGRAPH_CLIENT_OPTIONS` (InjectionToken).
- Possibly `resolveClientOptions` stays internal (not exported) unless a
consumer needs it — default keep internal.
- `LangGraphClientOptions`, `provideAgent({ clientOptions })` unchanged (#677).
9 changes: 8 additions & 1 deletion examples/chat/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser';
import { LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph';
import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph';
import { provideChat } from '@threadplane/chat';
import { e2eClientOptions } from './shell/e2e-overrides';
import { routes } from './app.routes';
import { environment } from '../environments/environment';

Expand All @@ -18,6 +19,12 @@ export const appConfig: ApplicationConfig = {
provide: LANGGRAPH_THREADS_CONFIG,
useValue: { apiUrl: environment.langGraphApiUrl },
},
// Single source of truth for the SDK client retry budget — both the agent
// transport and the threads adapter read this. Production: e2eClientOptions()
// returns undefined → SDK default. Under e2e: the THREADPLANE_E2E_MAX_RETRIES
// localStorage flag → fail fast. useFactory runs at injection time (post-
// bootstrap), so the flag is readable.
{ provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: () => e2eClientOptions() },
// Optional license token, populated from environment.license. When
// unset (the default in main), @threadplane/chat runs in advisory mode and
// logs a console.warn once. A smoke-test session can drop a real
Expand Down
13 changes: 2 additions & 11 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ 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';

Expand Down Expand Up @@ -86,17 +85,9 @@ 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.
// 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(() => ({
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
Expand All @@ -109,7 +100,7 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } {
// resulting tools:<id>-namespaced stream events.
subagentToolNames: ['research'],
telemetry: (event) => telemetrySink?.(event),
})),
}),
{ provide: DEMO_AGENT, useFactory: () => inject(DemoShell).agent },
],
})
Expand Down
114 changes: 113 additions & 1 deletion libs/langgraph/src/lib/agent.fn.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import type { AIMessage as CoreAIMessage } from '@langchain/core/messages';
import { agent } from './agent.fn';
import { MockAgentTransport } from './transport/mock-stream.transport';
import type { StreamEvent } from './agent.types';
import type { ThreadState } from '@langchain/langgraph-sdk';
import { createLangGraphClient } from './client/create-langgraph-client';
import { LANGGRAPH_CLIENT_OPTIONS } from './client/client-options';
import { AGENT_CONFIG } from './agent.provider';

vi.mock('./client/create-langgraph-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('./client/create-langgraph-client')>();
return { ...actual, createLangGraphClient: vi.fn(actual.createLangGraphClient) };
});

function withInjectionContext<T>(fn: () => T): T {
let result!: T;
Expand Down Expand Up @@ -896,6 +904,110 @@ describe('agent', () => {
});
});

describe('agent — LANGGRAPH_CLIENT_OPTIONS resolution (no mock transport)', () => {
// Spy on createLangGraphClient so we can assert which clientOptions reach
// the FetchStreamTransport constructor, without making real network calls.
// The mock returns a minimal stub object that satisfies all downstream
// accesses (threads.create, runs.stream, etc. are never called in these
// construction-only tests).
const mockClientStub = {
threads: {
create: vi.fn().mockResolvedValue({ thread_id: 'stub-thread' }),
getState: vi.fn().mockResolvedValue({ values: {}, next: [] }),
patchState: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockResolvedValue([]),
},
runs: {
stream: vi.fn().mockReturnValue((async function* () {})()),
},
};

const createLangGraphClientMock = createLangGraphClient as ReturnType<typeof vi.fn>;

beforeEach(() => {
TestBed.resetTestingModule();
createLangGraphClientMock.mockClear();
createLangGraphClientMock.mockReturnValue(mockClientStub);
});

it('case 1 — token only: passes token clientOptions to createLangGraphClient', () => {
TestBed.configureTestingModule({
providers: [
{ provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } },
],
});

TestBed.runInInjectionContext(() => {
agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' });
});

expect(createLangGraphClientMock).toHaveBeenCalledWith(
'http://localhost:2024',
{ maxRetries: 0 },
);
});

it('case 2 — call-site wins: call-site clientOptions override the token', () => {
TestBed.configureTestingModule({
providers: [
{ provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } },
],
});

TestBed.runInInjectionContext(() => {
agent({
apiUrl: 'http://localhost:2024',
assistantId: 'a',
clientOptions: { maxRetries: 7 },
});
});

expect(createLangGraphClientMock).toHaveBeenCalledWith(
'http://localhost:2024',
{ maxRetries: 7 },
);
});

it('case 3 — none provided: passes undefined clientOptions to createLangGraphClient', () => {
TestBed.configureTestingModule({});

TestBed.runInInjectionContext(() => {
agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' });
});

expect(createLangGraphClientMock).toHaveBeenCalledWith(
'http://localhost:2024',
undefined,
);
});

it('case 4 — AGENT_CONFIG middle layer: provideAgent clientOptions override the token', () => {
TestBed.configureTestingModule({
providers: [
{ provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } },
{
provide: AGENT_CONFIG,
useValue: {
assistantId: 'a',
apiUrl: 'http://localhost:2024',
clientOptions: { maxRetries: 3 },
},
},
],
});

TestBed.runInInjectionContext(() => {
// No call-site clientOptions — AGENT_CONFIG.clientOptions is the winner.
agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' });
});

expect(createLangGraphClientMock).toHaveBeenCalledWith(
'http://localhost:2024',
{ maxRetries: 3 },
);
});
});

import { computeMessageCheckpoints } from './agent.fn';

describe('computeMessageCheckpoints', () => {
Expand Down
11 changes: 10 additions & 1 deletion libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
import type { ThreadState, ToolProgress } from '@langchain/langgraph-sdk';
import type { MessageMetadata } from '@langchain/langgraph-sdk/ui';
import { createStreamManagerBridge } from './internals/stream-manager.bridge';
import { LANGGRAPH_CLIENT_OPTIONS, resolveClientOptions } from './client/client-options';
import { buildBranchTree } from './internals/branch-tree';
import { extractCitations } from './internals/extract-citations';
import { createClientToolsCapability, mergeClientTools } from './client-tools';
Expand Down Expand Up @@ -145,12 +146,20 @@ export function agent<
// Injection context required
const destroyRef = inject(DestroyRef);
const globalConfig = inject(AGENT_CONFIG, { optional: true });
const sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true });
const destroy$ = new Subject<void>();
destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); });

// Merge: call-site options take precedence over global provider config
const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? '';
const transport = options.transport ?? globalConfig?.transport;
// clientOptions precedence: agent({...}) call-site → provideAgent config →
// app-wide LANGGRAPH_CLIENT_OPTIONS token → SDK default.
const clientOptions = resolveClientOptions(
options.clientOptions,
globalConfig?.clientOptions,
sharedClientOptions,
);

const init = (options.initialValues ?? {}) as T;

Expand Down Expand Up @@ -291,7 +300,7 @@ export function agent<
});

const manager = createStreamManagerBridge({
options: { ...options, apiUrl, transport },
options: { ...options, apiUrl, transport, clientOptions },
subjects,
threadId$,
destroy$: destroy$.asObservable(),
Expand Down
Loading
Loading