Skip to content

feat(tsagentspec): add LangGraph adapter for the TypeScript SDK - #41

Open
spichen wants to merge 14 commits into
mainfrom
claude/langgraph-typescript-adapter-c5de52
Open

spichen wants to merge 14 commits into
mainfrom
claude/langgraph-typescript-adapter-c5de52

Conversation

@spichen

@spichen spichen commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Port of the pyagentspec LangGraph adapter to LangGraph JS, exposed as the agentspec/adapters/langgraph subpath export, plus the SDK surfaces it depends on. All LangChain packages are optional peer dependencies; the core SDK entry is unchanged.

Loader (Agent Spec → LangGraph JS)

  • Agent → langchain createAgent (converted model/tools, structured outputs via tool strategy, middleware pass-through, checkpointer, declared-inputs state extension)
  • Swarm@langchain/langgraph-swarm with per-relationship handoff tools
  • ManagerWorkers → hand-built hierarchical StateGraph (__manager__ node, __delegate_to__ tools, Send routing, workers looping back)
  • Flow → StateGraph over the shared flow-state contract, all 12 node types
  • Tools: ServerTool (registry), ClientTool (interrupt), RemoteTool, MCPTool/MCPToolBox via @langchain/mcp-adapters, requiresConfirmation interrupts
  • LLM configs: OpenAI / OpenAI-compatible / vLLM / bare LlmConfig → ChatOpenAI, Ollama → ChatOllama

State keys, node names, tool names, interrupt payloads, and error texts match the Python adapter for cross-SDK compatibility.

Exporter (LangGraph JS → Agent Spec)

Structured tools → ServerTool, chat models → LLM configs (including retry/timeout → RetryPolicy), createAgent agents → Agent via their public options, generic StateGraphs → Flow with conditional-edge expansion, and disaggregated export with [component, customId] pairs.

SDK additions

The TypeScript SDK was missing spec surfaces the Python SDK has, which meant specs carrying them loaded with the capability silently stripped. Added with pyagentspec-matching wire names and version gates:

  • RetryPolicy component, on RemoteTool, ApiNode, and the LLM configs — with the full Python retry engine wired into the adapter: bounded exponential backoff, all four jitter modes, Retry-After parsing with the 30s cap, recoverable-status matching, per-request timeouts, and no retry on TLS failures
  • urlAllowList on RemoteTool and ApiNode, now enforced on every rendered (post-template) URL — previously the validators existed but were always passed undefined, so a spec that constrained egress in Python performed unrestricted egress in TypeScript
  • Bare LlmConfig with api_provider dispatch
  • OAuth auth configs on remote MCP transports, with secrets registered as sensitive fields
  • src/tracing/ — async-only port of pyagentspec.tracing: span and event classes, the SpanProcessor interface, and AsyncLocalStorage-based trace context. The adapter emits Python-parity spans for agent/flow/manager-workers runs, LLM generations (including streamed chunks), tool executions, and flow nodes, so span processors work across SDKs

Also fixes MCP session_parameters serializing under a camelCase key, which silently dropped a non-default read timeout when a spec crossed SDKs.

Documented divergences

Async-only API; OciGenAiConfig and mTLS MCP transports unsupported (no JS packages); MCP auth is representation-only (as in Python); several small tracing payload differences where Python's own sync and async paths disagree. Full list in the README.

Quality

  • Suite at 1,251 tests across 81 files, offline-only (mocked fetch / MCP / fake tool-calling models); lint and build clean
  • Adversarially reviewed against the Python reference in several passes; confirmed findings fixed, including redirect-following (SSRF), missing request timeouts, tool-input defaults, an empty-apiKey env-substitution leak, and a trace-context corruption bug under concurrent runs
  • A structural pass unified duplicated HTTP request assembly, split node execution into per-node modules, canonicalized JSON-schema comparison into src/property.ts, and derived component-policy groups from the SDK unions so new union members cannot silently escape block-lists
  • The ajv npm override is scoped to ajv@^6 so @modelcontextprotocol/sdk gets the ajv v8 it requires

Usage

Quickstart, the supported-components table, and a SpanProcessor snippet are in the README's LangGraph adapter section; examples/09-langgraph-adapter.ts is a fully offline runnable example.

Port of the pyagentspec LangGraph adapter to LangGraph JS, exposed as the
agentspec/adapters/langgraph subpath export.

Loader (AgentSpec -> LangGraph JS): Agent via langchain createAgent (tools,
structured outputs, middleware, checkpointer), Swarm via
@langchain/langgraph-swarm, ManagerWorkers as a hand-built hierarchical
graph (__manager__ node, __delegate_to__ tools, Send routing), and Flow as
a StateGraph with all 12 node executors. Server/client/remote tools with
requires_confirmation interrupts, MCP tools/toolboxes via
@langchain/mcp-adapters, OpenAI/OpenAI-compatible/vLLM/Ollama LLM configs.
State keys, node names, interrupt payloads and error texts match the
Python adapter for cross-SDK compatibility.

Exporter (LangGraph JS -> AgentSpec): structured tools, chat models,
createAgent ReactAgents (via their public options), and generic
StateGraphs as Flows with conditional-edge expansion.

Shared adapters/common layer: loader/exporter bases, component load policy
(StdioTransport blocked by default), templating, URL allow-list
validation, and the fetch-based remote tool executor (no redirects, 5s
timeout, matching httpx defaults).

Also: serializer support for [component, customId] disaggregation pairs
(mirroring Python), and the ajv npm override scoped to ajv@^6 so
@modelcontextprotocol/sdk gets the ajv v8 it needs.

All LangChain packages are optional peer dependencies; the core SDK entry
is unchanged. Documented divergences from the Python adapter (async API,
no RetryPolicy/urlAllowList fields yet, OciGenAiConfig and mTLS MCP
transports unsupported, tracing seams only) are listed in the README.

310 new tests (1047 total).
… node execution

One buildTemplatedHttpRequest in adapters/common now serves both the
RemoteTool executor and the ApiNode executor, ending a ~100-line
duplication that had already drifted: the ApiNode copy read the
Content-Type header with ?? where Python (and the RemoteTool copy) use
falsy coalescing — the || behavior is now shared, aligning the
empty-string-header edge with the Python adapter.

node-execution.ts becomes a re-export barrel over focused modules
(python-parity value coercion, executor base, and per-node executors),
none above 1000 lines. LlmNodeExecutor tracks structured generation by
the presence of the structured model alone, and MapNodeExecutor drops
an unused constructor parameter and gives the non-sized-iterable error
its own accurate message.
…licy groups, prune tracing seam

jsonSchemasHaveSameType now lives only in src/property.ts — the
adapter's private re-implementation is deleted, and the canonical copy
gains the Python adapter's 100-entry union-length guard it was missing.
Component-policy group membership is derived from the SDK's runtime
component unions (with drift-pinning tests where derivation is not
clean), so a new union member can no longer silently escape a
group-level block-list. The tracing module drops two never-called
callback builders and passes the spec component through the
patchWithExecutionSpan identity seam, making the future tracing port's
attachment sites honest. Loader/exporter bases pass typed closures
instead of re-deriving a string mode tag.
…pe graph vocabulary

ManagerWorkersNodeExecutor goes back to Python's template-method shape,
overriding two protected hooks instead of re-implementing the parent
executor around four shadow fields. ReactAgentInfo is replaced by
createReactAgent(agent, context, overrides), convertNode is typed so
the three *Like identity interfaces and their casts disappear, and
subflow validation gets one assertInvocableGraph home. The adapter's
core vocabulary is consolidated: adapters/common/guards.ts (strict and
loose record guards under separate names), InvocableGraph and
DynamicStateGraph in types.ts, and graph-introspection.ts as the single
owner of everything probed from LangGraph internals, shared by both
converter directions. ToolRegistry states its real two-member contract,
Flow StateGraph compilation moves to langgraph-converter-flow.ts, and
the minor-bundle cleanups (duplicate Agent guard, identical LLM config
cases, client-tool confirmation reuse, one optional-peer-import helper)
land alongside.
…ared helpers

flow-nodes.test.ts (1,434 lines) becomes per-node suites mirroring the
Python test layout, the exporter's state-graph-flow coverage moves to
exporter-flow.test.ts, and the flow/message helpers that had been
copied verbatim between suites now live in test-helpers.ts. No
assertions added or removed.
Resolves the residual gaps found while auditing the quality-review
remediation against the pre-refactor behavior baseline.
…and MCP auth to the SDK

Ports the Python SDK surfaces the TypeScript SDK was missing: the
RetryPolicy component (attached to RemoteTool, ApiNode, and the LLM
configs that carry it in Python, with the same version-gated
serialization), urlAllowList on RemoteTool and ApiNode, the concrete
bare LlmConfig component with api_provider dispatch, and the OAuth
auth configuration components on remote MCP transports with their
secrets registered as sensitive fields. Wire-format field names and
version gates match pyagentspec, so specs carrying these fields now
round-trip through the TypeScript SDK instead of being silently
stripped.
Async-only port of pyagentspec.tracing: execution/LLM/tool/node span
classes and their start/end events, the message model traces carry,
the SpanProcessor interface with registration, and
AsyncLocalStorage-based trace context that keeps parallel async
branches isolated. Class and field names match the Python package so
span processors written against either SDK see the same shapes.
Python's sync/async bridging has no JS equivalent and is not ported.
… bare LlmConfig into the LangGraph adapter

RemoteTool and ApiNode requests now honor the spec's RetryPolicy with
the full Python engine — per-tool timeouts, bounded exponential
backoff with all four jitter modes, Retry-After parsing with the 30s
cap, recoverable-status matching, and the no-retry rule for TLS
failures — and enforce urlAllowList on every rendered URL, suppressing
the unrestricted-templated-URL warning when a list is configured.
LLM configs map retryPolicy to ChatOpenAI retries/timeout in both
converter directions, the bare LlmConfig component dispatches by
api_provider, and transport auth configuration survives load→export
untouched (runtime OAuth is unwired, matching Python).
… adapter

The identity tracing seams become real emission: execution spans wrap
agent, flow, and manager-workers runs (streaming preserved), every
converted chat model carries the LLM callback handler emitting
generation spans with request, streamed-chunk, and response events,
server/remote/MCP tools emit tool-execution spans (client tools
excluded, as in Python), and flow nodes are wrapped in node-execution
spans with exception events from CatchException. Span and event
payloads match the Python adapter so processors work across SDKs.
…e key

SessionParameters was not registered as a model-object field, so a
transport's read timeout serialized as session_parameters.readTimeoutSeconds
where pyagentspec writes and expects read_timeout_seconds. A non-default
timeout was therefore dropped whenever a spec crossed SDKs, silently
falling back to the 60s default on the Python side. Registering the field
on both the serialization and deserialization plugins fixes the emitted key
and lets Python-authored blocks parse.
…ts in tool errors

A hostile spec could turn one tool call into a high-rate request flood or
a silent event-loop spin. max_attempts is unbounded and the retry delays
can be configured to zero, so the elapsed-time cap alone permitted roughly
300,000 requests from a single call (measured at ~513 req/s against a real
server); an invalid negative Retry-After was honored as a zero-second wait,
letting a hostile server erase the operator's own backoff; and a non-finite
request_timeout made AbortSignal.timeout throw a RangeError that the engine
then retried as if it were a transient transport failure, spinning for the
full 600s window without emitting a single request.

One call now makes at most MAX_HTTP_ATTEMPTS_PER_CALL attempts separated by
at least MIN_RETRY_DELAY_SECONDS (the same attack now measures 100 requests
at 17 req/s), an invalid Retry-After is treated as absent per RFC 9110 so
the configured backoff applies, request timeouts are clamped to a delay a
timer can express, and RangeError is classified as a permanent local failure
rather than a transient one. TLS handshake failures join certificate
failures as non-retryable. Errors raised for a non-2xx response no longer
echo the query string, which routinely carries credentials into model
context and logs.

Also sets an explicit vitest testTimeout: the suite has always run on the
5s default, which is too tight for the tests that dynamically import the
LangChain peer packages, so whichever suite pulled a package in first would
time out on a loaded machine and the failure rotated between files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant